写了这么多webpack配置,不想做一个自己的打包工具吗

926 阅读3分钟

总所周知,现代前端,基本都离不开前端工程化,多多少少都要用上打包工具进行处理,例如webpack、rollup。不过写得再多,也只是针对webpack/rollup的配置工程师,具体打包过程,对我们来说却是个黑盒。

不如,我们来搞点事情?

梳理流程

在构建之前我们要梳理一下打包的流程。

  1. 我们的打包工具要有一个打包入口 [entry]

  2. 我们的 [entry] 文件会引入依赖,因此需要一个 {graph} 来存储模块图谱,这个模块图谱有3个核心内容:

    • filepath :模块路径,例如 ./src/message.js'
    • dependencies :模块里用了哪些依赖
    • code :这个模块中具体的代码
  3. {graph} 会存储所有的模块,因此需要递归遍历每一个被依赖了的模块

  4. 根据 {graph} 的依赖路径,用 {graph.code} 构建可运行的代码

  5. 将代码输出到出口文件,打包完成

搭建架构

首先写好我们需要打包的代码

// ./src/index.js
import message from './message.js';
import {word} from './word.js';

console.log(message);
console.log(word);
// ./src/message.js
import {word} from './word.js';
const message = `hello ${word}`;

export default message;
// ./src/word.js
export const word = 'paraslee';

然后在根目录创建bundler.js,设计好具体要用上的功能,

// ./bundler.js
// 模块分析能力:分析单个模块,返回分析结果
function moduleAnalyser(filepath) {
    return {};
}

// 图谱构建能力:递归模块,调用moduleAnalyser获取每一个模块的信息,综合存储成一个模块图谱
function moduleGraph(entry) {
    const moduleMuster = moduleAnalyser(entry);
    
    const graph = {};
    return graph;
}

// 生成代码能力:通过得到的模块图谱生成可执行代码
function generateCode(entry) {
    const graph = moduleGraph(entry);
    const code = '';
    
    return code;
}

// 调用bundleCode执行打包操作,获取到可执行代码后,将代码输出到文件里
function bundleCode() {
    const code = generateCode('./src/index.js');
}

bundleCode();

自底向上,编码开始!

模块分析

首先是最底层的功能:moduleAnalyser(模块分析)

因为第一个分析的模块一定是入口文件,所以我们在写 moduleAnalyser 的具体内容时可以把其他代码先注释掉,单独编写这一块。

function moduleAnalyser(filepath) {}
moduleAnalyser('./src/index.js');

首先,我们需要读取这个文件的信息,通过node提供的 fs API,我们可以读取到文件的具体内容

const fs = require('fs');
function moduleAnalyser(filepath) {
    const content = fs.readFileSync(filePath, 'utf-8');
}

打印content能得到下图的结果

第二步,我们需要获取这个模块的所有依赖,即 ./message.js./word.js 。有两种方法可以拿到依赖:1. 手写规则进行字符串匹配;2. 使用babel工具操作

第一种方法实在吃力不讨好,而且容错性低,效率也不高,因此我采用第二种方法

babel有工具能帮我们把JS代码转换成AST,通过对AST进行遍历,可以直接获取到使用了 import 语句的内容

npm i @babel/parser @babel/traverse
...
const parser = require('@babel/parser');
const traverse = require("@babel/traverse").default;

const moduleAnalyser = (filePath) => {
    const content = fs.readFileSync(filePath, 'utf-8');
    // 通过 [@babel/parser的parse方法] 能将js代码转换成ast
    const AST = parser.parse(content, {
        sourceType: 'module' // 如果代码使用了esModule,需要配置这一项
    });

    // [@babel/traverse] 能遍历AST树
    traverse(AST, {
        // 匹配 ImportDeclaration 类型节点 (import语法)
        ImportDeclaration: function(nodePath) {
            // 获取模块路径
            const relativePath = nodePath.node.source.value;
        }
    });
}

如果我们在控制台中打印 relativePath 就能输出 ./message.js./word.js

AST实在是太长♂了,感兴趣的小伙伴可以自己输出AST看看长啥样,我就不放出来了


第三步,获取到依赖信息后,连同代码内容一起存储下来

下面是 moduleAnalyser的完整代码,看完后可能会有几个疑惑,我会针对标注处挨个进行解释

npm i @babel/core
...
const babel = require('@babel/core');

const moduleAnalyser = (filePath) => {
    const content = fs.readFileSync(filePath, 'utf-8');
    const AST = parser.parse(content, {
        sourceType: 'module'
    });

    // 存放文件路径 #1
    const dirname = path.dirname(filePath);
    // 存放依赖信息
    const dependencies = {};

    traverse(AST, {
        ImportDeclaration: function(nodePath) {
            const relativePath = nodePath.node.source.value;
            // 将相对模块的路径 改为 相对根目录的路径 #2
            const absolutePath = path.join(dirname, relativePath);
            // replace是为了解决windows系统下的路径问题 #3
            dependencies[relativePath] = './' + absolutePath.replace(/\\/g, '/');
        }
    });

    // 用babel将AST编译成可运行的代码 #4
    const {code} = babel.transformFromAst(AST, null, {
        presets: ["@babel/preset-env"]
    })

    return {
        filePath,
        dependencies,
        code
    }
}

#1 为什么要获取dirname?

首先入口文件为 ./src/index.js,默认 ./src 为代码的根目录,所有依赖,所有模块文件都在 ./src 下面 (暂时先不考虑node_modules),因此我们要获取这个根目录信息, dirname === 'src'

#2 为什么要把相对模块的路径 改为 相对根目录的路径

在 ./src/index.js 中是这样引入模块的 import message from './message.js' ,relativePath 变量存储的值为 ./message.js ,这对于分析 message.js 文件非常不便,转换成 ./src/message.js 后就能直接通过fs读取这个文件,方便了很多

#3 为什么要这样存储依赖信息

通过键值对的存储,既可以保留 将相对模块的路径 ,又可以存放 相对根目录的路径

#4 为什么要做代码编译

代码编译可以将esModule转换成commonjs,之后构建代码时,我们可以编写自己的 require() 方法进行模块化处理.


OK,现在已经理解了 moduleAnalyser 方法,让我们看看输出结果长啥样

图谱构建

现在已经实现了 模块分析能力,接下来我们需要递归所有被导入了的模块,将每个模块的分析结果存储下来作为 grapth

...
const moduleAnalyser = (filePath) => {...}

const moduleGraph = (entry) => {
    // moduleMuster 存放已经分析过的模块集合, 默认直接加入入口文件的分析结果
    const moduleMuster = [moduleAnalyser(entry)];
    // cache记录已经被分析过的模块,减少模块的重复分析
    const cache = {
        [moduleMuster[0].filePath]: 1
    };
    // 存放真正的graph信息
    const graph = {};

    // 递归遍历所有的模块
    for (let i = 0; i < moduleMuster.length; i++) {
        const {filePath} = moduleMuster[i];

        if (!graph[filePath]) {
            const {dependencies, code} = moduleMuster[i];
            graph[filePath] = {
                dependencies,
                code
            };

            for (let key in dependencies) {
                if (!cache[dependencies[key]]) {
                    moduleMuster.push(moduleAnalyser(dependencies[key]));
                    cache[dependencies[key]] = 1;
                }
            }
        }
    }

    return graph;
}

// 先直接传入enrty信息,获取图谱信息
moduleGraph('./src/index.js');

moduleGraph 方法并不难理解,主要内容在递归层面。

输出看看最终生成的图谱 graph

构建代码

接下来就是重点中的重点,核心中的核心:根据graph生成可执行代码

...
const moduleAnalyser = (filePath) => {...}
const moduleGraph = (entry) => {...}

const generateCode = (entry) => {
    // 代码在文件里其实是一串字符串,浏览器是把字符串转换成AST后再操作执行的,因此这里需要把图谱转换成字符串来使用
    const graph = JSON.stringify(moduleGraph(entry));
    return `
        (function(graph) {
            // 浏览器没有require方法,需要自行创建
            function require(module) {

                // 代码中引入模块时是使用的相对模块的路径  ex. var _word = require('./word.js')
                // 但我们在引入依赖时需要转换成相对根路径的路径  ex. require('./src/word.js')
                // 将requireInEval传递到闭包中供转换使用
                function requireInEval(relativePath) {
                    return require(graph[module].dependencies[relativePath]);
                }

                // 子模块的内容存放在exports中,需要创建空对象以便使用。
                var exports = {};

                // 使用闭包避免模块之间的变量污染
                (function(code, require, exports) {
                    // 通过eval执行代码
                    eval(code);
                })(graph[module].code, requireInEval, exports)

                // 将模块所依赖的内容返回给模块
                return exports;
            }

            // 入口模块需主动引入
            require('${entry}');

        })(${graph})`;
}

generateCode('./src/index.js');

此刻一万匹草泥马在心中狂奔:这破函数到底写的是个啥玩意儿? 给爷看晕了

GGMM不着急,我来一步步说明

首先把字符串化的graph传入闭包函数中以供使用。

然后需要手动导入入口文件模块,即 require('${entry}') ,注意这里要使用引号包裹,确保为字符串

因此我们的 require 函数此时为

function require(module = './src/index.js') {}

根据 graph['./src/index.js'] 能获取到 入口文件的分析结果,

function require(module = './src/index.js') {
    (function(code) {
        // 通过eval执行代码
        eval(code);
    })(graph[module].code)
}

然后我们看一下此时eval会执行的代码,即入口文件编译后的代码

"use strict";
Object.defineProperty(exports, "__esModule", {
  value: true
});
exports["default"] = void 0;
var _word = require("./word.js");
var message = "hello ".concat(_word.word);
var _default = message;
exports["default"] = _default;

在第六行里有一句 var _word = require("./word.js"); ,因为作用域链的存在,这里的require会调用最外层的require方法, 但是我们自己写的require方法接受的是相对根目录的路径,因此需要有一个方法进行转换。

// 此时的require
function require(module = './src/index.js') {
    function requireInEval(relativePath = './word.js') {
        return require(graph[module].dependencies[relativePath]);
    }
    var exports = {};
    (function(code, require, exports) {
        eval(code);
    })(graph[module].code, requireInEval, exports)
    return exports;
}

通过 requireInEval 进行路径转换,并传入到闭包当做,根据作用域的特性,eval中执行的 require 为传入的requireInEval 方法。

eval执行时,会把依赖里的数据存放到exports对象中,因此在外面我们也需要创建一个exports对象接受数据。

最后将exports返回出去


之后就是重复以上步骤的循环调用

生成文件

现在,打包流程基本已经完成了,generateCode 方法返回的code代码,可以直接放到浏览器中运行。

不过好歹是个打包工具,肯定要把打包结果输出出来。

const os = require('os'); // 用来读取系统信息
...
const moduleAnalyser = (filePath) => {...}
const moduleGraph = (entry) => {...}
const generateCode = (entry) => {...}

function bundleCode(entry, output) {
    // 获取输出文件的绝对路径
    const outPath = path.resolve(__dirname, output);
    const iswin = os.platform() === 'win32'; // 是否是windows
    const isMac = os.platform() === 'darwin'; // 是否是mac
    const code = generateCode(entry);

    // 读取输出文件夹
    fs.readdir(outPath, function(err, files) {
        // 如果没有文件夹就创建文件夹
        let hasDir = true;
        if (err) {
            if (
                (iswin && err.errno === -4058)
                || (isMac && err.errno === -2)
            ) {
                fs.mkdir(outPath, {recursive: true}, err => {
                    if (err) {
                        throw err;
                    }
                });
                hasDir = false;
            } else {
                throw err;
            }
        }

        // 清空文件夹里的内容
        if (hasDir) {
            files = fs.readdirSync(outPath);
            files.forEach((file) => {
                let curPath = outPath + (iswin ? '\\' :"/") + file;
                fs.unlinkSync(curPath);
            });
        }

        // 将代码写入文件,并输出
        fs.writeFile(`${outPath}/main.js`, code, 'utf8', function(error){
            if (error) {
                throw error;
            }

            console.log('打包完成!');
        })
    })
}

bundleCode('./scr/index.js', 'dist');

执行 node bundler.js 看看最终结果吧!

尾声

到这里,一个基础的打包工具就完成了!

你可以自己添加 bundler.config.js ,把配置信息添加进去,传入bundler.js,这样看上去更像个完整的打包工具了。

这个打包工具非常简单,非常基础,webpack/rollup因为涉及了海量的功能和优化,其内部实现远比这个复杂N倍,但打包的核心思路大体相同。

这里放上完整的代码: github

如果文中有错误/不足/需要改进/可以优化的地方,希望能在评论里提出,作者看到后会在第一时间里处理

既然你都看到这里了,为何不点个赞👍再走,github的星星⭐是对我持续创作最大的支持❤️️

拜托啦,这对我真的很重要