mini webpack
编写配置文件
ts
export default {
entry: './main.js',
output: {
filename: 'build.js',
path: path.resolve(process.cwd(), 'dist')
}
}
编写打包函数
ts
function createAssets(filePath: string) {
const content = fs.readFileSync(filePath, 'utf-8') // 读取入口文件代码
const ast = parse(content, {
sourceType: 'module'
})
const deps: string[] = []
traverse(ast, {
ImportDeclaration({ node }) {
deps.push(node.source.value)
}
})
return {
filePath,
deps
}
}
babel
babel用来执行代码的转换,比如es6代码转换为更古老的es3或者es5代码
@babel/parser
通过这个库的parse
方法将通过fs
读到的js代码转成ast抽象语法树
ts
import { parse } from '@babel/parser'
const ast = parse(content, {
sourceType: 'module'
})
@babel/traverse
通过这个库遍历抽象语法树获取各个文件的依赖关系,如下
ts
const deps: string[] = []
traverse(ast, {
ImportDeclaration({ node }) {
deps.push(node.source.value)
}
})
总结 经过以上步骤,我们能够获取主文件的依赖关系如下:
{ filePath: './main.js', deps: ['./app.js']}
但是,如果文件层层递归的话,以上函数并不满足要求,因此我们需要编写一个函数来处理文件的多层级嵌套。
ts
export function createGraph(entry: string): Graph[] {
const graph = createAssets(entry, config)
const queue = [graph]
for (let assets of queue) {
const deps = assets.deps
deps.forEach((dep) => {
const child = createAssets(dep, config)
queue.push(child)
})
}
return queue
}
总结 通过处理多层嵌套关系,得到的结果如下:
ts
[
{ filePath: './main.js', deps: ['./app.js']},
{ filePath: './app.js', deps: ['./foo.js']},
{ filePath: './foo.js', deps: []}
]
@babel/core
将ast转为代码
ts
const source = transformFromAstSync(ast, undefined, {
presets: ['@babel/preset-env'] //es6-> es5 并且变成cjs
})
通过以上代码获取文件的代码
@babel/preset-env
将代码转为cjs
格式的es5
代码
手写require
在打包过程中,我们需要将所有的代码都整合到一个文件中,这样会导致
- 变量冲突 1.1 可以通过将每个文件的代码用函数封装
- import必须处于顶层 2.1 我们可以采用
CommonJS
规范的require
来完成文件的导入,但是浏览器并不支持require ,因此我们需要模拟实现require
js
function require(filePath) {
/**
* modules是传入的映射关系:
* {
* './main.js': function(require) {}
* }
*/
const fn = modules(filePath)
const module = { exports: {} }
fn(require, module, module.exports)
return module.exports
}
构建模板
有了以上require
函数之后,我们需要构建文件路径 与代码之间的映射关系。这里采用ejs
模板的方式来动态构建。
ejs
(function(modules){ function require(filePath) { const fn = modules[filePath]; const module = { exports: {}
}; fn(require, module,
module.exports); return module.exports; } require('<%- entry %>'); })({ <% graph.forEach(item => { %> "<%- item.filePath %>":
function(require, module, exports) { <%- item.code %> })
通过
@babel/core
和@babel/preset-env
两个库获取到了代码,并将code作为数据传递给模板,最终完成了webpack的打包。代码如下:
ts
export function build(graph: Graph[], config: Config) {
const template = fs.readFileSync(path.join(process.cwd(), 'lib/template/bundle.ejs'), 'utf-8')
const code = ejs.render(template, { graph, entry: 0 })
const outDir = config.output.path
const filename = config.output.filename
fs.mkdirSync(config.output.path, { recursive: true })
fs.writeFileSync(path.join(outDir, filename), code)
}
问题
由于映射对象中键是由文件的相对路径组成的,如果我们的文件处于目录中的话,可能会导致文件找不到,因此我们需要改下映射关系。改用id的方式
解决
- 重写require
js
function require(filePath) {
/**
* modules是传入的映射关系:
* {
* './main.js': function(require) {}
* }
*/
const [fn, mapping] = modules(filePath)
const module = { exports: {} }
function localRequire(relativePath) {
return require(mapping[filePath])
}
fn(localRequire, module, module.exports)
return module.exports
}
- 传递映射关系
ts
function createAssets(filePath: string, config: Config) {
const content = fs.readFileSync(filePath, 'utf-8') // 读取入口文件代码
const ast = parse(content, {
sourceType: 'module'
})
const deps: string[] = []
traverse(ast, {
ImportDeclaration({ node }) {
deps.push(node.source.value)
}
})
const source = transformFromAstSync(ast, undefined, {
presets: ['@babel/preset-env'] //es6-> es5 并且变成cjs
})
return {
filePath,
deps,
code: source?.code,
id: id++,
mapping: {} as Record<string, number>
}
}
- 重新构建依赖图
ts
export function createGraph(entry: string, config: Config): Graph[] {
const graph = createAssets(entry, config)
const queue = [graph]
for (let assets of queue) {
const deps = assets.deps
deps.forEach((dep) => {
const child = createAssets(dep, config)
assets.mapping[dep] = child.id
queue.push(child)
})
}
return queue
}
- 更新模板
js
(function(modules){ function require(filePath) { const [fn, mapping] = modules[filePath]; const module = { exports: {}
}; function localRequire(relativePath) { return require(mapping[relativePath]); } fn(localRequire, module,
module.exports); return module.exports; } require('<%- entry %>'); })({ <% graph.forEach(item => { %> "<%- item.id %>":
[function(require, module, exports) { <%- item.code %> }, <%- JSON.stringify(item.mapping) %> ], <% }) %> })
loader
- 更新配置文件
ts
const jsLoader = (source: string) => {
source += 'console.log(11111)'
return source
}
export default {
...
rules: {
module: [
{
test: /\.js$/,
use: jsLoader
}
]
}
} as Config
- 编写loader函数
ts
import { Config } from '../type'
export function Loader(source: string, config: Config) {
const module = config.rules.module
module.forEach((item) => {
if (Array.isArray(item.use)) {
item.use.reverse().forEach((fn) => {
source = fn(source)
})
} else {
source = item.use(source)
}
})
return source
}
- 在打包之前调用
ts
function createAssets(filePath: string, config: Config) {
...
// 执行Loader
if (source?.code) {
source.code = Loader(source.code, config)
}
...
}
plugin
- plugin是一个类
- 要有apply方法
实现HtmlWebpackPlugin
- 编写配置文件
ts
interface Plugin {
apply: (compiler: any) => void
}
interface Config {
...
plugins: Plugin[]
}
export default {
...
plugins: []
} as Config
- 编写模板
ts
import type { Hooks } from './lib/type'
class HtmlWebpackPlugin {
apply(hooks:Hooks) {}
}
tips webpack插件的hook 是基于
tapable
这个库实现的tapable
是基于发布-订阅实现的
js
import { SyncHook } from 'tapable'
const hook = new SyncHook<string>(['params'])
// 先监听,再触发
hook.tap('webpack', (name) => {
console.log(name)
})
hook.call('xs')
基于发布-订阅 addEventListener mitt 组件通信 webpack hooks electron ipcMain ipcRender nodejs eventEmitter process
- 自定义hook生命周期
ts
import { AsyncSeriesHook, SyncHook } from 'tapable'
export interface Hooks<T = unknown> {
emit: AsyncSeriesHook<T>
afterEmit: AsyncSeriesHook<T>
initialize: SyncHook<T>
done: AsyncSeriesHook<T>
afterPlugins: SyncHook<T>
}
export const hooks: Hooks = {
afterPlugins: new SyncHook(), // 插件调用后执行
initialize: new SyncHook(), // 插件初始化执行
emit: new AsyncSeriesHook(), // 打包之前执行
afterEmit: new AsyncSeriesHook(), // 打包之后执行
done: new AsyncSeriesHook(['done']) // 打包完成执行
}
- 编写订阅方法
ts
class HtmlWebpackPlugin {
options: { template: string }
constructor(options: { template: string }) {
this.options = options
}
apply(compiler: Hooks) {
compiler.afterPlugins.tap('htmlWebpackPlugin', () => {
console.log(1111)
})
compiler.done.tapPromise('htmlWebpackPlugin', () => {
const template = fs.readFileSync(this.options.template, 'utf-8')
const newTemplate = template.replace(/<head>/, '<head>\n<script src="bundle.js"></script>')
fs.writeFileSync(path.resolve(process.cwd(), 'dist/index.html'), newTemplate)
return new Promise((resolve) => {
resolve()
})
})
}
}
以上这个类实现了对于插件初始化时的监听以及打包完成的监听
- 调用plugins中每个实例的
apply
方法
ts
import type { Config } from '../type'
import { hooks } from './hook'
export const initPlugin = (config: Config) => {
if (config.plugins) {
config.plugins.forEach((item) => {
item.apply(hooks)
})
hooks.afterPlugins.call(this)
}
}
在构建依赖之前调用以上函数,因为是先订阅再触发,因此我们需要先执行
plugins
数组中实例的apply
方法先进行订阅
- 调用
ts
export function build(graph: Graph[], config: Config) {
...
// 执行打包完成的钩子,将打包后的js引入html中
hooks.done.callAsync(graph, (error) => {
if (error) throw error
})
}