学习 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 需要项目一个入口,从这个入口开始构造"依赖图"
- 资源
- 依赖图
- 打包
我们来看看例子
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. 最后
可以在📦这里看到文章源码。