前言
我研究 Solid.js
源码已经有一段时间了,在钻研的过程中我发现了其中的一些迷惑行为,在搞懂之后终于恍然大悟,忍不住想要分享给大家。不过这么说其实也不太准确,因为在严格意义上来讲 Solid.js
其实是被划分为了两个部分的。我只认真钻研了其中一个部分,所以也不能说钻研 Solid.js
源码,因为另外一个部分压根就不叫 Solid
。
两部分
有些同学看到这可能就会感到疑惑了,哪两个部分?Solid
、.js
?其实是这样:大家应该都听说过 Solid.js
是一个重编译、轻运行的框架吧,所以它可以被分为编译器和运行时两个部分。
那有人可能会问:你要是这么说的话那岂不是 Vue
也可以被分为两部分,毕竟 Vue
也有编译器和运行时,为什么从来没有人说过 Vue
是两部分组成的呢?是这样,Vue
的编译器和运行时全都放在了同一仓库内的 Monorepo
中:
你可以说 Vue2
和 Vue3
是两个部分,因为它俩被放在了两个不同的仓库中:
虽然它俩已经是两个不同的仓库了,但好歹也都是 vuejs
名下的吧:
而 Solid.js
的两部分不仅不在同一个仓库内,甚至连组织名都不一样:
一个是 solidjs/solid:
而另一个则是 ryansolid/dom-expressions:
ryan
是 Solid.js
作者的名字,所以 ryan
+ solid
= ryansolid
(有点迷,为啥不放在 solidjs
旗下非要单独开一个 ryansolid
)
这个 dom-expressions
就是 Solid.js
的编译器,那为啥不像 Vue
编译器似的都放在同一个仓库内呢?因为 Vue
的编译器就是专门为 Vue
设计的,你啥时候看非 Vue
项目中使用 xxx.vue
这样的写法过?
.vue
这种单文件组件就只有 Vue
使用,虽说其他框架也有单文件组件的概念并且有着类似的写法(如:xxx.svelte
)但人家 Svelte
也不会去用 Vue
的编译器去编译人家的 Svelte
组件。不过 Solid
不一样,Solid
没自创一个 xxx.solid
,而是明智的选择了 xxx.jsx
。
SFC VS JSX
单文件组件和 jsx
各有利弊,不能说哪一方就一定比另一方更好。但对于一个声明式框架作者而言,选择单文件组件的好处是可以自定义各种语法,并且还可以牺牲一定的灵活性来换取更优的编译策略。缺点就是成本太高了,单单语法高亮和 TS
支持度这一方面就得写一个非常复杂的插件才能填平。
好在 Vue
的单文件组件插件 Volar
已经可以支持自定义自己的单文件组件插件了,这有效的降低了框架作者的开发成本。但 Solid
刚开始的时候还没有 Volar
呢(可以去看看 Volar
的源码有多复杂 这还仅仅只是一个插件就需要花费那么多时间和精力),甚至直到现在 Volar
也没个文档,就只有 Vue
那帮人在用 Volar
(毕竟是他们自己研究的):
并且人家选择 jsx
也有可能并非是为了降低开发成本,而是单纯的钟意于 jsx
语法而已。那么为什么选择 jsx
会降低开发成本呢?首先就是不用自己写 parser
、generator
等一堆编译相关的东西了,一个 babel
插件就能识别 jsx
语法。语法高亮、TS
支持度这方面更是不用操心,甚至用户都不需要为编辑器安装任何插件(何时听过 jsx
插件)。
并且由于 React
是全球占有率最高的框架,jsx
已被广泛接受(甚至连 Vue
都支持 jsx
)但如果选择单文件组件的话又会产生有人喜欢这种写法有人喜欢那种写法的问题,比方说同样使用 sfc
的 Vue
和 Svelte
,if-else
写法分别是这样:
<template>
<h1 v-if="xxx" />
<div v-else />
</template>
{#if xxx}
<h1 />
{:else}
<div />
{/if}
有人喜欢上面那种写法就有人喜欢下面那种写法,众口难调,无论选择哪种写法可能都会导致另一部分的用户失望。而 jsx
就灵活的多了,if-else
想写成什么样都可以根据自己的喜好来:
if (xxx) {
return <h1 />
} else {
return <div />
}
// 或者
return xxx ? <h1 /> : <div />
// 亦或
let Title = 'h1'
if (xxx) Title = 'div'
return <Title />
jsx
最大程度的融合了 js
,正是因为它对 js
良好的兼容性才导致它的适用范围更广,而不是像 Vue
、Svelte
那样只适用于自己的框架。
毕竟每种模板语言的 if-else
、循环等功能写法都不太一样,当然 jsx
里的 if-else
也可以有各种千奇百怪的写法,但毕竟还是 js
写法,而不是自创的 ng-if
、v-else
、{:else if}
{% for i in xxx %}
等各种不互通的写法。
正是由于 jsx
的这个优势导致了很多非 React
框架(如:Preact、Stancil、Solid 等)用 jsx
也照样用的飞起,那么既然 jsx
可以不跟 React
绑定,那 Ryan
自创的 jsx
编译策略也同样可以不跟 Solid
绑定啊对不对?
这是一款可以和 Solid.js
搭配使用的 babel
插件,也同样是一款可以和 MobX
、和 Knockout
、和 S.js
、甚至和 Rx.js
搭配使用的插件,只要你有一款响应式系统,那么 dom-expressions
就可以为你提供 jsx
服务。
Solid.js
所以这才是 Ryan
没把 dom-expressions
放在 solidjs/solid 里的重要原因之一,但 Solid.js
又是一个注重编译的框架,没了 dom-expressions
还不行,所以只能说 Solid.js
是由两部分组成的。
DOM Expressions
DOM Expressions
翻译过来就是 DOM
表达式的意思,有人可能会问那你标题为啥不写成《盘点 DOM Expressions 源码中的那些迷惑行为》
?拜托!谁知道 DOM Expressions
到底是个什么鬼!
如果不是我苦口婆心的说了这么多,有几个能知道这玩意就是 Solid.js
的编译器,甭说国内了,就连国外都没几个知道 DOM Expressions
的。你要说 Solid.js
那别人可能会竖起大拇指说声 Excellent
,但你要说 DOM Expressions
那别人说的很可能就是 What the fuck is that?
了。不信你看它俩的🌟对比:
再来看看 Ryan
在油管上亲自直播 DOM Expressions时的惨淡数据:
这都没我随便写篇文章的点赞量高,信不信如果我把标题中的 Solid.js
换成了 DOM Expression
的话点赞量都不会有 Ryan
直播的数据好?好歹人家还是 Solid
的作者,都只能获得如此惨淡的数据,那更别提我了。
言归正传,为了防止大家不知道 Solid.js
编译后的产物与 React
编译后的产物有何不同,我们先来写一段简单的 jsx
:
import c from 'c'
import xxx from 'xxx'
export function Component () {
return (
<div a="1" b={2} c={c} onClick={() => {}}>
{ 1 + 2 }
{ xxx }
</div>
)
}
React
编译产物:
import c from 'c';
import xxx from 'xxx';
import { jsxs as _jsxs } from "react/jsx-runtime";
export function Component() {
return /*#__PURE__*/_jsxs("div", {
a: "1",
b: 2,
c: c,
onClick: () => {},
children: [1 + 2, xxx]
});
}
Solid
编译产物:
import { template as _$template } from "solid-js/web";
import { delegateEvents as _$delegateEvents } from "solid-js/web";
import { insert as _$insert } from "solid-js/web";
import { setAttribute as _$setAttribute } from "solid-js/web";
const _tmpl$ = /*#__PURE__*/_$template(`<div a="1" b="2">3`);
import c from 'c';
import xxx from 'xxx';
export function Component() {
return (() => {
const _el$ = _tmpl$(),
_el$2 = _el$.firstChild;
_el$.$$click = () => {};
_$setAttribute(_el$, "c", c);
_$insert(_el$, xxx, null);
return _el$;
})();
}
_$delegateEvents(["click"]);
Solid
编译后的产物乍一看有点不太易读,我来给大家写一段伪代码,用来帮助大家快速理解 Solid
到底把那段 jsx
编译成了啥:
import c from 'c';
import xxx from 'xxx';
const template = doucment.createElement('template')
template.innerHTML = '<div a="1" b="2">3</div>'
const el = template.content.firstChild.cloneNode(true) // 大家可以简单的理解为 el 就是 <div a="1" b="2">3</div>
export function Component() {
return (() => {
el.onclick = () => {};
el.setAttribute("c", c);
el.insertBefore(xxx);
return el;
})();
}
这样看上去就清晰多了吧?直接编译成了真实的 DOM
操作,这也是它性能为何能够如此强悍的原因之一,没有中间商(虚拟DOM)赚差价。但大家有没有感觉有个地方看起来好像有点多此一举,就是那个自执行函数:
export function Component() {
return (() => {
el.onclick = () => {};
el.setAttribute("c", c);
el.insertBefore(xxx);
return el;
})();
}
为何不直接编译成这样:
export function Component() {
el.onclick = () => {};
el.setAttribute("c", c);
el.insertBefore(xxx);
return el;
}
效果其实都是一样的,不信你试着运行下面这段代码:
let num = 1
console.log(num) // 1
num = (() => {
return 1
})()
console.log(num) // 还是 1 但感觉多了一个脱裤子放屁的步骤
看了源码才知道,原来看似多此一举的举动实则是有苦衷的。因为我们这是典型的站在上帝视角来审视编译后的代码,源码的做法是只对 jsx
进行遍历,在刚刚那种情况下所编译出来的代码确实不是最优解,但它能保证在各种的场景下都能正常运行。
我们来写一段比较罕见的代码大家就能明白过来怎么回事了:
if (<div a={value} onClick={() => {}} />) {
// do something…
}
当然这么写没有任何的意义,这是为了帮助大家理解为何 Solid
要把它的 jsx
编译成一段自执行函数才会写成这样的。我们来写一段伪代码,实际上 Solid
编译出来的并不是这样的代码,但相信大家能够明白其中的含义:
<div a={value} onClick={() => {}} />
// 将会被编译成
const el = document.createElement('div')
el.setAttribute('a', value)
el.onclick = () => {}
发现问题所在了么?原本 jsx
只有一行代码,但编译过后却变成三行了。所以如果不加一个自执行函数的话将会变成:
if (const el = document.createElement('div'); el.setAttribute('a', value); el.onclick = () => {}) {
// do something…
}
这很明显是错误的语法,if
括号里根本不能写成这样,会报错的!但如果把 if
括号里的代码放在自执行函数中那就没问题了:
if ((() => {
const el = document.createElement('div')
el.setAttribute('a', value)
el.onclick = () => {}
return el
})()) {
// do something…
}
我知道肯定有人会说把那三行代码提出去不就得了么:
const el = document.createElement('div')
el.setAttribute('a', value)
el.onclick = () => {}
if (el) {
// do something…
}
还记得我之前说过的那句:我们是站在上帝视角来审判 Solid
编译后代码的么?理论上来说这么做确实可以,但编译成本无疑会高上许多,因为还要判断 jsx
到底写在了哪里,根据上下文的不同来生成不同的代码,但这样肯定没有只编译 jsx
而不管 jsx
到底是被写在了哪里来的方便。而且我们上述的那种方式也不是百分百没问题的,照样还是会有一些意想不到的场景:
for (let i = 0, j; j = <div a={i} />, i < 3; i++) {
console.log(j)
}
但假如按照我们那种策略来编译代码的话:
const el = document.createElement('div')
el.setAttribute('a', i)
for (let i = 0, j; j = el, i < 3; i++) {
console.log(j)
}
此时就会出现问题,因为 el
用到了变量 i
,而 el
又被提到外面去了所以访问不到 i
变量,所以 el
这几行代码必须要在 jsx
的原位置上才行,只有自执行函数能够做到这一点。由于 js
是一门极其灵活的语言,各种骚操作数不胜数,所以把编译后的代码全都加上一段自执行函数才是性价比最高并且最省事的选择之一。
迷之叹号❗️
有次在用 playground.solidjs.com 编译 jsx
时惊奇的发现:
不知大家看到这段 <h1>Hello, <!>!</h1>
时是什么感受,反正我的第一感觉就是出 bug
了,把我的叹号 !
给编译成 <!>
了。
但令人摸不着头脑的是,这段代码完全可以正常运行,没有出现任何的 bug
。随着测试的深入,发现其实并不是把我的叹号 !
给编译成 <!>
了,只是恰巧在那个位置上我写了个叹号,就算不写叹号也照样会有这个 <!>
的:
发现没?<!>
出现的位置恰巧就是 {xxx}
的位置,我们在调试的时候发现最终生成的代码其实是这样:
<h1>1<!---->2</h1>
也就是说当我们 .innerHTML = '<!>'
的时候其实就相当于 .innerHTML = ''
了,很多人看到这个空注释节点以后肯定会联想到 Vue
,当我们在 Vue
中使用 v-if="false"
时,按理说这个节点就已经不复存在了。但每当我们打开控制台时就会看到原本 v-if
的那个位置变成了这样:
尤雨溪为何要留下一个看似没有任何意义的空注释节点呢?广大强迫症小伙伴们忍不了了,赶忙去 GitHub
里开个 issue 问尤雨溪:
尤雨溪给出的答案是这样:
那 Solid
加一个这玩意也是和 Vue
一样的原由么?随着对源码的深入,我发现它跟 Vue
的 原由并不一样,我们再来用一段伪代码来帮助大家理解
Solid
为什么需要一段空注释节点:
<h1>1{xxx}2</h1>
// 将会被编译成:
const el = template('<h1>12</h1>')
const el1 = el.firstChild // 1
const el2 = el1.nextSibling //
const el3 = el2.nextSibling // 2
// 在空节点之前插入 xxx 而空节点恰好就在 1 2 之间 所以就相当于在 1 2 之间插入了 xxx
el.insertBefore(xxx, el2)
看懂了么,Solid
需要在 1
和 2
之间插入 xxx
,如果不加这个空节点的话那就找不到该往哪插了:
<h1>1{xxx}2</h1>
// 假如编译成没有空节点的样子:
const el = template('<h1>12</h1>')
const el1 = el1.firstChild // 12
const el2 = el2.nextSibling // 没有兄弟节点了 只有一个子节点:12
el.insertBefore(xxx, 特么的往哪插?)
所以当大家在 playground.solidjs.com 中发现有 <!>
这种奇怪符号时,请不要觉得这是个 bug
,这是为了留个占位符,方便 Solid
找到插入点。只不过大多数人都想不到,把这个 <!>
赋值给 innerHTML
后会在页面上生成一个 <!---->。
迷之 ref
无论是 Vue
还是 React
都是用 ref
来获取 DOM
的,Solid
的整体 API
设计的与 React
较为相似,ref
自然也不例外:
但它也有自己的小创新,就是 ref
既可以传函数也可以传普通变量。如果是函数的话就把 DOM
传进去,如果是普通变量的话就直接赋值:
// 伪代码
<h1 ref={title} />
// 将会编译成:
const el = document.createElement('h1')
typeof title === 'function'
? title(el)
: title = el
但在查看源码时发现了一个未被覆盖到的情况:
// 简化后的源码
transformAttributes () {
if (key === "ref") {
let binding,
isFunction =
t.isIdentifier(value.expression) &&
(binding = path.scope.getBinding(value.expression.name)) &&
binding.kind === "const";
if (!isFunction && t.isLVal(value.expression)) {
...
} else if (isFunction || t.isFunction(value.expression)) {
...
} else if (t.isCallExpression(value.expression)) {
...
}
}
}
稍微给大家解释一下,这个 transformAttributes
是用来编译 jsx
上的属性的:
当 key
等于 ref
时需要进行一些特殊处理,非常迷的一个命名就是这个 isFunction
,看名字大家肯定会认为这个变量代表的是属性值是否为函数。我来用人话给大家翻译一下这个变量赋的值代表什么含义:t.isIdentifier(value.expression)
的意思是这个 value
是否为变量名:
比方说 ref={a}
中的 a
就是个变量名,但如果是 ref={1}
、ref={() => {}}
那就不是变量名,剩下那俩条件是判断这个变量名是否是 const
声明的。也就是说:
const isFunction = value 是个变量名 && 是用 const 声明的
这特么就能代表 value
是个 function
了?
在我眼里看来这个变量叫 isConst
还差不多,我们再来梳理一下这段逻辑:
// 简化后的源码
transformAttributes () {
if (key === "ref") {
const isConst = value is 常量
if (!isConst && t.isLVal(value.expression)) {
...
} else if (isConst || t.isFunction(value.expression)) {
...
} else if (t.isCallExpression(value.expression)) {
...
}
}
}
接下来就是 if-else
条件判断里的条件了,再来翻译下,t.isLVal
代表的是:value
是否可以放在等号左侧,这是什么意思呢?一个例子就能让大家明白:
// 此时 key = 'ref'、value = () => {}
<h1 ref={() => {}} />
// 现在我们需要写一个等号 看看 value 能不能放在等号的左侧:
() => {} = xxx // 很明显这是错误的语法 所以 t.isLVal(value.expression) 是 false
// 但假如写成这样:
<h1 ref={a.b.c} />
a.b.c = xxx // 这是正确的语法 所以 t.isLVal(value.expression) 现在为 true
明白了 t.isLVal
接下来就是 t.isFunction
了,这个从命名上就能看出来是判断是否为函数的。然后就是 t.isCallExpression
,这是用来判断是否为函数调用的:
// 这就是 callExpression
xxx()
翻译完了,接下来咱们就来分析一遍:
当
value
不是常量并且不能放在等号左侧时(这种情况有处理)当
value
是常量或者是一个函数字面量时(这种情况有处理)当
value
是一个正在调用的函数时(这种情况有处理)
不知大家看完这仨判断后有什么感悟,反正当我捋完这段逻辑的时候感觉有点迷,因为好像压根儿就没覆盖掉全部情况啊!咱们先这么分一下:value
肯定是变量名、字面量以及常量中的其中一种对吧?是常量的情况下有覆盖,不是常量时就有漏洞了,因为它用了个并且符号 &&
,也就是说当 value
不是常量时必须还要同时满足不能放在等号左侧这种情况才会进入到这个判断中去,那假如我们写一个三元表达式或者二元表达式那岂不就哪个判断也没进么?不信我们来试一下:
可以看到编译后的 abc
三个变量直接变暗了,哪都没有用到这仨变量,也就是说相当于吞掉了这段逻辑(毕竟哪个分支都没进就相当于没处理)不过有人可能会感到疑惑,三元表达式明明能放到等号左侧啊:
实际上并不是你想的那样,等号和三元表达式放在一起时有优先级关系,调整一下格式你就明白是怎样运行的了:
const _tmpl$ = /*#__PURE__*/_$template(`<h1>Hello`)
a ? b : c = 1
// 实际上相当于
a
? b
: (c = 1)
// 相当于
if (a) {
b
} else {
c = 1
}
如果我们用括号来把优先级放在三元这边就会直接报错了:
二元表达式也是同理:
我想在 ref
里写成这样没毛病吧:
<h1 ref={a || b} />
虽然这种写法比较少见,但这也不是你漏掉判断的理由呀!毕竟好多用 Solid.js
的人都是用过 React
的,他们会把在 React
那养成的习惯不自觉的带到 Solid.js
里来,而且这不也是 Solid.js
把 API
设计的尽可能与 React
有一定相似性的重要原因之一吗?
但人家在 React
没问题的写法到了你这就出问题了的话,是会非常影响你这框架的口碑的!而且在文档里还没有提到任何关于 ref
不能写表达式的说明:
后来我仔细想了一下,发现还真不是他们不小心漏掉的,而是有意为之。至于为什么会有意为之那就要看它编译后的产物了:
// 伪代码
<div ref={a} />
// 将会被编译为:
const el = template(`<div>`)
typeof a === 'function' ? a(el) : a = el
其中咱们重点看 a = el
这段代码,a
就是我们写在 ref
里的,但假如我们给它换成一个二元表达式就会变成:
// 伪代码
<div ref={a || b} />
// 将会被编译为:
const el = template(`<div>`)
a || b = el
a || b
不能放在等号左侧,所以源码中的 isLVal
就是为了过滤这种情况的。那为什么不能编译成:
(a = el) || (b = el)
这么编译是错的,因为假如 a
为 false
,a
就不应该被赋值,但实际上 a
会被赋值为 el
:
所以要把二元编译成三元:
如果是并且符号就要编译成取反:
// 伪代码
<div ref={a && b} />
// 将会被编译为:
const el = template(`<div>`)
!a ? a = el : b = el
然后三元表达式以及嵌套三元表达式:
<div
ref={
Math.random() > 0.5
? refFactory() && refArr[0] && (refTarget1 = refTarget2) && (refTarget1 > refTarget2)
: refTarget1
? refTarget2
: refTarget3
}
/>
当然可能并不会有人这么写,Solid
那帮人也是这么想的,所以就算了,太麻烦了,如果真要是有复杂的条件的话可以用函数:
<div
ref={
el => Math.random() > 0.5
? refTarget1 = el
: refTarget2 = el
}
/>
就先不管 isLVal
为 false
的情况了,不过我还是觉得至少要在官网上提一嘴,不然真有人写成这样的时候又搜不到答案的话那多影响口碑啊!
总结
看过源码之后感觉有的地方设计的很巧妙,但有些地方又不是很严谨。也怪 jsx
太灵活了,不可能做判断把所有情况都做到面面俱到,当你要写一些在 React
里能运行的骚操作可能在 Solid
里就哑火了。
模板 VS JSX
所以说模版语法和 jsx
各有千秋,同为无虚拟 DOM
框架的 Svelte
就依靠着灵活性受限的模版语法才能编译成命令式 DOM
操作,而 Solid
的 jsx
也已经不再是那个异常灵活的 jsx
了,而是有一定的限制以及在某些情况下有 bug
的阉割版。
不过你要是问我喜欢哪个,我还是更喜欢 jsx
,但如果是阉割版 jsx
的话… 我可能就更喜欢模版语法了,因为我在 jsx
里还是有挺多骚操作的,不想骚着骚着发现不好使结果又搜不到答案只能去看源码,所以莫不如用只有固定几个用法的模板语法。
那有人可能会讲:你就老老实实的不写太骚的
jsx
不就得了?
怎么说呢,就像是你买了一个新键盘,你难免会把以前在键盘上的那些习惯带到这个新键盘上来吧?结果这个新键盘是阉割版的,某几个不常用的键有 bug
,但说明书上还没写,出了 bug
你还纳闷呢!
所以在这种情况下莫不如买一个缺了键的键盘,哪怕说不能按,但也比按了出 bug
强吧!毕竟你以前的打字习惯还在呢,缺的键会限制你的打字习惯,你自然而然的就会想出一些替代方案,而不限制你打字习惯的带 bug
键盘必然坑你坑的更惨。
其实也没有说哪个功能 jsx
能实现但模版就实现不了的,顶多就是稍微麻烦点。