写了这么多webpack配置,不想作一个本身的打包工具吗

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

不如,咱们来搞点事情?node

梳理流程

在构建以前咱们要梳理一下打包的流程。webpack

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

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

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

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

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

搭建架构

首先写好咱们须要打包的代码windows

// ./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的星星⭐是对我持续创做最大的支持❤️️

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

相关文章
相关标签/搜索