怎么样,这个开头够不够标题党?但大家千万别误会,我并不是要侮辱接下来列出的这些技术,而是想跟各位讨论一个困扰了我很久的问题。
另外,本文并非软文,不会向大家推销什么“完美的替代方案”。
一切要从 Svelte 说起
本月初,Svlte 5 预告片正式发布,其中介绍了新的 runes。
大家似乎都对此感到兴奋,我自己也一样。
真正让我恼火的,其实是 Svelte 在解决 UI 问题时同样选择了东拼西凑的方案。
为什么组件中的反应方法仍然需要转译 / 编译?为什么还在使用带有假指令的劫持 HTML 语法?
为什么 UI 表达还是命令式的?为什么开发技术还在试图模仿 HTML?
让我们先从最后一点说起。
谁说 HTML 就是正确的抽象?
有些朋友可能觉得我在无理取闹。可能吧,但 HTML 本身也只是 DOM 树的映照,是一种表示树结构的方式,而且还不是最优的那种。没错,绝对算不上最优。
可别误会,我不是说 HTML 本身有啥问题——它确实是项很好的技术,有它自己的功能定位。但浏览器不会直接处理 HTML,而是处理 DOM 节点。另外,所有主流客户端库 / 框架现在都会直接通过 JS-API 生成 DOM,借此回避 HTML 表示。也就是说,我们不仅可以使用 HTML,也可以使用其他 DOM 序列化格式作为目标 DOM 描述语言。典型的例子包括 HAML 还有 JSON。所以从这个角度来看,使用 HTML 模板更多是种对固有传统的致敬,而不是真有什么必要性。
再有,为了完整描述每个 DOM 节点,就需要用到以下七种 props:
属性
处理程序
样式
data- 属性
可见性
文本内容
子元素
可遗憾的是,很多开发者压根没意识到或者没考虑过,我们其实并不需要隐藏这种复杂性。这是我们自己的平台,当然有责任把一切都搞清楚。
此外,现代开发会假定组件可以拆分。而有拆分,自然就有组合。所以说我们需要一款工具来创建组件实例,对其进行自定义,再通过不同方向的反应链接把它们相互对接起来。而这一切,在 HTML 中都没办法实现。
遗憾的是,几乎所有 UI 解决方案使用的都是最原始的技术——用简单性建议一次又一次自欺欺人。
它们都试图把 DOM 节点属性的多样性压缩成一份简单的属性列表。这样根本没用。而且它完全可见。把 DOM 节点 props 的七种类别压缩成一份扁平的属性列表并不会让开发更轻松,毕竟这七种类别仍然存在,只是换了种形式。
复杂性,它严重吗?
解释一下,这里的复杂性分为两种类别:引入的,还有天然的。引入复杂性来自库、框架、语言和范式等。天然复杂性则是平台本身所固有的,旨在解决领域内的基本问题。出色的工程师会减少引入复杂性,并尝试接受并处理天然复杂性。所以,请别再刻意隐藏天然复杂性,尊重平台的客观现实才是正道。
这里我要再对Svelte说几句:Rich Harris发布的这段视频相当精彩,介绍了getter和setter的情况,还回应了一些人对于Svelte新的反应方法的担忧。但唯一没能充分解决的,就是“必须编写更多代码”这个问题。我们的最终目标不是编写更少的代码,而是在明确表达应用程序意图的前提下编写更少的代码。如果这项技术单纯强调“简单性”,那就是在试图掩盖种种重要的细微差别。它们最终还是会暴露在开发者面前,只是角度有所不同。
但现在只给你 HTML
现代前端中之所以还是沿用类 HTML 解决方案,主要理由就是“开发人员更熟悉”。只要大家之前用过 HTML,那么 Vue 或者 Angular 等模板也就是在对已经精通的内容做扩展。但如果再深挖下去,我们就会发现事情没这么简单——不管宣传怎么说,它们本质上并不是 HTML。
换言之,这些格式都是模拟出来的幻象。
虽然看似是扩展,但它们实际上却属于完全不同的格式。现在它们呈现为类 HTML 的形式,可未来随时有可能转换成其他某种完全独立的新形式。而且在这类格式当中,每个属性都有不同的语义,但在语法上又相互等效,这当然容易产生误导效果。
下面咱们来看看这个 Angular“模板”(语法高亮完全对不上,请大家直接忽略):
<bi-panel class="example">
<check-box
class="editable"
side="left"
[(checked)]="editable"
i18n
>
Editable
</check-box>
<text-area
#input
class="input"
side="left"
[(value)]="text"
[enabled]="editable"
placeholer="Markdown content.."
i18n-placeholder="Showed when input is empty"
/>
<div
*ngIf="text"
class="output-label"
side="right"
i18n
>
Result
</div>
<mark-down
*ngIf="text"
class="output"
side="right"
text="{{text}}"
/>
</bi-panel>
#input 属于本地标识符,用于通过 TS 访问。
class="editable" 是通过 CSS 绑定样式的类的名称。
side="left" 是放置此元素的 slot 的名称。
[(checked)]="editable" 是嵌套组件与外部组件的属性的双向绑定。
[enabled]="editable" 则是单向绑定。
text="{{text}}" 也是一样。
placeholer="Markdown content..." 是某种 Markdown 文本。
i18n-placeholder="Showed when input is empty." 这里突然又说占位符属性是可翻译的,并对翻译器做了解释。
*ngIf="text" 这部分跟组件完全无关,负责控制组件是否能在父级中呈现。
它们用起来又是一样的所以在我看来,非得从 onClick={...}、on:click={...} 和 @click="..."当中做出选择,其实就是缺乏选择的表现。我真的受够了。
从某些方面来说,十年之前的解决方案是这个样子倒是可以理解:
因为这样的栈易于使用、但难于设计。
因为早期的应用程序更简单,而且原始的模板方法足以支持 DOM API。
因为直到最近 4、5 年,这种形式的代码才具有合理的运行性能。
总结成一句话,就是:
因为我们需要大量时间进行试验,并且能够接受新实现和当前方法的失败。
不幸的是,大家很少关注后面一半。但我也理解,毕竟这就是习惯的力量。但每一年过去,僵化的现实都令人心生不满。难道大家不会为自己在 HOC、render-props 和其他毫无意义的东西上浪费的时间感到心痛吗?
于是我开始认为这已经形成了一种畸形的逻辑链:因为我们没有学会如何正确地开发一套平台,所以才因为各种妥协而浪费精力;这就导致财务成本很高且难于维护,致使如今的应用开发仍然很困难。
被劫持的语法
大家可能会抱怨 React 中的 JSX 语法、Vue 中的模板方法,或者 Svelte 中的组件。没错,它们都有各自的毛病。但原因并不是它们不够好,而是它们从根本上就选错了方向、而且错得离谱。
下面咱们一起看点代码示例,我会借此论证自己的判断:
React
function Component() {
return (
<div>
<h1>Hey there</h1>
</div>
)
}
Vue
<template>
<div>
<h1>Hey there</h1>
</div>
</template>
Svelte
<div>
<h1>Hey there</h1>
</div>
说实在的,它们看起来都还不错。
但在尝试添加一些条件渲染之后,情况就不同了:
React
function ConditionalComponent({ showMessage }) {
return (
<div>
{showMessage ? (
<h1>Hey there</h1>
) : null}
</div>
);
}
...
<ConditionalComponent showMessage={true} />
Vue
<template>
<div>
<h1 v-if="showMessage">Hey there</h1>
</div>
</template>
<script>
export default {
name: 'ConditionalComponent',
props: {
showMessage: Boolean
}
}
</script>
...
<ConditionalComponent :showMessage="true"
Svelte
<div>
{#if showMessage}
<h1>Hey there</h1>
{/if}
</div>
...
<ConditionalComponent showMessage={true} />
呃……
视图树内的 If 语句回退为 null(或者某些插件组件,但这并不重要)?v-if 指令是什么?{#if ...}模板块又是什么?带 *ngIf 的结构指令?我得说 DOM API 里压根没有这些东西,它们单纯就是些廉价的把戏。请注意,我针对的不是它们的命名,而是其概念本身。
其中最引人注目的,还得数 Vue。我们要么使用 v-if 并每次都销毁组件,要么愚蠢地把组件隐藏掉。都 2023 年了,还在用 display: none?这完全就是对开发平台的亵渎好吗?
而且有问题可不只是 Vue。比如在 React 当中,函数组件的内容也充满了副作用。因此,只能大量使用重新渲染来计算这些副作用并更新数据,白白增加不必要的工作量。
“虽然 React 导致了不必要的重绘,但其底层机制还是有道理的,就是为了优化性能并让 UI 跟应用程序的数据保持同步。”真的吗?拜托面对现实吧,重新渲染绝对是每个人都想绕着走的麻烦事。而像 useMemo 和 useCallback 这类“解决方案”也仍不足以彻底消除额外渲染。
我再说一次,这就是自暴自弃加盲目妥协的产物。能解决问题的不是加快重新渲染速度,而是消除不必要的重新渲染步骤。
具体方式,可以对整个接口树做静态初始化。每个元素(更确切地讲,是栈内元素的回调)只会被计算并调用一次,从而将反应值跟节点关联起来。如此一来,主任务就只须执行一次所描述的代码,接下来沿着 DOM 图的数据 / 事件流推进即可。
咱们继续讨论。比如说要对一个列表中的内容进行渲染,它们分别是这么干的:
React
function UserList() {
return (
<div>
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
Vue
<template>
<div>
<ul>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</div>
</template>
Svelte
<div>
<ul>
{#each users as user (user.id)}
<li>{user.name}</li>
{/each}
</ul>
</div>
好吧,又来了。大量虚构的语法、模板、还有指令。这种情况过去有、现在有,将来恐怕还是有。毕竟看那个意思,React 和 Vue 两位大哥毫无做出改变的念头。而这样的技术一旦脱离了主流,大概率会沦为难以维护的遗留债,不信就想想当年的 Ember 吧。
拥抱 DOM API
下面,咱们继续聊聊有可能解决这个问题的潜在答案——DOM API!这里有不少有趣的点,而且奇怪的是,多年来人们其实一直在做潜心研究。DOM API 体量庞大、功能繁多,而且其中很多特性根本没法用 props 掩盖掉。
例如,我们要怎么解决条件渲染的问题?DOM API 提供 node.append() 或 node.appendChild() 方法、node.remove() 方法和 node.isConnected 属性。我们可以用它随时添加或删除节点,并确定其是否接入 DOM 树。
接入 DOM 树的节点(甚至是其子节点)的状态就应该由组件本身来报告,而非借助那些外部块。所以我们完全可以这样:
export function Component({ showMessage }) {
h('div', () => {
h('h1', {
text: 'Hey there',
visible: showMessage,
})
})
}
用不着虚构语法和扩展,也不必非得把这些基本特性隐藏在 props 之下。这就是个常规的 JS 函数,有着用于跟 DOM 交互的便捷 API。那要怎么在应用程序中使用这个组件?当然就是把它当普通函数处理喽:
using(body, () => {
Component({ showMessage: true })
})
注意,这里只是一段伪代码示例。
这种方法借鉴了 SwiftUI 和 Flutter 的思路。其中的第二个回调参数就相当于 SwiftUI 中的嵌套组件块,visible 属性就类似于 Flutter 中的 visible 属性。没错,这里的 visible 不再是 Vue 中的“花招”,而会从子树中实际插入 / 提取 DOM 节点。
这样,我们就不用发明一大堆抽象语法来模拟自己需要的行为。是的,我知道很多朋友可能并不喜欢 JavaScript,也能理解个中缘由。但前端开发的“原生”语言仍然是 JavaScript,尝试用虚假的解决方案绕过它只会让事情变得更糟。类似的情况之前出现过很多次了,无一例外。
好的,处理 visible 的方法已经基本清楚了。那渲染组件列表又该如何?下面来看:
export const function User({ key, name, isRestricted }) {
h('li', {
attr: { id: key },
text: name,
visible: isRestricted,
classList: ["border-gray-200"]
})
}
using(document.body, () => {
h('ul', () => {
list(users, ({ store: user, key: idx }), () => {
User({ key: idx, name: user.name, isRestricted: user.isRestricted })
})
})
})
这种方法参考的是 SwiftUI 的解决思路,即:
List(users) { user in
// usage of user
}
此外,所呈现代码中使用的每个变量或属性都可以是反应式的。这样,每当我们更改用户列表或其属性时,结果都会反映在最终布局当中。
为什么不用 for/map 循环?因为 for/map 循环是个黑箱,会与内部调用的上下文相脱离,我们根本没办法提前采取行动。例如,React 要求开发人员为此类列表中的各个条目指定唯一键,借此使其保持稳定。看见没有,又是个明明没有困难、非要制造困难的典型。
再有,这种 list 方法也让列表本身更加精巧。它不再计算列表中各个条目的所有内容,而是创建模板(请注意,是 JS 模板,不要跟 Vue 等其他模板弄混了)以供应用程序使用。这些模板会提前生成,每次 list 调用对应一个模板。这样,每当 users 的反应值发生变化,我们就只需要为已配置模板创建新实例,而不必在运行时内计算所有内容。
但遗憾的是,不少现代解决方案仍在使用虚拟 DOM 和协调(reconciliation),引入阶段的概念来检查从组件返回的结构变更。正因为如此,重绘和性能问题才反反复复得不到解决。此外还有其他一些人为限制。
有一说一,Svelte 在这方面表现得不好。它并不依赖虚拟 DOM,而是使用编译器将组件转换为 JS。转换出的 JS 代码非常高效,但又会引发新的问题:不必要的 build 步骤,而且 Svelte 的这些特定代码会一直存在于最终包当中。所以说,重新渲染和假语法问题依旧在那里。
咱们再次回归主题。那事件处理程序和属性规范呢?我们用以下代码为例:
using(document.body, () => {
h('section', () => {
spec({ style: {width: '15em'} })
h('form', () => {
spec({
handler: {
config: { prevent: true },
on: { submit },
},
style: {
display: 'flex',
flexDirection: 'column',
},
})
h('input', {
attr: { placeholder: 'Username' },
handler: { input: changeUsername },
})
h('input', {
attr: { type: 'password', placeholder: 'Password' },
classList: ['w-full', 'py-2', 'px-4'],
handler: { input: changePassword },
})
h('button', {
text: 'Submit',
attr: {
disabled: fields.map(
fields => !(fields.username && fields.password),
),
},
})
})
})
})
第一眼看去,很多读者朋友可能会觉得:
这跟常规习惯不太一样;
太过冗长;
必须亲自处理 DOM API 的那些琐事。
但事实真是如此吗?
其实这里没什么不一样的,它就是个 JS 函数,其余的部分分别为:
attr:带有节点属性的对象。
style:带有节点样式的对象。
classList:节点类的数组。顺带一提,在 DOM API 里它也叫这个名字。
handler:带有节点事件处理程序的对象,其中包含配置对象 (注意 config: { prevent: true })。
spec:一个打包函数,用于描述节点的属性类别(如果组件在其回调内具有子元素的话)。通过这种方式,我们可以在组件之上描述属性集(其实在回调内的任意位置都可以,但这不重要)。
有点冗长?确实,这种方法看起来确实比 React、Vue、Svelte 和 Solid 之类的要繁复。但这些框架只是让人误以为回避掉了前端复杂性,却并不能真正让事情变得简单。所以我觉得大家不妨直面现实,跟难题交朋友,而不是一味躲藏。通过这种方式,我们能够清楚了解自己的应用程序是如何构建而成。没错,确实冗长,但却并不复杂。相信大家都能看明白这是在干什么,甚至理解每一行的具体作用。
另外,这里我们也不用直接使用 DOM API。真正需要的,就是一个能用来与之交互的便捷 JS API。我也坚持认为视图树应该由原生工具管理,而对树进行添加、删除和更新的手动操作,倒是可以交给技术工具来接管。
再次强调,我不是想跟大家推销什么看似酷炫的技术工具。相反,我是想指出现有解决方案中存在的问题,还有如何通过原生工具将其解决,避免重新造轮子。
总之,我的核心观点就是尊重平台的天然属性。大家都应该学会怎么使用自己的平台,而不再像过去那样不断用新的虚假解决方案来自欺欺人。
不出问题就别管?但真的没出问题吗?
坦白地讲,本文展示的简单案例很难表现真实的情况,因为有些问题不会在简单的例子中暴露出来。
比方说,我们要怎么描述实际应用程序中的表单部分:
export const Auth = () => {
h("div", () => {
spec({
classList: ["mt-10", "max-w-sm", "w-full"],
});
h("form", () => {
Input({
type: "email",
label: "Email",
inputChanged: authForm.fields.email.changed,
errorText: authForm.fields.email.$errorText,
errorVisible: authForm.fields.email.$errors.map(Boolean),
});
Input({
type: "password",
label: "Password",
inputChanged: authForm.fields.password.changed,
errorText: authForm.fields.password.$errorText,
errorVisible: authForm.fields.password.$errors.map(Boolean),
});
Button({
text: "Create",
event: authForm.submit,
size: "base",
prevent: true,
variant: "default",
});
ErrorHint($authError, $authError.map(Boolean));
});
});
};
...
export const Input = ({
value,
type,
label,
required,
inputChanged,
errorVisible,
errorText,
}: {
value?: Store<string>;
type: string;
label: string;
required?: boolean;
inputChanged: Event<any>;
errorVisible?: Store<boolean>;
errorText?: Store<string>;
}) => {
h("div", () => {
spec({
classList: ["mb-6"],
});
h("label", () => {
spec({
classList: ["block", "mb-2", "text-sm", "font-medium", "text-gray-900", "dark:text-white"],
text: label,
});
});
h("input", () => {
const localInputChanged = createEvent<any>();
sample({
source: localInputChanged,
fn: (event) => event.target.value,
target: inputChanged,
});
spec({
classList: [
"bg-gray-50",
"border",
"border-gray-300",
"text-gray-900",
"text-sm",
"rounded-lg",
"focus:ring-blue-500",
"focus:border-blue-500",
"block",
"w-full",
"p-2.5",
"dark:bg-gray-700",
"dark:border-gray-600",
"dark:placeholder-gray-400",
"dark:text-white",
"dark:focus:ring-blue-500",
"dark:focus:border-blue-500",
],
attr: { type: type, required: Boolean(required), value: value || createStore("") },
handler: { on: { input: localInputChanged } },
});
});
ErrorHint(errorText, errorVisible);
});
};
...
export const ErrorHint = (text: Store<string> | string | undefined, visible: Store<boolean> | undefined) => {
h("p", {
classList: ["mt-2", "text-sm", "text-red-600", "dark:text-red-400"],
visible: visible || createStore(false),
text: text || createStore(""),
});
};
又该怎么用带有标签、属性和动态内容的预定义卡来描述日志列表?
export const LogsList = () => {
h("div", () => {
spec({
classList: ["flex", "flex-col", "space-y-6", "mt-2"],
});
list(logModel.$logsGroups, ({ store: group }) => {
CardHeaded({
tags: group.map((g) => g.tags),
href: group.map((g) => `${g.schema_name}/${g.group_hash}`),
content: () => {
LogsTable(group.map((g) => g.logs));
},
withMore: true,
});
});
});
};
我们根本不需要用到这些 createStore、createEvent。Store 就是个反应值,事件则是用来改变或调用某些效果的执行信号。它们可以来自任何库。
这里最重要的就是描述视图这个基本事实,也就是视图逻辑。在我看来,哪怕视图描述本身比较简单,也不该随意引入不必要的解决方案。那目前的主流框架能否以最佳方式发挥作用?如果不能,问题出在哪里?
HTMX 可太棒了!它正在市场上积聚人气,这里请允许我向 ThePrimeagen 表达谢意。
但这项技术只是另外一种反模式,甚至夸张点说是种反平台方案。
请别误会,HTMX 确实给问题提供了答案。而且据我所知,它在功能和方法所及范围内的确很好地解决了问题。但这项技术还是老毛病——对前端的客观现实视而不见,用开倒车的方式打补丁。具体讲,它其实是把前端的问题移交给了后端,指望着“能在那边解决”。
是的,没人喜欢前端,就连前端自己也不喜欢。但咱们能不能现实一点,用户交互难道不该由客户端负责处理吗?谁见过哪款移动应用会在用户交互时把请求发给服务器,再从那边获取新布局的?桌面端有吗?
另外,使用 HTMX 还给网络连接速度带来了新的挑战,任何一点小事都去劳烦服务器真的很讨厌。并不是每个人在所有场景下都有足够好的网络连接,这么搞肯定会被印度和非洲的移动用户骂个狗血淋头。而且那里可是目前增长速度最快的新兴市场哦。
另外,这个例子可能有点极端,但大家可以尝试在 HTMX 上执行以下操作:
创建一份预订表单,预订剧院第 16 排的 4 到 8 个座位,场次为下午 1:00 至 3:30。其中 6 号座已经售出。如果一次性购买 3 个以上座位,可享受 5% 的折扣。由于是老顾客预订,所以你这一单可以得到免费的爆米花。浏览器时区为 CT,剧院时区为 ET。服务器偶尔会响应 502。
这就是我们需要在前端解决的实际问题,不用指望什么 Todo MVC 加超媒体。
HTMX 有它的作用,但更适合那些以后端为中心的任务。至于前端,咱们还是尽量用自己的功能和平台。
本文是不是太过关注语法了?
谈到语法这个问题,大家的观点往往各不相同。有人觉得语法不重要、没必要争来争去,但也有很多人被固有语法折磨得头痛欲裂。我想说的是,语法在“定义”技术方面确实发挥着极其重要的作用。但受篇幅所限,这里就不过多展开了。
为什么要讨论这个问题
大家可能觉得我对当前主流框架方案的评价过于激进,但事实并非如此。
事实上,我承认这些技术都有一定程度的必要性,也在常规前端开发当中解决了开发者的部分问题。但让我难以接受的是,我们过去十年来一直在同样的困境里打转,至今没人给开发者们提个醒。所以,我们的应用程序仍然难以复现,而且即使是在最简单的开发需求下也得承受大量不必要的工作内容。
我的观点绝不是劝大家直接放弃所有现成的解决方案。不,那也太蠢了。我也不建议大家每次都手动执行 DOM 操作,这确实该由库 / 框架 / 技术 /API 之类来代劳。我想说的是,也许是时候给那些具有严重设计缺陷的“雪花”型方案提个醒了。至于就个人来讲,我觉得这个问题很有意义。
而且行业似乎还没有意识到当前实践的缺陷,反而在错误的道路上越走越远。
总之,请尊重我们的平台、尊重它的固有特性。
作者 | Moonthoughts 译者 | 核子可乐 策划 | 丁晓昀
原文链接:https://moonthought.github.io/posts/all-your-mainstream-ui-frameworks-are-lying-to-you/
写在最后
【版權聲明】