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

가장 최근에 진행된 더욱 강력해진 라이브 스트림에서는 코드 분할과 경로 기반 청킹을 구현했습니다. 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은 흔히 '트랜스파일'에 사용됩니다. 최신 자바스크립트 코드를 사용하여 더 많은 브라우저에서 코드를 실행할 수 있도록 이전 버전의 자바스크립트로 변환하는 것입니다. 여기서 첫 번째 단계는 코드를 '추상 구문 트리'(AST)로 변환하는 파서(Babel이 babylon을 사용)를 사용하여 새 자바스크립트를 파싱하는 것입니다. 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 저장소에서 확인할 수 있습니다.

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