用了這麼久的webpack,你不會還沒掌握原理?

語言: CN / TW / HK

一、基本要素

1、Entry/Output

1.1、單入口配置

module.exports = {
  entry: './src/index.js', // 打包的入口檔案
  output: './dist/main.js', // 打包的輸出
};

1.2、多入口配置

const path = require('path');

module.exports = {
  entry: {
    app: './src/app.js',
    admin: './src/admin.js',
  },
  output: {、
    filename: '[name].[hash].js', //通過佔位符確保檔名稱的唯一,可選擇設定hash
    path: path.join(__dirname, 'dist'),
    // publicPath用於設定載入靜態資源的baseUrl,例如prod模式下指向cdn,dev模式下指向本地服務
    publicPath: process.env.NODE_ENV === 'production' ? `//cdn.xxx.com` : '/',  // 
  },
};

2、Loaders

Loaders函式接收檔案型別作為引數,返回轉換的結果。目前webpack支援的兩種型別分別為JSJSON,其它型別均需轉換

2.1、通配Loaders

module:{
  rules:[
    {test:/.\(js|jsx|ts|tsx)$/,use:'ts-loader'} // 例如ts使用ts-loader
  ]
},

2.2、內聯Loaders

Loaders 還可以直接內聯到程式碼中使用:

import 'style-loader!css-loader!less-loader!./style.less';

2.3、多個Loaders

多個 Loaders 之間執行順序是和 rules 配置相反的,即從右向左執行

2.3.1、原始碼邏輯

loader 先進後出,對應出棧順序從右向左

if (matchResourceData === undefined) {
  for (const loader of loaders) allLoaders.push(loader);
  for (const loader of normalLoaders) allLoaders.push(loader);
} else {
  for (const loader of normalLoaders) allLoaders.push(loader);
  for (const loader of loaders) allLoaders.push(loader); // 入棧
}
for (const loader of preLoaders) allLoaders.push(loader); // pre loaders入棧
2.3.2、更改順序

通過配置 enforce 改變執行順序,enforce有四個列舉值,其執行順序是prenormalinlinepost

module:{
  rules:[
     {
        test:/\.less$/,
        loader:'less-loader',
        enforce:'pre' // 預處理
    },
    {
        test: /\.less$/,
        loader:'css-loader',
        enforce:'normal' // 預設是normal
    },
    {
        test: /\.less$/,
        loader:'style-loader',
        enforce:'post' // 後處理
    },
  ]
},

3、Plugins

Plugins負責優化bundle檔案、資源管理和環境變數注入,webpack 內建了很多 plugin。例如 DefinePlugin 全域性變數注入外掛、IgnorePlugin 排除檔案外掛、ProgressPlugin 打包進度條外掛等

plugins: [new HtmlwebpackPlugin({ template: './src/index.html' })];

4、Mode

指定當前的構建環境,有三個選項,分別是:productiondevelopmentnone,當 modeproduction 時會啟用內建優化外掛,比如TreeShakingScopeHoisting、壓縮外掛等

module.exports = {
  mode: 'production', // 會寫入到環境變數NODE_ENV
};

也可以通過 webpack cli 引數設定

webpack --mode=production  

二、熱更新

1、更新流程

熱更新的原理

1.1、啟動階段 1 -> 2 -> A -> B

  • 通過WebpackCompileJS檔案進行編譯成Bundle
  • Bundle檔案執行在Bundle Server,使得檔案可通過localhost://xxx訪問
  • 接著構建輸出bundle.js檔案給到瀏覽器

1.2、熱更新階段 1 -> 2 -> 3 -> 4

  • WebpackCompileJS檔案進行編譯成Bundle
  • Bundle檔案執行在HMR Server
  • 一旦磁盤裡面的檔案修改,就將有修改的資訊輸出給HMR Runtime
  • 接著HMR Runtime區域性更新檔案的變化

2、配置方式

2.1、WDS + HotMoudleReplacementPlugin

2.1.1、WDS(webpack-dev-server)

WDS 提供了 bundle server 的能力,不輸出檔案,而是放在記憶體中,即生成的 bundle.js 檔案可以通過 localhost://xxx 的方式去訪問,同時它提供的livereload能力,使得瀏覽器能夠自動重新整理

// package.json
"scripts":{
  "dev":"webpack-dev-server --open"
}
2.1.2、HotMoudleReplacementPlugin 外掛

HotMoudleReplacementPlugin外掛給 WDS 提供了熱更新的能力,源自它擁有區域性更新頁面能力的HMR Runtime。一旦磁盤裡面的檔案修改,HMR Server就將有修改的js module資訊傳送給HMR Runtime

// webpack.dev.js  僅在開發環境使用
module.exports = {
  mode: 'development',
  plugins: [new webpack.HotModuleReplacementPlugin()],
  devServer: {
    contentBase: './dist', //服務基礎目錄
    hot: true, //開啟熱更新
  },
};
2.1.3、互動邏輯

監聽到檔案修改時,HotMoudleReplacementPlugin 會生成一個 mainifestupdate file,其中 mainifest描述了發生變化的 modules ,緊接著webpack-dev-server通過 websocket 通知 client 更新程式碼,client 使用 jsonp 請求 server 獲取更新後的程式碼

2.2、WDM(webpack-dev-middleware)

WDMwebpack 輸出的檔案傳輸給伺服器,適用於靈活的定製場景

const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');

const app = express();
const config = require('./webpack.config.js');
const compiler = webpack(config);

app.use(
  webpackDevMiddleware(compiler, {
    publicPath: config.output.publicPath,
  }),
);

app.listen(3000, function () {
  console.log('listening on port 3000');
});

三、檔案指紋

檔案指紋主要用於版本管理,表現於打包後文件名的字尾,如xxx//xxx_51773db.js中的51773db

1、三種類型

型別 含義
Hash 和整個專案的構建相關,只要專案檔案有修改,整個專案構建的 hash 值就會更改
Chunkhash 和 webpack 打包的 chunk 有關,不同的 entry 會生成不同的 chunkhash 值
Contenthash 根據檔案內容來定義 hash,檔案內容不變,則 contenthash 不變

2、常用場景

  • 設定outputfilename,使用[chunkhash]
filename: '[name][chunkhash:8].js';
  • 設定MiniCssExtractPluginfilename,使用[contenthash]
new MiniCssExtractPlugin({
  filename: `[name][contenthash:8].css`,
});
  • 設定file-loadername,使用[hash]
rules: [
  {
    test: /\.(png|svg|jpg|gif)$/,
    use: [
      {
        loader: 'file-loader',
        options: {
          name: 'img/[name][hash:8].[ext]',
        },
      },
    ],
  },
];
// 佔位符解釋:[name]:檔名稱,[ext]:資源字尾名

注意喔:hash是由程式碼和路徑生成的。因此相同的程式碼在多臺機器打包部署 hash 會不同,導致資源載入 404。一般通過一臺機器打包,分發部署到不同機器

四、SourceMap

1、開啟配置

開發環境開啟,線上環境關閉。線上排查問題的時候可以將 source map 上傳到錯誤監控系統

module.exports = {
  devtool: 'source-map',
};

2、型別

型別 說明
cheap-source-map 沒有列號,只有行號,速度快
cheap-module-source-map 優化後的 cheap-source-map,避免 babel 等編譯過程式碼行號對不上
eval 通過內聯程式碼 eval 函式 baseURL 確定程式碼路徑
eval-source-map sourcemap 放在 eval 函式後
inline-source-map 放在打包程式碼最後

3、檔案格式

利用 mappings 對映表和 namessourcesContent 就可以還原出原始碼字串

{
  "version": 3, // Source Map版本
  "file": "out.js", // 輸出檔案(可選)
  "sourceRoot": "", // 原始檔根目錄(可選)
  "sources": ["foo.js", "bar.js"], // 原始檔列表
  "sourcesContent": [null, null], // 源內容列表(可選,和原始檔列表順序一致)
  "names": ["src", "maps", "are", "fun"], // mappings使用的符號名稱列表
  "mappings": "A,AAAB;;ABCDE;" // 帶有編碼對映資料的字串
}

五、TreeShaking

  • 程式碼不會被執行,不可到達
  • 程式碼執行的結果不會被用到
  • 程式碼只會影響死變數(只寫不讀)

TreeShaking會將以上視為廢棄的程式碼在uglify階段消除

mode 設定為 production的情況下,是預設開啟的。通過在.babelrc裡設定modules:false進行取消

TreeShaking是利用 ES6 模組的特點進行清除

  • import 只能作為模組頂層的語句出現,且模組名只能是字串常量

import 匯入模組是靜態載入,其獲取的是變數引用,即當模組內部變更時,import出的變數也會變更。因此 import 不能出現在條件、函式等語句中( export類似),而 commonjsrequire 獲取的是模組的快取

  • import bindingimmutable

六、模組機制

webpack打包後,會給模組加上一層包裹,import 會被轉換成__webpack_require

webpack模組轉換

1、匿名閉包

webpack打包後是一個匿名閉包,接收的引數 modules 是一個數組,每一項是一個模組初始化函式。通過__webpack_require載入模組,並返回modules.exports

webpack的模組機制

modules 的每個模組成員都是用 __webpack_require__ 載入的,installedModules 是載入模組的快取,如果已經__webpack_require__載入過無需再次載入。

2、ScopeHoisting

構建後的程式碼存在大量的閉包程式碼,導致執行時建立的函式作用域增多,記憶體開銷大,ScopeHoisting 將所有模組的程式碼按照引用順序放在一個函式作用域裡,然後適當的重新命名一些變數以防止變數名衝突,從而減少函式宣告程式碼和記憶體開銷

七、SSR

SEO友好的服務端渲染SSR的核心是減少請求,從而減少白屏時間。其實現原理是:服務端通過react-dom/serverrenderToString方法將React元件渲染成字串,返回路由對應的模版。協助的客戶端通過打包,生成針對服務端的元件

renderToString 攜帶有 data-reactid 屬性可配合 hydrate 使用,會複用之前節點只進行事件繫結從而優化首次渲染速度。類似的方法還有 renderToStaticMarkup

1、相容問題

1.1、瀏覽器的全域性變數

  • node.js中沒有 documentwindow,需通過打包環境進行適配

react ssr 應用中,讀取 documentwindow 可以在 useEffectcomponentDidMount 中進行,當 nodejs 渲染時就會跳過這些執行,避免報錯

  • 使用isomorphic-fetchaxios 替換 fetchxhr

1.2、樣式問題

  • node.js 無法解析 css,可使用ignore-loader忽略 css 的解析

對於 antd 元件庫,在babel-plugin-import 設定 stylefalse

  • 使用 isomorphic-style-loader 替換 style-loader

2、兩端協作

使用打包後的HTML為模板,服務端獲取資料後替換佔位符

<body>
  <div id="root">
    <!--HTML_PLACEHOLDER-->
  </div>
  <!--INITIAL_DATA_PLACEHOLDER-->
</body>

八、常見優化措施

1、程式碼壓縮

1.1、JS 檔案的壓縮

  • 內建了uglifyjs-webpack-plugin

  • CommonsChunkPlugin 提取 chunks 中的公共模組減少總體積

1.2、CSS 檔案的壓縮

  • 使用optimize-css-assets-webpack-plugin,同時使用cssnano

  • extract-text-webpack-plugincss 從產物中分離。

1.3、html 檔案的壓縮

html-webpack-plugin 通常用來定義 html 模板,也可以設定壓縮 minify 引數(production 模式下自動設定 true

1.4、圖片壓縮

使用image-webpack-loader

2、自動清理構建目錄

利用 CleanWebpackPlugin 自動清理 output 指定的輸出目錄

3、靜態資源內聯

首屏渲染的樣式儘量選擇內聯或使用 styled-components。資源內聯可減少請求數,可避免首屏頁面閃動,可進行相關上報打點,可初始化指令碼

3.1、程式碼層面

  • raw-loader:js/html 內聯
  • style-loader: css 內聯

3.2、請求層面

  • url-loader:小圖片或字型內聯

  • file-loader:可以解析專案中的 url 引入路徑,修改打包後文件引用路徑,指向輸出的檔案。

4、基礎庫分離

4.1、HtmlWebpackExternalsPlugin

將基礎包通過cdn,而不壓縮排bundle

plugins: [
  new HtmlWebpackExternalsPlugin({
    externals: [
      {
        module: 'react',
        entry: '//11.url.cn/now/lib/15.1.0/react-with-addons.min.js?_bid=3123',
        global: 'React',
      },
    ],
  }),
];

4.2、SplitChunksPlugin

可將公共指令碼、基礎包以及頁面公共檔案分離

splitChunks:{
  chunks:'async',// async:非同步引入的庫進行分離(預設)  initial:同步引入的庫進行分離 all:所有引入的庫進行分離(推薦)
  ...
  cacheGroups:{
    // 1、公共指令碼分離
    vendors:{
      test:/[\\/]node_modules[\\/]/,
      priority:-10
    },
    // 2、基礎包分離
    commons:{
      test:/(react|react-dom)/,
      name:'vendors',
      chunks:'all'
    },
    // 3、頁面公共檔案分離
    commons:{
      name:'commons',
      chunks:'all',
      minChunks:2
    }
  }
}

4.3、分包

plugins: [
  // 使用DLLPlugin進行分包
  new webpack.DLLPlugin({
    name: '[name]',
    path: './build/library/[name].json',
  }),
  // DllReferencePlugin 對 manifest.json引用
  new webpack.DllReferencePlugin({
    manifest: require('./build/library/manifest.json'),
  }),
];

5、多程序多例項構建

多程序多例項構建,換句話說就是:每次webpack解析一個模組,將它及它的依賴分配給worker執行緒中,比如HappyPackThreadLoader

HappyPack工作流程

6、快取

  • 開啟快取:babel-loaderterser-webpack-plugin
  • 使用cache-loaderhard-source-webpack-plugin

7、縮小構建目標、減少檔案搜尋範圍

  • 合理配置 loadertest,使用 include 來縮小 loader 處理檔案範圍
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/, // 尾部補充$號表示尾部匹配
        use: ['babel-loader?cacheDirectory'], // babel-loader 通過 cacheDirectory 選項開啟快取
        include: path.resolve(__dirname, 'src'), // 只處理src目錄下程式碼,極大提升編譯速度。(如果node_modules下有未編譯過的庫,這裡不建議開啟)
      },
    ],
  },
};
  • 優化 resolve 配置:
module.exports = {
  resolve: {
    modules: [path.resolve(__dirname, 'node_modules')], // 使用絕對路徑指明第三方模組存放的位置,以減少搜尋步驟
    extensions: ['.js', '.json'], // extensions儘量少,減少檔案查詢次數
    noParse: [/\.min\.js$/], // noParse可以忽略模組的依賴解析,對於min.js檔案一般已經打包好了
  },
};

九、可維護的 webpack 構建配置

1、多個配置檔案管理不同環境的 webpack 配置

構建包功能設計

1.1、通過webpack-merge合併配置

merge = require('webpack-merge');
module.exports = merge(baseConfig, devConfig);

2、webpack 構建分析

2.1、日誌分析

package.json檔案的構建統計資訊欄位新增stats

"scripts":{
  "build:stats":"webpack --env production --json > stats.json"
}

2.2、速度分析

利用 speedMeasureWebpackPlugin分析整個打包總耗時和每個外掛和loader的耗時情況

const speedMeasureWebpackPlugin = require("speed-measure-webpack-plugin")
const smp = new speedMeasureWebpackPlugin()
const webpackConfig = smp.wrap({
  plugins:[
    new MyPlugin()
    ...
  ]
})

2.3、體積分析

利用bundleAnalyzerPlugin分析依賴的第三方模組檔案大小和業務裡面的元件程式碼大小,構建完成後會在 8888 埠展示

const bundleAnalyzerPlugin = require('webpack-bundle-analyzer');
module.exports = {
  plugins: [
    new bundleAnalyzerPlugin({
      analyzerMode: 'server',
      analyzerHost: 'localhost',
      analyzerPort: 8888, // 埠號
      reportFilename: 'report.html',
      defaultSizes: 'parsed',
      openAnalyzer: true,
      generateStatsFile: false, // 是否輸出到靜態檔案
      statsFilename: 'stats.json',
      statsOptions: null,
      logLevel: 'info',
    }),
  ],
};

2.4、編譯時進度分析

利用ProgressPlugin分析編譯進度和模組處理細節

const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.ProgressPlugin({
      activeModules: false,
      entries: true,
      handler(percentage, message, ...args) {
        // 列印實時處理資訊
        console.info(percentage, message, ...args);
      },
      modules: true,
      modulesCount: 5000,
      profile: false,
      dependencies: true, // 顯示正在進行的依賴項計數訊息
      dependenciesCount: 10000,
      percentBy: null,
    }),
  ],
};