vue

update和render两个函数的调用

_update和_render两个函数的调用

Posted by czk on February 11, 2022

前言

书接上文,在updateComponent函数最终其实是对_update_render两个函数的一次调用

// src/core/instance/lifecycle.js
updateComponent = () => {
  vm._update(vm._render(), hydrating);
};

_render 函数

Vue 的 _render 方法是实例的一个私有方法,它用来把实例渲染成一个虚拟 Node

Vue.prototype._render = function(): VNode {
  const vm: Component = this;
  const { render, _parentVnode } = vm.$options;

  if (_parentVnode) {
    vm.$scopedSlots = normalizeScopedSlots(
      _parentVnode.data.scopedSlots,
      vm.$slots,
      vm.$scopedSlots
    );
  }

  // 设置父vnode。这允许渲染函数访问
  vm.$vnode = _parentVnode;
  // render self
  let vnode;
  try {
    // 当父组件patched 的时候
    currentRenderingInstance = vm;
    vnode = render.call(vm._renderProxy, vm.$createElement);
  } catch (e) {
    handleError(e, vm, `render`);
   // 返回错误结果或旧的vnode,以防止渲染错误导致的空白
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== "production" && vm.$options.renderError) {
      try {
        vnode = vm.$options.renderError.call(
          vm._renderProxy,
          vm.$createElement,
          e
        );
      } catch (e) {
        handleError(e, vm, `renderError`);
        vnode = vm._vnode;
      }
    } else {
      vnode = vm._vnode;
    }
  } finally {
    currentRenderingInstance = null;
  }
  // 如果返回的数组只包含一个节点
  if (Array.isArray(vnode) && vnode.length === 1) {
    vnode = vnode[0];
  }
  // 渲染函数出错,返回空的vnode
  if (!(vnode instanceof VNode)) {
    if (process.env.NODE_ENV !== "production" && Array.isArray(vnode)) {
      warn(
        "Multiple root nodes returned from render function. Render function " +
          "should return a single root node.",
        vm
      );
    }
    vnode = createEmptyVNode();
  }
  // 设置父节点
  vnode.parent = _parentVnode;
  return vnode;
};

其实这段函数的核心是vnode = render.call(vm._renderProxy, vm.$createElement)这里就是render方法的调用

  • vm._renderProxy是什么?

    这里的_renderProxy其实是在Vue.prototype._init这个函数中定义的,主要如下,

      // src/core/instance/init.js
      Vue.prototype._init = function(options?: Object) {
        //...
        if (process.env.NODE_ENV !== "production") {
          // 开发环境下,调用`initProxy`方法,将`vm`作为参数
          initProxy(vm);
        } else {
         // 生产环境下,`vm._renderProxy`就是`vm`本身
          vm._renderProxy = vm; 
        }
        // ...
      };
    
    • initProxy
        // src/core/instance/proxy.js
        let initProxy;
        initProxy = function initProxy(vm) {
      if (hasProxy) {
        // 根据`options.render`和`options.render._withStripped`的值来选择使用
        // `getHandler`还是`hasHandler`
        const options = vm.$options;
        const handlers =
          options.render && options.render._withStripped ? getHandler : hasHandler;
        vm._renderProxy = new Proxy(vm, handlers);
      } else {
        vm._renderProxy = vm;
      }
        };
      
      1. 通过hasProxy来判断下浏览器是否支持Proxy。支持就创建一个Proxy对象赋给vm._renderProxy;不支持就同生产环境下一样,vm._renderProxy就是vm本身。
      2. 当使用vue-loader解析.vue文件时使用getHandler,使用compiler版本的Vue.js则会使用hasHandler
      3. 这里代理其实主要是会抛出2种警告提示,一种是在Vue中,以$_开头的属性不会被代理,因为有可能与内置属性产生冲突。如果你设置的属性以$_开头,那么不能直接通过vm.key这种形式访问,而是需要通过vm.$data.key来访问。 另一种是key没有在data中定义 我们就可以对initProxy的作用进行一个总结:在渲染阶段对不合法的数据做判断和处理
  • vm.$createElement是什么?

    vm.$createElement的定义是在initRender函数中:

      function initRender(vm: Component) {
        // ...
        // 将createElement绑定到这个实例
        // so that we get proper render context inside it.
        // args order: tag, data, children, normalizationType, alwaysNormalize
        // internal version is used by render functions compiled from templates
        vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false);
        // 用户编写的渲染函数。
        vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true);
        // ...
      }
    

    可以看其实就是分别给实例vm加上_c$createElement方法。这两个方法都调用了createElement方法,只是最后一个参数值不同。

    • 我们调用$createElement可以这样写
      <div id="app"></div>
            
      <script>
      render: function () {
        return this.$createElement('div', {
           attrs: {
              id: 'app'
            },
        }, this.message)
      },
      data() {
        return {
          message: '我是czkm'
        }
      }
      </script>
    
    • 我们平时开发常用的模板字符串 ```js

    ``` 这种使用字符串模板的情况,使用的就是调用vm._c的方法了。使用字符串模板,在相关代码执行完前,会先在页面显示 `` ,然后再展示 我是czkm;而手写 render 函数的话,内部就不会执行把字符串模板转换成 render 函数这个操作,并且立即就显示内容

    vm._render 最终是通过执行 createElement 方法只是最后一个参数不同,之后返回vnode,它是一个虚拟 Node

createElement

// src/core/vdom/create-element.js
const SIMPLE_NORMALIZE = 1 // 简单规范化
const ALWAYS_NORMALIZE = 2 // 始终规范化
export function createElement (
  context: Component, // `VNode`当前上下文环境。
  tag: any, // 标签,可以是正常的`HTML`元素标签,也可以是`Component`组件。
  data: any, // `VNode`的数据,其类型为`VNodeData`,定义在根目录`flow/vnode.js`。
  children: any, // `VNode`的子节点
  normalizationType: any, // `children`子节点规范化类型
  alwaysNormalize: boolean // 是否格式化,区别模板编译,还是用户手写render方法
): VNode | Array<VNode> {
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE
  }
  return _createElement(context, tag, data, children, normalizationType)
}

createElement 方法实际上是对 _createElement 方法的封装,它允许传入的参数更加灵活,在处理这些参数后,调用真正创建 VNode 的函数 _createElement,多包裹一层的目的是为了让方法达到一种类似于函数重载的功能。

对于模板编译调用_c时,其alwaysNormalize传递的是false_c只会在内部使用,因此其方法调用的参数格式无需格式化。

$createElement是用户手写的render函数,因为允许用户传递不同形式的参数来调用$createElement,所以需要对参数进行格式化。

$createElement_c最后一个不相同的参数,体现在调用_c时对children只是进行简单组合,而调用$createElement时必须始终对children进行格式化。

export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  // ...省略代码
  if (!tag) {
    // in case of component :is set to falsy value
    return createEmptyVNode()
  }
  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }
  let vnode, ns
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      // platform built-in elements
      if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.nativeOn)) {
        warn(
          `The .native modifier for v-on is only valid on components but it was used on <${tag}>.`,
          context
        )
      }
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // parent将children normalizes
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  // ...省略代码
}

_createElement主要实现了children 的规范化以及 VNode 的创建

  • children 的规范化

因为虚拟DOM是树形结构,每一个节点都应该是VNode类型,_createElement 接收的第 4 个参数 children 是任意类型的,因此我们需要把它们规范成 VNode 类型。

如果没有子节点,那么children就是undefined。通过normalizationType参数来实现的,其中normalizationType可能的值有三种:undefined表示不进行规范化,1表示简单规范化,2表示始终规范化。

  1. 值为1的情况,调用了simpleNormalizeChildren,代码如下:

     // src/core/vdom/helpers/normalize-children.js
     export function simpleNormalizeChildren (children: any) {
       for (let i = 0; i < children.length; i++) {
         if (Array.isArray(children[i])) {
           return Array.prototype.concat.apply([], children)
         }
       }
       return children
     }
    

    simpleNormalizeChildren的作用是把多维数组降低一个维度,例如二维数组降低到一维数组,三维数组降低到二维数组,这样做的目的是为了方便后续遍历children

  2. 值为2的情况,它调用了normalizeChildren,其代码如下:

     // src/core/vdom/helpers/normalize-children.js
     export function normalizeChildren (children: any): ?Array<VNode> {
       return isPrimitive(children)
         ? [createTextVNode(children)]
         : Array.isArray(children)
           ? normalizeArrayChildren(children)
           : undefined
     }
    

    normalizeChildren作用是判断当children是基础类型值的时候,直接返回一个文本节点的VNode数组,否则再判断是否为数组,是的话就调用normalizeArrayChildren来规范化,不是则其children就是undefined

    • normalizeArrayChildren
       function normalizeArrayChildren (children: any, nestedIndex?: string): Array<VNode> {
      const res = []
      let i, c, lastIndex, last
      for (i = 0; i < children.length; i++) {
        c = children[i]
        if (isUndef(c) || typeof c === 'boolean') continue
        lastIndex = res.length - 1
        last = res[lastIndex]
        //  嵌套
        if (Array.isArray(c)) {
          if (c.length > 0) {
            c = normalizeArrayChildren(c, `${nestedIndex || ''}_${i}`)
            // 合并相邻文本节点
            if (isTextNode(c[0]) && isTextNode(last)) {
              res[lastIndex] = createTextVNode(last.text + (c[0]: any).text)
              c.shift()
            }
            res.push.apply(res, c)
          }
        } else if (isPrimitive(c)) {
          if (isTextNode(last)) {
            // 合并相邻文本节点
            res[lastIndex] = createTextVNode(last.text + c)
          } else if (c !== '') {
            // convert primitive to vnode
            res.push(createTextVNode(c))
          }
        } else {
          if (isTextNode(c) && isTextNode(last)) {
            // 合并相邻文本节点
            res[lastIndex] = createTextVNode(last.text + c.text)
          } else {
            // 嵌套的子数组的默认键(例如由v-for生成)
            if (isTrue(children._isVList) &&
              isDef(c.tag) &&
              isUndef(c.key) &&
              isDef(nestedIndex)) {
              c.key = `__vlist${nestedIndex}_${i}__`
            }
            res.push(c)
          }
        }
      }
      return res
       }
      

      上述代码好像看起来很多,其实normalizeArrayChildren主要做的了3个判断,我们来分析下。

    首先normalizeArrayChildren 接收 2 个参数,children 表示要规范的子节点,nestedIndex 表示嵌套的索引,因为单个 child 可能是一个数组类型。 normalizeArrayChildren 主要的逻辑就是遍历 children,获得单个节点 c,然后对 c 的类型判断

    1. 如果是数组类型,则递归调用 normalizeArrayChildren,举个例子
    2. 如果是基础类型,调用封装的createTextVNode方法来创建一个文本节点,然后push到结果数组res中。
    3. 如果不属于以上两种情况,那么代表本身已经是VNode类型了,直接push到结果数组中即可。

    着3种情况都判断了isTextNode,表示如果存在两个连续的 text 节点,会把它们合并成一个 text 节点

     // 合并前
     const children = [
       { text: 'Hello ', ... },
       { text: 'czkm', ... },
     ]
    
     // 合并后
     const children = [
       { text: 'Hello czkm' ... }
     ]
    

    经过对 children 的规范化,children 变成了一个类型为 VNode 的 Array。

  • VNode 的创建
      let vnode, ns
      if (typeof tag === 'string') {
        let Ctor
        ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
        if (config.isReservedTag(tag)) {
          // platform built-in elements
          vnode = new VNode(
            config.parsePlatformTagName(tag), data, children,
            undefined, undefined, context
          )
        } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
          // component
          vnode = createComponent(Ctor, data, context, children, tag)
        } else {
         // 未知或未列出的名称空间元素
         // 在运行时检查,因为它可能被分配一个命名空间
          vnode = new VNode(
            tag, data, children,
            undefined, undefined, context
          )
        }
      } else {
        // direct component options / constructor
        vnode = createComponent(tag, data, context, children)
      }
    

    创建VNode节点的逻辑有两大分支,tagstring类型和component类型

     tag如果是 string 类型,判断如果是内置的一些节点(例如htmlsvg标签),则直接创建一个普通 VNode如果不是则尝试在已经全局或者局部注册的组件中去匹配,匹配成功则使用createComponent去创建组件节点。tag 如果是  Component 类型,则直接调用 createComponent 创建一个组件类型的 VNode。例如:

      <template>
        <div id="app">
          <div></div>
          <hello-world :msg="msg" />
          <test/>
        </div>
      </template>
      <script>
      import HelloWorld from '@/components/HelloWorld.vue'
      export default {
        name: 'App',
        data () {
          return {
            msg: 'message',
          }
        },
        components: {
          HelloWorld
        }
      }
      </script>
    

    这里的tagtest,但div这种内置标签节点,又不像hello-world是已经局部注册过的组件,它属于未知的标签。这里之所以直接创建未知标签的VNode而不是报错,这是因为子节点在createElement的过程中,有可能父节点会为其提供一个namespace,真正做未知标签校验的过程发生在path阶段。

_update 函数

回到 mountComponent 函数, vm._render 创建了一个 VNode,接下来就是要把这个 VNode 渲染成一个真实的 DOM,而这个过程正是通过vm._update来完成的。

Vue 的 _update 是实例的一个私有方法,它被调用的时机有 2 个,一个是首次渲染,一个是数据更新的时候,主要作用是把 VNode 渲染成真实的 DOM

// src/core/instance/lifecycle.js
 Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  // 页面的挂载点,真实的元素
  const prevEl = vm.$el
  // 老 VNode
  const prevVnode = vm._vnode
  const restoreActiveInstance = setActiveInstance(vm)
  // 新 VNode
  vm._vnode = vnode
  // Vue.prototype.__patch__ is injected in entry points
  // based on the rendering backend used.
  if (!prevVnode) {
    // 老 VNode 不存在,表示首次渲染,即初始化页面时走这里
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // 响应式数据更新时,即更新页面时走这里
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  restoreActiveInstance()
  // update __vue__ reference
  if (prevEl) {
    prevEl.__vue__ = null
  }
  if (vm.$el) {
    vm.$el.__vue__ = vm
  }
  // if parent is an HOC, update its $el as well
  if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
    vm.$parent.$el = vm.$el
  }
  // updated hook is called by the scheduler to ensure that children are
  // updated in a parent's updated hook.
}

_update方法使用setActiveInstance来设置当前激活的实例,使用restoreActiveInstance来恢复,setActiveInstance方法定义如下

const restoreActiveInstance = setActiveInstance(vm)
export function setActiveInstance(vm: Component) {
    // 定义了闭包变量保存了当前激活的实例
  const prevActiveInstance = activeInstance
  activeInstance = vm
  return () => {
  //这个函数的目的是用来恢复`activeInstance`到上一个缓存下来的激活实例
    activeInstance = prevActiveInstance
  }
}

_update代码并不是很多,其核心就是调用__patch__方法,__patch__ 函数在不同平台会有不同的定义

// 判断了当前是否处于浏览器环境
Vue.prototype.__patch__ = inBrowser ? patch : noop

使用inBrowser判断了当前是否处于浏览器环境,如果是则赋值为path,否则就是noop空函数。这样判断是因为Vue还可以运行在node服务端

```js
import * as nodeOps from 'web/runtime/node-ops'
//`node-ops.js`文件中封装的方法,实际上就是对真实`DOM`操作的一层封装,传递`nodeOps`的
// 目的是为了在虚拟`DOM`转成真实`DOM`节点的过程中提供便利
import { createPatchFunction } from 'core/vdom/patch'
//`baseModules`是对模板标签上`ref`和`directives`各种操作的封装
import baseModules from 'core/vdom/modules/index'
// `platformModules`是对模板标签上`class`、`style`、`attr`以及`events`等操作的封装。
import platformModules from 'web/runtime/modules/index'

// modules 是由 platformModules 和 baseModules 合并而来
// 里面定义了一些模块的钩子函数的实现
const modules = platformModules.concat(baseModules)

// 传入平台特有的一些操作,然后返回一个 patch 函数
export const patch: Function = createPatchFunction({ nodeOps, modules })
``` 来对`_update`做一个总结:
  1. 首次渲染和派发更新重新渲染的patch是差异的,表现为首次渲染时提供的根节点是一个真实的DOM元素,在派发更新重新渲染时提供的是一个VNode
       //......
     if (!prevVnode) {
         // 老 VNode 不存在,表示首次渲染,即初始化页面时走这里
         vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
       } else {
         // 响应式数据更新时,即更新页面时走这里
         vm.$el = vm.__patch__(prevVnode, vnode)
       }
       //......
    
  2. 父子组件递归渲染的时候,首先渲染子组件,子组件渲染完毕后才会去渲染父组件,在这过程中,activeInstance始终指向当前渲染的组件实例。同时根据父子组件递归渲染的顺序,父组件created后,子组件才开始渲染,具体的的执行顺序为:
     // parent beforeCreate
     // parent created
     // parent beforeMount
     // child beforeCreate
     // child created
     // child beforeMount
     // child mounted
     // parent mounted
    
  3. render函数执行会得到一个VNode的树形结构,update的作用就是把这个虚拟DOM节点树转换成真实的DOM节点树。

image.png