VUE3源码学习笔记-第二部分:渲染器的设计

226 阅读7分钟

1.渲染器与响应式系统的结合

渲染器renderer用于执行渲染任务,在浏览器平台上用于将虚拟DOM渲染真实DOM,这个过程称为挂载。同时渲染器也是跨平台的关键,以下是一个简易的渲染器:

function renderer(domString,container){
    container.innerHTML = domString;
}
const count = ref(1);
effect(()=>{
    renderer(`<h1>${count.value}/<h1>`,document.getElementById('app'))
})

渲染器除了渲染外还可以激活已有的DOM元素,所以用createRenderer函数来创建渲染器:

function createRender(){
    function render(vnode,container) {
        //...
    }
    function hydrate(vnode,container) {
        //...
    }
    return {render,hydrate}
}

渲染器接收两个参数domString是要挂载的内容,container是指定挂载位置的真实DOM,即容器。由于count是响应式数据,当修改count的值后,副作用函数重新执行,就会重新渲染。多次在同一个容器上进行渲染时,渲染器除了执行挂载动作,还会执行更新动作。

const renderer = createRender();
//首次渲染
renderer.render(oldNode,container);
//再次渲染
renderer.render(newNode,container);

再次渲染的过程被称为patch,即更新。挂载可以视为旧的渲染内容不存在的更新。

function createRender(){
    function render(vnode,container) {
        if(vnode){
            //新节点存在,将其与旧节点一起传递给patch函数执行更新
            patch(container._vnode,vnode,container)
        }else{
            if(container._vnode){
                //旧节点不存在,新节点存在,说明是卸载操作,直接清空container的内容
                container.innerHTML = "";
            }
        }
        //把vnode存在container._vnode上,即后续渲染的旧vnode;
        container._vnode = vnode;
    }
    return {render}
}

首次渲染的时候patch函数接收到的旧节点值为空,它会忽略旧节点,直接把新节点的内容渲染到容器中,所以patch函数不仅可以更新,也可以挂载。

2.自定义渲染器

使用vnode对象来描述一个h1标签。

const vnode = {
    type:"h1",
    children:"hello"
}

type用来描述vnode类型,当type值是字符串时,vnode是普通标签。而元素除了文本节点还可以包含其他类型的节点,子节点类型可以是很多个,所以我们将vnode.children定义为数组。

const vnode = {
    type:"div",
    props:{
        id:"foo"
    },
    children:[
        {
            type:'p',
            children:"hello"
         }
    ]
}

以上描述的是:一个div标签具有一个p标签子节点。使用mountElement函数渲染。vnode.props字段代表元素的属性。

function mountElement(vnode,container){
      const el = createElement(vnode.type);
      if(typeof vnode.children === "string"){
          setElementText(el,vnode.children);
      }else if(Array.isArray(vnode.children)){
          //如果children是数组,则遍历每一个子节点,并使用patch函数挂载
          vnode.children.forEach(child => {
              patch(null,child,el)
          })
      }
      //如果vnode具有属性
      if(vnode.props){
          for(const key in vnode.props){
              el.setAttribute(key,vnode.props[key])
          } 
      }
      insert(el,container)
}

3.HTML Attributes 与 DOM Properties

以如下代码为例:

<input id="my-input" type="text" value="foo"/>

HTML Attributes指的是定义在HTML标签上的属性,如id、type和value,可以通过js代码来读取DOM对象:

const el = document.getElementById("my-input");

这个DOM对象包含很多属性,这些属性就是DOM Properties。有很多HTML Attributes在DOM对象上有与之同名的DOM Properties,HTML Attributes的作用是设置与之对应的DOM Properties的初始值。对于普通的HTML文件而言,浏览器会自动分析HTML Attributes并设置DOM Properties。但在vue的template模板中需要由框架来解析和设置。

//HTML模板
<button disabled>Button</button>
//HTML模板编译得到的vnode
const button = {
    type:"button",
    props:{
        disabled:''
    }
}

这里的props.disabled是空字符串,在浏览器中设置属性el.setAttribute('disabled',''),按钮会被禁用,但如果props.disabled值为false,就会变成el.setAttribute('disabled','false'),原本的布尔值false会被当作字符串'false'执行。按钮依然被禁用。为了避免这个错误,我们不使用setAttribute,而是使用el.disabled = false。 当props.disabled为空字符串时,手动校正为true。

function mountElement(vnode,container){
    const el = createElement(vnode.type);
    if(vnode.props){
        for(const key of vnode.props){
            if(key in el){
                const type = typeof el[key],value = vnode.props[key];
                //如果是布尔类型且value是空字符串则校正为true。
                if(type === "boolean" && value === ""){
                    el[key] = true;
                }else{
                    el[key] = value;
                }
            }else{
                el.setAttribute(key,vnode.props[key])
            }
        }
    }
    insert(el,container)
}

class属性需要特殊处理,因为vue中template设置class有三种情况,class可以是字符串、对象或数组。

<p class="foo bar"></p>
<p :class="{foo:true,bar:false}"></p>
<p :class="['baz',{foo:true,bar:false}]"></p>

由于class可以是多种类型,所以我们需要把它转换为字符串类型。el.className设置class性能最好。

4.卸载操作

在挂载完成后,后续渲染时如果传递null作为新的vnode,意味着什么都不渲染,要卸载之前的内容。卸载时要做三件事: 1.容器的内容可能是某个或多个组件渲染的,卸载时需要调用这些组件的生命周期函数; 2.有些元素存在自定义指令,应该在卸载时执行对应的钩子函数; 3.移除绑定在DOM元素上的事件处理函数。 因此我们需要在vnode和真实DOM元素之间建立联系,卸载时,通过vnode来获取真实DOM,再使用原生的方法将真实DOM移除。修改mountElement函数:

function mountElement(vnode,container){
    //让vnode.el引用真实DOM
    const el =vnode.el = createElement(vnode.type);
    ...
}

卸载时:

function render(vnode,container){
    if(!node){
        if(container._vnode){
            unmount(container._vnode)
        }
    }
}
function unmount(vnode){
    const parent = vnode.el.parentNode;
    if(parent){
        parent.removeChild(vnode.el)
    }
}

如果新旧vnode.type值不同,则卸载旧节点,再挂载新节点。

5.事件的处理

先约定在vnode.props对象中凡是以on开头的属性都视为事件:

const vnode = {
    type:"p",
    props:{
        onClick()=>{
            alert('clicked')
        }
    },
    children:"text"
}

使用patchProps函数调用addEventListener来绑定事件。

patchProps(el,key,prevValue,next){
    //如果key以on开头,则视为事件。
    if(key.startWith("on")){
        const name = key.slice(2).toLowerCase();
        //绑定事件
        el.addEventListener(name,nextValue)
    }
}

在绑定事件后,如果要更新事件,可以先将添加的事件移除,再添加新的事件。但是有一个技巧,定义伪造的事件处理函数invoker,然后将真正的事件处理函数设置为invoker.value的值,这样在更新事件时只需要修改invoker.value即可。同时为了能给同一个DOM对象绑定多个事件,invoker.value的值应该是一个对象,键是事件名称,值是绑定的函数。另外,一个事件可能具有多个函数,所以props对象中的事件不仅可以是方法,也可以是存放方法的数组。

patchProps(el,key,prevValue,nextValue){
    //如果key以on开头,则视为事件。
    if(key.startWith("on")){
        const invokers = el._vei||(el._vei = {}), name = key.slice(2).toLowerCase();
        let invoker = invokers[key];
        if(nextValue){
            if(!invoker){
                invoker = el._vei[key] = (e) => {
                    //如果invoker.value是数组,则遍历它并逐个调用事件处理函数
                    if(Array.isArray(invoker.value)){
                        invoker.value.forEach(fn => fn(e))
                    }else{
                        invoker.value(e)
                    }
                }
                invoker.value = nextValue;
                el.addEventListener(name,invoker)
            }else{
                invoker.value = nextValue;
            }
        }else if(invoker){
             el.removeEventListener(name,invoker)
        }
        //绑定事件
        el.addEventListener(name,nextValue)
    }
}

6.事件冒泡和更新时机

我们创建一个响应式数据bol,它的初始值为false,一个div元素,它的props是一个三元表达式,当bol值为false时什么也不做,当bol为true时为div绑定点击事件。在div内部有个p元素,它的点击事件是将bol设置为true:

const {effect,ref} = VueReactivity,bol = ref(false);
effect(()=>{
    const vnode={
        type:"div",
        props:bol.value ? {
            onClick:()=>{
                alert('父元素clicked')
            }
        } :{},
        children:[
            {
                type:"p",
                props:{
                    onClick:()=>{
                        bol.value = true;
                    }
                },
                children:'text'
            }
        ]
    }
})

当我们点击p元素时,预想的情况是,bol初始值为false,所以div没有点击事件,虽然点击p元素可以冒泡到div元素上,但由于div元素上没有点击事件,所以不会触发alert('父元素clicked')。但实际的情况alert('父元素clicked')竟然执行了。原因是bol变为true后为div绑定事件会发生在事件冒泡之前。为了避免这种情况,如果一个事件处理函数的绑定事件晚于事件触发时间,则我们屏蔽它:

patchProps(el,key,prevValue,nextValue){
    //如果key以on开头,则视为事件。
    if(key.startWith("on")){
        const invokers = el._vei||(el._vei = {}), name = key.slice(2).toLowerCase();
        let invoker = invokers[key];
        if(nextValue){
            if(!invoker){
                invoker = el._vei[key] = (e) => {
                    //如果一个事件处理函数的绑定事件晚于事件触发时间,则我们屏蔽它
                    if(e.timeStamp < invoker.attached){
                        return
                    }
                    //如果invoker.value是数组,则遍历它并逐个调用事件处理函数
                    if(Array.isArray(invoker.value)){
                        invoker.value.forEach(fn => fn(e))
                    }else{
                        invoker.value(e)
                    }
                }
                invoker.value = nextValue;
                //添加invoker.attached属性,储存事件处理函数被绑定的时间
                invoker.attached = performance.now()
                el.addEventListener(name,invoker)
            }else{
                invoker.value = nextValue;
            }
        }else if(invoker){
             el.removeEventListener(name,invoker)
        }
        //绑定事件
        el.addEventListener(name,nextValue)
    }
}

7.Fragment与多根节点模板

vue3支持在template模板中有多个根节点,原理是使用Fragment作为vnode类型来描述多根节点,在渲染时只渲染Fragment的子节点,卸载时也只卸载它的子节点:

const Fragment = Symbol();
const vnode = {
    type:Fragment,
    children:[
        {type:"li",children:"text1"},
        {type:"li",children:"text2"}
        {type:"li",children:"text3"}
    ]
}
function patch(n1,n2,container){
    if(n2.type === Fragment){
        //如果旧节点不存在,则将Fragment的子节点依次挂载即可
        if(!n1){
            n2.children.forEach(item => {patch(null,c,container)})
        }else{
            patchChildren(n1,n2,container)
        }
    }
}
function unmount(vnode){
    if(vnode.type === Fragment){
        vnode.children.forEach(item => unmount(item));
        return
    }
    const parent = vnode.el.parentNode;
    if(parent){
        parent.removeChild(vnode.el);
    }
}