前端性能精进——构建

Posted 漫思

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了前端性能精进——构建相关的知识,希望对你有一定的参考价值。

前端性能精进(七)——构建

  前端构建是指通过工具自动化地处理那些繁琐、重复而有意义的任务。

  这些任务包括语言编译、文件压缩、模块打包、图像优化、单元测试等一切需要对源码进行处理的工作。

  在将这类任务交给工具后,开发人员被解放了生产力,得以集中精力去编写代码业务,提高工作效率。

  构建工具从早期基于流的 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 的插件为例,已提供了 ImageMinimizerPluginOptimizeCssPluginUglifyjsPlugin 等压缩插件,生态圈非常丰富。

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,以此来减小文件尺寸。

  在合并时,可以将那些第三方库提取到一起,组成一个单独的文件,这些文件既能按需加载,也能被浏览器缓存。

  资源内联是另一种优化手段,虽然文件尺寸会变大,但是能得到通信次数变少,读取的文件是最新内容等收益。

 

前端构建WebPack实例与前端性能优化

计划把微信的文章也搬一份上来。

这篇主要介绍一下我在玩Webpack过程中的心得。通过实例介绍WebPack的安装,插件使用及加载策略。感受构建工具给前端优化工作带来的便利。

壹 | Fisrt

技术分享

曾几何时,我们是如上图的方式引入JS资源的,相信现在很少遇见了。近年来Web前端开发领域朝着规范开发的方向演进。体现在以下两点:

  1. MVC研发构架。多多益处(逻辑清晰,程序注重数据与表现分离,可读性强,利于规避和排查问题...)

  2. 构建工具层出不穷。多多益处(提升团队协作,以及工程运维,避免人工处理琐碎而重复的工作)

    • 模块化开发
    • 将前端性能优化理论落地,代码压缩,合并,缓存控制,提取公共代码等
    • 其他的还包括比如你可以用ES 6 或CoffeeScript写源码,然后构建出浏览器支持的ES5

所以,前端这么好玩,如果还有项目没有前后端分离的话,真的是守旧过头了。

 

主流构建工具

市面上有许多构建工具,包括Grunt、Gulp、browserify等,这些和WebPack都是打包工具。但WebPack同时也具备以下特点:

  1. 相比Grunt,WebPack除了具备丰富的插件外,同时带有一套加载(Loader)系统。使它支持多种规范的加载方式,包括ES6、CommonJS、AMD等方式,这是Grunt、Gulp所不具备的。

  2. 从代码混淆的角度来看,WebPack更加的极致

  3. 代码分片为处理单元(而不是文件),使得文件的分片更为灵活。

P.S.此处只做简单的比较,不论孰优孰劣。其实工具都能满足需求,关键是看怎么用,工具的使用背后是对前端性能优化的理解程度。

 

貳 | Second 

WebPack安装与使用

WebPack运行在 NodeJS之下,并且它及其插件都是使用NPM(NodeJS的包管理工具)管理。

  1. 安装Node及NPM。到NodeJS官网安装包,安装即可

  2. 全局安装WebPack。联网情况下,执行命令行 $npm install webpack -g 即可。 

此至即可使用WebPack了,到WebPack官网去按着Get start(http://webpack.github.io/docs/tutorials/getting-started/)的步骤来,感受一个最简单的构建过程。 

然而要把WebPack用好,只是跑起来是远远不够的。

 

叁 | Third 

WebPack插件

花较大篇幅介绍插件的使用,以下通过在一个DemoApp的构建过程中思考的一些问题(这些问题基本会在每个项目中遇到),让这些插件逐一登场。

一、文件过大

DemoApp最初的构建结果如下:

技术分享

这里生成了一个topic.xxx.js,这个文件本来很小,估计只有10Kb左右,但构建的结果居然快1Mb了。在3G网络(750Kb/s)下的加载瀑布流如下图:

技术分享

这张图(体现前端文件加载过程)曝露了几个问题:

  1. 上面箭头所指的很蓝色柱子,说明了大部分时间消耗在加载topic.xxxx.js上。

  2. 所有JS文件相关的柱子是一根结束了另一根才开始,说明不合理的文件合并策略,导致文件串行加载。

  3. 结果就是如底部的箭头所看到的,页面的加载时间太长了(3G网络,10+秒)。

观察构建的文件,发现原来构建工具把React、jQuery等代码库合并到了topic.xxxx.js,造成此文件过大。如果再加一个activity模块呢?很明显,activity.xxx.js得到类似的结果,都太大了,并且React、jQuery等代码库重复被合并到activity和topic里,如下图。如果再加模块也会得到同样的结果,模块越多重复加载的情况越严重

技术分享

可见,提取公共代码,情况可以得到改善,另外,压缩代码无疑是可以使文件变小的。

1. 提取React、jQuery等库文件。它们很少变化,并且到处被复用,应该被提取出来,并且得到长时间的缓存。

此处使用插件:WebPack.optimize.CommonsChunkPlugin(WebPack内建插件)

技术分享

 

 2. 代码压缩。React由700+ Kb压缩成100+ Kb

此处使用插件:WebPack.optimize.UglifyJsPlugin (WebPack内建插件)

技术分享

处理后topic.xxx.js和activity.xxx.js都很小了,并且多了jquery.xxx.js和react.xxx.js

技术分享

 

再看看文件加载的瀑布流,柱子所占比例短了,同时资源并行加载。

技术分享

到此为止,这个问题算比较好地解决了,但还不够,随着项目越来越大,还有一个重要因素会导致文件很大。这部分内容放到本文的最后介绍。

 

二、如何缓存

缓存控制要做到两件事情,提到缓存命中率

  1. 对于没有修改的文件,从缓存中获取文件

  2. 对于已经修改的文件,不要从缓存中获取

围绕这两点,演绎出了很多方案,此处列两种:

  • 不处理,等待用户浏览器缓存过期,自动更新。这是最偷懒的,命中率低一些,同时可能会出现部分文件没有更新,导致报错的情况。

  • Http头对文件设置很大的max-age,例如1年。同时,给每个文件命名上带上该文件的版本号,例如把文件的hash值做为版本号,topic. ef8bed6c.js。即是让文件很长时间不过期。

      • 当文件没有更新时,使用缓存的文件自然不会出错;

      • 当文件已经有更新时,其hash值必然改变,此时文件名变了,自然不存在此文件的缓存,于是浏览器会去加载最新的文件。

从上面的截图可以看出来,通过WebPack是可以很轻松做到第二点的——只需要给文件名配置上[chunkhash:8]即可,其中8是指hash长度为8,默认是16。

技术分享

P.S.这样的处理效果已经很好了,但同样有劣处,即浏览器给这种缓存方式的缓存容量太少了,只有12Mb,且不分Host。所以更极致的做法是以文件名为Key,文件内容为value,缓存在localStorage里,命中则从缓存中取,不命中则去服务器取,虽然缓存容量也只有5Mb,但是每个Host是独享这5Mb的。

 

三、自动生成页面

文件名带上版本号后,每一次文件变化,都需要Html文件里手动修改引用的文件名,这种重复工作很琐碎且容易出错。

技术分享

使用 HtmlWebpackPlugin 和 ExtractTextPlugin 插件可以解决此问题。

  • 生成带JS的页面

  • 技术分享

  • 生成带css的页面

  new ExtractTextPlugin("comm.[contenthash:9].css")

  插件介绍到此为止,然而,还有一个关于同步加载和异步加载的问题,否则入口文件还是会很臃肿。

 

肆 | Fourth

关于同步加载和异步加载

使用WebPack打包,最爽的事情莫过于可以像服务器编程那样直接require文件,看起来是同步地从服务器上取得文件直接就使用了。如下面的代码一样,没有任何异步逻辑,代码很干净。

 技术分享

然而,这种爽是有代价的,对于直接require模块,WebPack的做法是把依赖的文件都打包在一起,造成文件很臃肿。

所以在写代码的时候要注意适度同步加载,同步的代码会被合成并且打包在一起;异步加载的代码会被分片成一个个chunk,在需要该模块时再加载,即按需加载,这个度是要开发者自己把握的,同步加载过多代码会造成文件过大影响加载速度,异步过多则文件太碎,造成过多的Http请求,同样影响加载速度。

  • 同步加载的写法,如:

     var TopicItem = require(‘../topic/topicitem‘);

  • 异步加载的写法,如:

技术分享

一个原则是:首屏需要的同步加载,首屏过后才需要的则按需加载(异步)

 

结语

以上是WebPack构建工具比较好的实践,可见,要用好还是很考验前端性能优化的功力的,比较什么时候同步,什么时候异步,如果做缓存等等。

技术分享

如果觉得文章有用,顺手点击下方的推荐 

 

以上是关于前端性能精进——构建的主要内容,如果未能解决你的问题,请参考以下文章

前端构建WebPack实例与前端性能优化

《Go语言精进之路》读书笔记 | 理解Go语言的包导入

性能优化webpack前端构建性能优化策略小结

深度优化 Webpack 性能,翻倍构建性能

webpack 前端构建性能优化策略小结

前端性能优化成神之路—前端页面渲染流程