编译parser源码-盘点Solid.js源码中的这些欺骗行为

前言

我已经研究Solid.js的源代码很长时间了。 在研究过程中,我发现了一些欺骗行为。 想通之后终于恍然大悟,忍不住想和大家分享一下。 不过这样说并不准确,因为严格意义上Solid.js似乎被定义为两部分。 我只仔细研究了一部分,所以不能说研究了Solid.js的源码,因为另一部分根本不叫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。

SFFCVSJSX

单文件组件和jsx各有优缺点,不能说孰优孰劣。 但对于声明式框架作者来说,选择单文件组件的用途就是定制各种句型,可以牺牲一定的灵活性来换取更好的编译策略。 缺点是成本太高,需要写一个非常复杂的插件来填补句子高亮和TS支持方面的问题。

幸运的是,Vue 的单文件组件插件 Volar 已经可以支持定制自己的单文件组件插件编译parser源码,有效降低了框架作者的开发成本。 但是Solid刚开始的时候,还没有Volar(你可以去看看Volar的源码有多复杂,光是一个插件就需要花费这么多的时间和精力),甚至到现在Volar都没有文档,只有 Vue 那些人在使用 Volar(其实他们自己做了研究):

但也有可能人们选择jsx并不是为了降低开发成本,而仅仅是因为喜欢jsx句型。 那么为什么选择jsx可以降低开发成本呢? 首先编译parser源码,不需要自己写一堆解析器、生成器等编译相关的东西。 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、loop等函数写法不同,但其实jsx中的if-else也可以有各种奇怪的写法,但实际上还是用js写的,不是自创的ng-if、v-else、{:elseif}{%foriinxxx%}等不同的写法。

正是因为 jsx 的这个优势,很多非 React 框架(如:Preact、Stancil、Solid 等)都使用 jsx 飞起来,所以既然 jsx 无法与 React 绑定,Ryan 自创的 jsx 编译策略不绑定Solid也是可以的吧?

这是一个 babel 插件,可以与 Solid.js 一起使用。 它也是一个可以与 MobX、Knockout、S.js 甚至 Rx.js 一起使用的插件。 只要你有一个响应式系统,那么 dom-expressions 就可以为你服务 jsx。

Solid.js

所以这也是Ryan没有把dom-表达式放在solidjs/solid中的重要原因之一,但是Solid.js是一个非常重视编译的框架。 没有 dom 表达式是不够的,所以我们只能说 Solid.js 由两部分组成。

DOME压缩

DOMEpressions翻译过来就是DOM表达式的意思。 有人可能会问你的标题为什么不写成“盘点DOMEpressions源码中的这些欺骗行为”? 请! 谁知道DOMEpressions是什么鬼啊!

如果不是我认真说的那么多,有多少人知道这个东西是Solid.js编译器,别说国外,就连美国也很少有人知道DOMEpressions。 如果你想说 Solid.js,其他人可能会竖起大拇指说 Excellent,但如果你想说 DOMEpressions,那么其他人可能会说 What the fuckisthat?。 不信的话,看一下两者的对比:

我们来看看Ryan在Youtube亲自直播DOMEpressions时的低迷数据:

这还没有我随意写的点赞数高。 不管你信不信,如果我把标题中的Solid.js换成DOMEpression,点赞数会不会还不如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",
    b2,
    c: c,
    onClick() => {},
    children: [1 + 2, xxx]
  });
}

固体编译产品:

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(`
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 编译成什么:

编译parser源码-盘点Solid.js源码中的这些欺骗行为

import c from 'c';
import xxx from 'xxx';

const template = doucment.createElement('template')
template.innerHTML = '
3
'

const el = template.content.firstChild.cloneNode(true// 大家可以简单的理解为 el 就是 
3


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编译出来的代码并不是这样的代码,不过相信你能明白其中的含义:

 {}} />
// 将会被编译成
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 是什么。 那里写着方便。 而我们上面提到的方法也不是100%没有问题,还是会出现一些意想不到的场景:

for (let i = 0, j; j = <div a={i} />, i 3i++) {
  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时,我惊讶地发现:

编译parser源码-盘点Solid.js源码中的这些欺骗行为

不知道你看到这篇《Hello,!》时有何感受。 简而言之,我的第一个想法是存在错误。 我编了个感叹号! 进去。

但令人困惑的是,这段代码可以正常运行,没有任何bug。 随着测试的深入,我发现我的感叹号! 没有编译,只是在那个位置写了一个感叹号,即使不写感叹号,还是会有这样的:

你注意到了吗? 它出现的位置正是{xxx}的位置。 我们调试的时候发现最终生成的代码是这样的:

<h1>12</h1>

也就是说,当我们.innerHTML=''时,虽然相当于.innerHTML='',但是很多人听到这个空注释节点后肯定会想到Vue。 当我们在Vue中使用v-if=“false”时,按理说这个节点早已不复存在了。 但是每次我们打开控制台,就会看到一开始的v-if的位置变成了这样:

尤玉溪为何留下一个看似毫无意义的空评论节点? 大部分有强迫症的男人都看不下去了,赶紧在GitHub上开了一个issue来询问尤玉溪:

游宇熙给出的答案如下:

是不是和Vue把这个东西加到Solid里是一样的原因呢? 随着源码的深入,我发现它和Vue不太一样,我们用一段伪代码来帮助大家理解为什么Solid需要一个空的注释节点:

1{xxx}2</h1>
/
/ 将会被编译成:
const el = template('

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,如果不添加这个空节点,你就找不到在哪里插入:

1{xxx}2</h1>
/
/ 假如编译成没有空节点的样子:
const el = template('

12

'
)
const el1 = el1.firstChild  // 12
const el2 = el2.nextSibling  // 没有兄弟节点了 只有一个子节点:12

el.insertBefore(xxx, 特么的往哪插?)

所以当你在playground.solidjs.com中发现这些奇怪的符号时,请不要认为这是一个bug,它只是Solid寻找插入点的占位符。 只是大多数人并不认为把这个形参给innerHTML后,页面上就会生成一个。

神秘的裁判

Vue 和 React 都使用 ref 来获取 DOM。 Solid 的整体 API 设计与 React 类似,ref 也不例外:

但它也有自己的小创新,那就是ref既可以传递函数,也可以传递普通变量。 如果是函数,则传入DOM,如果是普通变量,则直接参数:

// 伪代码


// 将会编译成:
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) 表示该值是否是一个变量名:

编译parser源码-盘点Solid.js源码中的这些欺骗行为

例如ref={a}中的a是变量名,但如果是ref={1}、ref={()=>{}},那么它就不是变量名,剩下的两个条件是判断变量名称是否声明为const。 也就是说:

const isFunction = value 是个变量名 && 是用 const 声明的

这怎么意味着价值是一个函数呢?

在我眼里,这个变量几乎就叫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代表的是:值是否可以放在等号左边,这是什么意思呢? 一个反例可以让你明白:

// 此时 key = 'ref'、value = () => {}

 {}} />

// 现在我们需要写一个等号 看看 value 能不能放在等号的左侧:
() => {} = xxx // 很明显这是错误的语法 所以 t.isLVal(value.expression) 是 false

// 但假如写成这样:



a.b.c = xxx // 这是正确的语法 所以 t.isLVal(value.expression) 现在为 true

我的理解是t.isLVal与t.isFunction相连,从命名上可以看出判断是否是一个函数。 之后是t.isCallExpression,用于判断是否是函数调用:

// 这就是 callExpression
xxx()

翻译完了,我们再分析一下:

不知道您看完这三个判决有何感想。 不管怎样,当我完成这个逻辑时,我感到有点困惑,因为似乎不仅没有涵盖所有情况! 我们先这样划分:值必须是变量名、文字和常量之一,对吧? 如果是常量的话,就有覆盖,当不是常量的时候,就有漏洞,因为它使用了一个but符号&&,也就是说,当值不是常量的时候,必须满足在同时且不能放在等号左边。 进入到这个判断中,如果我们写一个三元表达式或者一个二元表达式,那不是会漏掉那个判断吗? 如果你不相信我,我们来试试:

可以看到编译后的abc的三个变量直接变暗了,而且这三个变量没有在任何地方使用,也就是说这个逻辑被吃掉了(虽然如果不进入那个分支,就相当于没有被处理) ,但是有人可能会很惊讶,三元表达式显然可以放在等号的右侧:

事实上,事情并不是你想的那样。 等号和三元表达式放在一起,就存在优先级关系。 调整一下格式,你就会明白它是如何工作的:

const _tmpl$ = /*#__PURE__*/_$template(`

Hello`)
a ? b : c = 1
// 实际上相当于
a
  ? b
  : (c = 1)
// 相当于
if (a) {
  b
else {
  c = 1
}

如果我们用括号把优先级放在三元这边,就会直接报错:

对于二进制表达式也是如此:

我认为在ref中这样写并没有什么问题:


其实这些写法都比较少见,但这并不是你错失判断的理由! 其实很多使用Solid.js的人以前都用过React,他们会不自觉地将React中养成的习惯带到Solid.js中,但这并不是因为Solid.js将API设计得尽可能紧密。 React 是造成某些相似之处的主要原因之一吗?

但如果React中人家没问题的写法出现问题,尤其会影响你框架的声誉! 而且文档中也没有提到 ref 不能写表达式:

后来我仔细想了想,发现他们确实不是无意中错过的,而是故意的。 至于为什么是故意的,那就要看它编译出来的产品了:

// 伪代码

// 将会被编译为:
const el = template(`
`)
typeof a === 'function' ? a(el) : a = el

其中,我们重点关注代码a=el,a是我们在ref中写的,但是如果我们将其替换为二进制表达式,就会变成:

// 伪代码

// 将会被编译为:
const el = template(`
`)
a || b = el

a||b不能放在等号右边,所以源码中的isLVal就是为了过滤这些情况。 那为什么不能编译成:

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

那么编译是错误的,因为如果a为false,a不应该被参数化,但实际上a会被参数化为el:

因此,要将二进制编译为三进制:

如果是,并且该符号被编译为否定:

// 伪代码

// 将会被编译为:
const el = template(`
`)
!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是假的,但我还是觉得至少有必要在官网上提及一下,不然如果有人这样写,找不到答案,就会影响这个词的嘴!

总结

看完源码查看,感觉有些地方设计得很巧妙,但有些地方却不是很严谨。 也是因为jsx太过灵活,无法做出判断并考虑到所有情况。 当你想编写一些可以在React中运行的show操作时,在Solid中可能会失败。

模板 VSJSX

所以,模板语句和jsx各有优缺点。 Svelte 同样是一个非虚拟 DOM 框架,可以借助模板语句编译成命令式 DOM 操作,灵活性有限,而 Solid 的 jsx 不再是那种极其灵活的了。 jsx,但是一个阉割版本,在个别情况下有一定的限制和错误。

但如果你问我喜欢哪一个,我还是更喜欢jsx,但如果是阉割版的jsx……我可能更喜欢模板句型,因为jsx里还有很多调情操作,我不喜欢想要调情如果发现不用就找不到答案,那就只能看源码了,所以最好使用只有几个固定用法的模板句型。

那么有人可能会说:你老老实实别写太浮夸的jsx不就够了吗?

怎么说呢,就像你买了一个新按钮一样,你必然会把鼠标上的这些习惯带到这个新按钮上吧? 原来这个新按钮是阉割版的。 有些不常用的按钮有Bug,但说明书上还没写出来,所以你还在怀疑是否有Bug!

所以在这些情况下,还不如买一个缺按钮的按钮,即使按不下去,也比按bug还惨! 事实上,你以前的打字习惯仍然存在。 丢失的按键会限制您的打字习惯。 你自然会想出一些替代方案,而那些不限制你打字习惯的有缺陷的按键肯定会让你变得更糟。

虽然没有说jsx可以实现这个功能,但是模板也实现不了,顶多就是麻烦一点。

收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

悟空资源网 源码编译 编译parser源码-盘点Solid.js源码中的这些欺骗行为 https://www.wkzy.net/game/146588.html

常见问题

相关文章

官方客服团队

为您解决烦忧 - 24小时在线 专业服务