react native 使用Tree Shaking

我们将用于 Web 应用的优化技术应用到 React Native 应用中,使启动时间减少了20%。

Tree Shaking是什么?

Tree Shaking可能是一个令人费解的的术语。在 TypeScript 中,您可能已经听说过“import elision”。Tree Shaking是一种死代码消除的形式,特别涉及未使用的导出的删除。如果我们将所有模块连接起来,则未使用的导出实际上是死代码,可以删除。然而,确定未使用的导出的过程并不容易。Tree Shaking通常是在编译器/打包工具级别(例如 Webpack 或 ESBuild)实现的,而不是由 JavaScript 引擎(如 V8 或 Hermes)实现的。JavaScript 中的许多模式都可能破坏树摇,但在本文中,我想专注于一个方面:模块系统。这里我们需要了解的两个相关的模块系统是 CommonJS 模块和 ES 模块。

当您编写 module.exports = {} 或 exports.someMethod = () => {} 时,使用 的是CommonJS。而ES 模块使用 import 和 export 语法则可以识别分析 。对于使用 CommonJS 的代码,编译器比对 ES 模块更难应用 Tree Shaking。CommonJS 模块经常是动态的,而 ES 模块可以被静态分析。例如,在以下代码中静态地检测所有导出标识符并不容易:

1
2
3
4
5
6
7
8
const constants = require("./constants");
const upperCasedConstants = Object.fromEntries(
Object.entries(constants).map(([constant, value]) => [
constant,
value.toUpperCase(),
])
);
module.exports = { ...upperCasedConstants }

由于 ES 模块在设计上是可静态分析的,因此编译器更容易检测未使用的导出。
因此,最好使用 ES 模块而不是 CommonJS 模块来让您的优化编译器处理。

背景

在加入 Klarna 之前,我没有使用 React Native 的经验。在进行例行重构期间,我应用了以下差异:

1
2
3
4
5
6
+import {someFeatureMethod} from 'some-feature-module';
...
if (SOME_STATIC_FLAG) {
- const { someFeatureMethod } = require('some-feature-module');
someFeatureMethod();
}

假设所使用的打包工具会在 SOME_STATIC_FLAG 为 false 时将 someFeatureMethod 视为未使用,从而将 some-feature-module 从最终的打包文件中移除。在代码审查中,这个差异被标记为有问题,所以我坐下来仔细检查了我的假设以及出现问题的地方。幸运的是,我们已经在几个月前切换到了 Webpack(以 Re.Pack 的形式),以实现与 React.lazy 的代码拆分。这使得我可以以一种方式配置打包过程,以便检查最终的 JavaScript 打包文件。在我们的情况下,只需要禁用 Hermes,就可以查看最终的 JavaScript 输出。

经过一些试错,为了更容易找到 some-feature-module 的导入位置,我发现了以下行:c=(n(463526),n(456189) 逗号运算符通常是不使用的,所以让我总结一下它的作用:它计算所有操作数,只使用最后一个操作数的返回值。换句话说,n(463526) 的返回值是未使用的。由于我已经有在 Web 上使用 Tree Shaking 的经验,所以在代码被压缩之前,这很容易理解:require(‘some-feature-module’)(Webpack 将导入源字符串转换为数字)。

实际上,Webpack 确实识别出了 someFeatureMethod 是未使用的,因此删除了它的使用。然而,Webpack 没有删除模块中未使用的导出项,因此保留了导入,因为它不知道模块是否具有副作用。如果一个模块具有副作用,我们不能简单地将其从打包文件中移除,因为这会改变程序的流程。

要使原始差异按预期工作,我们所要做的就是确保 Tree Shaking 应用到最终的打包文件中。

实现

这一切都取决于确保在Webpack捆绑所有模块之前,不要将ES模块转译为CommonJS。如果你正在使用Metro Babel预设(新的React Native应用程序的默认设置),那么大部分工作都需要启用disableImportExportTransform:

1
2
3
4
5
6
7
8
9

presets: [
[
'module:metro-react-native-babel-preset',
- { disableImportExportTransform: false },',
+ { disableImportExportTransform: true },
],
'@babel/preset-typescript',
],

这个选项目前没有被记录在文档中,随时可能被删除。

我们还需要告诉Webpack使用使用ES模块而不是CommonJS模块的入口点。对于单个文件,这意味着首选 .mjs 文件,而对于包,我们需要告诉Webpack使用 module main 字段。

然而,这暴露了我们在编写 JavaScript 和 React Native 生态系统中编写代码的问题,我们已经确定了 3 类问题。

在main和module中导出不同的语法

这些main字段应该只用于区分模块系统(main用于CommonJS,module用于ES模块)。然而,许多软件包从模块入口点(shipping)提供了更现代的语法。例如,Hermes目前不支持类语法。

目前,我们通过向Webpack配置添加自定义规则,将所有node_modules内容转换为ES5语法或Hermes支持的语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const webpackConfig = {
module: {
rules: [
{
// Workaround for some `module` entries containing `class` syntax
// `module` is only for the used module system but some packages abuse it to ship modern syntax
// Until `class` support landed in Hermes we need to transpile JS classes
// TODO: Only transpile offending packages
// TODO: Only apply necessary plugins (syntax-class-properties, transform-classes)
test: /\.([jt]sx?|mjs)$/,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
babelrc: false,
extends: babelConfig,
},
},
},
]
}
}

使用不明确的CommonJS模块

Webpack无法从混合使用模块系统的模块中找到导出。然而,React Native本身的源文件就是使用混合模块系统的,例如:

1
2
3
4
5

import AnimatedColor from './nodes/AnimatedColor';
module.exports = {
Value: AnimatedValue
};

这里的解决方案是继续将这些模块转换为CommonJS(从而禁用Tree Shaking),通过在Webpack配置中添加特殊规则来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const webpackConfig = {
module: {
rules: [
{
// TODO: Patch packages to not mix module systems
test: /\.([jt]sx?|mjs)$/,
include: [
/node_modules(.*[/\\])+react-native/,
/node_modules(.*[/\\])+cobrowse-sdk-react-native/,
/node_modules(.*[/\\])+@react-native-picker\/picker/,
],
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
babelrc: false,
plugins: ['@babel/plugin-transform-modules-commonjs'],
},
},
},
]
}
}

未使用import

这实际上是JavaScript中的SyntaxError,许多人并不知道。例如,import { doesNotExist } from ‘some-module’; 会抛出SyntaxError。对于开发人员来说,这主要是一个麻烦,但可能导致实际的运行时问题。我们通过在Webpack配置中启用module.parser.javascript.exportsPresence来强制实施ES模块的严格实现。
大多数这些问题是由于在TypeScript中重新导出类型导致的,例如:

1
2
import { SomeType } from 'some-module';
export { SomeType } from 'some-module';

幸运的是,TypeScript可以通过启用独立模块选项来在类型级别上标记这些问题:

1
2
3
-import { SomeType } from 'some-module';
+import { type SomeType } from 'some-module';
export { SomeType } from 'some-module';

在TypeScript 4.5中,导入名称上的类型修饰符是新功能。为了支持导入名称上的类型修饰符,我们需要升级使用的ESLint解析器、Prettier和TypeScript,这是一个相当具有挑战性的任务。
在导入名称中添加类型修饰符会导致Babel删除在运行时实际上不存在的类型导入。

结果

最初的实现方法非常艰难。不过,初步的结果已经显示了跨两个平台的20%中位数启动时间的改进(三星Galaxy S9由2.8秒降至2.2秒,iPhone 11由802毫秒降至640毫秒)。
我们看到的是我们初始的、关键的 JavaScript chunk 减少了 46%。我们所发送的 JavaScript 总大小减少了 14%。这个差异很大程度上归因于将代码从主 chunk 移动到异步 chunk(features 和 routes)。


这些图片是由统计数据创建的,它帮助我们分析这个变化,并将继续帮助我们推动包大小的进一步改进。
请注意,减少并不仅仅来自于删除未使用的导出项,还有Webpack的ModuleConcatenationPlugin能够更多地连接模块。换句话说,我们可以提升更多模块。我们还没有完全利用作用域提升。现在,只有20%的模块被提升。一旦我们增加这个数字,我们预计会获得更多的包大小和运行时收益。
这40%的JavaScript大小几乎与在可以执行JavaScript代码之前评估它所需的时间完全相符。JavaScript大小会影响启动时间,因此减少直接JavaScript资源可以直接减少启动时间。
在实现的最后一步完成2周后,我们仍然在实验室中得到了相同的结果,并准备将此功能发布到我们的主分支中。我们特别注意在发布截止日期之后直接发布最终更改。这使我们能够在内部应用程序版本中广泛测试新模块系统。在进行了一周的内部测试后,该功能逐步向最终用户推出。我们看到应用程序的稳定性基本没有受到影响,非常有希望。生产数据显示,相对于我们在实验室结果中看到的中位数启动时间,同样有相对的改善:
Version 22.37 未使用tree-shaking; 22.38使用 tree-shaking
android
ios
| |tree-shaking| no tree-shaking| diff|
|–|–|–|–|
|Android p50 |2,265ms |2,722ms |-17%|
|Android p75 |3,816ms |4,815ms |-21%|
|iOS p50 |1,855ms |2,184ms| -15%|
|iOS p75 |2,549ms |2,875ms| -11%|
这些改进的代价是增加了构建时间。打包生产JavaScript捆绑包需要的时间增加了大约30%。我们很高兴接受这些增加的构建时间,因为它们直接转化为更好的用户体验。其中一些增加的构建时间归因于需要转译的内容过多。最初的实现没有花费时间来减少需要转译的内容。我们也将收回一些构建时间增加,随着更多的软件包使用适当的ES模块进行发布。请记住,构建React Native应用程序所需的JavaScript构建时间不是唯一的任务。与编译二进制文件等等的任务相比,增加的JavaScript构建时间并不会对最终产生太大的影响。

后续

在 React Native 生态系统中,似乎并没有积极地研究 ES 模块。我们希望更多地在 ES 模块的正确用法上进行生态系统的协调(例如,将模块条目指向具有等效语法的 JavaScript)。这样我们就可以减少构建配置并减少编译的工作量。
虽然Metro中有一个支持使用ES模块的开关(experimentalImportSupport),但是它被标记为实验性且未经文档化。在开发环境下启用该开关对我们来说并不起作用,但我们希望有一天可以在开发和生产中都使用相同的模块系统。我们希望重新开始讨论React Native中的ES模块,因为目前似乎并没有积极支持ES模块的工作。甚至在多年前,Tree Shaking的支持也已经被完全放弃了。
ES模块是每个了解JavaScript的人最终都会学习的语言功能。我们认为React Native没有理由要有额外的学习步骤来理解bundle拆分和死代码消除。

原文:https://engineering.klarna.com/tree-shaking-react-native-apps-472681c06aaf