Skip to main content
overcache

Dive into Metro: Transformer

现代的前端工程, 开发环境和运行环境之间有很大的鸿沟:

  • 语法
    • 开发环境可以用各种新版本的 js 语法, 或者直接使用强类型的 ts 进行开发
    • 但是运行环境只支持一些老旧的 js 语法, ts 更是想都别想.
  • 文件系统
    • 开发环境可以用 import/require 使用各种本地文件
    • 运行环境只能用远端加载文件内容, 或者内嵌文件内容

把开发环境编写的各种源码变成运行环境可执行的脚本, 这个过程称之为转译. 大部分打包器的转译功能都是用 Babel 实现的, 虽然说目前也有一些 Babel 的竞品, 如 swc, 但是生态上远远没有 Babel 丰富.

Metro 未能免俗, 转译的功能也交由 Babel 完成, 它相关的封装称之为 Transformer. 本文将了解 Metro 是如何设计高效的转译系统的.

转译流程

Metro 的 Transformer 类(src/DeltaBundler/Transformer.js)拥有实例方法 transformFile . 该类在内部维护了一个基于 Jest-Worker 的 WorkerFarm, 当调用 transformFile 时, 经过 WorkerFarm.transform, 最终由 Jest-Worker 调度到子进程上执行.

graph TB Transformer --> WorkerFarm WorkerFarm --workerPath:DeltaBundler/Worker.js--> Jest-Worker Jest-Worker --Worker--> childProcess1 Jest-Worker --Worker--> childProcess2 Jest-Worker --Worker--> ... Jest-Worker --Worker--> childProcessN

Jest-Worker 的子进程也仅是一个代理人, 他执行的是实例化时指定的文件路径上暴露出来的 transform 方法:

const worker = this._makeFarm(
        this._config.transformer.workerPath,
        ['transform'],
        this._config.maxWorkers,
      );

// 默认的 workerPath 由 metro-config/src/configTypes.flow.js 指定:
workerPath: 'metro/src/DeltaBundler/Worker',

一层层代理下去, 最终代理到 @babel/core 上. 当然, 这个流程里的具体文件可以通过参数 transformerPath 以及 babelTransformerPath 指定.

// file:[metro/src/DeltaBundler/Worker.flow.js]
...
const Transformer = (require.call(
    null,
    transformerConfig.transformerPath,
  ): TransformerInterface);
const result = await Transformer.transform(
    transformerConfig.transformerConfig,
    projectRoot,
    filename,
    data,
    transformOptions,
  );
...

// file:[metro-config/src/configTypes.flow.js]
...
transformerPath: 'metro-transform-worker',
...

// file:[metro-transform-worker/src/index.js]
if (filename.endsWith('.json')) {
  return await transformJSON(jsonFile, context);
}
if (options.type === 'asset') {
  return await transformAsset(file, context);
}

const transformer: BabelTransformer = require(babelTransformerPath);
return await transformer.transform(
  getBabelTransformArgs(file, context),
 );

// file:[@react-native-community/cli-plugin-metro/build/tools/loadMetroConfig.js]
babelTransformerPath: 'metro-react-native-babel-transformer',

// file:[metro-react-native-babel-transformer/src/index.js]
const babelConfig = {
  // ES modules require sourceType='module' but OSS may not always want that
  sourceType: 'unambiguous',
  ...buildBabelConfig(filename, options, plugins),
  caller: {name: 'metro', bundler: 'metro', platform: options.platform},
  ast: true,
};
const sourceAst = parseSync(src, babelConfig)
const result = transformFromAstSync(sourceAst, src, babelConfig);
const functionMap = generateFunctionMap(sourceAst, {filename});

// buildBabelConfig 里会引入'metro-react-native-babel-preset', 该 preset 指示了 babel 该如何转译源代码文件.

Asset and JSON

在探究 metro 如何转译 js 文件前, 先来看看 metro 是怎么处理 JSON以及资源文件的.

  • JSON

    当检测到文件名以 .json 结尾时, 进入 transformJSON 的处理流程. 该流程会将 json 文件的内容包裹成一个 js 函数(剧透: 即最终序列化后定义模块的__d函数)中然后返回一个如下的数据结构.

    {
      dependencies: [],
      output: [
        {
          type: 'js/module'|'js/script'|'js/module/asset',
          data: {
            code: warppedCode,
            lineCount,
            map,
            functionMap: null,
          },
        },
      ],
    }
    
  • Asset

    当传入的 option.type === ‘asset’ 时, 进入 transformAsset 的处理流程.

    // 1. 首先根据根据图片的路径, 拿到图片的元信息, 如哈希值, 宽高, 尺寸等
    // 2. 然后元数据依次经过 AssetPlugins 的处理, 得到最终的元数据
    async function applyAssetDataPlugins(
      assetDataPlugins: $ReadOnlyArray<string>,
      assetData: AssetData,
    ): Promise<AssetData> {
      if (!assetDataPlugins.length) {
        return assetData;
      }
    
      const [currentAssetPlugin, ...remainingAssetPlugins] = assetDataPlugins;
      // $FlowFixMe: impossible to type a dynamic require.
      const assetPluginFunction: AssetDataPlugin = require(currentAssetPlugin);
      const resultAssetData = await assetPluginFunction(assetData);
      return await applyAssetDataPlugins(remainingAssetPlugins, resultAssetData);
    }
    
    // 3. 包装成对 asssetRegisty 的调用, 封装为ast
    function generateAssetCodeFileAst(
      assetRegistryPath: string,
      assetDescriptor: AssetDataWithoutFiles,
    ): File {
      const properDescriptor = filterObject(
        assetDescriptor,
        assetPropertyBlockList,
      );
    
      // {...}
      const descriptorAst = babylon.parseExpression(
        JSON.stringify(properDescriptor),
      );
      const t = babelTypes;
    
      // require('AssetRegistry').registerAsset({...})
      const buildRequire = template.statement(`
        module.exports = require(ASSET_REGISTRY_PATH).registerAsset(DESCRIPTOR_AST)
      `);
    
      return t.file(
        t.program([
          buildRequire({
            ASSET_REGISTRY_PATH: t.stringLiteral(assetRegistryPath),
            DESCRIPTOR_AST: descriptorAst,
          }),
        ]),
      );
    }
    
    // 4. 对 AST 进一步处理, 从这开始就和普通的 js 模块处理过程一致了
    const jsFile = {
      ...file,
      type: 'js/module/asset',
      ast,
      functionMap: null,
    };
    
    return transformJS(jsFile, context);
    

    在业务代码中 require 一个图片时, 拿到的是 registry.registerAsset 的返回值, 在默认的 registry 实现中, 这个返回值只是一个简单的id, 记录了该图片在所有图片资源中的位置:

    // react-native/Libraries/Image/AssetRegistry.js
    function registerAsset(asset: PackagerAsset): number {
      return assets.push(asset);
    }
    
    function getAssetByID(assetId: number): PackagerAsset {
      return assets[assetId - 1];
    }
    
    module.exports = {registerAsset, getAssetByID};
    

    然后 Image 组件会根据这个 id 拿到图片的元数据, 根据需要拼接上 devUrl/platfrom等信息, 最终拿到图片的真实地址, 渲染出来.

对 js 文件的处理

metro 把 js 源码分成了两种: script 和 module, script 是没依赖的源码, module 意外着该文件可能依赖其他模块. 在项目中, 后者更为常见, script 更多只是作为一种补充, 在 bundle 行首添加或者维护一些变量.

不管是哪种源码, 首先都需要经过 babel 的处理拿到 ast. 在这个过程中, metro 根据配置寻找相关的 babelrc 文件, 如果没有找到, 那么 metro 会添加默认的 preset: metro-react-native-babel-preset. 同时, 会根据当前环境添加相关的 babel 插件, 比如Fast Refresh 插件:

if (options.dev && options.hot) {
  const mayContainEditableReactComponents =
    filename.indexOf('node_modules') === -1;

  if (mayContainEditableReactComponents) {
    const hmrConfig = makeHMRConfig();
    hmrConfig.plugins = withExtrPlugins.concat(hmrConfig.plugins);
    config = {...config, ...hmrConfig};
  }
}

构造恰当的 babel 配置, 然后调用 parser.parseSync(code, babelOption) 得到 ast, 第一阶段的工作就算完成了.

第二个阶段的工作, 是提取文件的依赖, script 类型的文件是座孤岛, 它的依赖为空自不必说. module 类型的文件根据ast, 获取所有通过 import / import() / require / require.context 声明的依赖, 最终得到所有的依赖名称. 实现细节在文件metro/src/ModuleGraph/worker/collectDependencies.js中.

第三个阶段个工作, 进一步处理 ast, 并生成代码.

  • script 类型的文件, 封装成一个 iife 函数:
    function makeIdentifier(name: string): Identifier {
      return t.identifier(name);
    }
    
    function functionFromProgram(
      program: Program,
      parameters: $ReadOnlyArray<string>,
    ): FunctionExpression {
      return t.functionExpression(
        undefined,
        parameters.map(makeIdentifier),
        t.blockStatement(program.body, program.directives),
      );
    }
    
    function wrapPolyfill(fileAst: BabelNodeFile): BabelNodeFile {
      const factory = functionFromProgram(fileAst.program, ['global']);
    
      const iife = t.callExpression(factory, [IIFE_PARAM()]);
      return t.file(t.program([t.expressionStatement(iife)]));
    }
    
  • module 类型, 封装为一个定义module的语句
    function wrapModule(
      fileAst: BabelNodeFile,
      importDefaultName: string,
      importAllName: string,
      dependencyMapName: string,
      globalPrefix: string,
    ): {
      ast: BabelNodeFile,
      requireName: string,
    } {
      const params = buildParameters(
        importDefaultName,
        importAllName,
        dependencyMapName,
      );
      // 1. 首先生成一个匿名函数
      const factory = functionFromProgram(fileAst.program, params);
      const def = t.callExpression(t.identifier(`${globalPrefix}__d`), [factory]);
      // 2. 生成一个对__d函数的调用, 匿名函数作为参数
      const ast = t.file(t.program([t.expressionStatement(def)]));
    
      // 3. 替换所有的require关键字 => $$_REQUIRE
      const requireName = renameRequires(ast);
    
      return {ast, requireName};
    }
    

Cache

在整个打包的工程中, transformer 是最耗时的部分了, 它既是 io 密集又是 cpu 密集型的工作. 没有对转译结果进行缓存的打包器, 是不严肃的玩具. metro 的缓存设计体现在 Transformer 类的设计上, 它有一个相关的成员变量:

  • _baseHash, 由 cacheVersion/projectRoot/transformerPath/transformerConfig 组成

在对每一个文件进行转译前, 会根据文件哈希值和_baseHash 计算出一个 fullkey:

const partialKey = stableHash([
  // This is the hash related to the global Bundler config.
  this._baseHash,

  // Path.
  localPath,

  customTransformOptions,
  dev,
  experimentalImportSupport,
  hot,
  inlinePlatform,
  inlineRequires,
  minify,
  nonInlinedRequires,
  platform,
  runtimeBytecodeVersion,
  type,
  unstable_disableES6Transforms,
  unstable_transformProfile,
]);

const sha1 = this._getSha1(filePath);
let fullKey = Buffer.concat([partialKey, Buffer.from(sha1, 'hex')]);
const result = await cache.get(fullKey);
...


cache.set(fullKey, data.result);

根据 fullKey 从缓存系统查询, 如果存在值则进行复用, 否则才进行转译.

当然还少不了 Cache 类的实现, metro 默认的 FileCache (packages/metro-cache/stores/FileStore.js)有文件存放的细节, 感兴趣可以前往查阅.

Q&A

  • metro-transform-worker 中, transform 函数接受的 options.type 是如何产生的? 这个参数的值, 影响到transform的output的 type. 如:

    if (file.type === 'asset') {
        jsType = 'js/module/asset';
      } else if (file.type === 'script') {
        jsType = 'js/script';
      } else {
        jsType = 'js/module';
      }
    
      const output = [
        {
          data: {code, lineCount: countLines(code), map, functionMap: null},
          type: jsType,
        },
      ];