将webpack打包优化到极致_20180619

背景:

项目上线前是专门针对 webpack 打包作了优化的,可是在以后作网络优化的时候经过webpack-bundle-analyzer这个插件发现一些公共的js文件重复打包进了业务代码的js中。这些代码体积虽然很小,可是为了将优化作到极致仍是想要将其优化一下。这个过程最大的收获就是让本身对 webpack4.x 相关配置项更加的熟悉,可以使用 webpack 游刃有余的实现本身想要的打包方式。javascript

记得以前的一位前辈同事说过一句前端优化的话:前端优化就是权衡各类利弊后作的一种妥协。css

优化结果:

这里先看下优化结果,由于项目是多入口打包的模式(项目脚手架点击这里)每个页面一个连接且每个页面都会有本身的一个js文件。html

结果以下:前端

  • js代码体积减小: 20kb+
  • 网络链接时长缩短: 500ms+

移动端项目可以在原有的基础上减小20kb已经不小了,并且这20kb是属于重复打包产生的。网络优化方面经过咱们公司内部监控平台看到的数据也很是明显,项目上线后统计3天内平均数据,页面加载时间节省了接近800ms(上面的500ms是一个保守的写法,由于会存在网络抖动等不可抗因素)。vue

优化前的数据统计 java

优化后的数据统计 node

优化网络解析时长和执行时长

一、添加DNS预解析

在 html 的 head 标签中经过 meta 标签指定开启DNS 预解析和添加预解析的域名,实例代码以下:webpack

<!--告诉浏览器开启DNS 预解析-->
    <meta http-equiv="x-dns-prefetch-control" content="on" />
    <!--添加须要预解析的域名-->
    <link rel="dns-prefetch" href="//tracker.didiglobal.com">
    <link rel="dns-prefetch" href="//omgup.didiglobal.com">
    <link rel="dns-prefetch" href="//static.didiglobal.com">
复制代码

先来看下添加上面的代码以前,页面中静态资源的解析时间css3

添加了DNS 预解析的代码以后与上面的图片比较以后能够明显发现tracker.didiglobal.com 这个域名在须要加载的时候已经提早完成了预解析。若是这个js文件是影响页面渲染的(好比按需加载的页面),能够提升页面的渲染性能。git

二、延时执行影响页面渲染的代码

平时移动端开发过程当中咱们都会引用一些第三方的js依赖,好比说调用客户端 jsbridge 方法的 client.js和接入打点服务的console.log.js

一般的方法也是比较暴力的方法就是将这些依赖的第三方js加入到 head 标签中,在 dom 解析到 head 标签的时候提早下载这些js而且在以后须要的时候可以随时的调用。可是这些放在head 标签中的js下载时间和执行时间无疑会影响页面的渲染时间。

下面的那张图是咱们的项目优化前的一个现状,浅绿色的竖线是开始渲染的时间。从下面的图种能够发现咱们引用客户端方法的 fusion.js 和打点的omega.js (这两个js都是放在head标签中的)都影响了页面的渲染的开始时间。

其实咱们的业务场景在页面渲染出来以前是不须要这些js的执行结果的,那咱们为何不能将这些js作成异步加载呢?

js的异步加载就是选择合适的时机,使用动态建立 script标签的方式,将须要的js加载到页面中来。咱们的项目使用的是 vue ,咱们选择在 vue 的生命周期 mounted 中将须要打点的js给加载进来,在咱们须要调用客户端方法的地方去加载 fusion.js 经过异步加载库回调的方式,当js加载完以后执行用户的操做。

下面是一段简单的示例代码:

export default function executeOmegaFn(fn) {
    // 动态加载js到当前的页面中来,而且当js加载完以后执行回调,注意须要判断js是否已经在当前环境加载过了
    scriptLoader('//xxx.xxxx.com/static/tracker_global/2.2.2/xxx.min.js', function () {
        fn && fn();
    });
}

// 异步加载 须要加载的js
mounted() {
    executeOmegaFn();
},
复制代码

下图是修改以后的效果,能够发现,页面开始渲染的时间提早了,页面渲染完成的时间也比上面图种的渲染完成的时间提早了 2s 左右。能够很明显的看出页面的渲染和下载执行 omega 的时间是互不影响的。

总结:

  1. 在html模板文件中添加域名 DNS 预解析代码块,使浏览器提早预解析须要加载的静态文件的域名。当须要下载静态资源时,加快静态文件的下载速度。
  2. 经过将首屏渲染不须要的js文件延时加载和执行,将页面开始渲染的时间提早,以提升首屏渲染速度。

优化webpack产出

一、优化代码重复打包

默认的 webpack4.x 在 production 模式下会对代码作 tree shaking。可是看完这篇文章以后会发现大多数状况下,tree shaking 并无办法去除重复的代码。 你的Tree-Shaking并没什么卵用

在个人项目中有个lib目录下面放着根据业务须要本身编写的函数库,经过 webpack-bundle-analyzer 发现它重复的打包进了咱们的业务代码的js文件中。下面的图是打包后业务代码包含的js文件,能够看到 lib 目录下的内容重复打包了

看到这种状况的时候就来谈谈本身的优化方案:

  • 将node_modules目录下的依赖统一打包成一个vendor依赖;
  • 将lib和common目录下的本身编写的函数库单独打包成一个 common ;
  • 将依赖的第三方组件库按需打包,若是使用了组件库中体积比较大的组件,好比 cube-ui 中的 date-picer 和 scroll 组件。若是只使用一次就打包进入本身引用页面的js文件中,若是被多个页面引用就打包进入 common 中。后面优化第三方依赖会详细的介绍下这部分的优化和打包策略。

先来看下个人 webpack 关于拆包的配置文件,具体看注释:

splitChunks: {
    chunks: 'all',
    automaticNameDelimiter: '.',
    name: undefined,
    cacheGroups: {
        default: false,
        vendors: false,
        common: {
            test: function (module, chunks) {
                // 这里经过配置规则只将 common lib cube-ui 和cube-ui 组件scroll依赖的better-scroll打包进入common中
                if (/src\/common\//.test(module.context) ||
                    /src\/lib/.test(module.context) ||
                    /cube-ui/.test(module.context) ||
                    /better-scroll/.test(module.context)) {
                    return true;
                }
            },
            chunks: 'all',
            name: 'common',
            // 这里的minchunks 很是重要,控制cube-ui使用的组件被超过几个chunk引用以后才打包进入该common中不然不打包进该js中
            minChunks: 2,
            priority: 20
        },
        vendor: {
            chunks: 'all',
            test: (module, chunks) => {
                // 将node_modules 目录下的依赖统一打包进入vendor中
                if (/node_modules/.test(module.context)) {
                    return true;
                }
            },
            name: 'vendor',
            minChunks: 2,
            // 配置chunk的打包优先级,这里的数值决定了node_modules下的 cube-ui 不会打包进入 vendor 中
            priority: 10,
            enforce: true
        }
    }
}
复制代码

在项目中写了一个 js 将整个项目须要使用的 cube-ui 组件统一引入。具体的代码看这里调用方法看这里

这里须要注意下,就是不要将使用频率低的体积大的组件在这个文件中引入,具体的缘由能够看下面代码的注释。

/** * @file cube ui 组件引入统一配置文件 建议这里只引入每一个页面使用的基础组件,对于复杂的组件好比scroll datepicer组件 * 在页面中单独引入,而后在webpack中同过 minChunk 来指定当这些比较大的组件超过 x 引用数时才打进common中不然单独打包进页面的js中 * @date 2019/04/02 * @author hpuhouzhiqiang@gmail.com */

/* eslint-disable */
import Vue from 'vue';
import {
    Style,
    Toast,
    Loading,
    // 这里去除 scroll是在页面中单独引入,以使webpack打包时能够根据引用chunk选择是否将该组件打包进入页面的js中仍是选择打包进入common中
    // Scroll,
    createAPI
} from 'cube-ui';


export default function initCubeComponent() {
    Vue.use(Loading);
    // Vue.use(Scroll);
    createAPI(Vue, Toast, ['timeout'], true);
}

复制代码

项目中目前只有pay_history这个页面使用了 cube-ui 的 scroll 组件,单独打包进业务代码的js中因此该页面的js较大。

当有超过两个页面使用了 scroll 这个组件的时候,根据 webpack 的配置会自动打包进入common中。下图是打包结果,页面的js大小缩小了,commonjs文件的体积变大了。

总结:

  • 优化对于第三方依赖组件的加载方式,减小没必要要的加载和执行时间的损耗。

二、去掉没必要要的import

有时候我在写代码的时候没有注意,经过 import 引用了一个第三方依赖可是最后没有使用它或者是上线的时候并不须要将执行的表达式注释掉,而没有注释掉 import 语句 ,打包结果也会包含这个import的js的。好比如下代码:

import VConsole from 'vconsole';
// 测试的时候咱们可能打开了下面的注释,可是在上线的时候只是注释了下面的代码,webpack打包的时候仍然会将vconsole打包进目标js中。
// var vConsole = new VConsole();

复制代码

总结:

  • 肯定无效的 import 语句,若是没有使用 import 导入的函数或者是表达式就直接将 import 语句注释掉或者是删除掉。

三、babel-preset-env 和 autoprefix 配置优化

目前使用 babel + ES6 组合编写前端代码已经不多使用 babel-polyfill 了。 主要是它会污染全局变量,并且是全量引入 polyfill ,打包后的目标js文件会很是的大。

如今大多数状况下都会使用 babel-preset-env 来作 polyfill。更智能或是高级的作法是使用在线的polyfill服务,参考连接

在使用 preset-env 的时候,大多数状况都会忽略去配置兼容的 browsers 列表。或者直接从网络上搜索到配置,不深究其产出结果直接复制、粘贴使用。其实这里的配置会影响咱们的js文件的大小。

若是使用 autoprefix 给css自动添加厂商前缀时,也是须要配置一个 browsers 列表。这个配置列表也是会影响css文件大小的。 browserslist官方文档

举个例子,如今 windows phone 手机几乎绝迹,对于如今的移动端项目是不须要考虑兼容 pc 和 wp手机的,那咱们在添加 polyfill 或是 css 厂商前缀时是否是能够去掉 -ms- 前缀呢,那么该怎么配置呢?

个人配置以下:

"browsers": [
    "> 1%",
    "last 2 versions",
    "iOS >= 6.0",
    "not ie > 0",
    "not ie_mob > 0",
    "not dead"
]
复制代码

这里简单提一下,正确使用css新特性的重要性。下面这段代码是我在咱们的一个比较旧的项目中看到的。其实咋一看没有什么问题,可是在现代浏览器中浏览却出现了问题?

.example {
    display: flex;
    display: -webkit-box;
}

.test {
   flex:1
}

复制代码

这总写法就是对 flex 布局 解析不一致致使的问题。 在chrome 中 .example生效的是 display: -webkit-box 这个弹性盒布局过渡期的写法。 在 .test 中生效的是flex:1 而这个是新标准的写法。致使布局显示出现问题。

autoprefix以后的代码

.example {
    display: -ms-flexbox;
    display: flex;
    display: -webkit-box;
}

.test {
   -webkit-box-flex:1;
       -ms-flex:1;
           flex:1
}

复制代码

一样的也会致使上面没有 autoprefix 以前的问题,布局发生错误。

总结:

  1. 根据本身的业务场景,添加具体的polyfill配置。
  2. 若是使用了css3 的新特性,且使用了 autoprefix 作自动添加厂商前缀的处理,只须要在原始代码中使用最新标准写法就好了。

四、webpack runtime文件inline

使用webpack编译代码,当代码生成了多个chunk时,webpack是怎么加载这些chunk呢?

webpack 本身实现了一个模块加载器来维护不一样模块间的关系(webpack 文章中称它为 runtime 模块)。标识不一样模块是经过一串数字来作标识的(具体的能够写个简单的demo来看下编译结果)。

当修改了一个文件的代码,在 runtime 模块中这串数字会发生变化,若是在webpack 打包时对这部分代码不作处理,它会默认的产出到咱们的 vendor 代码中。致使只要修改代码,生成的 vendor 文件的hash就会发生变化,没办法充分利用浏览器缓存。

webpack 已经提供了配置能够将这部分代码单独抽离出来生成一个文件。由于这部分代码常常发生变化,并且代码体积很小,为了减小 http 请求能够在打包的时候选择将这部分代码内联进html模板文件中。

在webpack4.x中能够经过如下配置实现optimization.runtimeChunk: 'single'。若是想要将生产的runtime代码内联进入html,可使用这个webpack插件inline-manifest-webpack-plugin

五、 去除没必要要的async语句

async和await语法糖可以很好的解决异步编程问题。在编写前端代码的过程当中也可使用该语法糖。无论是使用 babel 和 typescript 编译代码其实都是将 async和 await 编译成了 generator。

若是对代码体积有极致的需求,我是不太建议在前端代码中使用 async 和await的。由于如今不少第三方依赖处理异步的方式都是使用 Promise ,咱们使用的 node_modules依赖通常也都是编译后的 ES5 的源文件,都是对 Promise 作了 Polyfill 的。并且咱们本身的 babel 配置也会对 Promise 作 Polyfill, 若是混合使用 async 和 await ,babel又会增长相关 generator run time 代码。

看一个真实的代码案例:如下代码中出现了一个 async 表达式,可是在任何调用这个方法地方的时候都没有使用await ,经过阅读代码也肯定这里不须要使用 async 表达式

添加了一个async表达式后,编译结果以下图。能够发如今产出的目标文件中多了一个generaotr runtime 的代码,并且这部分代码的体积仍是比较大的

这是编译前的文件大小

去掉这个没必要要的 async 表达式后,下图能够看到编译后的文件大小,代码体积缩小了将近 3KB

六、优化第三方依赖

在第一小节中已经简单了介绍了优化第三方依赖的打包方法了,这里再作下总结:

  • 若是第三方依赖支持后编译,使用后编译,且按需加载的方式使用第三方依赖,不要将组件库全量引入;
  • 若是第三方依赖某个组件体积较大,且在项目中使用次数较少,页面又是按需加载,能够选择配置规则,当引用次数超过多少次以后才将该组件打包进入公共的 common 中,不然将该组件直接打包进入业务代码中;
  • 经过script标签和连接引入第三方依赖时,这些连接尽可能不要写入 head 标签中,能够选择按需加载引入;

后编译,就是在使用的时候编译,能够解决代码重复打包的问题。按需引入是指,假如我使用的cube-ui有20个组件可是我只使用了其中的一个 alert 组件,避免所有引入,增长产出文件的体积;

七、lodash按需引入

lodash这个库确实挺好用的,但它有个缺点,全量引入打包后体积较大。那么lodash能不能按需引入呢?

固然是能够的,能够在 npm上搜索 lodash-es这个模块,而后根据文档执行命令能够将 lodash 导出为 es6 modules。 而后就能够经过 import 方式单独导入某个函数的方式使用。

其实lodash到底怎么优化,有没有必要优化,这个也是有一些争议的,具体的能够阅读下百度高T灰大的这篇文章 lodash在webpack中的各项优化的尝试。灰大的这篇文章也论证了文章开头所说的,优化就是根据业务需求作了各类权衡后的一种妥协。

webpack 重要知识总结

一、hash、contenthash、chunkhash的区别

hash 是跟整个项目的构建相关,只要项目里有文件更改,整个项目构建的hash值都会更改,而且所有文件都共用相同的hash值;

chunkhash 采用hash计算的话,每一次构建后生成的哈希值都不同,即便文件内容压根没有改变。这样子是没办法实现缓存效果,咱们须要换另外一种哈希值计算方式,即chunkhash。chunkhash和hash不同,它根据不一样的入口文件(Entry)进行依赖文件解析、构建对应的chunk,生成对应的哈希值。咱们在生产环境里把一些公共库和程序入口文件区分开,单独打包构建,接着咱们采用chunkhash的方式生成哈希值,那么只要咱们不改动公共库的代码,就能够保证其哈希值不会受影响。

contenthash 使用 webpack 编译代码时,咱们能够在js文件里面引用css文件的。因此这两个文件应该共用相同的chunkhash值。可是有个问题,若是js更改了代码,css文件就算内容没有任何改变,因为是该模块发生了改变,致使css文件会重复构建。这个时候,咱们可使用 extra-text-webpack-plugin 里的 contenthash 值,保证即便css文件所处的模块里其它文件内容改变,只要css文件内容不变,那么就不会重复构建。

二、splitChunks详解

目前网络上可以查询到的 webpack4.x 的文档对于 splitChunks 并无完整的中文翻译,若是对于英文阅读没有障碍,能够直接去阅读官方文档,若是英文很差能够参考下面的参数和中文释义:

首先 Webpack4.x 会根据下述条件自动进行代码块分割:

  • 新代码块能够被共享引用或者这些模块都是来自node_modules文件夹里面
  • 新代码块大于30kb(min + gziped以前的体积)
  • 按需加载的代码块,最大数量应该小于或者等于5
  • 初始加载的代码块,最大数量应该小于或等于3
// 配置项解释以下
splitChunks: {
    // 默认做用于异步chunk,值为all
    //initial模式下会分开优化打包异步和非异步模块。而all会把异步和非异步同时进行优化打包。也就是说moduleA在indexA中异步引入,indexB中同步引入,initial下moduleA会出如今两个打包块中,而all只会出现一个。
    // all 全部chunk代码(同步加载和异步加载的模块均可以使用)的公共部分分离出来成为一个单独的文件
    // async 将异步加载模块代码公共部分抽离出来一个单独的文件
    chunks: 'async',
    // 默认值是30kb 当文件体积 >= minsize 时将会被拆分为两个文件 某则不生成新的chunk
    minSize: 30000,
    // 共享该module的最小chunk数 (当>= minchunks时才会被拆分为新的chunk)
    minChunks: 1,
    // 最多有5个异步加载请求该module
    maxAsyncRequests: 5,
    // 初始话时最多有3个请求该module
    maxInitialRequests: 3,
    // 名字中间的间隔符
    automaticNameDelimiter: '~',
    // 打包后的名称,若是设置为 truw 默认是chunk的名字经过分隔符(默认是~)分隔开,如vendor~ 也能够本身手动指定
    name: true,
    // 设置缓存组用来抽取知足不一样规则的chunk, 切割成的每个新的chunk就是一个cache group
    cacheGroups: {
        common: {
            // 抽取的chunk的名字
            name: 'common',
            // 同外层的参数配置,覆盖外层的chunks,以chunk为维度进行抽取
            chunks: 'all',
            // 能够为字符串,正则表达式,函数,以module为维度进行抽取,
            // 只要是知足条件的module都会被抽取到该common的chunk中,为函数时第一个参数
            // 是遍历到的每个模块,第二个参数是每个引用到该模块的chunks数组
            test(module, chunks) {
                // module.context 当前文件模块所属的目录 该目录下包含多个文件
                // module.resource 当前模块文件的绝对路径

                if (/scroll/.test(module.context)) {
                    let chunkName = ''; // 引用该chunk的模块名字

                    chunks.forEach(item => {
                        chunkName += item.name + ',';
                    });
                    console.log(`module-scroll`, module.context, chunkName, chunks.length);
                }
            },
            // 优先级,一个chunk极可能知足多个缓存组,会被抽取到优先级高的缓存组中, 数值打的优先被选择
            priority: 10,
            // 最少被几个chunk引用
            minChunks: 2,
            // 若是该chunk中引用了已经被抽取的chunk,直接引用该chunk,不会重复打包代码 (当module未发生变化时是否使用以前的Module)
            reuseExistingChunk: true,
            // 若是cacheGroup中没有设置minSize,则据此判断是否使用上层的minSize,true:则使用0,false:使用上层minSize
            enforce: true
        }
    }
};


复制代码

参考文章

相关文章
相关标签/搜索