手寫一個簡單的 webpack 編譯代碼

1、webpack 打包編譯的主要流程

compiler 的流程:
  1. 將 webpack.config.js 做爲參數傳入 Compiler 類 (entry-options)
  2. 建立 Compiler 實例
  3. 調用 Compiler.run 開始編譯 (make)
  4. 建立 Compilation( compiler 內建立 compilation 對象,並將 this 傳入,compilation 就包含了對 compiler 的引用)
  5. 基於配置開始建立 Chunk (讀取文件,轉成 AST )
  6. 使用 Parser 從 Chunk 開始解析依賴 (找到依賴關係)
  7. 使用 Module 和 Dependency 管理代碼模塊相互依賴關係 (build-module)
  8. 使用 Template 基於 Compilation 的數據生成結果代碼
  • 能夠簡單分爲這三個階段
step

2、準備工做

咱們先建一個項目,目錄以下:html

selfWebpack
    - src
      - data.js
      - index.js
      - random.js
複製代碼
// index.js
import data from './data.js'
import random from './random.js'

console.log('🐻我是數據文件--->', data)
console.log('🦁我是隨機數--->', random)
console.log('🐺我是index.js')
複製代碼
// data.js
const result = '我是文件裏面的數據'

export default result
複製代碼
// random.js
const random = Math.random()

export default random

複製代碼

而後咱們先用 webpack 進行一次打包,分析一下 咱們須要作什麼工做node

// 基本安裝
npm init -y
npm install webpack@4.44.2 webpack-cli@4.2.0 --save-dev
複製代碼
// package.json
// 修改
"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
  "build": "webpack --mode development"
},
複製代碼

整理一下打包後的代碼webpack

(function(modules) {
  var installedModules = {};
  function __webpack_require__(moduleId) {
    if(installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    var module = installedModules[moduleId] = { i: moduleId, l: false, exports: {} };
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    module.l = true;
    return module.exports;
  }
  // Load entry module and return exports
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
})({
  "./src/data.js": function(module, __webpack_exports__, __webpack_require__) {
 "use strict";
    const result = '我是文件裏面的數據'
    __webpack_exports__["default"] = (result);

  },
  "./src/index.js": function(module, __webpack_exports__, __webpack_require__) {
 "use strict";
    var _random_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__("./src/random.js");
    console.log('🐻我是數據文件--->', _data_js__WEBPACK_IMPORTED_MODULE_0__["default"])
    console.log('🦁我是隨機數--->', _random_js__WEBPACK_IMPORTED_MODULE_1__["default"])
    console.log('🐺我是index.js')
  },
  "./src/random.js": function(module, __webpack_exports__, __webpack_require__) {
 "use strict";
    const random = Math.random()
    __webpack_exports__["default"] = (random);
  }
});
複製代碼

最外層是一個當即執行函數,入參是全部的 modules(模塊) list。傳入的 modules 參數是一個對象。git

  • 對象的格式是,文件名: 方法。
  • key 是 index.js 文件的相對路徑,value 是一個匿名函數,函數體裏面就是我們寫在 index.js 裏的代碼。(這就是 webpack 加載模塊的方式)
咱們要是實現的兩個功能
  1. import 變成 __webpack_require__
  2. 讀取模塊中的全部依賴,生成一個 Template

3、開始搭建本身的 selfpack

  • 實現 打包編譯的代碼,放在 src 同級的 selfpack 目錄,再增長一個配置文件(selfpack.config.js),以下:
selfWbpack
    + src
    // 新增
    - selfpack
      - compilation.js
      - compiler.js
      - index.js
      - Parser.js
    - selfpack.config.js
複製代碼
// selfpack.config.js
const { join } = require('path')
module.exports = {
  entry: join(__dirname, './src/index.js'),
  output: {
    path: join(__dirname, './dist'),
    filename: 'main.js'
  }
}
複製代碼

4、實現轉換 AST

  • 爲何要轉成 ast ? 由於有 import ,咱們要把它替換成 webpack_require 。
  • 怎麼作? 遍歷 AST ,把其中 import 語句引入的文件路徑收集起來。
  1. 第一步,實現經過參數找到入口文件並獲取文件內容
  2. 第二步,轉成 AST
  3. 第三步,解析主模塊文件依賴
  4. 第四步,將 AST 轉換回 JS 代碼
  5. 第五步,分析模塊之間的依賴關係,將 import 替換成 webpack_require
4.1 獲取入口文件
// selfpack/index.js
const Compiler = require('./Compiler')
const options = require('../selfpack.config.js')
const compiler = new Compiler(options)
compiler.run()
複製代碼
// selfpack/compilation.js
const fs = require('fs')

class Compilation {
  constructor(compiler) {
    const { options } = compiler
    this.options = options
  }compiler
  static ast(path){
    const content = fs.readFileSync(path, 'utf-8') // 讀取文件
    console.log('獲取文件', content)
  }
  buildModule(absolutePath, isEntry) {
    this.ast(absolutePath)
  }
}
module.exports = Compilation
複製代碼

npm install tapablegithub

// selfpack/compiler.js
const { SyncHook } = require('tapable')
const Compilation = require('./Compilation')

class Compiler {
  constructor(options) {
    this.options = options
    this.hooks = {
      run: new SyncHook()
    }
  }
  run() {
    this.compile()
  }
  compile() {
    const compilation = new Compilation(this)
     //經過entry找入口文件
     const entryModule = compilation.buildModule(this.options.entry, true)
  }
}
module.exports = Compiler
複製代碼

靜態方法 MDNweb

// selfpack/Parser.js
const fs = require('fs')
class Parser{
  static ast(path) {
    const content = fs.readFileSync(path, 'utf-8') // 讀取文件
    console.log('讀取文件', content)
  }
}
module.exports = Parser
複製代碼

將 selfpack.config.js 做爲參數傳入 Compiler 類,執行 run 方法。 經過 new 一個 Compilation 實例,調用 buildModule()npm

  • buildModule( absolutePath, isEntry )
    • absolutePath: 入口文件的絕對路徑
    • isEntry: 是不是主模塊

獲取入口文件的結果:json

getEntryFile

第一步成功實現,下面實現第二步轉成ASTapi

4.2 轉化成AST

這一步須要用到 @babel/parser , 將代碼轉化爲 AST 語法樹。
npm install @babel/parser sourceType 表明咱們要解析的是ES模塊瀏覽器

  • 調用 Parser.ast()
  • 經過 readFileSync 讀取文件內容,傳給 parser.parse() 獲得 AST。
// selfpack/Parser.js
const fs = require('fs')
const parser = require('@babel/parser')

class Parser{
  static ast(path) {
    const content = fs.readFileSync(path, 'utf-8') // 讀取文件
    console.log('讀取文件', content)
    const _ast = parser.parse(content, {
      sourceType: 'module' //表示咱們要解析的是ES模塊
    })
    console.log(_ast)
    console.log('我是body內容', _ast.program.body)
    return _ast
  }
}
module.exports = Parser
複製代碼
getAST

到這一步咱們很順利! 這是整個文件的信息,而咱們須要的文件內容在它的屬性 program 裏的 body 裏。 看一下 body 的內容

getASTBody

這是 src/index.js 的一個 import 的 Node 屬性,它的類型是 ImportDeclaration。

4.3 解析主模塊文件依賴

接下來,解析主模塊。

遍歷AST要用到 @babel/traverse
npm install @babel/traverse
traverse() 的用法:第一個參數就是 AST ,第二個參數就是配置對象

// selfpack/Parser.js
const traverse  = require('@babel-traverse').default
const fs = require('fs')
const parser = require('@babel/parser')
const path = require('path')

class Parser{
  static ast(path){
    const content = fs.readFileSync(path, 'utf-8') // 讀取文件
    const _ast = parser.parse(content, {
      sourceType: 'module' //表示咱們要解析的是ES模塊
    })
    console.log(_ast)
    console.log('我是body內容', _ast.program.body)
    return _ast
  }
  static getDependecy(ast, file) {
    const dependecies = {}
    traverse(ast, {
      ImportDeclaration: ({node}) => {
        const oldValue = node.source.value
        const dirname = path.dirname(file)
        const relativepath = "./" + path.join(dirname, oldValue) 
        dependecies[oldValue] = relativepath
        node.source.value = relativepath // 將 ./data.js 轉化成 ./src/data.js
      }
    })
    return dependecies
  }
}
module.exports = Parser
複製代碼
  • 調用 Parser.getDependecy 方法,獲取主模塊的依賴路徑,修改源碼。
  • getDependecy(): 靜態方法,是對 type 爲 ImportDeclaration 的節點的處理。
  • node.source.value: 就是 import 的值。
  • 由於咱們打包後的代碼,入參部分的 key 變成了 ./src/data.js,因此這裏也須要作出相應的改變

import data from './data.js' ==> require('./data.js') ==> require('./src/data.js')

relativepath: 這裏獲取的是依賴的文件路徑
dependecies: 是收集的依賴對象,key 爲 node.source.value ,value 爲轉換後的路徑。

import data from './data.js'
import random from './random.js'
複製代碼

node.source.value: 指的是 from 後面的 './data.js' 、'./random.js'

path.relative(from, to): 方法根據當前工做目錄返回 ( from ) 到 ( to ) 的 ( 相對路徑 )

process.cwd(): 返回 Node.js 進程的當前工做目錄(path.resolve())

// selfpack/compilation.js
const Parser = require('./Parser')
const path = require('path')

class Compilation {
  constructor(compiler) {
    const { options } = compiler
    this.options = options
    this.entryId
    // 增長
    this.root = process.cwd() // 執行命令的當前目錄
  }
  buildModule(absolutePath, isEntry) {
    let ast = ''
    ast = Parser.ast(absolutePath)
    const relativePath = './' + path.relative(this.root, absolutePath)
    if(isEntry){
      this.entryId = relativePath
    }
    const dependecies = Parser.getDependecy(ast, relativePath)
    console.log("依賴項", dependecies)
  }
}
module.exports = Compilation
複製代碼

遍歷以後 在 ast 裏面找到節點類型, 經過 index.js 的 ast 獲取到 index.js 文件的依賴(也就是data.js、random.js)

getDependecies

主模塊的依賴路徑已經所有找到啦! 走到這一步,離成功就不遠了。

4.4 轉換代碼

接下來是轉換代碼,就是將修改後的 AST 轉換成 JS 代碼。
用到了 @babel/core 的 transformFromAst 和 @babel/preset-env。
安裝一下 npm install @babel/core @babel/preset-env

  • transformFromAst: 就是將咱們傳入的 AST 轉化成咱們在第三個參數(@babel/preset-env)裏配置的模塊類型,會返回轉換後的代碼

@babel/preset-env 是將咱們使用的 JS 新特性轉換成兼容的代碼。

此時 Parser.js 長這樣

// selfpack/Parser.js 完整
const traverse  = require('@babel-traverse').default
const fs = require('fs')
const parser = require('@babel/parser')
const path = require('path')
// 增長
const { transformFromAst } = require('@babel/core')

class Parser{
  static ast(path){
    const content = fs.readFileSync(path, 'utf-8') // 讀取文件
    const _ast = parser.parse(content, {
      sourceType: 'module' //表示咱們要解析的是ES模塊
    })
    console.log(_ast)
    console.log('我是body內容', _ast.program.body)
    return _ast
  }
  static getDependecy(ast, file) {
    const dependecies = {}
    traverse(ast, {
      ImportDeclaration: ({node}) => {
        const oldValue = node.source.value
        const dirname = path.dirname(file)
        const relativepath = "./" + path.join(dirname, oldValue) 
        dependecies[oldValue] = relativepath
        node.source.value = relativepath // 將 ./data.js 轉化成 ./src/data.js
      }
    })
    return dependecies
  }
  // 增長
  static transform(ast) {
    const { code } = transformFromAst(ast, null, {
        presets: ['@babel/preset-env']
    })
    return code
  }
}
module.exports = Parser
複製代碼
// selfpack/compilation.js
  ...
  buildModule(absolutePath, isEntry) {
    let ast = ''
    ast = Parser.ast(absolutePath)
    const relativePath = './' + path.relative(this.root, absolutePath)
    if(isEntry){
      this.entryId = relativePath  // 保存主入口的文件路徑
    }
    const dependecies = Parser.getDependecy(ast, relativePath)
    // 增長
    const transformCode = Parser.transform(ast)
    console.log("轉換後的代碼 ", transformCode)
    return {
      relativePath,
      dependecies,
      transformCode 
    }
  }
}
  ...
複製代碼

先來看下結果:

getTransformFromAst

能夠看到 const 成功轉換成了 var,可是 require("./data.js") 引用的路徑尚未和 modules 的 key 保持一致。

4.5 遞歸收集依賴

咱們怎麼去肯定一個模塊應該包含什麼信息呢?
首先要肯定這個文件的惟一性,因此咱們須要要的文件路徑,由於這個是惟一的。
而後再來分析文件的內容:

  • 是否引入了其餘文件
  • 本身的主體內容

因此咱們須要的模塊信息以下:

  • 該模塊的路徑
  • 該模塊的依賴
  • 該模塊轉換後的代碼

這裏咱們獲取轉換後的代碼,並在 buildModule 返回一個對象,返回值結構以下:

// 獲取的模塊信息
  {
    relativePath: './src/xxx',
    dependecies: {
      './data.js': './src/data.js',
      './random.js': './src/random.js'
    },
    transformCode: {
      ...
    }
  }
複製代碼

可是 buildModule 只能收集一個模塊的依賴,而咱們最終的目的是收集全部依賴,因此咱們要作一個遞歸處理。 修改一下 compiler.js

// selfpack/compiler.js
  ...
  compile() {
    const compilation = new Compilation(this)
     //經過entry找入口文件
    const entryModule = compilation.buildModule(this.options.entry, true)

    // 增長
    this.modules.push(entryModule)
    this.modules.map((_module) => {
      const deps = _module.dependecies
      for (const key in deps){
        if (deps.hasOwnProperty(key)){
          this.modules.push(compilation.buildModule(deps[key], false))
        }
      }
    })
    console.log('最終的 modules', this.modules)
  }
  ...
複製代碼

先來看一下 compile 中遞歸的方法:

  1. 將主入口文件傳入buildModule ,獲得主入口的文件模塊
  2. 最外層遍歷的主入口文件的模塊
  3. 而後獲取主模塊的依賴全部模塊
  4. 把依賴的模塊 push 到 this.modules 裏

來看一下最終的 modules

getModules

成功獲得了包含全部模塊的:路徑、依賴、轉換後的代碼。

5、生成 webpack 模版文件

編譯的最後一步就是 生成模板文件,並放到 output 目錄。
咱們直接借用文章開頭那段打包出來的 dist/main.js 文件的內容,而後作些修改。
來看修改後的的 compilation.js

// selfpack/compilation.js 完整
const path = require('path')
const Parser = require('./Parser')
const fs = require('fs')


class Compilation {
  constructor(compiler) {
    // 修改
    const { options, modules } = compiler
    this.options = options
    this.root = process.cwd() // 執行命令的當前目錄
    this.entryId
    // 增長
    this.modules = modules
  }
  buildModule(absolutePath, isEntry) {
    let ast = ''
    ast = Parser.ast(absolutePath)
    const relativePath = './' + path.relative(this.root, absolutePath)
    if(isEntry){
      this.entryId = relativePath
    }
    const dependecies = Parser.getDependecy(ast, relativePath)
    const transformCode = Parser.transform(ast)
    // console.log("依賴項", dependecies)
    // console.log("轉換後的代碼 ", transformCode)
    return {
      relativePath,
      dependecies,
      transformCode 
    }
  }
  // 增長
  emitFiles(){
    let _modules = ''
    const outputPath = path.join(
      this.options.output.path,
      this.options.output.filename
    )
    this.modules.map((_module) => {
      // 記得加引號
      _modules += `'${_module.relativePath}': function(module, exports, require){ ${_module.transformCode} },`
    })
    const template = ` (function(modules) { var installedModules = {}; function __webpack_require__(moduleId) { // Check if module is in cache if(installedModules[moduleId]) { return installedModules[moduleId].exports; } var module = installedModules[moduleId] = { exports: {} }; modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); return module.exports; } // 執行的入口函數 return __webpack_require__('${this.entryId}'); })({ ${_modules} }) `
    const dist = path.dirname(outputPath)
    fs.mkdirSync(dist)
    fs.writeFileSync(outputPath, template, 'utf-8')
  }
}
module.exports = Compilation
複製代碼

打包以後的文件內容,大致上長這樣,還有點小瑕疵。
看下 emitFiles 函數的做用

  1. 獲取 selfpack.config.js 中的 output 對象的 path,filename
  2. 遍歷全部的 modules 並放在模板的入參位置
  3. 新建一個文件,將編譯後的代碼寫入

完整的 compiler

// selfpack/compiler.js 完整
const { SyncHook } = require('tapable')
const Compilation = require('./Compilation')

class Compiler {
  constructor(options) {
    this.modules = []
    this.options = options
    this.hooks = {
      run: new SyncHook()
    }
  }
  run() {
    this.compile()
  }
  compile() {
    const compilation = new Compilation(this)
    const entryModule = compilation.buildModule(this.options.entry, true)
    this.modules.push(entryModule)
    this.modules.map((_module) => {
      const deps = _module.dependecies
      for (const key in deps){
        if (deps.hasOwnProperty(key)){
          this.modules.push(compilation.buildModule(deps[key], false))
        }
      }
    })
    // 增長
    compilation.emitFiles()
  }
}
module.exports = Compiler
複製代碼

編譯後的代碼以下:

// dist/main.js
(function (modules) {
  var installedModules = {};
  function __webpack_require__(moduleId) {
    // Check if module is in cache
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    var module = installedModules[moduleId] = {
      exports: {}
    };
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    return module.exports;
  }
  // 執行的入口函數
  return __webpack_require__('./src/index.js');
})({
  './src/index.js': function (module, exports, require) {
 "use strict";

    var _data = _interopRequireDefault(require("./src/data.js"));

    var _random = _interopRequireDefault(require("./src/random.js"));

    function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }

    console.log('🐻我是數據文件--->', _data["default"]);
    console.log('🦁我是隨機數--->', _random["default"]);
    console.log('🐺我是index.js');
  }, './src/data.js': function (module, exports, require) {
 "use strict";

    Object.defineProperty(exports, "__esModule", {
      value: true
    });
    exports["default"] = void 0;
    var result = '我是文件裏面的數據';
    var _default = result;
    exports["default"] = _default;
  }, './src/random.js': function (module, exports, require) {
 "use strict";

    Object.defineProperty(exports, "__esModule", {
      value: true
    });
    exports["default"] = void 0;
    var random = Math.random();
    var _default = random;
    exports["default"] = _default;
  },
})
複製代碼

走到這裏一個簡單 webpack 的編譯流程代碼就算寫完啦。
把代碼複製到瀏覽器測試一下

6、實現 webpack 的 Plugins 功能

怎麼開發一個自定義的plugins?
webpack中內部實現了本身的一套生命週期,而 plugins 就是用 apply 來調用webpack裏面提供的生命週期。
而 webpack 的生命週期主要就是 tapable 來實現的。
這裏只用到了 SyncHook,更多可參考這篇 Tapable 詳解

咱們修改一下官網的 ConsoleLogOnBuildWebpackPlugin.js 例子。
在 src同級目錄新建一個 plugins

+ src
  - plugins
    - ConsoleLogOnBuildWebpackPlugin.js
複製代碼

編寫一個簡單的 plugins

// ConsoleLogOnBuildWebpackPlugin.js
const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
  apply(compiler) {
    compiler.hooks.run.tap(pluginName, compilation => {
      console.log('The webpack build process is starting!!!');
    });
    // 在文件打包結束後執行
    compiler.hooks.done.tap(pluginName,(compilation)=> {
      console.log("整個webpack打包結束")
    })
    // 在webpack輸出文件的時候執行
    compiler.hooks.emit.tap(pluginName,(compilation)=> {
        console.log("文件開始發射")
    })
  }
}
module.exports = ConsoleLogOnBuildWebpackPlugin;
複製代碼

而後再配置文件引入這個 plugins

// selfpack.config.js
const { join } = require('path')
const ConsoleLogOnBuildWebpackPlugin = require('./plugins/ConsoleLogOnBuildWebpackPlugin')

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

要讓咱們的 selfwebpack 支持 plugins ,還要作些改動。

// selfpack/index.js
const Compiler = require('./Compiler')
const options = require('../selfpack.config.js')
const compiler = new Compiler(options)
const plugins = options.plugins
for (let plugin of plugins) {
    plugin.apply(compiler)
}
compiler.run()
複製代碼
// selfpack/compiler.js
const { SyncHook } = require('tapable')
const Compilation = require('./Compilation')

class Compiler {
  constructor(options) {
    this.modules = []
    this.options = options
    this.hooks = {
      run: new SyncHook(),
      // 增長
      emit: new SyncHook(),
      done: new SyncHook()
    }
  }
  run() {
    this.compile()
  }
  compile() {
    const compilation = new Compilation(this)
    // 增長
    this.hooks.run.call()
     //經過entry找入口文件
    const entryModule = compilation.buildModule(this.options.entry, true)
    this.modules.push(entryModule)
    this.modules.map((_module) => {
      const deps = _module.dependecies
      for (const key in deps){
        if (deps.hasOwnProperty(key)){
          this.modules.push(compilation.buildModule(deps[key], false))
        }
      }
    })
    // console.log('最終的 modules', this.modules)
    compilation.emitFiles()
    // 增長
    this.hooks.emit.call()
    this.hooks.done.call()

  }
}
module.exports = Compiler
複製代碼

在 compiler 函數一初始化的時候就定義本身的 webpack 的生命週期,而且在 run 期間進行相應的調用,這樣咱們就實現了本身的生命週期。

打印結果以下:
runPlugins

本文只實現了簡單的編譯原理,更多實現請看 webapck-github

對應的代碼放到了這裏 github

參考文章:手寫webpack核心原理

相關文章
相關標籤/搜索