webpack的優化方案


前言

在前端應用的優化中,對加載資源的大小控制極其的重要,大多數時候我們能做的是在打包編譯的過程對資源進行大小控制、拆分與複用。
本片文章中主要是基於 webpack 打包,以 React、vue 等生態開發的單頁面應用來舉例說明如何從 webpack 打包的層面去處理資源以及緩存,其中主要我們需要做的是對 webpack 進行配置的優化,同時涉及少量的業務代碼的更改。

同時對打包資源的分析可以使用 webpack-bundle-analyzer 插件,當然可選的分析插件還是很多的,在本文中主要以該插件來舉例分析。

TIP: webpack 版本 @3.6.0 。


打包環境與代碼壓縮

首先我們有一個最基本的 webpack 配置:

// webpack.config.js
const path = require('path');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const PROJECT_ROOT = path.resolve(__dirname, './');
module.exports = {
  entry: {
    index: './src/index.js'
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[chunkhash:4].js'
  },
  module: {
    rules: [
      {
        test: /\.js[x]?$/,
        use: 'babel-loader',
        include: PROJECT_ROOT,
        exclude: /node_modules/
      }
    ]
  },
  plugins: [
    new BundleAnalyzerPlugin()
  ],
  resolve: {
    extensions: ['.js', '.jsx']
  },
};

執行打包可以看到一個項目的 js 有 1M 以上:

Hash: e51afc2635f08322670b
Version: webpack 3.6.0
Time: 2769ms
        Asset    Size  Chunks                    Chunk Names
index.caa7.js  1.3 MB       0  [emitted]  [big]  index

這時候只需要增加插件 DefinePlugin 與 UglifyJSPlugin 即可減少不少的體積,在 plugins 中添加:

// webpack.config.js
...
{
  ...
  plugins: [
    new BundleAnalyzerPlugin(),
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production')
    }),
    new UglifyJSPlugin({
      uglifyOptions: {
        ie8: false,
        output: {
          comments: false,
          beautify: false,
        },
        mangle: {
          keep_fnames: true
        },
        compress: {
          warnings: false,
          drop_console: true
        },
      }
    })
  ]
  ...
}

可以看到這時候的打包輸出:

Hash: 84338998472a6d3c5c25
Version: webpack 3.6.0
Time: 9940ms
        Asset    Size  Chunks                    Chunk Names
index.89c2.js  346 kB       0  [emitted]  [big]  index

代碼的大小從 1.3M 降到了 346K。

DefinePlugin

DefinePlugin 允許創建一個在編譯時可以配置的全局常量。這可能會對開發模式和發佈模式的構建允許不同的行爲非常有用。如果在開發構建中,而不在發佈構建中執行日誌記錄,則可以使用全局常量來決定是否記錄日誌。這就是 DefinePlugin 的用處,設置它,就可以忘記開發和發佈構建的規則。

在我們的業務代碼和第三方包的代碼中很多時候需要判斷 process.env.NODE_ENV 來做不同處理,而在生產環境中我們顯然不需要非 production 的處理部分。
在這裏我們設置 process.env.NODE_ENV 爲 JSON.stringify('production'),也就是表示講打包環境設置爲生產環境。之後配合 UglifyJSPlugin 插件就可以在給生產環境打包的時候去除部分的冗餘代碼。

UglifyJSPlugin

UglifyJSPlugin 主要是用於解析、壓縮 js 代碼,它基於 uglify-es 來對 js 代碼進行處理,它有多種配置選項:github.com/webpack-con…
通過對代碼的壓縮處理以及去除冗餘,大大減小了打包資源的體積大小。


代碼拆分/按需加載

在如 React 或者 vue 構建的單頁面應用中,對頁面路由與視圖的控制是由前端來實現的,其對應的業務邏輯都在 js 代碼中。
當一個應用設計的頁面和邏輯很多的時候,最終生成的 js 文件資源也會相當大。

然而當我們打開一個 url 對應的頁面時,實際上需要的並非全部的 js 代碼,所需要的僅是一個主的運行時代碼與該視圖對應的業務邏輯的代碼,在加載下一個視圖的時候再去加載那部分的代碼。
因此,對這方面可做的優化就是對 js 代碼進行按需加載。

懶加載或者按需加載,是一種很好的優化網頁或應用的方式。這種方式實際上是先把你的代碼在一些邏輯斷點處分離開,然後在一些代碼塊中完成某些操作後,立即引用或即將引用另外一些新的代碼塊。這樣加快了應用的初始加載速度,減輕了它的總體體積,因爲某些代碼塊可能永遠不會被加載。

在 webpack 中提供了動態導入的技術來實現代碼拆分,首先在 webpack 的配置中需要去配置拆分出來的每個子模塊的配置:

// webpack.config.js
const path = require('path');
const webpack = require('webpack');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const PROJECT_ROOT = path.resolve(__dirname, './');
module.exports = {
  entry: {
    index: './src/index.js'
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[chunkhash:4].js',
    chunkFilename: '[name].[chunkhash:4].child.js',
  },
  module: {
    rules: [
      {
        test: /\.js[x]?$/,
        use: 'babel-loader',
        include: PROJECT_ROOT,
        exclude: /node_modules/
      }
    ]
  },
  plugins: [
    new BundleAnalyzerPlugin(),
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production')
    }),
    new UglifyJSPlugin({
      uglifyOptions: {
        ie8: false,
        output: {
          comments: false,
          beautify: false,
        },
        mangle: {
          keep_fnames: true
        },
        compress: {
          warnings: false,
          drop_console: true
        },
      }
    }),
  ],
  resolve: {
    extensions: ['.js', '.jsx']
  },
};

其中主要需要定義的則是 output 中的 chunkFilename,它是導出的拆分代碼的文件名,這裏給它設置爲 [name].[chunkhash:4].child.js,其中的 name 對應模塊名稱或者 id,chunkhash 是模塊內容的 hash。

之後在業務代碼中 webpack 提供了兩種方式來動態導入:

  • import('path/to/module') -> Promise
  • require.ensure(dependencies: String[], callback: function(require), errorCallback: function(error), chunkName: String)

對於最新版本的 webpack 主要推薦使用 import() 的方式 注意:import 使用到了 Promise,因此需要確保代碼中支持了 Promise 的 polyfill 。

// src/index.js
function getComponent() {
  return import(
    /* webpackChunkName: "lodash" */
    'lodash'
  ).then(_ => {
    var element = document.createElement('div');
    element.innerHTML = _.join(['Hello', 'webpack'], ' ');
    return element;
  }).catch(error => 'An error occurred while loading the component');
}
getComponent().then(component => {
  document.body.appendChild(component);
})

可以看到打包的信息:

Hash: d6ba79fe5995bcf9fa4d
Version: webpack 3.6.0
Time: 7022ms
               Asset     Size  Chunks             Chunk Names
lodash.89f0.child.js  85.4 kB       0  [emitted]  lodash
       index.316e.js  1.96 kB       1  [emitted]  index
   [0] ./src/index.js 441 bytes {1} [built]
   [2] (webpack)/buildin/global.js 509 bytes {0} [built]
   [3] (webpack)/buildin/module.js 517 bytes {0} [built]
    + 1 hidden module

可以看到打包出來的代碼生成了 index.316e.js 和 lodash.89f0.child.js 兩個文件,後者則是通過 import 來實現拆分的。
import 它接收一個 path 參數,指的是該子模塊對於的路徑,同時還注意到其中可以添加一行註釋 /* webpackChunkName: "lodash" */,該註釋並非是無用的,它定義了該子模塊的 name,其對應與 output.chunkFilename 中的 [name]
import 函數返回一個 Promise,當異步加載到子模塊代碼是會執行後續操作,比如更新視圖等。

React 中的按需加載

在 React 配合 React-Router 開發中,往往就需要代碼根據路由按需加載的能力,下面是一個基於 webpack 代碼動態導入技術實現的 React 動態載入的組件:

import React, { Component } from 'react';
export default function lazyLoader (importComponent) {
  class AsyncComponent extends Component {
    state = { Component: null }
    async componentDidMount () {
      const { default: Component } = await importComponent();
      this.setState({
        Component: Component
      });
    }
    render () {
      const Component = this.state.Component;
      return Component
        ? <Component {...this.props} />
        : null;
    }
  }
  return AsyncComponent;
};

在 Route 中:

<Switch>
  <Route exact path="/"
    component={lazyLoader(() => import('./Home'))}
  />
  <Route path="/about"
    component={lazyLoader(() => import('./About'))}
  />
  <Route
    component={lazyLoader(() => import('./NotFound'))}
  />
</Switch>

在 Route 中渲染的是 lazyLoader 函數返回的組件,該組件在 mount 之後會去執行 importComponent 函數(既:() => import('./About'))動態加載其對於的組件模塊(被拆分出來的代碼),待加載成功之後渲染該組件。

使用該方式打包出來的代碼:

Hash: 02a053d135a5653de985
Version: webpack 3.6.0
Time: 9399ms
          Asset     Size  Chunks                    Chunk Names
0.db22.child.js  5.82 kB       0  [emitted]
1.fcf5.child.js   4.4 kB       1  [emitted]
2.442d.child.js     3 kB       2  [emitted]
  index.1bbc.js   339 kB       3  [emitted]  [big]  index

抽離 Common 資源

第三方庫的長緩存

首先對於一些比較大的第三方庫,比如在 React 中用到的 react、react-dom、react-router 等等,我們不希望它們被重複打包,並且在每次版本更新的時候也不希望去改變這部分的資源導致在用戶端重新加載。
在這裏可以使用 webpack 的 CommonsChunkPlugin 來抽離這些公共資源;

CommonsChunkPlugin 插件,是一個可選的用於建立一個獨立文件(又稱作 chunk)的功能,這個文件包括多個入口 chunk 的公共模塊。通過將公共模塊拆出來,最終合成的文件能夠在最開始的時候加載一次,便存起來到緩存中供後續使用。這個帶來速度上的提升,因爲瀏覽器會迅速將公共的代碼從緩存中取出來,而不是每次訪問一個新頁面時,再去加載一個更大的文件。

首先需要在 entry 中新增一個入口用來打包需要抽離出來的庫,這裏將 'react', 'react-dom', 'react-router-dom', 'immutable' 都給單獨打包進 vendor 中;
之後在 plugins 中定義一個 CommonsChunkPlugin 插件,同時將其 name 設置爲 vendor 是它們相關聯,再將 minChunks 設置爲 Infinity 防止其他代碼被打包進來。

// webpack.config.js
const path = require('path');
const webpack = require('webpack');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const PROJECT_ROOT = path.resolve(__dirname, './');
module.exports = {
  entry: {
    index: './src0/index.js',
    vendor: ['react', 'react-dom', 'react-router-dom', 'immutable']
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[chunkhash:4].js',
    chunkFilename: '[name].[chunkhash:4].child.js',
  },
  module: {
    rules: [
      {
        test: /\.js[x]?$/,
        use: 'babel-loader',
        include: PROJECT_ROOT,
        exclude: /node_modules/
      }
    ]
  },
  plugins: [
    new BundleAnalyzerPlugin(),
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production')
    }),
    new UglifyJSPlugin({
      uglifyOptions: {
        ie8: false,
        output: {
          comments: false,
          beautify: false,
        },
        mangle: {
          keep_fnames: true
        },
        compress: {
          warnings: false,
          drop_console: true
        },
      }
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: Infinity,
    }),
  ],
  resolve: {
    extensions: ['.js', '.jsx']
  },
};

運行打包可以看到:

Hash: 34a71fcfd9a24e810c21
Version: webpack 3.6.0
Time: 9618ms
          Asset     Size  Chunks                    Chunk Names
0.2c65.child.js  5.82 kB       0  [emitted]
1.6e26.child.js   4.4 kB       1  [emitted]
2.e4bc.child.js     3 kB       2  [emitted]
  index.4e2f.js  64.2 kB       3  [emitted]         index
 vendor.5fd1.js   276 kB       4  [emitted]  [big]  vendor

可以看到 vendor 被單獨打包出來了。

當我們改變業務代碼時再次打包:

Hash: cd3f1bc16b28ac97e20a
Version: webpack 3.6.0
Time: 9750ms
          Asset     Size  Chunks                    Chunk Names
0.2c65.child.js  5.82 kB       0  [emitted]
1.6e26.child.js   4.4 kB       1  [emitted]
2.e4bc.child.js     3 kB       2  [emitted]
  index.4d45.js  64.2 kB       3  [emitted]         index
 vendor.bc85.js   276 kB       4  [emitted]  [big]  vendor

vendor 包同樣被打包出來的,然而它的文件 hash 卻發生了變化,這顯然不符合我們長緩存的需求。
這是因爲 webpack 在使用 CommoChunkPlugin 的時候會生成一段 runtime 代碼(它主要用來處理代碼模塊的映射關係),而哪怕沒有改變 vendor 裏的代碼,這個 runtime 仍然是會跟隨着打包變化的並且打入 verdor 中,所以 hash 就會開始變化了。解決方案則是把這部分的 runtime 代碼也單獨抽離出來,修改之前的 CommonsChunkPlugin 爲:

// webpack.config.js
...
new webpack.optimize.CommonsChunkPlugin({
  name: ['vendor', 'runtime'],
  minChunks: Infinity,
}),
...

執行打包可以看到生成的代碼中多了 runtime 文件,同時即使改變業務代碼,vendor 的 hash 值也保持不變了。

當然這段 runtime 實際上非常短,我們可以直接 inline 在 html 中,如果使用的是 html-webpack-plugin 插件處理 html,則可以結合 html-webpack-inline-source-plugin 插件自動處理其 inline。

公共資源抽離

在我們打包出來的 js 資源包括不同入口以及子模塊的 js 資源包,然而它們之間也會重複載入相同的依賴模塊或者代碼,因此可以通過 CommonsChunkPlugin 插件將它們共同依賴的一些資源打包成一個公共的 js 資源。

// webpack.config.js
plugins: [
  new BundleAnalyzerPlugin(),
  new webpack.DefinePlugin({
    'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production')
  }),
  new UglifyJSPlugin({
    uglifyOptions: {
      ie8: false,
      output: {
        comments: false,
        beautify: false,
      },
      mangle: {
        keep_fnames: true
      },
      compress: {
        warnings: false,
        drop_console: true
      },
    }
  }),
  new webpack.optimize.CommonsChunkPlugin({
    name: ['vendor', 'runtime'],
    minChunks: Infinity,
  }),
  new webpack.optimize.CommonsChunkPlugin({
    // ( 公共chunk(commnons chunk) 的名稱)
    name: "commons",
    // ( 公共chunk 的文件名)
    filename: "commons.[chunkhash:4].js",
    // (模塊必須被 3個 入口chunk 共享)
    minChunks: 3
  })
],

可以看到這裏增加了 commons 的一個打包,當一個資源被三個以及以上 chunk 依賴時,這些資源會被單獨抽離打包到 commons.[chunkhash:4].js 文件。

執行打包,看到結果如下:

Hash: 2577e42dc5d8b94114c8
Version: webpack 3.6.0
Time: 24009ms
          Asset     Size  Chunks                    Chunk Names
0.2eee.child.js  90.8 kB       0  [emitted]
1.cfbc.child.js  89.4 kB       1  [emitted]
2.557a.child.js    88 kB       2  [emitted]
 vendor.66fd.js   275 kB       3  [emitted]  [big]  vendor
  index.688b.js  64.2 kB       4  [emitted]         index
commons.a61e.js  1.78 kB       5  [emitted]         commons

卻發現這裏的 commons.[chunkhash].js 基本沒有實際內容,然而明明在每個子模塊中也都依賴了一些相同的依賴。
藉助 webpack-bundle-analyzer 來分析一波:

img

可以看到三個模塊都依賴了 lodash,然而它並沒有被抽離出來。

這是因爲 CommonsChunkPlugin 中的 chunk 指的是 entry 中的每個入口,因此對於一個入口拆分出來的子模塊(children chunk)是不生效的。
可以通過在 CommonsChunkPlugin 插件中配置 children 參數將拆分出來的子模塊的公共依賴也打包進 commons 中:

// webpack.config.js
plugins: [
  new BundleAnalyzerPlugin(),
  new webpack.DefinePlugin({
    'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production')
  }),
  new UglifyJSPlugin({
    uglifyOptions: {
      ie8: false,
      output: {
        comments: false,
        beautify: false,
      },
      mangle: {
        keep_fnames: true
      },
      compress: {
        warnings: false,
        drop_console: true
      },
    }
  }),
  new webpack.optimize.CommonsChunkPlugin({
    name: ['vendor', 'runtime'],
    minChunks: Infinity,
  }),
  new webpack.optimize.CommonsChunkPlugin({
    // ( 公共chunk(commnons chunk) 的名稱)
    name: "commons",
    // ( 公共chunk 的文件名)
    filename: "commons.[chunkhash:4].js",
    // (模塊必須被 3個 入口chunk 共享)
    minChunks: 3
  }),
  new webpack.optimize.CommonsChunkPlugin({
    // (選擇所有被選 chunks 的子 chunks)
    children: true,
    // (在提取之前需要至少三個子 chunk 共享這個模塊)
    minChunks: 3,
  })
],

查看打包效果:

img

其子模塊的公共資源都被打包到 index 之中了,並沒有理想地打包進 commons 之中,還是因爲 commons 對於的是 entry 中的入口模塊,而這裏並未有 3 個 entry 模塊共用資源;
在單入口的應用中可以選擇去除 commons,而在子模塊的 CommonsChunkPlugin 的配置中配置 async 爲 true

// webpack.config.js
plugins: [
  new BundleAnalyzerPlugin(),
  new webpack.DefinePlugin({
    'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production')
  }),
  new UglifyJSPlugin({
    uglifyOptions: {
      ie8: false,
      output: {
        comments: false,
        beautify: false,
      },
      mangle: {
        keep_fnames: true
      },
      compress: {
        warnings: false,
        drop_console: true
      },
    }
  }),
  new webpack.optimize.CommonsChunkPlugin({
    name: ['vendor', 'runtime'],
    minChunks: Infinity,
  }),
  new webpack.optimize.CommonsChunkPlugin({
    // (選擇所有被選 chunks 的子 chunks)
    children: true,
    // (異步加載)
    async: true,
    // (在提取之前需要至少三個子 chunk 共享這個模塊)
    minChunks: 3,
  })
],

查看效果:

img

子模塊的公共資源都被打包到 0.9c90.child.js 中了,該模塊則是子模塊的 commons。


tree shaking

tree shaking 是一個術語,通常用於描述移除 JavaScript 上下文中的未引用代碼(dead-code)。它依賴於 ES2015 模塊系統中的靜態結構特性,例如 import 和 export。這個術語和概念實際上是興起於 ES2015 模塊打包工具 rollup。

在我們引入一個依賴的某個輸出的時候,我們可能需要的僅僅是該依賴的某一部分代碼,而另一部分代碼則是 unused 的,如果能夠去除這部分代碼,那麼最終打包出來的資源體積也是可以有可觀的減小。
首先,webpack 中實現 tree shaking 是基於 webpack 內部支持的 es2015 的模塊機制,在大部分時候我們使用 babel 來編譯 js 代碼,而 babel 會通過自己的模塊加載機制處理一遍,這導致 webpack 中的 tree shaking 處理將會失效。因此在 babel 的配置中需要關閉對模塊加載的處理:

// .babelrc
{
  "presets": [
    [
      "env", {
        "modules": false,
      }
    ],
    "stage-0"
  ],
  ...
}

然後我們來看下 webpack 是如何處理打包的代碼,舉例有一個入口文件 index.js 和一個 utils.js 文件:

// utils.js
export function square(x) {
  return x * x;
}
export function cube(x) {
  return x * x * x;
}
// index.js
import { cube } from './utils.js';
console.log(cube(10));

打包出來的代碼:

// index.bundle.js
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
/* unused harmony export square */
/* harmony export (immutable) */ __webpack_exports__["a"] = cube;
function square(x) {
  return x * x;
}
function cube(x) {
  return x * x * x;
}
可以看到僅有 cube 函數被 __webpack_exports__ 導出來,而 square 函數被標記爲 unused harmony export square,然而在打包代碼中既是 square 沒有被導出但是它仍然存在與代碼中,而如何去除其代碼則可以通過添加 UglifyjsWebpackPlugin 插件來處理。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章