明天来实现一个简略的打包工具

文件依赖

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

文件内容如下

// src/index.jsimport { aInit } from './a.js'aInit()// src/a.jsimport b from './b.js'import c from './c.js'export const aInit = () => {  console.log('a init')}// src/b.jsconsole.log('b import')// src/c.jsconsole.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.jsimport { aInit } from './a.js'aInit()// a.jsimport b from './b.js'import c from './c.js'export const aInit = () => {  console.log('a init')}// b.jsconsole.log('b import')// c.jsconsole.log('c import')

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

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

改为 require 后同样是无奈运行的, 因为浏览器里基本没有 require 办法, 所以咱们须要给他一个

// index.jsfunction indexJs(require, module, exports) {  const { aInit } = require('./a.js')   aInit()}// a.jsfunction aJs(require, module, exports) {  const b = require('./b.js')   const c = require('./c.js')   module.exports.aInit = () => {    console.log('a init')  }}// b.jsfunction bJs(require, module, exports) {  console.log('b import')}// c.jsfunction 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 = 0function 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 的外围