從零搭建一個 webpack 腳手架工具(四)
- 2019 年 12 月 26 日
- 筆記
loader 原理與實現
loader 的執行順序
loader 的執行順序是:從右到左、從下到上。在配置 sass 樣式時,需要這麼去寫 loader:
{ test: /.sass$/, use: ['style-loader','css-loader','sass-loader'] }
loader 會先執行 sass-loader,讓 sass 格式的樣式轉成 css 格式,然後使用 css-loader 處理樣式中引入的圖片路徑,最後使用 style-loader 將樣式插入到 style 標籤中。因此是 「從右到左」 執行。再看下面的配置:
[ { test: /.js$/, use: 'babel-loader' },{ test: /.js$/, use: 'eslint-loader' } ]
eslint-loader 放在最後,就是先執行 eslint-loader,檢驗程式碼書寫規則,然後再執行別的 js loader,所以是 「從下到上」 執行。
loader 的類型
expose-loader
expose-loader 可以將局部變數暴露到 window 上。比如當我們使用 jQuery 時,可以這樣引入:import $ from jquery
。但是當獲取 window.$
時必不能獲得 jQuery 對象。這是因為 webpack 做了處理沒讓 jquery 變數暴露給 window。如果想讓 改變數暴露出來,就可以使用 expose-loader
。用法是將 import
語法修改成下面的樣子:
import $ from "expose-loader?$!jquery";
上面的程式碼中,expose-loader
就是指使用的該模組,而 ?
和 !
是固定格式,它們之間的 $
表示要暴露給 window 的變數。這樣,jQuery 對象就暴露給了全局。
當然也可以在 webpack 中進行配置:
{ rules: [ { // 當引用了 jquery 模組時就使用 expose-loader test: require.resolve('jquery'), use: 'expose-loader?$' } ] }
但是使用上面的配置之後,還是要使用 import $ from "jquery";
方式去引入模組。如果不想每次重複去寫這一句程式碼。可以使用 webpack 自帶的一個插件:ProvidePlugin
。使用了這個插件之後,也不用再每次都引入。配置如下:
const webpack from 'webpack'; { plugins: [ new webpack.ProvidePlugin({ // 在每個模組中注入 $ 變數 $: 'jquery' }) ] }
expose-loader
的配置與別的 loader 有些不同,expose-loader 可以用在 import
語句中。這種 loader 稱為內聯(inline)loader。
前面已經介紹過,webpack loader 的配置項中,有一個 enforce
配置項可以指定 loader 的執行順序。
rules: [ { test: /.js$/, use: 'loader1', // pre 表示這個 loader 在前面先執行 enforce: 'pre' } ]
enforce 有三個取值:
pre
:這個 loader 在前面先執行;post
:這個 loader 在後面執行;normal
:在中間執行(pre 之後,post 之前,normal 是默認的值)
除了這三種 loader 還有一個就是行內 loader(inline)。這四種 loader 的執行順序是這樣的:先執行 pre;在執行 normal;然後執行 inline;最後執行 post。
行內 loader 比較特殊,不能使用 enforce
進行配置。需要在引入文件時進行配置,比如使用行內 loader 處理 a.js 文件的執行,需要這麼來寫:
// inline-loader-name 就是行內 loader 的名字 // ! 感嘆後右邊是文件路徑 // 當然,import 方式的書寫方式與 require 一樣 const a = require("inline-loader-name!./a.js");
如果你使用了 inline-loader,又不希望前置 loader 和 normal loader 再去執行,可以使用 -!
的方式禁止:
const a = require("-!inline-loader-name!./a.js");
當前面只加了 !
時表示 normal loader 不會再執行(const a = require("!inline-loader-name!./a.js");
); 前面有兩個 !!
時表示 只有 inline-loader 會執行,別的 loader 都不會再執行。
loader 的組成
loader 默認有兩部分組成:pitch 和 normal。

pitch無返回值的loader
loader 會先執行 pitch,然後獲取資源再執行 normal loader。如果 pitch 有返回值時,就不會走之後的 loader,並將返回值返回給之前的 loader。

pitch有返回值的loader
loader 其實就是一個函數,函數的參數是處理文件的文件內容,參數類型是字元串。這個函數還有一個 pitch 方法,同樣也有一個參數,是字元串形式的剩餘參數,這個剩餘參數中有當前 loader 之後還沒有執行的 loader 的所在的絕對路徑。
因此 webpack 的配置文件中的 use: [loader3,loader2,loader1]
的執行順序是這樣的:假如三個 loader 的 pitch 函數都沒有返回值(或者說沒寫 loader.pitch 函數,沒有返回值的 pitch 函數是沒有用的,當寫 pitch 函數時就應考慮返回什麼),那麼就直接獲取資源,然後走下面的 normal 部分。如果 loader2 的 pitch 有返回值,則 pitch 的 loader1 和 normal 的 loader1、loader2 就不會執行,而是執行 normal 的 loader3 函數。
loader 的特點
- 第一個 loader 要返回 js 腳本(字元串格式的腳本,這裡的第一個 loader 指的是數組的最左邊的那個 loader)
- 每個 loader 只做一件事,為了使 loader 在更多的場景中鏈式調用;
- 每一個 loader 都是一個模組;
- 每個 loader 都是無狀態的,確保 loader 在不同的模組轉換之間保存狀態。
var loader = function(source){ console.log(source); } loader.pitch = function(remainingRequest){ return ; }
實現 babel-loader
需要先下載 @babel/core
、@babel/preset-env
兩個 Babel 包:
npm install @babel/core @babel/preset-env
{ test: /.js$/, use: { loader: 'babel-loader', options: { "presets": [ "@babel/preset-env" ] } } }
下面的庫在以下的程式碼中會用到,需要下載:
@babel/core
babel 核心模組;@babel/preset-env
babel 必備模組,負責程式碼轉碼;less
編寫 less-laoder 時需要引入;loader-utils
編寫 webpack loader 的工具庫;schema-utils
一個可以校驗變數類型的庫;mime
該模組可以獲取文件後綴;
babel-loader
const babel = require("@babel/core"); const loaderUtils = require("loader-utils"); function loader(source){ // loader 中有一個 this 指向 loaderContext // getOptions 可以獲得 loader 中的 options 配置對象 var options = loaderUtils.getOptions(this); // loaderContext 中有一個 async 方法 // 這個方法是為了能非同步的返回處理好的結果 // cb 接受兩個參數,第一個參數是 error 資訊, // 第二個參數是 處理好 source 後的結果 var cb = this.async(); // babel 轉碼函數 babel.transform(source,{ ...options, // 在 loader 中指定了 sourceMap 後還需要在 webpack 中進行配置(devtool: 'source-map')才能生成 sourceMap sourceMaps: true, // 指定文件的名字。resourcePath 就是文件所在的絕對路徑(因此需要使用 split 方法) filename: this.resourcePath.split('/').pop() },function(err,result){ // console.log(result); // 非同步的返回結果 // result.code 就是loader處理後的程式碼 // result.map 就是 sourceMap cb(err,result.code,result.map); }); } module.exports = loader;
banner-loader
banner-loader
是一個可以將注釋插入到 js 文件頁面頂部的 loader,這個 loader 可以表示 js 文件的一些說明。banner-loader 接受兩個參數:text:直接傳入一個注釋用的字元串,filename:一個注釋模板文件(路徑),指定後就會讀取模板文件中的內容。
{ test: /.js$/, use: { loader: 'banner-loader', options: { text: "xxxx", filename: "" } } }
具體的實現源碼:
const fs = require("fs"); const loaderUtils = require("loader-utils"); // schema-utils 是一個校驗模組 const schemaUtils = require("schema-utils"); function loader(source){ // 指定為 false 後,webpack 每次打包都不進行快取 // webpack 默認有快取(有快取是有好處的,可以節約時間) this.cacheable(false); var options = loaderUtils.getOptions(this); var cb = this.async(); // 創建一個驗證骨架 var schema = { // 屬性中的參數 properties: { text: { type: "string", }, filename: { type: 'string', } } } // 第三個參數表示不符合條件時報出的錯誤資訊 schemaUtils(schema, options, "banner-loader"); if(options.filename){ // 這個方法原理是這樣的: // webpack 中可以指定 witch 配置為 true // 表示當需要打包的文件更改時,webpack會自動打包 // 而 options.filename 中的文件更改後webpack並不會進行打包 // 因此需要讓 webpack 明白,該文件在修改後也會觸發 witch 監聽並自動打包 this.addDependency(options.filename); // 讀取文件 fs.readFile(options.filename,"utf8",function(err,data){ cb(err,`/** ${data} **/rn${source}`); }); }else{ // 指定的是text參數 cb(null,`/** ${options.text} **/rn${source}`); } return source; } module.exports = loader;
file-loader 和 url-loader
file-loader
const loaderUtils = require("loader-utils"); /** * file-loader 的作用: * 根據圖片生成一個 MD5 並發射到打包的目錄下 * file-laoder 還會返回當前的文件路徑(在 js 中可以使用 import 方式進行引入) * @param {string} source */ function loader(source){ // 根據當前的格式和文件內容來創建一個路徑 let filename = loaderUtils.interpolateName(this,'[name].[ext]',{ content: source }); // 發射文件 this.emitFile(filename,source); // 返迴文件的路徑 return `module.exports="${filename}"`; } // source 接受的是字元串,而圖片是二進位文件 // 因此需要使用 raw 屬性,將字元串轉成 二進位數據 loader.raw = true; module.exports = loader;
url-loader
const loaderUtils = require("loader-utils"); // mime 包可以獲取文件的後綴 const mime = require("mime"); /** * url-loader 會處理路徑 * url-loader 有一個 options 選項:limit * limit 選項可以指定文件的大小(位元組) * 當文件小於 limit 值時會生成 base64 的字元串 * 大於 limit 值時才會像 file-loader 一樣去處理文件 * 因此,在 webpack 中使用了 url-loader 後,就不用再使用 file-loader 了。 * @param {string} source */ function loader(source){ var {limit} = loaderUtils.getOptions(this); if(limit && limit > source.length){ // 轉成 base64 格式 return `module.exports="data:${mime.getType(this.resourcePath)};base64,${source.toString("base64")}"`; }else{ // 否則的話就交給 file-loader 去處理 return require("./file-loader").call(this,source); } } loader.raw = true; module.exports = loader;
樣式 loader 的編寫
webpack 中的配置:
rules: [ { test: /.less$/, use: ['style-loader','css-loader','less-loader'] } ]
less-loader 的實現
less-loader 主要是將 less 格式的樣式轉成瀏覽器能認識的原生 css 程式碼。
- 首先需要先下載
less
:npm install less
。 - 編寫
less-loader
的 loader 文件。
// less-loader let less = require("less"); // source 就是 less 文件中的源碼 function loader(source){ let css; // less 中有一個方法 // 這個方法可以處理 less 文件中的樣式 less.render(source,function(err,result){ // 處理好後,回調函數中的 result 參數就是處理好後的結果 css = r.css }); // 返回處理好的結果 return css; } module.exports = loader;
上面就完成了 less-loader 的編寫。less-loader 的返回值回傳給 css-loader,css-loader 再對樣式做進一步的處理。處理好後再把處理好的結果返回,讓 style-loader
接受,做最後的處理。
css-loader 的處理過程比較麻煩,這裡先介紹一下 style-loader。
style-loader 的編寫
style-loader
的作用是將 css 程式碼插入到 head
標籤下的 style
標籤中。
webpack 配置文件中的 use 數組中的第一個 loader 應該返回一個 JS 腳本(字元串格式的 JS 腳本),因此 style-loader
需要這麼做。
// style-loader const loaderUtils = require("loader-utils"); function loader(source){ // 創建一個 style 標籤,標籤里的內容就是 css-loader 處理後的結果 let str = ` let style = document.createElement("style"); style.innerHTML = ${JSON.stringify(source)}; document.head.appendChild(style); `; // 返回這個 JS 腳本 return str; }
css-loader 的實現
css-loader 處理的是樣式中引入的圖片路徑(url(xxx)
)。
我們就需要想辦法將源碼中的 url()
字元串提取出來,然後給路徑做替換。再把替換後的路徑插入到源碼中。
先說一下 JavaScript 正則表達式中的一個方法:exec
。這個方法很強大。它的調用格式:reg.exec(str)
。
這個方法會返回一個數組,數組裡面是匹配到的字元串結果。此數組的第 0 個元素是與正則表達式相匹配的文本,第 1 個元素是與 RegExpObject 的第 1 個子表達式相匹配的文本(如果有的話),第 2 個元素是與 RegExpObject 的第 2 個子表達式相匹配的文本(如果有的話),以此類推。RegExpObject
可以看做是正則表達式中的括弧里匹配的內容。比如下面的字元串:
let str = ` body{ background: url('./01.jpg'); } div{ background: url('./02.png'); } `; var reg = /url((.+?))/g, res = reg.exec(str); console.log(res);
列印的結果將返回一個數組:["url('./01.jpg')","'./01.jpg'"]
。數組第一項正則表達式匹配的文本,而第二項匹配的是正則表達式中 (.+?)
中的內容。
exec
方法可以連續調用,當再次調用 var next = reg.exec(str)
時,將返回 ["url('./02.png')","'./01.png'"]
。表示匹配下一個符合條件的的字元串。當匹配不到時會返回 null
。
因此可以使用循環找出所有符合條件的結果:
var current = reg.exec(str), arr = []; while(current){ arr.push(current); current = reg.exec(str); }
RegExpObject
中有一個 lastIndex
屬性,當 exec() 找到了與表達式相匹配的文本時,在匹配後,它將把 RegExpObject 的 lastIndex 屬性設置為匹配文本的最後一個字元的下一個位置。比如:
var reg = /123(abc)/g, str = 'qwe123abcqqq', reg.exec(str); console.log(reg.lastIndex); // 9
因此利用 lastIndex
熟悉就可以將截掉的 css 程式碼拿出來,利用字元串的 slice
方法。
以下就是 css-loader 的源碼:
// css-loader function loader(source){ // 匹配 url(xxx) 格式的字元串 // 正則表達式的子項(括弧里匹配的內容)匹配的就是純粹的路徑 let reg = /url((.+?))/g; let pos = 0; let current; let arr = ['let list = []']; // current while(current = reg.exec(source)){ let [matchUrl,g] = current; // last 就是 url() 字元的第一個字元(u)前面的那個字元的索引 let last = reg.lastIndex - matchUrl.length; // 將 url() 字元串的前面的內容添加到數組中 arr.push(`list.push(${JSON.stringify(source.slice(pos,last))}`); // 然後 pos 等於 lastIndex,為了保存 url() 字元串後面的內容 pos = reg.lastIndex; // 把 g 替換成 require 的寫法 => url(require('xxx')) arr.push(`list.push('url('+require(${g}+')')`); } // url() 字元串後面的內容截取完即可 arr.push(`list.push(${JSON.stringify(source.slice(pos))})`); // 最後將 list 拼接並導出(list 中存入的改過的 css 程式碼) arr.push(`module.exports = list.join('')`); return eval(arr.join('rn')); }
我們還沒有用到 pitch 函數,這裡可以在 style-loader 中使用 pitch
函數(當然,不使用也可以,前面都已實現,這裡只是再使用 pitch 模擬一下):
// style-loader.js const loaderUtils = require("loader-utils"); function loader(source){ // 創建一個 style 標籤,標籤里的內容就是 css-loader 處理後的結果 let str = ` let style = document.createElement("style"); style.innerHTML = ${JSON.stringify(source)}; document.head.appendChild(style); `; // 返回這個 JS 腳本 return str; } // 在 style-loader 上寫 pitch //(pitch 的執行順序是從左到右,即:style-loader 先被執行) // `style-loader` 的 pitch 有返回值時, // css-loader 和 less-loader 的 pitch 就不在執行了。就是開始執行 `normal`。 loader.pitch = function(remainingRequest){ // remainingRequest 表示為「剩餘的請求」, // 在 pitch 中,先走的是 style-loader // 因此還剩下 css-loader 和 less-loader 沒有執行 // 所以,remainingRequest 就是 css-loader!less-loader! 當前文件路徑(就是 less 文件路徑) 的格式的字元串 // 讓 style-laoder 去處理 remainingRequest // loaderUtils.stringifyRequest 方法可以將絕對路徑轉成相對路徑 // !!css-loader!less-loader!less文件路徑 // remainingRequest 中的路徑是絕對路徑,需要轉換一下 // require 的路徑返回的就是 css-loader 處理好的結果 // innerHTML中 引用了 css-loader 和 style-loader // 這時就會跳過剩餘的 pitch,開始獲取資源,執行 normal。先執行 less-loader // 然後執行 css-loader 最後執行 style-loader var str = ` let style = document.createElement('style'); style.innerHTML = require(${loaderUtils.stringifyRequest(this,'!!' + remainingRequest)}); document.head.appendChild(style); `; return str; } module.exports = loader;
以上就是樣式 loader 的實現原理。