Ulepszony blog z transmisją na żywo – podział kodu

W najnowszej transmisji na żywo z funkcją Supercharged wdrożyliśmy podział kodu i fragmentowanie na podstawie trasy. Dzięki modułom HTTP/2 i natywnym modułom ES6 te techniki staną się niezbędne do wydajnego wczytywania i buforowania zasobów skryptu.

Różne porady i wskazówki w tym odcinku

  • asyncFunction().catch() z error.stack: 9:55
  • Moduły i atrybut nomodule w tagach <script>: 7:30
  • promisify() w węźle 8: 17:20

TL;DR

Jak dokonać podziału kodu za pomocą fragmentowania na podstawie trasy:

  1. Uzyskaj listę punktów wejścia.
  2. Wyodrębnij zależności modułów ze wszystkich tych punktów wejścia.
  3. Znajdź wspólne zależności między wszystkimi punktami wejścia.
  4. Połącz współdzielone zależności.
  5. Zmodyfikuj punkty wejścia.

Podział kodu a podział na fragmenty na podstawie trasy

Dzielenie kodu i fragmentowanie na podstawie trasy są ściśle powiązane i często są stosowane wymiennie. Wprowadziło to pewne zamieszanie. Spróbujmy to wyjaśnić:

  • Dzielenie kodu: proces dzielenia kodu na kilka pakietów. Jeśli nie wysyłasz do klienta jednego dużego pakietu z całym kodem JavaScriptu, zajmujesz się dzieleniem kodu. Jednym ze sposobów dzielenia kodu jest użycie fragmentacji na podstawie trasy.
  • Fragmentowanie na podstawie trasy: podział na segmenty na podstawie trasy tworzy pakiety powiązane z trasami aplikacji. Analizując Twoje trasy i ich zależności, możemy wskazać, które moduły należą do poszczególnych pakietów.

Dlaczego dzieli się kod?

Luźne moduły

Dzięki natywnym modułom ES6 każdy moduł JavaScript może importować własne zależności. Gdy przeglądarka otrzyma moduł, wszystkie instrukcje import aktywują dodatkowe pobieranie, aby pobrać moduły, które są niezbędne do uruchomienia kodu. Wszystkie te moduły mogą jednak zależeć od siebie. Istnieje ryzyko, że przeglądarka zakończy się kaskadą pobrań, która trwa kilka razy w obie strony, zanim kod zostanie w końcu wykonany.

Grupowanie

Dzięki temu, że wszystkie moduły znajdują się w jednym pakiecie, przeglądarka ma wszystkie niezbędne kody po jednej sesji w obie strony i szybciej je uruchamia. Zmusza to jednak użytkownika do pobrania dużej ilości kodu, który nie jest potrzebny, więc marnuje przepustowość i czas. Poza tym każda zmiana w jednym z oryginalnych modułów spowoduje zmianę pakietu, co spowoduje unieważnienie jego wersji zapisanej w pamięci podręcznej. Użytkownicy muszą jeszcze raz pobrać całą aplikację.

Podział kodu

Dzielenie kodu to środkowy składnik. Jesteśmy gotowi zainwestować dodatkowe loty w obie strony, aby zwiększyć wydajność sieci, pobierając tylko to, co jest potrzebne, i zwiększyć wydajność buforowania, ponieważ znacznie zmniejszymy liczbę modułów na pakiet. Jeśli grupowanie zostanie wykonane poprawnie, łączna liczba podróży w obie strony będzie znacznie mniejsza niż w przypadku modułów luźnych. Na koniec moglibyśmy użyć mechanizmów wstępnego ładowania, takich jak link[rel=preload], aby w razie potrzeby zaoszczędzić więcej razy trio.

Krok 1. Uzyskaj listę punktów wejścia

To tylko jedno z wielu sposobów, ale w tym odcinku przeanalizowaliśmy sitemap.xml, aby uzyskać punkty wejścia do witryny. Zwykle używany jest specjalny plik JSON zawierający wszystkie punkty wejścia.

Przetwarzanie JavaScript za pomocą Babel

Babel często używa się do „transpilacji” – przetwarzania najnowszego kodu JavaScript i przekształcania go w starszą wersję tego języka, tak by więcej przeglądarek mogło wykonać kod. Pierwszym krokiem jest przeanalizowanie nowego kodu JavaScript za pomocą parsera (Babel używa babylon), który przekształca kod w tak zwane drzewo składni abstrakcyjnej (AST). Po wygenerowaniu AST szereg wtyczek analizuje i modyfikuje usługę AST.

Będziemy intensywnie korzystać z języka babel do wykrywania (i później zmanipulowanej) operacji importowania modułu JavaScript. Wyrażenia regularne mogą być kuszące, ale nie są one na tyle skuteczne, by poprawnie przeanalizować język i trudno się nimi zarządzać. Korzystanie z sprawdzonych narzędzi, takich jak Babel, pozwala uniknąć wielu bólów głowy.

Oto prosty przykład uruchamiania Babel z niestandardową wtyczką:

const plugin = {
  visitor: {
    ImportDeclaration(decl) {
      /* ... */
    }
  }
}
const {code} = babel.transform(inputCode, {plugins: [plugin]});

Wtyczka może dostarczać obiekt visitor. Użytkownik zawiera funkcję dla każdego typu węzła, który ma obsługiwać wtyczka. Jeśli podczas przemierzania interfejsu AST znajdzie się węzeł tego typu, odpowiednia funkcja w obiekcie visitor zostanie wywołana z tym węzłem jako parametrem. W powyższym przykładzie metoda ImportDeclaration() będzie wywoływana dla każdej deklaracji import w pliku. Aby dowiedzieć się więcej o typach węzłów i AST, zajrzyj na stronę astexplorer.net.

Krok 2. Wyodrębnij zależności modułu

Aby utworzyć drzewo zależności modułu, przeanalizujemy ten moduł i utworzymy listę wszystkich importowanych modułów. Musimy też przeanalizować te zależności, ponieważ one również mogą mieć zależności. Klasyczny przypadek rekurencji!

async function buildDependencyTree(file) {
  let code = await readFile(file);
  code = code.toString('utf-8');

  // `dep` will collect all dependencies of `file`
  let dep = [];
  const plugin = {
    visitor: {
      ImportDeclaration(decl) {
        const importedFile = decl.node.source.value;
        // Recursion: Push an array of the dependency’s dependencies onto the list
        dep.push((async function() {
          return await buildDependencyTree(`./app/${importedFile}`);
        })());
        // Push the dependency itself onto the list
        dep.push(importedFile);
      }
    }
  }
  // Run the plugin
  babel.transform(code, {plugins: [plugin]});
  // Wait for all promises to resolve and then flatten the array
  return flatten(await Promise.all(dep));
}

Krok 3. Znajdź wspólne zależności między wszystkimi punktami wejścia

Ponieważ mamy zestaw drzew zależności (w razie potrzeby las zależności), możemy znaleźć wspólne zależności, wyszukując węzły występujące w każdym drzewie. Wyrównujemy i usuwamy duplikaty lasów i filtrujemy je, aby zachować tylko elementy, które pojawiają się na wszystkich drzewach.

function findCommonDeps(depTrees) {
  const depSet = new Set();
  // Flatten
  depTrees.forEach(depTree => {
    depTree.forEach(dep => depSet.add(dep));
  });
  // Filter
  return Array.from(depSet)
    .filter(dep => depTrees.every(depTree => depTree.includes(dep)));
}

Krok 4. Połącz udostępnione zależności

Aby połączyć zestaw wspólnych zależności, wystarczy połączyć wszystkie pliki modułów. W trakcie stosowania takiego podejścia mogą wystąpić 2 problemy. Pierwszym z nich jest to, że pakiet nadal będzie zawierał instrukcje import, co spowoduje, że przeglądarka spróbuje pobrać zasoby. Drugi problem polega na tym, że zależności zależności nie są grupowane. Ponieważ robiliśmy to już wcześniej, wkrótce utworzymy kolejną wtyczkę do Babel.

Kod jest podobny do kodu naszej pierwszej wtyczki, ale zamiast wyodrębniania importowanych plików usuniemy je i wstawimy w pakietach wersję zaimportowanego pliku:

async function bundle(oldCode) {
  // `newCode` will be filled with code fragments that eventually form the bundle.
  let newCode = [];
  const plugin = {
    visitor: {
      ImportDeclaration(decl) {
        const importedFile = decl.node.source.value;
        newCode.push((async function() {
          // Bundle the imported file and add it to the output.
          return await bundle(await readFile(`./app/${importedFile}`));
        })());
        // Remove the import declaration from the AST.
        decl.remove();
      }
    }
  };
  // Save the stringified, transformed AST. This code is the same as `oldCode`
  // but without any import statements.
  const {code} = babel.transform(oldCode, {plugins: [plugin]});
  newCode.push(code);
  // `newCode` contains all the bundled dependencies as well as the
  // import-less version of the code itself. Concatenate to generate the code
  // for the bundle.
  return flatten(await Promise.all(newCode)).join('\n');
}

Krok 5. Przepisz punkty wejścia

Na koniec napiszemy kolejną wtyczkę Babel. Jego zadaniem jest usunięcie wszystkich importowanych modułów, które znajdują się w udostępnionym pakiecie.

async function rewrite(section, sharedBundle) {
  let oldCode = await readFile(`./app/static/${section}.js`);
  oldCode = oldCode.toString('utf-8');
  const plugin = {
    visitor: {
      ImportDeclaration(decl) {
        const importedFile = decl.node.source.value;
        // If this import statement imports a file that is in the shared bundle, remove it.
        if(sharedBundle.includes(importedFile))
          decl.remove();
      }
    }
  };
  let {code} = babel.transform(oldCode, {plugins: [plugin]});
  // Prepend an import statement for the shared bundle.
  code = `import '/static/_shared.js';\n${code}`;
  await writeFile(`./app/static/_${section}.js`, code);
}

Koniec

To była świetna jazda, prawda? Pamiętaj, że celem tego odcinka było wyjaśnienie i objaśnienie podziału kodu. Rezultat działa, ale dotyczy on naszej witryny demonstracyjnej i w ogóle się nie sprawdzi. W środowisku produkcyjnym zalecam korzystanie z uznanych narzędzi, takich jak WebPack, RollUp itp.

Nasz kod znajdziesz w repozytorium GitHub.

Do zobaczenia!