前端主流框架:自欺欺人的真相揭秘

发表时间: 2023-11-01 15:30

作者 | Moonthoughts

译者 | 核子可乐

策划 | 丁晓昀

怎么样,这个开头够不够标题党?但大家千万别误会,我并不是要侮辱接下来列出的这些技术,而是想跟各位讨论一个困扰了我很久的问题。


另外,本文并非软文,不会向大家推销什么“完美的替代方案”。


一切要从 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 能不能解救我们?


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