连锁反应
December 11, 2023
我在编辑器里写了一小段 JSX:
<p className="text-2xl font-sans text-purple-400 dark:text-purple-500">
Hello, <i>Alice</i>!
</p>
现在,这段信息只存在于我的设备上。但如果一切顺利,它会穿越时空,最终到达你的设备,并显示在你的屏幕上。
Hello, Alice!
这一切能够实现,本身就是工程学上的奇迹。
在你的浏览器深处,有一些代码片段专门负责显示段落或以斜体绘制文本。这些代码在不同浏览器之间,甚至同一浏览器的不同版本之间都不尽相同。而在不同操作系统上,绘制到屏幕的方式也各有差异。
然而,由于这些概念被赋予了大家都认可的名称(比如段落用 <p>
,斜体用 <i>
),我就可以直接引用它们,而不必担心它们在你的设备上具体是如何实现的。我无法直接访问它们的内部逻辑,但我知道可以向它们传递哪些信息(比如 CSS 的 className
)。多亏了 Web 标准,我可以相当有信心地认为我的问候会如我所愿地显示出来。
像 <p>
和 <i>
这样的标签让我们能够引用浏览器内置的概念。但名称不一定只能指代内置的东西。例如,我用到了像 text-2xl
和 font-sans
这样的 CSS 类来为我的问候语设置样式。这些名字不是我自己发明的——它们来自一个叫 Tailwind 的 CSS 库。我在本页面引入了它,这样我就可以使用它定义的任意 CSS 类名。
那么,为什么我们喜欢给事物命名呢?
我写下了 <p>
和 <i>
,我的编辑器识别了这些名字,你的浏览器也识别了它们。如果你做过 Web 开发,你大概率也认识它们,甚至可能仅凭标记就能猜到屏幕上会显示什么。从这个意义上说,名称帮助我们建立了一定程度的共识。
从根本上讲,计算机执行的其实是一些非常基础的指令——比如加减乘除、读写内存,或者和显示器等外部设备通信。仅仅是在你的屏幕上显示一个 <p>
,可能就涉及数十万条这样的指令。
如果你看到计算机为显示一个 <p>
所执行的所有指令,你几乎无法猜出它们在做什么。这就像试图通过分析房间里所有原子的运动来判断正在播放哪首歌一样,完全无法理解!你需要“拉远视角”才能看清全貌。
要描述一个复杂系统,或者指挥一个复杂系统做事,把它的行为分层、让每一层建立在前一层的概念之上会非常有帮助。
这样,负责屏幕驱动的人可以专注于如何把正确的颜色送到正确的像素上。负责文本渲染的人可以专注于如何把每个字符变成一堆像素。而像我这样的人,则可以专注于为我的“段落”和“斜体”挑选合适的颜色。
我们喜欢名称,因为它们让我们可以忘记背后的细节。
我用过许多别人起的名字。有些是浏览器内置的,比如 <p>
和 <i>
;有些是我用的工具自带的,比如 text-2xl
和 font-sans
。这些都是我的“积木”,但我到底在搭建什么呢?
比如,这是什么?
<p className="text-2xl font-sans text-purple-400 dark:text-purple-500">
Hello, <i>Alice</i>!
</p>
Hello, Alice!
从你的浏览器的角度看,这就是一个带有特定 CSS 类(让它变大且变成紫色)和一些内部文本(部分为斜体)的段落。
但从我的角度看,它是给 Alice 的问候。虽然我的问候恰好是一个段落,但大多数时候我更希望这样来思考它:
<Greeting person={alice} />
给这个概念起个名字,让我获得了新的灵活性。现在,我可以显示多个 Greeting
,而不需要复制粘贴它们的标记。我可以传递不同的数据给它们。如果我想改变所有问候的外观和行为,只需要在一个地方修改即可。把 Greeting
变成一个独立的概念,让我可以把*“展示哪些问候”和“一个问候是什么”*分开处理。
不过,这样做也带来了一个新问题。
现在我给这个概念起了名字,我脑海中的“语言”就和你的浏览器所理解的“语言”不同了。你的浏览器只认识 <p>
和 <i>
,但它从没听说过 <Greeting>
——那是我自己的概念。如果我想让你的浏览器明白我的意思,就必须把这段标记“翻译”成只用浏览器已经认识的概念。
我要把这个:
<Greeting person={alice} />
变成这样:
<p className="text-2xl font-sans text-purple-400 dark:text-purple-500">
Hello, <i>Alice</i>!
</p>
我该怎么做呢?
要给某个东西命名,我需要先定义它。
比如,alice
这个名字没有任何意义,除非我定义了它:
const alice = {
firstName: 'Alice',
birthYear: 1970
};
现在,alice
就代表了那个 JavaScript 对象。
同样,我也需要定义我的 Greeting
概念到底是什么意思。
我将为任意 person
定义一个 Greeting
:它是一个段落,内容是 “Hello, ”,后面跟着那个人的名字(斜体),再加一个感叹号:
function Greeting({ person }) {
return (
<p className="text-2xl font-sans text-purple-400 dark:text-purple-500">
Hello, <i>{person.firstName}</i>!
</p>
);
}
和 alice
不同,我把 Greeting
定义成了一个函数。这是因为问候每个人都不一样。Greeting
是一段代码——它执行一种转换或翻译。它把一些数据变成一些 UI。
这让我想到,可以用这个方法来处理:
<Greeting person={alice} />
你的浏览器并不知道什么是 Greeting
——那是我自己的概念。但现在我已经写出了这个概念的定义,就可以应用这个定义,把我的意图“展开”。你看,给某个人的问候其实就是一个段落:
function Greeting({ person }) {
return (
<p className="text-2xl font-sans text-purple-400 dark:text-purple-500">
Hello, <i>{person.firstName}</i>!
</p>
);
}
把 alice
的数据代入这个定义,最终得到这样的 JSX:
<p className="text-2xl font-sans text-purple-400 dark:text-purple-500">
Hello, <i>Alice</i>!
</p>
此时我只用了浏览器自己的概念。通过用我的定义替换 Greeting
,我就已经为你的浏览器“翻译”好了。
Hello, Alice!
现在,让我们教计算机也能做同样的事情。
来看看 JSX 的本质。
const originalJSX = <Greeting person={alice} />;
console.log(originalJSX.type); // Greeting
console.log(originalJSX.props); // { person: { firstName: 'Alice', birthYear: 1970 } }
在底层,JSX 会构造一个对象,type
属性对应标签,props
属性对应 JSX 的属性。
你可以把 type
理解为“代码”,props
理解为“数据”。要得到结果,你需要像刚才那样,把数据塞进代码里。
我写了一个小函数,正好能做到这一点:
function translateForBrowser(originalJSX) {
const { type, props } = originalJSX;
return type(props);
}
在这个例子里,type
是 Greeting
,props
是 { person: alice }
,所以 translateForBrowser(<Greeting person={alice} />)
会返回调用 Greeting
并传入 { person: alice }
的结果。
你还记得上一节的内容,这样会得到:
<p className="text-2xl font-sans text-purple-400 dark:text-purple-500">
Hello, <i>Alice</i>!
</p>
这正是我想要的!
你可以验证一下,把我最初的 JSX 传给 translateForBrowser
,就会得到只包含 <p>
和 <i>
这些浏览器“认识”的概念的“浏览器 JSX”。
const originalJSX = <Greeting person={alice} />;
console.log(originalJSX.type); // Greeting
console.log(originalJSX.props); // { person: { firstName: 'Alice', birthYear: 1970 } }
const browserJSX = translateForBrowser(originalJSX);
console.log(browserJSX.type); // 'p'
console.log(browserJSX.props); // { className: 'text-2xl font-sans text-purple-400 dark:text-purple-500', children: ['Hello', { type: 'i', props: { children: 'Alice' }, '!'] }
对于这个“浏览器 JSX”,我可以做很多事。比如把它转成 HTML 字符串发给浏览器,或者把它变成一系列指令去更新已有的 DOM 节点。现在我们不关注这些用法。此时最重要的是,当我拿到“浏览器 JSX”时,已经没有什么需要再“翻译”的了。
就好像我的 <Greeting>
已经消失了,剩下的只有 <p>
和 <i>
。
让我们试试稍微复杂一点的情况。假设我想把问候包裹在一个 <details>
标签里,让它默认收起:
<details>
<Greeting person={alice} />
</details>
浏览器应该这样显示(点击“Details”可以展开!)
Hello, Alice!
所以现在我的任务是把这个:
<details>
<Greeting person={alice} />
</details>
变成这样:
<details>
<p className="text-2xl font-sans text-purple-400 dark:text-purple-500">
Hello, <i>Alice</i>!
</p>
</details>
看看 translateForBrowser
能不能直接处理。
const originalJSX = (
<details>
<Greeting person={alice} />
</details>
);
console.log(originalJSX.type); // 'details'
console.log(originalJSX.props); // { children: { type: Greeting, props: { person: alice } } }
const browserJSX = translateForBrowser(originalJSX);
你会在 translateForBrowser
调用时遇到一个错误:
function translateForBrowser(originalJSX) {
const { type, props } = originalJSX;
return type(props); // 🔴 TypeError: type is not a function
}
发生了什么?我的 translateForBrowser
假设 type
(即 originalJSX.type
)总是像 Greeting
那样的函数。
但注意,这次 originalJSX.type
实际上是个字符串:
const originalJSX = (
<details>
<Greeting person={alice} />
</details>
);
console.log(originalJSX.type); // 'details'
console.log(originalJSX.props); // { children: { type: Greeting, props: { person: alice } } }
当你用小写字母开头写 JSX 标签(比如 <details>
),按照惯例就表示你想要一个内置标签,而不是你自己定义的函数。
由于内置标签没有任何关联的代码(那段代码在你的浏览器内部!),type
就会是像 'details'
这样的字符串。对于 <details>
如何工作,我的代码是无法干涉的——我唯一知道的就是它的名字。
我们把逻辑分成两种情况,暂时跳过内置标签的翻译:
function translateForBrowser(originalJSX) {
const { type, props } = originalJSX;
if (typeof type === 'function') {
return type(props);
} else if (typeof type === 'string') {
return originalJSX;
}
}
这样改完后,translateForBrowser
只会在 type
真的是像 Greeting
这样的函数时才调用它。
那这样就得到我想要的结果了吗?…
<details>
<Greeting person={alice} />
</details>
等等。我想要的是这个:
<details>
<p className="text-2xl font-sans text-purple-400 dark:text-purple-500">
Hello, <i>Alice</i>!
</p>
</details>
在我的翻译过程中,我希望跳过 <details>
标签,因为它的实现对我来说是黑盒的,我无法做什么。但它内部的内容可能还需要翻译!
让我们修改 translateForBrowser
,让它递归翻译内置标签的 children:
function translateForBrowser(originalJSX) {
const { type, props } = originalJSX;
if (typeof type === 'function') {
return type(props);
} else if (typeof type === 'string') {
return {
type,
props: {
...props,
children: translateForBrowser(props.children)
}
};
}
}
这样,当遇到 <details>...</details>
这样的元素时,会返回另一个 <details>...</details>
标签,但里面的内容会再次用我的函数翻译——于是 Greeting
就消失了:
<details>
<p className="text-2xl font-sans text-purple-400 dark:text-purple-500">
Hello, <i>Alice</i>!
</p>
</details>
现在我又在说浏览器的“语言”了:
Hello, Alice!
Greeting
已经消失了。
现在假设我定义了一个 ExpandableGreeting
:
function ExpandableGreeting({ person }) {
return (
<details>
<Greeting person={person} />
</details>
);
}
这是我新的原始 JSX:
<ExpandableGreeting person={alice} />
如果我用 translateForBrowser
处理它,会得到这样的 JSX:
<details>
<Greeting person={alice} />
</details>
但这不是我想要的!里面还有个 Greeting
,而我们认为一段 JSX 只有在所有自定义概念都消失后才算“浏览器就绪”。
这是我的 translateForBrowser
函数的一个 bug。当它调用像 ExpandableGreeting
这样的函数时,只会返回它的输出,不再做任何处理。但我们还需要继续!返回的 JSX 也需要翻译。
幸运的是,这很容易解决。当我调用像 ExpandableGreeting
这样的函数时,可以把它返回的 JSX 再递归翻译一次:
function translateForBrowser(originalJSX) {
const { type, props } = originalJSX;
if (typeof type === 'function') {
const returnedJSX = type(props);
return translateForBrowser(returnedJSX);
} else if (typeof type === 'string') {
return {
type,
props: {
...props,
children: translateForBrowser(props.children)
}
};
}
}
还需要在遇到 null
或字符串时停止递归;如果遇到数组,则递归处理每一项。加上这两个修正,translateForBrowser
就完整了:
function translateForBrowser(originalJSX) {
if (originalJSX == null || typeof originalJSX !== 'object') {
return originalJSX;
}
if (Array.isArray(originalJSX)) {
return originalJSX.map(translateForBrowser);
}
const { type, props } = originalJSX;
if (typeof type === 'function') {
const returnedJSX = type(props);
return translateForBrowser(returnedJSX);
} else if (typeof type === 'string') {
return {
type,
props: {
...props,
children: translateForBrowser(props.children)
}
};
}
}
现在,假如我从这个开始:
<ExpandableGreeting person={alice} />
它会变成这样:
<details>
<Greeting person={alice} />
</details>
再变成这样:
<details>
<p className="text-2xl font-sans text-purple-400 dark:text-purple-500">
Hello, <i>Alice</i>!
</p>
</details>
到这一步,处理就结束了。
让我们再来看一次这个过程,这次再加一层深度。
我这样定义 WelcomePage
:
function WelcomePage() {
return (
<section>
<h1 className="text-3xl font-sans pb-2">Welcome</h1>
<ExpandableGreeting person={alice} />
<ExpandableGreeting person={bob} />
<ExpandableGreeting person={crystal} />
</section>
);
}
现在假设我用这个原始 JSX 开始处理:
<WelcomePage />
你能在脑海中回溯一下转换的过程吗?
让我们一步步来。
首先,想象 WelcomePage
消失,留下它的输出:
<section>
<h1 className="text-3xl font-sans pb-2">Welcome</h1>
<ExpandableGreeting person={alice} />
<ExpandableGreeting person={bob} />
<ExpandableGreeting person={crystal} />
</section>
然后,每个 ExpandableGreeting
消失,留下它们的输出:
<section>
<h1 className="text-3xl font-sans pb-2">Welcome</h1>
<details>
<Greeting person={alice} />
</details>
<details>
<Greeting person={bob} />
</details>
<details>
<Greeting person={crystal} />
</details>
</section>
接着,每个 Greeting
消失,留下它们的输出:
<section>
<h1 className="text-3xl font-sans pb-2">Welcome</h1>
<details>
<p className="text-2xl font-sans text-purple-400 dark:text-purple-500">
Hello, <i>Alice</i>!
</p>
</details>
<details>
<p className="text-2xl font-sans text-purple-400 dark:text-purple-500">
Hello, <i>Bob</i>!
</p>
</details>
<details>
<p className="text-2xl font-sans text-purple-400 dark:text-purple-500">
Hello, <i>Crystal</i>!
</p>
</details>
</section>
现在已经没有什么需要再“翻译”的了。我的所有概念都已经消失。
Welcome
Hello, Alice!
Hello, Bob!
Hello, Crystal!
这就像是一场连锁反应。你把数据和代码混合在一起,不断地转化,直到再也没有代码可运行,只剩下“残渣”。
如果有一个库能帮我们自动完成这些转换就好了。
不过,这里有个问题。这些转换必须在你我之间的数据传递过程中某个地方发生。那么,它们究竟发生在你的电脑上,还是我的电脑上呢?
Pay what you like