简单实现Vue3渲染器模块Renderer

75 阅读3分钟

Vue 渲染系统主要分成3个模块

  1. h函数,用于返回一个VNode对象
  2. mount函数,用于将VNode挂载到DOM
  3. patch函数,用于对两个VNode进行对比,决定如何处理新的VNode

h函数实现

/**
 * 创建h函数,返回vnode
 * @param {*} type
 * @param {*} props
 * @param {*} children
 * @returns
 */
function h(type, props, children) {
  return {
    type,
    props,
    children
  }
}

Mount函数(挂载VNode

/**
 * 给el添加属性
 * @param {*} el 
 * @param {*} key 
 * @param {*} value 
 */
function addAttr(el, key, value) {
  // 如果属性值是string类型,如class等属性,直接添加
  if (typeof value === "string") {
    el.setAttribute(key, value)
  }
  // 如果是style样式对象,则将属性对象转换成string,并以;号分割
  if (key === "style" && Object.prototype.toString.call(value) === "[object Object]") {
    el.setAttribute(key, JSON.stringify(value).replace(/^{|"|}$/g, "").replace(/,/g, ";"))
  }
  // 如果是事件函数,以on开头的,如onClick等,则是绑定事件
  if (key.startsWith("on") && Object.prototype.toString.call(value) === "[object Function]") {
    const event = key.slice(2).toLowerCase()
    el.addEventListener(event, value)
  }
}

/**
 * 创建mount函数,添加到指定元素
 * @param {*} vnode 
 * @param {*} container 
 */
function mount(vnode, container) {
  const { type, props, children } = vnode
  
  // 处理type元素类型,创建元素,并在vnode上保留el
  const el = vnode.el = document.createElement(type)

  // 处理props属性,给元素添加属性
  if (props) {
    for (const key in props) {
      addAttr(el, key, props[key])
    }
  }

  // 处理children子节点(这里的子节点一般是字符串或者数组,这里不考虑对象的写法)
  if(children) {
    // 如果是string,直接添加内容即可
    if (typeof children === "string") {
      el.textContent = children
    } else {
      children.forEach(child => {
        mount(child, el)
      })
    }
  }

  // 添加元素
  container.appendChild(el)
}

patch函数(对比两个VNode

/**
 * 创建patch函数,进行新旧vnode对比
 * @param {*} oldVnode 
 * @param {*} newVnode 
 */
function patch(oldVnode, newVnode) {
  // 新节点和旧节点一样
  if (oldVnode.type === newVnode.type) {
    const el = newVnode.el = oldVnode.el

    // 把新节点上在旧节点上不同的属性添加进去
    const newProps = newVnode.props || {}
    const oldProps = oldVnode.props || {}
    for(const key in newProps) {
      if (newProps[key] !== oldProps[key]) {
        addAttr(el, key, newProps[key])
      }
    }

    // 把旧节点在新节点中不存在的属性移除
    for(const key in oldProps) {      
      // 如果是事件函数,以on开头的,如onClick等,则是移除绑定事件
      if (key.startsWith("on") && Object.prototype.toString.call(propValue) === "[object Function]") {
        const propValue = oldProps[key]
        const event = key.slice(2).toLowerCase()
        el.removeEventListener(event, propValue)
      }

      if (!(key in newProps)) {              
        el.removeAttribute(key)
      }
    }

    // 处理children子节点
    const oldChildren = oldVnode.children || []
    const newChildren = newVnode.children || []

    // 如果子节点是一个string
    if (typeof newChildren === "string") {
      if (typeof oldChildren === "string") {
        if (newChildren !== oldChildren) {
          el.textContent = newChildren
        }
      } else {
        el.innerHTML = newChildren
      }
    } else { // 子节点是一个数组
      if (typeof oldChildren === "string") {
        el.innerHTML = ""
        newChildren.forEach(item => {
          mount(item, el)
        })
      } else {
        // oldChildren [v1, v2, v3]
        // newChildren [v1, v2, v3, v4 ,v5]

        // 取出新旧children数组的最小长度进行对比
        const commonLength = Math.min(oldChildren.length, newChildren.length)
        for (let i = 0; i < commonLength; i++) {
          patch(oldChildren[i], newChildren[i])
        }
        
        // 如果新children大于旧的children,则把新的多出来的那部分挂载到el
        if (newChildren.length > oldChildren.length) {
          newChildren.slice(oldChildren.length).forEach(item => {
            mount(item, el)
          })
        }
        
        // 如果新children小于旧的children,则把旧的多出来的那部分el移除掉
        if (newChildren.length < oldChildren.length) {
          oldChildren.slice(newChildren.length).forEach(item => {
            el.removeChild(item.el)
          })
        }
      }
    }
  } else {
    // 如果新的节点和旧的节点不一样,则直接用新的节点替换掉旧的节点
    const oldVnodeParent = oldVnode.el.parentElement
    oldVnodeParent.removeChild(oldVnode.el)
    mount(newVnode, oldVnodeParent)
  }
}

eg:将下面这段html代码用renderer来实现

<div>
    <p style="font-size: 20px;font-weight: bold;margin: 0">这是一个列表</p>
    <ul class="ul-class">
      <li>
        <span style="color: red;text-decoration: underline;cursor: pointer;" onclick="alert('vue')">vue(我绑定了点击事件)</span>
      </li>
      <li>react</li>
      <li>javascript</li>
    </ul>
</div>

image.png

1、先准备一个div用于挂载

<div id="app"></div>

2、调用h函数生成VNode

const vnode = h("div", null, [
  h("p", {
    style: {
      "font-size": "20px",
      "font-weight": "bold",
      "margin": "0"
    }
  }, "这是一个列表"),
  h("ul", {"class": "ul-class"}, [
    h("li", {
      style: {
        "color": "red",
        "text-decoration": "underline",
        "cursor": "pointer"
      },
      onClick() {
        alert('vue')
      }
    }, "vue(我绑定了点击事件)"),
    h("li", null, "react"),
    h("li", null, "javascript")
  ])
])

3、通过mount函数,将vnode挂载到div#app上

mount(vnode, document.querySelector("#app"))

至此,已经完成了虚拟dom的渲染


image.png

4、调用patch函数,进行新旧节点对比

创建一个新的VNode并更新

const vnode2 = h("div", { "class": "my-div" }, [
  h("p", {
    style: {
      "font-size": "20px",
      "font-weight": "bold",
    }
  }, "你是什么"),
  h("p", null, "再来一次"),
])

setTimeout(() => {
  patch(vnode, vnode2)
}, 2000);

2秒之后页面发生变化,更新成功


image.png

结尾

如果是通过template编写html代码,最终也是被编译成VNode,返回一个render函数;

如果是通过jsx编写,如下

render() {
    return (
        <div>你好呀</div>
    )
}

jsx代码块最终也会被编译成VNode,然后返回给render函数

render函数接收一个返回值,而这个返回值其实是VNode