今天来实现一个简单的打包工具

文件依赖

src
├─ a.js
├─ b.js
├─ c.js
└─ index.js

文件内容如下

// src/index.js
import { aInit } from './a.js'

aInit()

// src/a.js
import b from './b.js'
import c from './c.js'
export const aInit = () => {
  console.log('a init')
}

// src/b.js
console.log('b import')

// src/c.js
console.log('c import')

思路

核心原理是三大阶段 解析-转换-生成

解析

解析的是文件的依赖关系, 将其全部收集起来,用于之后的转换

转换

  1. 将 es module 导入语法转换为 commonjs 导入语法
  2. 利用 iife 实现模块化

生成

将转换完的代码输出到 bundle.js 文件

总结

有了宏观上的三大流程, 我们就可以思考怎么去实现这个打包工具了:

  1. 读取文件内容
  2. 分析文件内容, 找出它的子依赖
  3. 保存当前文件的 code 和 子依赖列表, 并且如果有子依赖则回到第一步挨个处理子依赖, 全部处理完毕后生成 依赖关系图
  4. 将 es module 转换为 common js, 以兼容浏览器
  5. 生成iife代码,以实现模块化
  6. 输出为bundle.js

开始动手

首先我们在根目录创建一个 mini-webpack.js 文件, 用来编写我们的mini-webpack

1. 读取文件内容

利用 fs模块, 并将入口文件地址传入, 并将文件路径和读取到的内容导出

const fs = require('fs');

function createAsset(filePath) {

    const originCode = fs.readFileSync(filePath, 'utf8')
    
    return {
    
            filePath,
            
            code: originCode,
        
        }

}

createAsset('./src/index.js')

2. 分析文件内容, 找出它的子依赖

如何才能知道一个文件的依赖呢, 大体上有两个方案

  1. 使用正则去匹配 import from 等关键字, 缺点就是比较不灵活
  2. 使用 ast

我们这里当然使用 ast, 说到ast 那 babel 就是好东西了,对babel不熟悉的同学可以看之前写的这篇文章: 一文看懂 webpack 的所有 source map !🤔

利用 @babel/parser 生成 ast , 然后利用 @babel/traverse 对ast进行处理, 将分析到的依赖加入 deps 数组中,并导出

const parser = require('@babel/parser')
const traverse = require('@babel/traverse')
function createAsset(filePath) {
    const originCode = fs.readFileSync(filePath, 'utf8')
    
    const ast = parser.parse(originCode, {
        // 需要声明是 es module 
        sourceType: 'module'    
    });

    // 收集到内部import依赖
    const deps = []
    traverse.default(ast, {
        // 找到import 
        ImportDeclaration(path) {
            const node = path.node
            // 收集到依赖数组
            deps.push(node.source.value)
        }
    })

    return {
        filePath,
        code,
        deps
    }
}

3. 保存当前文件的 code 和 子依赖列表, 并且如果有子依赖则回到第一步挨个处理子依赖

利用广度优先遍历,以入口为起点,将所有资源的依赖收集到 graph 中

function createGraph (entryFile) {
  const entryAsset = createAsset(entryFile)
  // 首先是将入口文件加入
  const graph = [ entryAsset ]
  
  // 遍历图中所有资源
  for (const asset of graph) {
    // 如果当前资源存在子依赖
    asset.deps.forEach(relativePath => {
      const childAsset = createAsset(path.join(__dirname, 'src', relativePath))
      // 将子依赖资源加入到 graph 数组,后续for循环自然也会将其处理
      graph.push(childAsset)
    })
  }

  return graph
}
// 调用
const graph = createGraph('./src/index.js')

我们看一下此时 graph的输出结果

我们拿到了每个文件的文件路径, 文件内容,还有子依赖

4.将 es module 转换为 common js, 以兼容浏览器

这个转换 ,babel 同样可以做到, 此时我们修改一下 createAsset 方法, 利用 @babel/core
transformFromAst ,在将 ast 转回 code 的阶段使用@babel/preset-env 实现转换

const { transformFromAst } =require("@babel/core")

function createAsset(filePath) {
  const originCode = fs.readFileSync(filePath, 'utf8')

  const ast = parser.parse(originCode, {
    sourceType: 'module'
  });

  // 收集到内部import依赖
  const deps = []
  traverse.default(ast, {
    ImportDeclaration(path) {
      const node = path.node
      // 检测到的依赖都添加到数组里
      deps.push(node.source.value)
    }
  })


  // 将 es module 转换为 common js, 以兼容浏览器
  const { code } = transformFromAst(ast, originCode, {
    "presets": ["@babel/preset-env"]
  })


  return {
    filePath,
    code,
    deps
  }
}

再来看一下 graph的输出结果

可以看到里面 code ,已经不再是上一阶段的 import ,而是使用 require 进行引入

5. 生成iife代码,以实现模块化

虽然我们又了文件的依赖关系, 也将es module 转换为 common js , 但目前还是无法在浏览器中使用, 为此我们要实现iife 模块化,我们来看看如何编写

首先,我们的目的是把下图的这些文件, 都打入到最终的 bundle.js

所以我们先手动把所有文件内容都放入bundle.js 看看

// index.js
import { aInit } from './a.js'
aInit()

// a.js
import b from './b.js'
import c from './c.js'
export const aInit = () => {
  console.log('a init')
}

// b.js
console.log('b import')

// c.js
console.log('c import')

显然,这样直接放在一起是无法运行的
首先需要将 esmodule 手动改为 common js

// index.js
const { aInit } = require('./a.js') 
aInit()

// a.js
const b = require('./b.js') 
const c = require('./c.js') 
module.exports.aInit = () => {
  console.log('a init')
}

// b.js
console.log('b import')

// c.js
console.log('c import')

改为 require 后同样是无法运行的, 因为浏览器里根本没有 require 方法, 所以我们需要给他一个

// index.js
function indexJs(require, module, exports) {
  const { aInit } = require('./a.js') 
  aInit()
}

// a.js
function aJs(require, module, exports) {
  const b = require('./b.js') 
  const c = require('./c.js') 
  module.exports.aInit = () => {
    console.log('a init')
  }
}
// b.js
function bJs(require, module, exports) {
  console.log('b import')
}

// c.js
function cJs(require, module, exports) {
  console.log('c import')
}

把每个文件都看成是一个 function , 它会接收 require, module, exports 三个参数, 现在 require 有地方取了, 但是我们还没做具体实现, 所以我们需要继续改造。

(function (moduleMap) {
  function require(filePath) {
    const fn = moduleMap[filePath]

    const module = {
      exports: {}
    }
    fn(require, module, module.exports)
    return module.exports
  }

  require('./index.js')
}({
  './index.js': function (require, module, exports) {
    const { aInit } = require('./a.js')
    aInit()
  },
  './a.js': function (require, module, exports) {
    const b = require('./b.js') 
    const c = require('./c.js') 
    module.exports.aInit = () => {
      console.log('a init')
    }
  },
  './b.js': function (require, module, exports) {
    console.log('b import')
  },
  './c.js': function (require, module, exports) {
    console.log('c import')
  }
}))

使用 iife 进行包裹,将文件映射作为参数传入, 并在函数内构造 require 方法, 其内部本质是从文件映射中获取相应的 function, 构造一个 module 对象,调用 function 并传入

并且最后调用一下入口文件

现在我们在浏览器里加载一下这个代码, 看看能否运行

ok , 没有问题

6. 输出为bundle.js

如何动态构造iife 呢,比较方便的方法就是使用模版引擎

我们编写一个 build 方法, 使用 bundle.ejs 模版进行渲染

function build(graph) {
  const template = fs.readFileSync('./template/bundle.ejs', { encoding: 'utf-8' })
  
  const ejsData = graph.map(asset => ({
    filePath: asset.filePath,
    code: asset.code
  }))
  console.log(ejsData)

  const code = ejs.render(template, { ejsData })

  fs.writeFileSync('./dist/bundle.js', code)
}

const graph = createGraph('./src/index.js')


build(graph)

bundle.ejs 模版内容如下 【 ejs模版引擎具体语法可取官方文档查看】

// bundle.ejs
(function (moduleMap) {
  function require(filePath) {
    const fn = moduleMap[filePath]

    const module = {
      exports: {}
    }
    fn(require, module, module.exports)
    return module.exports
  }

  require('./index.js')
}({
  <% ejsData.forEach(item => { %>
  "<%- item["filePath"] %>": function (require, module, exports) {
    <%- item["code"] %>
  },
  <% }) %>
}))

ejsData 是我们需要注入的变量, 就是我们的 资源关系依赖图, 对其进行forEach , 循环出所有资源

此时最终文件如下:

(function (moduleMap) {
  function require(filePath) {
    const fn = moduleMap[filePath];

    const module = {
      exports: {},
    };
    fn(require, module, module.exports);
    return module.exports;
  }

  require("./index.js");
})({
  "./src/index.js": function (require, module, exports) {
    "use strict";

    var _a = require("./a.js");

    (0, _a.aInit)();
  },

  "/Users/nxl3477/Documents/study_space/mini-series/mini-webpack/my-mini-webpack/src/a.js":
    function (require, module, exports) {
      "use strict";

      Object.defineProperty(exports, "__esModule", {
        value: true,
      });
      exports.aInit = void 0;

      var _b = _interopRequireDefault(require("./b.js"));

      var _c = _interopRequireDefault(require("./c.js"));

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

      var aInit = function aInit() {
        console.log("a init");
      };

      exports.aInit = aInit;
    },

  "/Users/nxl3477/Documents/study_space/mini-series/mini-webpack/my-mini-webpack/src/b.js":
    function (require, module, exports) {
      "use strict";

      console.log("b import");
    },

  "/Users/nxl3477/Documents/study_space/mini-series/mini-webpack/my-mini-webpack/src/c.js":
    function (require, module, exports) {
      "use strict";

      console.log("c import");
    },
});

我们发现产出的文件有一些问题,
我们实际 require 的是文件的相对路径, 而传入的 moduleMap 很多资源是绝对路径,所以,比如 require('./a.js') 是找不到的, 所以我们得再想办法改造一下

iife 代码改造为如下,我们赋予所有的 资源模块一个 id ,并携带一个用于 相对路径映射实际id 的对象


(function (moduleMapping) {
  function require(moduleId) {
    // 取出函数和 映射对象
    const [ fn, mapping ] = moduleMapping[moduleId]

    // 对 require 包装一层, 先通过自身的 mapping 取出实际的id, 然后再调用 require 取出实际的资源
    function localRequre(filePath) {
      const _moduleId = mapping[filePath]
      return require(_moduleId)
    }

    const module = {
      exports: {}
    }

    fn(localRequre, module, module.exports)
    return module.exports
  }

  require(0)
}({
  // 改为传入数组, 第一个元素为函数, 第二个是用来映射相对路径的对象
  // ./index.js
  0: [function (require, module, exports) {
    const { aInit } = require('./a.js')
    aInit()
  }, { './a.js': 1 }],
  // ./a.js
  1: [function (require, module, exports) {
    const b = require('./b.js') 
    const c = require('./c.js') 
    module.exports.aInit = () => {
      console.log('a init')
    }
  }, { './b.js': 2, './c.js': 3 }],
  // './b.js'
  2: [function (require, module, exports) {
    console.log('b import')
  }, {}],
  // './c.js'
  3: [function (require, module, exports) {
    console.log('c import')
  }, {}]
}))

根据上面这个代码,我们更新一下 ejs模版

// bundle.ejs
(function (moduleMapping) {
  function require(moduleId) {
    const [ fn, mapping ] = moduleMapping[moduleId]

    function localRequre(filePath) {
      const _moduleId = mapping[filePath]
      return require(_moduleId)
    }

    const module = {
      exports: {}
    }

    fn(localRequre, module, module.exports)
    return module.exports
  }
  // 入口Id 为0
  require(0)
}({
  <% ejsData.forEach(item => { %>
  "<%- item["id"] %>": [function (require, module, exports) {
    <%- item["code"] %>
  }, <%- JSON.stringify(item.mapping) %> ],
  <% }) %>
}))

mini-webpack.js 也要进行调整:

  1. 为每个资源模块生成一个 id
  2. 为每个资源模块构造 mapping 用来映射子依赖的模块id
let uid = 0

function createAsset(filePath) {
  const originCode = fs.readFileSync(filePath, 'utf8')

  const ast = parser.parse(originCode, {
    sourceType: 'module'
  });

  // 收集到内部import依赖
  const deps = []
  traverse.default(ast, {
    ImportDeclaration(path) {
      const node = path.node
      // 检测到的依赖都添加到数组里
      deps.push(node.source.value)
    }
  })

  const { code } = transformFromAst(ast, originCode, {
    "presets": ["@babel/preset-env"]
  })

  return {
    filePath,
    code,
    // 新增 mappping
    mapping: {},
    deps,
    // 新增 id
    id: uid++
  }
}


// 完善mapping数据
function createGraph (entryFile) {
  const entryAsset = createAsset(entryFile)
  const graph = [ entryAsset ]
  
  // 遍历图中所有资源
  for (const asset of graph) {
    // 编译每个资源的依赖
    asset.deps.forEach(relativePath => {
      const child = createAsset(path.join(__dirname, 'src', relativePath))
      // 将子依赖的id 写入父资源模块的
      asset.mapping[relativePath] = child.id
      graph.push(child)
    })
  }

  return graph
}


// 完善 ejsData 的数据
function build(graph) {
  const template = fs.readFileSync('./template/bundle.ejs', { encoding: 'utf-8' })
  
  const ejsData = graph.map(asset => ({
    filePath: asset.filePath,
    code: asset.code,
    id: asset.id,
    mapping: asset.mapping
  }))
  console.log(ejsData)

  const code = ejs.render(template, { ejsData })

  fs.writeFileSync('./dist/bundle.js', code)
}

好此时,我们再来看看生成的代码

(function (moduleMapping) {
  function require(moduleId) {
    const [fn, mapping] = moduleMapping[moduleId];

    function localRequre(filePath) {
      const _moduleId = mapping[filePath];
      return require(_moduleId);
    }

    const module = {
      exports: {},
    };

    fn(localRequre, module, module.exports);
    return module.exports;
  }

  require(0);
})({
  0: [
    function (require, module, exports) {
      "use strict";

      var _a = require("./a.js");

      (0, _a.aInit)();
    },
    { "./a.js": 1 },
  ],

  1: [
    function (require, module, exports) {
      "use strict";

      Object.defineProperty(exports, "__esModule", {
        value: true,
      });
      exports.aInit = void 0;

      var _b = _interopRequireDefault(require("./b.js"));

      var _c = _interopRequireDefault(require("./c.js"));

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

      var aInit = function aInit() {
        console.log("a init");
      };

      exports.aInit = aInit;
    },
    { "./b.js": 2, "./c.js": 3 },
  ],

  2: [
    function (require, module, exports) {
      "use strict";

      console.log("b import");
    },
    {},
  ],

  3: [
    function (require, module, exports) {
      "use strict";

      console.log("c import");
    },
    {},
  ],
});

然后去浏览器执行一下

ok, 完美, 一个简单的打包工具就完成了

完整代码已上传至github: mini-webpack


参考资料:
手摸手带你实现打包器 仅需 80 行代码理解 webpack 的核心


nxl3477
299 声望15 粉丝

挑战10000小时定律。