從零搭建一個 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 的特點

  1. 第一個 loader 要返回 js 腳本(字元串格式的腳本,這裡的第一個 loader 指的是數組的最左邊的那個 loader)
  2. 每個 loader 只做一件事,為了使 loader 在更多的場景中鏈式調用;
  3. 每一個 loader 都是一個模組;
  4. 每個 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 是一個可以將注釋插入到 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 程式碼。

  1. 首先需要先下載 lessnpm install less
  2. 編寫 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 的實現原理。