【CSDN 编者按】这篇文案是一位前端研发者揭示了主流 UI 框架的局限性,认为它们都在误导研发者,隐匿了 DOM 节点的真实繁杂性。作者指出 HTML 语法并不是描述 UI 的最佳抽象,而是 DOM 树的一种投影。HTML 没法有效地表达 DOM 节点的七类属性,而只能将它们混合在一块。作者认为开发者应该面对并理解 DOM 节点的自然繁杂性,而不是被框架所迷惑。作者亦说到了 Svelte 5 的 runes 特性,暗示对其有必定的期待。
原文链接:https://moonthought.github.io/posts/all-your-mainstream-ui-frameworks-are-lying-to-you/
未经准许,禁止转载!
作者 | moonthought.github.io 译者 | 明明如月 责编 | 夏萌出品 | CSDN(ID:CSDNnews)
Svelte 的全新篇章
几天前,Svelte 5 的预览版本随着对 runes 的仔细介绍 被公研发布。该信息令包含我在内众多人士激动不已。$props、$derived、$effects、$state、信号等概念,我并非第1次接触,我在 5-6 年前就见过这般的响应式处理机制。我认为 Svelte 正沿着正确的方向演进。虽然 Svelte 仍然运用了有些主流但不太适合处理繁杂 Web 问题的方法,我仍然期望她们能够克服这些困难。不外,这并非本文今天要探讨的核心议题。
令我不解的是,人们在处理 UI 问题时仍采用一样的权宜之计。
为何组件的响应性处理方法仍然需要编译周期?为么咱们还在运用非标准的 HTML 语法和一系列自定义指令?为么 UI 的描述仍然是以命令式的方式进行?为何技术界还在奋斗模仿 HTML?
让咱们从最后一个问题起始探讨。
HTML 到底是不是合适的抽象层?
这个问题可能会诱发不少争议,但事实上,HTML 仅仅是 DOM(文档对象模型)树的一种表现形式,而这种表现形式并非必定是最优的。确切地说,浏览器处理的不是 HTML,而是 DOM 节点。一个完整的 DOM 节点应该包含以下七类属性:
属性(Attributes)
事件处理器(Event Handlers)
样式(Styles)
自定义数据属性(Data-* Attributes)
可见性(Visibility)
文本内容(Text Content)
子节点(Children)
遗憾的是,许多研发者要么无认识到这种繁杂性是不可避免的,要么便是不愿承认。几乎所有现有的 UI 处理方法都在运用一种过于简化的方式试图把这种繁杂性忽略掉,这是不可取的。
这些处理方法都试图把 DOM 节点属性的多样性简化为一个扁平的属性列表,这种做法显然不切实质。即便将七大类 DOM 节点属性简化为一张扁平的属性列表,这些属性的多样性仍然会存在,只是变成为了一堆难以管理的碎片信息。
复杂性重点有两类:人为引入的繁杂性和自然存在的繁杂性。人为引入的繁杂性一般来自库、框架、编程语言和设计范式等。而自然繁杂性则是平台本身固有的,用于处理特定行业的基本问题。优秀的工程师会奋斗减少人为引入的繁杂性,同期积极面对和处理自然繁杂性。咱们应当再也不回避这种自然存在的繁杂性,而是应更加尊重和理解咱们所运用的平台。
Rich Harris 发布了一则精彩的视频,仔细解释了 getter 和 setter 的实质运作原理,并回复了公众对 Svelte 新响应机制的疑惑。然而,他无对“需要编写更加多代码”这一观点进行充分解释。最后目的不仅是编写更少的代码,而是用最少量的代码知道地表达应用的意图。倘若某项技术所强调或独一供给的便是“简单性”,那样你可能就忽略了有些关键的细节。这些问题迟早会从其他方向显现。
实质上,选取 onClick={...}, on:click={...}和 @click="..." 并无多少差异。
这种处理方法之因此存在,某种程度上是能够理解的:
技术栈运用起来简单,但设计困难。
初期应用一般较为简单,因此呢基本的模板就可满足 DOM API 的需要。
这种代码的性能直到近几年(约 4-5 年)才真正达到了足够好的水平。
重点原由实质上是:
你需要投入海量时间进行实验,并愿意接受新的现实和当前办法的不足。
令人遗憾的是,大都数人几乎无多余的时间来做这些事情。我不怪罪于她们,每一个人都有自己的局限性。但这种局面每年令我感到越发懊丧。难道你们不觉得针对 HOCs、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>) : }</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
<!-- Svelte --><div>{#if showMessage} <h1>Hey there</h1> {/if}</div>...<ConditionalComponent showMessage={true} />首要,让咱们聊聊视图树(View Tree)内部的 if语句。是不是能够用空值或某种插件组件做为回退呢?这是一种指令还是模板块?需要知道的是,这种设计在 DOM API 中是不存在的,只能叫作其为一种权宜之计。问题不仅在于命名,更在于全部概念的设立。例如,v-if 和 {#if ...} 便是这般的表现。
Vue 无疑是这一现象的重要表率。你有两种选取:要么频繁地创建和销毁组件,要么简单地隐匿组件(经过 display: none等)。在 2023 年,这种实践已然过时,是对浏览器和 DOM API 的一种不尊重。
当然,问题并非仅存在于 Vue。以 React 为例,其函数组件因为 hooks 机制的特性,内容经常充满副功效。这引起了即使无必要,亦会过度依赖重新渲染来重新计算副功效和更新数据。
平常的观点是:“React 会引起额外的重新渲染,但其核心机制目的是优化性能并保持 UI 与应用数据的同步。”然而,实质操作中,大都数研发者都在奋斗减少重新渲染。像useMemo 这般的优化办法,并不可保准避免额外的渲染。
显然,这些都是权宜之计和不必要的妥协。快速和优化的重新渲染并不可处理基本问题。真正的处理方法应在于消除重新渲染这一现象。
这能够经过全部界面树的静态初始化来达到。简言之,每一个元素(或更准确地说,堆栈内元素的回调函数)应只计算和调用一次,以实现响应式值和节点的绑定。此后,只需按照 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。倘若这些技术一旦失去主流地位,又将怎样防止它们沦为难以守护的遗留系统?
这诱发出一个新的问题:为何无论是研发者还是框架的创造者,都连续地采用与平台习惯相违背的技术?
深入探索 DOM API
继续讨论现有问题的潜在处理方法,让咱们将目光转向 DOM API。这是一个历经数年精研且功能丰富的库。有些功能实质上是你不可仅经过属性(props)来规避的。
当面对要求渲染的需要时,DOM API 供给了多个处理方法,如 node.append() 和 node.appendChild() 办法,还有 node.remove() 办法以及 node.isConnected 属性。这些 API 让咱们能够随时向 DOM 树添加或移除节点,并检测节点是不是与 DOM 树连接。React 便是基于这般的原理进行操作的。
这儿需要指出的是,许多框架或库在组件设计时,无给予 DOM 节点状态管理足够的注重。实质上,组件自己应当负责管理与 DOM 树连接的节点以及这些节点的子节点的状态,而不该该由外边模块来进行。思虑以下代码示例:
export function Component({ showMessage }) { h(div, () => { h(h1, { text: Hey there, visible: showMessage, }) })}这儿并无运用任何特殊的语法或扩展,亦无试图隐匿任何基本规律。它仅仅是一个用于方便 DOM 操作的普通 JavaScript 函数。在应用程序中,这般的组件依然能够像普通函数同样运用:
using(body, () => { Component({ showMessage: true })})这种设计思路受到了 SwiftUI 和 Flutter 的影响。其中,第二个回调参数是 SwiftUI 的嵌套组件块的替代品,而visible 属性则与 Flutter 中的同名属性相对应。值得重视的是,这儿的 visible 并非 Vue 的“hack”,而是用于直接插进或移除 DOM 子树的属性。
总而言之,咱们无需额外发明抽象语法来模拟咱们需要的行径。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 // 运用 user}更值得重视的是,代码中所有用到的变量或属性都支持响应式更新。这寓意着,当用户列表或其关联属性有所改变,这些改变会即时反映在最后的布局中。
另外,这种 list办法实现并不像表面上看起来那样简单。系统会预生成用于该应用的模板(这儿的模板指的是 JS 模板,与 Vue 或其他框架的模板区别)。因此,每当响应式变量users 出现变化时,咱们只需利用已然预设好的模板生成一个新的实例,而不是在运行时重新计算所有元素。
但不幸的是,许多现代处理方法利用虚拟 DOM 和调和(Reconciliation),引入了周期来双重检测从组件返回的结构的变化。这就引起了重绘和性能问题。以及有些人为的约束。不得不说,Svelte 做得很好。Svelte 不依赖于虚拟 DOM,而是运用编译器将组件转换为 JavaScript。这个 JS 代码会非常有效,然则,遗憾的是,其他问题亦显现了:不必要的构建过程,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), ), }, }); }); });});在这段代码中,changeUsername 和 changePassword 是用于响应用户输入并动态更新相应值的事件处理器。而 fields 是一个包括关联属性的响应式对象。实质上,这个 fields 对象便是一个数据存储,不论个人爱好怎样。咱们还采用了 map() 办法来创建一个派生属性,这在 Svelte 中对应 $derived。这个派生属性会在用户名或秘码出现变更时同步更新,从而改变提交按钮的状态。
针对这段代码,你初次浏览可能会有以下几种看法:
这种写法不太平常
代码过于繁琐
需要手动处理 DOM API 的各个细节
然而,事实真的是这般吗?
首要,代码中并无什么反常的内容,这些都是基本的 JavaScript 函数。详细来讲:
attr - 用于定义节点属性的对象。
style - 用于设置节点样式的对象。
classList - 一个包括节点类名的数组,该名叫作与 DOM API 的官方命名 一致。
handler - 节点事件处理器的配置对象,如你所见 config: { prevent: true }。
spec- 实质上是一个包装函数,用于描述节点属性类别。当组件的回调函数内有子元素时,你能够在组件的最外层(或回调函数中的任何地区,尽管这并不是重点)设定一组属性。
确实,相比于 React、Vue、Svelte、Solid 等,这种方式更显繁琐。但这般的设计方式不会让你对前端的繁杂性有所误解,亦不会给你一个所说的“简单处理方法”。事实上,你应该面对这些现实,而不是逃避。这会让你更清晰地认识应用是怎样构建的。虽然这种办法比较繁琐,但它真的繁杂到让你难以理解吗?我相信你完全能够理解每一行代码的功效。
其次,你并不需要直接操作 DOM API。你真正需要的是一个简洁的 JavaScript API 用于与 DOM 进行交互。我坚信,视图树的管理应该由原生工具来完成。哪些需要手动添加、删除、更新树结构的操作都能够由底层技术来完成。
我要再次强调,我的目的不是推崇某个特定的新技术处理方法。相反,我期盼能指显现有方法中存在的问题,并讨论怎样用原生工具来处理这些问题,而无需重新发明轮子。
最后,我想提醒大众,尊重你所运用的平台是很重要的。其他平台的研发人员都已然学会了怎样与她们的平台和谐共处。与此区别,前端研发人员有时会尝试用新的、还不足成熟的处理方法来处理问题。
事情并没那样简单
用简单的例子来展示实质场景是有难度的,由于某些问题在简单的例子中可能不会显现。
例如,在一个真实应用中,你可能会这般描述一个表单:
export const Auth = ()=> { h("div", () => { spec({ classList: ["mt-10", "max-w-sm", "w-full"], }); h("form", () => { Input({ type: "email", label: "电子邮件",inputChanged: authForm.fields.email.changed, errorText: authForm.fields.email.$errorText, errorVisible: authForm.fields.email.$errors.map(Boolean), }); Input({ type: "password", label: "秘码",inputChanged: authForm.fields.password.changed, errorText: authForm.fields.password.$errorText, errorVisible: authForm.fields.password.$errors.map(Boolean),}); Button({ text: "创建", event: authForm.submit, size: "base", prevent: true, variant: "default", }); ErrorHint($authError, $authError.map(Boolean)); }); });};...exportconst 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", () => { constlocalInputChanged = 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 本质上是一个响应式数据结构,而 Event 则是用于修改这些数据或触发某种效果的信号,这些都能够从任何库中获取。
这儿的关键点是怎样描述视图和视图规律。即使视图描述存在差异,亦不寓意着必定要寻求全新的处理方法。你是不是确信现有方法已然是最优的?倘若不是,你能知道指出原由吗?
为何咱们需要改变思维方式
你亦许会误以为我对现有的主流处理方法持批判态度,但事实并非如此。我相信这些技术在必定程度上都是必要的。或说,曾经是必要的,最少针对通常的前端研发而言。但我不爱好的是,咱们似乎陷入了过去十年的思维模式,无人在主流中试图提醒研发者重视这个问题。结果,咱们的应用程序仍然无可重现性,况且即使是简单的任务,亦需要很高的劳动强度。
我并不意见咱们抛弃所有现有的处理方法,这般做是愚蠢的。我亦不意见每次都自己手动操作 DOM。这些工作应由库 / 框架 / 技术 / API / 或其他何种形式来完成。我只想说,亦许是时候停止实施存在严重设计缺陷的独特的“雪花”类型解决方法了?并起始利用咱们自己的平台供给给咱们的东西,发挥其功效。亦许不是以之前呈现的形式,但以某种其他形式。最少在我看来,存在潜在的可能性。
然而,非常多人并无认识到当前做法的局限性,反而继续在有些独特的但有严重设计缺陷的处理方法中做选取。
前端研发者应该尊重自己的平台,不要被过时的技术所捆绑,而要勇于面对现实和进行技术创新。
你认为 UI 架构在过去十年停滞的原由是什么?你认为应该从那些方面进行创新性突破?欢迎在评论区留言讨论。
参考链接
runes 的仔细介绍:https://svelte.dev/blog/runes
node.append():https://developer.mozilla.org/en-US/docs/Web/API/Element/append
node.appendChild():https://developer.mozilla.org/en-US/docs/Web/API/Node/appendChild
node.remove():https://developer.mozilla.org/en-US/docs/Web/API/Element/remove
node.isConnected:https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected
DOM API 的官方命名:https://developer.mozilla.org/en-US/docs/Web/API/Element/classList
欢迎参与 CSDN 重磅发起的《2023 AI 研发者生态调查问卷》,分享您真实的 AI 运用体验,更有精美好礼等你拿!