决战性能之巅 - Taro H5 转换与优化升级

前言

做为一个多端开发框架,Taro 从项目发起时就已经支持编译到 H5 端。随着 Taro 多端能力的不断成熟,咱们对 Taro H5 端应用的要求也不断提高。咱们已经再也不知足于“能跑”,更但愿 Taro 能跑得快。javascript

咱们常常收到用户反馈:为何使用 Taro 脚手架建立的空项目,打包后代码体积却有 400KB+;也有用户在 Issue 中提到,Taro 的部分 Api 占用空间巨大,事实上功能却并不完美,等等。做为一个开源项目,咱们很是重视社区开发者们的意见。因此在最新版本中,咱们对 Taro H5 端的性能表现进行了优化。java

做为运行时的基础,每个 Taro 的 H5 端应用都须要引入 @tarojs/components 和 @tarojs/taro-h5 等基础依赖包。在编译、打包以后,这些依赖包大约会在首屏占用 400KB 以上的空间。若是开发者还使用了 UI 库,例如 Taro-UI,基础体积还会更大,这严重限制了 Taro H5 端应用的性能优化空间。webpack

事实上,咱们在 H5 端应用中并不会使用到所有的 Taro 组件和 Api。将这些依赖包所有打包进应用是没有必要,也是不合理的。进行死码删除(Dead code elimination),进一步缩减代码体积,就是咱们的优化方向之一。git

效果

在介绍具体细节以前,咱们先看一看优化的效果,由于这可能会让你更有兴趣了解后面的内容。用一句话来讲,效果很是显著。github

咱们创建了一个空项目,在项目配置中加入了webpack-bundle-analyzer插件以查看编译分析。下图是优化前的打包文件分析结果:web

before

而在优化后,对比很是明显:npm

after

优化前生成的代码总大小为 455KB,而在优化后仅剩约 96KB,仅是原来的 1/5 左右。编程

你须要作什么

很简单,做为使用者,你不须要作任何代码上的改动,只须要将 Taro 更新到最新版本便可。但在看不见的地方,Taro 却默默地作了许多工做。下面会从原理出发,详细介绍 Taro 的工做。json

原理

死码删除(Dead code elimination)是一种代码优化技术,能够删除对应用程序执行结果没有影响的代码。Web Fundamentals 的一篇文章有提到,treeshaking 是由 Rollup 提出的一种死码删除的形式。api

Tree shaking is a form of dead code elimination. The term was popularized by Rollup, but the concept of dead code elimination has existed for some time.

-- Reduce JavaScript Payloads with Tree Shaking, Jeremy Wagner

经过在构建时进行静态分析,编译工具能够分析出咱们代码中真正的依赖关系。treeshaking 把咱们的代码想象成一棵树,代码的每一个依赖项看做树上的节点。将未使用过的依赖项从构建结果中移除,这就是 treeshaking 的基本思想。

那么,假设咱们手头有一段代码,咱们要怎样辨别其中能够删除的部分呢?答案是,经过分析反作用:

// add.js
export default function adda, b{ return a + b; }

// add2.js
console.log('这是一个log')
export default function add2a, b{ return a + b; }

// index.js
import add from './add.js' // 没有反作用,能够删除
import add2 from './add2.js' // 有反作用,不能直接删除

// EOF
复制代码

反作用这个名词对于了解函数式编程的同窗确定不陌生。修改外部状态,或者是产生输出等等,都是反作用;而存在反作用的代码,是不能被直接移除的。相似上面这个代码示意,add2 模块就是存在反作用的。

站在巨人的肩膀上

除了 Rollup 以外,支持 treeshaking 的工具/插件还有不少,好比 babel-plugin-transform-dead-code-elimination、uglify、terser等。 webpack 在 v2 以后就内置了对 treeshaking 的支持,并在 webpack@4 中对 treeshaking 功能进行了扩展。

Taro H5 端在构建过程当中,使用 webpack 做为构建的核心。在 webpack 中使用 treeshaking 功能有几个须要注意的地方:

  1. 若是是 npm 模块,须要package.json中存在sideEffects字段,而且准确配置了存在反作用的源代码。
  2. 必须使用 ES6 模块语法。因为诸如babel-preset-env之类的 babel 预配置包默认会对代码的模块机制进行改写,还须要将modules设置为false,将模块解析的工做直接交给 webpack。
  3. 须要工做在 webpack 的production模式下。

webpack 的 treeshaking 工做主要分为两步。第一步是在模块级别移除未使用且无反作用的模块,这一步由 webpack 的内置插件完成;第二步是在文件级别移除未使用的代码,这一步由代码压缩工具 Terser 完成的。

移除未使用的模块

前面咱们提到,须要在package.json中配置sideEffects字段。

webpack 文档 中有提到,这一举动正是为了让 webpack 正确地识别到没有反作用的代码模块。

在 webpack 中,模块依赖分析是由内置插件 SideEffectsFlagPlugin 进行的。

image-20190225220418363

通过 SideEffectsFlagPlugin处理后,没有使用过而且没有反作用的模块都会被打上sideEffectFree标记。

ModuleConcatenationPlugin 中,带着sideEffectFree标记的模块将不会被打包:

image-20190222111301698

来到这里,webpack 完成了在模块级别对未使用模块的排除。接下来,依靠 Terser,webpack 能够在文件级别,对未使用、无反作用的代码进行移除。

移除未使用的代码

在 CommonJS 规范中,咱们经过require函数来引入模块,经过module.exports进行导出。这意味着咱们能够在代码中的任何地方用任何方式引入和导出模块:能够是在某个须要等待用户输入的回调函数中,或者是在符合某个条件才进行引入等等。因此在使用 ES6 的模块系统以前,对 Javascript 作编译时的依赖关系分析是近乎不可能的(并非彻底不可能。prepack 经过实现一个 JS 解释器,甚至能够在编译时提早进行静态计算)。

// utils.js
module.exports.add = function (a, b) { return a + b };
module.exports.minus = function (a, b) { return a - b };

// index.js;
var utils = require('./utils.js');

utils.add(1, 2);
复制代码

像上面这段代码,虽然咱们最终只使用了add函数,但minus函数也会在最终的打包代码中出现,由于在编译时没法快速得知是否使用了minus函数。

在 ES6 的模块系统中,咱们使用import/export语法来进行模块的引入和导出。与 CommonJS 规范不一样的是,这套新的模块系统存在一些限制:import/export行为只能在代码的顶层、默认使用严格模式等等。这些限制使代码模块的导入与导出变得静态化,模块间的依赖关系在开发时已经肯定,编译器也更容易解析咱们的代码。

// utils.js
export function add (a, b) { return a + b };
export function minus (a, b) { return a - b };

// index.js;
import { add } from './utils.js';
add(1, 2);
复制代码

在使用 ES6 模块系统改造后,咱们能够清楚地看到,minus函数确实没有被使用过,因此能够安全地将其从最终打包代码中移除。

固然,具体的分析过程很是复杂。变量提高、object 取值操做、for(var i in list) 语句、自执行函数、函数传参(onClick(function a () {…}))等等,都有可能致使意料以外的状况,从而致使 treeshaking 失效。若是想了解 Terser 的具体处理过程,百度/Google 会是最好的老师。

Taro 作了什么

Taro 须要对依赖包作一些修改。

组件的 ES 模块化

在进行组件库的 ES 模块化改造以前,若是要发布 @tarojs/components 包,Taro 会执行命令 yarn build,使用 webpack 对源代码进行打包,输出为dist/index.js文件。因为 webpack 并不支持输出为 ES 模块,因此这是个 UMD 模块。

image-20190225154632128

这个文件占据了 462KB 的空间,而且因为模块规范等问题,没法进行 treeshaking。因此就算开发者在 Taro 的项目中只引入了两个组件,最终的打包结果也会包含全部的内置组件。

事实上,@tarojs/components 的源码自己是使用 ESM 规范的:

image-20190225160508956

因此只要让 webpack 直接解析组件库的源码,咱们当即就能够享受到 webpack 自带 treeshaking 带来的好处了。

image-20190225162018328

同时,咱们也在sideEffects属性中对样式文件作了标记,帮助 webpack 对样式代码的反作用进行识别,在项目编译的代码中保留样式代码。

Api 的 ES 模块化

一样,之前在发布 @tarojs/taro-h5 以前,Taro 也须要执行命令 yarn build,使用 Rollup 对源代码进行打包,输出为dist/index.js文件:

image-20190225162654885

这个文件占据了 262KB 的空间。一样,只要是个 Taro 的 H5 端应用,生成的代码都会全量引入这个文件。

咱们对 @tarojs/taro-h5 进行模块化改造的思路与 @tarojs/components 相同。咱们但愿 @tarojs/taro-h5 模块自己遵照 ESM 模块规范,那就只须要标记一下sideEffects,再修改一下模块入口就好。

image-20190225165957461

粗略一看,@tarojs/taro-h5 还挺 “ESM” 的,但这还不够。咱们还须要将这些 Api 以 namedExports 的形式导出,开发者使用import { XXX } from '@tarojs/taro-h5'导入 Api 便可。

image-20190225172609369

那么问题来了。在 Taro 项目中,咱们一直使用的是 defaultImport,并不会使用 Api 的 namedExports 形式:

import Taro from '@tarojs/taro-h5'

Taro.navigateTo()
Taro.getSystemInfo()
// Taro.xxx ...
复制代码

只要 Api 是经过对Taro变量取属性获取,Taro变量就须要具有全部的 Api,treeshaking 也就无从谈起。

有没有办法把 defaultImport 修改成 namedImports 呢?答案是确定的。咱们写了一个 babel 插件 babel-plugin-transform-taroapi,将指定的 Api 调用替换为 namedImports,未指定的变量则保留属性取值的形式。具体源码能够在__这里__查看。

// const apis = new Set(['navigateTo', 'navigateBack', ...])
{
  babel: {
    preset: ['babel-preset-env'],
    plugins: [
      // ...,
      ['babel-plugin-transform-taroapi', {
        packageName: '@tarojs/taro-h5',
        apis
      }]
    ]
  }
}
复制代码

这个插件接受一个对象做为配置参数:packageName属性指定须要进行替换的模块名,apis接受一个 Set 对象,也就是全部 Api 的列表。

为了不后期手动维护 Api 列表的状况,咱们给 @tarojs/taro-h5 模块加了一个编译任务,经过一个简单的Rollup 插件,在执行yarn build命令时生成一份 Api 列表:

image-20190225210238592

下面是编译先后的代码对比。能够看到,在编译后setStoragegetStorage的调用都被替换为 namedImports。

// 编译前
import Taro from '@tarojs/taro-h5';
Taro.initPxTransform({});
Taro.setStorage()
Taro['getStorage']()

// 编译后
import Taro, { setStorage as _setStorage, getStorage as _getStorage } from '@tarojs/taro-h5';
Taro.initPxTransform({});
_setStorage();
_getStorage();
复制代码

到这里,虽然过程比较艰辛,但咱们对 @tarojs/taro-h5 的模块化改造终于完成了。

最后

截至目前,Taro 在 H5 端的完成度已经很高,可是并不完美。将来,在对已有问题进行修复的同时,咱们还将继续为 Taro H5 端带来更多新的特性,好比对社区中呼声至关高的switchTab、页面滚动监听onPageScroll、下拉刷新onPullDownRefresh等 Api 的支持,更加统一的页面切换动画,以及更加稳定的多页面模式等等。

Taro 发展到如今,离不开社区的支持。很是感谢在 github、微信群中踊跃反馈的开发者们。若是你对Taro有什么想法或建议,Taro 很是欢迎你来吐槽或观光:

https://github.com/NervJS/taro

相关文章
相关标签/搜索