模仿webpack开发一个属于自己的x-pack打包工具

模拟webpack实现 x-pack 自定义打包

先贴一下demo的连接,可直接clone 到本地,查看代码和效果
x-pack: https://github.com/yangJianWeb/x-pack
x-pack-demo: https://github.com/yangJianWeb/x-pack-demo
x-pack clone到本地后将命令 npm link 到全局即可在x-pack-demo中执行打包。

模仿webpack打包工具写一个自己的小型打包工具,并开发一个自定义loader;

分析webpack打包结果

我们可以先建立一个简单的工程项目,然后使用webpack打包之后发现其打包之后的文件主要是根据 webpack.config.js 配置文件进行一系列的打包,比如设置出入口文件各种loader, 将注释的部分删除,可以发现其整体是一个立即执行函数,函数内部实现了一个自己的require方法即(webpack_require),然后参数是模块路径和模块源码的映射。其中他将模块源码编译为一个字符串脚本,传入 eval函数中去执行。打包之后大体骨架如下:
在这里插入图片描述
看懂了这个骨架之后,我们思路就很清晰,主要做两件事情:

  • 实现一个类似 webpack_require 的方法, 这里叫 xpack_require
  • 根据配置文件中配置的入口文件,递归遍历出模块路径和模块源码的对应关系

开发自定义 x-pack 打包

先把工程结构贴出来,会有一个直观的了解,后面也会根据工程结构按照逻辑分文件来讲述

|--bin              x-pack 自定义打包工具入口文件
|   |
|   |- pack.js      x-pack 入口执行文件,只要是读取打包工程中的xpack.config.js配置文件以及开始运行打包脚本
|
|--lib              x-pack打包脚本文件夹
|   |
|   |- Compiler.js  将项目中的源码打包
|   |- main.ejs     项目中打包生成文件的模板
|
|--node_modules     项目依赖
|
|--package.json     项目依赖配置
|
|--readME.md        项目说明文件
|

pack.json

新建一个文件夹,npm init 初始化之后,主要配置bin字段,如下:

 "bin": {
    "x-pack": "bin/pack.js"
  },

然后在终端 cd到工程根目录,跑如下命令,将 x-pack 命令link到全局

npm link

pack.js

x-pack打包工具的入口文件,在顶部添加如下,声明该文件用什么环境去运行,这里是用node环境运行该js文件

#!/usr/bin/env node

入口文件代码相对简单,主要做了两件事

  • 读取打包工程中的配置文件,类似webpack需要读取 webpack.config.js文件一样,这里x-pack 的配置文件是 xpack.config.js
  • 实例化打包运行脚本,并运行
    整体代码如下
#!/usr/bin/env node
// 1:需要找到当前执行名的路径, 拿到 xpack.config.js
let path = require('path');
// config 配置文件
let config = require(path.resolve('xpack.config.js'));
const Compiler = require('../lib/Compiler');
let compiler = new Compiler(config);
// 标识运行编译代码
compiler.run();

main.ejs

使用ejs模板定义了打包之后的文件模板。整个模本和webpack 一个套路

Compiler.js

x-pack的核心文件,主要是根据配置来对代码进行打包,下面就一点一点的来讲解这部分代码吧。
先是构造函数 constructor, 在实例上保存读取的配置文件、获取当前工程在磁盘上的绝对路径,以及新建变量用于存储模块间的依赖关系

 constructor(config){
        //在实例上保存读取的配置文件
        this.config = config
     	//保存入口文件名称
        this.entryId;
          // 保存所有的模块依赖
        this.modules = {};
         // 保存入口文件的路径
        this.entry = config.entry;
        //  获取 运行 x-pack 的工程路径
        this.root = process.cwd();
    }

之后是run 方法,根据前面的分析,run 主要会做两件事情即:

  • 执行,并创建模块间的依赖关系
  • 发射一个文件,打包后的文件
 run(){
     	// 根据入口文件建模块间的依赖关系
        this.buildModule(path.resolve(this.root, this.entry), true);
    	// 发射打包完成之后的文件到输出目录
        this.emitFile()
    }

buildModule:,从入口文件开始分析模块之间的依赖关系,这个函数调用了 getSource函数和parse函数。
其中getSource这个函数是根据模块的路径读取该路径对应的源码,并看配置文件中有没有设置的loader,如果有就对该文件进行规则解析。
parse函数主要是对文件进行 ast语法解析。下面会详细讲解该函数。
buildModule函数主要是分析模块间的依赖关系,并且记录配置文件中配置的打包入口文件。如果模块中有其他依赖 比如 解析 A 文件 A文件可能又会引入 B文件,也会递归解析。
最终会将解析的结果放入 this.modules这个对象中,其中key是模块在项目中的相对路径,value是模块的源码内容。

buildModule(modulePath, isEntry){
        // 拿到模块的内容
        let source = this.getSource(modulePath);
        // 模块 id modulePath(总路径) - this.root(工作路径) = 文件的相对路径
        // get path like ./src/index.js
        let moduleName = './' + path.relative(this.root, modulePath);
        if(isEntry ){
            this.entryId = moduleName; // 保存入口的名字
        }
        // 需要把 source 源码进行改造,并返回一个依赖列表 params2: .src
        let { sourceCode, dependencies } = this.parse(source, path.dirname(moduleName));
        // 路径名和文件内容对应起来
        // 得到类似 './src/a.js': 模块内容 的映射关系
        this.modules[moduleName] = sourceCode;
        // 解析 A 文件 A文件可能又会引入 B文件,所以需要递归一下
        // 附模块的加载 递归加载
        dependencies.forEach(dep => {
            this.buildModule(path.join(this.root, dep), false);
        })
    }

getSource方法:如上所述,被 buildModule 方法引用,主要是根据路径读取文代码内容,并查看 xpack.config.js 配置文件中有没有针对所读文件设置符合规则的 loader,如果有的话就用对应的loader方法去解析文件内容。在这个demo中配置了 less的文件,会将less文件解析为css文件,并append到header之中。最后会返回被所有loader处理之后的代码内容。

// 通过路径,读取该路径对应文件的源码
    getSource(modulePath){

        // 获取配置中的规则
        let rules = this.config.module.rules;
        let content = fs.readFileSync(modulePath, 'utf8');

        // 获取每一个规则,分别取处理
        for (let i =0; i < rules.length; i ++) {
            let rule = rules[i];
            let { test, use } = rule;
            let len = use.length - 1;
            // 读取的文件和配置规则能匹配上,则用对应的loader去处理该文件
            if(test.test(modulePath)){
                 const normalLoader = ()=> {
                    // 先获取对应的最后一个 loader函数
                    let loader = require(use[len--]);
                    content = loader(content)
                    if(len >= 0){
                        normalLoader();
                    }
                }
                normalLoader();
            }
        }

        return content
    }

parse 方法:该方法主要是对根据路径读取出文件源码内容,并对源码内容进行 AST语法解析。(这里假设你对AST有一定的了解)
其中会用到 几个AST 的包如下:
babylon npm 包,其作用是讲源码部分进行 AST语法抽象树的解析
traverse npm 包, 其作用是遍历 AST 语法树
generator npm 包, 其作用是替换好的结果再生成代码
比较重要的是npm 包两点
1:会解析代码中有没有用到 require这个关键字,如果有的话,我们就会将其方法名替换为 __xpack_require__类似于 webpack中的__webpack_require__方法。
2:解析的模块中有没有依赖别的模块,如果有就push到dependencies数组中,返回一个依赖列表。最终这个函数返回的是被改造之后的源码内容和该模块的依赖列表。
有几个AST的要点如下:
替换函数名称,我们可以在AST解析的过程中查看当前的方法名是什么,并且可以更改该方法名称。而这个参数在节点中的 callee.name上。如下:
在这里插入图片描述
所以整个 parse的源码如下:

 parse(source, parentPath){
        // 将文件内容解析为 AST
        let ast = babylon.parse(source);
        // 依赖的数组
        let dependencies = [];

        // 遍历 AST 语法树
        traverse(ast, {
            // 调用表达式 a(), require(),都是调用表达式
            // 只需要处理 require()这个调用表达式
            CallExpression(p){
                let node = p.node;
                if(node.callee.name === 'require'){
                    node.callee.name = '__xpack_require__';
                    // 取到模块的引用其他模块名字;
                    // 即 AST 解析 A文件源码,A文件require了B文件,得到B文件相对路径文件名
                    let moduleName = node.arguments[0].value;
                    // 得到类似 ./a.js
                    moduleName = moduleName + (path.extname(moduleName) ? '' : 'js');
                    // 得到类似 ./src/a.js;
                    moduleName = './' + path.join(parentPath, moduleName);
                    // A 文件依赖其他模块的依赖数组
                    dependencies.push(moduleName);
                    node.arguments = [t.stringLiteral(moduleName)];
                }
            }
        });

        let sourceCode = generator(ast).code;
        return { sourceCode, dependencies}
    }

emitFile函数对应做的事情就是将使用loader处理完的源码(如果有配置对应的规则),和进行AST解析修改之后的代码使用ejs模板渲染完之后写入到一个文件之中,并发射到配置的出口路径中。

emitFile(){
        // 拿到输出到那个目录下
        let main = path.join(this.config.output.path, this.config.output.filename);
        // 根据模板的路径得到模板的内容
        let templateStr = this.getSource(path.join(__dirname, 'main.ejs'));
        // 模板字符串通过ejs模板渲染之后就是一个代码块
        let code = ejs.render(templateStr, {
            entryId:this.entryId,
            modules:this.modules
        });
        this.assets = {};
        // 资源中,路径对应的代码
        this.assets[main] = code;
        fs.writeFileSync(main, code);
    }

这样一来我们就将工程中的代码仿照 webpac 实现了 x-pack 的打包了。
下面看一下打包之后的效果,在x-pack-demo这个项目中配置了 xpack.config.js,配置的入口文件为 /src/index.js出口文件为 dist文件夹下面的 x-pack.js
引入了less,样式很简单,改变了body的背景色,字体颜色和字体大小,
未打包之前dist文件夹下没有x-pack.js这个打包文件。
运行 x-pack打包命令之后,就会在dist文件夹下生成 x-pack.js 文件,再建立一个html 引入这个打包之后的js,我们可以发现页面的body部分全部变红色了,证明引入的less进过loader处理之后变为css了,字体大小和字体颜色也变了,控制台也输出了 ab的字符串。
效果如下:
在这里插入图片描述

发布了54 篇原创文章 · 获赞 50 · 访问量 5万+

猜你喜欢

转载自blog.csdn.net/weixin_38080573/article/details/98489666