盤點 Solid-js 源碼中的那些迷惑行爲
前言
我研究 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
能實現但模版就實現不了的,頂多就是稍微麻煩點。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/O6WyKHxqfEoVkDmIUXmdxg