vue3源码系列(六)——渲染真实dom

134 阅读8分钟

前言

通过上一篇我相信大家已经知道了vue3大致是怎样创造虚拟dom的,但也留下了一个伏笔,就是跟渲染有关的patch方法。接下来我们就通过patch方法来介绍vue3是怎么把虚拟dom转化为真实dom的。在这之前,我们先定义一些操作dom的API,这跟平台有关,在源码中是定义在runtime-dom模块中的。

// nodeOps对象存放一些操作dom的方法
export const nodeOps = {
    insert: (child, parent, anchor = null) => { // 插入有追加的功能 child:孩子节点, parent: 父节点 , anchor:在插入父节点时,以哪个子节点作为参照物
        parent.insertBefore(child, anchor); // anchor为null时,相当于parent.appendChild(child)
    },
    remove: child => { // 从父节点移除哪个子节点
        const parent = child.parentNode;
        if (parent) {
            parent.removeChild(child);
        }
    },
    createElement: tag => document.createElement(tag), // 创造元素节点
    createText: text => document.createTextNode(text), // 创造文本节点
    setElementText: (el, text) => el.textContent = text, // 给元素节点添加文本内容
    parentNode: node => node.parentNode, // 获取节点的父节点
    nextSibling: node => node.nextSibling, // 获取节点的兄弟节点
    querySelector: selector => document.querySelector(selector) // 获取dom元素
}


// patchProp:比较dom元素更行前后props的不同,并更行dom元素上的props
export const patchProp = (el, key, prevValue, nextValue) => { el:dom元素,key: 属性名称, prevValue:dom更新之前的值,  nextValue: dom更新之后的值
    if (key === 'class') { // 当key为类名时 
        patchClass(el, nextValue); // 更新类名的方法
    } else if (key === 'style') { // 当key为样式时
        patchStyle(el, prevValue, nextValue); // 更新样式值的方法
    } else if (/^on[^a-z]/.test(key)) { // 当为onXxx(事件)时 
        // 如果有事件 addEventListener  如果没事件 应该用removeListener
        patchEvent(el, key, nextValue);
        // 绑定一个 换帮了一个  在换绑一个
    } else {
        // 其他属性 setAttribute
        patchAttr(el, key, nextValue);
    }
}

// 需要比对属性 diff算法    属性比对前后值
function patchClass(el, value) { // 属性为class时,直接判断更新后有值就替换之前的,为null就直接删除class属性
    if (value == null) {
        el.removeAttribute('class');
    } else {
        el.className = value;
    }
}
function patchStyle(el, prev, next) {
    const style = el.style; // 操作的是样式
    // 最新的肯定要全部加到元素上
    for (let key in next) {
        style[key] = next[key];
    }
    // 新的没有 但是老的有这个属性, 将老的移除掉
    if (prev) {
        for (let key in prev) {
            if (next[key] == null) {
                style[key] = null;
            }
        }
    }
}
function createInvoker(value) {
    const invoker = (e) => { // 每次事件触发调用的都是invoker 
        invoker.value(e)
    }
    invoker.value = value; // 存储这个变量, 后续想换绑 可以直接更新value值
    return invoker
}
// 更新事件时,稍微复杂点
function patchEvent(el, key, nextValue) {
    // vei  vue event invoker  缓存绑定的事件 
    const invokers = el._vei || (el._vei = {}); // 在元素上绑定一个自定义属性 用来记录绑定的事件
    let exisitingInvoker = invokers[key]; // 先看一下有没有绑定过这个事件
    if (exisitingInvoker && nextValue) { // 换绑逻辑
        exisitingInvoker.value = nextValue
    } else {
        const name = key.slice(2).toLowerCase(); // eventName
        if (nextValue) {
            const invoker = invokers[key] = createInvoker(nextValue); // 返回一个引用,这样做的好处是每次换事件不用先解绑,再重新绑定,因为绑定的一直都是invoker,只是换了里面执行的方法
            el.addEventListener(name, invoker);  // 正规的时间 onClick =(e)=>{}
        } else if (exisitingInvoker) {
            // 如果下一个值没有 需要删除
            el.removeEventListener(name, exisitingInvoker);
            invokers[key] = undefined; // 解绑了
        }
        // else{
        //     // 压根没有绑定过 事件就不需要删除了
        // }
    }
}
function patchAttr(el, key, value) {
    if (value == null) {
        el.removeAttribute(key)
    } else {
        el.setAttribute(key, value)
    }
}

以上介绍了一些渲染时会用到的相关API和方法,接下来正式介绍patch方法。

    const render = (vnode, container) => { // 将虚拟节点 转化成真实节点渲染到容器中
        // 后续还有更新 patch  包含初次渲染 还包含更新
        patch(null, vnode, container);// 后续更新 prevNode nextNode container
    }
    
    const patch = (n1, n2, container, anchor = null) => {
    
        // 两个元素 完全没用关系 
        if (n1 && !isSameVNodeType(n1, n2)) { // n1有值,说明为更新操作,这时如果n1和n2为不同类型的节点,直接把n1(旧的节点)删除,再走初始化逻辑
            unmount(n1);
            n1 = null;
        }
        // 如果前后元素不一致 需要删除老的元素 换成新的元素


        if (n1 == n2) return;
        const { shapeFlag, type } = n2; // createApp(组件)

        switch (type) {
            case Text:
                processText(n1, n2, container); // 文本节点
                break;

            default:
                if (shapeFlag & ShapeFlags.COMPONENT) { // 组件节点
                    processComponent(n1, n2, container); // 渲染组件节点,我们的vue项目初始化渲染就是一个App组件,我们先从processComponent这个方法分析
                } else if (shapeFlag & ShapeFlags.ELEMENT) { // 元素节点
                    processElement(n1, n2, container, anchor); // 渲染元素节点
                }
        }
    }
    
    const processComponent = (n1, n2, container) => {
        if (n1 == null) {
            // 组件的初始化
            mountComponent(n2, container);
        } else {
            // 组件的更新
        }
    }
    
    const mountComponent = (initialVNode, container) => { // 组件的挂载流程
        // 根据组件的虚拟节点 创造一个真实节点 , 渲染到容器中
        // 1.我们要给组件创造一个组件的实例 
        const instance = initialVNode.component = createComponentInstance(initialVNode);
        // 2. 需要给组件的实例进行赋值操作
        setupComponent(instance); // 给实例赋予属性

        // 3.调用render方法实现 组件的渲染逻辑。 如果依赖的状态发生变化 组件要重新渲染
        // 数据和视图是双向绑定的 如果数据变化视图要更新 响应式原理 
        // effect  data  effect 可以用在组件中,这样数据变化后可以自动重新的执行effect函数
        setupRenderEffect(initialVNode, instance, container); // 渲染effect

    }
    
    export function createComponentInstance(vnode){ // 创造一个组件实例
    const type = vnode.type; // 用户自己传入的属性
    const instance = {
        vnode, // 实例对应的虚拟节点
        type, // 组件对象
        subTree: null, // 组件渲染的内容   vue3中组件的vnode 就叫vnode  组件渲染的结果 subTree
        ctx: {}, // 组件上下文
        props: {}, // 组件属性
        attrs: {}, // 除了props中的属性 
        slots: {}, // 组件的插槽
        setupState: {}, // setup返回的状态
        propsOptions: type.props, // 属性选项
        proxy: null, // 实例的代理对象
        render:null, // 组件的渲染函数
        emit: null, // 事件触发
        exposed:{}, // 暴露的方法
        isMounted: false // 是否挂载完成
    }
    instance.ctx = {_:instance}; // 稍后会说 , 后续会对他进行代理
    return instance;
}

// 组件实例创造好了之后,需要对instance实例赋值
export function setupComponent(instance){
    const  {props,children} = instance.vnode;
    // 组件的props 做初始化  attrs也要初始化
    initProps(instance,props)
    // 插槽的初始化
    setupStatefulComponent(instance); // 这个方法的目的就是调用setup函数 拿到返回值 给

}

export function initProps(instance,rawProps){
    const props = {};
    const attrs = {};
    const options = Object.keys(instance.propsOptions); // 用户注册过的, 校验类型
    if(rawProps){
        for(let key in rawProps){
            const value = rawProps[key];
            if(options.includes(key)){
                props[key] = value;
            }else{
                attrs[key] = value
            }
        }
    }
    instance.props = reactive(props); // 组件的props是响应式的,因此使用reactive
    instance.attrs = attrs; // 这个attrs 是非响应式的
}

export function setupStatefulComponent(instance){
    // 核心就是调用组件的setup方法
    const Component = instance.type; // App对象
    const {setup} = Component; // vue3中vue文件中会有一个setup方法
    instance.proxy = new Proxy(instance.ctx,PublicInstanceProxyHandlers); // proxy就是代理的上下文
    if(setup){
        const setupContext = createSetupContext(instance); // 创建执行上下文,为setup方法的第二个参数,有emit,expose等方法
        let setupResult = setup(instance.props,setupContext); /// 获取setup的返回值
        if(isFunction(setupResult)){
            instance.render = setupResult; // 如果setup返回的是函数那么就是render函数
        }else if(isObject(setupResult)){
            instance.setupState = setupResult;
        }
    }
    if(!instance.render){
        // 如果 没有render 而写的是template  可能要做模板编译  下个阶段 会实现如何将template -》 render函数 (耗性能)
        instance.render = Component.render; // 如果setup没有写render 那么就采用组件本身的render
    }
}

function createSetupContext(instance){
    return {
        attrs:instance.attrs,
        slots:instance.slots,
        emit:instance.emit,
        expose:(exposed) =>instance.exposed = exposed || {}
    }
}

以上组件实例就创建完毕了, 接下来就是渲染了

const setupRenderEffect = (initialVNode, instance, container) => {
        // 创建渲染effect

        // 核心就是调用render,数据变化 就重新调用render 
        const componentUpdateFn = () => {
            let { proxy } = instance; //  render中的参数
            if (!instance.isMounted) { // 组件初始化
                // 组件初始化的流程
                // 调用render方法 (渲染页面的时候会进行取值操作,那么取值的时候会进行依赖收集 , 收集对应的effect,稍后属性变化了会重新执行当前方法)
                const subTree = instance.subTree = instance.render.call(proxy, proxy); // 渲染的时候会调用h方法,这是的subTree就类似于 h('div', {style: {color: red}}, '张三')

                // 真正渲染组件 其实渲染的应该是subTree
                
                // 重点:这时开始递归去渲染组件内的dom元素了
                patch(null, subTree, container); // 稍后渲染完subTree 会生成真实节点之后挂载到subTree
                initialVNode.el = subTree.el
                instance.isMounted = true;
            } else {
                // 组件更新的流程 。。。
                // 我可以做 diff算法   比较前后的两颗树 

                const prevTree = instance.subTree;
                const nextTree = instance.render.call(proxy, proxy);
                patch(prevTree, nextTree, container); // 比较两棵树
            }
        }
        const effect = new ReactiveEffect(componentUpdateFn); // 这里用到之前我们讲响应式那块用到的ReactiveEffect类,主要是为了响应式变量跟新后,触发componentUpdateFn,执行patch方法去更新,这里不清楚可以去effect那章看看
        // 默认调用update方法 就会执行componentUpdateFn
        const update = effect.run.bind(effect);
        update();
    }

以上是组件的的初始化和更新,接下来看看元素的初始化和更新。

 const patch = (n1, n2, container, anchor = null) => {
        // 两个元素 完全没用关系 
        if (n1 && !isSameVNodeType(n1, n2)) { // n1 有值 再看两个是否是相同节点
            unmount(n1);
            n1 = null;
        }
        // 如果前后元素不一致 需要删除老的元素 换成新的元素


        if (n1 == n2) return;
        const { shapeFlag, type } = n2; // createApp(组件)

        switch (type) {
            case Text:
                processText(n1, n2, container);
                break;

            default:
                if (shapeFlag & ShapeFlags.COMPONENT) {
                    processComponent(n1, n2, container);
                } else if (shapeFlag & ShapeFlags.ELEMENT) {
                    processElement(n1, n2, container, anchor); // 现在走到元素渲染中来了
                }
        }
    }
    
    const processElement = (n1, n2, container, anchor) => { // 组件对应的返回值的初始化
        if (n1 == null) {
            // 初始化
            mountElement(n2, container, anchor);
        } else {
            // 这个是元素的更新操作,涉及到diff,下章会重点介绍
            patchElement(n1, n2); // 更新两个元素之间的差异
        }

    }
    
    const mountElement = (vnode, container, anchor) => {
        // vnode中的children  可能是字符串 或者是数组  对象数组  字符串数组

        let { type, props, shapeFlag, children } = vnode; // 获取节点的类型 属性 儿子的形状 children

        let el = vnode.el = hostCreateElement(type) // 创造dom元素

        if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
            hostSetElementText(el, children) // 给元素增加文本内容
        } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {  // 按位与
            mountChildren(children, el); // 节点的孩子是数组
        }
        // 处理属性
        if (props) {
            for (const key in props) {
                hostPatchProp(el, key, null, props[key]); // 给元素添加属性
            }
        }
        hostInsert(el, container, anchor); // 把创建好的真实dom元素插入container(父节点中)
    }
    const mountChildren = (children, container) => {
        // 如果是一个文本 可以直接   el.textContnt = 文本2
        // ['文本1','文本2']   两个文本 需要 创建两个文本节点 塞入到我们的元素中
        // 孩子是数组,遍历孩子节点,并且运行patch方法递归去创建真实dom元素,再插入container中
        for (let i = 0; i < children.length; i++) { 
            const child = (children[i] = normalizeVNode(children[i]));
            patch(null, child, container); // 如果是文本需要特殊处理
        }
    }

总结

在渲染阶段,最重要的就是patch方法,vue就是利用递归调用patch方法实现把虚拟dom转化为真实dom插入页面中的。