실시간 스트림 블로그 - 코드 분할

최근 Supercharged 라이브 스트림에서는 코드 분할 및 경로 기반 청크를 구현했습니다. HTTP/2 및 네이티브 ES6 모듈을 사용하면 이러한 기법이 스크립트 리소스를 효율적으로 로드하고 캐시하는 데 필수적입니다.

이 에피소드의 기타 도움말 및 유용한 정보

  • asyncFunction().catch()error.stack: 9:55
  • <script> 태그의 모듈 및 nomodule 속성: 7:30
  • 노드 8의 promisify(): 17:20

요약

경로 기반 청크를 통해 코드 분할을 실행하는 방법은 다음과 같습니다.

  1. 진입점 목록을 가져옵니다.
  2. 이러한 모든 진입점의 모듈 종속 항목을 추출합니다.
  3. 모든 진입점 간에 공유 종속 항목을 찾습니다.
  4. 공유 종속 항목을 번들로 묶습니다.
  5. 진입점을 다시 작성합니다.

코드 분할과 경로 기반 청킹 비교

코드 분할과 경로 기반 청크는 밀접하게 관련되어 있으며 서로 바꿔서 사용되는 경우가 많습니다. 이로 인해 혼란이 야기되었습니다. 문제를 해결해 보겠습니다.

  • 코드 분할: 코드 분할은 코드를 여러 번들로 분할하는 프로세스입니다. 모든 JavaScript가 포함된 하나의 큰 번들을 클라이언트로 전송하지 않는 경우 코드 분할을 실행하는 것입니다. 코드를 분할하는 한 가지 방법은 경로 기반 청크를 사용하는 것입니다.
  • 경로 기반 청크 처리: 경로 기반 청크 처리는 앱의 경로와 관련된 번들을 만듭니다. 경로와 그 종속 항목을 분석하여 어떤 모듈이 어떤 번들에 들어갈지 변경할 수 있습니다.

코드 분할을 하는 이유는 무엇인가요?

느슨한 모듈

기본 ES6 모듈을 사용하면 모든 JavaScript 모듈이 자체 종속 항목을 가져올 수 있습니다. 브라우저가 모듈을 수신하면 모든 import 문이 추가 가져오기를 트리거하여 코드 실행에 필요한 모듈을 가져옵니다. 그러나 이러한 모든 모듈에는 자체 종속 항목이 있을 수 있습니다. 위험한 점은 코드가 최종적으로 실행되기 전에 브라우저가 여러 왕복을 반복하는 연쇄 가져오기를 실행하게 된다는 것입니다.

번들로 묶기

모든 모듈을 하나의 번들로 인라인 처리하는 번들링을 사용하면 브라우저가 왕복 1회 후에 필요한 모든 코드를 보유하게 되므로 코드 실행을 더 빠르게 시작할 수 있습니다. 그러나 이로 인해 사용자가 필요하지 않은 많은 코드를 다운로드해야 하므로 대역폭과 시간이 낭비됩니다. 또한 원래 모듈 중 하나를 변경할 때마다 번들이 변경되어 캐시된 버전의 번들이 무효화됩니다. 사용자는 전체를 다시 다운로드해야 합니다.

코드 분할

코드 분할은 중간 지점입니다. 필요한 것만 다운로드하여 네트워크 효율성을 높이고 번들당 모듈 수를 훨씬 줄여 캐시 효율성을 높이기 위해 추가 왕복에 투자할 의향이 있습니다. 번들이 제대로 구성되면 총 왕복 횟수가 느슨한 모듈보다 훨씬 적습니다. 마지막으로 link[rel=preload]와 같은 미리 로드 메커니즘을 사용하여 필요한 경우 추가 라운드 3중 시간을 절약할 수 있습니다.

1단계: 진입점 목록 가져오기

이는 여러 접근 방식 중 하나에 불과하지만 이 에피소드에서는 웹사이트의 sitemap.xml를 파싱하여 웹사이트의 진입점을 가져왔습니다. 일반적으로 모든 진입점을 나열하는 전용 JSON 파일이 사용됩니다.

babel을 사용하여 JavaScript 처리

Babel은 일반적으로 '전환'에 사용됩니다. 최신 JavaScript 코드를 사용하고 더 많은 브라우저에서 코드를 실행할 수 있도록 이전 버전의 JavaScript로 변환합니다. 여기서 첫 번째 단계는 코드를 소위 '추상 문법 트리'(AST)로 변환하는 파서(Babel은 babylon을 사용함)를 사용하여 새 JavaScript를 파싱하는 것입니다. AST가 생성되면 일련의 플러그인이 AST를 분석하고 손상시킵니다.

babel을 많이 사용하여 JavaScript 모듈의 가져오기를 감지하고 나중에 조작합니다. 정규 표현식을 사용하고 싶을 수 있지만 정규 표현식은 언어를 올바르게 파싱할 수 있을 만큼 강력하지 않으며 유지 관리하기 어렵습니다. Babel과 같이 검증된 도구를 사용하면 많은 골칫거리를 피할 수 있습니다.

다음은 맞춤 플러그인으로 Babel을 실행하는 간단한 예입니다.

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

플러그인은 visitor 객체를 제공할 수 있습니다. 방문자는 플러그인이 처리하려는 모든 노드 유형의 함수를 포함합니다. AST를 탐색하는 중에 이러한 유형의 노드가 발견되면 visitor 객체의 해당 함수가 해당 노드를 매개변수로 사용하여 호출됩니다. 위 예에서는 파일의 모든 import 선언에 대해 ImportDeclaration() 메서드가 호출됩니다. 노드 유형과 AST에 대해 자세히 알아보려면 astexplorer.net을 살펴보세요.

2단계: 모듈 종속 항목 추출

모듈의 종속 항목 트리를 빌드하려면 해당 모듈을 파싱하고 가져오는 모든 모듈의 목록을 만듭니다. 이러한 종속 항목에도 종속 항목이 있을 수 있으므로 이러한 종속 항목도 파싱해야 합니다. 재귀의 전형적인 사례입니다.

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));
}

3단계: 모든 진입점 간에 공유 종속 항목 찾기

종속 항목 트리 세트(원하는 경우 종속 항목 포레스트)가 있으므로 모든 트리에 표시되는 노드를 찾아 공유된 종속 항목을 찾을 수 있습니다. 포레스트의 중복을 제거하고 모든 트리에 표시되는 요소만 유지하도록 필터링합니다.

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)));
}

4단계: 공유 종속 항목 번들로 묶기

공유 종속 항목 집합을 번들로 묶으려면 모든 모듈 파일을 연결하기만 하면 됩니다. 이 방법을 사용하면 두 가지 문제가 발생합니다. 첫 번째 문제는 번들에 import 문이 계속 포함되어 브라우저에서 리소스 가져오기를 시도하게 된다는 점입니다. 두 번째 문제는 종속 항목의 종속 항목이 번들로 묶이지 않았다는 점입니다. 이전에 해 보았으므로 다른 babel 플러그인을 작성해 보겠습니다.

이 코드는 첫 번째 플러그인과 매우 유사하지만 가져오기를 추출하는 대신 가져오기를 삭제하고 가져온 파일의 번들 버전을 삽입합니다.

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');
}

5단계: 진입점 재작성

마지막 단계로 또 다른 Babel 플러그인을 작성합니다. 공유 번들에 있는 모듈의 모든 가져오기를 삭제합니다.

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);
}

종료

꽤 긴 여정이었죠? 이 에피소드의 목표는 코드 분할을 설명하고 이해하기 쉽게 설명하는 것이었습니다. 결과는 작동하지만 데모 사이트에만 해당하며 일반 사례에서는 제대로 작동하지 않습니다. 프로덕션의 경우 WebPack, RollUp과 같은 기존 도구를 사용하는 것이 좋습니다.

GitHub 저장소에서 코드를 확인할 수 있습니다.

그럼 다른 과정에서 뵙겠습니다.