作者 | Moonthoughts
译者 | 核子可乐
策划 | 丁晓昀
怎么样,这个开头够不够标题党?但大家千万别误会,我并不是要侮辱接下来列出的这些技术,而是想跟各位讨论一个困扰了我很久的问题。
另外,本文并非软文,不会向大家推销什么“完美的替代方案”。
本月初,Svlte 5 预告片正式发布,其中介绍了新的 runes。
大家似乎都对此感到兴奋,我自己也一样。
真正让我恼火的,其实是 Svelte 在解决 UI 问题时同样选择了东拼西凑的方案。
为什么组件中的反应方法仍然需要转译 / 编译?为什么还在使用带有假指令的劫持 HTML 语法?
为什么 UI 表达还是命令式的?为什么开发技术还在试图模仿 HTML?
让我们先从最后一点说起。
有些朋友可能觉得我在无理取闹。可能吧,但 HTML 本身也只是 DOM 树的映照,是一种表示树结构的方式,而且还不是最优的那种。没错,绝对算不上最优。
可别误会,我不是说 HTML 本身有啥问题——它确实是项很好的技术,有它自己的功能定位。但浏览器不会直接处理 HTML,而是处理 DOM 节点。另外,所有主流客户端库 / 框架现在都会直接通过 JS-API 生成 DOM,借此回避 HTML 表示。也就是说,我们不仅可以使用 HTML,也可以使用其他 DOM 序列化格式作为目标 DOM 描述语言。典型的例子包括 HAML 还有 JSON。所以从这个角度来看,使用 HTML 模板更多是种对固有传统的致敬,而不是真有什么必要性。
再有,为了完整描述每个 DOM 节点,就需要用到以下七种 props:
可遗憾的是,很多开发者压根没意识到或者没考虑过,我们其实并不需要隐藏这种复杂性。这是我们自己的平台,当然有责任把一切都搞清楚。
此外,现代开发会假定组件可以拆分。而有拆分,自然就有组合。所以说我们需要一款工具来创建组件实例,对其进行自定义,再通过不同方向的反应链接把它们相互对接起来。而这一切,在 HTML 中都没办法实现。
遗憾的是,几乎所有 UI 解决方案使用的都是最原始的技术——用简单性建议一次又一次自欺欺人。
它们都试图把 DOM 节点属性的多样性压缩成一份简单的属性列表。这样根本没用。而且它完全可见。把 DOM 节点 props 的七种类别压缩成一份扁平的属性列表并不会让开发更轻松,毕竟这七种类别仍然存在,只是换了种形式。
解释一下,这里的复杂性分为两种类别:引入的,还有天然的。引入复杂性来自库、框架、语言和范式等。天然复杂性则是平台本身所固有的,旨在解决领域内的基本问题。出色的工程师会减少引入复杂性,并尝试接受并处理天然复杂性。所以,请别再刻意隐藏天然复杂性,尊重平台的客观现实才是正道。
这里我要再对 Svelte 说几句:Rich Harris 发布的这段视频相当精彩,介绍了 getter 和 setter 的情况,还回应了一些人对于 Svelte 新的反应方法的担忧。但唯一没能充分解决的,就是“必须编写更多代码”这个问题。我们的最终目标不是编写更少的代码,而是在明确表达应用程序意图的前提下编写更少的代码。如果这项技术单纯强调“简单性”,那就是在试图掩盖种种重要的细微差别。它们最终还是会暴露在开发者面前,只是角度有所不同。
现代前端中之所以还是沿用类 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>
复制代码
所以在我看来,非得从 onClick={…}、on:click={…} 和 @click="…"当中做出选择,其实就是缺乏选择的表现。我真的受够了。
从某些方面来说,十年之前的解决方案是这个样子倒是可以理解:
总结成一句话,就是:
不幸的是,大家很少关注后面一半。但我也理解,毕竟这就是习惯的力量。但每一年过去,僵化的现实都令人心生不满。难道大家不会为自己在 HOC、render-props 和其他毫无意义的东西上浪费的时间感到心痛吗?
于是我开始认为这已经形成了一种畸形的逻辑链:因为我们没有学会如何正确地开发一套平台,所以才因为各种妥协而浪费精力;这就导致财务成本很高且难于维护,致使如今的应用开发仍然很困难。
大家可能会抱怨 React 中的 JSX 语法、Vue 中的模板方法,或者 Svelte 中的组件。没错,它们都有各自的毛病。但原因并不是它们不够好,而是它们从根本上就选错了方向、而且错得离谱。
下面咱们一起看点代码示例,我会借此论证自己的判断:
function Component() { return ( <div> <h1>Hey there</h1> </div> )}
复制代码
<template> <div> <h1>Hey there</h1> </div></template>
复制代码
<div> <h1>Hey there</h1></div>
复制代码
说实在的,它们看起来都还不错。
但在尝试添加一些条件渲染之后,情况就不同了:
function ConditionalComponent({ showMessage }) { return ( <div> {showMessage ? ( <h1>Hey there</h1> ) : null} </div> );}...<ConditionalComponent showMessage={true} />
复制代码
<template> <div> <h1 v-if="showMessage">Hey there</h1> </div></template><script> export default { name: 'ConditionalComponent', props: { showMessage: Boolean } }</script>...<ConditionalComponent :showMessage="true"
复制代码
<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 图的数据 / 事件流推进即可。
咱们继续讨论。比如说要对一个列表中的内容进行渲染,它们分别是这么干的:
function UserList() { return ( <div> <ul> {users.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> </div> );}
复制代码
<template> <div> <ul> <li v-for="user in users" :key="user.id">{{ user.name }}</li> </ul> </div></template>
复制代码
<div> <ul> {#each users as user (user.id)} <li>{user.name}</li> {/each} </ul></div>
复制代码
好吧,又来了。大量虚构的语法、模板、还有指令。这种情况过去有、现在有,将来恐怕还是有。毕竟看那个意思,React 和 Vue 两位大哥毫无做出改变的念头。而这样的技术一旦脱离了主流,大概率会沦为难以维护的遗留债,不信就想想当年的 Ember 吧。
下面,咱们继续聊聊有可能解决这个问题的潜在答案——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), ), }, }) }) })})
复制代码
第一眼看去,很多读者朋友可能会觉得:
但事实真是如此吗?
其实这里没什么不一样的,它就是个 JS 函数,其余的部分分别为:
有点冗长?确实,这种方法看起来确实比 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 之类来代劳。我想说的是,也许是时候给那些具有严重设计缺陷的“雪花”型方案提个醒了。至于就个人来讲,我觉得这个问题很有意义。
而且行业似乎还没有意识到当前实践的缺陷,反而在错误的道路上越走越远。
总之,请尊重我们的平台、尊重它的固有特性。
https://moonthought.github.io/posts/all-your-mainstream-ui-frameworks-are-lying-to-you/
前端精准测试实践
前后端分离技术体系
大前端测试的思考和在语雀的实践分享
前端文档站点搭建方案
本文转载来源:
https://www.infoq.cn/article/Xmuo1eijM3OOBhqWdKM2