深入淺出 webpack(vue 專案優化)

fairyly發表於2020-01-10

深入淺出 webpack

最近在寫一下對自己的思考,發現還有兩篇草稿沒發,記得應該是去年年初的時候寫的,後來公司一直忙,就很少來社群了,今天先發一篇了,這篇記得當時還找到作者,加了微信

webpack 專案優化

webpack 版本不同,配置也會有一些地方不一樣的,這裡是 webpack 4

  • 1.優化構建速度。在專案龐大時構建耗時可能會變的很長,每次等待構建的耗時加起來也會是個大數目。
    • 4-1 縮小檔案搜尋範圍
    • 4-2 使用 DllPlugin
    • 4-3 使用 HappyPack
    • 4-4 使用 ParallelUglifyPlugin
  • 2.優化使用體驗。通過自動化手段完成一些重複的工作,讓我們專注於解決問題本身。
    • 4-5 使用自動重新整理
    • 4-6 開啟模組熱替換
  • 3.優化輸出質量 優化輸出質量的目的是為了給使用者呈現體驗更好的網頁,例如減少首屏載入時間、提升效能流暢度等。
    這至關重要,因為在網際網路行業競爭日益激烈的今天,這可能關係到你的產品的生死。 優化輸出質量本質是優化構建輸出的要釋出到線上的程式碼,分為以下幾點:
    • 減少使用者能感知到的載入時間,也就是首屏載入時間。
      • 4-7 區分環境
      • 4-8 壓縮程式碼
      • 4-9 CDN 加速
      • 4-10 使用 Tree Shaking
      • 4-11 提取公共程式碼
      • 4-12 按需載入
    • 提升流暢度,也就是提升程式碼效能。
      • 4-13 使用 Prepack
      • 4-14 開啟 Scope Hoisting
    • 優化的關鍵是找出問題所在,這樣才能一針見血,
      • 4-15 輸出分析 教你如何利用工具快速找出問題所在。
    • 4-16 優化總結 對以上的優化方法做一個總結

4-1 縮小檔案搜尋範圍

  • 4-1-1 優化 loader 配置
     為了儘可能少的讓檔案被 Loader 處理,可以通過 include 去命中只有哪些檔案需要被處理。
    複製程式碼
  • 4-1-2 優化 resolve.alias 配置
  • 4-1-3 優化 resolve.extensions 配置

4-2 使用DllPlugin

用過 Windows 系統的人應該會經常看到以 .dll 為字尾的檔案,這些檔案稱為動態連結庫,
在一個動態連結庫中可以包含給其他模組呼叫的函式和資料。

要給 Web 專案構建接入動態連結庫的思想,需要完成以下事情:

  • 把網頁依賴的基礎模組抽離出來,打包到一個個單獨的動態連結庫中去。一個動態連結庫中可以包含多個模組。
  • 當需要匯入的模組存在於某個動態連結庫中時,這個模組不能被再次被打包,而是去動態連結庫中獲取。
  • 頁面依賴的所有動態連結庫需要被載入。

為什麼給 Web 專案構建接入動態連結庫的思想後,會大大提升構建速度呢? 原因在於包含大量複用模組的動態連結庫只需要編譯一次,
在之後的構建過程中被動態連結庫包含的模組將不會在重新編譯,
而是直接使用動態連結庫中的程式碼

4-3 使用 HappyPack

分解任務和管理執行緒的事情 HappyPack 都會幫你做好

4-4 使用 ParallelUglifyPlugin

用過 UglifyJS 的你一定會發現在構建用於開發環境的程式碼時很快就能完成,
但在構建用於線上的程式碼時構建一直卡在一個時間點遲遲沒有反應,其實卡住的這個時候就是在進行程式碼壓縮。

由於壓縮 JavaScript 程式碼需要先把程式碼解析成用 Object 抽象表示的 AST 語法樹,
再去應用各種規則分析和處理 AST,導致這個過程計算量巨大,耗時非常多。

為什麼不把在4-3 使用 HappyPack中介紹過的多程式並行處理的思想也引入到程式碼壓縮中呢?

ParallelUglifyPlugin 就做了這個事情。
當 Webpack 有多個 JavaScript 檔案需要輸出和壓縮時,原本會使用 UglifyJS 去一個個挨著壓縮再輸出,
但是 ParallelUglifyPlugin 則會開啟多個子程式,把對多個檔案的壓縮工作分配給多個子程式去完成,
每個子程式其實還是通過 UglifyJS 去壓縮程式碼,但是變成了並行執行。
所以 ParallelUglifyPlugin 能更快的完成對多個檔案的壓縮工作。

使用 ParallelUglifyPlugin 也非常簡單,把原來 Webpack 配置檔案中內建的 UglifyJsPlugin 去掉後,再替換成 ParallelUglifyPlugin,

不過看到 GitHub 上說是支援並行的uglifyjs-webpack-plugin/#parallel

4-5 使用自動重新整理

要讓 Webpack 開啟監聽模式,有兩種方式: 在配置檔案 webpack.*.config.js 中設定 watch: true。 在執行啟動 Webpack 命令時,帶上 --watch 引數,完整命令是 webpack --watch

檔案監聽工作原理: 在 Webpack 中監聽一個檔案發生變化的原理是定時的去獲取這個檔案的最後編輯時間,
每次都存下最新的最後編輯時間,如果發現當前獲取的和最後一次儲存的最後編輯時間不一致,就認為該檔案發生了變化。
配置項中的 watchOptions.poll 就是用於控制定時檢查的週期,具體含義是每隔多少毫秒檢查一次。

  • 優化檔案監聽效能
watchOptions: {
  // 不監聽的 node_modules 目錄下的檔案
  ignored: /node_modules/,
}
複製程式碼

4-6 開啟模組熱替換

webpack 內建外掛 HotModuleReplacementPlugin,
配置 devServer

  // these devServer options should be customized in /config/index.js
  devServer: {
    clientLogLevel: 'warning',
    historyApiFallback: {
      rewrites: [
        { from: /.*/, to: path.posix.join(config.dev.assetsPublicPath, 'index.html') },
      ],
    },
    hot: true,
複製程式碼

要優化模組熱替換的構建效能,思路和在4-5 使用自動重新整理中提到的很類似:
監聽更少的檔案,忽略掉 node_modules 目錄下的檔案。
但是其中提到的關閉預設的 inline 模式手動注入代理客戶端的優化方法不能用於在使用模組熱替換的情況下,
原因在於模組熱替換的執行依賴在每個 Chunk 中都包含代理客戶端的程式碼。

4-7 區分環境

4-8 壓縮程式碼

要在 Webpack 中接入 UglifyJS 需要通過外掛的形式,目前有兩個成熟的外掛,分別是:
UglifyJsPlugin:通過封裝 UglifyJS 實現壓縮。
ParallelUglifyPlugin:多程式並行處理壓縮

  • 壓縮 CSS

    把 cssnano 接入到 Webpack 中也非常簡單,因為 css-loader 已經將其內建了,
    要開啟 cssnano 去壓縮程式碼只需要開啟 css-loader 的 minimize 選項

const path = require('path');
const {WebPlugin} = require('web-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,// 增加對 CSS 檔案的支援
        // 提取出 Chunk 中的 CSS 程式碼到單獨的檔案中
        use: ExtractTextPlugin.extract({
          // 通過 minimize 選項壓縮 CSS 程式碼
          use: ['css-loader?minimize']
        }),
      },
    ]
  },
  plugins: [
    // 用 WebPlugin 生成對應的 HTML 檔案
    new WebPlugin({
      template: './template.html', // HTML 模版檔案所在的檔案路徑
      filename: 'index.html' // 輸出的 HTML 的檔名稱
    }),
    new ExtractTextPlugin({
      filename: `[name]_[contenthash:8].css`,// 給輸出的 CSS 檔名稱加上 Hash 值
    }),
  ],
}
複製程式碼
  • 壓縮 ES6

4-9 CDN 加速

之前的相對路徑,都變成了絕對的指向 CDN 服務的 URL 地址,配置中的path 也需要換成 CDN 地址字首

4-10 使用 Tree Shaking

Tree Shaking 可以用來剔除 JavaScript 中用不上的死程式碼。它依賴靜態的 ES6 模組化語法

4-11 提取公共程式碼

Webpack 內建了專門用於提取多個 Chunk 中公共部分的外掛 CommonsChunkPlugin,CommonsChunkPlugin 大致使用方法如下:

const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');

new CommonsChunkPlugin({
  // 從哪些 Chunk 中提取
  chunks: ['a', 'b'],
  // 提取出的公共部分形成一個新的 Chunk,這個新 Chunk 的名稱
  name: 'common'
})
複製程式碼

4-12 按需載入

router 按需載入

4-13 使用 Prepack

在前面的優化方法中提到了程式碼壓縮和分塊,這些都是在網路載入層面的優化,
除此之外還可以優化程式碼在執行時的效率,Prepack 就是為此而生。
Prepack 由 Facebook 開源,它採用較為激進的方法:
在保持執行結果一致的情況下,改變原始碼的執行邏輯,輸出效能更高的 JavaScript 程式碼。
實際上 Prepack 就是一個部分求值器,編譯程式碼時提前將計算結果放到編譯後的程式碼中,而不是在程式碼執行時才去求值。

Prepack 通過在編譯階段預先執行了原始碼得到執行結果,再直接把執行結果輸出來以提升效能

  • Prepack 的工作原理和流程大致如下:

    通過 Babel 把 JavaScript 原始碼解析成抽象語法樹(AST),以方便更細粒度地分析原始碼;
    Prepack 實現了一個 JavaScript 直譯器,用於執行原始碼。
    藉助這個直譯器 Prepack 才能掌握原始碼具體是如何執行的,並把執行過程中的結果返回到輸出中。
    從表面上看去這似乎非常美好,但實際上 Prepack 還不夠成熟與完善。

  • Prepack 目前還處於初期的開發階段,侷限性也很大,例如:

    • 不能識別 DOM API 和 部分 Node.js API,如果原始碼中有呼叫依賴執行環境的 API 就會導致 Prepack 報錯;
    • 存在優化後的程式碼效能反而更低的情況;
    • 存在優化後的程式碼檔案尺寸大大增加的情況。
    • 總之,現在把 Prepack 用於線上環境還為時過早
  • 接入 Webpack

      const PrepackWebpackPlugin = require('prepack-webpack-plugin').default;
    
      module.exports = {
        plugins: [
          new PrepackWebpackPlugin()
        ]
      };
    複製程式碼

4-14 開啟 Scope Hoisting

Scope Hoisting 可以讓 Webpack 打包出來的程式碼檔案更小、執行的更快, 它又譯作 "作用域提升",
是在 Webpack3 中新推出的功能

好處是:

  • 程式碼體積更小,因為函式申明語句會產生大量程式碼;

  • 程式碼在執行時因為建立的函式作用域更少了,記憶體開銷也隨之變小。

  • Scope Hoisting 的實現原理其實很簡單: 分析出模組之間的依賴關係,儘可能的把打散的模組合併到一個函式中去,但前提是不能造成程式碼冗餘。
    因此只有那些被引用了一次的模組才能被合併。

由於 Scope Hoisting 需要分析出模組之間的依賴關係,因此原始碼必須採用 ES6 模組化語句,不然它將無法生效。
原因和4-10 使用 TreeShaking 中介紹的類似。

4-15 輸出分析

為了更簡單直觀的分析輸出結果,社群中出現了許多視覺化的分析工具。
這些工具以圖形的方式把結果更加直觀的展示出來,讓你快速看到問題所在。

兩種分析工具:

4-15-1 生成 stats.json

在啟動 Webpack 時帶上以上兩個引數,啟動命令如下:

  webpack --profile --json > stats.json,
複製程式碼

如果沒有問題,你會發現專案中多出了一個 stats.json 檔案。
這個 stats.json 檔案是給後面介紹的視覺化分析工具使用的。

可是我在 vue 專案中使用時出現了一個問題

web>webpack --profile --json > stats.json
No configuration file found and no output filename configured via CLI option.
A configuration file could be named 'webpack.config.js' in the current directory
.
Use --help to display the CLI options.
複製程式碼
  • 它的意思是,假如沒有指定配置檔案,會在當前目錄尋找webpack.config.js 作為配置檔案
  • 解決: 使用 config 指定配置檔案,
      webpack --config ./build/webpack.dev.conf.js --json > stats.json
    複製程式碼

webpack --profile --json 會輸出字串形式的 JSON,
stats.json 是 UNIX/Linux 系統中的管道命令,
含義是把 webpack --profile --json 輸出的內容通過管道輸出到 stats.json 檔案中。

4-15-2 官方的視覺化分析工具: Webpack Analyse: 線上 Web 應用

開啟 Webpack Analyse 連結的網頁後,你就會看到一個彈窗提示你上傳 JSON 檔案,
也就是需要上傳上面講到的 stats.json 檔案

4-15-3 webpack-bundle-analyzer

發現 vue-cli 2 版本中 webpack.prod.conf.js 裡面有關於是否開啟 webpack-bundle-analyzer 配置; 也就是說 npm run build --report 的時候,BundleAnalyzerPlugin 能以視覺化的方式展示打包結果;

如果單獨使用 webpack-bundle-analyzer:

  • 1.安裝 webpack-bundle-analyzer 到全域性,執行命令 npm i -g webpack-bundle-analyzer;
  • 2.按照上面提到的方法生成 stats.json 檔案;
  • 3.在專案根目錄中執行 webpack-bundle-analyzer 後,瀏覽器會開啟對應網頁看到以上效果

4-16 優化總結

按照開發環境和線上環境為該專案配置了兩份檔案,下面是使用 webpack4 版本

  • 側重優化開發體驗的配置檔案 webpack.config.js:
const path = require('path');
const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');
const {AutoWebPlugin} = require('web-webpack-plugin');
const HappyPack = require('happypack');

// 自動尋找 pages 目錄下的所有目錄,把每一個目錄看成一個單頁應用
const autoWebPlugin = new AutoWebPlugin('./src/pages', {
  // HTML 模版檔案所在的檔案路徑
  template: './template.html',
  // 提取出所有頁面公共的程式碼
  commonsChunk: {
    // 提取出公共程式碼 Chunk 的名稱
    name: 'common',
  },
});

module.exports = {
  // AutoWebPlugin 會找為尋找到的所有單頁應用,生成對應的入口配置,
  // autoWebPlugin.entry 方法可以獲取到生成入口配置
  entry: autoWebPlugin.entry({
    // 這裡可以加入你額外需要的 Chunk 入口
    base: './src/base.js',
  }),
  output: {
    filename: '[name].js',
  },
  resolve: {
    // 使用絕對路徑指明第三方模組存放的位置,以減少搜尋步驟
    // 其中 __dirname 表示當前工作目錄,也就是專案根目錄
    modules: [path.resolve(__dirname, 'node_modules')],
    // 針對 Npm 中的第三方模組優先採用 jsnext:main 中指向的 ES6 模組化語法的檔案,使用 Tree Shaking 優化
    // 只採用 main 欄位作為入口檔案描述欄位,以減少搜尋步驟
    mainFields: ['jsnext:main', 'main'],
  },
  module: {
    rules: [
      {
        // 如果專案原始碼中只有 js 檔案就不要寫成 /\.jsx?$/,提升正規表示式效能
        test: /\.js$/,
        // 使用 HappyPack 加速構建
        use: ['happypack/loader?id=babel'],
        // 只對專案根目錄下的 src 目錄中的檔案採用 babel-loader
        include: path.resolve(__dirname, 'src'),
      },
      {
        test: /\.js$/,
        use: ['happypack/loader?id=ui-component'],
        include: path.resolve(__dirname, 'src'),
      },
      {
        // 增加對 CSS 檔案的支援
        test: /\.css$/,
        use: ['happypack/loader?id=css'],
      },
    ]
  },
  plugins: [
    autoWebPlugin,
    // 使用 HappyPack 加速構建
    new HappyPack({
      id: 'babel',
      // babel-loader 支援快取轉換出的結果,通過 cacheDirectory 選項開啟
      loaders: ['babel-loader?cacheDirectory'],
    }),
    new HappyPack({
      // UI 元件載入拆分
      id: 'ui-component',
      loaders: [{
        loader: 'ui-component-loader',
        options: {
          lib: 'antd',
          style: 'style/index.css',
          camel2: '-'
        }
      }],
    }),
    new HappyPack({
      id: 'css',
      // 如何處理 .css 檔案,用法和 Loader 配置中一樣
      loaders: ['style-loader', 'css-loader'],
    }),
    // 4-11提取公共程式碼
    new CommonsChunkPlugin({
      // 從 common 和 base 兩個現成的 Chunk 中提取公共的部分
      chunks: ['common', 'base'],
      // 把公共的部分放到 base 中
      name: 'base'
    }),
  ],
  watchOptions: {
    // 4-5使用自動重新整理:不監聽的 node_modules 目錄下的檔案
    ignored: /node_modules/,
  }
};
複製程式碼
  • 側重優化輸出質量的配置檔案 webpack-dist.config.js:
const path = require('path');
const DefinePlugin = require('webpack/lib/DefinePlugin');
const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');
const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const {AutoWebPlugin} = require('web-webpack-plugin');
const HappyPack = require('happypack');
const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin');

// 自動尋找 pages 目錄下的所有目錄,把每一個目錄看成一個單頁應用
const autoWebPlugin = new AutoWebPlugin('./src/pages', {
  // HTML 模版檔案所在的檔案路徑
  template: './template.html',
  // 提取出所有頁面公共的程式碼
  commonsChunk: {
    // 提取出公共程式碼 Chunk 的名稱
    name: 'common',
  },
  // 指定存放 CSS 檔案的 CDN 目錄 URL
  stylePublicPath: '//css.cdn.com/id/',
});

module.exports = {
  // AutoWebPlugin 會找為尋找到的所有單頁應用,生成對應的入口配置,
  // autoWebPlugin.entry 方法可以獲取到生成入口配置
  entry: autoWebPlugin.entry({
    // 這裡可以加入你額外需要的 Chunk 入口
    base: './src/base.js',
  }),
  output: {
    // 給輸出的檔名稱加上 Hash 值
    filename: '[name]_[chunkhash:8].js',
    path: path.resolve(__dirname, './dist'),
    // 指定存放 JavaScript 檔案的 CDN 目錄 URL
    publicPath: '//js.cdn.com/id/',
  },
  resolve: {
    // 使用絕對路徑指明第三方模組存放的位置,以減少搜尋步驟
    // 其中 __dirname 表示當前工作目錄,也就是專案根目錄
    modules: [path.resolve(__dirname, 'node_modules')],
    // 只採用 main 欄位作為入口檔案描述欄位,以減少搜尋步驟
    mainFields: ['jsnext:main', 'main'],
  },
  module: {
    rules: [
      {
        // 如果專案原始碼中只有 js 檔案就不要寫成 /\.jsx?$/,提升正規表示式效能
        test: /\.js$/,
        // 使用 HappyPack 加速構建
        use: ['happypack/loader?id=babel'],
        // 只對專案根目錄下的 src 目錄中的檔案採用 babel-loader
        include: path.resolve(__dirname, 'src'),
      },
      {
        test: /\.js$/,
        use: ['happypack/loader?id=ui-component'],
        include: path.resolve(__dirname, 'src'),
      },
      {
        // 增加對 CSS 檔案的支援
        test: /\.css$/,
        // 提取出 Chunk 中的 CSS 程式碼到單獨的檔案中
        use: ExtractTextPlugin.extract({
          use: ['happypack/loader?id=css'],
          // 指定存放 CSS 中匯入的資源(例如圖片)的 CDN 目錄 URL
          publicPath: '//img.cdn.com/id/'
        }),
      },
    ]
  },
  plugins: [
    autoWebPlugin,
    // 4-14開啟ScopeHoisting
    new ModuleConcatenationPlugin(),
    // 4-3使用HappyPack
    new HappyPack({
      // 用唯一的識別符號 id 來代表當前的 HappyPack 是用來處理一類特定的檔案
      id: 'babel',
      // babel-loader 支援快取轉換出的結果,通過 cacheDirectory 選項開啟
      loaders: ['babel-loader?cacheDirectory'],
    }),
    new HappyPack({
      // UI 元件載入拆分
      id: 'ui-component',
      loaders: [{
        loader: 'ui-component-loader',
        options: {
          lib: 'antd',
          style: 'style/index.css',
          camel2: '-'
        }
      }],
    }),
    new HappyPack({
      id: 'css',
      // 如何處理 .css 檔案,用法和 Loader 配置中一樣
      // 通過 minimize 選項壓縮 CSS 程式碼
      loaders: ['css-loader?minimize'],
    }),
    new ExtractTextPlugin({
      // 給輸出的 CSS 檔名稱加上 Hash 值
      filename: `[name]_[contenthash:8].css`,
    }),
    // 4-11提取公共程式碼
    new CommonsChunkPlugin({
      // 從 common 和 base 兩個現成的 Chunk 中提取公共的部分
      chunks: ['common', 'base'],
      // 把公共的部分放到 base 中
      name: 'base'
    }),
    new DefinePlugin({
      // 定義 NODE_ENV 環境變數為 production 去除 react 程式碼中的開發時才需要的部分
      'process.env': {
        NODE_ENV: JSON.stringify('production')
      }
    }),
    // 使用 ParallelUglifyPlugin 並行壓縮輸出的 JS 程式碼
    new ParallelUglifyPlugin({
      // 傳遞給 UglifyJS 的引數
      uglifyJS: {
        output: {
          // 最緊湊的輸出
          beautify: false,
          // 刪除所有的註釋
          comments: false,
        },
        compress: {
          // 在UglifyJs刪除沒有用到的程式碼時不輸出警告
          warnings: false,
          // 刪除所有的 `console` 語句,可以相容ie瀏覽器
          drop_console: true,
          // 內嵌定義了但是隻用到一次的變數
          collapse_vars: true,
          // 提取出出現多次但是沒有定義成變數去引用的靜態值
          reduce_vars: true,
        }
      },
    }),
  ]
};
複製程式碼

吳浩麟擁有本書的著作權。
其它人不能將本書用於商用用途,不能轉載,不能以任何形式發行,違者將追究法律責任。

參考

相關文章