【Webpack】507- 基于Tree-shaking的多平台Web代码打包实践

  • 2020 年 2 月 26 日
  • 筆記

在业务中,我们常常会遇到一个场景:同一套web业务代码要在多平台下执行其对应的不同职能。这样很容易出现两个问题:代码里“尸横遍野”的环境判断和分支,提高了代码维护难度;执行环境下载了其他环境的功能代码,造成了资源的浪费。只要我们合理使用Webpack的Tree-shaking功能,就可以很好地解决问题。

一、需求背景

不以解决实际问题为目标的技术实践都是耍流氓 —— shijisun

需求

出现一套Web代码在多个平台下执行需要实现不同功能的问题,功能包括但不限于:数据加载、展示样式、用户交互等。

例如,腾讯课堂H5课程详情页需要承载起H5AppPadApp小程序等多平台的页面功能,以该页面在H5App两个环境下的对比为例:

对比 项

H5

App

数据加载

CGI数据

首屏从App加载数据,并行加载CGI数据,竞速关系

功能组件

全量展示

不展示播放器、目录、推荐课程等模块,同时老师模块展示样式/用户交互不一样

版本判断

不需要版本判断

依赖App版本开启分销、砍价、拼团、打卡等功能

用户反馈

功能完全由H5实现

切换班级、支付保障浮层展示、查看课详图片、跳转打卡小程序等功能需要依赖Native原生组件

存在问题

1、代码里“尸横遍野”的环境判断和分支,提高了代码维护难度;

2、执行环境下载了其他环境的功能代码,造成了资源的浪费;

问题到底有多严峻呢?请看下面实际生产环境代码的截图?

二、技术方案

灵感,是由于顽强的劳动而获得的奖赏。—— 列宾

以其中的一个组件为例(如下代码),只要是在移动端需要适配多平台,那类似这样 isApp() 的运行时环境判断代码一定不会少见(无论你是通过App/小程序内嵌H5页面、React-Native-Web三端同构、kbone同构小程序/H5等)。

export class SaleLabel extends React.Component</* … */> {    // ...    componentDidMount() {      if (isApp()){        // APP 需要判断版本是否支持分销,如果支持分销才开始初始化流程      } else {        // H5 直接获初始化分销数据      }    }    // ...    handleClick = () => {      if (isApp()){        // APP 内直接唤起原生浮层,不需要额外判断      } else {        // H5 执行检查流程        // 1. 检查登录信息 2. 获取分销token 3. 展示分销浮层      }      // ...    }    // ...  }

这样的代码一方面容易在多次迭代中慢慢沦为垃圾代码(当然这个可以通过更合理的目录和代码重构解决);另一方面在不同的平台也加载了多余的代码逻辑,例如App相关的逻辑代码在H5上完全不会执行,但是还是被加载了。

一套web代码想要在多个平台实现不同功能,无论你使用 条件分支、还是 继承派生 等方法,一个页面一份代码打天下的实践已经无法满足我们的需求了。细究这么多种多平台同构的方案,其基本原理都是一份统一API的代码,通过编译打包引用不同的平台底层组件,最后打包成多份可执行程序的过程。

那么纯Web的场景是否可以有类似的实践呢?

重新回头看上文的 isApp 判断逻辑,如果我们把运行时环境判断提前到编译时环境判断,根据逻辑判断的结果,通过 Tree-shaking 优化去除多余的代码,那么就能得到指定运行平台的可执行代码了!

export class SaleLabel extends React.Component</* … */> {    // ...    componentDidMount() {      if (true){ // 编译时环境变量注入,利用Tree-shaking去除多余代码        // APP...      } else {        // H5...      }    }    // ...    handleClick = () => {      if (true){ // 编译时环境变量注入,利用Tree-shaking去除多余代码        // APP...      } else {        // H5...      }      // ...    }    // ...  }

✨✨整体方案如下✨✨:

  • 1、使用环境变量注入的方法在打包阶段将编译时环境变量注入到执行代码里,在通过自动化 Tree-shaking 将多余的代码自动去除;
  • 2、通过多轮编译过程的形式对需要区分多执行环境的页面执行多次打包,每次固定打出一个执行环境下的代码;
  • 3、通过Nginx以及直出路由控制返回给用户端的代码;

三、实现落地

老夫写代码就是一把梭 —— 匿名程序员

3.1 环境注入

DefinePlugin允许创建一个在编译时可以配置的全局常量。

通过 webpack.DefinePlugin 注入编译时环境变量,后续我们的执行代码里就可以引用这个环境变量进行当前平台的判断了。

new webpack.DefinePlugin({      // RUNTIME_ENV_EXPECT: JSON.stringify('H5'),        // ...      RUNTIME_ENV_EXPECT: JSON.stringify('APP'),  });

当然如果你的项目用到了TypeScript,那你还需要全局声明 TS 类型。

/**   * 运行时环境变量(由构建工具打包时注入)   */  declare const RUNTIME_ENV_EXPECT: string;

3.2 代码重构(容器组件/功能组件)

需要对目前项目代码进行代码重构,重构范围包括:

  • 分离容器组件和功能组件,通常容器组件以组合的形式实现,功能组件以继承的方式实现;
  • 容器组件,以组合的形式实现,控制同层级组件的引用;
  • 功能组件,以继承的方式实现,通常你需要一个基础父组件和多个平台下的子组件;
  • 更新环境判断逻辑,需要把运行时环境判断修改为编译时环境判断,同时这也是一个梳理的过程,你可以了当前你的代码需要支撑多少平台;

每一个组件的范式目录树:

...  Component(组件目录,例如Container、Bottom等)      ├── app.tsx (app实现逻辑)      ├── app.tsx (H5实现逻辑或者抽象的统一逻辑)      ├── index.tsx (环境判断 & 路由入口)      ├── ipad.tsx (ipad实现逻辑)      └── type.tsx (组件相关类型封装)  ...  以上的目录组成由你的组件所需要支撑的运行平台而定。

3.3 开启 Tree-shaking 功能

本文章已 Webpack4 的角度进行阐述,其他版本或者构建工具可以进行参考。

3.3.1 更新构建配置

开启Tree-shaking,具体查看 本文档。

需要注意的是 Tree-shaking依赖 ES6模块语法,如果你的项目使用的babel:

  • 设置 @babel/preset-env 相关配置;
  • 也不能引用类似 @babel/plugin-transform-modules-commonjs,这种会把模块编译成commonjs的插件;
// 以下的babel配置可以通过 babel-loader 动态设置或者复写,基本原则不变  module.exports = {    presets: [      [        '@babel/preset-env',        {          modules: false, // 设置成false指定不转换模块类型          useBuiltIns: 'usage',          corejs: 2        }      ],      '@babel/preset-typescript',      '@babel/preset-react'    ],    plugins: [      // '@babel/plugin-transform-modules-commonjs', // 不能引用类似模块,否则会破坏 Tree-shaking      '@babel/plugin-proposal-optional-chaining',      '@babel/plugin-transform-runtime',      '@babel/plugin-proposal-class-properties',      '@babel/proposal-object-rest-spread',      '@babel/plugin-syntax-dynamic-import'    ]  };

3.3.2 确认组件是否无副作用

声明指定文件的副作用,可以通过 include 或者 exclude 指定文件范围。

rules: [{      test: /.tsx?$/,      loader: ['ts-loader'],      include: [path.resolve(rootDir, 'src')],      sideEffects: false // 声明无副作用  }]

注意:

  • 如果你是一个新的项目,可以通过声明 package.jsonsideEffects 属性;
  • 如果你是一个旧的项目,那么推荐缩小副作用声明的范围,除非你有一定把握不会有问题;

3.3.3 构建调试

什么样的模块会被Tree-shaking去除呢?通过官方教程,我们了解到:

  • dev 模式下模块/方法被标识成 unused harmonyexportYourComponent...
  • dist 模式下该模块/方法将自动去除;

以上图的 CourseDetail 组件为例,当编译时环境变量 RUNTIME_ENV_EXPECT 注入为 APP 时,相关条件判断代码将被置为 true,借而产生不可到达的分支,而这种条件分支和相关依赖都会被 Tree-shaking 自动去除,也就达到了去除非本环境依赖代码的效果。

到底有什么办法可以确认哪些模块会被移除呢?这部分内容我们放到后文讲解。

3.4 应用多轮代码编译

3.4.1 静态资源打包

通过上面的三个步骤,我们可以走通指定一个运行平台的代码构建打包过程。接下来要做的事情就是将该过程重复多轮,每一轮注入特定的编译时环境变量用来指定运行平台。

// webpack-dist.config.js || webpack.config.js  // ...  const RUNTIME_ENV = {    H5: 'H5',    APP: 'APP',    IPAD: 'IPAD',    MINI_PROGRAM: 'MINI_PROGRAM'  };  // ...  module.exports = Object.keys(RUNTIME_ENV).map((env) => {    // 静态资源打包    return buildDistConfigForEnv(env);  });

其中 buildDistConfigForEnv 根据输入的参数生成指定运行平台的构建配置,需要做以下几件事情:

  • 注入编译时环境变量,通过声明 webpack.DefinePlugin 注入;
  • 选定打包入口(entry),如果你的项目不是所有页面都需要按照平台进行打包,则需要根据平台指定打包入口;
  • 标识输出文件名,例如同一个页面的代码最后可以打包成 page.h5.jspage.app.jspage.ipad.js等;
  • 其他需要根据平台设置的配置、插件列表等 … ;

3.4.2 直出代码打包

直出代码打包同理,需要根据编译时环境变量打包出多个平台使用的模板代码和组件。

// webpack-dist.config.js || webpack.config.js  // ...  const RUNTIME_ENV = {    H5: 'H5',    APP: 'APP',    IPAD: 'IPAD',    MINI_PROGRAM: 'MINI_PROGRAM'  };  // ...  // webpack-dist.config.js  module.exports = Object.entries(RUNTIME_ENV)    .map(([_, env]) => {      return [        // 静态资源打包        buildDistConfigForEnv(env),        // 直出代码打包        ...getSSRConfigs({ mode, output }, env),      ]    }).flat();

最后打包进直出 templates 的模板有多个,例如 腾讯课堂App内嵌课详页时是使用course.app.html。所以需要一个直出服务的路由逻辑,在访问同一个URL时,自动根据请求带的用户环境信息选择对应合适的模板文件(指向不同的静态资源)进行渲染。

// 根据运行时环境获取模板文件名  const runtimeEnv = getRuntimeEnv(ctx); // 这里可以通过 url 结合 UA 进行判断  let pageNameByEnv = `${pageName}.${runtimeEnv.toLowerCase()}`; // page.env  pageNameByEnv = templates[pageNameByEnv] ? pageNameByEnv : pageName;  // 获取资源路径  const reducerPath = path.resolve(rootPath, `reducers/${pageNameByEnv}.js`);  const componentPath = path.resolve(rootPath, `components/${pageNameByEnv}.js`);  const templatePath = path.resolve(rootPath, `templates/${pageNameByEnv}.html`);

四、优化和总结

Tree-shaking是个玄学的过程 — shijisun

4.1 构建性能优化

每个平台都需要进行静态资源 + 直出资源的打包,总共累计 平台数*2 的编译过程,这个过程是串行执行的,一旦打包平台增加不免需要等待更长的构建事件。

parallel-webpack allows you to run multiple webpack builds in parallel, spreading the work across your processors and thus helping to significantly speed up your build.

我们可以利用 parallel-webpack 同时启动多个打包构建过程,例如:

parallel-webpack --config=webpack-dist.config.js

但是以前无往不利的构建配置似乎出现了异常,最后输出的文件夹只有一个平台的打包代码,这是为什么呢?原因很简单,在构建打包的各个阶段我们使用了不同的插件,其中 CleanWebpackPluginEndWebpackPlugin 造成了破坏性结果,前者会在构建开始前清除构建输出目录,后者会在构建结束阶段允许用户执行脚本。

于是我们的多进程并行打包过程就受影响了,后一个启动的进程把前一个进程的结果给破坏了,最后构建结束阶段做的工作也被重复了多次。

我们可以通过 parallel-webpack 提供的 Node.js API,手动控制打包过程,特别是打包前置操作和打包后置操作,例如:

4.2 代码分包结果

页面

common(KB)

vendor(KB)

page(KB)

all (KB)

%

course(原)

125

142

125

392

100%

pkg(原)

125

142

94

361

100%

course

124

142

110

376

95.9%

pkg

124

142

86

352

97.5%

course.app

79

111

132

322

82.1%

pkg.app

79

111

82

273

75.3%

抽调了 WebApp 两个平台中的两个页面进行分析,其中:

  • 代码压缩率可以达到 4.1% – 24.7%,随着支撑平台数增多,跨平台功能逻辑复杂度的上升,这里的优化效果会越来越明显;
  • 其中 App平台的页面逻辑(page.js)上升,公共逻辑(common.js)下降,其主要原因是因为在该平台仅部分页面开启了多平台打包过程,抽取的公共模块(即大于两个页面共同引用的模块)比较少;
  • Web上的基础依赖(vendor.js)没有下降,其主要原因为基础依赖的模块并为标识为 sideEffects=false缩小Tree-shaking影响的范围,降低本次重构造成的风险,当然如果把这部分模块也开启,可以得到更加明显的优化效果;
  • App上的基础依赖(vendor.js)下降 21.8%,其主要原因是App中对比H5端少了部分功能组件,而这些功能组件依赖的一些基础模块也被 Tree-shaking 消除了;

4.3 Tree-shaking 模块

到底有什么办法可以确认哪些模块会被移除呢?

这是前文留下的一个疑问,先抛出结论:没有一个简单快捷的方式来确认模块到底会不会被Tree-shaking

不过还是有一些实践总结出来的大方向可供参考:

1. 未被引用的模块成员unused harmony export

这个也是官方教程中给的例子,如果这个模块的成员被标志成 unused harmonyexport,就说明该成员没有外部引用使用到该成员,那么是可以将其安全去除的。当然这里还有一种情况就是该成员没有被外部引用,但是被内部调用了,那这种情况也会把export语句和声明语句分离,只将export语句去除。

2. 没有提供导出成员的模块

/*! exports provided: [^/]+ */n/***

通过上图我们还可以得知每个模块在 dev 模式下有两行注解:

  • exports provided
  • exports used

有部分模块是只有暴露的成员,但是没有被引用的成员,这种模块会被直接消除。

// ./src/modules/edu-discount/seckill/index.ts  import * as SeckillTypes from './types';  export { SeckillTypes };

3. 自执行的模块import

部分模块是自执行的,即本身自带副作用的模块,而我们通常会使用 import'xxx'来进行模块引用,而不进行显示的调用。

这种模块有两种处理方式:

  • 1、加入到有副作用的模块声明中,避免 Tree-shaking 将其消除;
  • 2、模块改造,暴露成员支持显式调用;

4. 部分被标注为 harmony export 的模块成员

没错,这个第四个分类你没看错,部分被标注为 harmony export 的模块成员依旧会被消除掉。当然 Tree-shaking 最后是由著名压缩工具 UglifyJS 做的。如果你真的对这里的细节感兴趣,可以看一下 UglifyJS 跟以下压缩选项配置相关的代码:

  • dead_code: remove unreachable code
  • unused: drop unreferenced functions and variables

IMWeb 团队隶属腾讯公司,是国内最专业的前端团队之一。

我们专注前端领域多年,负责过 QQ 资料、QQ 注册、QQ 群等亿级业务。目前聚焦于在线教育领域,精心打磨 腾讯课堂、企鹅辅导 及 ABCMouse 三大产品。