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 natywnego ES6 te techniki staną się niezbędne do efektywnego wczytywania i zapisywania do pamięci podręcznej zasobów skryptu.

Wskazówki i porady z tego odcinka

  • 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 za pomocą podziału na części na podstawie tras:

  1. Uzyskaj listę punktów wejścia.
  2. Wyodrębnij zależności modułów wszystkich tych punktów wejścia.
  3. znajdować wspólne zależności między wszystkimi punktami wejścia;
  4. Utwórz pakiet wspólnych zależności.
  5. Przepisz punkty wejścia.

Dzielenie kodu a dzielenie na fragmenty na podstawie tras

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 klientowi jednego dużego pakietu ze wszystkimi plikami JavaScript, oznacza to, że dzielisz kod. 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 tras i ich zależności możemy określić, które moduły mają się znaleźć w danym pakiecie.

Dlaczego dzielić kod?

Modułach luźnych

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. Wszystkie te moduły mogą jednak 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.

Pakiety

Pakowanie, czyli umieszczanie wszystkich modułów w jednym pakiecie, zapewni przeglądarce dostęp do całego kodu, którego potrzebuje, po 1 przesyłaniu i umożliwi szybsze rozpoczęcie jego wykonywania. Wymusza to jednak na użytkowniku pobranie dużej ilości kodu, który nie jest potrzebny, 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 ponownie pobrać całą aplikację.

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, i poprawić wydajność buforowania, znacznie zmniejszając liczbę modułów w każdym pakiecie. Jeśli zgrupowanie zostanie wykonane prawidłowo, łączna liczba rund będzie znacznie niższa 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”: zamienia najnowszy kod JavaScript w wersję starszą, aby więcej przeglądarek mogło go wykonać. Pierwszym krokiem jest przeanalizowanie nowego kodu JavaScript za pomocą parsowania (Babel używa Babylon), które zamienia kod w tzw. „abstrakcyjny drzewo kodu” (AST). Po wygenerowaniu AST serię wtyczek analizuje i modyfikuje AST.

Będziemy intensywnie korzystać z babel, aby wykrywać (a później manipulować) importy 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ć. Korzystanie z sprawdzonych narzędzi takich jak Babel pozwoli Ci zaoszczędzić sporo 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 każdego typu węzła, który ma obsługiwać wtyczka. Gdy podczas przeszukiwania AST napotkasz węzeł tego typu, wywołana zostanie odpowiadająca mu funkcja w obiekcie visitor, a jako parametr zostanie przekazany ten węzeł. 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

Mamy zestaw drzew zależności – las zależności – więc możemy znaleźć wspólne zależności, szukając węzłów występujących 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 wtyczek 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!