Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

手写一个简单的 webpack 编译流程 #3

Open
scTaoFelix opened this issue Dec 23, 2020 · 1 comment
Open

手写一个简单的 webpack 编译流程 #3

scTaoFelix opened this issue Dec 23, 2020 · 1 comment

Comments

@scTaoFelix
Copy link
Owner

scTaoFelix commented Dec 23, 2020

手写一个简单的 webpack 编译流程

一、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

二、准备工作

我们先建一个项目,目录如下:

  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 进行一次打包,分析一下 我们需要做什么工作

// 基本安装
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"
},

整理一下打包后的代码

(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 参数是一个对象。

  • 对象的格式是,文件名: 方法。
  • key 是 index.js 文件的相对路径,value 是一个匿名函数,函数体里面就是咱们写在 index.js 里的代码。(这就是 webpack 加载模块的方式)
我们要是实现的两个功能
  1. import 变成 __webpack_require__
  2. 读取模块中的所有依赖,生成一个 Template

三、开始搭建自己的 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'
  }
}

四、实现转换 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 tapable

// 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

静态方法 MDN

// 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()

  • buildModule( absolutePath, isEntry )
    • absolutePath: 入口文件的绝对路径
    • isEntry: 是否是主模块

获取入口文件的结果:

getEntryFile

第一步成功实现,下面实现第二步转成AST

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

成功得到了包含所有模块的:路径、依赖、转换后的代码。

五、生成 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 的编译流程代码就算写完啦。
把代码复制到浏览器测试一下

六、实现 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核心原理

@ghost
Copy link

ghost commented Aug 5, 2021

image
请问 ESModule这种导入导出方式可以被babael-traverse识别import,然后收集每个模块导入的依赖,那如果是 commonJs这种方式怎么收集他的依赖呢

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant