美文网首页
react native 使用Tree Shaking

react native 使用Tree Shaking

作者: 涅槃快乐是金 | 来源:发表于2023-05-08 22:41 被阅读0次

    我们将用于 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 模块可以被静态分析。例如,在以下代码中静态地检测所有导出标识符并不容易:
    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 的经验。在进行例行重构期间,我应用了以下差异:

    +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:

    
     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支持的语法:

    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本身的源文件就是使用混合模块系统的,例如:

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

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

    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中重新导出类型导致的,例如:

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

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

    -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

    相关文章

      网友评论

          本文标题:react native 使用Tree Shaking

          本文链接:https://www.haomeiwen.com/subject/sczlsdtx.html