【webpack 系列】進階篇

阿林十一發表於2020-04-06

本文將繼續引入更多的 webpack 配置,建議先閱讀【webpack 系列】基礎篇的內容。如果發現文中有任何錯誤,請在評論區指正。本文所有程式碼都可在 github 找到。

打包多頁應用

之前我們配置的是一個單頁的應用,但是我們的應用可能需要是個多頁應用。下面我們來進行多頁應用的 webpack 配置。 先看一下我們的目錄結構

├── public
│   ├── detail.html
│   └── index.html
├── src
│   ├── detail-entry.js
│   ├── index-entry.js
複製程式碼

public 下面有 index.htmldetail.html 兩個頁面,對應 src 下面有 index-entry.jsdetail-entry.js 兩個入口檔案。

webpack.config.js 配置

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
// ...

module.exports = {
  entry: {
    index: path.resolve(__dirname, 'src/index-entry.js'),
    detail: path.resolve(__dirname, 'src/detail-entry.js')
  },
  output: {
    path: path.resolve(__dirname, 'dist'), // 輸出目錄
    filename: '[name].[hash:6].js', // 輸出檔名
  },
  plugins: [
    // index.html
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, 'public/index.html'), // 指定模板檔案,不指定會生成預設的 index.html 檔案
      filename: 'index.html', // 打包後的檔名
      chunks: ['index'] // 指定引入的 js 檔案,對應在 entry 配置的 chunkName 
    }),
    // detail.html
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, 'public/detail.html'), // 指定模板檔案,不指定會生成預設的 index.html 檔案
      filename: 'detail.html', // 打包後的檔名
      chunks: ['detail'] // 指定引入的 js 檔案,對應在 entry 配置的 chunkName
    }),
    // 打包前自動清除dist目錄
    new CleanWebpackPlugin()
  ]
}
複製程式碼

npm run build 之後可以看到生成的 dist 目錄如下

dist
├── assets
│   └── author_ee489e.jpg
├── detail.dbcb15.js
├── detail.dbcb15.js.map
├── detail.html
├── index.dbcb15.js
├── index.dbcb15.js.map
└── index.html
複製程式碼

index.html 頁面中已經引入了打包好的 index.dbcb15.js 檔案,detail.html 檔案也已經引入了 detail.dbcb15.js 檔案。更多配置請檢視 html-webpack-plugin

將 CSS 樣式單獨抽離生成檔案

webpack4css 模組支援的完善以及在處理 css 檔案提取的方式上也做了些調整,由 mini-css-extract-plugin 來代替之前使用的 extract-text-webpack-plugin,使用方式很簡單。

該外掛將 css 提取到單獨的檔案中,為每個包含 cssjs 檔案建立一個 css 檔案,支援 csssourcemap 的按需載入。 與 extract-text-webpack-plugin 相比有如下優點

  1. 非同步載入
  2. 沒有重複的編譯(效能)
  3. 更容易使用
  4. 特定於 css

安裝 extract-text-webpack-plugin

npm i -D mini-css-extract-plugin
複製程式碼

配置 webpack.config.js

// webpack.config.js

const MiniCssExtractPlugin = require('mini-css-extract-plugin');
// ...

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.(c|le)ss$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader', 'less-loader'],
        exclude: /node_modules/
      },
      {
        test: /\.sass$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader', 'sass-loader'],
        exclude: /node_modules/
      },
      // ...
    ]
  },
  plugins: [
    // ...
    new MiniCssExtractPlugin({
      filename: 'css/[name].[hash:6].css'
    })
  ]
}
複製程式碼

npm run build 之後會發現在 dist/css 目錄有了抽離出來的 css 檔案了。

【webpack 系列】進階篇

這時我們發現兩個問題:

  1. 打包生成的 css 檔案沒有進行壓縮。
  2. 所有檔案命名的 hash 部分都是一樣的,存在快取問題。

對 css 檔案進行壓縮

通過 optimize-css-assets-webpack-plugin 外掛壓縮 css 程式碼

npm i -D optimize-css-assets-webpack-plugin
複製程式碼

配置 webpack.config.js

// webpack.config.js
//...
const OptimizeCssPlugin = require('optimize-css-assets-webpack-plugin');

module.exports = {
  //...
  plugins: [
    //...
    new OptimizeCssPlugin()
  ]
}
複製程式碼

這樣就可以對 css 檔案進行壓縮了。

對於第二個問題,我們首先需要了解下 hashchunkHashcontentHash 的區別。

hash、chunkhash、contenthash 的區別和使用

hash

hash 是基於整個 module identifier 序列計算得到的,webpack 預設為給各個模組分配一個 id 以作標識,用來處理模組之間的依賴關係,預設的 id 命名規則是根據模組引入的順序賦予一個整數(123...)。任意修改、增加、刪除一個模組的依賴,都會對整個 id 序列造成影響,從而改變 hash 值。也就是每次修改或者增刪任何一個檔案,所有檔名的 hash 值都將改變,整個專案的檔案快取都將失效。

output: {
  path: path.resolve(__dirname, 'dist'), // 輸出目錄
  filename: '[name].[hash:6].js', // 輸出檔名
}

new MiniCssExtractPlugin({
  filename: 'css/[name].[hash:6].css'
})
複製程式碼

【webpack 系列】進階篇

可以看到打包後的 jscss 檔案的 hash 值是一樣的,所以對於沒有發生改變的模組而言,這樣做是不合理的。

當然可以看到,對於圖片等資源該 hash 還是可以生成一個唯一值的。

chunkhash

chunkhash 根據不同的入口檔案進行依賴檔案解析、構建對應的 chunk,生成對應的雜湊值。我們將 filename 配置成 chunkhash 來看一下打包的結果。

output: {
  path: path.resolve(__dirname, 'dist'), // 輸出目錄
  filename: '[name].[chunkhash:6].js', // 輸出檔名
}

new MiniCssExtractPlugin({
  filename: 'css/[name].[chunkhash:6].css'
})
複製程式碼

【webpack 系列】進階篇
可以看到此時打包之後的 index.jsdetail.jschunkhash 是不一樣的。但是會發現 index.jsindex.css 以及 detail.jsdetail.csschunkhash 是一致的,並且任意改動 js 或者 css 都會引起對應的 cssjs 檔案的 chunkhash 的改變,這是不合理的。所以這裡抽離出來的 css 檔案將使用 contenthash,來區分 css 檔案和 js 檔案的更新。

contenthash

contenthash 是針對檔案內容級別的,只有你自己模組的內容變了,那麼 hash 值才改變。

output: {
  path: path.resolve(__dirname, 'dist'), // 輸出目錄
  filename: '[name].[chunkhash:6].js', // 輸出檔名
}

new MiniCssExtractPlugin({
  filename: 'css/[name].[contenthash:6].css'
})
複製程式碼

【webpack 系列】進階篇
OK,可以看到分離出來的 css 檔案已經和入口檔案的 hash 值區分開了。

如何使用

為了實現理想的快取,我們一般這樣使用他們:

  1. JS 檔案使用 chunkhash
  2. 抽離的 CSS 樣式檔案使用 contenthash
  3. gif|png|jpe?g|eot|woff|ttf|svg|pdf 等使用 hash

按需載入

很多時候我們並不需要在一個頁面中一次性載入所有的 js 或者 css 檔案,而是應該是需要用到時才去載入相應的 js 或者 css 檔案。

import()

比如,現在我們需要點選一個按鈕才會使用對應的 jscss 檔案,需要 import() 語法:

// index-entry.js

import './index.sass';
//...
const handle = () => import('./handle');
const handle2 = () => import('./handle2');


document.querySelector('#btn').onclick = () => {
  handle().then(module => {
    module.handleClick();
  });

  handle2().then(module => {
    module.default();
  });
}
複製程式碼
// handle.js

import './handle.css';

export function handleClick () {
  console.log('handleClick');
}
複製程式碼
// handle2.js

export default function handleClick () {
  console.log('handleClick2');
}
複製程式碼

npm run build 可以看到,多了這 3 個檔案,並且只有在我們點選該按鈕是才會去載入這 3 個檔案。

【webpack 系列】進階篇

webpackChunkName

這些檔案可能不太好區分,我們可以通過設定 webpackChunkName 來定義生成的檔名

// index-entry.js
const handle = () => import(/* webpackChunkName: "handle" */ './handle');
const handle2 = () => import(/* webpackChunkName: "handle2" */ './handle2');
複製程式碼

我們再將這些檔案的 hash 長度設定為 8 加以區分

// webpack.config.js
module.exports = {
    output: {
      path: path.resolve(__dirname, 'dist'), // 輸出目錄
      filename: '[name].[chunkhash:6].js', // 輸出檔名
      chunkFilename: '[name].[chunkhash:8].js'
    }
    // ...
    new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash:6].css',
      chunkFilename: 'css/[name].[contenthash:8].css'
    }),
複製程式碼

npm run build 之後檢視

【webpack 系列】進階篇
當然我們也可以將 handlehandle2 檔案的 webpackChunkName 設定成一樣的,這樣這兩個檔案將會打包在一起生成一個檔案,可以減少請求數量。

熱更新( HMR, Hot Module Replacement )

開發過程中,我們希望在瀏覽器不重新整理頁面的情況下能夠去載入我們修改的程式碼,來提高我們的開發效率。我們來看下如何配置:

  1. 開啟 webpack-dev-server 的熱更新開關
  2. 使用 HotModuleReplacementPlugin 外掛

HotModuleReplacementPlugin 外掛是 Webpack 自帶的,在 webpack.config.js 直接配置

// webpack.config.js

module.exports = {
  devServer: {
    //...
    hot: true
  },
  plugins: [
    //...
    new webpack.HotModuleReplacementPlugin() // 熱更新外掛
  ]
}
複製程式碼

在入口檔案新增

if (module && module.hot) {
    module.hot.accept()
}
複製程式碼

這樣就完成了熱更新的配置,但是此時 webpack 打包卻報錯了。

【webpack 系列】進階篇
搜了一下相關的問題,在開發環境中我們使用了 HotModuleReplacementPlugin 此時需要使用 hash 來輸出檔案,使用 chunkhash 會導致 webpack 報錯,而生產環境則沒有問題。但是現在我們只是通過 process.env.NODE_ENV 這個變數來區分環境,這顯然不是一個很好的方式。 我們最好能夠需要區分一下開發環境和生產環境的配置檔案。

定義不同環境的配置

我們可以給不同的環境定義不同的配置檔案,但是這些檔案將會有大量相似的配置,這時我們可以這樣來定義檔案:

  1. webpack.base.js:定義公共的配置
  2. webpack.dev.js:定義開發環境的配置
  3. webpack.prod.js:定義生產環境的配置

我們可以將一些公共的配置抽離到 webpack.base.js,然後在 webpack.dev.jswebpack.prod.js 進行對應環境的配置。我們還需要通過 webpack-merge 來合併兩個配置檔案。

安裝 webpack-merge

npm i -D webpack-merge
複製程式碼

現在 webpack.dev.js 就是這樣的

// webpack.dev.js

const path = require('path');
const webpack = require('webpack');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const merge = require('webpack-merge');
const baseConfig = require('./webpack.config.base');

module.exports = merge(baseConfig, {
  mode: 'development',
  devtool: 'inline-source-map',
  devServer: {
    contentBase: path.join(__dirname, 'dist'),
    port: '9000', // 預設是8080
    compress: true, // 是否啟用 gzip 壓縮
    hot: true
  },
  output: {
    path: path.resolve(__dirname, 'dist'), // 輸出目錄
    filename: '[name].[hash:6].js', // 輸出檔名
    chunkFilename: '[name].[hash:8].js'
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'css/[name].[hash:6].css',
      chunkFilename: 'css/[name].[hash:8].css'
    }),
    new webpack.HotModuleReplacementPlugin() // 熱更新外掛
  ]
});

複製程式碼

同時需要在 package.json 中指定我們的配置檔案

// package.json

"scripts": {
  "dev": "cross-env NODE_ENV=development webpack-dev-server --config webpack.config.dev.js",
  "build": "cross-env NODE_ENV=production webpack --config webpack.config.pro.js"
},
複製程式碼

這時我們就很優雅的區分開不同環境的配置了。

拷貝靜態資源

有時候我們需要在 html 中直接引用一個打包好的第三方外掛庫,這個庫不需要通過 webpack 編譯。比如我們 lib 目錄下有個 lib-a.js,需要在 public/index.html 中直接引用它。

<!-- public/index.html -->
<script src="/lib/lib-a.js"></script>
複製程式碼

這時 build 之後會發現 dist 下是沒有 lib 目錄的,這時會找不到這個檔案。這時我們需要藉助 CopyWebpackPlugin 這個外掛來幫助我們把根目錄下的 lib 目錄拷貝到 dist 目錄下面。

首先安裝 CopyWebpackPlugin

npm i -D CopyWebpackPlugin
複製程式碼

配置 webpack.config.js

// webpack.config.js

const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
  //...
  plugins: [
    //...
    new CopyWebpackPlugin([
      {
        from: path.resolve(__dirname, 'lib'),
        to: path.resolve(__dirname, 'dist/lib')
      }
    ])
  ]
}
複製程式碼

這時後執行 npm run build 就會發現,dist 目錄下已經有了 lib目錄及檔案了。

更多的配置請檢視copy-webpack-plugin

Resolve 配置

Webpack 在啟動後會從配置的入口模組出發找出所有依賴的模組,Resolve 配置 Webpack 如何尋找模組所對應的檔案。 Webpack 內建 JavaScript 模組化語法解析功能,預設會採用模組化標準里約定好的規則去尋找,但你也可以根據自己的需要修改預設的規則。

alias

resolve.alias 配置項通過別名來把原匯入路徑對映成一個新的匯入路徑。 比如我們在 index-entry.js 中引入 lib/lib-b.js,你可能需要這樣引入

import '../lib/lib-b.js';
複製程式碼

而當目錄層級比較深時,這個相對路徑就會變得不好辨認了。這時我們可以配置 lib 的一個別名。

// webpack.config.js

module.exports = {
  //...
  resolve: {
    alias: {
      '@lib': path.resolve(__dirname, 'lib') // 為lib目錄新增別名
    }
  }
}
複製程式碼

這時無論你處於目錄的哪個層級,你只需要這樣引入

import '@lib/lib-b.js';
複製程式碼

extensions

如果在匯入檔案時沒有帶字尾名,webpack 會自動帶上字尾後去嘗試訪問檔案是否存在。 resolve.extensions 用於配置在嘗試過程中用到的字尾列表,預設是

extensions: ['.js', '.json']
複製程式碼

就是說當遇到 import '@lib/lib-b'; 時,webpack 會先去尋找 @lib/lib-b.js 檔案,如果該檔案不存在就去尋找 @lib/lib-b.json 檔案, 如果還是找不到就報錯。

如果你想優先使用其他字尾檔案,比如 .ts 檔案,可以這樣配置

// webpack.config.js

module.exports = {
  //...
  resolve: {
    alias: {
      '@lib': path.resolve(__dirname, 'lib'), // 為lib目錄新增別名
      extensions: ['.ts', '.js', '.json'] // 從左往右
    }
  }
}
複製程式碼

這樣就會先去找 .ts 了。不過一般我們會將高頻的字尾放在前面,並且陣列不要太長,減少嘗試次數,不然會影響打包速度。

現在我們引入 js 檔案時可以省略字尾名了。

modules

resolve.modules 配置 webpack 去哪些目錄下尋找第三方模組,預設是隻會去 node_modules 目錄下尋找。如果專案中某個資料夾下的模組經常被匯入,不希望寫很長的路徑,比如 import '../../../components/link',那麼就可以通過配置 resolve.modules 來簡化。

// webpack.config.js

module.exports = {
  //...
  resolve: {
    modules: ['./src/components', 'node_modules'] // 從左到右查詢
  }
}
複製程式碼

這時,你就可以通過 import 'link' 引入了。

mainFields

有一些第三方模組會針對不同環境提供幾份程式碼。例如分別提供採用 es5es62 份程式碼,這 2 份程式碼的位置寫在 package.json 檔案裡。

{
  "jsnext:main": "es/index.js",// 採用 ES6 語法的程式碼入口檔案
  "main": "lib/index.js" // 採用 ES5 語法的程式碼入口檔案
}
複製程式碼

webpack 會根據 mainFields 的配置去決定優先採用那份程式碼, mainFields 預設配置如下:

mainFields: ['browser', 'main']
複製程式碼

假如你想優先採用 ES6 的那份程式碼,可以這樣配置:

mainFields: ['jsnext:main', 'browser', 'main']
複製程式碼

enforceExtension

resolve.enforceExtension 如果配置為 true,那麼所有匯入語句都必須要帶檔案字尾。

enforceModuleExtension

enforceModuleExtensionenforceExtension 作用類似,但 enforceModuleExtension 只對 node_modules下的模組生效。 因為安裝的第三方模組中大多數匯入語句沒帶檔案字尾,如果這時你配置了 enforceExtensiontrue,那麼就需要配置 enforceModuleExtension: false來相容第三方模組。

利用 webpack 解決跨域問題

本地開發時,前端專案的埠號是 9000,但是服務端可能是 9001,根據瀏覽器的同源策略,是不能直接請求到後端服務的。當然你可以在後端配置 CORS 相關的頭部來實現跨域,其實也可以通過 webpack 的配置來解決跨域問題。

首先,我們起一個後端服務,安裝 koakoa-router

npm i -D koa koa-router
複製程式碼

新建 server/index.js

// server/index.js

const Koa = require('koa');
const KoaRouter = require('koa-router');

const app = new Koa();

// 建立 router 例項物件
const router = new KoaRouter();

// 註冊路由
router.get('/user', async (ctx, next) => {
  ctx.body = {
    code: 0,
    data: {
      name: '阿林十一'
    },
    msg: 'success'
  };
});

app.use(router.routes());  // 新增路由中介軟體
app.use(router.allowedMethods()); // 對請求進行一些限制處理

app.listen(9001);
複製程式碼

使用 node server/index.js 啟動服務後,在 http://localhost:9001/user 可以訪問結果。

之後再修改 handle.js,在點選按鈕之後會請求介面

import './handle.css';

export function handleClick () {
  console.log('handleClick');

  fetch('/api/user')
    .then(r => r.json())
    .then(data => console.log(data))
    .catch(err => console.log(err));
}
複製程式碼

這是會發現介面報 404,下面我們配置一下 webpack.config.dev.js

// webpack.config.dev.js

module.exports = {
  //...
  proxy: {
    '/api': {
      target: 'http://127.0.0.1:9001/',
      pathRewrite: {
        '^/api': ''
      }
    }
  }
}
複製程式碼

請求到 http://localhost:9000/api/user 現在會被代理到請求 http://localhost:9001/user。點選按鈕發起請求:

【webpack 系列】進階篇

最後

現在,我們對 webpack 的配置有了更進一步的瞭解了,快動手試試吧。本文所有程式碼可以檢視 github

後續將會繼續推出 webpack 系列的其他內容哦~

喜歡本文的話點個贊吧~

【webpack 系列】進階篇

更多精彩內容,歡迎關注微信公眾號~

相關文章