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

W naszej ostatniej transmisji na żywo Supercharged Livestream wprowadziliśmy podział kodu i dzielenie na fragmenty na podstawie trasy. W przypadku modułów HTTP/2 i natywności ES6 te techniki staną się niezbędne do efektywnego wczytywania i zapisywania do pamięci podręcznej 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 podzielić kod przez dzielenie na fragmenty:

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

Dzielenie kodu a dzielenie na podstawie trasy

Dzielenie kodu i dzielenie na fragmenty na podstawie trasy są ze sobą ściśle powiązane i często używane zamiennie. To spowodowało pewne zamieszanie. Spróbujmy to wyjaśnić:

  • Podział kodu: polega na podziale kodu na kilka pakietów. Jeśli nie wysyłasz do klienta jednego dużego pakietu z całym kodem JavaScript, oznacza to, że doszło do podziału kodu. Jednym ze sposobów podziału kodu jest dzielenie na części na podstawie przekierowań.
  • Dzielenie na części na podstawie tras: dzielenie na części na podstawie tras tworzy pakiety powiązane z trasami w aplikacji. Dzięki analizie Twoich tras i ich zależności możemy zmieniać, które moduły trafiają do którego pakietu.

Dlaczego następuje dzielenie kodu?

Moduł bezpłatny

Dzięki natywności modułów ES6 każdy moduł JavaScript może importować własne zależności. Gdy przeglądarka otrzyma moduł, wszystkie instrukcje import spowodują dodatkowe pobieranie, aby pobrać moduły potrzebne do wykonania kodu. Jednak wszystkie te moduły mogą mieć własne zależności. Niebezpieczeństwo polega na tym, że przeglądarka musi pobrać wiele danych, co trwa kilka rund, zanim kod zostanie w końcu wykonany.

Łączenie

Pakowanie, czyli umieszczanie wszystkich modułów w jednym pakiecie, zapewni przeglądarce dostęp do całego kodu, którego potrzebuje, po jednej wymianie danych, i umożliwi szybsze rozpoczęcie jego wykonywania. Wymusza to jednak na użytkowniku pobranie dużej ilości niepotrzebnego kodu, co powoduje marnowanie przepustowości i czasu. Ponadto każda zmiana w jednym z naszych oryginalnych modułów spowoduje zmianę w pakiecie, co spowoduje unieważnienie wszystkich wersji pakietu w pamięci podręcznej. Użytkownicy będą musieli pobrać całą aplikację ponownie.

Dzielenie kodu

Dzielenie kodu to złoty środek. Jesteśmy gotowi na dodatkowe wczytywanie, aby zwiększyć wydajność sieci, pobierając tylko to, czego potrzebujemy, oraz na lepsze korzystanie z bufora, zmniejszając liczbę modułów w pakiecie. Jeśli prawidłowo łączysz dane w pakiety, łączna liczba transferów w obie strony będzie znacznie mniejsza niż w przypadku luźnych modułów. Oprócz tego możemy korzystać z mechanizmów wstępnego wczytywania, takich jak link[rel=preload], aby w razie potrzeby skrócić czas trwania rundy.

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

To tylko jedno z wielu podejść, ale w tym odcinku przeanalizowaliśmy stronę internetową za pomocą sitemap.xml, aby znaleźć punkty wejścia do naszej witryny. Zwykle używa się specjalnego pliku JSON zawierającego wszystkie punkty wejścia.

Przetwarzanie JavaScriptu za pomocą Babel

Babel jest często używany do „transpilacji”: przetwarzania najnowszego kodu JavaScript i przekształcania go w starszą wersję JavaScript, aby więcej przeglądarek mogło go wykonać. Pierwszym krokiem jest przeanalizowanie nowego JavaScriptu za pomocą parsera (z użyciem babylon), który zamienia kod w tak zwaną „składnię abstrakcyjną” (AST). Po wygenerowaniu AST serię wtyczek analizuje i modyfikuje AST.

Będziemy intensywnie korzystać z Babel do wykrywania (i późniejszego manipulowania) importów modułu JavaScript. Możesz sięgnąć po wyrażenia regularne, ale nie są one wystarczająco wydajne do prawidłowego analizowania języka i trudno je utrzymać. Polecanie wypróbowanych i przetestowanych narzędzi, takich jak Babel, zaoszczędzi wiele problemów.

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

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

Wtyczka może udostępnić obiekt visitor. Funkcja ta zawiera funkcję dla dowolnego typu węzła, który ma obsługiwać wtyczka. Gdy podczas przemierzania AST zostanie napotkany węzeł tego typu, odpowiednia funkcja w obiekcie visitor zostanie wywołana z tym węzłem jako parametr. W powyższym przykładzie metoda ImportDeclaration() zostanie wywołana w przypadku każdego zadeklarowania import w pliku. Aby lepiej poznać typy węzłów i AST, odwiedź 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 przez niego modułów. Musimy też przeanalizować te zależności, ponieważ mogą one mieć też inne zależności. Klasyczny przykład 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 zbiór drzew zależności (czyli las zależności), możemy znaleźć współdzielone zależności, wyszukując węzły występujące w każdym drzewie. Sprowadzimy i odduplikujemy nasz las, a potem odfiltrujemy elementy, które występują we 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. Zgrupuj wspólne zależności

Aby połączyć zestaw wspólnych zależności, wystarczy połączyć wszystkie pliki modułów. Przy takim podejściu występują 2 problemy: pierwszy polega na tym, że pakiet nadal będzie zawierać instrukcje import, które spowodują, że przeglądarka będzie próbować pobrać zasoby. Drugim problemem jest to, że zależności zależności nie zostały pogrupowane. Ponieważ robiliśmy to już wcześniej, napiszemy jeszcze jeden wtyczkę Babel.

Kod jest dość podobny do naszego pierwszego wtyczki, ale zamiast tylko wyodrębniania importowanych danych, usuniemy je i wstawimy złączoną 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ściowe

Na koniec napiszemy kolejny wtyczkę Babel. Jego zadaniem jest usunięcie wszystkich importowanych modułów, które znajdują się w wspólnym 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 niezła przejażdżka, prawda? Pamiętaj, że celem tego odcinka było wyjaśnienie i zdemistyfikowanie podziału kodu. Wynik działa, ale jest specyficzny dla naszej witryny demonstracyjnej i nie będzie działać w ogólnym przypadku. W przypadku wersji produkcyjnej zalecam korzystanie z dobrze znanych narzędzi, takich jak WebPack czy RollUp.

Kod znajdziesz w repozytorium GitHub.

Do zobaczenia!