webpack 手寫 loader 與 plugin,手寫簡易版本的 webpack

前言>本文示例

手寫 loader

什麼是 loader?

loader 用於對模塊的源代碼進行轉換。loader 可使你在 import 或 "load(加載)" 模塊時預處理文件。所以,loader 相似於其餘構建工具中「任務(task)」,並提供了處理前端構建步驟的得力方式。loader 能夠將文件從不一樣的語言(如 TypeScript)轉換爲 JavaScript 或將內聯圖像轉換爲 data URL。loader 甚至容許你直接在 JavaScript 模塊中 import CSS 文件!javascript

對於 webpack 來講,一切資源皆是模塊,但因爲 webpack 默認只支持 es5 的 js 以及 json,像是 es6+, react,css 等都要由 loader 來轉化處理。css

loader 代碼結構

loader 就只是一個導出爲函數的 js 模塊。html

module.exports = function(source, map) {
	return source;
}
複製代碼

其中 source 表示匹配上的文件資源字符串,map 表示 SourceMap。前端

注意: 不要寫成箭頭函數,由於 loader 內部的屬性和方法,須要經過 this 進行調用,好比默認開啓 loader 緩存,配製 this.cacheable(false) 來關掉緩存java

同步 loader

需求: 替換 js 裏的某個字符串node

實現:react

新建個 replaceLoader.js:webpack

module.exports = function (source) {
  return `${source.replace('hello', 'world')} `;
};
複製代碼

webpack.config.js:git

const path = require('path');

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'main.js',
  },
  module: {
    rules: [{ test: /\.js$/, use: './loaders/replaceLoader.js' }],
  },
};
複製代碼

傳遞參數

上面的 replaceLoader 是固定將某個字符串(hello)替換掉,實際場景中更多的是經過參數傳入es6

webpack.config.js:

const path = require('path');

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'main.js',
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        // 經過 options 參數傳參
        use: [
          {
            loader: './loaders/replaceLoader.js',
            options: {
              name: 'hello',
            },
          },
        ],
        // 經過字符串來傳參
        // use: './loaders/replaceLoader.js?name=hello'
      },
    ],
  },
};

複製代碼

以上兩種傳參方式,若是使用 query 屬性來獲取參數,就會出現字符串傳參獲取到的是字符串, options 傳參獲取到的是對象格式,很差處理。這裏推薦使用 loader-utils 庫來處理。

這裏使用庫裏的 getOptions 函數來接收參數

const { getOptions } = require('loader-utils');
module.exports = function (source) {
  const params = getOptions(this);
  return `${source.replace(params.name, 'world')} `;
};

複製代碼

異常處理

第一種: loader 內直接經過 throw 拋出

const { getOptions } = require('loader-utils');
module.exports = function (source) {
  const params = getOptions(this);
  throw new Error('出錯了');
};
複製代碼

第二種: 經過 this.callback 傳遞錯誤

this.callback({
    //當沒法轉換原內容時,給 Webpack 返回一個 Error
    error: Error | Null,
    //轉換後的內容
    content: String | Buffer,
    //轉換後的內容得出原內容的Source Map(可選)
    sourceMap?: SourceMap,
    //原內容生成 AST語法樹(可選)
    abstractSyntaxTree?: AST 
})
複製代碼

第一個參數表示錯誤信息,當傳遞 null 時,做用跟前面的直接 return 個字符串做用相似,更建議採用這種方式返回內容

const { getOptions } = require('loader-utils');
module.exports = function (source) {
  const params = getOptions(this);
  this.callback(new Error("出錯了"), `${source.replace(params.name, 'world')} `);
};

複製代碼

異步處理

當遇到要處理異步需求時,好比獲取文件,此時經過 this.async() 告知 webpack 當前 loader 是異步運行。

const fs = require('fs');
const path = require('path');
module.exports = function (source) {
  const callback = this.async();
  fs.readFileSync(
    path.resolve(__dirname, '../src/async.txt'),
    'utf-8',
    (error, content) => {
      if (error) {
        callback(error, '');
      }
      callback(null, content);
    }
  );
};

複製代碼

其中 callback 是跟上面 this.callback 同樣的用法。

文件輸出

經過 this.emitFile 進行文件寫入。

const { interpolateName } = require('loader-utils');
const path = require('path');
module.exports = function (source) {
  const url = interpolateName(this, '[name].[ext]', { source });
  this.emitFile(url, source);
  this.callback(null, source);
};

複製代碼

resolveLoader

上述設置 loader 時將整個文件路徑都配置了,這樣寫多了,是有些麻煩的,能夠經過 resolveLoader 定義 loader 的查找文件路徑。

const path = require('path');

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'main.js',
  },
  resolveLoader: { modules: ['./loaders/', 'node_modules'] },
  module: {
    rules: [
      {
        test: /\.js$/,
        // 經過 options 參數傳參
        use: [
          {
            loader: 'asyncLoader.js',
          },
          {
            loader: 'emitLoader.js',
          },
          {
            loader: 'replaceLoader.js',
            options: {
              name: 'hello',
            },
          },
        ],
        // 經過字符串來傳參
        // use: './loaders/replaceLoader.js?name=hello'
      },
    ],
  },
};

複製代碼

plugin 工做機制

在手寫 plugin 以前,先講下 webpack 裏 plugin 的工做機制,方便後續的講解。

在 webpack.js 有以下代碼:

compiler = new Compiler(options.context);
compiler.options = options;
if (options.plugins && Array.isArray(options.plugins)) {
	for (const plugin of options.plugins) {
		if (typeof plugin === "function") {
			plugin.call(compiler, compiler);
		} else {
			plugin.apply(compiler);
		}
	}
}
複製代碼

能夠看到會遍歷 options.plugins 並依次調用 apply 方法,固然若是 plugin 是個函數的話,會調用 call,官網推薦將 plugin 定義成類。

Tapable

上面代碼中能夠看到建立了個 Compiler 實例,將傳遞給各個 plugin。那麼 Compiler 究竟是作什麼的?

進入 Compiler.js 與 Compilation.js ,能夠看到這兩個類都繼承自 Tapable

class Compiler extends Tapable {}
class Compilation extends Tapable {}
複製代碼

Tapable 是一個相似於 Node.js 的 EventEmitter 的庫,主要是控制鉤子函數的發佈與訂閱,控制着 webpack 的插件系統。 Tapable 庫暴露了不少 Hook(鉤子)類,其中既有同步 Hook,好比 SyncHook;也有異步 Hook,好比 AsyncSeriesHook。

new 一個 hook 獲取咱們須要的鉤子,該方法接收數組參數 options,非必傳。 好比:

const hook1 = new SyncHook(["arg1", "arg2", "arg3"]);
複製代碼

hook 鉤子的綁定與執行

同步與異步 hook 的綁定與執行是不同的:

Async*(異步) Sync* (同步)
綁定:tapAsync/tapPromise/tap 綁定:tap
執行:callAsync/promise 執行:call
const { SyncHook } = require('tapable');
const hook1 = new SyncHook(["arg1", "arg2", "arg3"]); 
//綁定事件到webapck事件流 
hook1.tap('hook1', (arg1, arg2, arg3) => console.log(arg1, arg2, arg3)) //1,2,3 
//執行綁定的事件 
hook1.call(1,2,3)
複製代碼

模擬插件執行

模擬個 Compiler.js

const { SyncHook, AsyncSeriesHook } = require('tapable');

module.exports = class Compiler {
  constructor() {
    this.hooks = {
      add: new SyncHook(), // 無參同步
      reduce: new SyncHook(['arg']), // 有參同步
      fetchNum: new AsyncSeriesHook(['arg1', 'arg2']), // 異步 hook
    };
  }
  // 入口執行函數
  run() {
    this.add();
    this.reduce(20);
    this.fetchNum('async', 'hook');
  }
  add() {
    this.hooks.add.call();
  }
  reduce(num) {
    this.hooks.reduce.call(num);
  }
  fetchNum() {
    this.hooks.fetchNum.promise(...arguments).then(
      () => {},
      (error) => console.info(error)
    );
  }
};

複製代碼

自定義個 plugin,綁定上面定義的幾個 hook

class MyPlugin {
  apply(compiler) {
    compiler.hooks.add.tap('add', () => console.info('add'));
    compiler.hooks.reduce.tap('reduce', (num) => console.info(num));
    compiler.hooks.fetchNum.tapPromise('fetch tapAsync', (num1, num2) => {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          console.log(`tapPromise to ${num1} ${num2}`);
          resolve();
        }, 1000);
      });
    });
  }
}
module.exports = MyPlugin;

複製代碼

模擬執行

const MyPlugin = require('./my-plugin');
const Compiler = require('./compiler');

const myPlugin = new MyPlugin();
const options = {
  plugins: [myPlugin],
};
const compiler = new Compiler();
for (const plugin of options.plugins) {
  if (typeof plugin === 'function') {
    plugin.call(compiler, compiler);
  } else {
    plugin.apply(compiler);
  }
}
compiler.run();

複製代碼

具體代碼見 MyPlugins

Compiler 與 Compliation

Compiler:編譯管理器,webpack 啓動後會建立 compiler 對象,該對象一直存活直到結束退出。

Compliation: 單次編輯過程的管理器,好比 watch = true 時,運行過程當中只有一個 compiler 但每次文件變動觸發從新編譯時,都會建立一個新的 compilation 對象

手寫 plugin

有了上面的講解,如今來手寫 plugin

什麼是 plugin

插件是 webpack 的支柱功能。webpack 自身也是構建於你在 webpack 配置中用到的相同的插件系統之上! 插件目的在於解決 loader 沒法實現的其餘事。

插件相似於 React, Vue 裏的生命週期,在某個時間點會觸發,好比 emit 鉤子:輸出 asset 到 output 目錄以前執行;done 鉤子:在編譯完成時執行。

plugin 代碼結構

plugin 就是個類,該類裏有個 apply 方法,方法會接收 compiler 參數 插件定義:

class DemoPlugin {
  // 插件名稱
  apply(compiler) {
    // 定義個 apply 方法
    // 同步的 hook ,採用 tap,第二個函數參數只有 compilation 參數
    compiler.hooks.compile.tap('demo plugin', (compilation) => {
      //插件的 hooks
      console.info(compilation); // 插件處理邏輯
    });
  }
}
module.exports = DemoPlugin;
複製代碼

插件使用:

plugins: [ new DemoPlugin() ]
複製代碼

傳遞參數

在類的 constructor 裏接收便可

接收參數:

class DemoPlugin {
  constructor(options) {
    this.options = options;
  }
  // 插件名稱
  apply(compiler) {
    // 定義個 apply 方法
    // 同步的 hook ,採用 tap,第二個函數參數只有 compilation 參數
    compiler.hooks.compile.tap('demo plugin', (compilation) => {
      //插件的 hooks
      console.info(this.options); // 插件處理邏輯
    });
  }
}
module.exports = DemoPlugin;
複製代碼

傳遞參數:

plugins: [new DemoPlugin({ name: 'zhangsan' })],
複製代碼

文件寫入

webpack 在 emit 階段,會將 compliation.assets 文件寫入磁盤。因此可使用 compilation.assets 對象設置要寫入的文件。

class CopyRightWebpackPlugin {
  apply(compiler) {
    // 異步的 hook ,採用 tap,第二個函數參數有 compilation 跟 cb 參數,必定要調用 cb()
    compiler.hooks.emit.tapAsync(
      'CopyrightWebpackPlugin',
      (compilation, cb) => {
        compilation.assets['copyright.txt'] = {
          source() {
            return 'copyright by webInRoad';
          },
          size() {
            return 11;
          },
        };
        cb();
      }
    );
  }
}
module.exports = CopyRightWebpackPlugin;

複製代碼

簡易版本 webpack

該版本要實現的功能,不包括對於 options 參數的處理,好比 WebpackOptionsApply 將全部的配置 options 參數轉換成 webpack 內部插件。也不包括對於非 js 的處理,只實現將 es6 js 文件轉成支持瀏覽器端運行的代碼。其中涉及 js 轉成 AST,獲取依賴圖譜,輸出文件。

簡易版本 webpack

項目初始化

npm init -y 
複製代碼

初始化 package.json,以及建立 src 目錄,該目錄底下新建 index.js 與 welcome.js。其中 index.js 引用 welcome.js。 目錄結構以下: 目錄結構 文件代碼以下:

// index.js
import { welcome } from './welcome.js';
document.write(welcome('lisi'));

// welcome.js
export function welcome(name) {
  return 'hello' + name;
}
複製代碼

根目錄下新建個 index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="./src/index.js"></script>
</head>
<body>
  
</body>
</html>
複製代碼

使用瀏覽器訪問該 index.html ,顯然是會報錯的,由於瀏覽器目前還沒法直接支持 import 語法

Uncaught SyntaxError: Cannot use import statement outside a module
複製代碼

開始手寫

本質上,webpack 是一個用於現代 JavaScript 應用程序的 靜態模塊打包工具。當 webpack 處理應用程序時,它會在內部構建一個 依賴圖(dependency graph),此依賴圖對應映射到項目所需的每一個模塊,並生成一個或多個 bundle。

根據官網給出的對於 webpack 定義,咱們要實現的簡單版本 webpack,大致要有以下幾個功能:

  1. 讀取配製文件
  2. 從入口文件開始,遞歸去讀取模塊所依賴的文件內容,生成依賴圖
  3. 根據依賴圖,生成瀏覽器可以運行的最終代碼
  4. 生成 bundle 文件

讀取配製文件

首先建立個相似 webpack.config.js 的 simplepack.config.js 配製文件

'use strict';

const path = require('path');

module.exports = {
    entry: path.join(__dirname, './src/index.js'),
    output: {
        path: path.join(__dirname, './dist'),
        filename: 'main.js'
    }
};
複製代碼

而後在根目錄下建立 lib 文件夾,用於實現簡單版本 webpack。

lib 下建立個 index.js 引用 compiler.js 以及 simplepack.config.js 文件

const Compiler = require('./compiler');
const options = require('../simplepack.config');
new Compiler(options).run();
複製代碼

compiler.js 文件先建立放在那。

單個文件的處理

在 lib 目錄下新建 parser.js (用於解析文件),還有 test.js (用於測試 parser.js 的功能)

讀取內容

在 parser.js 裏定義個函數 getAST ,採用 node fs 包加載文件內容

const fs = require('fs');

module.exports = {
  getAST: (path) => {
    const content = fs.readFileSync(path, 'utf-8');
    return content;
  },
};

複製代碼

在 test.js 裏

const { getAST } = require('./parser');
const path = require('path');
const content = getAST(path.join(__dirname, '../src/index.js'));
console.info(content);

複製代碼

node test.js 獲得入口文件的字符串內容: 入口文件內容

獲取依賴

能夠經過正則表達式等方式獲取 import 以及 export 的內容以及相應的路徑文件名,但當文件裏 import 的文件過多時,這種處理方式會很麻煩。這裏咱們藉助 babylon 來完成文件的解析,生成 AST 抽象語法樹。

parser.js:

const babylon = require('babylon');
// 根據代碼生成 AST (抽象語法樹)
getAST: (path) => {
  const content = fs.readFileSync(path, 'utf-8');
  return babylon.parse(content, {
    sourceType: 'module', // 表示項目裏採用的是 ES Module
  });
},
複製代碼

test.js

const { getAST } = require('./parser');
const path = require('path');
const ast = getAST(path.join(__dirname, '../src/index.js'));
console.info(ast.program.body);
複製代碼

文件內容是在 ast.program.body 裏的。

執行 node test.js ,打印出 ast 內容 能夠看到數組裏有兩個 Node 節點,每一個 Node 節點都有個 type 屬性,像第一個 Node 節點的 type 值爲 ImportDeclaration,即對應 index.js 裏的第一行 import 語句,第二行是表達式,因此 type 值爲 ExpressionStatement。

咱們能夠經過遍歷 Node 節點,獲取 type 值爲 ImportDeclaration 的,該 Node 的 source.value 屬性是引用模塊的相對路徑。但這種方式有些複雜,咱們借用 babel-traverse 來獲取依賴:

在 parser.js 裏新增 getDependencies 函數,用於根據 AST 獲取依賴內容

const fs = require('fs');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;

module.exports = {
  // 根據代碼生成 AST (抽象語法樹)
  getAST: (path) => {
    const content = fs.readFileSync(path, 'utf-8');

    return babylon.parse(content, {
      sourceType: 'module',
    });
  },
  // 分析依賴
  getDependencies: (ast) => {
    const dependencies = [];
    traverse(ast, {
      ImportDeclaration: ({ node }) => {
        dependencies.push(node.source.value);
      },
    });
    return dependencies;
  },
};

複製代碼

test.js

const { getAST, getDependencies } = require('./parser');
const path = require('path');
const ast = getAST(path.join(__dirname, '../src/index.js'));
const dependencies = getDependencies(ast);
console.info(dependencies);

複製代碼

執行 node test.js

依賴

獲取到了相對於入口文件的依賴文件路徑

編譯內容

獲取依賴以後,咱們須要對 ast 作語法轉換,把 es6 的語法轉化爲 es5 的語法,使用 babel 核心模塊 @babel/core 以及 @babel/preset-env完成

在 parser.js 裏新增 transform 方法,用於根據 ast ,生成對應的 es5 代碼。

const fs = require('fs');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;
const { transformFromAst } = require('babel-core');

module.exports = {
  // 根據代碼生成 AST (抽象語法樹)
  getAST: (path) => {
    const content = fs.readFileSync(path, 'utf-8');

    return babylon.parse(content, {
      sourceType: 'module',
    });
  },
  // 分析依賴
  getDependencies: (ast) => {
    const dependencies = [];
    traverse(ast, {
      ImportDeclaration: ({ node }) => {
        dependencies.push(node.source.value);
      },
    });
    return dependencies;
  },
  // 將 ast 轉換成 es5 代碼
  transform: (ast) => {
    const { code } = transformFromAst(ast, null, {
      presets: ['env'],
    });

    return code;
  },
};

複製代碼

test.js

const { getAST, getDependencies, transform } = require('./parser');
const path = require('path');
const ast = getAST(path.join(__dirname, '../src/index.js'));
const dependencies = getDependencies(ast);
const source = transform(ast);
console.info(source);

複製代碼

es5

能夠看到是轉成了 es5 語法,但裏頭有個 require 函數,該函數瀏覽器可不自帶,須要定義個。

獲取依賴圖

上面已經實現了單個文件依賴的獲取,如今從入口模塊開始,對每一個模塊以及模塊的依賴模塊進行分析,最終返回一個包含全部模塊信息的對象,存儲在 this.modules 裏。 lib 目錄下新建 compiler.js

  1. constructor 構造函數裏接收 options 參數(包含入口與出口配製信息) 以及定義 this.modules 用於存儲模塊內容
  2. run 函數,做爲入口函數,從入口文件開始獲取全部的依賴信息存儲在 this.modules 裏
  3. buildModule 調用 parser 裏封裝的函數,返回個對象,包含文件名稱,依賴數組,以及相應的可執行代碼
const path = require('path');
const { getAST, getDependencies, transform } = require('./parser');

module.exports = class Compiler {
  constructor(options) {
    const { entry, output } = options;
    this.entry = entry;
    this.output = output;
    this.modules = [];
  }
 // 首先獲取入口的信息,對象裏包含文件名稱,編譯成 es5 的代碼, 
 // 還有依賴模塊數組; 
 // 而後遍歷模塊的依賴,往 this.modules 裏添加模塊信息,
 // 這樣就能夠繼續獲取依賴模塊所依賴的模塊,至關於遞歸獲取模塊信息
  run() {
    const entryModule = this.buildModule(this.entry, true);
    this.modules.push(entryModule);
    this.modules.map((_module) => {
      _module.dependencies.map((dependency) => {
        this.modules.push(this.buildModule(dependency));
      });
    });
  }

  // 模塊構建
  // 要區分是入口文件仍是其餘的,由於其餘的路徑是相對路徑, 
  // 須要轉成絕對路徑,或者說相對於項目文件夾
  buildModule(filename, isEntry) {
    let ast;
    if (isEntry) {
      ast = getAST(filename);
    } else {
      let absolutePath = path.join(path.dirname(this.entry), filename);
      ast = getAST(absolutePath);
    }

    return {
      filename,
      dependencies: getDependencies(ast),
      transformCode: transform(ast),
    };
  }
};

複製代碼

執行 node index.js modules

生成代碼

根據上面的模塊分析數據,生成最終瀏覽器運行的代碼。 看下上一節獲得的依賴圖,能夠看到,最終的 transformCode 裏包含 exports 以及 require 這樣的語法,而這兩個不是瀏覽器自帶的,須要咱們在代碼裏實現。 在 compiler.js 新增 emitFile 函數,用於生成最終代碼,並寫入 output 裏

  1. 首先將全部的模塊信息,轉成以模塊名稱爲 key ,並定義個函數(該函數接收 require 與 exports 參數,模塊代碼做爲函數體) 做爲值
  2. 而後定義個 IIFE (當即執行函數),參數爲上一步的處理結果 modules 對象,函數裏頭定義個 require 函數,接收文件名,函數邏輯爲根據文件名獲取文件代碼,定義個 exports 對象,並將 require 與 exports 做爲參數傳入函數執行,最後返回 exports
  3. 最後將最終生成的代碼寫入 output 裏
// 輸出文件
  emitFiles() {
    const outputPath = path.join(this.output.path, this.output.filename);
    let modules = '';
    this.modules.map((_module) => {
      modules += `'${_module.filename}': function (require, exports) { ${_module.transformCode} },`;
    });
    const bundle = ` (function(modules) { function require(fileName) { const fn = modules[fileName]; const exports = {}; fn(require, exports ); return exports; } require('${this.entry}'); })({${modules}}) `;

    fs.writeFileSync(outputPath, bundle, 'utf-8');
  }
複製代碼

在 compiler.js run 函數裏調用 emitFiles

compiler 完整代碼

const fs = require('fs');
const path = require('path');
const { getAST, getDependencies, transform } = require('./parser');

module.exports = class Compiler {
  constructor(options) {
    const { entry, output } = options;
    this.entry = entry;
    this.output = output;
    this.modules = [];
  }

  run() {
    const entryModule = this.buildModule(this.entry, true);
    this.modules.push(entryModule);
    this.modules.map((_module) => {
      _module.dependencies.map((dependency) => {
        this.modules.push(this.buildModule(dependency));
      });
    });
    this.emitFiles();
  }

  // 模塊構建
  buildModule(filename, isEntry) {
    let ast;
    if (isEntry) {
      ast = getAST(filename);
    } else {
      let absolutePath = path.join(path.dirname(this.entry), filename);
      ast = getAST(absolutePath);
    }

    return {
      filename,
      dependencies: getDependencies(ast),
      transformCode: transform(ast),
    };
  }

  // 輸出文件
  emitFiles() {
    const outputPath = path.join(this.output.path, this.output.filename);
    let modules = '';
    this.modules.map((_module) => {
      modules += `'${_module.filename}': function (require, exports) { ${_module.transformCode} },`;
    });
    const bundle = ` (function(modules) { function require(fileName) { const fn = modules[fileName]; const exports = {}; fn(require, exports); return exports; } require('${this.entry}'); })({${modules}}) `;

    fs.writeFileSync(outputPath, bundle, 'utf-8');
  }
};

複製代碼

執行 node index.js 會生成最終代碼並寫入 dist 目錄下的 main.js 記得 dist 目錄要手動建立 image.png 在 index.html 裏引入 main.js 能夠正常顯示出結果 結果

相關文章
相關標籤/搜索