likes
comments
collection
share

手撸 webpack 源码

作者站长头像
站长
· 阅读数 78

简介

webpack是一个打包模块化 JavaScript 的工具,在 webpack里一切文件皆模块,通过 Loader 转换文件,通过 Plugin 注入钩子,最后输出由多个模块组合成的文件。 webpack专注于构建模块化项目。

打包流程

当webpack 的配置只有一个出口时,不考虑分包的情况,其实我们只得到了一个bundle.js的文件,这个文件里包含了我们所有用到的js模块,可以直接被加载执行。

Webpack的运行流程是一个基于事件流串行的过程,从启动到结束会依次执行以下流程:

  1. 定义 Compiler 类
  2. 解析入口文件,获取 AST

我们这里使用@babel/parser,这是 babel7 的工具,来帮助我们分析内部的语法,包括 es6,返回一个 AST 抽象语法树。

  1. 找出所有依赖模块

Babel 提供了@babel/traverse(遍历)方法维护这 AST 树的整体状态,我们这里使用它来帮我们找出依赖模块。

  1. AST 转换为 code

将 AST 语法树转换为浏览器可执行代码,我们这里使用@babel/core 和 @babel/preset-env。

  1. 递归解析所有依赖项,生成依赖关系图
  2. 重写 require 函数,输出 bundle
  3. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

手撸一个webpack

1. 定义 Compiler 类

class Compiler {  
  constructor(options) {    
    // webpack 配置    
    const { entry, output } = options    
    // 入口    
    this.entry = entry   
     // 出口    
    this.output = output    
    // 模块    
    this.modules = []  
  }  
  // 构建启动  
  run() {}  
  // 重写 require函数,输出bundle  
  generate() {}
}

2. 解析入口文件,获取 AST

这里我们会用到@babel/parser这个包,它负责将代码解析为AST抽象语法树:

手撸 webpack 源码

// webpack.config.js
const path = require('path')
module.exports = {  
  entry: './src/index.js', 
  output: {    
    path: path.resolve(__dirname, './dist'),    
    filename: 'main.js'  
  }
}
const fs = require('fs')
const parser = require('@babel/parser')
const options = require('./webpack.config')
const Parser = {  
  getAst: path => {    
    // 读取入口文件    
    const content = fs.readFileSync(path, 'utf-8')    
    // 将文件内容转为AST抽象语法树    
    return parser.parse(content, {      
      sourceType: 'module'    
    })  
  }
}
  
class Compiler {  
  constructor(options) {    
    // webpack 配置    
    const { entry, output } = options    
    // 入口    
    this.entry = entry   
     // 出口    
    this.output = output    
    // 模块    
    this.modules = []  
  }  
  // 构建启动  
  run() {
    const ast = Parser.getAst(this.entry)
  }  
  // 重写 require函数,输出bundle  
  generate() {}
}
new Compiler(options).run()

我们去看下@babel/parser的文档:

手撸 webpack 源码

提供了两个API,而我们目前用到的是parse这个API。

它的主要作用是 parses the provided code as an entire ECMAScript program,也就是将我们提供的代码解析成完整的ECMAScript代码的AST。

再看看该API提供的参数:

手撸 webpack 源码

我们暂时用到的是sourceType,也就是用来指明我们要解析的代码是什么模块。

一个完整的AST展示:

{
  "type": "CallExpression",
  "start": 24,
  "end": 43,
  "loc": {
    "start": { "line": 3, "column": 2 },
    "end": { "line": 3, "column": 21 }
  },
  "callee": {
    "type": "MemberExpression",
    "start": 24,
    "end": 35,
    "loc": {
      "start": { "line": 3, "column": 2 },
      "end": { "line": 3, "column": 13 }
    },
    "object": {
      "type": "Identifier",
      "start": 24,
      "end": 31,
      "loc": {
        "start": { "line": 3, "column": 2 },
        "end": { "line": 3, "column": 9 },
        "identifierName": "console"
      },
      "name": "console"
    },
    "property": {
      "type": "Identifier",
      "start": 32,
      "end": 35,
      "loc": {
        "start": { "line": 3, "column": 10 },
        "end": { "line": 3, "column": 13 },
        "identifierName": "log"
      },
      "name": "log"
    },
    "computed": false
  },
  "arguments": [
    {
      "type": "StringLiteral",
      "start": 36,
      "end": 42,
      "loc": {
        "start": { "line": 3, "column": 14 },
        "end": { "line": 3, "column": 20 }
      },
      "extra": { "rawValue": "data", "raw": "'data'" },
      "value": "data"
    }
  ]
}

3. 找出所有依赖模块

现在我们需要遍历AST,将用到的依赖收集起来。什么意思呢?其实就是将用import语句引入的文件路径收集起来。我们将收集起来的路径放到deps里。

前面我们提到过,遍历AST要用到@babel/traverse依赖包。

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const options = require('./webpack.config')
const traverse = require('@babel/traverse').default
const Parser = {  
  getAst: path => {    
    // 读取入口文件    
    const content = fs.readFileSync(path, 'utf-8')    
    // 将文件内容转为AST抽象语法树    
    return parser.parse(content, {      
      sourceType: 'module'    
    })  
  },
  getDependecies: (ast, filename) => {
    const dependecies = {};
    // 遍历所有的 import 模块,存入dependecies
    traverse(ast, {
      // 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
      ImportDeclaration({ node }) {
        const dirname = path.dirname(filename);
        // 保存依赖模块路径,之后生成依赖关系图需要用到
        const filepath = './' + path.join(dirname, node.source.value);
        dependecies[node.source.value] = filepath;
      },
    });
    return dependecies;
  };
}

class Compiler {  
  constructor(options) {    
    // webpack 配置    
    const { entry, output } = options    
    // 入口    
    this.entry = entry   
     // 出口    
    this.output = output    
    // 模块    
    this.modules = []  
  }  
  // 构建启动  
  run() {
    const { getAst, getDependecies } = Parser    
    const ast = getAst(this.entry)    
    const dependecies = getDependecies(ast, this.entry)
  }  
  // 重写 require函数,输出bundle  
  generate() {}
}
new Compiler(options).run()

我们去看下@babel/traverse的文档:

手撸 webpack 源码

它的主要作用是 We can use it alongside the babel parser to traverse and update nodes - "我们可以将它与 babel 解析器一起使用来遍历和更新节点"

4. AST 转换为 code**

将 AST 语法树转换为浏览器可执行代码,前面讲到过,执行这一步需要两个依赖包:@babel/core 和 @babel/preset-env。

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const options = require('./webpack.config')
const traverse = require('@babel/traverse').default
const { transformFromAst } = require('@babel/core')
const Parser = {  
  getAst: path => {    
    // 读取入口文件    
    const content = fs.readFileSync(path, 'utf-8')    
    // 将文件内容转为AST抽象语法树    
    return parser.parse(content, {      
      sourceType: 'module'    
    })  
  },
  getDependecies: (ast, filename) => {
    const dependecies = {};
    // 遍历所有的 import 模块,存入dependecies
    traverse(ast, {
      // 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
      ImportDeclaration({ node }) {
        const dirname = path.dirname(filename);
        // 保存依赖模块路径,之后生成依赖关系图需要用到
        const filepath = './' + path.join(dirname, node.source.value);
        dependecies[node.source.value] = filepath;
      },
    });
    return dependecies;
  },
  getCode: 
    ast => {    
    // AST转换为code    
    const { code } = transformFromAst(ast, null, {      
      presets: ['@babel/preset-env']    
    })    
    return code 
  }
}

class Compiler {  
  constructor(options) {    
    // webpack 配置    
    const { entry, output } = options    
    // 入口    
    this.entry = entry   
     // 出口    
    this.output = output    
    // 模块    
    this.modules = []  
  }  
  // 构建启动  
  run() {
    const { getAst, getDependecies } = Parser    
    const ast = getAst(this.entry)    
    const dependecies = getDependecies(ast, this.entry)
    const code = getCode(ast)
  }  
  // 重写 require函数,输出bundle  
  generate() {}
}
new Compiler(options).run()

我们看看官网文档对@babel/core 的transformFromAst的介绍:

手撸 webpack 源码

它的作用就是将我们传入的AST转化成我们在第三个参数里配置的模块类型。

你可能会有疑问,上一步的收集依赖在这里怎么没啥关系啊,确实如此。收集依赖是为了下面进行的递归操作。

5. 递归解析所有依赖项,生成依赖关系图

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const options = require('./webpack.config')
const traverse = require('@babel/traverse').default
const { transformFromAst } = require('@babel/core')
const Parser = {  
  getAst: path => {    
    // 读取入口文件    
    const content = fs.readFileSync(path, 'utf-8')    
    // 将文件内容转为AST抽象语法树    
    return parser.parse(content, {      
      sourceType: 'module'    
    })  
  },
  getDependecies: (ast, filename) => {
    const dependecies = {};
    // 遍历所有的 import 模块,存入dependecies
    traverse(ast, {
      // 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
      ImportDeclaration({ node }) {
        const dirname = path.dirname(filename);
        // 保存依赖模块路径,之后生成依赖关系图需要用到
        const filepath = './' + path.join(dirname, node.source.value);
        dependecies[node.source.value] = filepath;
      },
    });
    return dependecies;
  },
  getCode: 
    ast => {    
    // AST转换为code    
    const { code } = transformFromAst(ast, null, {      
      presets: ['@babel/preset-env']    
    })    
    return code 
  }
}

class Compiler {  
  constructor(options) {    
    // webpack 配置    
    const { entry, output } = options    
    // 入口    
    this.entry = entry   
     // 出口    
    this.output = output    
    // 模块    
    this.modules = []  
  }  
  run() {    
    // 解析入口文件    
    const info = this.build(this.entry)   
    this.modules.push(info)    
    this.modules.forEach(({ dependecies }) => {      
      // 判断有依赖对象,递归解析所有依赖项      
      if (dependecies) {        
        for (const dependency in dependecies) {          
          this.modules.push(this.build(dependecies[dependency]))        
        }      
      }    
    })    
    // 生成依赖关系图    
    const dependencyGraph = this.modules.reduce(      
      (graph, item) => ({        
        ...graph,        
        // 使用文件路径作为每个模块的唯一标识符,保存对应模块的依赖对象和文件内容        
        [item.filename]: {          
          dependecies: item.dependecies,          
          code: item.code        
        }      
      }),      
      {}    
    )  
  },
  build(filename) {    
    const { getAst, getDependecies, getCode } = Parser    
    const ast = getAst(filename)    
    const dependecies = getDependecies(ast, filename)    
    const code = getCode(ast)    
    return {      
      // 文件路径,可以作为每个模块的唯一标识符      
      filename,      
      // 依赖对象,保存着依赖模块路径      
      dependecies,      
      // 文件内容      
      code    
    }  
  },
  // 重写 require函数,输出bundle  
  generate() {}
}
new Compiler(options).run()

6. 重写 require 函数,输出 bundle

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const options = require('./webpack.config')
const traverse = require('@babel/traverse').default
const { transformFromAst } = require('@babel/core')
const Parser = {  
  getAst: path => {    
    // 读取入口文件    
    const content = fs.readFileSync(path, 'utf-8')    
    // 将文件内容转为AST抽象语法树    
    return parser.parse(content, {      
      sourceType: 'module'    
    })  
  },
  getDependecies: (ast, filename) => {
    const dependecies = {};
    // 遍历所有的 import 模块,存入dependecies
    traverse(ast, {
      // 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
      ImportDeclaration({ node }) {
        const dirname = path.dirname(filename);
        // 保存依赖模块路径,之后生成依赖关系图需要用到
        const filepath = './' + path.join(dirname, node.source.value);
        dependecies[node.source.value] = filepath;
      },
    });
    return dependecies;
  },
  getCode: 
    ast => {    
    // AST转换为code    
    const { code } = transformFromAst(ast, null, {      
      presets: ['@babel/preset-env']    
    })    
    return code 
  }
}

class Compiler {  
  constructor(options) {    
    // webpack 配置    
    const { entry, output } = options    
    // 入口    
    this.entry = entry   
     // 出口    
    this.output = output    
    // 模块    
    this.modules = []  
  }  
  run() {    
    // 解析入口文件    
    const info = this.build(this.entry)   
    this.modules.push(info)    
    this.modules.forEach(({ dependecies }) => {      
      // 判断有依赖对象,递归解析所有依赖项      
      if (dependecies) {        
        for (const dependency in dependecies) {          
          this.modules.push(this.build(dependecies[dependency]))        
        }      
      }    
    })    
    // 生成依赖关系图    
    const dependencyGraph = this.modules.reduce(      
      (graph, item) => ({        
        ...graph,        
        // 使用文件路径作为每个模块的唯一标识符,保存对应模块的依赖对象和文件内容        
        [item.filename]: {          
          dependecies: item.dependecies,          
          code: item.code        
        }      
      }),      
      {}    
    )  
  },
  build(filename) {    
    const { getAst, getDependecies, getCode } = Parser    
    const ast = getAst(filename)    
    const dependecies = getDependecies(ast, filename)    
    const code = getCode(ast)    
    return {      
      // 文件路径,可以作为每个模块的唯一标识符      
      filename,      
      // 依赖对象,保存着依赖模块路径      
      dependecies,      
      // 文件内容      
      code    
    }  
  },
  // 重写 require函数 (浏览器不能识别commonjs语法),输出bundle  
  generate(code) {    
    // 输出文件路径    
    const filePath = path.join(this.output.path, this.output.filename)    
    // 懵逼了吗? 没事,下一节我们捋一捋    
    const bundle = `(function(graph){      
      function require(module){        
        function localRequire(relativePath){          
          return require(graph[module].dependecies[relativePath])        
        }        
        var exports = {};        
        (function(require,exports,code){          
          eval(code)        
        })(localRequire,exports,graph[module].code);        
        return exports;      
      }      
      require('${this.entry}')    
    })(${JSON.stringify(code)})`    
    // 把文件内容写入到文件系统    
    fs.writeFileSync(filePath, bundle, 'utf-8')  
  }
}
new Compiler(options).run()

理解bundle 实现

我们创建了add.js文件和reduce.js文件,然后 在index.js中引入,再将index.js文件引入index.html。

add.js:

export const add = (a,b)=>{
  return a+b;
}

reduce.js:

export const minus = (a,b)=>{
    return a-b
}

index.js:

import {add} from "./add.js";
import {minus} from "./reduce.js";

const sum = add(1,2);
const division = minus(2,1);

console.log(sum);
console.log(division);

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
  <script src="./src/index.js"></script>
</body>
</html>

我们在node中运行bundle.js,得到index.js和add.jscode内容:

// index.js
"use strict"
var _add = _interopRequireDefault(require("./add.js"));
var _minus = require("./minus.js");
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
var sum = (0, _add["default"])(1, 2);
var division = (0, _minus.minus)(2, 1);
console.log(sum); console.log(division);
// add.js
"use strict";
Object.defineProperty(exports, "__esModule", {  value: true});
exports["default"] = void 0;
var _default = function _default(a, b) {  return a + b;};
exports["default"] = _default;

但是我们现在是不能执行index.js这段代码的,因为浏览器不会识别执行require和exports。

不能识别是为什么?不就是因为没有定义这require函数,和exports对象。那我们可以自己定义。

我们创建一个函数:

const bundle = (file) =>{
    
}

我们看下返回的这段代码

(function(graph){      
  function require(module){        
    function localRequire(relativePath){          
      return require(graph[module].dependecies[relativePath])        
    }        
    var exports = {};        
    (function(require,exports,code){          
      eval(code)        
    })(localRequire,exports,graph[module].code);        
    return exports;      
  }      
  require('${this.entry}')    
})(${JSON.stringify(code)})
}

其实就是:

  1. 把字符串code传入一个立即执行函数。
  2. 将主模块路径传入require函数执行
  3. 执行reuire函数的时候,又立即执行一个立即执行函数,这里是把code的值传进去了
  4. 执行eval(code)。也就是执行主模块的code这段代码

我们再来看下code的值:

// index.js
"use strict"
var _add = _interopRequireDefault(require("./add.js"));
var _minus = require("./minus.js");
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
var sum = (0, _add["default"])(1, 2);
var division = (0, _minus.minus)(2, 1);
console.log(sum); console.log(division);

执行这段代码的时候,又会用到require函数。此时require的参数为add.js的路径,不是绝对路径,需要转化成绝对路径。因此写一个函数localRequire来转化。怎么实现呢?我们来看下代码:

(function(graph){      
  function require(module){        
    function localRequire(relativePath){          
      return require(graph[module].dependecies[relativePath])        
    }        
    var exports = {};        
    (function(require,exports,code){          
      eval(code)        
    })(localRequire,exports,graph[module].code);        
    return exports;      
  }      
  require('${this.entry}')    
})(${JSON.stringify(code)})
}

实际上是实现了一层拦截。

  1. 执行require('./src/index.js')函数,实际执行了:
(function(require,exports,code){
  eval(code)
})(localRequire,exports,graph[module].code);
  1. 执行eval,也就是执行了index.js的代码。
  2. 执行过程会执行到require函数。
  3. 这时会调用这个require,也就是我们传入的localRequire.
(function(require,exports,code){
  eval(code)
})(localRequire,exports,graph[module].code);
  1. 而执行localRequire就执行了return require(graph[module].dependecies[relativePath])这段代码,也就是执行了外面这个require: 手撸 webpack 源码 在这里return require(graph[module].dependecies[relativePath]),我们已经对路径转化成绝对路径了。因此执行外面的require的时候就是传入绝对路径。
  2. 而执行require("./src/add.js")之后,又会执行eval,也就是执行add.js文件的代码。

这里其实是个递归。

这样就将代码整合起来了,但是有个问题,就是在执行add.js的code时候,会遇到exports这个还没定义的问题。如下所示

// add.js
"use strict";
Object.defineProperty(exports, "__esModule", {  value: true});
exports["default"] = void 0;
var _default = function _default(a, b) {  return a + b;};
exports["default"] = _default;

我们发现 这里它把exports当作一个对象来使用了,但是这个对象还没定义,因此我们可以自己定义一个exports对象。

手撸 webpack 源码

我们增添了一个空对象 exports,执行add.js代码的时候,会往这个空对象上增加一些属性,

// add.js
"use strict";
Object.defineProperty(exports, "__esModule", {  value: true});
exports["default"] = void 0;
var _default = function _default(a, b) {  return a + b;};
exports["default"] = _default;

比如,执行完这段代码后:

exports = {
  __esModule:{  value: true},
  defaultfunction _default(a, b) {  return a + b;}
}

然后我们把exports对象return出去。

可见,return出去的值,被interopRequireDefault接收, interopRequireDefault再返回default这个属性给add,因此add = function _default(a, b) { return a + b;}

现在明白了,为什么ES6模块引入的是一个对象引用了吧,因为exports就是一个对象。

就是将我们早期收集的所有依赖作为参数传入到立即执行函数当中,然后通过eval来递归地执行每个依赖的code。

至此,处理require和exports两个关键词的功能就完整了。

我们的手写webpack核心原理就到此结束了。

最后我贴一下我跑的demo的结果。bundle.js的文件内容为:

(function(graph) {
    // 重写require函数
    function require(moduleId) {
        // 找到对应moduleId的依赖对象,调用require函数,eval执行,拿到exports对象
        function localRequire(relativePath) {
            return require(graph[moduleId].dependecies[relativePath])
        }
        var exports = {};
        (function(require, exports, code) {
            eval(code);
        })(localRequire, exports, graph[moduleId].code);
        //暴露exports对象,即暴露依赖对象对应的实现
        return exports;
    }
    require('./src/index.js')
    })({
      './src/index.js': {    
        dependecies: { './add.js': './reduce.js' },    
        code: '"use strict"
          var _add = _interopRequireDefault(require("./add.js"));
          var _minus = require("./minus.js");
          function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
          var sum = (0, _add["default"])(1, 2);
          var division = (0, _minus.minus)(2, 1);
          console.log(sum); console.log(division);
      },
      ...
    })
})
转载自:https://juejin.cn/post/7374651496618868770
评论
请登录