帶你走進webpack世界,成為webpack頭號玩家。

lihuanji發表於2018-04-08

最近朋友圈被《頭號玩家》刷爆了,斯皮爾伯格一個資深電影導演,把對過去經典的致敬,對未來的憧憬濃縮在這一部電影中,可以說讓觀眾燃了起來。

觀望整個前端開發,不斷的演化,發展迅速。前端開發從最開始切頁面, 前端自動化構建工具日新月異,從最初的Grunt,Gulp到現在前端專案可以說標配的webpack。

我們先來致敬經典:

帶你走進webpack世界,成為webpack頭號玩家。

帶你走進webpack世界,成為webpack頭號玩家。

1. 什麼是webpack?

可以看做一個模組化打包機,分析專案結構,處理模組化依賴,轉換成為瀏覽器可執行的程式碼。

  • 程式碼轉換: TypeScript 編譯成 JavaScript、SCSS,LESS 編譯成 CSS.
  • 檔案優化:壓縮 JavaScript、CSS、HTML 程式碼,壓縮合並圖片。
  • 程式碼分割:提取多個頁面的公共程式碼、提取首屏不需要執行部分的程式碼讓其非同步載入。
  • 模組合併:在採用模組化的專案裡會有很多個模組和檔案,需要構建功能把模組分類合併成一個檔案。
  • 自動重新整理:監聽本地原始碼的變化,自動重新構建、重新整理瀏覽器。

構建把一系列前端程式碼自動化去處理複雜的流程,解放生產力。

2. 進入webpack世界

初始化專案

	npm install webpack webpack-cli -D
複製程式碼

webpack4抽離出了webpack-cli,所以我們需要下載2個依賴。

Webpack 啟動後會從Entry裡配置的Module開始遞迴解析 Entry 依賴的所有 Module。 每找到一個 Module, 就會根據配置的Loader去找出對應的轉換規則,對 Module 進行轉換後,再解析出當前 Module 依賴的 Module。 這些模組會以 Entry 為單位進行分組,一個 Entry 和其所有依賴的 Module 被分到一個組也就是一個 Chunk。最後 Webpack 會把所有 Chunk 轉換成檔案輸出。 在整個流程中 Webpack 會在恰當的時機執行 Plugin 裡定義的邏輯。

webpack需要在專案根目錄下建立一個webpack.config.js來匯出webpack的配置,配置多樣化,可以自行定製,下面講講最基礎的配置。

module.exports = {
	entry: './src/index.js',
	output: {
		path: path.join(__dirname, './dist'),
		filename: 'main.js',
	}
}
複製程式碼
  • entry代表入口,webpack會找到該檔案進行解析
  • output代表輸入檔案配置
  • path把最終輸出的檔案放在哪裡
  • filename輸出檔案的名字

有時候我們的專案並不是spa,需要生成多個js html,那麼我們就需要配置多入口。

module.exports = {
	entry: {
		pageA: './src/pageA.js',
		pageB: './src/pageB.js'
	},
	output: {
		path: path.join(__dirname, './dist'),
		filename: '[name].[hash:8].js',
	},
}
複製程式碼

entry配置一個物件,key值就是chunk: 程式碼塊,一個 Chunk 由多個模組組合而成,用於程式碼合併與分割。看看filename[name]: 這個name指的就是chunk的名字,我們配置的key值pageA pageB,這樣打包出來的檔名是不同的,再來看看[hash],這個是給輸出檔案一個hash值,避免快取,那麼:8是取前8位。

這裡有人會有疑問了,專案是多頁面的,應該有pageA.html``pageA.js``pageA.css, 那麼我應該生成多個html,這個只是做了JS的入口區分,我不想每一個頁面都去複製貼上一個html,並且html是大部分重複的,可能不同頁面只需要修改title,下面來看看這個問題怎麼解決:

需要引入一個webpack的plugin:

npm install html-webpack-plugin -D
複製程式碼

該外掛可以給每一個chunk生成html,指定一個template,可以接收引數,在模板裡面使用,下面來看看如何使用:

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

module.exports = {
	entry: {
		pageA: './src/pageA.js',
		pageB: './src/pageB.js'
	},
	output: {
		path: path.join(__dirname, './dist'),
		filename: '[name].[hash:8].js',
	},
	plugins: [
		 new HtmlWebpackPlugin({
            template: './src/templet.html',
            filename: 'pageA.html',
            title: 'pageA',
            chunks: ['pageA'],
            hash: true,
            minify: {
                removeAttributeQuotes: true
            }
        }),
        new HtmlWebpackPlugin({
            template: './src/templet.html',
            filename: 'pageB.html',
            title: 'pageB',
            chunks: ['pageB'],
            hash: true,
            minify: {
                removeAttributeQuotes: true
            }
        }),
	]
}
複製程式碼

在webpack中,外掛的引入順序沒有規定,這個在後面在繼續詳說。

  • template: html模板的路徑地址
  • filename: 生成的檔名
  • title: 傳入的引數
  • chunks: 需要引入的chunk
  • hash: 在引入JS裡面加入hash值 比如: <script src='index.js?2f373be992fc073e2ef5'></script>
  • removeAttributeQuotes: 去掉引號,減少檔案大小<script src=index.js></script>
  • 具體文件

這樣在dist目錄下就生成了pageA.html和pageB.html並且通過配置chunks,讓pageA.html里加上了script標籤去引入pageA.js。那麼現在還剩下css沒有匯入,css需要藉助loader去做,所以現在要下載幾個依賴,以scss為例,less同理

npm install css-loader style-loader sass-loader node-sass -D
複製程式碼
  • css-loader: 支援css中的import
  • style-loader: 把css寫入style內嵌標籤
  • sass-loader: scss轉換為css
  • node-sass: scss轉換依賴

來看看如何配置loader

module.exports = {
	module: {
        rules: [
        		{
        			test: /\.scss$/,
        			use: ['style-loader', 'css-loader', 'sass-loader'],
        			exclude: /node_modules/
        		}
        ]
    }
}
複製程式碼
  • test: 一個正規表示式,匹配檔名
  • use: 一個陣列,裡面放需要執行的loader,倒序執行,從右至左。
  • exclude: 取消匹配node_modules裡面的檔案

如果想把css作為一個單獨的檔案,需要用到一個外掛來做(webpack4.0.0以上版本需要next版本):

 npm i extract-text-webpack-plugin@next -D
複製程式碼
const ExtractTextPlugin = require('extract-text-webpack-plugin');
module.exports = {
	entry: './src/index.js',
	output: {
		path: path.join(__dirname, './dist'),
		filename: 'main.js',
    },
    module: {
        rules: [
            {
                test: /\.scss$/,
                use: ExtractTextPlugin.extract({
                    // style-loader 把css直接寫入html中style標籤
                    fallback: 'style-loader',
                    // css-loader css中import支援
                    // loader執行順序 從右往左執行
                    use: ['css-loader', 'sass-loader']
                }),
                exclude: /node_modules/
            }
        ]
    },
    plugins: [
        new ExtractTextPlugin('[name].[contenthash:8].css'),
    ]
}
複製程式碼
  • 需要在plugins里加入外掛name: chunk名字 contenthash:8: 根據內容生成hash值取前8位
  • 修改loader配置下的use: fallback: 相容方案

這樣就實現了js,html,css的打包,那麼再來看看一些常用的loader:

  • babel-loader: 用babel轉換程式碼
  • url-loader: 依賴於file-loader,把圖片轉換成base64嵌入html,如果超出一定閾值則交給file-loader
	rules: [
		 // 處理js
		 {
            test: /\.js?$/,
            exclude: /node_modules/,
            use: ['babel-loader']
        },
        // 處理圖片
        {
            test: /\.(png|jpg|gif|ttf|eot|woff(2)?)(\?[=a-z0-9]+)?$/,
            use: [{
                loader: 'url-loader',
                options: {
                    query: {
                        // 閾值 單位byte
                        limit: '8192',
                        name: 'images/[name]_[hash:7].[ext]',
                    }
                }
            }]
        },
	]
複製程式碼

babel的配置建議在根目錄下新建一個.babelrc檔案

{
    "presets": [
        "env",
        "stage-0", 
        "react"
    ],
    "plugins": [
        "transform-runtime",
        "transform-decorators-legacy",
        "add-module-exports"
    ]
}
複製程式碼
  • presets: 預設, 一個預設包含多個外掛 起到方便作用 不用引用多個外掛
  • env: 只轉換新的句法,例如const let => ..等 不轉換 Iterator、Generator、Set、Maps、Proxy、Reflect、Symbol、Promise、Object.assign。
  • stage-0: es7提案轉碼規則 有 0 1 2 3 階段 0包含 1 2 3裡面的所有
  • react: 轉換react jsx語法
  • plugins: 外掛 可以自己開發外掛 轉換程式碼(依賴於ast抽象語法數)
  • transform-runtime: 轉換新語法,自動引入polyfill外掛,另外可以避免汙染全域性變數
  • transform-decorators-legacy: 支援裝飾器
  • add-module-exports: 轉譯export default {}; 新增上module.exports = exports.default 支援commonjs

因為我們在檔名中加入hash值,打包多次後dist目錄變得非常多檔案,沒有刪除或覆蓋,這裡可以引入一個外掛,在打包前自動刪除dist目錄,保證dist目錄下是當前打包後的檔案:

plugins: [
	new CleanWebpackPlugin(
	    // 需要刪除的資料夾或檔案
	    [path.join(__dirname, './dist/*.*')],
	    {
	        // root目錄
	        root: path.join(__dirname, './')
	    }
	),
]
複製程式碼

指定extension之後可以不用在require或是import的時候加副檔名,會依次嘗試新增副檔名進行匹配:

resolve: {
    extensions: ['.js', '.jsx', '.scss', '.json'],
},
複製程式碼

3. 優化實戰 高階裝備

天下武功唯快不破,優化方案千千萬萬,各取所需吧。

提出公共的JS檔案

webpack4中廢棄了webpack.optimize.CommonsChunkPlugin外掛,用新的配置項替代

module.exports = {
	entry: './src/index.js',
	output: {
		path: path.join(__dirname, './dist'),
		filename: 'main.js',
	},
    optimization: {
        splitChunks: {
            cacheGroups: {
                commons: {
                    chunks: 'initial',
                    minChunks: 2,
                    maxInitialRequests: 5,
                    minSize: 2,
                    name: 'common'
                }
            }
        }
    },
}
複製程式碼

把多次import的檔案打包成一個單獨的common.js

HappyPack

在webpack執行在node中打包的時候是單執行緒去一件一件事情的做,HappyPack可以開啟多個子程式去併發執行,子程式處理完後把結果交給主程式

 npm i happypack -D
複製程式碼

需要改造一下loader配置,此loader用子程式去處理

const HappyPack = require('happypack');
module.exports = {
	entry: './src/index.js',
	output: {
		path: path.join(__dirname, './dist'),
		filename: 'main.js',
    },
    module: {
        rules: [
            {
                test: /\.jsx?$/,
                exclude: /node_modules/,
                use: 'happypack/loader?id=babel',
            },
        ]
    },
    plugins: [
        new HappyPack({
            id: 'babel',
            threads: 4,
            loaders: ['babel-loader']
        }),
    ]
}
複製程式碼
  • id: id值,與loader配置項對應
  • threads: 配置多少個子程式
  • loaders: 用什麼loader處理
  • 具體文件

作用域提升

如果你的專案是用ES2015的模組語法,並且webpack3+,那麼建議啟用這一外掛,把所有的模組放到一個函式裡,減少了函式宣告,檔案體積變小,函式作用域變少。

module.exports = {
	entry: './src/index.js',
	output: {
		path: path.join(__dirname, './dist'),
		filename: 'main.js',
    },
    plugins: [
        new webpack.optimize.ModuleConcatenationPlugin(),
    ]
}
複製程式碼

提取第三方庫

方便長期快取第三方的庫,新建一個入口,把第三方庫作為一個chunk,生成vendor.js

module.exports = {
    entry: {
        main: './src/index.js',
        vendor: ['react', 'react-dom'],
    },
}
複製程式碼

DLL動態連結

第三庫不是經常更新,打包的時候希望分開打包,來提升打包速度。打包dll需要新建一個webpack配置檔案,在打包dll的時候,webpack做一個索引,寫在manifest檔案中。然後打包專案檔案時只需要讀取manifest檔案。

webpack.vendor.js

const webpack = require('webpack');
const path = require('path');

module.exports = {
    entry: {
        vendor: ['react', 'react-dom'],
    },
    output: {
        path: path.join(__dirname, './dist'),
        filename: 'dll/[name]_dll.js',
        library: '_dll_[name]',
    },
    plugins: [
        new webpack.DllPlugin({
            path: path.join(__dirname, './dist/dll', 'manifest.json'),
            name: '_dll_[name]',
        }),
    ]
};
複製程式碼

path: manifest檔案的輸出路徑 name: dll暴露的物件名,要跟output.library保持一致 context: 解析包路徑的上下文,這個要跟接下來配置的dll user一致

webpack.config.js

module.exports = {
    entry: {
        main: './src/index.js',
        vendor: ['react', 'react-dom'],
    },
    plugins: [
        new webpack.DllReferencePlugin({
            manifest: path.join(__dirname, './dist/dll', 'manifest.json')
        })
    ]
}
複製程式碼

html

<script src="vendor_dll.js"></script>
複製程式碼

4. 線上和線下

在生產環境和開發環境其實我們的配置是存在相同點,和不同點的,為了處理這個問題,會建立3個檔案:

  • webpack.base.js: 共同的配置
  • webpack.dev.js: 在開發環境下的配置
  • webpack.prod.js: 在生產環境的配置

通過webpack-merge去做配置的合併,比如:

開發環境

const path = require('path');
const webpack = require('webpack');
const merge = require('webpack-merge');
const base = require('./webpack.base');

const dev = {
    devServer: {
        contentBase: path.join(__dirname, '../dist'),
        port: 8080,
        host: 'localhost',
        overlay: true,
        compress: true,
        open:true,
        hot: true,
        inline: true,
        progress: true,
    },
    devtool: 'inline-source-map',
    plugins: [
        new webpack.HotModuleReplacementPlugin(),
        new webpack.NamedModulesPlugin(),
    ]
}
module.exports = merge(base, dev);
複製程式碼

開發環境中我們可以啟動一個devServer靜態檔案伺服器,預覽我們的專案,引入base配置檔案,用merge去合併配置。

  • contentBase: 靜態檔案地址
  • port: 埠號
  • host: 主機
  • overlay: 如果出錯,則在瀏覽器中顯示出錯誤
  • compress: 伺服器返回瀏覽器的時候是否啟動gzip壓縮
  • open: 打包完成自動開啟瀏覽器
  • hot: 模組熱替換 需要webpack.HotModuleReplacementPlugin外掛
  • inline: 實時構建
  • progress: 顯示打包進度
  • devtool: 生成程式碼對映,檢視編譯前程式碼,利於找bug
  • webpack.NamedModulesPlugin: 顯示模組的相對路徑

生產環境

再來看看生產環境最重要的程式碼壓縮,混淆:

const path = require('path');
const merge = require('webpack-merge');
const WebpackParallelUglifyPlugin = require('webpack-parallel-uglify-plugin');
const base = require('./webpack.base');

const prod = {
    plugins: [
        // 文件: https://github.com/gdborton/webpack-parallel-uglify-plugin
        new WebpackParallelUglifyPlugin(
            {
                uglifyJS: {
                    mangle: false,
                    output: {
                        beautify: false,
                        comments: false
                    },
                    compress: {
                        warnings: false,
                        drop_console: true,
                        collapse_vars: true,
                        reduce_vars: true
                    }
                }
            }
        ),
    ]
}
module.exports = merge(base, prod);
複製程式碼

webpack-parallel-uglify-plugin可以並行壓縮程式碼,提升打包效率

uglifyJS配置:

  • mangle: 是否混淆程式碼
  • output.beautify: 程式碼壓縮成一行 true為不壓縮 false壓縮
  • output.comments: 去掉註釋
  • compress.warnings: 在刪除沒用到程式碼時 不輸出警告
  • compress.drop_console: 刪除console
  • compress.collapse_vars: 把定義一次的變數,直接使用,取消定義變數
  • compress.reduce_vars: 合併多次用到的值,定義成變數
  • 具體文件

5. 成為頭號玩家

想要成為頭號玩家,玩轉配置可不行,當然還要做一些loader和plugin的開發,去為專案做一些優化,解決痛點。

loader

loader是一個模組匯出函式,在正則匹配成功的時候呼叫,webpack把檔案陣列傳入進來,在this上下文可以訪問loader API

  • this.context: 當前處理檔案的所在目錄,假如當前 Loader 處理的檔案是 /src/main.js,則 this.context 就等於 /src。
  • this.resource: 當前處理檔案的完整請求路徑,包括 querystring,例如 /src/main.js?name=1。
  • this.resourcePath: 當前處理檔案的路徑,例如 /src/main.js。
  • this.resourceQuery: 當前處理檔案的 querystring。
  • this.target: 等於 Webpack 配置中的 Target
  • this.loadModule: 但 Loader 在處理一個檔案時,如果依賴其它檔案的處理結果才能得出當前檔案的結果時, 就可以通過 - - - this.loadModule(request: string, callback: function(err, source, sourceMap, module)) 去獲得 request 對應檔案的處理結果。
  • this.resolve: 像 require 語句一樣獲得指定檔案的完整路徑,使用方法為 resolve(context: string, request: string, callback: function(err, result: string))。
  • this.addDependency: 給當前處理檔案新增其依賴的檔案,以便再其依賴的檔案發生變化時,會重新呼叫 Loader 處理該檔案。使用方法為 addDependency(file: string)。
  • this.addContextDependency: 和 addDependency 類似,但 addContextDependency 是把整個目錄加入到當前正在處理檔案的依賴中。使用方法為 addContextDependency(directory: string)。
  • this.clearDependencies: 清除當前正在處理檔案的所有依賴,使用方法為 clearDependencies()。
  • this.emitFile: 輸出一個檔案,使用方法為 emitFile(name: string, content: Buffer|string, sourceMap: {...})。
  • this.async: 返回一個回撥函式,用於非同步執行。

下面來看看less-loaderstyle-loader如何實現:

let less = require('less');
module.exports = function (source) {
    const callback = this.async();
    less.render(source, (err, result) => {
        callback(err, result.css);
    });
}
複製程式碼
module.exports = function (source) {
    let script = (`
      let style = document.createElement("style");
      style.innerText = ${JSON.stringify(source)};
      document.head.appendChild(style);
   `);
    return script;
}
複製程式碼

plugin

webpack整個構建流程有許多鉤子,開發者可以在指定的階段加入自己的行為到webpack構建流程中。外掛由以下構成:

  • 一個 JavaScript 命名函式。
  • 在外掛函式的 prototype 上定義一個 apply 方法。
  • 指定一個繫結到 webpack 自身的事件鉤子。
  • 處理 webpack 內部例項的特定資料。
  • 功能完成後呼叫 webpack 提供的回撥。

整個webpack流程由compiler和compilation構成,compiler只會建立一次,compilation如果開起了watch檔案變化,那麼會多次生成compilation. 那麼這2個類下面生成了需要事件鉤子

compiler hooks 文件 compilation hooks 文件

寫一個小外掛,生成所有打包的檔案列表(webpack4不推薦使用compiler.plugin來註冊外掛,webpack5將不支援):

class FileListPlugin{
    constructor(options) {
        this.options = options;
    }
    apply(compiler) {
        compiler.hooks.emit.tap('FileListPlugin',function (compilation) {
            let fileList = 'filelist:\n\n';
            for (let filename in compilation.assets) {
                fileList += ('- '+filename+'\n');
            }
            compilation.assets['filelist.md']={
                source() {
                    return fileList;
                },
                size() {
                    return fileList.length
                }
            }
        });
    }
}
module.exports = FileListPlugin;
複製程式碼

6. 最後

都讀在這裡了,還不點個贊嗎。

謝謝你閱讀我的文章

相關文章