现代前端框架的实现原理都可以用以下公式进行概括:
UI = f(state)
其中:
-
state ------ 当前的视图的状态
-
f ------ 框架内部的运行机制
-
UI ------ 宿主环境的视图
这个公式说明,框架内部运行机制根据当前状态渲染视图 ,这也能看出现代框架的两个重要特性:数据驱动和 描述 UI。
如何描述 UI 界面?
现状
现代前端 UI 框架还是沿用类 HTML 解决方案,主要理由就是"开发人员更熟悉"。但实际上,它们都已经扩展了 HTML,扩展的部分已经必不可少,实际可能远离了初衷 - "开发人员更熟悉"。
Vue 描述 UI 界面的部分代码如下:
html
<div id="app">
<button @click="count++">
Count is: {{ count }}
</button>
</div>
TSX 描述 UI 界面的部分代码如下:
tsx
export default function MyApp() {
return (
<div>
<h1>Welcome to my app</h1>
<MyButton onClick={{handleClick}} />
</div>
);
}
拥抱 Dom API
目前所有的前端 UI 框架对于描述 UI 界面的方式都是通过拓展 HTML 来实现的。因为 HTML 不支持直接运行 JS 代码,所以具体的扩展实现对开发者而言都是黑盒。同时随着框架演变,出现越来越多的魔法实现、指令和语法糖。对于开发者而言无疑增会加学习和使用成本。
通过解读框架对于描述 UI 界面的实现方法,可以发现都是通过 DOM API 来最后生成 HTML。因此,如果绕过扩展的 HTML,直接使用 DOM API 来描述 UI 界面是可行的。但 DOM API 是命令式,直接使用会产生大量样板代码,同时已经不符合当前开发者的习惯和思维。那么就需要对 DOM API 进行轻量封装,实现声明式的方法。
Himmel 描述 UI 界面的部分代码如下:
typescript
function App() {
return Div([
Div([
Img(himmelLogo).className("logo").attrs({ alt: "himmel Logo" }),
Img(viteLogo).className("logo").attrs({ alt: "TypeScript Logo" }),
]).className("flex gap-2 justify-center"),
H1("Himmel + TypeScript"),
])
}
因为不需要扩展 HTML,所有没有构建过程(当然也会损失构建中的性能优化,但这并非不可弥补),所有实现对开发者都是透明直接的。也不存在语法糖,使用 JS 便可以描述复杂且完整的 UI 界面。
如何驱动 UI 界面?
所有的前端框架都具有一套响应式机制来驱动 UI 界面。从本质而言,响应式可以认为是通过监听一系列状态,根据状态的变化来更新 UI 的过程。
React 的实现是通过订阅发布模式监听在 JSX 中通过 useState
创建的状态,然后通过 diff 算法,计算出整个 UI 界面(虚拟 dom 树)中的变化部分,然后仅更新变化的部分。存在的缺点是:
-
JSX 内状态的订阅和 state 代码书写顺序有关(具体查阅),这非常不符合开发逻辑。
-
状态只能在 JSX 中被使用和更新,传递需要通过 props 或 context 等特定 API,使用成本较大。
-
状态变更后,需要通过 diff 算法(后面 Solid 证明 diff 算法不是必须)来知道具体变化的部分。
-
更新 UI 界面时,是通过 re-render 方式,需要开发者格外小心将 once-run 的代码通过各种手段避免在 re-render 时重复执行。
tsx
function MyButton() {
const [count, setCount] = useState(0); // define state
function handleClick() {
setCount(count + 1); // update state
}
return (
<button onClick={handleClick}>
{/* use state */}
Clicked {count} times
</button>
);
}
Vue 的实现是通过 Proxy 来监听在 Setup
方法中通过 ref 创建的状态,然后同样通过 diff 算法,计算变化部分。Vue 的实现避免 React 中 state 要注意顺序的问题,当其他缺陷是同样存在的。
typescript
import { createApp, ref } from 'vue'
createApp({
setup() {
return {
count: ref(0) // define state
}
}
}).mount('#app')
html
<div id="app">
<button @click="count++"> // update state
Count is: {{ count }} // use state
</button>
</div>
Solid 的实现是通过 Proxy 来监听文件通过 createSignal
方法创建的状态,然后更新状态值对应的 UI 部分,因为没有虚拟DOM,所有不存在 re-render,每次更新都是在真实的 dom 上操作。
javascript
import {
createSignal,
onCleanup,
} from "https://cdn.skypack.dev/solid-js";
import { render } from "https://cdn.skypack.dev/solid-js/web";
import html from "https://cdn.skypack.dev/solid-js/html";
const App = () => {
const [count, setCount] = createSignal(0), // define state
timer = setInterval(() => setCount(count() + 1), 1000); // update state
onCleanup(() => clearInterval(timer));
return html`<div>${count}</div>`; // use state
// or
return h("div", {}, count);
};
render(App, document.body);
Solid 是最直观的使用响应式方式的框架,Himmel 采用了完全相同的实现,只是在 api 命名上有所区别而已。因为 Himmel 没有使用 JSX,所以无法通过构建来将状态值自动包裹进 getter 函数中。但 Himmel 使用单独的 get
和 dispatch
两个方法来获取最新状态值和更新状态值,却同样可以减少大量定义 getter、setter 方法的代码。
typescript
import { dispatch, get, signal } from "himmel/signal";
const countSignal = signal(0); // define state
const hideSignal = signal(true);
export default function App() {
return Div(
Button(() => `count is ${get(countSignal)}`) // use state
.attrs({ id: "counter" })
.onClick(() => {
dispatch(countSignal, (old) => old + 1); // update state
})
)
}
总结
我们的最终目标不是编写更少的代码,而是在明确表达应用程序且提供良好维护性的前提下编写更少的代码。如果这项技术单纯强调"简单",那可能会包含过多的魔法,而掩盖掉重要的细节,比如 Vue 的种种指令,React 的 useEffect 。但掩盖掉的细节带来的副作用最终还是会暴露在开发者面前,只是角度有所不同,开发者仍然不得不面对。
这并非劝大家直接放弃所有现成的解决方案。但对于开发人员值得思考的是,如果对那些具有严重设计缺陷的"雪花" 前端框架进行重新设计,是否有新的方式来使得实现应用程序的最终代码会更好?
但如果那些强大的前端 UI 框架还没有意识到当前实践的缺陷,反而在错误的道路上越走越远。那么有新的挑战者出现也并非坏事。