🐡实现Mini-webpack

学习 cuixiaorui 大佬的 mini-webpack 的学习笔记!

webpack 是一个 js 应用程序的静态打包工具,官网首页的这幅图很直观的说明了一点,将各类资源根据他们之间的依赖关系,将他们打包到一起。

🤔 为什么会出现 webpack?

这涉及到 js 的历史,在 es6 之前,js 是没有自己的模块系统的,大家更多的使用 cjs 模块规范(node 环境下),而浏览器在很长时间里是不支持模块系统的,所以需要有一个"打包工具"将我们分散的模块代码打包到一起,从而在浏览器环境下运行。

🤔现在浏览器环境支持 esm 模块规范了,还需要 webpack 吗?

首先尽管现如今主流浏览器的最新版本都支持这一特性,但是目前还无法保证用户的浏览器使用情况。所以我们还需要解决兼容问题。其次将分散的代码打包也有益于提高 web 应用的性能(浏览器同时请求的资源有限制)

🤔vite vs webpack:

vite 是与 webpack 的理念不同,vite 在开发环境下不打包的,直接使用主流浏览器支持 esm 的特性,对于大型应用可以大大减少打包的时间,提高项目启动速度。

1. 了解 webpack

我们了解下 webpack 最核心的几个特性,后续我们来对应实现这些特性。

  • 打包:从一个入口处构建一份依赖图,最终将这些有依赖关系的小文件打包为一个大的文件,便于浏览器加载。
  • loader 机制:webpack 本身只认识 js,对于 css、图片等其他资源,webpack 提供了一个 loader 机制,从而可以处理不同的资源;
  • plugin:webpack 提供的一种插件机制,使得开发者可以在 webpack 构建流程中引入自定义的行为。

2. webpack 实现思路分析

2.1. 打包

webpack 将所有的资源(如 js、css、img 等)都视为模块,webpack 需要项目一个入口,从这个入口开始构造"依赖图"

  1. 资源
  2. 依赖图
  3. 打包

我们来看看例子

js 复制代码
// main.js
import foo from './foo.js'

foo(1,2)
console.log('main.js')

// foo.js 
import bar from './bar.js'
const add = (a,b)=>{
  console.log(bar)
  return a+b
}

export default add

// bar.js
export default  'foo'

我们最终的目的是把这三个 js 文件打包为一个 可以直接执行的 js 文件。

首先我们将一个 js 文件视为一份资产:

js 复制代码
interface Asset {
  id: number,
  filePath: string,
  code: any,
  deps: string[],
}

我们需要把这个 js 文件的依赖关系也存储下来。

提取 js 文件的依赖关系有很多方式,比如我们可以通过正则表达式来匹配代码中import部分,这里我们使用babel,将代码转换为 ast,从 ast 中获取该文件的依赖。

AST(Abstract Syntax Tree,抽象语法树)是源代码的抽象语法结构的树状表现形式。在这个网站中,我们可以看到我们 js 代码转换为 ast 后的形式:

我们 js 代码 import的依赖就存储在 ast 中ImportDeclaration属性下边。

通过下边的代码,我们就可以通过下边的代码获取依赖关系:

js 复制代码
import parse from '@babel/parser'
import traverse from '@babel/traverse'
// 存储依赖关系
const deps: string[] = []
// 编译为ast
const ast = parse.parse(source, {
  sourceType: 'module'
})

// 遍历ast,并获取依赖项
traverse.default(ast, {
  ImportDeclaration({ node }) {
    deps.push(node.source.value)
  }
})

接下来我们从入口文件 main.js 构造一份依赖图,所谓依赖图就是不同模块(js) 之间 的依赖关系

js 复制代码
/**
 * 从入口开始,构造"依赖图",
 * @param entry 
 * @returns 
 */
function createGraph(entry: string) {
  const mainAsset = createAsset(entry) as Graph
  const queue = [mainAsset]

  // 通过这种方式来实现类似递归的效果,当解析的文件中存在其他的依赖时,
  // 把这个依赖添加到queue中
  for (let asset of queue) {
    asset.mapping = {}
    // 这个模块所在的目录
    const dirname = path.dirname(asset.filePath)
    //遍历资源的依赖
    asset.deps.forEach((relativePath) => {
      const absolutePath = path.resolve(dirname, relativePath)
      const child = createAsset(absolutePath)
      asset.mapping[relativePath] = child.id
      queue.push(child)
    })
  }
  return queue
}

createGraph函数执行的效果就是从入口文件 main.js 开始分析出所有涉及的资产(js 文件)以及他们所依赖的资产。

js 复制代码
[
  {
    id: 0,
    filePath: "./example/main.js",
    code: '...',
    deps: ["./foo.js",],
    mapping: {"./foo.js": 1,},
  },
  {
    id: 1,
    filePath: "./foo.js",
    code: '...',
    deps: ["./bar.js", ],
    mapping: {"./bar.js": 2,},
  },
  {
    id: 2,
    filePath: "./bar.js",
    code: '...',
    deps: [],
    mapping: {},
  },
]

有了依赖图之后,我们需要根据这份依赖图构造出最终的大文件,这个文件包含了所有的代码,并且可以直接执行。

我们首先先来分析一下这份文件长什么样子:

  • 首先不同的模块文件作用域需要分割开来,防止变量污染
  • 源代码中采用的是 es6 的模块语法import来引入依赖,而在一个大的 js 文件中,我们不需要也不能再用import来引入
    • 所以我们需要对源码中 import进行转换: 我们采用 cjs 规范,自己实现了一个 require 函数,来实现模块加载,同时需要把源码中的 import语法替换为我们实现的 require 函数
js 复制代码
; (function (modules) {
  // 实现了一个require函数,它的作用其实就是根据id获取对应模块中内容
  function require(id) {
    const [fn, mapping] = modules[id]
    const module = {
      exports: {}
    }
    // 实际源码中是通过文件路径来获取模块的,所以这里我们构建一个函数来做一个转换
    function localRequire(filePath) {
      const id = mapping[filePath]
      return require(id)
    }
    fn(localRequire, module, module.exports)

    return module.exports
  }
  require(1)
})({
  1: [function (require, module, exports) {
    const {foo} = require("./foo.js")
    foo()
  }, {
    './foo.js': 2
  }],
  2: [function (require, module, exports) {
    function foo() {
      console.log('foo')
    }
    module.exports = { foo }
  }, {}],
})

最后我们需要解决的就是如何将我们构建好的依赖图打包为最终的文件。

我们采用ejs这个库来解决这个问题:

esj 模板文件:bundle.ejs

ejs 复制代码
// 这一部分代码的作用是模拟打包后的代码
// require函数是模拟commonjs的require函数的作用,
// 传入的参数其实就是一个Map<filepath: function>
  ; (function (modules) {
    function require(id) {
      const [fn, mapping] = modules[id]
      const module = {
        exports: {}
      }
      function localRequire(filePath) {
        const id = mapping[filePath]
        return require(id)
      }
      fn(localRequire, module, module.exports)
  
      return module.exports
    }
    require(1)
  })({
  <% data.forEach(info => { %>
  "<%- info["filePath"] %>": function (require, module, exports) {
    <%- info["code"] %>
  },
    <%  });  %>

  });

实现打包代码:

js 复制代码
/**
 * 根据依赖图dependency graph,使用ejs构造最终的打包代码
 * @param graph 
 */
function build(graph) {
  const template = fs.readFileSync('./bundle.ejs', { encoding: 'utf8' })

  const data = graph.map((asset) => {
    return {
      filePath: asset.filePath,
      code: asset.code
    }
  })
  const code = ejs.render(template, { data })
  fs.writeFileSync('./dist/bundle.js', code)
}

2.2. loader

loader 是用与处理非 js 文件的转换,自定义 loader 本质上就是实现了一个函数,当我们导入非 js 文件时,交由这个 loader 函数进行处理,将非 js 文件转换为 js 中的对象,再输出转换后的结果。

比如说 json 数据我们可以转换为 js 中的对象,png 等图片资源我们可以转换为一个文件路径。

这是官网示例中的自定义loader函数,source 是转换前的源码,由 webpack 传入,再 loader 函数内进行一些处理后输出。

了解了 loader 本质上就是一个函数,那我们来动手实现loader 机制。

首先我们写一个可以处理 json 数据的 loader:

js 复制代码
export default function (source) {
  console.log('jsonLoader', source)
  // 对资源应用一些转换......
  return `export default ${JSON.stringify(source)}`;
}

接着我们来在构建过程中实现 loader 机制。

js 复制代码
import jsonLoader from './jsonLoader.js'
// ... 省略代码
// 模拟webpack配置
const webpackConfig = {
  module: {
    rules: [
      {
        test: /.json$/,
        use: jsonLoader
      },
    ],
  },
}
+
function createAsset(filePath: string): Asset {
  // 1.获取文件内容
  let source = fs.readFileSync(filePath, {
    encoding: 'utf-8'
  })

  // 新增处理loader逻辑
  const loaders = webpackConfig.module.rules
  loaders.forEach(({test,use}) => {
      if(new RegExp(test).test(filePath)){
        source = use(source)
      }
  })

  // ...交由babel处理,编译为ast
  
}

2.3. plugin

plugin 本质上是 webpack 给开发者开的口子,在 webpack 对文件进行处理的过程中,触发一系列的事件,使得开发者可以在 webpack 构建流程中引入自定义的行为。

所以插件实现是利用事件机制,在 webpack 构建流程中发出一系列的事件,开发者可以注册这些发出的事件,注入处理逻辑。

有些类似与 vue 的生命周期、maven 插件机制等;

那知道了他的核心逻辑,我们就可以实现一个基础的插件机制。

我们 以一个可以改变输出路径的插件为例;

js 复制代码
export default class ChangeOutputPath {
  apply(hooks) {
    hooks.emitFile.tap("changeOutputPath", (context) => {
      // context为插件传入的上下文
      change.
      console.log("___________________changeOutputPath");
    })
  }
}

模仿 webpack 自定义插件的写法,插件就是一个函数(类),函数原型上有一个 apply 方法,在这个方法中注入自定义逻辑。

接着我们需要有一个初始化插件和插件 hooks 的地方:

js 复制代码
import { SyncHook } from 'tapable' // webpack使用的实现事件机制的库
import ChangeOutputPath from './changeOutputPath.js'
const webpackConfig = {
  /// ... 其他配置
  plugin: [new ChangeOutputPath()]
}

const hooks = {
  emitFile: new SyncHook()
}

function initPlugin(){
  const plugins = webpackConfig.plugin
  plugins.forEach(plugin => {
    // 把这个hooks传给插件的apply方法,实现注册
    plugin.apply(hooks);
  })
}
initPlugin()

初始完插件后,我们需要再 webpack 构建过程中发出 对应的事件,这里我们是模拟的修改输出文件位置的插件,所以我们需要再输出文件前调用。

js 复制代码
function build(graph) {
  const template = fs.readFileSync('./bundle.ejs', { encoding: 'utf8' })

  const data = graph.map((asset) => {
    return {
      filePath: asset.filePath,
      code: asset.code
    }
  })
  const code = ejs.render(template, { data })
  // 触发emit
  hooks.emitFile.call()
  fs.writeFileSync('./dist/bundle.js', code)
}

3. 最后

可以在📦这里看到文章源码。

相关推荐
ChoSeitaku6 小时前
No.1|Godot|俄罗斯方块复刻|棋盘和初始方块的设置
java·前端·godot
生信天地6 小时前
jQuery:前端开发的高效利器
前端·jquery
牛奶皮子6 小时前
vue3Class 与 Style 绑定
前端·javascript·vue.js
傻小胖7 小时前
React setState详细使用总结
前端·react.js·前端框架
网络安全Jack7 小时前
[CTF/网络安全] 攻防世界 Web_php_unserialize 解题详析
前端·web安全·php
宿命小人7 小时前
Electron使用记录
前端·javascript·electron
傻小胖8 小时前
React Error Boundary 错误边界限制
前端·react.js·前端框架
夫琅禾费米线8 小时前
react全局状态管理——redux和zustand,及其区别
前端·javascript·react.js
GISer_Jing8 小时前
React中createRoot函数原理解读——Element对象与Fiber对象、FiberRootNode与HostRootNode
前端·react.js·前端框架