Tree shaking 和代码移除
编辑页面
了解 Expo CLI 如何优化生产环境的 JavaScript bundle。
For the complete documentation index, see llms.txt. Use this Use this file to discover all available pages.
Tree shaking(也称为 死代码移除)是一种从生产包中移除未使用代码的技术。Expo CLI 采用多种技术,包括 压缩,通过移除未使用的代码来提升启动速度。
平台摇树
Expo CLI 在应用打包时采用一种称为 平台摇树 的流程,它会为每个平台(Android、iOS、web)创建独立的包。它会确保某段代码只在一个平台上使用,并从其他平台中移除。
任何基于 react-native 中的 Platform 模块进行条件判断的代码,都会从其他平台中移除。不过,这种排除仅适用于在每个文件中直接从 react-native 导入 Platform.select 和 Platform.OS 的情况。如果它们通过其他模块重新导出,则在不同平台的打包过程中不会被移除。
例如,考虑以下转换输入:
import { Platform } from 'react-native'; if (Platform.OS === 'ios') { console.log('Hello on iOS'); }
生产包会移除基于平台的条件判断:
%%placeholder-start%%在 Android 上为空 %%placeholder-end%%
console.log('Hello on iOS');
这种优化仅在生产环境中生效,并且按文件级别运行。如果你通过其他模块重新导出 Platform.OS,它就不会从生产包中移除。
可以使用 process.env.EXPO_OS 来检测 JavaScript 被打包到哪个平台(运行时无法更改)。由于 Metro 在依赖解析之后会对代码进行压缩,这个值不支持平台摇树式导入。
移除仅开发环境代码
在你的项目中,可能会有一些用于辅助开发流程的代码。它应该从生产包中排除。要处理这些场景,请使用 process.env.NODE_ENV 环境变量或非标准的 __DEV__ 全局布尔值。
1
例如,以下代码片段会从生产包中移除:
if (process.env.NODE_ENV === 'development') { console.log('Hello in development'); } if (__DEV__) { console.log('Another development-only conditional...'); }
2
在 常量折叠 之后,这些条件可以被静态求值:
if ('production' === 'development') { console.log('Hello in development'); } if (false) { console.log('Another development-only conditional...'); }
3
这些不可达条件会在 压缩 过程中被移除:
%%placeholder-start%%空文件 %%placeholder-end%%
为了提升速度,Expo CLI 只会在生产构建中执行代码消除。上面代码片段中的条件判断在开发构建中会被保留。
自定义代码移除
EXPO_PUBLIC_ 环境变量会在压缩过程之前被内联。这意味着它们可以用来从生产包中移除代码。例如:
1
EXPO_PUBLIC_DISABLE_FEATURE=true;
if (!process.env.EXPO_PUBLIC_DISABLE_FEATURE) { console.log('Hello from the feature!'); }
2
经过 babel-preset-expo 之后,上面的输入代码片段会被转换为以下内容:
if (!'true') { console.log('Hello from the feature!'); }
3
然后,上面的代码片段会被压缩,从而移除未使用的条件判断:
// 空文件
- 该系统不适用于服务器代码,因为环境变量不会在服务器包中内联。
- 库作者不应使用
EXPO_PUBLIC_环境变量,因为出于安全原因,它们仅在应用代码中运行。
移除服务器代码
通常会使用 typeof window === 'undefined' 来有条件地为服务端和客户端环境启用或禁用代码。
babel-preset-expo 在为服务器环境打包时,会将 typeof window === 'undefined' 转换为 true。默认情况下,在为 web 客户端环境打包时,这个检查保持不变。这个转换在开发和生产环境中都会运行,但只会在生产环境中移除条件 require。
你可以通过传入 { minifyTypeofWindow: true } 来配置 babel-preset-expo 以启用此转换。
默认情况下,即使在 web 环境中,此转换也保持禁用,因为 web worker 没有 window 全局对象。
1
if (typeof window === 'undefined') { console.log('Hello on the server!'); }
2
上一节中的输入代码在为服务器环境打包时,会在 babel-preset-expo 之后转换为以下代码片段(API 路由、服务端渲染):
if (true) { console.log('Hello on the server!'); }
为 web 或原生应用打包客户端代码时,不会替换 typeof window,除非设置了 minifyTypeOfWindow: true:
if (typeof window === 'undefined') { console.log('Hello on the server!'); }
3
对于服务器环境,上面的代码片段随后会被压缩,从而移除未使用的条件判断:
console.log('Hello on the server!');
if (typeof window === 'undefined') { console.log('Hello on the server!'); } // 空文件
React Native web 导入
babel-preset-expo 为 react-native-web 的 barrel 文件提供了内置优化。如果你直接使用 ESM 导入 react-native,那么该 barrel 文件会从生产包中移除。
如果你使用静态 import 语法导入 react-native,barrel 文件会被移除。
import { View, Image } from 'react-native';
import View from 'react-native-web/dist/exports/View'; import Image from 'react-native-web/dist/exports/Image';
如果你使用 require() 导入 react-native,barrel 文件会原样保留在生产包中。
const { View, Image } = require('react-native');
const { View, Image } = require('react-native-web');
移除未使用的导入和导出
实验性 功能,适用于 SDK 52 及更高版本。
你可以以实验方式启用支持,自动移除模块之间未使用的导入和导出。这对于加快原生 OTA 下载以及优化 web 性能很有用,因为 JavaScript 必须使用标准 JavaScript 引擎进行解析和执行。
考虑下面的示例代码:
import { ArrowUp } from './icons'; export default function Home() { return <ArrowUp />; }
export function ArrowUp() { /* ... */ } export function ArrowDown() { /* ... */ } export function ArrowRight() { /* ... */ } export function ArrowLeft() { /* ... */ }
由于在 index.js 中只使用了 ArrowUp,生产包会移除 icons.js 中所有其他组件。
export function ArrowUp() { /* ... */ }
该系统可扩展到自动优化应用中所有平台上的 import 和 export 语法。虽然这会生成更小的包,但处理 JS 仍然需要时间和内存,因此请避免导入数百万个模块。
- Tree-shaking 只在生产包中运行,并且只能作用于使用
import和export语法的模块。使用module.exports和require的文件不会被 tree-shaking。 - 避免添加诸如
@babel/plugin-transform-modules-commonjs之类的 Babel 插件,因为它们会将import/export语法转换为 CJS。这会破坏你项目中的 tree-shaking。 - 被标记为有副作用的模块不会从依赖图中移除。
export * from "..."会被展开并优化,除非导出使用了module.exports或exports。- Expo SDK 中的所有模块都以 ESM 形式发布,并且可以被彻底 tree-shaken。
启用 tree shaking
实验性 功能,适用于 SDK 52 及更高版本。
1
确保启用 experimentalImportSupport,并确保你的应用构建和运行符合预期。
注意:从 SDK 54 及更高版本开始默认启用。
如何在较旧的 SDK 版本中启用 import 支持?
const { getDefaultConfig } = require('expo/metro-config'); const config = getDefaultConfig(__dirname); config.transformer.getTransformOptions = async () => ({ transform: { experimentalImportSupport: true, }, }); module.exports = config;
实验性 import 支持使用了 @babel/plugin-transform-modules-commonjs 插件的自定义版本。这会大幅减少解析数量并简化输出包。此功能可以与 inlineRequires 一起使用,以实验性地进一步优化你的包。
2
打开环境变量 EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH=1,以便在整个图创建完成之前保留模块。在继续之前,请确保在启用此功能的生产环境中,你的应用构建和运行符合预期。
EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH=1
这只会在生产模式下使用。
3
打开环境变量 EXPO_UNSTABLE_TREE_SHAKING=1 来启用该功能。
EXPO_UNSTABLE_TREE_SHAKING=1
这只会在生产模式下使用。
4
以生产模式打包你的应用,以查看 tree shaking 的效果。
- npx expo export此功能仍然非常实验性,因为它改变了 Metro 打包代码的基础结构。默认情况下,Metro 会按需并以惰性方式打包所有内容,以确保尽可能快的开发速度。相比之下,tree shaking 需要将某些转换延迟到整个包创建完成之后。这意味着可缓存的代码会更少,不过这通常没问题,因为 tree shaking 只是生产环境功能,而且生产包通常不会使用转换缓存。
条带文件
实验性地 在 SDK 52 及更高版本中可用。
借助 Expo 树摇,星号导出会根据使用情况自动展开并进行摇树优化。例如,考虑以下代码片段:
export * from './icons';
优化过程会遍历 ./icons 并将导出添加到当前模块中。如果这些导出未被使用,它们将从生产包中移除。
export { ArrowRight, ArrowLeft } from './icons';
这将按照标准的树摇规则进行优化。如果你只导入 ArrowRight,那么 ArrowLeft 将从生产包中移除。
如果星号导出引入了歧义导出,例如 module.exports.ArrowUp 或 exports.ArrowDown,那么优化过程不会展开该星号导出,并且条带文件中的任何导出都不会被移除。你可以使用 Expo Atlas 来检查展开后的导出。
你可以将此策略用于像 lucide-react 这样的库,以移除应用中未使用的所有图标。
递归优化
实验性地 在 SDK 52 及更高版本中可用。
Expo 会通过对图进行穷尽式递归来优化模块,以查找未使用的导入。考虑以下代码片段:
export function foo() { // 因为这里使用了 bar,所以它不能被移除。 bar(); } export function bar() {}
在这种情况下,bar 在 foo 中被使用,因此它不能被移除。然而,如果 foo 在应用中的任何地方都没有被使用,那么 foo 将被移除,模块会再次被扫描,以查看 bar 是否也能被移除。对于给定模块,这个过程会递归 5 次,然后由于性能原因停止。
副作用
Expo CLI 会根据 Webpack 系统 尊重模块副作用。副作用通常用于定义全局变量(console.log)或修改原型(避免这样做)。
你可以在 package.json 中标记模块是否具有副作用:
{ "name": "library", "sideEffects": ["./src/*.js"] }
副作用会阻止未使用模块的移除,并禁用模块内联,以确保 JS 代码按预期顺序运行。如果副作用为空,或仅包含注释和指令("use strict"、"use client" 等),它们将被移除。
当启用 Expo 树摇时,你可以安全地在生产包中为 metro.config.js 启用 inlineRequires。这会在模块被求值时延迟加载它们,从而加快启动时间。如果不使用 Expo 树摇,请避免使用此功能,因为它会以可能改变副作用执行顺序的方式重新排列模块。
const { getDefaultConfig } = require('expo/metro-config'); const config = getDefaultConfig(__dirname); config.transformer.getTransformOptions = async () => ({ transform: { experimentalImportSupport: true, inlineRequires: true, }, }); module.exports = config;
为树摇进行优化
在 Expo 树摇出现之前,React Native 库通常会通过将导入包装在条件块中来移除它们,例如:
if (process.env.NODE_ENV === 'development') { require('./dev-only').doSomething(); }
这有问题,因为你无法获得准确的 TypeScript 支持,而且由于你无法静态分析代码,图会变得模糊。启用 Expo 树摇后,你可以重构这段代码以使用 ESM 导入:
import { doSomething } from './dev-only'; if (process.env.NODE_ENV === 'development') { doSomething(); }
在这两种情况下,整个模块在生产包中都将为空。