前言
我研究 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} notallow={() => {}} />
// 将会被编译成
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 里就哑火了。
文章版权声明
1 原创文章作者:2502,如若转载,请注明出处: https://www.52hwl.com/29906.html
2 温馨提示:软件侵权请联系469472785#qq.com(三天内删除相关链接)资源失效请留言反馈
3 下载提示:如遇蓝奏云无法访问,请修改lanzous(把s修改成x)
4 免责声明:本站为个人博客,所有软件信息均来自网络 修改版软件,加群广告提示为修改者自留,非本站信息,注意鉴别