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 调度到子进程上执行.
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, }, ];