前端性能精进(七)——构建
前端构建是指通过工具自动化地处理那些繁琐、重复而有意义的任务。
这些任务包括语言编译、文件压缩、模块打包、图像优化、单元测试等一切需要对源码进行处理的工作。
在将这类任务交给工具后,开发人员被解放了生产力,得以集中精力去编写代码业务,提高工作效率。
构建工具从早期基于流的 gulp,再到静态模块打包器 webpack,然后到现在炙手可热的 Vite,一直在追求更极致的性能和体验。
构建工具的优化很大一部分其实就是对源码的优化,例如压缩、合并、Tree Shaking、Code Splitting 等。
一、减少尺寸
减少文件尺寸的方法除了使用算法压缩文件之外,还有其他优化方式也可以减小文件尺寸,例如优化编译、打包等。
1)编译
在现代前端业务开发中,对脚本的编译是必不可少的,例如 ES8 语法通过 Babel 编译成 ES5,Sass 语法编译成 CSS 等。
在编译完成后,JavaScript 或 CSS 文件的尺寸可能就会有所增加。
关于脚本文件,若不需要兼容古老的浏览器,那推荐直接使用新语法,不要再编译成 ES5 语法。
例如 ES6 的 Symbol 类型编译成 ES5 语法,如下所示,代码量激增。
let func = () => { let value = Symbol(); return typeof value; }; // 经过 Babel 编译后的代码 function _typeof(obj) { "@babel/helpers - typeof"; return ( (_typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }), _typeof(obj) ); } var func = function func() { var value = Symbol(); return _typeof(value); };
为了增加编译效率,需要将那些不需要编译的目录或文件排除在外。
例如 node_modules 中所依赖的包,配置如下所示。
module.exports = { module: { rules: [ { test: /\.(js|jsx)$/, use: "babel-loader", exclude: /node_modules/ }, ] } };
2)打包
在 webpack 打包生成的 bundle 文件中,除了业务代码和引用的第三方库之外,还会包含管理模块交互的 runtime。
runtime 是一段辅助代码,在模块交互时,能连接它们所需的加载和解析逻辑,下面是通过 webpack 4.34 生成的 runtime。
/******/ (function(modules) { // webpackBootstrap /******/ // The module cache /******/ var installedModules = {}; /******/ /******/ // The require function /******/ function __webpack_require__(moduleId) { /******/ /******/ // Check if module is in cache /******/ if(installedModules[moduleId]) { /******/ return installedModules[moduleId].exports; /******/ } /******/ // Create a new module (and put it into the cache) /******/ var module = installedModules[moduleId] = { /******/ i: moduleId, /******/ l: false, /******/ exports: {} /******/ }; /******/ /******/ // Execute the module function /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); /******/ /******/ // Flag the module as loaded /******/ module.l = true; /******/ /******/ // Return the exports of the module /******/ return module.exports; /******/ } /******/ /******/ /******/ // expose the modules object (__webpack_modules__) /******/ __webpack_require__.m = modules; /******/ /******/ // expose the module cache /******/ __webpack_require__.c = installedModules; /******/ /******/ // define getter function for harmony exports /******/ __webpack_require__.d = function(exports, name, getter) { /******/ if(!__webpack_require__.o(exports, name)) { /******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); /******/ } /******/ }; /******/ /******/ // define __esModule on exports /******/ __webpack_require__.r = function(exports) { /******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { /******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); /******/ } /******/ Object.defineProperty(exports, '__esModule', { value: true }); /******/ }; /******/ /******/ // create a fake namespace object /******/ // mode & 1: value is a module id, require it /******/ // mode & 2: merge all properties of value into the ns /******/ // mode & 4: return value when already ns object /******/ // mode & 8|1: behave like require /******/ __webpack_require__.t = function(value, mode) { /******/ if(mode & 1) value = __webpack_require__(value); /******/ if(mode & 8) return value; /******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; /******/ var ns = Object.create(null); /******/ __webpack_require__.r(ns); /******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); /******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); /******/ return ns; /******/ }; /******/ /******/ // getDefaultExport function for compatibility with non-harmony modules /******/ __webpack_require__.n = function(module) { /******/ var getter = module && module.__esModule ? /******/ function getDefault() { return module['default']; } : /******/ function getModuleExports() { return module; }; /******/ __webpack_require__.d(getter, 'a', getter); /******/ return getter; /******/ }; /******/ /******/ // Object.prototype.hasOwnProperty.call /******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; /******/ /******/ // __webpack_public_path__ /******/ __webpack_require__.p = ""; /******/ /******/ /******/ // Load entry module and return exports /******/ return __webpack_require__(__webpack_require__.s = 0); /******/ }) /************************************************************************/
在代码中定义了一个加载模块的函数:__webpack_require__(),其参数是模块标识符,还为它添加了多个私有属性。
在编写的源码中所使用的 import、define() 或 require() 等模块导入语法,都会被转换成 __webpack_require__() 函数。
也就是说,webpack 自己编写 polyfill 来实现 CommonJS、ESM 等模块语法。
这里推荐另一个模块打包工具:rollup,它默认使用 ESM 模块标准,而非 CommonJS 和 AMD。
所以,rollup 打包出的脚本比较干净(如下所示),适合打包各类库,React、Vue 等项目都是用 rollup 打包。
import { age } from './maths.js'; console.log(age + 1) console.log(1234) // maths.js 文件中的代码 export const name = 'strick' export const age = 30 // 经过 rollup 打包后的代码 const age = 30; console.log(age + 1); console.log(1234);
目前,支持 ES6 语法的浏览器已达到 98.35%,如下图所示,若不需要兼容 IE6~IE10 等古老浏览器的话,rollup 是打包首选。
3)压缩
目前市面上有许多成熟的库可对不同类型的文件进行压缩。
例如压缩 HTML 的 html-minifier,压缩 JavaScript 的 uglify-js,压缩 CSS 的 cssnano,压缩图像的 imagemin。
压缩后的文件会被去除换行和空格,像脚本还会修改变量名,部分流程替换成三目预算,删除注释或打印语句等。
webpack 和 rollup 都支持插件的扩展,在将上述压缩脚本封装到插件中后,就能在构建的过程中对文件进行自动压缩。
以 webpack 的插件为例,已提供了 ImageMinimizerPlugin、OptimizeCssPlugin、UglifyjsPlugin 等压缩插件,生态圈非常丰富。
4)Tree Shaking
Tree Shaking 是一个术语,用于移除 JavaScript 中未被引用的死代码,依赖 ES6 模块语法的静态结构特性。
在执行 Tree Shaking 后,在文件中就不存在冗余的依赖和代码。在下面的示例中,ES 模块可以只导入所需的 func1() 函数。
export function func1() { console.log('strick') } export function func2() { console.log('freedom') } // maths.js 文件中的代码 import { func1 } from './maths.js'; func1(); // 经过 Tree Shaking 后的代码 function func1() { console.log('strick'); } func1();
Tree Shaking 最先在 rollup 中出现,webpack 在 2.0 版本中也引入了此概念。
5)Scope Hoisting
Scope Hoisting 是指作用域提升,具体来说,就是在分析出模块之间的依赖关系后,将那些只被引用了一次的模块合并到一个函数中。
下面是一个简单的示例,action() 函数直接被注入到引用它的模块中。
import action from './maths.js'; const value = action(); // 经过 Scope Hoisting 后的代码 (function() { var action = function() { }; var value = action(); });
注意,由于 Scope Hoisting 依赖静态分析,因此需要使用 ES6 模块语法。
webpack 4 以上的版本可以在 optimization.concatenateModules 中配置 Scope Hoisting 的启用状态。
比起常规的打包,在经过 Scope Hoisting 后,脚本尺寸将变得更小。
二、合并打包
模块打包器最重要的一个功能就是将分散在各个文件中的代码合并到一起,组成一个文件。
1)Code Splitting
在实际开发中,会引用各种第三方库,若将这些库全部合并在一起,那么这个文件很有可能非常庞大,产生性能问题。
常用的优化手段是 Code Splitting,即代码分离,将代码拆成多块,分离到不同的文件中,这些文件既能按需加载,也能被浏览器缓存。
不仅如此,代码分离还能去除重复代码,减少文件体积,优化加载时间。
Vue 内置了一条命令,可以查看每个脚本的尺寸以及内部依赖包的尺寸。
在下图中,vendors.js 的原始尺寸是 3.76M,gzipped 压缩后的尺寸是 442.02KB,比较大的包是 lottie、swiper、moment、lodash 等。
这类比较大的包可以再单独剥离,不用全部聚合在 vendors.js 中。
在 vue.config.js 中,配置 config.optimization.splitChunks(),如下所示,参数含义可参考 SplitChunksPlugin 插件。
config.optimization.splitChunks( { cacheGroups: { vendors: { name: 'chunk-vendors', test: /[\\/]node_modules[\\/]/, priority: -10, chunks: 'initial' }, lottie: { name: 'chunk-lottie', test: /[\\/]node_modules[\\/]lottie-web[\\/]/, chunks: 'all', priority: 3, reuseExistingChunk: true, enforce: true }, swiper: { name: 'chunk-swiper', test: /[\\/]node_modules[\\/]_swiper@3.4.2@swiper[\\/]/, chunks: 'all', priority: 3, reuseExistingChunk: true, enforce: true }, lodash: { name: 'chunk-lodash', test: /[\\/]node_modules[\\/]lodash[\\/]/, chunks: 'all', priority: 3, reuseExistingChunk: true, enforce: true } } } )
在经过一顿初步操作后,原始尺寸降到 2.4M,gzipped 压缩后的尺寸是 308.64KB,比之前少了 100 多 KB,如下图所示。
其实有时候只是使用了开源库的一个小功能,若不复杂,那完全可以自己用代码实现,这样就不必依赖那个大包了。
例如常用的 lodash 或 underscore,都是些短小而实用的工具方法,只要单独提取并修改成相应的代码(参考此处),就能避免将整个库引入。
2)资源内联
资源内联会让文件尺寸变大,但是会减少网络通信。
像移动端屏幕适配脚本,就比较适合内联到 HTML 中,因为这类脚本要最先运行,以免影响后面样式的计算。
若是通过域名请求,当请求失败时,整个移动端页面的布局将是错位的。
webpack 的 InlineSourcePlugin 就提供了 JavaScript 和 CSS 的内联功能。
将小图像转换成 Data URI 格式,也是内联的一种应用,同样也是减少通信次数,但文件是肯定会大一点。
还有一种内联是为资源增加破缓存的随机参数,以免读取到旧内容。
随机参数既可以包含在文件名中,也可以包含在 URL 地址中,如下所示。
<script src="/js/chunk-vendors.e35b590f.js"></script>
在 webpack.config.js 中,有个 output 字段,用于配置输出的信息。
它的 filename 属性可声明输出的文件名,可以配置成唯一标识符,如下所示。
module.exports = { output: { filename: "[name].[hash].bundle.js" } };
总结
在构建之前,也可以做一些前置优化。
例如对浏览器兼容性要求不高的场景,可以将编译脚本选择 ES6 语法,用 rollup 打包。
还可以将一些库中的简单功能单独实现,以免引入整个库。这部分优化后,打包出来的尺寸肯定会比原先小。
在构建的过程中,可以对文件进行压缩、Tree Shaking 和 Scope Hoisting,以此来减小文件尺寸。
在合并时,可以将那些第三方库提取到一起,组成一个单独的文件,这些文件既能按需加载,也能被浏览器缓存。
资源内联是另一种优化手段,虽然文件尺寸会变大,但是能得到通信次数变少,读取的文件是最新内容等收益。