做为前端打工仔,怎么能不懂webpack原理呢!

123 阅读13分钟

实现mini-webpack

最近阳的人好多呀,记得以前阳过的人比较稀奇,现在别人听见我还没阳过都很诧异哈哈。我也不知道是我抵抗力强还是防护得比较好,本来以为我可能是无症状,但是刚刚用了抗原测,只有一道杠。身体是本钱呐,要照顾好呀。
好多人都在催我阳,我偏不!!!妹的

好了就扯到这里,进入正题!


为了尽量表达清楚代码量还有注释比较多,很快就能看完的,要坚持!!! 源码链接在文章末尾哈!
食用过程中如果有问题或者表达不清楚的地方,欢迎指出,十分感谢!

要实现mini-webpack,肯定需要先知道webpack是什么。
引用官方文档的话。webpack 是一个用于现代 JavaScript 应用程序的 静态模块打包工具。 当 webpack 处理应用程序时,它会在内部从一个或多个入口点构建一个 依赖图(dependency graph),然后将你项目中所需的每一个模块组合成一个或多个 bundles,它们均为静态资源,用于展示你的内容。
简单来说,webpack会通过程序的入口文件,将程序的所有资源整合起来,输出一个整合后的资源。

webpack基础打包功能

打包主要分成两大步骤:1.构建依赖关系图 2.根据依赖关系图生成代码

构建依赖关系图流程

  • 读取入口文件并获取其内容
  • 解析文件内容,得到一个抽象语法树
  • 从抽象语法树上获取入口文件与其他文件的依赖关系(将这个依赖关系的信息称为asset)
    • asset中包含该文件的 路径-filePath、源码-source、依赖的文件列表-deps(入口文件所依赖的文件的相对路径组成的数组)
  • 创建依赖关系表(获取所有模块之间的依赖关系)
    • 创建一个队列,队列中保存所有模块的依赖关系(asset组成的一个数组),开始只有入口文件的asset
    • 遍历这个队列,逐个处理asset(目前只有一个入口文件的asset,没关系,遍历的过程中还会添加其他文件的asset)
    • 每个asset中都有一个deps,保存这个asset对应的模块依赖的其他文件,遍历这个deps,可以拿到依赖的每个文件的相对路径, 使用 path.resolve() 和 依赖文件的相对路径 获取到这个文件的位置进行下一步操作
  • 现在已经可以拿到入口文件所依赖文件的相对路径,使用这个(些)相对路径做与入口文件相同的操作,可以得到依赖文件的asset
  • 将依赖文件的asset添加到队列中(对依赖的所有文件都进行上一步的操作,实际上是一个递归处理,直到处理完所有用到的文件)
  • 到这里可以拿到所有模块的依赖关系表了,先实现构建依赖关系图的功能

实现构建依赖关系图

实现之前呢,先做好准备工作,上面流程中的操作需要安装一些包。

  • 读取文件:fs模块-node内置不用安装
  • 拼接路径:path模块-node内置不用安装
  • 根据源码生成抽象语法树:@babel/parser
  • 从抽象语法树中获取依赖模块的相对路径:@babel/traverse 需要安装@babel/parser@babel/transveryarn add @babel/parser @babel/transver,接下来可以实现了。

需要打包的文件目录结构
|- dist dist目录必须创建,否则生成文件的时候可能会因为找不到dist目录而写入失败
|- bundle.ejs |- index.js |- example |- index.html
|- main.js
|- foo.js

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>mini-webpack</title>
</head>
<body>

<script src="main.js"></script>
<!--<script src="dist/bundle.js"></script>-->

</body>
</html>

main.js

import { foo } from './foo.js'

console.log(data)

foo()

console.log('main.js')

foo.js

export function foo () {
  console.log('foo')
}

根据模块相对路径创建模块的asset index.js:

function createAsset(filePath) {
  // 获取源码
  const source = fs.readFileSync(filePath, { encoding: 'utf-8' })
  // 根据源码生成抽象语法树
  const ast = parser.parse(source, { sourceType: 'module' })
  const deps = [] // 保存该模块的依赖
  // 从树上获取文件的依赖信息并保存起来
  traverse.default(ast, {
    // 遍历抽象语法树上所有的导入语句
    ImportDeclaration(path) {
      // path.node.source.value就是导入语句中的路径,例如:'./foo.js'
      deps.push(path.node.source.value)
    }
  })
  // 返回该模块的asset
  return {
    filePath,
    source,
    deps
  }
}

根据入口文件asset创建整个系统的依赖关系表 index.js:

function createGraph() {
  // 创建入口文件的asset
  const mainAsset = createAsset('./example/main.js')
  /*
  * 接下来基于上一步获取到的依赖信息,继续查找依赖关系
  * 上一步获取到了main.js依赖的信息-foo.js
  * 接下来遍历mainAsset.deps,获取里面的依赖信息,即foo.js的依赖信息
  * */
  // 开始处理模块的依赖关系,从入口文件开始
  const queue = [mainAsset] // 用来保存所有模块,在下边遍历的时候会给队列中添加数据
  // 遍历一个队列,在遍历时队列长度会增长
  for (const asset of queue) {
    // 遍历每个模块的依赖项,处理所有模块的依赖关系
    asset.deps.forEach(relativePath => { // 遍历每个模块的依赖项
      // 根据依赖项的绝对路径创建该模块的信息
      const childAsset = createAsset(path.resolve('./example', relativePath))
      // 将创建的依赖模块信息添加到队列中(因为依赖的模块中还可能依赖其他模块)
      queue.push(childAsset)
    })
  }
  // 返回处理后的所有模块组成的关系图
  return queue
}

这里已经可以根据入口文件获取整个系统的所有依赖关系了。获取的依赖关系数据:

[
  {
    "filePath": "./example/main.js",
    "source": "import { foo } from './foo.js'\n\nconsole.log(data)\n\nfoo()\n\nconsole.log('main.js')\n",
    "code": "\"use strict\";\n\nvar _foo = require(\"./foo.js\");\n\nconsole.log(data);\n(0, _foo.foo)();\nconsole.log('main.js');",
    "deps": [
      "./foo.js"
    ],
    "mapping": {
      "./foo.js": 1
    },
    "id": 0
  },
  {
    "filePath": "D:\\relax\\powerful\\example\\foo.js",
    "source": "export function foo () {\n console.log('foo')\n}\n",
    "code": "\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.foo = foo;\n\nfunction foo() {\n console.log('foo');\n}",
    "deps": [],
    "mapping": {},
    "id": 1
  }
]

生成代码的准备工作

在生成代码之前应该先确定好如何组织所有的代码,以及如何生成文件。
这一步先自己手动将所有文件组织起来。

1.将所有代码组织起来实际上就是将所有文件合并到一个文件中
bundle.js:

// main.js的内容
import { foo } from './foo.js'

foo()

console.log('main.js')

// foo.js的内容
export function foo () {
    console.log('foo')
}

2.要避免同名的变量发生冲突,因此将每个文件中的代码单入放入一个函数中
bundle.js:

function mainJs() {
    import { foo } from './foo.js'
    
    foo()

    console.log('main.js')
}
function fooJs() {
    export function foo () {
        console.log('foo')
    }
}

3.ESM模块化只能写在文件开头,但是CJS的可以在函数内部,所以把ESM改成CJS(不是使用CJS,而是模拟CJS)
bundle.js:

function mainJs() {
    const { foo } = require('./foo.js')
    
    foo()

    console.log('main.js')
}
function fooJs() {
    function foo () {
        console.log('foo')
    }
    module.exports = {
        foo
    }
}

4.这一步需要实现自己的require,用来根据每个模块的相对路径来获取这个模块的内容(即该模块对应的函数)
bundle.js:

function require (filePath) {
    const map = {
        './foo.js': fooJs,
        './main.js': mainJs
    }
    // 根据文件路径获取对应的函数
    const fn = map[filePath]
    // 执行
    fn()
}

// 这里就相当于整个文件的执行入口
require('./main.js')

function mainJs() {
    const { foo } = require('./foo.js')
    
    foo()

    console.log('main.js')
}

function fooJs() {
    function foo () {
        console.log('foo')
    }
    module.exports = {
        foo
    }
}

5.上面代码整个流程是看起来是通了,但是前面说过只是模拟CJS,所有需要为每个模块的函数提供 require、module
bundle.js:

function require (filePath) {
    const map = {
        './foo.js': fooJs,
        './main.js': mainJs
    }
    // 根据文件路径获取对应的函数
    const fn = map[filePath]
    const module = {
        exports: {}
    }
    // 执行
    fn(require, module, module.exports)
    
    // require作为导出模块的入口,所以应该返回module.exports
    return module.exports
}

// 这里就相当于整个文件的执行入口
require('./main.js')

// exports 就是 module.exports 的简写
function mainJs(require, module, exports) {
    const { foo } = require('./foo.js')

    foo()

    console.log('main.js')
}

function fooJs(require, module, exports) {
    function foo () {
        console.log('foo')
    }
    module.exports = {
        foo
    }
}

6.重构,将上面的代码改为立即执行函数
bundle.js:

(
    function (map) {
        function require (filePath) {
            // 根据文件路径获取对应的函数
            const fn = map[filePath]
            const module = {
                exports: {}
            }
            // 执行
            fn(require, module, module.exports)

            // require作为导出模块的入口,所以应该返回module.exports
            return module.exports
        }

        // 这里就相当于整个文件的执行入口
        require('./main.js')
    }
)({
    './foo.js': function (require, module, exports) {
        function foo () {
            console.log('foo')
        }
        module.exports = {
            foo
        }
    },
    './main.js': function (require, module, exports) {
        const { foo } = require('./foo.js')
    
        foo()
    
        console.log('main.js')
    }
})

到这一步 代码组合完毕,可以看到需要动态改变的只是这个立即执行函数的参数对象,对象中的key和value中的函数体部分需要改变。
举个例子:

const params = {
    './main.js': // 这一行的key
    function (require, module, exports) {
        // 这行以下
        const { foo } = require('./foo.js')

        foo()

        console.log('main.js')
        // 这行以上
    }
}

执行上面代码输出:

foo
main.js

根据依赖关系图生成代码

如何生成代码

这一步已经知道应该怎么组织将要生成的代码,接下来就是如何生成代码

因为组织代码的时候已经将ESM模块化的语法改成了CJS,之前创建模块asset代码的功能需要调整,因为生成的asset中包含代码内容source。

将ESM转为CJS需要使用babel-core中的 transformFromAst 方法,转CJS时需要传入一个配置对象,对象中的presets设置为 ['env'], presets的值为 ['env'] 时需要额外安装一个包,babel-preset-env
安装 yarn add babel-core babel-preset-env

index.js:

function createAsset(filePath) {
  // 获取源码
  const source = fs.readFileSync(filePath, { encoding: 'utf-8' })
  // 根据源码生成抽象语法树
  const ast = parser.parse(source, { sourceType: 'module' })
  const deps = [] // 保存该模块的依赖
  // 从树上获取文件的依赖信息并保存起来
  traverse.default(ast, {
    // 遍历抽象语法树上所有的导入语句
    ImportDeclaration(path) {
      // path.node.source.value就是导入语句中的路径,例如:'./foo.js'
      deps.push(path.node.source.value)
    }
  })

    // TODO 将 esm 改为 cjs,加入ESM转CJS的逻辑
    const { code } = transformFromAst(ast, null, {
        presets: ['env']
    })
    
  // 返回该模块的asset
  return {
    filePath,
    source, // ESM的源码
    code, // CJS的源码
    deps
  }
}

上面不是已经有了手写的模板文件了嘛,最后可以通过这个模板文件和 ejs 来生成打包结果。
安装ejs yarn add ejs

处理模板中的动态内容, bundle.ejs:

(map => {
  function require(filePath) {
    const fn = map[filePath]
    const module = {
      exports: {}
    }
    fn(require, module, module.exports)

    return module.exports
  }

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

根据模板之前生成的依赖关系图和模板使用 ejs 生成打包结果 index.js:

// 根据所有模块的依赖信息生成打包后的代码
function build (graph) {
  // 读取模板
  const template = fs.readFileSync('./bundle.ejs', { encoding: 'utf-8' })

  // 处理成模板渲染需要的数据 { filePath: string, code: string }[]
  const data = graph.map(asset => {
    const { code, filePath } = asset
    return {
      code, filePath
    }
  })
  // 根据模板和处理后的数据渲染代码
  const code = ejs.render(template, { data })
  // 写入
  fs.writeFileSync('./dist/bundle.js', code)
}

到这里,打包功能就已经ok啦! 这是自动打包生成的文件。 dist/bundle.js:

(map => {
  function require(filePath) {
    const fn = map[filePath]
    const module = {
      exports: {}
    }
    fn(require, module, module.exports)

    return module.exports
  }

  require('./main.js')
})(
  {
      "./example/main.js": function (require, module, exports) {
          "use strict";

            var _foo = require("./foo.js");

            console.log(data);
            (0, _foo.foo)();
            console.log('main.js');
      },
      "D:\relax\powerful\example\foo.js": function (require, module, exports) {
          "use strict";

            Object.defineProperty(exports, "__esModule", {
              value: true
            });
            exports.foo = foo;
            
            function foo() {
              console.log('foo');
            }
      },
    }
)

修改打包策略

不对,好像还有一个问题,就是传入立即执行函数的参数对象中的key,它不一定是相对路径。因此需要改变一下传参的这个策略。 还存在一个问题就是可能存在同名的文件夹(比如foo.js),其他文件引入的时候相对路径可能也是 './foo.js' ,因此需要改变 当前的策略,给每个模块一个id(作为map的key),值是一个数组,第一项是该模块的函数,第二项是该模块依赖的信息。

原来的参数案例

map = {
    './foo.js': function foojs(require, module, exports) {
      function foo() {
        console.log('foo')
      }

      module.exports = {
        foo
      }
    },
    './main.js': function mainjs(require, module, exports) {
      const {foo} = require('./foo.js')

      foo()

      console.log('main.js')
    }
  }

修改后的参数案例

map = {
    1: [function foojs(require, module, exports) {
      function foo() {
        console.log('foo')
      }

      module.exports = {
        foo
      }
    }, {}], // foo.js 没有依赖
    2: [function mainjs(require, module, exports) {
      const {foo} = require('./foo.js')

      foo()

      console.log('main.js')
    }, { './foo.js': 1 }] // main.js依赖了foo.js
  }

// 修改后的参数
const params = {
    "模块ID": [
        "模块对应的代码,一个函数,就是原来的value",
        "该模块的依赖信息,一个对象,对象的key是依赖模块的相对路径,对象的value是依赖模块的ID"
    ]
}

修改打包策略,自然也需要修改打包的模板文件
新增了一个localRequire函数,有必要说一下:

// 每次调用require的时候,比如 var _foo = require("./foo.js")
// 先根据mapping基于模块路径查找到模块id
// 在根据模块id找到 fn 和 该模块 的mapping
function localRequire (filePath) {
    const id = mapping[filePath]
    return require(id)
}

根据上面的params看这行代码,将fn和mapping结构出来 const [fn, mapping] = map[id],只是 取数据的方式发生一些小变化,整体逻辑并没有太大变化
bundle.ejs:

(map => {
    function require(id) {
        const [fn, mapping] = map[id]
        const module = {
            exports: {}
        }

        function localRequire (filePath) {
            const id = mapping[filePath]
            return require(id)
        }

        fn(localRequire, module, module.exports)

        return module.exports
    }

    require(0)
})(
    {
    <% data.forEach(info => { %>
        <%- info["id"] %>: [function (require, module, exports) {
           <%- info["code"] %>
        }, <%- JSON.stringify(info["mapping"])%>],
    <% }) %>
    }
)

实现新的打包策略

index.js:

// 声明一个全局变量,作为模块id
let id = 0
// 更新创建模块依赖信息的函数
function createAsset(filePath) {
    // 获取文件内容
    let source = fs.readFileSync(filePath, { encoding: 'utf-8' })

    // 获取依赖关系
    // 获取抽象语法树
    const ast = parser.parse(source, { sourceType: 'module' })
    const deps = [] // 保存模块的依赖
    // 2-2.从树上获取文件的依赖信息并保存起来
    traverse.default(ast, {
        // 回调
        ImportDeclaration(path) {
            deps.push(path.node.source.value)
        }
    })

    // 将 esm 改为 cjs
    const { code } = transformFromAst(ast, null, {
        presets: ['env'] // 需要安装babel-preset-env
    })

    return {
        filePath,
        source, // ESM 转换前
        code, // ESM 转 CJS 之后
        deps, // 依赖
        mapping: {}, // 用来保存当前模块依赖的其他模块和依赖模块的id
        id: id++ // 模块id
    }
}

// 更新创建所有依赖项关系图的函数
function createGraph() {
    const mainAsset = createAsset('./example/main.js')
    /*
    * 接下来基于上一步获取到的依赖信息,继续查找依赖关系
    * 上一步获取到了main.js依赖的信息-foo.js
    * 接下来遍历mainAsset.deps,获取里面的依赖信息,即foo.js的依赖信息
    * */
    // 开始处理模块的依赖关系,从入口文件开始
    const queue = [mainAsset] // 用来保存所有模块,在下边遍历的时候会给队列中添加数据
    // TODO 遍历一个队列,在遍历时队列长度发生变化
    for (const asset of queue) {
        // 遍历每个模块的依赖项,处理所有模块的依赖关系
        asset.deps.forEach(relativePath => { // 遍历每个模块的依赖项
            // 根据依赖项的绝对路径创建该模块的信息
            const child = createAsset(path.resolve('./example', relativePath))
            // 将当前模块(asset)的依赖信息保存到mapping中
            // 当前模块依赖作为key,依赖的模块的id作为值
            asset.mapping[relativePath] = child.id
            // 将创建的依赖模块信息添加到队列中(因为依赖的模块中还可能依赖其他模块)
            queue.push(child)
        })
    }
    // 返回处理后的所有模块
    return queue
}

// 更新生成代码的函数
function build (graph) {
    // 读取模板
    const template = fs.readFileSync('./bundle.ejs', { encoding: 'utf-8' })

    // 处理成模板渲染需要的数据 { filePath: string, code: string }[]
    // 处理成模板渲染需要的数据 { id, code, mapping }[]
    const data = graph.map(asset => {
        const { id, code, mapping } = asset
        return {
            id, code, mapping
        }
    })
    // 根据模板和处理后的数据渲染代码
    const code = ejs.render(template, { data })
    // 5-4写入
    fs.writeFileSync('./dist/bundle.js', code)
}

更新完成!!!

最后执行打包功能:node index.js 生成代码: dist/bundle.js

(map => {
    function require(id) {
        const [fn, mapping] = map[id]
        const module = {
            exports: {}
        }

        function localRequire (filePath) {
            const id = mapping[filePath]
            return require(id)
        }

        fn(localRequire, module, module.exports)

        return module.exports
    }

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

            var _foo = require("./foo.js");
            
            console.log(data);
            (0, _foo.foo)();
            console.log('main.js');
        }, {"./foo.js":1}],
                
        1: [function (require, module, exports) {
           "use strict";
            
            Object.defineProperty(exports, "__esModule", {
              value: true
            });
            exports.foo = foo;

            function foo() {
              console.log('foo');
            }
        }, {}],
        
    }
)

源码:传送门

过几天分享loader和plugin的逻辑,这一篇看明白了的话,loader和plugins很简单的喔。

源码目录目前有一丢丢乱,明天上班就整理好并且加上一个索引说明。(可以不好好工作,但是必须好好学习!)