gwuhaolin / blog

浩的技术博客

Home Page:https://wuhaolin.cn

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

webpack2 终极优化

gwuhaolin opened this issue · comments

webpack是当下最流行的js打包工具,这得益于网页应用日益复杂和js模块化的流行。webpack2增加了一些新特性也正式发布了一段时间,是时候告诉大家如何用webpack2优化你的构建让它构建出更小的文件尺寸和更好的开发体验。

优化输出

打包结果更小可以让网页打开速度更快以及简约宽带。可以通过这以下几点做到

压缩css

css-loader 在webpack2里默认是没有开启压缩的,最后生成的css文件里有很多空格和tab,通过配置
css-loader?minimize参数可以开启压缩输出最小的css。css的压缩实际是是通过cssnano实现的。

tree-shaking

tree-shaking 是指借助es6 import export 语法静态性的特点来删掉export但是没有import过的东西。要让tree-shaking工作需要注意以下几点:

  • 配置babel让它在编译转化es6代码时不把import export转换为cmd的module.export,配置如下:
"presets": [
    [
      "es2015",
      {
        "modules": false
      }
    ]
]
  • 大多数分布到npm的库里的代码都是es5的,但是也有部分库(redux,react-router等等)开始支持tree-shaking。这些库发布到npm里的代码即包含es5的又包含全采用了es6 import export 语法的代码。
    拿redux库来说,npm下载到的目录结构如下:
├── es
│   └── utils
├── lib
│   └── utils

其中lib目录里是编译出的es5代码,es目录里是编译出的采用import export 语法的es5代码,在redux的package.json文件里有这两个配置:

"main": "lib/index.js",
"jsnext:main": "es/index.js",

这是指这个库的入口文件的位置,所以要让webpack去读取es目录下的代码需要使用jsnext:main字段配置的入口,要做到这点webpack需要这样配置:

module.exports = {
	resolve: {
            mainFields: ['jsnext:main','main'],
        }
};

这会让webpack先使用jsnext:main字段,在没有时使用main字段。这样就可以优化支持tree-shaking的库。

优化 UglifyJsPlugin

webpack --optimize-minimize 选项会开启 UglifyJsPlugin来压缩输出的js,但是默认的UglifyJsPlugin配置并没有把代码压缩到最小输出的js里还是有注释和空格,需要覆盖默认的配置:

new UglifyJsPlugin({
    // 最紧凑的输出
    beautify: false,
    // 删除所有的注释
    comments: false,
    compress: {
      // 在UglifyJs删除没有用到的代码时不输出警告  
      warnings: false,
      // 删除所有的 `console` 语句
      // 还可以兼容ie浏览器
      drop_console: true,
      // 内嵌定义了但是只用到一次的变量
      collapse_vars: true,
      // 提取出出现多次但是没有定义成变量去引用的静态值
      reduce_vars: true,
    }
})

定义环境变量 NODE_ENV=production

很多库里(比如react)有部分代码是这样的:

if(process.env.NODE_ENV !== 'production'){
// 不是生产环境才需要用到的代码,比如控制台里看到的警告    
}

在环境变量 NODE_ENV 等于 production 的时候UglifyJs会认为if语句里的是死代码在压缩代码时删掉。

使用 CommonsChunkPlugin 抽取公共代码

CommonsChunkPlugin可以提取出多个代码块都依赖的模块形成一个单独的模块。要发挥CommonsChunkPlugin的作用还需要浏览器缓存机制的配合。在应用有多个页面的场景下提取出所有页面公共的代码减少单个页面的代码,在不同页面之间切换时所有页面公共的代码之前被加载过而不必重新加载。这个方法可以非常有效的提升应用性能。

在生产环境按照文件内容md5打hash

webpack编译在生产环境出来的js、css、图片、字体这些文件应该放到CDN上,再根据文件内容的md5命名文件,利用缓存机制用户只需要加载一次,第二次加载时就直接访问缓存。如果你之后有修改就会为对应的文件生产新的md5值。做到以上你需要这样配置:

{
  output: {
    publicPath: CND_URL,
    filename: '[name]_[chunkhash].js',
  },
}

知道以上原理后我们还可以进一步优化:利用CommonsChunkPlugin提取出使用页面都依赖的基础运行环境。比如对于最常见的react体系你可以抽出基础库react react-dom redux react-redux到一个单独的文件而不是和其它文件放在一起打包为一个文件,这样做的好处是只要你不升级他们的版本这个文件永远不会被刷新。如果你把这些基础库和业务代码打包在一个文件里每次改动业务代码都会导致浏览器重复下载这些包含基础库的代码。以上的配置为:

// vender.js 文件抽离基础库到单独的一个文件里防止跟随业务代码被刷新
// 所有页面都依赖的第三方库
// react基础
import 'react';
import 'react-dom';
import 'react-redux';
// redux基础
import 'redux';
import 'redux-thunk';
// webpack配置
{
  entry: {
    vendor: './path/to/vendor.js',
  },
}

DedupePlugin 和 OccurrenceOrderPlugin

在webpack1里经常会使用 DedupePlugin 插件来消除重复的模块以及使用 OccurrenceOrderPlugin 插件让被依赖次数更高的模块靠前分到更小的id 来达到输出更少的代码,在webpack2里这些已经这两个插件已经被移除了因为这些功能已经被内置了。

除了压缩文本代码外还可以:

以上优化点只需要在构建用于生产环境代码的时候才使用,在开发环境时最好关闭因为它们很耗时。

优化开发体验

优化开发体验主要从更快的构建和更方便的功能入手。

更快的构建

缩小文件搜索范围

webpack的resolve.modules配置模块库(通常是指node_modules)所在的位置,在js里出现import 'redux'这样不是相对也不是绝对路径的写法时会去node_modules目录下找。但是默认的配置会采用向上递归搜索的方式去寻找node_modules,但通常项目目录里只有一个node_modules在项目根目录,为了减少搜索我们直接写明node_modules的全路径:

module.exports = {
    resolve: {
        modules: [path.resolve(__dirname, 'node_modules')]
    }
};

除此之外webpack配置loader时也可以缩小文件搜索范围。

  • loader的test正则表达式也应该尽可能的简单,比如在你的项目里只有.js文件时就不要把test写成/\.jsx?$/
  • loader使用include命中只需要处理的文件,比如babel-loader的这两个配置:

只对项目目录下src目录里的代码进行babel编译

{
    test: /\.js$/,
    loader: 'babel-loader',
    include: path.resolve(__dirname, 'src')
}	

项目目录下的所有js都会进行babel编译,包括庞大的node_modules下的js

{
    test: /\.js$/,
    loader: 'babel-loader'
}	

开启 babel-loader 缓存

babel编译过程很耗时,好在babel-loader提供缓存编译结果选项,在重启webpack时不需要创新编译而是复用缓存结果减少编译流程。babel-loader缓存机制默认是关闭的,打开的配置如下:

module.exports = {
    module: {
         loaders: [{
                test: /\.js$/,
                loader: 'babel-loader?cacheDirectory',
         }]
  }
};

使用 alias

resolve.alias 配置路径映射。
发布到npm的库大多数都包含两个目录,一个是放着cmd模块化的lib目录,一个是把所有文件合成一个文件的dist目录,多数的入口文件是指向lib里面下的。
默认情况下webpack会去读lib目录下的入口文件再去递归加载其它依赖的文件这个过程很耗时,alias配置可以让webpack直接使用dist目录的整体文件减少文件递归解析。配置如下:

module.exports = {
  resolve: {
    alias: {
      'moment': 'moment/min/moment.min.js',
      'react': 'react/dist/react.js',
      'react-dom': 'react-dom/dist/react-dom.js'
    }
  }
};

使用 noParse

module.noParse 配置哪些文件可以脱离webpack的解析。
有些库是自成一体不依赖其他库的没有使用模块化的,比如jquey、momentjs、chart.js,要使用它们必须整体全部引入。
webpack是模块化打包工具完全没有必要去解析这些文件的依赖,因为它们都不依赖其它文件体积也很庞大,要忽略它们配置如下:

module.exports = {
  module: {
    noParse: /node_modules\/(jquey|moment|chart\.js)/
  }
};

除此以外还有很多可以加速的方法:

更方便的功能

模块热替换

模块热替换是指在开发的过程中修改代码后不用刷新页面直接把变化的模块替换到老模块让页面呈现出最新的效果。
webpack-dev-server内置模块热替换,配置起来也很方便,下面以react应用为例,步骤如下:

  • 在启动webpack-dev-server的时候带上--hot参数开启模块热替换,在开启--hot后针对css的变化是会自动热替换的,但是js涉及到复杂的逻辑还需要进一步配置。
  • 配置页面入口文件
import App from './app';

function run(){
	render(<App/>,document.getElementById('app'));
}
run();

// 只在开发模式下配置模块热替换
if (process.env.NODE_ENV !== 'production') {
  module.hot.accept('./app', run);
}

当./app发生变化或者当./app依赖的文件发生变化时会把./app编译成一个模块去替换老的,替换完毕后重新执行run函数渲染出最新的效果。

自动生成html

webpack只做了资源打包的工作还缺少把这些加载到html里运行的功能,在庞大的app里手写html去加载这些资源是很繁琐易错的,我们需要自动正确的加载打包出的资源。
webpack原生不支持这个功能于是我做了一个插件 web-webpack-plugin
具体使用点开链接看详细文档,使用大概如下:

demo

webpack配置

module.exports = {
    entry: {
        A: './a',
        B: './b',
    },
    plugins: [
        new WebPlugin({
            // 输出的html文件名称,必填,注意不要重名,重名会覆盖相互文件。
            filename: 'index.html',
            // 该html文件依赖的entry,必须是一个数组。依赖的资源的注入顺序按照数组的顺序。
            requires: ['A', 'B'],
        }),
    ]
};

将会输出一个index.html文件,这个文件将会自动引入 entry AB 生成的js文件,

输出的html:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
</head>
<body>
<script src="A.js"></script>
<script src="B.js"></script>
</body>
</html>

输出的目录结构

├── A.js
├── B.js
└── index.html

管理多页面

虽然webpack适用于单页应用,但复杂的系统经常是由多个单页应用组成,每个页面一个功能模块。webpack给出了js打包方案但缺少管理多个页面的功能。 web-webpack-pluginAutoWebPlugin会自动的为你的系统里每个单页应用生成一个html入口页,这个入口会自动的注入当前单页应用依赖的资源,使用它你只需如下几行代码:

plugins: [
    // ./src/pages/ 代表存放所有页面的根目录,这个目录下的每一个目录被看着是一个单页应用
    // 会为里面的每一个目录生成一个html入口
    new AutoWebPlugin('./src/pages/', {
      //使用单页应用的html模版文件,这里你可以自定义配置
      template: './src/assets/template.html',
    }),
],

查看web-webpack-plugin的文档了解更多

分析输出结果

如果你对当前的配置输出或者构建速度不满意,webpack有一个工具叫做webpack analyze 以可视化的方式直观的分析构建,来进一步优化构建结果和速度。要使用它你需要在执行webpack的时候带上--json --profile2个参数,这代表让webpack把构建结果以json输出并带上构建性能信息,使用如下:

webpack --json --profile > stats.json 

会生产一个stats.json 文件,再打开webpack analyze 上传这个文件开始分析。

最后附上这篇文章所讲到的webpack整体的配置,分为开发环境的webpack.config.js和生产环境的webpack-dist.config.js

阅读原文

你好,你给出的webpack配置链接好像是失效的?在知乎和这里都转不过去。

你是说 webpack整体的配置 这个链接吗?
https://gist.github.com/gwuhaolin/cebd252a23793e742e6acae90ab63e83
这个是可以访问的呀。

噢!可以了,竟然要翻个墙才能访问gits。

谢谢你的文章~

感谢楼主文章 CommonsChunkPlugin 抽取公共代码 这里有点模糊

  1. 请问下 怎么理解 单页面的抽取公用代码 多页面的抽取公用代码 他们有什么不同和注意点
  2. 代码优化应该还有比较重要的一部分应该是 分片加载 优化首屏时间。

@gwuhaolin 在环境变量 NODE_ENV 等于 production 的时候UglifyJs会认为if语句里的是死代码在压缩代码时删掉。
不理解这里的原理 webpack 到底是怎么处理的 webpack 应该不会处理执行语句 难道是字符串匹配处理去除 这里感觉还是比较难理解 不知道楼主是否有什么深入的解释

@yueswing07

  1. 请问下 怎么理解 单页面的抽取公用代码 多页面的抽取公用代码 他们有什么不同和注意点

如果你的应用由多个单页应用组成,由于把多个单页应用都依赖的代码抽离出来放到一个单独的文件里,用户在这几个单页应用之间切换时公共的代码由于缓存机制只会被加载一次,这有利于提升网页加载速度。如果你的应用只由一个单页应用组成,那么就没必要做公用代码提取。

  1. 代码优化应该还有比较重要的一部分应该是 分片加载 优化首屏时间。

你这里应该是指的代码分割,目的是把网页首屏不需要的展示的对应的那部分代码分割出来,等用户操作到一定的步骤时在异步加载被分割出来的代码,这有利于优化首屏加载速度。
通常是安装功能模块的划分做代码分割。

@yueswing07

在环境变量 NODE_ENV 等于 production 的时候UglifyJs会认为if语句里的是死代码在压缩代码时删掉。
不理解这里的原理 webpack 到底是怎么处理的 webpack 应该不会处理执行语句 难道是字符串匹配处理去除 这里感觉还是比较难理解 不知道楼主是否有什么深入的解释

我们经常会通过 process.env.NODE_ENV 环境变量去动态的控制部分代码的执行受到环境变量的影响,就像以下这段代码只会在非正式环境注入开放用的 Webpack 自动刷新代码:

// Webpack Hot Module Replace
if (process.env.NODE_ENV !== 'production') {
  if (module.hot) {
    module.hot.accept();
  }
}

这里面的原理是这样的,要分两种情况:

  1. 开发模式下,Webpack 会为编译出来的要运行在浏览器里的代码里注入模拟 和 nodejs 一样的 process 模块,同时从当前执行构建的环境中读取出所有环境变量的值注入到代码里。这样代码在浏览器里执行时就会根据值去判断怎么执行。
  2. 正式环境下,把代码交给 UglifyJs 去压缩后,UglifyJs 会在处理阶段,读取执行构建的环境中环境变量的值带入到代码里去,例如以上代码就变成了:
// Webpack Hot Module Replace
if (false) {
  if (module.hot) {
    module.hot.accept();
  }
}

之后 UglifyJs 会删掉所有确定不可能执行的代码,例如以上的 if (false) { 的代码。

@gwuhaolin 多谢你的解答

  1. 当页面应用 vue模板里面做了 公用提取 相关代码如下:
    // split vendor js into its own file
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: function (module, count) {
        // any required modules inside node_modules are extracted to vendor
        return (
          module.resource &&
          /\.js$/.test(module.resource) &&
          module.resource.indexOf(
            path.join(__dirname, '../node_modules')
          ) === 0
        )
      }
    }),
    // extract webpack runtime and module manifest to its own file in order to
    // prevent vendor hash from being updated whenever app bundle is updated
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest',
      chunks: ['vendor']
    }),

这里对vendor能看出是对第三方代码的提取 manifest提取的是什么东西 不是很理解
上面的解释能看懂 但是不能深入理解

讲的很实用

你好,能问下文章最后的 分析输出结果 的详细操作么?

commented

有没有基于Webpack 4的优化 哈哈