模組化打包工具-Webpack外掛與其他功能

Chenkai_Zhou發表於2023-10-18

1.Webpack外掛機制

上一篇提到的webpack的loader可以用來載入資源,包括各種css,圖片檔案資源,實現打包檔案的功能,而webpack的外掛則起到了加強webpack的作用,可以完成一些自動化的工作,比如自動清楚dist目錄,自動生成html等等工作。有了外掛的webpack基本可以實現絕大多數前端工程化的任務

2.常用外掛舉例與使用

自動清理目錄輸出-clean-webpack-plugin

對一個專案進行多次打包的時候,可能每一次打包的方式不同,產生的打包結果也不同,但是沒有自動清理外掛的話之前多次的打包結果檔案會仍然留在dist資料夾下

比如此時,這裡進行了2次打包,現在bundle.js才是需要的最後打包結果,而main.js只是之前的打包結果,需要手動刪除。如果檔案數量增多,手動刪除不需要的檔案將是十分複雜的,所以要一個自動清理目錄輸出的外掛,在每一次執行打包命令的時候,先把dist目錄清理一下,再去生成新的打包檔案

 安裝外掛

npm i clean-webpack-plugin

使用外掛

在webpack配置檔案裡引入clean-webpack-plugin外掛,並且新增一個配置項plugins,值是一個陣列,在陣列裡初始化一下CleanWebpackPlugin,然後再執行打包命令就可以看到,dist目錄下只剩一個bundle.js檔案了

 

 

const path = require('path')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
module.exports = {
    mode: 'none',
    entry: './src/index.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist')
    },
    module: {
        rules: [
            {
                use: [
                    'style-loader',
                    'css-loader'
                ],
                test: /.css$/
            }
        ]
    },
    plugins:[
        new CleanWebpackPlugin()
    ]
}

 

執行打包命令後的結果

 

自動生成html的外掛-html-webpack-plugin

 預設情況下,打包的所有檔案都不包括html檔案,也就是說我們需要在專案根目錄下的index.html裡面引入所有dist目錄下的內容,那麼此時又存在2個模組化的老問題,多次打包後資源發生了改變忘記引入了怎麼辦?資源已經被刪除了,但是還引入了怎麼辦?

也就是說,我們其實不希望直接去手動引入打包內容,而是希望webpack自動生成html,讓它來幫我們處理引入的問題,這時候就需要用到自動生成html的外掛html-webpack-plugin了。

安裝外掛

npm i html-webpack-plugin

 

使用外掛

 

在配置項plugins的陣列後再加一個元素,同時改publicPath屬性為空字串,因為此時的html檔案已經生成到dist目錄下了,只需要指定打包後的結果的在網站根目錄下即可

 

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

module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'dist'),
    publicPath: ''
  },
  module: {
    rules: [
      {
        test: /.css$/,
        use: [
          'style-loader',
          'css-loader'
        ]
      },
      {
        test: /.png$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 10 * 1024 // 10 KB
          }
        }
      }
    ]
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin()
  ]
}

 

其他配置

 

使用預設配置生成的index.html只有一個空的模板,而我們開發的時候可能需要加入一些meta標籤和修改一下網站的title,可能還需要加入一些特定的dom元素,除此之外可能不止輸出一個頁面,而是要輸出多個html頁面,這些事情都可以透過這個外掛的配置項去完成

  plugins: [
    new CleanWebpackPlugin(),
    // 用於生成 index.html
    new HtmlWebpackPlugin({
      title: 'Webpack Plugin Sample', //改變生成的html的title
      meta: {
        viewport: 'width=device-width' //增加視口相關標籤
      },
      template: './src/index.html' // html使用模板來生成,裡面有對應的特殊dom結構
    }),
    // 用於生成 about.html
    new HtmlWebpackPlugin({
      filename: 'about.html'
    })
  ]

 

複製靜態資源的外掛-copy-webpack-plugin

打包後的網站仍然需要一些靜態資源,比如一些靜態的圖片或者網站的favicon.ico圖示等,這些資源也需要跟著打包到dist資料夾才能正常使用。所以這裡要用到複製靜態資源的外掛了。

安裝外掛

npm i copy-webpack-plugin

使用外掛

正確引入之後,在plugins配置項下建立一個物件,傳的引數是一個陣列,陣列中指定了要複製的路徑,比如這裡要複製專案根目錄下的public資料夾下的資源,就寫路徑public

  plugins: [
    new CleanWebpackPlugin(),
    // 用於生成 index.html
    new HtmlWebpackPlugin({
      title: 'Webpack Plugin Sample',
      meta: {
        viewport: 'width=device-width'
      },
      template: './src/index.html'
    }),
    // 用於生成 about.html
    new HtmlWebpackPlugin({
      filename: 'about.html'
    }),
    new CopyWebpackPlugin([
      // 'public/**'
      'public'
    ])
  ]

3.Webpack其他功能

3.1 自動編譯

之前每次修改程式碼之後都需要手動執行打包命令然後再檢視效果,可以說是開發體驗很不好,所以需要一個自動編譯的功能,webpack已經提供了對應的指令。在執行打包命令的時候帶上--watch就可以啟動自動監視功能,當你的原始檔發生修改時就會自動重新執行打包功能。

npx webpack --watch

3.2 Webpack Dev Server

擁有了自動編譯功能,但是每次更新完程式碼後仍然需要手動點選瀏覽器的重新整理才能看到最新的效果,這裡希望可以讓瀏覽器根據打包結果自動重新整理。webpack-dev-server就可以完成這個工作。

npm i webpack-dev-server

有了這個包之後,直接使用命令即可完成自動編譯,瀏覽器自動重新整理了,加入--open引數可以讓自動用瀏覽器開啟打包後的結果

npx webpack-dev-server --open

為了加快打包的效率,webpack-dev-server沒有把打包結果直接寫入磁碟,所以看不到有dist檔案,而是把打包結果放在了記憶體裡面

靜態資源的訪問

webpack-dev-server可以訪問到所有透過webpack輸出的資源,但是其他的需要使用的靜態資源就需要透過配置來告訴server去哪裡尋找檔案。

例如public資料夾下有一些靜態資源,例如網站圖示和靜態圖片就需要透過devServer下的contentBase配置項來設定,這個配置項可以傳一個陣列或者傳一個字串,表示需要訪問的靜態資源的路徑。這個功能和之前的copy-webpack-plugin外掛的功能是相同的,但是在開發過程中一般只有最後上線前才會使用copy-webpack-plugin外掛去完成打包,因為開發中會頻繁執行打包任務,如果需要複製的檔案比較大,那麼每次使用這個外掛去複製的開銷也會較大,打包速度會降低

module.exports = {
    mode: 'none',
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist')
    },
    devServer: {
        contentBase: './public'
    }
}

代理伺服器

在開發過程中可能遇到一些跨域的問題,這個時候後端又沒有配置CORS,就需要透過代理伺服器來解決,此時需要建立一個與客戶端同源的伺服器,讓同源伺服器去請求後端。

配置裡需要設定目標地址,需要重寫的路徑和允許修改請求的主機名

  devServer: {
    contentBase: './public',
    proxy: {
      '/api': {
        // http://localhost:8080/api/users -> https://api.github.com/api/users
        target: 'https://api.github.com',
        // http://localhost:8080/api/users -> https://api.github.com/users
        pathRewrite: {
          '^/api': ''
        },
        // 不能使用 localhost:8080 作為請求 GitHub 的主機名
        changeOrigin: true
      }
    }
  }

3.3 Source Map

在開發過程中,一般避免不了出現報錯,報錯和除錯的時候也需要檢視原始碼和出現錯誤的位置,但是問題是瀏覽器的報錯和除錯只會顯示執行程式碼(打包後的程式碼)的錯誤位置,而不能顯示原始碼的位置,這就讓定位錯誤變得非常的困難。source map則可以幫助找到原始碼與執行程式碼之間的對映,找到原始碼中出錯的位置。

配置source map,新增devtool屬性,並且選擇要生成的source map型別

module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'dist')
  },
  devtool: 'source-map'
}

使用了source map之後可以在瀏覽器裡直接定位到錯誤位置,此時使用的值是source-map,所以還會產生.map檔案

devtool可以選其他的值,所生成的source map都不同,生成的效率和最終效果也不一樣,例如將這個值改成eval,就會透過eval的方式來實現source map。但是eval方式只會定位到出錯的檔案,無法定位到具體的某一行某一列,也不會產生.map檔案,所以打包速度肯定是比前者更快的。

對比不同的devtool

將module.exports的值改成一個陣列,就可以在一次打包時執行多個不同的打包任務。這裡讓不同的source-map去打包相同的檔案,檔案裡有一個小錯誤,觀察最後的效果

const HtmlWebpackPlugin = require('html-webpack-plugin')

const allModes = [
    'eval',
    'cheap-eval-source-map',
    'cheap-module-eval-source-map',
    'eval-source-map',
    'cheap-source-map',
    'cheap-module-source-map',
    'inline-cheap-source-map',
    'inline-cheap-module-source-map',
    'source-map',
    'inline-source-map',
    'hidden-source-map',
    'nosources-source-map'
]

module.exports = allModes.map(item => {
    return {
        devtool: item,
        mode: 'none',
        entry: './src/main.js',
        output: {
            filename: `js/${item}.js`
        },
        module: {
            rules: [
                {
                    test: /\.js$/,
                    use: {
                        loader: 'babel-loader',
                        options: {
                            presets: ['@babel/preset-env']
                        }
                    }
                }
            ]
        },
        plugins: [
            new HtmlWebpackPlugin({
                filename: `${item}.html`
            })
        ]
    }
})

eval,只能定位到錯誤檔案,沒有生成source map檔案

eval-source-map,能定位到錯誤的行和列,有source-map檔案

cheap-eval-source-map,只能定位到錯誤的行,有source-map檔案,定位到的是經過es6轉換後的程式碼

cheap-module-eval-source-map,與cheap-eval-source-map相似,但是定位到的是原始碼(下圖可以看到var和const的差別

透過以上幾個值和結果,可以從值的名字總結出

eval- 是否使用eval 執行模組程式碼
cheap - Source Map 是否只包含行資訊
module- 是否能夠得到 Loader 處理之前的原始碼

開發時常用的取值:cheap-module-eval-source-map。因為一般來說一行的長度不會特別長,定位到行即可方便查詢錯誤,同時es6轉換後的程式碼相比原始碼差異較大,所以最好看原始碼,同時在開發的時候因為頻繁打包更關注再次打包的速度,這種方式的rebuild速度又是最快的(見下圖)

 

不同的devtool取值的總結

 

3.4 Hot Module Replacement(熱模組替換)

在使用webpack-dev-server開發的過程中,仍然會有問題,因為修改檔案後再次自動打包會重新整理頁面,也就是會清除頁面的狀態,但其實我們的修改可能只是一小部分,這樣每次都丟失頁面狀態(尤其是頁面上有輸入文字的時候)還是很麻煩,所以我們需要一個熱模組替換的功能,只是去實時地替換修改過的地方,而不去重新整理整個頁面。

webpack-dev-server自帶了這個功能,只需要在執行命令的時候加上--hot即可開啟熱模組替換

npx webpack-dev-server --hot

如果不想改命令,也可以在webpack配置檔案裡做出相應配置,在devServer下加入hot配置,在外掛配置項下載入webpack內建的HMR外掛

const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'development',
  entry: './src/main.js',
  output: {
    filename: 'js/bundle.js'
  },
  devtool: 'source-map',
  devServer: {
    hot: true
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader'
        ]
      },
      {
        test: /\.(png|jpe?g|gif)$/,
        use: 'file-loader'
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'Webpack Tutorial',
      template: './src/index.html'
    }),
    new webpack.HotModuleReplacementPlugin()
  ]
}

 手動處理熱模組替換邏輯

webpack中的HMR是不會自動開啟的,這個必須要手動編寫處理邏輯,尤其是js檔案,一般都需要自己來編寫替換的邏輯,因為js檔案每一次匯出的內容都是不同的,可能是匯出一個普通的變數,也有可能是匯出一個函式或者匯出一個物件,這樣就無法用一個通用的方式去處理所有的情況。

使用HMR 外掛提供的API可以處理熱模組替換的邏輯,使用module.hot.accept()方法來設定熱替換後所需要做的事情,這個方法一共要傳2個引數:

  1. 第一個引數是一個字串,表示需要熱替換的模組的路徑
  2. 第二個引數是一個回撥函式,回撥函式里表示熱替換的邏輯

舉例,現在希望在替換圖片檔案的時候自動完成網頁上的更新,只要直接在回撥函式里設定圖片元素的src即可

 

import background from './better.png'
const img = new Image()
img.src = background
document.body.appendChild(img)  
module.hot.accept('./better.png', () => {
    img.src = background
    
  })

 

舉例,對於一個js檔案,希望可以在發生更改的時候保留輸入框的值,並且熱更新

  import createEditor from './editor'
  const editor = createEditor()
  document.body.appendChild(editor)
  let lastEditor = editor
  module.hot.accept('./editor', () => {
    const value = lastEditor.innerHTML
    document.body.removeChild(lastEditor)
    const newEditor = createEditor()
    newEditor.innerHTML = value
    document.body.appendChild(newEditor)
    lastEditor = newEditor
  })

每一次處理的情況都是不同的,因此必須自己手動處理更新邏輯。當然在更新css檔案的時候,style-loader已經寫過了熱更新的邏輯,只需要引入對應外掛開啟熱更新即可體驗到HMR的功能了。而現在一些現成的腳手架也都已經內建了這樣的功能(因為在腳手架裡都是按照約定好的規則寫程式碼的)

這樣寫HMR邏輯仍有問題,比如在HMR處理邏輯裡寫錯了東西,處理HMR的程式碼報錯會導致瀏覽器自動重新整理,這樣直接就看不到報錯資訊了。處理辦法是在webpack的配置檔案裡把devServer配置項下的hot改成hotOnly,這樣它就不會自動回退到自動重新整理,起碼可以看到錯誤的資訊

  devServer: {
    // hot: true
    hotOnly: true // 只使用 HMR,不會 fallback 到 live reloading
  }

 

3.5 生產環境最佳化

之前的用法和特性都注重了提高開發的效率,然而這對於生產環境可能是不利的,因為一些外掛和多餘的程式碼會降低執行的效率,比如我們在生產環境的時候就不需要使用熱模組替換,不需要處理source map。所以需要對生產環境進行單獨的配置。

分別的配置打包檔案

生產模式和開發模式注重的東西不同,打包的需求自然也不同,所以需要對生產模式和開發模式分別配置打包檔案

 這裡直接分3個檔案,一個公共的配置檔案,一個專用於開發,一個專用於生產

公共配置

const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'js/bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader'
        ]
      },
      {
        test: /\.(png|jpe?g|gif)$/,
        use: {
          loader: 'file-loader',
          options: {
            outputPath: 'img',
            name: '[name].[ext]'
          }
        }
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'Webpack Tutorial',
      template: './src/index.html'
    })
  ]
}

 

生產環境的配置,這裡使用webpack-merge這個庫來實現公共部分和生產環境部分的合併

注:不能直接使用Object.assign方法,因為Object.assign方法會對相同的鍵進行值的覆蓋(common裡的plugins部分直接被prod的完全覆蓋),這裡我們想實現的效果是在common的plugins陣列裡再加入一些新的配置

const merge = require('webpack-merge')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const common = require('./webpack.common')

module.exports = merge(common, {
  mode: 'production',
  plugins: [
    new CleanWebpackPlugin(),
    new CopyWebpackPlugin(['public'])
  ]
})

此時已經沒有預設的webpack配置檔案(webpack.config.js)了,執行生產環境打包需要加入--config引數

npx webpack --config webpack.prod.js

Tree shaking

對於一些沒有被引用的,無效的程式碼,在webpack生產環境下會被自動刪除,提高執行效率,這也就是tree shaking功能。Tree shaking會在生產環境打包的時候自動開啟,不需要進行配置。

在非生產模式下實現Tree shaking功能

這裡只有Button被引用了,其他2個元件都沒被引用,普通的開發模式下,這2個未被引用的元件仍會被寫在bundle.js中

修改配置檔案,增加Tree shaking相關功能配置,加入optimization配置項,其中usedExports會讓打包的結果中只匯出被使用到的成員(可以看作標記枯樹葉),而minimize則會壓縮程式碼,壓縮的過程中也會把沒用到的內容壓縮掉(可以看作搖樹),concatenateModules會把多個模組的程式碼合併在一個函式中(正常情況下是一個模組對應打包後的一個函式,如果模組過多,打包後的函式也會很多

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

程式碼分割

當我們專案比較龐大的時候,打包內容也會很龐大並且只有一個檔案,但是我們在一開始啟動網頁的時候不需要所有的內容,所以可以使用程式碼分割,提升執行效率

多入口打包

為了實現可以使用程式碼分割,可以使用多入口打包,比如一個頁面對應一個打包檔案,只要在配置檔案裡做一些修改即可。

  1. 將原來的單入口的entry變成一個物件,指定打包名和入口檔案路徑
  2. 在file那麼處使用[name]的佔位符來替換檔名
  3. 設定optimization裡的splictChunks為‘all’提取所有打包檔案裡的公共程式碼
  4. 對htmlWebpackPlugin加入chunks屬性,指定要匯入的模組(不指定的話預設會匯入所有打包模組)

 

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']
    }),
    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/album.html',
      filename: 'album.html',
      chunks: ['album']
    })
  ]
}

 

動態匯入

對於元件和網頁,有的時候不需要一次性全部載入,當使用者點選切換進入新元件的時候再載入即可。這時候就需要用到ES6提供的impot函式,import函式返回一個promise,透過then方法可以獲取到整個模組的module物件,將posts和album透過default的方式解構出來,並且渲染即可實現動態匯入// 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)