mini webpack

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.1 可以通过将每个文件的代码用函数封装
  2. 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

  1. plugin是一个类
  2. 要有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
    })
}
相关推荐
liangshanbo12156 小时前
Webpack 文件指纹面试题整理
前端·webpack·node.js
liangshanbo12157 小时前
Webpack 常见 Plugin 面试题
前端·webpack·node.js
liangshanbo12158 小时前
Webpack Tree Shaking 面试题整理
前端·webpack·node.js
陪我一起学编程10 小时前
vue3笔记
前端·webpack·vue3·vite·脚手架·vue3笔记·组件式
liangshanbo121511 小时前
Webpack的分包策略面试题
前端·webpack·node.js
liangshanbo12152 天前
面试题:如何优化 Webpack 的打包速度?
前端·webpack·node.js
晚风醉蝶3 天前
webpack 模块提取
前端·webpack·node.js·ast·逆向分析
晚风醉蝶13 天前
保姆级教程:用 AST 自动提取 webpack / rspack 打包后的模块
webpack·web逆向
guwentian19 天前
Rolldown vs esbuild vs Webpack:Rust 打包器大战我们该怎么选
前端·webpack·rust·esbuild
飞翔的熊blabla22 天前
# Webpack 5 + Jenkins 持久化缓存实战:前端构建从 288 秒降到 30 秒>
前端·webpack·jenkins