模板引擎 / 指令 / JSX / 虚拟 dom
在开始写代码之前得明晰几个概念。
-
模板引擎
这是后端渲染时代的主流操作。一般的就是在原 html 的语法上拓展,实现循环、判断、插入动态字符串等功能。
一般会将模板源码编译成拥有循环节点,判断节点,动态字符节点的抽象语法树,与 model 绑定的信息都记录在节点上。从而根据加载的 model 生成对应的 html 字符串。
不过后端渲染嘛,最终的结果是 html,所以记录下绑定的信息也无法做到局部更新。生成抽象语法树的好处主要还是在于避免重复解析,加快生成速度,减小后台性能开销上面。
-
指令
由于身处浏览器环境能够直接操作 dom,初代的 MVVM 框架自然是没必要以 html 全量更新的方式实现视图渲染。
另一方面当时的 js 效率虽没有那么尴尬不过还是处于一个尴尬的位置。出于性能考虑,所以当时的主流 mvvm 框架(knockout,angularjs,vue1.x)都是将解析 html 的工作交给浏览器。而 model 与视图绑定的信息,则记录在 dom 元素的自定义属性(指令)上。
-
JSX
主流是主流,但是总有人另辟蹊径。react 打算通过 js 动态生成 dom 元素。jsx 则是其对 js 扩展,旨在在 js 中利用 html 标签语法代替函数调用生成视图。
这样 js 无疑带来了灵活性,但是无法静态分析出 model 与视图的绑定关系,局部更新就无从谈起。
-
虚拟 dom
由于全局渲染无法避免,但是直接更新全部视图又开销太大。虚拟 dom 可以看作不得为之的操作。全局渲染出结果后,与之前视图对比(diff),再根据差异定点跟新。虚拟 dom 在这之中作为渲染的临时结果。
从中可以看出虚拟 dom 的性能优势仅在于渲染 + diff + 定点更新产生的开销相比全局更新视图要小。可是对于 mvvm 框架而言,model 与视图的绑定信息一直都有,定点更新不是难事。
新时代下的渲染思路
以模板引擎渲染 html 文本的方式早已被淘汰;以指令的方式,意味着需要依赖浏览器来解析 html 。那么跨平台,同构渲染则无从谈起;以 JSX 的方式,却又无法获取 model 和 view 之间的绑定关系。
以上的方式各有各的问题。但其实在上文中我们已经有了答案 —— 为了抽象化渲染过程,我们需要在真实 dom 之上添加一层抽象,并且要在这层抽象上记录下 model 和 view 之间的绑定关系。
至于以什么方式生成倒不是什么重点。你喜欢模板,你可以通过解析模板的方式生成;他喜欢 JSX,可以在 jsx 上拓展指令语法的方式生成。就如同写 vue 组件的时候既可以用 <template> ,又可以用 jsx,最终生成同样的组件。
实现动态文本和属性/样式控制
首先声明一个 BasicNode 的类,作为对 dom 的封装
export class BasicNode<T extends Node>{
#node: T
constructor(node: T) {
this.#node = node
}
setNode(node: T) { this.#node = node }
getNode() { return this.#node }
destroy() {
if (this.#node.parentNode) {
this.#node.parentNode.removeChild(this.#node)
}
}
}
次之,需要实现 Watcher 接口,以监听数据变化响应式更新。
-
RTextNode
插入动态文本的方式主要是对 Text 节点进行封装。
export class RTextNode
extends BasicNode<Text>
implements Watcher<string>{
#text: Reactive<string>
constructor(
node = document.createTextNode(''),
{ text = '' }: { text: Reactive<string> | string }
) {
super(node)
this.#text = text instanceof Reactive ? text : new Reactive(text)
this.#text.attach(this)
this.#updateText()
}
#updateText() {
this.getNode().data = this.#text.getVal()
}
destroy() {
this.#text.detach(this)
super.destroy()
}
emit() {
this.#updateText()
}
}
-
RElemntNode
对于元素属性,样式的控制,则需要对 HTMLElement 节点进行封装。
一般来说,对于元素的 tagName 和事件并不会动态更新。并且对于属性、样式字段是确定的,变化的往往是对应的数值。
这种设定下,代码则会简化很多。
export class RElementNode<T extends HTMLElement>
extends BasicNode<T>
implements Watcher<string>, Watcher<string | null>, Watcher<any> {
#WatchMap: Map<Reactive<any>, (
{ type: 'style', name: string } |
{ type: 'attr', name: string | null } |
{ type: 'prop', name: any }
)[]> = new Map()
constructor(node: T, { style, attr, prop, event }: {
style: { [key: string]: string | Reactive<string> }
attr: { [key: string]: string | null | Reactive<string | null> | Reactive<string> },
prop: { [P in keyof T]?: T[P] | Reactive<T[P]> },
event: { [key: string]: EventListenerOrEventListenerObject }
}) {
super(node)
Array.from(Object.entries(style)).forEach(([name, value]) => {
// 设定初始值
(this.getNode().style as any)[name] =
value instanceof Reactive
? value.getVal()
: value
// 记录下
if (value instanceof Reactive) {
const arr = this.#WatchMap.get(value)
const narr = (arr ?? []).concat([{ name, type: 'style' }])
this.#WatchMap.set(value, narr)
value.attach(this)
}
})
Array.from(Object.entries(prop)).forEach(([name, value]) => {
(this.getNode() as any)[name] =
value instanceof Reactive
? value.getVal()
: value
if (value instanceof Reactive) {
const arr = this.#WatchMap.get(value)
const narr = (arr ?? []).concat([{ name, type: 'prop' }])
this.#WatchMap.set(value, narr)
value.attach(this)
}
})
Array.from(Object.entries(attr)).forEach(([name, value]) => {
const val = value instanceof Reactive
? value.getVal()
: value
if (val === null) {
this.getNode().removeAttribute(name)
} else {
this.getNode().setAttribute(name, val)
}
if (value instanceof Reactive) {
const arr = this.#WatchMap.get(value)
const narr = (arr ?? []).concat([{ name, type: 'attr' }])
this.#WatchMap.set(value, narr)
value.attach(this)
}
})
Array.from(Object.entries(event)).forEach(([name, value]) => {
this.getNode().addEventListener(name, value)
})
}
emit(r: Reactive<any>) {
const infos = this.#WatchMap.get(r as Reactive<string>)
if (!infos) throw new Error('unknown Reactive')
infos.forEach(info => {
const { name, type } = info
if (type === 'style') {
(this.getNode().style as any)[name] = r.getVal()
}
if (type === 'prop') {
(this.getNode() as any)[name] = r.getVal()
}
if (type === 'attr') {
const val = r.getVal() as (string | null)
if (val === null) {
this.getNode().removeAttribute(name)
} else {
this.getNode().setAttribute(name, val)
}
}
})
}
destroy() {
Array.from(this.#WatchMap.keys()).forEach(v => {
v.detach(this)
})
super.destroy()
}
}
实现条件渲染和列表渲染
元素节点是可能存在子节点的。由于条件渲染和列表渲染的存在,子节点同样是动态的。
对于 RElementNode 而言,还需要实现一个 Watcher<BasicNode[]> 接口,用来响应后代元素的变化。
其改动如下。
export class RElementNode<T extends HTMLElement>
extends BasicNode<T>
implements Watcher<string>, Watcher<string | null>, Watcher<any> , Watcher<BasicNode<Node>[]>{
// ...
#children?: Reactive<BasicNode<Node>[]>
constructor(node: T, { style, attr, event }: {
style: { [key: string]: string | Reactive<string> }
attr: { [key: string]: string | Reactive<string> }
event: { [key: string]: EventListenerOrEventListenerObject },
children?: (BasicNode<Node>[]) | (Reactive<BasicNode<Node>[]>)
}) {
this.#children = children
this.#children?.attach(this)
this.#updateChildren()
}
emit(r: Reactive<string> | Reactive<BasicNode<Node>[]>) {
if(r === this.#children){
return this.#updateChildren()
}
const infos = this.#WatchMap.get(r as Reactive<string>)
if (!infos) throw new Error('unknown Reactive')
infos.forEach(info => {
const { name, type } = info
if (type === 'style') {
(this.getNode().style as any)[name] = r.getVal()
}
if (type === 'attr') {
this.getNode().setAttribute(name, (r as Reactive<string>).getVal())
}
})
}
#updateChildren(){
if (!this.#children) return
const target = this.getNode()
// 清空原有节点
Array.from(target.childNodes).forEach(v => {
target.removeChild(v)
})
// 添加新节点
this.#children.getVal().forEach(v => target.appendChild(v.getNode()))
}
}
RNodeGroup
不过这个问题并不是通过简单地实现一个 Watcher<BasicNode[]> 接口能解决的。因为这些子节点不完全是动态的,可能一部分是固定的,另一部分是动态生成的。在我们的数据结构中,需要把这些描述出来。
在 RNodeGroup 中的 #list,既能存静态的单一节点,又能存储动态节点列表 —— Reactive<BasicNode<Node>[]> 。
当获取数据的时候,则会将 list 内所有数据节点拍扁,得到 BasicNode<Node>[]
export class RNodeGroup
extends Reactive<BasicNode<Node>[]>
implements Watcher<BasicNode<Node>[]>
{
#list: (BasicNode<Node> | Reactive<BasicNode<Node>[]>)[]
constructor(list: (BasicNode<Node> | Reactive<BasicNode<Node>[]>)[]) {
super([])
this.#list = list
this.#list.forEach(v => {
if (v instanceof Reactive) v.attach(this)
})
this.emit()
}
emit() {
this.setVal(this.#list.flatMap(v => {
if (v instanceof BasicNode) {
return [v]
}
if (v instanceof Reactive) {
return v.getVal()
}
else return []
}))
}
destroy() {
this.#list.forEach(v => {
if (v instanceof Reactive) v.detach(this)
})
}
}
RNodeCase(条件渲染)
在此基础上,实现条件渲染就没有什么难度了, RNodeCase 可以看成一个 Compute<boolen,BasicNode<Node>[]> 的计算。不过由于子节点可以是动态的,我们还需要实现 Watcher<BasicNode<Node>[]> 接口。
export class RNodeCase
extends Reactive<BasicNode<Node>[]>
implements Watcher<boolean>, Watcher<BasicNode<Node>[]>
{
#val: Reactive<boolean>
#list: Reactive<BasicNode<Node>[]>
constructor(
val: Reactive<boolean> | boolean,
list: Reactive<BasicNode<Node>[]>,
) {
super([])
this.#list = list
this.#val = val instanceof Reactive ? val : new Reactive(val)
this.#val.attach(this)
this.#list.attach(this)
this.#update()
}
#update() {
const val = this.#val.getVal()
if (val) {
this.setVal(this.#list.getVal())
} else
this.setVal([])
}
emit() {
this.#update()
}
destroy() {
this.#val.detach(this)
this.#list.detach(this)
}
}
RNodeLoop(列表渲染)
同理,可推断处列表渲染。只不过需要注意一点,每次动态渲染要存下子节点数据。以方便为下次新渲染的 detach 旧节点做准备,一方面为 key 缓存节点提供基础。
export class RNodeLoop<T>
extends Reactive<BasicNode<Node>[]>
implements Watcher<T[]>, Watcher<BasicNode<Node>[]>{
#vals: Reactive<T[]>
#createNodeList: (t: T, i: number) => Reactive<BasicNode<Node>[]>
#createKey: (t: T, i: number) => any
#cacheMap: Map<any, Reactive<BasicNode<Node>[]>> = new Map()
#cacheList: Reactive<BasicNode<Node>[]>[] = []
constructor(
vals: Reactive<T[]> | T[],
createNodeList: (t: T, i: number) => Reactive<BasicNode<Node>[]>,
createKey: () => any = () => Math.random(),
) {
super([])
this.#vals = vals instanceof Reactive ? vals : new Reactive(vals)
this.#createKey = createKey
this.#createNodeList = createNodeList
this.#vals.attach(this)
this.#update()
}
#update() {
const vals = Array.from(this.#vals.getVal())
const newCache = new Map()
this.#cacheList.forEach(v => v.detach(this))
this.#cacheList = vals.flatMap((val, index) => {
const key = this.#createKey(val, index)
const rNodeList = this.#cacheMap.has(key)
? this.#cacheMap.get(key)
: this.#createNodeList(val, index)
newCache.set(key, rNodeList)
return rNodeList ? [rNodeList] : []
})
this.#cacheMap = newCache
this.setVal(this.#cacheList.flatMap(v => v.getVal()))
}
emit(r: Reactive<T[]> | Reactive<BasicNode<Node>[]>) {
if (r === this.#vals) {
this.#update()
} else {
this.setVal(this.#cacheList.flatMap(v => v.getVal()))
}
}
destroy() {
this.#vals.detach(this)
this.#cacheList.forEach(v => v.detach(this))
}
}
测试
工具函数
首先需要写几个工具函数为了方便生成视图。如果有闲心,可以实现一个 createElement 函数,用 tsx 解决。或者写个 compiler 用模板生成。 其中包括
- 节点创建
// 生成动态字符串节点
export const text = (text: Reactive<string> | string) =>
new RTextNode(document.createTextNode(''), { text })
// 生成元素节点
export const element = <T extends HTMLElement>(node: () => T) => (params: {
style?: { [key: string]: string | Reactive<string> }
attr?: { [key: string]: string | null | Reactive<string | null> | Reactive<string> }
prop?: { [P in keyof T]?: T[P] | Reactive<T[P]> },
event?: { [key: string]: EventListenerOrEventListenerObject },
children?: (BasicNode<Node> | Reactive<BasicNode<Node>[]>)[]
} = {}) => new RElementNode<T>(
node(), {
style: params.style ?? {},
attr: params.attr ?? {},
prop: params.prop ?? {},
event: params.event ?? {},
children: params.children
? new RNodeGroup(params.children)
: undefined
})
- 动态渲染
// 条件渲染
export const cond = (
f: boolean | Reactive<boolean>,
list: (Reactive<BasicNode<Node>[]> | BasicNode<Node>)[]
) => new RNodeCase(f, new RNodeGroup(list))
// 列表渲染
export const loop = <T>(
vals: Reactive<T[]> | T[],
createNodeList: (t: T, i: number) => (Reactive<BasicNode<Node>[]> | BasicNode<Node>)[],
createKey?: () => any,
) => new RNodeLoop(vals, (t: T, i: number) => new RNodeGroup(createNodeList(t, i)), createKey)
- 生成常见节点元素
// 生成 div 节点
export const div = element(() => document.createElement('div'))
// 生成 button 节点
export const button = element(() => document.createElement('button'))
// 生成 input 节点
export const input = element<HTMLInputElement>(() => document.createElement('input'))
// 生成 label 节点
export const label = element(() => document.createElement('label'))
- 插入样式
export const style = (str: string) => {
const styleNode = document.createElement('style')
styleNode.innerHTML = str
document.head.appendChild(styleNode)
}
生成 model
接着定义与视图绑定的响应式数据
// 是否显示输入框列表
const showInput = new Reactive<boolean>(true)
// 新输入框的名字
const newInputTitle = new Reactive('Title')
// 输入框列表数据
const InputData = new Reactive<{
title: string,
id: symbol,
value: Reactive<string>
}[]>([])
生成节点
因为只是简单的抽象几个工具函数,所以节点生成这一部分看上去还是很粗糙。
const createinput = (title: string, value: Reactive<string>, btn?: { name: string, cb: () => void }) => {
const focus = new Reactive(false)
const mode = new Computed(([focus, value]) =>
focus || value ? "input" : "blank",
[focus, value]
)
return div({
attr: {
'class': new Computed(([v]) => `control ${v}`, [mode])
},
children: [
label({
attr: { "for": '' },
children: [text(title)]
}),
div({
children: [
input({
attr: {
'class': 'input',
"type": 'text',
'value': value
},
event: {
focus: () => { focus.setVal(true) },
blur: () => { focus.setVal(false) },
change: (e: any) => { value.setVal(e.target.value) }
}
}),
cond(!!btn, [
button({
children: [text(btn?.name ?? '')],
event: { click: () => { btn && btn.cb() } }
})
])
]
})
]
})
}
const checkbox = div({
children: [
label({ children: [text('是否显示输入框')] }),
input({
attr: { type: 'checkbox' },
prop: { checked: showInput },
event: {
change: (e) => {
if (e.target) showInput.setVal((e.target as HTMLInputElement).checked)
}
}
})
]
})
const titleInput = div({
children: [createinput('输入框标题', newInputTitle, {
name: '添加', cb: () => {
const val = newInputTitle.getVal()
if (!val.trim()) return alert('请输入标题')
InputData.updateVal(v => v.concat([{
id: Symbol(), title: val, value: new Reactive(''),
}]))
console.log(InputData)
}
})]
})
const inputList = div({
children: [
cond(showInput, [loop(InputData, (v) => [createinput(v.title, v.value, {
name: "删除", cb: () => {
InputData.updateVal(arr => arr.filter(ele => ele.id !== v.id))
}
})])])
]
})
将节点以及样式插入文档
;[checkbox, titleInput, inputList].forEach(v => {
document.body.appendChild(v.getNode())
})
style(` html{
padding:60px;
}
.control {
position: relative;
margin-top:20px
}
.control::before {
bottom: -1px;
content: "";
left: 0;
position: absolute;
transition: 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
width: 100%;
border-style: solid;
border-width: thin 0 0;
border-color: rgba(0, 0, 0, 0.42);
}
.control > div{
display: flex;
flex-direction: row;
}
.control.input label {
max-width: 133%;
transform: translateY(-18px) scale(0.75);
}
.control label {
height: 20px;
line-height: 20px;
letter-spacing: normal;
left: 0px;
right: auto;
position: absolute;
font-size: 16px;
line-height: 1;
min-height: 8px;
transition: 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
transform-origin: left;
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
top: 8px;
white-space: nowrap;
pointer-events: none;
}
.control input {
color: rgba(0, 0, 0, 0.87);
background-color: transparent;
border-style: none;
line-height: 20px;
padding: 8px 0;
flex: auto;
}
button,
input,
select,
textarea {
background-color: transparent;
border-style: none;
outline: none;
}
button,
input,
optgroup,
select,
textarea {
font: inherit;
}
input {
border-radius: 0;
}`)
实现效果
总结
在本文中,我们实现了一个简单的动态绑定的视图库,其包含 :
- 基类
BasicNode<T extends Node> - 动态文本节点
RTextNode - 元素节点
RElementNode<T extends HTMLElementNode> - 动态子节点的
RNodeGroup - 条件渲染
RNodeCase - 列表渲染
RNodeLoop
这个视图库作为真实 dom 之上的抽象层,记录了动态数据与对应节点的绑定关系。并且写了一个测试 demo 来测试效果。
不过从测试代码中可以看出来,数据 model,视图 view 的定义零零散散的。下篇文章就是将这些整体封装起来,即实现前端中构建视图的基本单元 —— 组件。