【框架 · 二】实现响应式更新的视图库

397 阅读9分钟

模板引擎 / 指令 / 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;
        }`)

实现效果

gif.gif

总结

在本文中,我们实现了一个简单的动态绑定的视图库,其包含 :

  • 基类 BasicNode<T extends Node>
  • 动态文本节点 RTextNode
  • 元素节点 RElementNode<T extends HTMLElementNode>
  • 动态子节点的 RNodeGroup
  • 条件渲染 RNodeCase
  • 列表渲染 RNodeLoop

这个视图库作为真实 dom 之上的抽象层,记录了动态数据与对应节点的绑定关系。并且写了一个测试 demo 来测试效果。

不过从测试代码中可以看出来,数据 model,视图 view 的定义零零散散的。下篇文章就是将这些整体封装起来,即实现前端中构建视图的基本单元 —— 组件。