Template engine/instruction/JSX/virtual DOM

A few concepts need to be clear before you start writing code.

  • A template engine

This is the dominant operation in the era of back-end rendering. General is in the original HTML syntax expansion, to achieve loop, judge, insert dynamic string and other functions.

The template source code is typically compiled into an abstract syntax tree with loop nodes, judgment nodes, and dynamic character nodes, where information about the model binding is recorded. To generate the corresponding HTML string based on the loaded model.

However, back end rendering, the final result is HTML, so recording binding information can not be locally updated. The main benefits of generating abstract syntax trees are to avoid repeated parsing, speed up generation, and reduce background performance overhead.

  • instruction

Since you can manipulate the DOM directly in a browser environment, the original MVVM framework didn’t necessarily implement view rendering in the form of full HTML updates.

On the other hand, js efficiency at that time was not so awkward but still in an awkward position. Knockout, AngularJS, Vu1.x), the major MVVM frameworks at the time, left the HTML parsing to the browser for performance reasons. The information that binds the model to the view is recorded in the custom properties (directives) of the DOM element.

  • JSX

The mainstream is the mainstream, but there is always another way. React intends to generate DOM elements dynamically from JS. JSX is an extension to JS that uses HTML tag syntax instead of function calls to generate views in JS.

In this way, JS undoubtedly brings flexibility, but it is impossible to statically analyze the binding relationship between model and view, so local update is out of the question.

  • Virtual dom

Since global rendering is unavoidable, it is too expensive to update the entire view directly. The virtual DOM can be considered a no-go operation. After rendering the result globally, compare it to the previous view (DIff), and then update it according to the differences. The virtual DOM is in there as a temporary result of rendering.

It can be seen that the performance advantage of the virtual DOM is only that the overhead generated by rendering + diff + fixed-point updates is lower than that generated by the global update view. However, for the MVVM framework, the binding information between the model and the view is always there, and it is not difficult to update it at a certain point.

New era of rendering ideas

Rendering HTML text with a template engine has long been obsolete; In a directive manner, this means that you rely on the browser to parse the HTML. Cross-platform, isomorphic rendering is out of the question; With JSX, there is no way to get the binding relationship between model and View.

Each of these approaches has its own problems. But we already have the answer — in order to abstract the rendering process, we need to add a layer of abstraction on top of the real DOM, and record the binding relationship between the Model and view on this layer of abstraction.

It doesn’t really matter how it’s generated. You like templates, which you can generate by parsing them. He likes JSX and can extend the syntax of instruction generation in JSX. Just as you can write vUE components with

Implement dynamic text and property/style controls

Start by declaring a BasicNode class that wraps the 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)
        }
    }
}
Copy the code

Second, you need to implement the Watcher interface to listen for responsive updates to data changes.

  • RTextNode

The way to insert dynamic Text is mainly to encapsulate the Text node.

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()
    }
}
Copy the code
  • RElemntNode

For control of element attributes and styles, HTMLElement nodes need to be wrapped.

Generally, tagnames and events for elements are not updated dynamically. And for attributes, style fields are determined, change is often the corresponding value.

With this setup, the code is much simpler.

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(a)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 inkeyof T]? : T[P] | Reactive<T[P]> }, event: { [key:string]: EventListenerOrEventListenerObject }
    }) {
        super(node)



        Array.from(Object.entries(style)).forEach(([name, value]) = > {

            // Set the initial value
            (this.getNode().style as any)[name] =
                value instanceof Reactive
                    ? value.getVal()
                    : value

            / / record
            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()
    }

}
Copy the code

Implement conditional rendering and list rendering

Element nodes may have child nodes. Child nodes are also dynamic, thanks to conditional and list rendering.

For RelementNodes, you also need to implement a Watcher

interface to respond to changes to descendant elements.
[]>

The changes are as follows.

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()

        // Clear the original node
        Array.from(target.childNodes).forEach(v= > {
            target.removeChild(v)
        })

        // Add a new node
        this.#children.getVal().forEach(v= > target.appendChild(v.getNode()))
    }
}
Copy the code

RNodeGroup

However, this problem is not solved by simply implementing a Watcher

interface. Because these child nodes are not completely dynamic, some may be fixed and some may be dynamically generated. In our data structure, we need to describe this.
[]>

Reactive

[] the #list in the RNodeGroup can store both a static single Node and a dynamic list of nodes.

BasicNode

[] BasicNode

[]

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)}}}Copy the code

RNodeCase (Conditional rendering)

RNodeCase can be viewed as a Compute< BOOLen,BasicNode

[]>. However, since child nodes can be dynamic, we also need to implement the Watcher

[]> interface.

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)}}Copy the code

RNodeLoop (List rendering)

Similarly, it can be inferred that the list is rendered. Just note that sub-node data is saved for each dynamic rendering. To facilitate preparation of the old Detach nodes for the next new rendering, on the one hand, to provide the basis for the key cache nodes.

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(a)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))}}Copy the code

test

Tool function

I need to write a couple of utility functions first in order to generate the view. If you have more time, you can implement a createElement function and use TSX. Or write a compiler and use it as a template. including

  • Node to create
// Generate a dynamic string node
export const text = (text: Reactive<string> | string) = >
    new RTextNode(document.createTextNode(' '), { text })
// Generate element nodes
export constelement = <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 })Copy the code
  • Dynamic rendering
// Conditional render
export const cond = (
    f: boolean | Reactive<boolean>,
    list: (Reactive<BasicNode<Node>[]> | BasicNode<Node>)[]
) = > new RNodeCase(f, new RNodeGroup(list))

// List rendering
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)
Copy the code
  • Generate common node elements
// Generate a div node
export const div = element(() = > document.createElement('div'))

// Generate a button node
export const button = element(() = > document.createElement('button'))

// Generate the input node
export const input = element<HTMLInputElement>(() = > document.createElement('input'))

// Generate the label node
export const label = element(() = > document.createElement('label'))
Copy the code
  • Insert the style
export const style = (str: string) = > {
    const styleNode = document.createElement('style')
    styleNode.innerHTML = str
    document.head.appendChild(styleNode)
}
Copy the code

To generate the model

You then define the reactive data bound to the view

// Whether to display a list of input boxes
const showInput = new Reactive<boolean> (true)
// The name of the new input box
const newInputTitle = new Reactive('Title')
// Enter box list data
const InputData = new Reactive<{ 
    title: string.id: symbol, 
    value: Reactive<string> >} [] ([])Copy the code

Generate the nodes

The node generation part still looks rough because it is simply abstracting a few utility functions.

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('Display input field or not')] }),
    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('Input field title', newInputTitle, {
    name: 'add'.cb: () = > {

      const val = newInputTitle.getVal()
      if(! val.trim())return alert('Please enter a title')

      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: "Delete".cb: () = > {
        InputData.updateVal(arr= > arr.filter(ele= >ele.id ! == v.id)) } })])]) ] })Copy the code

Insert nodes and styles into the document

; [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%; The transform: translateY (18 px) 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; } `)
Copy the code

Implementation effect

conclusion

In this article, we implemented a simple dynamically bound visual library that contains:

  • The base classBasicNode<T extends Node>
  • Dynamic text nodeRTextNode
  • Element nodesRElementNode<T extends HTMLElementNode>
  • Dynamic child nodeRNodeGroup
  • Conditions apply colours to a drawingRNodeCase
  • The list of renderingRNodeLoop

This gallery acts as an abstraction layer on top of the real DOM, recording the binding relationship between dynamic data and corresponding nodes. And wrote a test demo to test the effect.

But as you can see from the test code, the data model, the view, the view definition is very fragmented. The next article will encapsulate the whole, which is to implement the basic building blocks of views in the front end: components.