1. 程式人生 > 實用技巧 >Webpack打包優化

Webpack打包優化

Webpack打包優化


Webapck 4 之後預設為我們做了很多配置項,內部開啟了很多優化功能。對於開發人員,這種開箱即用的體驗顯然是很好的,但是同時也會導致我們忽略了很多需要學習的東西,一旦出現什麼問題的時候,我們就無從下手了,下面我們就來看一下主要的優化配置項。

DefinePlugin

DefinePlugin 是用來為我們的程式碼來注入全域性成員的,在 production 模式下,這個外掛就會預設開啟。它會在我們的環境中注入了一個 process.env.NODE_ENV 這樣一個環境變數,我們可以通過這個環境變數去判斷執行環境,從而去執行一些相應的邏輯。

const webpack = require('webpack')

module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new webpack.DefinePlugin({
      // 值要求的是一個程式碼片段
      API_BASE_URL: JSON.stringify('https://api.example.com')
    })
  ]
}

這樣我們就可以直接在環境中使用 API_BASE_URL 這個變量了

// main.js
console.log(API_BASE_URL)

Tree-shaking

Tree-shaking 顧名思義就是搖樹,伴隨著搖這個動作,我們會將樹上的枯樹枝和枯樹葉搖下來。而在我們的專案中 Tree-shaking 會將我們程式碼中沒有引用的部分去掉,Tree-shaking 並不是某一個配置選項,它是一組功能搭配使用的效果。我們可以使用 optimization 去開啟一些功能,optimization 就是優化的意思,下面我們來看看怎樣去配置它

module.exports = {
  mode: 'none',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js'
  },
  optimization: {
    // 模組只匯出被使用的成員
    usedExports: true,
    // 儘可能合併每一個模組到一個函式中
    concatenateModules: true,
    // 壓縮輸出結果
    // minimize: true
  }
}

我們可以將 usedExports 想象成它就是去標記"枯樹葉"的,而 minimize 就是去搖下這些枯樹葉的。而 concatenateModules 將所有的程式碼都儘可能的合併到一個函式中去,這樣既提升了執行效率,又減少了程式碼的體積。這個特性又被稱為 Scope Hoisting,這時 Webpack3 中提出的一個特性。

  • Tree-shaking 與 babel

由於 Webpack 的發展比較快,所以我們在找資料的時候,找到的資料並不一定適用於我們當前的版本,Tree-shaking 更是如此,很多資料中都顯示如果我們使用的 babel-loader 的話,就會導致 Tree-shaking 失效。因為 Tree-shaking 使用的前提就是必須使用 ES Modules 規範去組織我們的程式碼,而 @babel/preset-env 這個外掛內部就會將 ES Modules 的程式碼轉換為 commonjs 程式碼的方式,所以 Tree-shaking 就不能生效。但是實際你同時開啟兩者的話,Tree-shaking 還是會生效的,因為 @babel/preset-env 這個外掛最新的版本內部將 ES Modules 轉換為 commonjs 關掉了。

module.exports = {
  mode: 'none',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              // 如果 Babel 載入模組時已經轉換了 ESM,則會導致 Tree Shaking 失效
              // ['@babel/preset-env', { modules: 'commonjs' }]
              // ['@babel/preset-env', { modules: false }]
              // 也可以使用預設配置,也就是 auto,這樣 babel-loader 會自動關閉 ESM 轉換
              ['@babel/preset-env', { modules: 'auto' }]
            ]
          }
        }
      }
    ]
  },
  optimization: {
    // 模組只匯出被使用的成員
    usedExports: true,
    // 儘可能合併每一個模組到一個函式中
    // concatenateModules: true,
    // 壓縮輸出結果
    // minimize: true
  }
}

sideEffects

Webpack4 中還新增了一個叫 sideEffects 的新特性,它允許我們去標識我們的程式碼是否有副作用,從而為 Tree shaking 提供更大的壓縮空間。副作用就是模組去執行時除了匯出成員之外所做的事情,sideEffects 一般只有我們在去開發一個 npm 模組的時候才會去使用,那是因為官網將 sideEffects 和 Tree shaking 混到了一起,所以很多人誤認為它們兩個是因果關係,其實它們兩個的關係不大。
當我們去封裝元件的時候,我們一般會將所有的元件都匯入在一個檔案中,然後通過這個檔案集體匯出,但是其他檔案引入這個檔案的時候,就會將這個匯出檔案的所有元件都引入

// components/index.js
export { default as Button } from './button'
export { default as Heading } from './heading'
// main.js
import { Button } from './components'
document.body.appendChild(Button())

這樣 Webpack 在打包的時候,也會將 Heading 元件打包到檔案中,這時 sideEffects 就能解決這個問題

module.exports = {
  mode: 'none',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js'
  },
  optimization: {
    sideEffects: true,
  }
}

同時我們在 packag.json 中匯入將沒有副作用的檔案關閉,這樣就不會將無用的檔案打包到專案中了

{
  "name": "side-effects",
  "version": "0.1.0",
  "main": "index.js",
  "author": "maoxiaoxing",
  "license": "MIT",
  "scripts": {
    "build": "webpack"
  },
  "devDependencies": {
    "webpack": "^4.41.2",
    "webpack-cli": "^3.3.9"
  },
  "sideEffects": false
}

使用 sideEffects 的需要注意的是,我們的程式碼中真的沒有副作用,如果有副作用的程式碼,我們就不能去這樣配置了。

// exten.js
// 為 Number 的原型新增一個擴充套件方法
Number.prototype.pad = function (size) {
  // 將數字轉為字串 => '8'
  let result = this + ''
  // 在數字前補指定個數的 0 => '008'
  while (result.length < size) {
    result = '0' + result
  }
  return result
}

例如我們在 extend.js 檔案中為 Number 的原型新增一個方法,我們並沒有向外匯出成員,只是基於原型擴充套件了一個方法,我們在其他檔案匯入這個 extend.js

// main.js
// 副作用模組
import './extend'
console.log((8).pad(3))

如果我們還標識專案中所有模組沒有副作用的話,這個新增在原型的方法就不會被打包進去,在執行中肯定會報錯,還有就是我們在程式碼中匯入的 css 模組,也都是副作用模組,我們就可以在 package.json 中去標識我們的副作用模組

{
  "name": "side-effects",
  "version": "0.1.0",
  "main": "index.js",
  "author": "maoxiaoxing",
  "license": "MIT",
  "scripts": {
    "build": "webpack"
  },
  "devDependencies": {
    "webpack": "^4.41.2",
    "webpack-cli": "^3.3.9"
  },
  "sideEffects": [
    "./src/extend.js",
    "*.css"
  ]
}

這樣標識的有副作用的模組也會被打包進來。

Webpack 程式碼分割(Code Splitting)

模組化的優勢固然很明顯,但是也存在一些弊端,就是在我們的專案中所有的程式碼都會被打包到一起,如果我們的專案過大的話,那麼我們的打包結果就會特別大。但是實際的情況是,我們在首次載入的時候,並不是所有的模組都是必須載入的,但是這些模組又被打包到一起,所以一方面在瀏覽器執行的時候會慢,一方面也會浪費一些流量和頻寬。所以合理的方式就是將我們的程式碼按照一定的規則打包到多個 js 檔案中去,做分包處理、按需載入,這樣我們就會大大提高我們的應用的響應速率。那麼有人可能會想到 Webpack 不就是將我們程式碼中散落的程式碼合併到一個函式中去執行,從而去提高效率,這裡為什麼又要做分包處理,不是自相矛盾嗎?其實任何事情都是物極必反,Webpack 做程式碼合併是因為我們在開發中往往模組化顆粒度太細,所以 Webpack 必須將很多程式碼合併到一起,但是如果總體程式碼量過大的話,就會導致我們的單個打包檔案過大,反而影響效率。所以模組化顆粒度太小不行,太大也不行,而 Code Splitting 就是為了解決我們模組化顆粒度太大的問題。

  • 多入口打包

多入口打包就是將一個頁面作為一個打包入口,而對於不同頁面中公共的部分再去提取到公共的檔案中去,而多入口打包的配置也很容易

const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'none',
  entry: { // 多入口打包,多個入口檔案
    index: './src/index.js',
    album: './src/album.js'
  },
  output: {
    filename: '[name].bundle.js' // 由於多入口打包,採用佔位符
  },
  optimization: {
    splitChunks: {
      // 自動提取所有公共模組到單獨 bundle
      chunks: 'all'
    }
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/index.html',
      filename: 'index.html',
      chunks: ['index'] // 配置chunk,防止同時載入
    }),
    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/album.html',
      filename: 'album.html',
      chunks: ['album']
    })
  ]
}
  • Webpack 按需載入

按需載入是我們在開發中常見的需求,我們在處理打包的時候,我們可以需要哪個模組是,再載入哪個模組。Webpack 中支援動態匯入的方式去支援按需載入我們的模組,所有動態載入的模組都會被自動分包,相比於分包載入的方式,動態載入的方式更加靈活。
例如我們有兩個模組 album 和 posts,我們就可以使用 import 去實現動態匯入,import 返回一個 promise 物件,

// ./posts/posts.js
export default () => {
    const posts = document.createElement('div')
    posts.className = 'posts'
    ...
    return posts
}
// ./album/album.js
export default () => {
    const album = document.createElement('div')
    album.className = 'album'
    ...
    return album
}
// import posts from './posts/posts'
// import album from './album/album'

const render = () => {
  const hash = window.location.hash || '#posts'

  const mainElement = document.querySelector('.main')

  mainElement.innerHTML = ''

  if (hash === '#posts') {
    // mainElement.appendChild(posts())\
    // 魔法註釋:給模組重新命名
    import(/* webpackChunkName: 'components' */'./posts/posts').then(({ default: posts }) => {
      mainElement.appendChild(posts())
    })
  } else if (hash === '#album') {
    // mainElement.appendChild(album())
    import(/* webpackChunkName: 'components' */'./album/album').then(({ default: album }) => {
      mainElement.appendChild(album())
    })
  }
}

render()

window.addEventListener('hashchange', render)

css 的模組化打包

  • MiniCssExtractPlugin 是一個能夠將 css 檔案從打包檔案中單獨提取出來的外掛,通過這個外掛我們就可以實現 css 模組的按需載入。
  • optimize-css-assets-webpack-plugin 是一個能夠壓縮 css 檔案的外掛,因為使用了 MiniCssExtractPlugin 之後,就不需要使用 style 標籤的形式去載入 css 了,所以我們就不需要 style-loader 了
  • terser-webpack-plugin 因為 optimize-css-assets-webpack-plugin 是需要使用在 optimization 的 minimizer 中的,而開啟了 optimization,Webpack 就會認為我們的壓縮程式碼需要自己配置,所以 js 檔案就不會壓縮了,所以我們需要安裝 terser-webpack-plugin 再去壓縮 js 程式碼
// 安裝 mini-css-extract-plugin
yarn add mini-css-extract-plugin --dev
// 安裝 optimize-css-assets-webpack-plugin
yarn add optimize-css-assets-webpack-plugin --dev
// 安裝 terser-webpack-plugin
yarn add terser-webpack-plugin --dev

接下來我們就可以配置它們了

const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
const TerserWebpackPlugin = require('terser-webpack-plugin')

module.exports = {
  mode: 'none',
  entry: {
    main: './src/index.js'
  },
  output: {
    filename: '[name].bundle.js'
  },
  optimization: {
    minimizer: [
      new TerserWebpackPlugin(), // 壓縮 js 程式碼
      new OptimizeCssAssetsWebpackPlugin() // 壓縮模組化的 css 程式碼
    ]
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          // 'style-loader', // 將樣式通過 style 標籤注入
          MiniCssExtractPlugin.loader, // 使用 MiniCssExtractPlugin 的 loader 就不需要 style-loader 了
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: 'Dynamic import',
      template: './src/index.html',
      filename: 'index.html'
    }),
    new MiniCssExtractPlugin()
  ]
}

輸出 hash 檔名

一般我們去部署前端的資原始檔的時候,我們都會啟用伺服器的靜態資源快取,這樣對於使用者的瀏覽器而言就可以快取住我們的靜態資源,後續就不再需要請求伺服器去請求這些靜態資源了,這樣我們的應用的相應速度就會有一個大幅度的提升。不過開啟客戶端的靜態資源快取也會有問題,如果我們在設定快取時間過短的話,那麼快取就沒什麼意義了,而設定過長的話,一旦應用發生了更新,就沒有辦法即時更新到客戶端。為了解決這個問題,我們就需要在生產模式下,為檔名使用 hash,這樣一旦我們的檔案資源發生改變,我們的檔名稱也會隨之發生改變,而對於客戶端而言,全新的檔名也就意味著全新的請求,這樣我們就可以將快取時間設定的非常長,也不用去擔心檔案不更新的問題。

  • hash
    專案級別的 hash,一旦任何檔案發生修改,都會生成新的 hash

  • chunkhash
    只要是同一路的打包,hash 都是相同的,例如一個模組內的 js 和 css 的 hash 字首都是相同的

  • contenthash
    檔案級別的 hash,根據檔案內容輸出的 hash 值,只要是不同的檔案就有不同的 hash 值,這也是最推薦的 hash 方式

const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
const TerserWebpackPlugin = require('terser-webpack-plugin')

module.exports = {
  mode: 'none',
  entry: {
    main: './src/index.js'
  },
  output: {
    filename: '[name]-[contenthash:8].bundle.js'
  },
  optimization: {
      ...
  },
  module: {
      ...
  },
  plugins: [
    ...
    new MiniCssExtractPlugin({
      filename: '[name]-[contenthash:8].bundle.css'
    })
  ]
}