halo 大家好,俺是 132,今天给大家带来的是俺课余时间开的一个新坑,暂定名称为 pak,是一个全新的 JavaScript 打包器
前情提要
今年已经回归学生生活,有一些时间用来研究和尝试,所以是时候开一个坑了
如果说 fre 是俺大学的代表作,那研究生到底写个啥好呢?其实也没啥能写的,最终决定写一个rust的打包器,但和 rolldown 之类的不同,俺追求的不是极致 tree shaking,而是小而美
之前研究了很久,怎样在 rust 的世界里,用 1000 行实现一个 JavaScript parser,最终被我找到了方法,听我一一道来
最简单的打包器算法
打包器算法有很多,但总体上分为两类,一类是将 esm 打包成 cjs,一类是打包成单闭包函数(通常配合 scope-hoist)
parcel 作者写了一篇文章,里面也提到了这两种模式,目前 parcel 是不喜欢第二种的,也开始认为 scope hoist 的意义很小
为了简化整个流程,俺选择了后者,因为前者还需要模拟 cjs 的运行才行
但,和 rollup 不同的时候,不需要 magic-string,tree shaking 也不需要做,而是进行简单的拼接和重命名,最终 tree shaking 由 minifier 做
//input
import { hello } from './hello.js'
function world() {
return 'World'
}
console.log(hello)
console.log(world())
//output
(function (global) {
const P$hello_js$hello = 'hello';
const P$index_js$world = () => console.log("world");
console.log(P$hello_js$hello);
console.log(P$index_js$world());
})(typeof global !== 'undefined' ? global : typeof window !== 'undefined' ? window : this);
这可以说是世界上最简单的打包算法了,基本上可以算作是 rollup 的终极简化版
最小的 JavaScript parser
过去我一直在寻找 1000 行的 JavaScript 思路,终于还是被我找到了
传统的 parser 是大体上分为,lexer(token)+parser(ast)的过程,比如 swc 和 oxc,都是传统编译器的代表,它们通过传统的拆分 token,然后生成一棵 estree 兼容的树
这好吗,这不好,这样的工作量也不低
很久以前,我发现了一种新的 parser 思路,也就是基于 combinator 的方法
如上,这是之前通过组合子解析 js 中 jsx 的一段代码,最终生成一个嵌套数组
通俗来讲,就是不生成 ast 树,而是生成一个“嵌套数组”
从结构层面来讲,其实很容易理解
const a = 0;
如果是传统的 estree,可能是这样的
{
"type": "VariableDeclaration",
"start": 0,
"end": 11,
"declarations": [
{
"type": "VariableDeclarator",
"start": 6,
"end": 11,
"id": {
"type": "Identifier",
"start": 6,
"end": 7,
"name": "a"
},
"init": {
"type": "Literal",
"start": 10,
"end": 11,
"value": 0,
"raw": "0"
}
}
],
"kind": "const"
}
但是基于组合子的 parser 则会生成一个数组
[const, [a = 0]]
这样一来,所有的事情都会变简单,rust 的 vec 结构也有类型,其实约等于也是一棵 ast 树了
如此,就可以做到 1000 行以内的 JavaScript parser 了
- 优点是代码量少,好维护,而且 vec 结构在 rust 中也比较容易遍历和修改
- 缺点是不符合 estree 标准,而且没有位置信息(有时也很重要)
内置 fre-compiler
现在的前端框架,基本上都转编译了,solidjs,react-compiler 之类的
但我仍旧发现了一个兼容 react 语法的新的编译思路,比如
export default () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
会编译为
// 卸载逻辑(作为marker)
const unmount = (ctx) => {
removeEventListener(ctx.el, "click", ctx.clickHandler);
};
export default () => {
const [count, setCount] = useState(0);
return (ctx) => {
// DOM缓存与复用逻辑
if (ctx.marker !== unmount) {
// 首次渲染或标记不匹配:清理旧元素并创建新元素
ctx.marker && ctx.marker(ctx);
ctx.el = createElement("button");
ctx.clickHandler = () => setCount(c => c + 1);
addEventListener(ctx.el, "click", ctx.clickHandler);
ctx.marker = unmount;
}
// 更新
ctx.el.textContent = count;
return ctx.el;
};
};
这种编译思路本质上是通过 marker 的缓存和比对,实现 dom 的创建更新和销毁
- 优点是可以完全兼容 react 和 fre 的语法,不需要修改语义
- 缺点是,组件仍然需要重新渲染
未来这会作为 fre-compiler 的主要渲染思路,用来将 fre 项目去 vdom 化
同时也会内知道 pak 打包器中,这也是俺费尽心思写一个打包器的终极原因
总结
总而言之,就是继 fre 以来,俺终于又开了一个大坑,为了满足 fre-compiler 的夙愿,专门写了一个小而美的打包器
当然,不得不吐槽一下 rust,俺个人其实很不适应写 rust 代码,基本上全程和编译器斗智斗勇
但是没办法,只能说 rust 真的很适合写编译器,趁着这个项目,研究更多的编译技巧和 rust 的编码技巧吧
最后放一个俺的微信号:shibubyayuri
大家欢迎加俺交流哈