谈谈数据驱动的UI框架
import framework # 前端框架
renderer = framework.compile(template) # 模板,即UI的描述
UI = renderer.render(state) # UI仅仅是数据的状态函数
抽象来看,这就是数据驱动的前端框架做的全部事情
内容、逻辑、样式
根据我自己的理论,一个文档可以分为三部分:
- 内容 —— 将数据按一定格式呈现。比如一个
markdown
文档。这决定了它基本上得是线性的结构 - 逻辑 —— 描述内容中元素间非线性的关系。比如预录制的ppt动作
- 样式 —— 内容呈现的外观等形式。比如
markdown
的主题
而一个应用程序,它与文档不同的地方就在于,它是动态的,尤其是内容是动态的。然而动态中依然有静态的成分,这就是格式。
内容 = 格式 + 数据
而格式与样式有一定联系,有时候也会写在一起。比如html中inline的style,本质上就是样式写在格式里
WEB做对了什么
传统的GUI
程序一般只会用它本身的编程语言来写,比较像MVC
架构:模型层执行后端逻辑,视图层就是前端的格式和样式,控制层负责相应前端的事件。它没有区分格式和样式,所以在创建GUI代码中经常见到(用Java
语法高亮的伪代码):
void greet(String name) {
var vBox = new VBox(10, 20, 30, 40, Align.LEFT);
this.root.appendChild(vBox);
var label = new Label();
label.setFontFamily("mono");
label.setFontColor(Color.BLACK);
label.setValue("Hello " + name + "!");
vBox.appendChild(label);
}
可以认为,“加一段格式化后的文字”这一目标,是由数据生成内容;然而实现它的这些代码却要和样式混在一起,这没必要。而WEB就是提供了一种更理想的编程方式:描述内容就用描述格式的语言,用代表数据的变量占位:
<>
<div>
Hello, {name}!
</div>
</>
样式则用键值对表示,而且可以表达级联的属性(例子中未体现):
div {
font-family: monospace;
color: black;
}
同样是9行,后者不比前者清晰明了多了吗
数据驱动
相比之下,MVVM
让开发者不需要关注底层的前端事件,让框架自动实现数据与内容的同步在现代的WEB系统中:
- 数据用键值对的方式表示,并用跨语言的编码方式传输(比如
JSON
,或者用可读性换效率的msgpack
等) - 格式用基于
XML
的HTML
语言描述 - 样式用灵活的
CSS
描述,既可以内嵌于格式中也可以分离出来 - 逻辑用动态类型的脚本语言
JavaScript
实现
html
是UI的一种抽象表达了。与写GUI相比起来,WEB项目其实把渲染过程中排版的细节从应用程序中剥离出去了,开发者从编写UI变成编写html,真正由html渲染出图形的工作由浏览器来完成
我前段时间还在想,为什么网页要是
html
而不是json
,毕竟写前端时真的有很多功夫都花在由json
转html
上,也不见得html
比json
可读性高。后来发现,其实html
或者说xml
是一种更面向对象的语言。我觉得更准确的说法是“面向类”,因为json
其实就是一种描述对象的编码语言,但是它没有保留“类”的信息,而xml
就有。
然而渲染html还得用户自己来搞,这还是挺讨厌的。下面以我在一个不用任何前端框架写的百科网站 bnu120 为例,下面是其中动态加载数据渲染视差字云效果的代码(略加修改,代码有点久远,可能写的比较幼稚):
const scene = document.getElementById("scene");
const depthScale = 100;
// 添加一个人物结点
function create_person_node(name) {
let span = document.createElement("span");
span.innerHTML = name;
scene.appendChild(span);
// 设置span元素随机深度,并将深度保存为元素的data-depth属性
// Parallax插件会用这个属性制造视差效果
let depth = Math.random();
span.setAttribute("data-depth", (depth * depthScale).toString());
}
// 设置结点的位置并设置不透明度
function decorate_person_node(span) {
let x = Math.random() - 0.5;
let y = Math.random() - 0.5;
span.style.left = `${x * window.innerWidth}pt`;
span.style.top = `${y * window.innerHeight}pt`;
// 设置元素的不透明度,形成先后出现的动画效果
setTimeout(() => {
span.style.setProperty("--opacity", Math.ceil(Math.random() * 100) + "%");
}, (Math.random() ** 2) * 2000);
}
// 从api获取people列表
fetch("/api/people/list").then(response => response.json()).then(people => {
people.forEach(create_person_node);
new Parallax(scene); // 初始化Parallax插件
scene.childNodes.forEach(decorate_person_node);
});
这段代码实现了一种类似视差效果的动画,通过获取API中的people列表,将每个人的信息和url添加到scene中,并且设置每个span标签的data-depth属性,从而实现视差动画。然后在scene中添加随机偏移量,最后设置每个span标签的各种CSS属性,实现动画效果。 —— 一个类ChatGPT的AI生成的描述
可以看到,其实创建html标签的过程又回到前面Java的那个例子一样方式了而用前端框架的模板语言要怎么实现这个呢
<script>
import { onMount } from "svelte";
$: nodes = []; // 一个响应式变量
onMount(
async () => {
let width = window.innerWidth;
let height = window.innerHeight;
let rand = () => Math.random() - 0.5;
let response = await fetch("/api/people/list");
let people = await response.json();
nodes = people.map((name) => (
{ name, x: rand() * width, y: rand() * width }
));
}
);
</script>
<div>
{#each nodes as { name, x, y }}
<span style:x style:y> {name} </span>
{/each}
</div>
清晰明了。<script>
里获得并格式化数据,在底下用模板语言表达格式。这才是数据驱动的开发。完全不用关注与dom
交互的细节
总之,在应用程序的语境下,内容就是数据和格式,逻辑就是脚本这三种不同的东西可以用三种不同的语言来写,但不一定要写在三个文件里而最开头提到的“UI描述”就是一类语言,专为描述UI而设计(废话),在其中可以用三种(子)语言来写UI的这四个部分
UI描述的方式
回到最开头的
renderer = framework.compile(template)
这里的template
一般是html的扩展,例如上一个例子那样。它相当于把描述UI有两种主流的语言大类:
- 在内容中插入脚本 —— template
- 在脚本中插入内容 —— jsx
虽然现在两边都很火,老牌有React
属于第1类,有Vue
属于第2类,新秀有Svelte
属于第1类,有Solid
属于第2类在内容为重的应用中,一定是第1种更方便;而在逻辑为重的应用中可能第2种会略方便些(个人认为这种使用场景应该比较少)
不同框架实现上也有优劣。我们看起来只是在声明变量,实际上框架在帮我们修改DOM。修改的粒度越小运行时开销就越少。细化修改粒度有两种方向,可以在编译时找出会变化的量,运行时不再判断其它量是否更新;也可以在运行时将前后渲染的UI描述作diff
。前者的唯一坏处就是会损失一些动态语言的灵活性,后者会带来diff
的运行时开销,各有取舍(也可以兼而有之)