盤點 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 會降低開發成本呢?首先就是不用自己寫 parsergenerator 等一堆編譯相關的東西了,一個 babel 插件就能識別 jsx 語法。語法高亮、TS 支持度這方面更是不用操心,甚至用戶都不需要爲編輯器安裝任何插件 (何時聽過 jsx 插件)。

並且由於 React 是全球佔有率最高的框架,jsx 已被廣泛接受(甚至連 Vue 都支持 jsx)但如果選擇單文件組件的話又會產生有人喜歡這種寫法有人喜歡那種寫法的問題,比方說同樣使用 sfc 的 Vue 和 Svelteif-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 良好的兼容性才導致它的適用範圍更廣,而不是像 VueSvelte 那樣只適用於自己的框架。

畢竟每種模板語言的 if-else、循環等功能寫法都不太一樣,當然 jsx 裏的 if-else 也可以有各種千奇百怪的寫法,但畢竟還是 js 寫法,而不是自創的 ng-ifv-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 不是常量時必須還要同時滿足不能放在等號左側這種情況纔會進入到這個判斷中去,那假如我們寫一個三元表達式或者二元表達式那豈不就哪個判斷也沒進麼?不信我們來試一下:

可以看到編譯後的 abc 三個變量直接變暗了,哪都沒有用到這仨變量,也就是說相當於吞掉了這段邏輯(畢竟哪個分支都沒進就相當於沒處理)不過有人可能會感到疑惑,三元表達式明明能放到等號左側啊:

實際上並不是你想的那樣,等號和三元表達式放在一起時有優先級關係,調整一下格式你就明白是怎樣運行的了:

const _tmpl$ = /*#__PURE__*/_$template(`<h1>Hello`)
a ? b : c = 1
// 實際上相當於
a
  ? b
  : (c = 1)
// 相當於
if (a) {
  b
} else {
  c = 1
}

如果我們用括號來把優先級放在三元這邊就會直接報錯了:

二元表達式也是同理:

我想在 ref 裏寫成這樣沒毛病吧:

<h1 ref={|| 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={|| b} />
// 將會被編譯爲:
const el = template(`<div>`)|| b = el

a || b 不能放在等號左側,所以源碼中的 isLVal 就是爲了過濾這種情況的。那爲什麼不能編譯成:

(a = el) || (b = el)

這麼編譯是錯的,因爲假如 a 爲 falsea 就不應該被賦值,但實際上 a 會被賦值爲 el

所以要把二元編譯成三元:

如果是並且符號就要編譯成取反:

// 僞代碼
<div ref={&& 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