Web 框架的替代方案
點擊上方?前端Q,關(guān)注公眾號
回復加群,加入前端Q技術(shù)交流群
上周,我們從框架試圖解決哪些核心問題的角度出發(fā),考察了使用框架的不同好處和代價,重點放在聲明性編程、數(shù)據(jù)綁定、反應性、列表和條件。今天,我們來看看能否在 Web 平臺上找到替代方案。
在探索沒有框架的生活中,一個看似不可避免的結(jié)果是,推出自己的框架,以進行反應性數(shù)據(jù)綁定。因為之前已經(jīng)試過了,也見識到了這種做法的代價有多大,所以我決定在這次探索中,遵循一條原則:不要推出自己的框架,而要看看能否直接利用 Web 平臺,這樣就能降低對框架的需求。如果你打算推出自己的框架,那么需要考慮的是,本文沒有涉及一系列的成本。
Web 平臺已經(jīng)提供了一個開箱即用的聲明性編程機制:HTML 和 CSS。這種機制是成熟的、經(jīng)過良好測試的、流行的、廣泛使用的,并且有文檔記錄。然而,它并沒有提供明確的數(shù)據(jù)綁定、條件渲染和列表同步的內(nèi)置概念,并且反應性是一個細微的細節(jié),散布于多個平臺的特性之中。
在瀏覽常見框架的文檔后,我就直接找到了第一部分中提及的特性。我在閱讀諸如 MDN 之類的 Web 平臺的文檔時,會發(fā)現(xiàn)很多工作方式都是雜亂無章的,沒有數(shù)據(jù)綁定,沒有列表同步,也沒有反應性的結(jié)論性表述。我會試圖為在 Web 平臺上解決這些問題提供指導,而不是用框架(也就是說,走普通路線)。
讓我們回到錯誤標簽的示例上。在 ReactJS 和 SolidJS 中,我們會創(chuàng)建聲明性代碼,并將其轉(zhuǎn)化為命令性代碼,向 DOM 中加入標簽或者刪除標簽。在 Svelte 中,生成這些代碼。
但是,如果我們根本沒有這些代碼,而是用 CSS 來隱藏和顯示錯誤標簽呢?
<style>label.error { display: none; }.app.has-error label.error {display: block; }style><label class="error">Messagelabel><script>app.classList.toggle('has-error', true);script>
在這種情況下,反應性是在瀏覽器中處理的:應用的類變化會傳播到它的后代,直到瀏覽器的內(nèi)部機制決定是否渲染標簽。
這種技術(shù)有幾個具有以下優(yōu)點:
包大小為零。
無構(gòu)建步驟。
變化傳播經(jīng)過優(yōu)化和良好的測試,在本地瀏覽器代碼中,避免了不必要的昂貴的 DOM 操作,如追加和刪除。
選擇器是穩(wěn)定的。在這種情況下,你可以指望標簽元素的存在。你可以對它應用動畫,而不必依賴復雜的結(jié)構(gòu),如“過渡組”。你可以在 JavaScript 中保持對它的引用。
如果標簽被顯示或隱藏,你可以在開發(fā)工具的樣式面板中看到原因,它顯示了整個級聯(lián),即最終導致標簽可見(或隱藏)的規(guī)則鏈。
即便你閱讀了本文,并且選擇繼續(xù)使用框架工作,但是要讓 DOM 保持穩(wěn)定,使用 CSS 的方式發(fā)生改變,這個想法還是很強大的。想想看,這對你來說有什么用處。
在大量使用 JavaScript 的單頁應用(single-page application,SPA)時代到來之前,表單是創(chuàng)建包含用戶輸入的 Web 應用的主要方式。傳統(tǒng)上,用戶填寫表格并點擊“提交”按鈕,服務器端的代碼就會處理響應。表單是數(shù)據(jù)綁定和互動性的多頁面應用版本。難怪具有 input 和 output 基本名稱的 HTML 元素是表單元素。
表單 API 應用范圍廣,歷史悠久,因此它具有一些潛在優(yōu)勢,可以幫助人們解決在傳統(tǒng)上認為不能通過表單來處理的問題。
表單可以通過名稱訪問(使用 document.forms),每個表單元素可以通過其名稱訪問(使用 form.elements)。此外,與一個元素相關(guān)的表單也可以被訪問(使用 form 屬性)。這不僅包括 input 元素,還包括其他表單元素,如 output、textarea 和 fieldset,這允許在一個樹中對元素進行嵌套訪問。
在上一節(jié)的錯誤標簽示例中,我們展示了如何反應性地顯示和隱藏錯誤信息。這就是我們在 React 中更新錯誤信息文本的方法(在 SolidJS 中也類似):
const [errorMessage, setErrorMessage] = useState(null);return?<label?className="error">{errorMessage}label>
當我們有一個穩(wěn)定的 DOM 和穩(wěn)定的樹形表單和表單元素時,我們可以做以下事情:
<form name="contactForm"><fieldset name="email"><output name="error">output>fieldset>form><script>function setErrorMessage(message) {document.forms.contactForm.elements.email.elements.error.value = message;}script>
這個原始格式看起來相當冗長,但是它也非常穩(wěn)定、直接和高效。
通常,當我們建立一個 SPA 時,我們有某種類似 JSON 的 API,我們用它來更新我們的服務器,或我們使用的任何模型。
這將是一個熟悉的示例(為了可讀性,用 Typescript 編寫):
interface Contact {id: string;name: string;email: string;subscriber: boolean;}function?updateContact(contact:?Contact)?{?…?}
在框架代碼中,通過選擇輸入元素并逐段構(gòu)建對象來生成這個聯(lián)系對象是很常見的。通過對表單的正確使用,有一個簡潔的替代方案。
<form name="contactForm"><input name="id" type="hidden" value="136" /><input name="email" type="email"/><input name="name" type="string" /><input name="subscriber" type="checkbox" />form><script>updateContact(Object.fromEntries(new FormData(document.forms.contactForm));script>
通過使用隱藏的輸入和有用的 FormData 類,我們可以在 DOM 輸入和 JavaScript 函數(shù)之間無縫轉(zhuǎn)換數(shù)值。
通過結(jié)合表單的高性能選擇器穩(wěn)定性和 CSS 反應性,我們可以實現(xiàn)更復雜的 UI 邏輯:
<form name="contactForm"><input name="showErrors" type="checkbox" hidden /><fieldset name="names"><input name="name" /><output name="error">output>fieldset><fieldset name="emails"><input name="email" /><output name="error">output>fieldset>form><script>function setErrorMessage(section, message) {document.forms.contactForm.elements[section].elements.error.value = message;}function setShowErrors(show) {document.forms.contactForm.elements.showErrors.checked = show;}script><style>input[name="showErrors"]:not(:checked) ~ * output[name="error"] {display: none;}style>
請注意,在這個示例中并沒有使用類:我們從表單的數(shù)據(jù)中開發(fā) DOM 的行為和風格,而不是通過手動更改元素的類。
我不喜歡過度使用 CSS 類作為 JavaScript 選擇器。我認為它們應該被用來將風格相似的元素組合在一起,而不是作為改變組件風格的一種萬能機制。
與級聯(lián)一樣,表單是內(nèi)置于 Web 平臺的,其大部分特性是穩(wěn)定的。這意味著更少的 JavaScript,更少的框架版本不匹配,而且沒有“構(gòu)建”。
默認情況下,表單是可訪問的。如果你的應用程序正確地使用表單,就不需要 ARIA 屬性、“輔助插件”和最后一分鐘的審核。表單適合于鍵盤導航、屏幕閱讀器和其他輔助技術(shù)。
表單帶有內(nèi)置的輸入驗證特性:通過 regex 模式進行驗證,對 CSS 中無效和有效表單進行反應性驗證,處理必需表單和可選表單,等等。為了享受這些特性,你不需要看起來像表單的東西。
表單的 submit 事件是非常有用的。例如,它允許在沒有提交按鈕的情況下捕獲“Enter”鍵,并允許通過 submitter 屬性來區(qū)分多個提交按鈕(正如我們將在后面的 TODO 示例中看到的)。
默認情況下,元素與它們所包含的表單相關(guān)聯(lián),但也可以使用 form 屬性與文檔中的任何其他表單相關(guān)聯(lián)。這使我們能夠在不對 DOM 樹產(chǎn)生依賴的情況下進行表單關(guān)聯(lián)。
使用穩(wěn)定的選擇器有助于實現(xiàn) UI 測試自動化。我們可以使用嵌套的 API 作為一種穩(wěn)定的方式來鉤住 DOM,而不管它的布局和層次結(jié)構(gòu)如何。form > (fieldsets) > element 的層次結(jié)構(gòu)可以作為你的文檔的互動骨架。
框架提供了它們自己表達可觀察列表的方式。現(xiàn)在很多開發(fā)者也依賴提供這種功能的非框架庫,如 MobX。
通用的可觀察列表的主要問題在于它們是通用的。這以性能為代價增加了便利性,而且還需要特殊的開發(fā)者工具來調(diào)試那些庫在后臺做的復雜動作。
使用這些庫并理解它們的作用是可以的,無論選擇什么樣的 UI 框架,它們都是有用的,但使用替代方案可能不會更復雜,而且可以避免一些在你試圖推出自己的模型時產(chǎn)生的陷阱。
CHACHA——也被稱為變化通道(Changes Channel)——是一個雙向流,其目的是通知意圖方向和觀察方向的變化。
在意圖方向上,UI 將用戶意圖的變化通知給模型。
在觀察方向上,模型將對模型所做的改變通知給 UI,而這些改變需要顯示給用戶。
這也許是一個有趣的名字,但它不是一個復雜或新穎的模式。雙向流在 Web 和軟件中隨處可見(例如,MessagePort)。在這種情況下,我們正在創(chuàng)建一個雙向流,它有一個特殊的目的:向 UI 報告實際的模型變化,并向模型報告意圖。
CHACHA 的接口通??梢詮膽玫囊?guī)范中導出,而不需要任何 UI 代碼。
例如,一個允許你添加和刪除聯(lián)系人并從服務器加載初始列表的應用程序(帶有刷新選項)可以有一個 CHACHA,它看起來像這樣:
interface Contact {id: string;name: string;email: string;}// "Observe" Directioninterface ContactListModelObserver {onAdd(contact: Contact);onRemove(contact: Contact);onUpdate(contact: Contact);}// "Intent" Directioninterface ContactListModel {add(contact: Contact);remove(contact: Contact);reloadFromServer();}
請注意,這兩個接口中的所有函數(shù)都是無效的,只接收普通對象。這是故意的。CHACHA 被構(gòu)建成一個通道,有兩個端口來發(fā)送消息,這使得它可以在 EventSource、HTML MessageChannel、服務工作者或任何其他協(xié)議中工作。
CHACHA 的好處是,它們很容易測試。你發(fā)送動作并期待對觀察者的特定調(diào)用作為回報。
HTML 模板是存在于 DOM 中的特殊元素,但不會被顯示。它們的目的是生成動態(tài)元素。
當我們使用 template 元素時,我們可以避免在 JavaScript 中創(chuàng)建元素和填充它們的所有模板代碼。
下面將使用 template 為列表添加名稱:
<ul id="names"><template><li><label class="name" />li>template>ul><script>function addName(name) {const list = document.querySelector('#names');const item = list.querySelector('template').content.cloneNode(true).firstElementChild;item.querySelector('label').innerText = name;list.appendChild(item);}script>
通過使用列表項的 template 元素,我們可以在原始 HTML 中看到列表項——它不是用 JSX 或其他語言“渲染”的。你的 HTML 文件現(xiàn)在包含了應用程序的所有 HTML——靜態(tài)部分是渲染的 DOM 的一部分,而動態(tài)部分在模板中表達,準備在時機成熟時被克隆并追加到文檔中。
TodoMVC 是一個 TODO 列表的應用規(guī)范,用于展示不同的框架。TodoMVC 模板帶有現(xiàn)成的 HTML 和 CSS,幫助你專注于框架。
你可以在 GitHub 資源庫中使用這個結(jié)果,并且可以獲得完整的源代碼。
我們將從規(guī)范開始,并使用它來構(gòu)建 CHACHA 接口:
interface Task {title: string;completed: boolean;}interface TaskModelObserver {onAdd(key: number, value: Task);onUpdate(key: number, value: Task);onRemove(key: number);onCountChange(count: {active: number, completed: number});}interface TaskModel {constructor(observer: TaskModelObserver);createTask(task: Task): void;updateTask(key: number, task: Task): void;deleteTask(key: number): void;clearCompleted(): void;markAll(completed: boolean): void;}
任務模型中的函數(shù)直接來自規(guī)范和用戶可以做的事情(清除已完成的任務,將所有任務標記為已完成或正在進行,獲得正在進行和已完成的計數(shù))。
請注意,它遵循 CHACHA 的準則。
有兩個界面,一個是動作的,一個是觀察的。
所有的參數(shù)類型都是基元或普通對象(很容易翻譯成 JSON)。
所有的函數(shù)都返回 void。
TodoMVC 的實現(xiàn)使用 localStorage 作為后端。
該模型非常簡單,與關(guān)于 UI 框架的討論沒有多大關(guān)系。它在需要的時候保存到 localStorage,并在某些情況發(fā)生變化時向觀察者觸發(fā)回調(diào),這些變化可能是用戶操作的結(jié)果,也可能是模型第一次從 localStorage 加載的時候。
接下來,我將采用 TodoMVC 模板,并將其修改為面向表單的模板:表單的層次結(jié)構(gòu),輸入和輸出元素代表可以用 JavaScript 改變的數(shù)據(jù)。
我怎么知道某個東西是否需要成為表單元素?作為一個經(jīng)驗法則,如果它與模型中的數(shù)據(jù)綁定,那么它就應該是一個表單元素。
完整的 HTML 文件是可用的,但這里是其主要部分:
<section class="todoapp"><header class="header"><h1>todosh1><form name="newTask"><input name="title" type="text" placeholder="What needs to be done?" autofocus>form>header><main><form id="main">form><input type="hidden" name="filter" form="main" /><input type="hidden" name="completedCount" form="main" /><input type="hidden" name="totalCount" form="main" /><input name="toggleAll" type="checkbox" form="main" /><ul class="todo-list"><template><form class="task"><li><input name="completed" type="checkbox" checked><input name="title" readonly /><input type="submit" hidden name="save" /><button name="destroy">Xbutton>li>form>template>ul>main><footer><output form="main" name="activeCount">0output><nav><a name="/" href="#/">Alla><a name="/active" href="#/active">Activea><a name="/completed" href="#/completed">Completeda>nav><input form="main" type="button" name="clearCompleted" value="Clear completed" />footer>section>
此 HTML 包括以下內(nèi)容:
我們有一個 main 表單,其中有所有的全局輸入和按鈕,還有一個新的表單用于創(chuàng)建一個新任務。請注意,我們使用 form 屬性將元素與表單聯(lián)系起來,以避免表單中的元素嵌套。
template 元素代表一個列表項,它的根元素是另一個表單,代表與特定任務相關(guān)的互動數(shù)據(jù)。當任務被添加時,這個表單將通過克隆模板的內(nèi)容而被重復。
隱藏的輸入表示不直接顯示的數(shù)據(jù),但用于樣式設計和選擇。
注意這個 DOM 是如何簡潔的。它沒有在其元素中散布類。它包括應用程序所需的所有元素,以合理的層次結(jié)構(gòu)排列。多虧了隱藏的輸入元素,你已經(jīng)可以很好地感覺到以后文檔中可能會有什么變化。
這個 HTML 不知道它將如何被樣式化,也不知道它到底與什么數(shù)據(jù)綁定。讓 CSS 和 JavaScript 為你的 HTML 工作,而不是讓你的 HTML 為某個特定的造型機制工作。這將使你在改變設計時變得更加容易。
現(xiàn)在我們在 CSS 中已經(jīng)有了大部分的反應性,在模型中也有了列表處理,剩下的就是控制器的代碼了,也就是把所有的東西固定在一起的“膠帶”。在這個小程序中,控制器的 JavaScript 大約是 40 行。
下面是一個版本,每個部分都有解釋:
import TaskListModel from './model.js';const?model?=?new?TaskListModel(new?class?{
上面,我們創(chuàng)建了一個新模型。
onAdd(key, value) {const newItem = document.querySelector('.todo-list template').content.cloneNode(true).firstElementChild;newItem.name = `task-${key}`;const save = () => model.updateTask(key, Object.fromEntries(new FormData(newItem)));newItem.elements.completed.addEventListener('change', save);newItem.addEventListener('submit', save);newItem.elements.title.addEventListener('dblclick', ({target}) => target.removeAttribute('readonly'));newItem.elements.title.addEventListener('blur', ({target}) => target.setAttribute('readonly', ''));newItem.elements.destroy.addEventListener('click', () => model.deleteTask(key));this.onUpdate(key, value, newItem);document.querySelector('.todo-list').appendChild(newItem);}
當一個項目被添加到模型中,我們在用 UI 中創(chuàng)建其相應的列表項。
在上面的代碼段中,我們克隆了項目 template 的內(nèi)容,為一個特定的項目分配了事件監(jiān)聽器,并將新的項目添加到列表中。
注意,這個函數(shù),以及 onUpdate、onRemove 和 onCountChange,都是要從模型中調(diào)用的回調(diào)。
onUpdate(key, {title, completed}, form = document.forms[`task-${key}`]) {form.elements.completed.checked = !!completed;form.elements.title.value = title;form.elements.title.blur();}
當一個項目被更新時,我們設置它的 completed 和 title 值,然后 blur(退出編輯模式)。
onRemove(key)?{?document.forms[`task-${key}`].remove();?}當從模型中移除一個項時,我們將從視圖中移除其對應的列表項。
onCountChange({active, completed}) {document.forms.main.elements.completedCount.value = completed;document.forms.main.elements.toggleAll.checked = active === 0;document.forms.main.elements.totalCount.value = active + completed;document.forms.main.elements.activeCount.innerHTML = `${active} item${active === 1 ? '' : 's'} left`;}
在上面的代碼中,當完成的或活動的項目數(shù)量發(fā)生變化時,我們設置適當?shù)妮斎雭碛|發(fā) CSS 反應,并格式化顯示計數(shù)的輸出。
const updateFilter = () => filter.value = location.hash.substr(2);window.addEventListener('hashchange', updateFilter);window.addEventListener('load',?updateFilter);
而我們從 hash 片段中更新過濾器(以及在啟動時)。我們在上面所做的只是設置一個表單元素的值:CSS 處理其余部分。
document.querySelector('.todoapp').addEventListener('submit',?e?=>?e.preventDefault(),?{capture:?true});在這里,我們確保當表單被提交時我們不會重新加載頁面。這一行代碼把這個應用程序變成了一個 SPA。
document.forms.newTask.addEventListener('submit', ({target: {elements: {title}}}) =>model.createTask({title: title.value}));document.forms.main.elements.toggleAll.addEventListener('change', ({target: {checked}})=>model.markAll(checked));document.forms.main.elements.clearCompleted.addEventListener('click', () =>????model.clearCompleted());
而這就處理了主要的操作(創(chuàng)建、標記所有、清除完成)。
完整的 CSS 文件可以供你查看。
CSS 處理了規(guī)范中的很多要求(做了一些有利于無障礙的修正)。我們來看看一些示例。
根據(jù)規(guī)范,“X”(destroy)按鈕只在懸停時顯示。我還添加了一個輔助位,使它在任務被聚焦時可見。
.task:not(:hover,?:focus-within)?button[name="destroy"]?{?opacity:?0?}當 filter 鏈接是當前鏈接時,它會得到一個紅色邊框:
.todoapp input[name="filter"][value=""] ~ footer a[href$="#/"] 。nav a:target {border-color: #CE4646;}
注意,我們可以使用鏈接元素的 href 作為部分屬性選擇器 -- 不需要 JavaScript 來檢查當前的過濾器,并在適當?shù)脑厣显O置一個 selected 類。
我們還使用了 :target 選擇器,這讓我們不必擔心是否要添加過濾器。
title 輸入的視圖和編輯樣式根據(jù)其只讀模式而改變:
.task input[name="title"]:read-only {…}.task input[name="title"]:not(:read-only) {…}
過濾(即只顯示活動的和已完成的任務)是用選擇器完成的:
input[name="filter"][value="active"] ~ * .task:is(input[name="completed"]:checked, input[name="completed"]:checked ~ *),input[name="filter"][value="completed"] ~ * .task:is(input[name="completed"]:not(:checked), input[name="completed"]:not(:checked) ~ *) {display: none;}
上面的代碼可能看起來有點冗長,用 Sass 這樣的 CSS 預處理程序可能更容易閱讀。但它所做的事情很簡單:如果過濾器處于 active 狀態(tài),而 completed 的復選框被選中,或者相反,那么我們就會隱藏復選框及其同級。
我選擇在 CSS 中實現(xiàn)這個簡單的過濾器,以顯示它能走多遠,但如果它開始變得棘手,那么把它移到模型中是完全有意義的。
我相信,框架為實現(xiàn)復雜的任務提供了方便的方法,而且它們有超越技術(shù)的好處,比如使一組開發(fā)人員向特定的風格和模式看齊。Web 平臺提供了許多選擇,而采用一個框架可以讓每個人至少部分地在這些選擇上達成一致,這是有價值的。另外,聲明式編程的優(yōu)雅性也是值得稱道的,而且組件化的大特點也不是我在這篇文章中所處理的。
但請記住,替代模式是存在的,通常成本較低,而且不一定需要較少的開發(fā)者經(jīng)驗。允許自己對這些模式感到好奇,即使你決定在使用框架時從它們中挑選。
保持 DOM 樹的穩(wěn)定。它啟動了一個連鎖反應,使事情變得簡單。
如果可以的話,依靠 CSS 的反應性而不是 JavaScript。
使用表單元素作為表示互動數(shù)據(jù)的主要方式。
使用 HTML template 元素而不是 JavaScript 生成的模板。
使用雙向的變化流作為模型的接口。
作者簡介:
Noam Rosenthal,Web 平臺顧問,WebKit 和 Chromium 的貢獻者,標準編輯,也是經(jīng)驗豐富的 Web 開發(fā)者。他的工作主要是在 Web 開發(fā)和瀏覽器 / 標準開發(fā)之間架起橋梁。
原文鏈接:
https://www.smashingmagazine.com/2022/02/web-frameworks-guide-part2/

往期推薦



最后
歡迎加我微信,拉你進技術(shù)群,長期交流學習...
歡迎關(guān)注「前端Q」,認真學前端,做個專業(yè)的技術(shù)人...


