《打包系列|webpack原理篇》

1,296 阅读4分钟

webpack 实现

这是一个如何实现一个简单的webpack生成bundle.js的demo, 主要的实现流程:

入口entry -> 递归解析AST获取依赖 -> 生成依赖图

-> 为每个模块包裹factory function -> 以入口脚本为起点,递归执行模块 -> 拼接IIFE(factory, require实现) -> 产出bundle

另外需要用到以下插件:

  • @babel/parser 用于分析源代码,产出 AST;
  • @babel/traverse 用于遍历 AST,找到 import 声明;
  • @babel/core 用于编译,将源代码编译为 ES5;
  • @babel/preset-env 搭配@babel/core使用;
  • resolve 用于获取依赖的绝对路径。

代码地址

全局配置

const fs = require("fs");
const path = require("path");
// 用于分析源代码,产出 AST
const parser = require("@babel/parser");
// 用于遍历 AST,找到 import 声明
const traverse = require("@babel/traverse").default;
// 用于编译,将源代码编译为 ES5
const babel = require("@babel/core");
// 用于获取依赖的绝对路径
const resolve = require("resolve").sync;

/**
 * 维护一个全局 ID,并通过遍历 AST
 * 访问ImportDeclaration节点,收集依赖到deps数组中,
 * 同时完成 Babel 降级编译
 */

let ID = 0
const log = console.log.bind(console)

生成依赖图

/**
 * 实现了查找一个文件的所有依赖
 * 返回文件的路径,文件的所有引用依赖,文件的esm代码, 模块对应 ID
 */
function createModuleInfo(filePath) {
    const content = fs.readFileSync(filePath, 'utf8')
    const ast = parser.parse(content, { sourceType: "module" })
    const deps = []

    traverse(ast, {
        ImportDeclaration: ({ node }) => {
            deps.push(node.source.value)
        }
    })

    const id = ID++

    // 编译成esm5
    const { code } = babel.transformFromAstSync(ast, null, {
        presets: ["@babel/preset-env"]
    })

    return {
        id,
        filePath,
        deps,
        code
    }
}

// log(createModuleInfo(path.resolve(__dirname, './test/app.js')))
/**
 * 对entry入口依赖进行遍历,对每个deps进行依赖信息生成
 * 注意: 这里不支持循环引用,否则死递归
 * 最后生成依赖图
 */
function createDependencyGraph(entry) {
    const entryInfo = createModuleInfo(entry)
    const graphArr = []
    graphArr.push(entryInfo)

    // 以入口模块为起点,遍历整个项目依赖的模块,并将每个模块信息维护到 graphArr 中
    for (const module of graphArr) {
        module.map = {}
        module.deps.forEach(depPath => {
            const baseDir = path.dirname(module.filePath)
            const moduleDepPath = path.resolve(baseDir, depPath)
            const moduleInfo = createModuleInfo(moduleDepPath)
            graphArr.push(moduleInfo)
            module.map[depPath] = moduleInfo.id
        })
    }

    return graphArr
}

对依赖图谱进行处理生成IIFE

/**
 * 使用 IIFE 的方式,来保证模块变量不会影响到全局作用域
 * 构造好的项目依赖树(Dependency Graph)数组,将会作为名为modules的行参,传递给 IIFE
 * 通过require(map[requireDeclarationName])方式,按顺序递归调用各个依赖模块
 * 通过调用factory(module.exports, localRequire)执行模块相关代码
 * 该方法最终返回module.exports对象,module.exports 最初值为空对象({exports: {}})
 * 但在一次次调用factory()函数后,module.exports对象内容已经包含了模块对外暴露的内容了
 */
function pack(graph) {
    const moduleArgArr = graph.map(module => {
        return `${module.id}: {
            factory: (exports, require) => {
                ${module.code}
            },
            map: ${JSON.stringify(module.map)}
        }`
    })

    const iifeBundler = `(function(modules){
        const require = id => {
            const {factory, map} = modules[id];
            const localRequire = requireDeclarationName => require(map[requireDeclarationName]);
            const module = {exports: {}};
            factory(module.exports, localRequire);
            return module.exports;
        }
        require(0)

        })({${moduleArgArr.join()}})
    `
    return iifeBundler;
}

webpack loader 和 plugin

下面再写一下简单的实现一个loader 和 plugin 代码地址如下

webpack hmr

基本实现原理大致这样的,构建 bundle 的时候,加入一段 HMR runtime 的 js 和一段和服务沟通的 js 。文件修改会触发 webpack 重新构建,服务器通过向浏览器发送更新消息,浏览器通过 jsonp 拉取更新的模块文件,jsonp 回调触发模块热替换逻辑。

服务端主要使用了: webpack, express, websocket

使用express启动本地服务,当浏览器访问资源时对此做响应 服务端和客户端使用websocket实现长连接

webpack监听源文件的变化,即当开发者保存文件时触发webpack的重新编译 每次编译都会生成hash值、已改动模块的json文件、已改动模块代码的js文件 编译完成后通过socket向客户端推送当前编译的hash戳

客户端的websocket监听到有文件改动推送过来的hash戳,会和上一次对比 一致则走缓存,不一致则通过ajax和jsonp向服务端获取最新资源

使用内存文件系统去替换有修改的内容实现局部刷新

具体步骤如下: 启动webpack-dev-server服务器 创建webpack实例 创建Server服务器

添加webpack的done事件回调 编译完成向客户端发送消息

创建express应用app 设置文件系统为内存文件系统 添加webpack-dev-middleware中间件 中间件负责返回生成的文件(生成hash值、已改动模块的json文件、已改动模块代码的js文件)

启动webpack编译

创建http服务器并启动服务 使用sockjs在浏览器端和服务端之间建立一个 websocket 长连接

创建socket服务器

客户端具体步骤(客户端bundles.js内被webpack处理后加入了一段websocket客户端代码)

webpack-dev-server/client端会监听到此hash消息 客户端收到ok的消息后会执行reloadApp方法进行更新

在reloadApp中会进行判断,是否支持热更新,如果支持的话发射webpackHotUpdate事件,如果不支持则直接刷新浏览器

在webpack/hot/dev-server.js会监听webpackHotUpdate事件 在check方法里会调用module.hot.check方法 HotModuleReplacement.runtime请求Manifest 它通过调用 JsonpMainTemplate.runtime的hotDownloadManifest方法 调用JsonpMainTemplate.runtime的hotDownloadUpdateChunk方法通过JSONP请求获取到最新的模块代码

补丁JS取回来后会调用JsonpMainTemplate.runtime.js的webpackHotUpdate方法 然后会调用HotModuleReplacement.runtime.js的hotAddUpdateChunk方法动态更新模块代码 然后调用hotApply方法进行热更新