7.优化-灵析社区

懒人学前端

二、Tree Shaking

tree-shaking

所谓 tree-shaking 就是 “摇树” 的意思,可以将应用程序想象成一棵树。绿色表示实际用到的 source code(源码) 和 library(库),是树上活的树叶。灰色表示未引用代码,是秋天树上枯萎的树叶。为了除去死去的树叶,你必须摇动这棵树,使它们落下。

tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块语法的静态结构特性,例如 import 和 export。这个术语和概念实际上是由 ES2015 模块打包工具 rollup 普及起来的。
webpack 2 正式版本内置支持 ES2015 模块(也叫做 harmony modules)和未使用模块检测能力。新的 webpack 4 正式版本扩展了此检测能力,通过 package.json 的 "sideEffects" 属性作为标记,向 compiler 提供提示,表明项目中的哪些文件是 "pure(纯正 ES2015 模块)",由此可以安全地删除文件中未使用的部分。

例子

tree shaking 会在编译过程中将未使用的代码进行标记,在 uglify 时移除被标记的无效代码,在 mode 设置为 production 时,webpack 会开启压缩和 tree-shaking 等优化,下面的例子如果配置为生产模式,打包后未使用的引用会被移除掉。

1、将 mode 设置为 none(不使用任何打包优化),optimization 配置中增加 usedExports 。

index.js

// add 只是导入没有使用
import { add } from './util.js';

const divElement = document.createElement('div');
divElement.innerHTML = addRes;
divElement.className = 'demo';
document.body.appendChild(divElement);

util.js

// 导出 add 函数
export const add = (a, b) => {
   return a + b
}

webpack.config.js

...
module.exports = {
  ...
  mode: 'none', // 不使用任何默认优化选项
  optimization: {
    // 告诉 Webpack 去决定每一个模块所用到的导出
    usedExports: true,
  },
};

执行打包命令后,查看 dist/index.js 中的代码。可以看到 index.js 中只做了导入但是没有调用的方法 add 被打了 unused harmony export 的注释。

2、package.json 中增加 sideEffects: false

  "sideEffects": false,

webpack.config.js 中移除 optimization 配置,index.js 与 util.js 中内容不变,执行打包命令后,可以看到打包后的 index.js 中 add 函数已经被移除。

sideEffects 与 usedExports

在了解上面两个参数区别之前,我们先来看下函数的副作用。

函数副作用是指函数在正常工作任务之外对外部环境所施加的影响。具体地说,函数副作用是指函数被调用,完成了函数既定的计算任务,但同时因为访问了外部数据,尤其是因为对外部数据进行了写操作,从而一定程度地改变了系统环境。

在上面例子中我们在 index.js 中导入的 util.js 中导出了一个 add 函数,我们来看下这个函数。

export const add = (a, b) => {
  return a + b
}

add 函数中接收了 a 和 b 两个参数,函数体中返回了 a 与 b 的和,此函数返回的结果只依赖于传入的值并且没有其他影响,如修改全局变量等操作,则此函数即为无副作用函数。

export const add = (a, b) => {
  window.userName = '***'
  return a + b
}

上面的 add 函数除了返回了 a 与 b 的和的同时还修改了 window对象中属性的值,则此函数即为有副作用的函数。

简单了解了函数副作用,我们来看下 sideEffects 和 usedExports(更多被认为是 tree shaking)的两种不同的优化方式。

  • usedExports 依赖于 terser 去检测语句中的副作用,对未使用的函数增加标记,之后交由压缩工具去移除死代码。

webpack.config.js

...
module.exports = {
  ...
  mode: 'none', // 不使用任何默认优化选项
  optimization: {
    // 告诉 Webpack 去决定每一个模块所用到的导出
    usedExports: true,
    // 开启压缩
    minimize: true
  },
};

index.js 与 util.js 内容保持不变,在 webpack.config.js 的 optimization 配置中增加参数 minimize 为 true,打包成功后查看输出文件,可以看到之前被打了 unused harmony export 注释的代码被移除掉了。

下面我们来写一个带有副作用的方法,看看 usedExports 会如何处理。

index.js

import './util.js';

const divElement = document.createElement('div');
divElement.innerHTML = 'demo';
divElement.className = 'demo';
document.body.appendChild(divElement);

util.js

Array.prototype.custom = function () {
  console.log('custom');
};
export const add = (a, b) => {
  return a + b;
};

util.js 中修在 Array 原型链上增加了方法,所以是 util.js 是有副作用的,在 index.js 中我们不只导入 add 方法而是导入整个 util.js 文件,执行打包命令后可以查看 dist/index.js 文件中 add 方法已被删除,有副作用的部分依然在打包文件中。

  • sideEffects 更为有效 是因为它允许跳过整个模块/文件和整个文件子树

在一个纯粹的 ESM 模块世界中,很容易识别出哪些文件有副作用。然而,我们的项目无法达到这种纯度,所以,此时有必要提示 webpack compiler 哪些代码是“纯粹部分”。

通过 package.json 的 "sideEffects" 属性,来实现这种方式。如果所有代码都不包含副作用,我们就可以将 sideEffects 标记为 false,来告诉 webpack 它可以跳过安全检测删除未用到的 export。

依然使用上面包含副作用的代码。来看下增加 sideEffects: false 的效果。

package.json

{
  // ...
  "sideEffects": false,
  // ...
}

webpack.config.js

module.exports = {
  mode: 'none',
  entry: {
    index: './src/index.js',
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].js',
  }
};

执行打包命令后查看 dist/index.js 文件,可以看到包含副作用的代码也被移除,即 util.js 导入部分被全部移除。

项目中包含副作用的函数被移除在打包后会导致部分功能不可用,所以 sideEffects 支持传入参数,告知 webpack 传入的文件包含副作用不要移除。

上面的例子中修改 package.json 的配置,将包含副作用的文件传入进去。此数组支持简单的 glob 模式匹配相关文件。其内部使用了 glob-to-regexp(支持:*,**,{a,b},[a-z])。如果匹配模式为 *.css,且不包含 /,将被视为 **/*.css。

package.json

{
 // ...
  "sideEffects": ["./src/util.js" ], 
 // ...
}

执行打包命令后查看输出文件,可以看到只有导入但是没使用的 add 函数和 Array 方法都被保留了下来。

tree shaking 开启条件

tree shaking 需要使用 ES2015 模块语法(即 import 和 export)才能生效,有时我们会发现只引用了某个库中的一个方法,却把整个库都加载进来了,同时 bundle 的体积也并没有因为 tree shaking 而减少。这可能是该库不是使用 ESModule 模式导出的,所以在使用某个库时,我们尽量引入 es 版,按需加载,这样 tree shaking 才能将无用代码删除掉。

总结

为了利用 tree shaking 的优势, 我们必须

  • 使用 ES2015 模块语法(即 import 和 export)。
  • 确保没有编译器将您的 ES2015 模块语法转换为 CommonJS 的(顺带一提,这是现在常用的 @babel/preset-env 的默认行为,详细信息请参阅文档)。
  • 在项目的 package.json 文件中,添加 "sideEffects" 属性。
  • 使用 mode 为 "production" 的配置项以启用更多优化项,包括压缩代码与 tree shaking。

二、代码分离及懒加载

代码分离及懒加载

前端性能优化一直是围绕在每一个前端周围的话题,减少网络请求、减少加载 bundle 体积、外部资源放在 CDN 等等。对于性能优化 webpack 也提供了一些手段。下面让我们来了解下代码分离、缓存和懒加载。

代码分离

代码分离是 webpack 最引人注目的特性之一,试想如果项目中所有代码都打包到一个 bundle 中,那 bundle 的体积将会变大,这对首次访问页面来说,加载资源的请求时间会变长,将影响用户体验。所以前端性能优化的一个方向是将代码按照一定规则拆分到不同的 bundle 中,触发不同的功能加载不同的资源,这样除了减少资源体积外还能增快请求响应速度。不过拆分的粒度大小还是要看实际的项目需求,无限拆分资源包也会造成资源请求过多。所以对于代码分离我们要结合项目情况合理使用,这会极大影响加载时间。

常用的代码分离方法有三种:

  • 入口分离:使用 entry 配置手动地分离代码。
  • 去重分离:使用 Entry dependencies 或者 SplitChunksPlugin 去重和分离 chunk。
  • 动态导入:通过模块的内联函数调用来分离代码。

入口分离

入口分离即从 webpack 入口文件配置处分离 bundle。这种分离方式根据项目的多个入口拆分出多个 bundle,这是所有分离方式中最简单的分离方式,不过这种方式也存在弊端即 输出的 bundle 会同时包含导入的三方依赖代码(重复代码),在后面的分离方式讲解中会解决这个问题。我们先来看看入口分离的例子。

src/index.js

index.js 文件中引入依赖包 lodash 的 join 方法。创建 div 标签并将内容添加到 body 中。

import { join } from 'lodash';
const divElement = document.createElement('div');
divElement.innerHTML = join(['hello', 'index'], '-');
document.body.appendChild(divElement);

src/main.js

main.js 文件中同样引入依赖包 lodash 的 join 方法。创建 div 标签并将内容添加到 body 中,只是展示内容与 index.js 不一致。

import { join } from 'lodash';
const divElement = document.createElement('div');
divElement.innerHTML = join(['hello', 'main'], '-');
document.body.appendChild(divElement);

webpack.config.js

在 entry 配置中传入两个入口,chunk 的名字分别是 index 和 main。

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
...

module.exports = {
  mode: 'development',
  entry: {
    index: './src/index.js',
    main: './src/main.js'
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].js',
  },
  plugins: [
    ...
    new HtmlWebpackPlugin({
      template: './index.html',
    }),
  ],
};

执行打包命令后,在 dist 文件夹下可以看到,同时输出了 mian.js 和 index.js 文件,通过配置 entry 实现了代码分离。我们分别打开 dist/index.js 和 dist/main.js 后可以看到两个 bundle 中都包含了 lodash 的源码。

去重分离

在入口分离的基础上,我们继续优化将重复的部分单独分离出一个 bundle,在 entry 中配置 dependOn 选项,这样可以在多个 chunk 之间共享模块。

webpack.config.js

在 entry 配置中将两个入口文件按照对象的形式定义,除定义入口之外,增加了一个 dependOn 选项,传入的 vendor 为共享模块的 chunk 名称。

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
...

module.exports = {
 ...
 entry: {
    index: {
      import: './src/index.js',
      dependOn: 'vendor',
    },
    main: {
      import: './src/main.js',
      dependOn: 'vendor',
    },
    vendor: 'lodash'
  },
};

执行打包命令后,在 dist 文件夹下除了两个入口文件外还多了一个 vendor.js 文件,文件的内容即为 lodash 源码。HtmlWebpackPlugin 插件将三个 js 文件都注入到了 index.html 中。项目可以正常运行。

通过在入口配置 dependOn 属性,虽然可以实现公共代码抽离,但是还存在一个问题是,在我们的项目中会有很多公共代码,难道我们要手动的都添加到 dependOn 中吗?这将会增加非常多的工作量并且容易出错。这时我们可以考虑使用 webpack 的 SplitChunksPlugin 插件了。

SplitChunksPlugin

SplitChunksPlugin 插件不需要单独安装,是 webpack 提供的开箱即用的插件。我们通过 optimization 配置即可。

默认情况下,SplitChunksPlugin 它只会影响到按需加载的 chunks,不过我们可以通过传入 chunks 属性不同变量来决定影响哪些 chunk 。

webpack 将根据以下条件自动拆分 chunks:

  • 新的 chunk 可以被共享,或者模块来自于 node_modules 文件夹
  • 新的 chunk 体积大于 20kb(在进行压缩和 gzip 之前的体积)
  • 当按需加载 chunks 时,并行请求的最大数量小于或等于 30
  • 当加载初始化页面时,并发请求的最大数量小于或等于 30

我们使用 SplitChunksPlugin 来拆分下上个例子中的 lodash,关于 SplitChunksPlugin 的各项配置参数含义,感兴趣的伙伴可以查看官网

webpack.config.js

...

module.exports = {
  entry: {
    index: './src/index.js',
    main: './src/main.js',
  },
  optimization: {
    splitChunks: {
      // 设置为 all 意味着 chunk 可以在异步和非异步 chunk 之间共享
      chunks: 'all',
    },
  },
  ...
};

执行打包命令后,在 dist 文件夹下可以看到除了 index.js 和 main.js 外还输出了一个文件,打开文件可以看到内容正是 lodash。

动态导入

动态导入也叫资源异步加载,当模块数量过多时,可以把一些暂时用不到的模块延迟加载,以此来减少用户初次加载页面的体积,后续模块等到恰当的时机再去加载,因此也把这种加载方式叫懒加载。

在 webpack 中提供了两种类似的技术来实现动态加载。import 函数及 require.ensure。require.ensure 是 Webpack 1 支持的异步加载方式,从 Webpack 2 开始引入了 import 函数,并且官方更推荐使用 import 函数的方式实现异步加载,因此下面我们只介绍 import 函数的使用。

通过 import 函数加载的模块会被异步的进行加载,并且返回一个 Primise 对象。

main.js

export const add = (a, b) => {
  console.log(a + b);
}

index.js

index 文件中通过 import 函数导入 main 文件内容并赋值给 addFunc,在 index 中通过 setTimeout 定时任务 2 秒钟后执行 addFunc 回调函数,在回调函数返回值中通过 ES6 的对象解构语法获取到 add 函数。

const addFunc = import('./main');

setTimeout(() => {
  addFunc.then(({add}) => {
    add(3, 8);
  });
}, 2000);

webpack.config.js

...

module.exports = {
  mode: 'development',
  entry: {
    index: './src/index.js'
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].js',
  },
  ...
};

通过 webpack.config.js 配置我们可以看到入口文件只有 index.js,执行打包命令后在生成的 dist 文件夹中可以看到除了入口文件 index.js 外,还生成了一个 src_main_js.js 的文件,内容即为 src/main.js 中内容对应的编译后代码。

通过 import 函数异步加载文件也可以实现拆分代码的功能,这种方式在实际的项目开发中非常实用,非主页面加载的内容都可以通过 import 函数动态加载,在点击到对应页面时在加载相应资源。

上面的例子中还可以进一步优化下异步加载文件的 chunk 名称。其他配置不变,我们修改下 import 导入函数部分。

index.js

const addFunc = import(/* webpackChunkName: "main" */ './main');

setTimeout(() => {
  addFunc.then(({add}) => {
    add(3, 8);
  });
}, 2000);

通过在 import 函数中增加注释 /* webpackChunkName: "main" */ 其中的 main 即为生成的 chunk 名称。这样在项目中通过定义语义化的名称,可以增加代码的可读性。

总结

上面的示例中,我们总结了代码分离的几种方式,分别是入口分离、去重分离、动态导入,其中的动态导入就是我们常说的懒加载。代码分离对于减少主包体积,优化项目加载速度,减少白屏时间来说将非常有用。

阅读量:2015

点赞量:0

收藏量:0