【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、信号等概念,我并非第一次接觸,我在 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>
實際上,它們看起來都不錯。
現在,讓我們嘗試添加一些條件渲染 :
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
<!-- 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 結構圖來處理流程和事件。
當然,其他技術同樣存在不足,但這是另一個話題。那麽,渲染列表呢?
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 ) ) ; } ) ; } ) ;};
...
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 本質上是一個響應式數據結構,而 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 使用體驗,更有精美好禮等你拿!