模拟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的字符串。
效果如下: