浅谈Vue原理之数据驱动(个人向)

424 阅读11分钟
  • 关于数据驱动,其实对于vue这样一个大项目来说肯定有很多细节和优化的地方,在下水平精力有限,不能一一尝试探索,本文仅以将数据驱动的大致流程个人向的梳理完毕为目的。
  • Vue.js其中的一个核⼼思想是数据驱动。所谓数据驱动,是指视图是由数据驱动⽣成的。
  • 对于数据驱动主要分为三部分来分析,1.实例挂载;2.生成Vnode;3.Vnode渲染。最后将是个人的分析。

一、从New一个Vue开始

1.生成个vue

  • src/core/instance/index.js
import ......

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue

这里我们可以清楚的看到

  • Vue是一个函数且是一个构造函数(约定俗成首字母大写),故Vue只能通过new 关键字初始化。
  • 然后会调⽤this._init⽅法进行各种初始化
  • 混入各种Mixin(暂不清晰)

所以啊,各种框架无论多么如何追其本源还是由基础的JS生成的一个"类",而后在其原型上追加方法属性等。

2. 进行初始化

  • src/core/instance/init.js
 Vue.prototype._init = function (options?: Object) {
    ....
    // merge options
    ....
    // expose real self
    vm._self = vm
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')
    ....
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }

代码过长仅留下部分,还是建议有精力的可以选择同时看代码, 这里_init做了大致几个事情

  • 合并配置,(另行分析)
  • 初始化⽣命周期,(另行分析)
  • 初始化事件中⼼,
  • 初始化渲染,
  • 初始化data、props、computed、watcher.......

可以看出此步功能将各种不通的功能,集中初始化,条理清楚。

总结:

说是写数据驱动,先写Vue的初始化也是为了对于整个过程的清晰的流程观念,其中我们可以明显的感觉到,如Vue这种算是成熟的框架来说它的初始化逻辑非常清晰。核心明显,并且每个功能逻辑拆分也是清晰明了。

我们可以看到_init的最后检测到如果有el属性,则调⽤vm.$moun⽅法挂载vm,挂载的⽬标就是把模板渲染成最终的DOM,那么接下来我们来看一下Vue的挂载过程。

二、实例挂载

  • src/platform/web/entry-runtimewith-compiler.js

Vue中我们是通过$mount实例⽅法去挂载vm的,$mount⽅法在多个⽂件中都有定义。因为$mount这个⽅法的实现是和平台、构建⽅式都相关的。接下来我们重点分析带compiler版本的$monut实现,因为抛开webpack的vue-loader,我们在纯前端浏览器环境分析Vue的⼯作原理,有助于我们对原理理解的深⼊。

代码太长不贴了而且整个方法的核心其实是调用mountComponent方法,我们还是主要看一下这个函数到底做了什么

  • src/core/instance/lifecycle.js
export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  ... 判断vm.$options.rende 
  
  callHook(vm, 'beforeMount')

  let updateComponent
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    ... 关于Whether to record perf的判断
  } else {
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }

  // we set this to vm._watcher inside the watchers constructor
  // since the watchers initial patch may call $forceUpdate (e.g. inside child
  // components mounted hook), which relies on vm._watcher being already defined
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false
  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

上面是我把关于render和Vue的record perf的判断删掉的源码。 我们可以大致看出,这个函数主要做了以下几点

  • 调用vm._render⽅法先⽣成虚拟Node。(重点方法)
  • 实例化 ⼀个渲染Watcher,在它的回调函数中会调⽤updateComponent⽅法,(响应式方面,有兴趣的可以看我的另一篇博客)
  • updateComponent里最终调⽤vm._update更新DOM。(重点方法)

除此之外我们可以看到类似callHook(vm, 'beforeUpdate') 这样的明显的钩子函数挂载的流程,希望大家也能在看的时候对比这每个钩子函数的区别,同时对应着我们在实例化或者挂载Vue的阶段程度,来记忆,这样我个人是感觉一些东西就很流畅的记忆了

总结:

没啥总结的前面铺垫了半天,Vue已经完成了大部分的初始化和渲染过程,但是渲染中核心的东西和细节就是接下来要讲的最核⼼的2个⽅法:vm._rendervm._update

三、生成Vnode(虚拟Node)

1._render

  • src/core/instance/render.js
... 
    vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  // normalization is always applied for the public version, used in
  // user-written render functions.
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
... 
 vm.$vnode = _parentVnode
    // render self
    let vnode
    try {
      // Theres no need to maintain a stack becaues all render fns are called
      // separately from one another. Nested components render fns are called
      // when parent component is patched.
      currentRenderingInstance = vm
      vnode = render.call(vm._renderProxy, vm.$createElement)
    } catch (e) {
        .....
    }
     // set parent
    vnode.parent = _parentVnode
    return vnode

那这里照旧之保留核心部分,注意以下几点:

  • vm._c方法和vm.$createElement除了最后一个参数的不一样其他都一样,而最后的那个参数就是判断是否是用户手写的参数。。
  • vm._render最终是通过执⾏createElement⽅法并返回的是vnode,也就是我们常说的Virtual DOM ,虚拟DOM和其中的diff算法又是个大的话题,我们不去细究。有兴趣的可以专门搜相关博客,我们仅大致说一下,

虚拟DOM

⽽ Virtual DOM 就是⽤⼀个原⽣的 JS 对象去描述⼀个 DOM 节点,所以它⽐创建⼀个 DOM 的代价要 ⼩很多。在 Vue.js 中,Virtual DOM 是⽤ VNode 这么⼀个 Class 去描述,它是定义在 src/core/vdom/vnode.js 中的。

有兴趣的可以去看一下源码,其实就是定义了就⼏个关键属性,标签名、数据、⼦ 节点、键值等,而这个类会在接下来辅助生成渲染Dom使用的虚拟dom树。

2.createElement(创建 VNode)

  • src/core/vdom/create-elemenet.js
export function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): 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。

这里我们也能看到_createElement接受一下五个参数

  • context:表⽰VNode的上下⽂环境,它是Component类 型;
  • tag:表⽰标签,它可以是⼀个字符串,也可以是⼀个Component ;
  • data:表⽰VNode的数 据,它是⼀个VNodeData类型,可以在flow/vnode.js 中找到它的定义;
  • children:表⽰当前VNode的⼦节点,它是任意类型的,它接下来需要被规范为标准的VNode 数组;
  • normalizationType:表⽰⼦节点规范的类型,类型不同规范的⽅法也就不⼀样,它主要是参 考render函数是编译⽣成的还是⽤户⼿写的。

createElement函数的流程略微有点多,我们接下来主要分析2个重点的流程children的规范化及VNode的创建。

1.children的规范化

  • src/core/vdom/helpers/normalzie-children.js
export function simpleNormalizeChildren (children: any) {}
。。。。。
// 2. When the children contains constructs that always generated nested Arrays,
// e.g. <template>, <slot>, v-for, or when the children is provided by user
// with hand-written render functions / JSX. In such cases a full normalization
// is needed to cater to all possible types of children values.
export function normalizeChildren (children: any): ?Array<VNode> {
  return isPrimitive(children)
    ? [createTextVNode(children)]
    : Array.isArray(children)
      ? normalizeArrayChildren(children)
      : undefined
}

function isTextNode (node): boolean {
  return isDef(node) && isDef(node.text) && isFalse(node.isComment)
}
function normalizeArrayChildren (children: any, nestedIndex?: string): Array<VNode> {...}

过于细节,不深究,其作用就是将children变成了⼀个类型为VNode的Array。 需要注意

  • 如果是⼀个数组类型,则递归调⽤normalizeArrayChildren
  • 如果是基础类型,则通过createTextVNode⽅法转换成VNode类型;

2.VNode的创建

  • src/core/vdom/create-elemenet.js
 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 ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }

这里仅仅截取核心部分的地方,

这⾥先对 tag 做判断,如果是 string 类型,则接着判断如果是内置的⼀些节点,则直接创建⼀个 普通 VNode,如果是为已注册的组件名,则通过 createComponent 创建⼀个组件类型的 VNode,否 则创建⼀个未知的标签的 VNode。 如果是 tag ⼀个 Component 类型,则直接调⽤ createComponent 创建⼀个组件类型的 VNode 节点。

看到这里的你是否和我当时一样有点懵逼,,怎么就生成了VNode,反正我当时看的一脸蒙蔽,关键是我一直再跟着流程走啊走,,,这里要注意的是啊vnode = new VNode()这个操作,,,,,你还记得吗?我上面大致讲Virtual DOM 的时候说过啊,Vue专门写了一个Vnode类啊,这里你是不是要去正儿八经的源码地址看一下了。这里说一下,实际上Vue.js中Virtual DOM 是借鉴了⼀个开源库snabbdom 的实现哦。那这个时候是不是就很清晰了。

总结

整个生成Vnode过程就是render=>_createElement完成的,但是注意__createElemen的对于children的规范的部分,⾄此,我们⼤致了解了 createElement 创建 VNode 的过程,每个 VNode 有 children , children 每个元素也是⼀个 VNode,这样就形成了⼀个 VNode Tree,它很好的描述 了我们的 DOM Tree。

接下来就是如何将DOM Tree渲染成⼀个真实的DOM 并渲染出来了。

四、Vnode渲染

1._update

,_update⽅法的作⽤是把 VNode渲染成真实的DOM它被调⽤的时机有2个,⼀个是⾸次渲染,⼀个是数据更新的时候;,数据更新部分可以看我响应式原理文章。

  • src/core/instance/lifecycle.js 这里我就不贴源码了, 直接说,_update 的核⼼就是调⽤vm.__patch⽅法,对于不同平台_patch我们也不去探讨,直接看_patch
  • src/platforms/web/runtime/patch.js 下面我们基本就会围绕这个文件里面的各种方法大致说流程,并且这个文件因为它有着⾮常多的分⽀逻辑,本文不会去深究,如果感兴趣的话,自己可以尝试观看(不建议),我只把实例化的数据驱动的流程走完。
...
export const emptyNode = new VNode('', {}, [])
const hooks = ['create', 'activate', 'update', 'remove', 'destroy']
sameVnode //方法
sameInputType //方法
createKeyToOldIdx //方法
export function createPatchFunction (backend) {
    。。。
    很多函数
    
    function insert (parent, elm, ref) {
        //注意⼦元素会优先调⽤ insert ,所以整个 vnode 树节点的插⼊顺序是先⼦后⽗。
        if (isDef(parent)) {
          if (isDef(ref)) {
            if (nodeOps.parentNode(ref) === parent) {
              nodeOps.insertBefore(parent, elm, ref)
            }
          } else {
            nodeOps.appendChild(parent, elm)
            //这里nodeOps的appendChild去看src/platforms/web/runtime/node-ops.js 可以看到就是正常的JSappendChild方法,至此,算是完结
          }
        }
    }
    function invokeCreateHooks (vnode, insertedVnodeQueue) {
        //执⾏所有的 create 的钩⼦并把 vnode push 到insertedVnodeQueue 中。
        for (let i = 0; i < cbs.create.length; ++i) {
          cbs.create[i](emptyNode, vnode)
        }
        i = vnode.data.hook // Reuse variable
        if (isDef(i)) {
          if (isDef(i.create)) i.create(emptyNode, vnode)
          if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
        }
    }
    
    function createChildren (vnode, children, insertedVnodeQueue) { 
        //遍历⼦虚拟节点,递归调⽤ createElm ,这是⼀种常⽤
        的深度优先的遍历算法,这⾥要注意的⼀点是在遍历过程中会把 vnode.elm 作为⽗容器的 DOM 节
        点占位符传⼊。
        if (Array.isArray(children)) {
          if (process.env.NODE_ENV !== 'production') {
            checkDuplicateKeys(children)
          }
          for (let i = 0; i < children.length; ++i) {
            createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
          }
        } else if (isPrimitive(vnode.text)) {
          nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
        }
    }
    
    function createElm (){
        if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) { //尝试创建⼦组件
          return
        }
        const data = vnode.data
        const children = vnode.children
        const tag = vnode.tag
        if (isDef(tag)) {
            。。。。
          vnode.elm = vnode.ns
            ? nodeOps.createElementNS(vnode.ns, tag)
            : nodeOps.createElement(tag, vnode)
          setScope(vnode)
          //判断 vnode 是否包含 tag,如果包含,先简单对
            tag 的合法性在⾮⽣产环境下做校验,看是否是⼀个合法标签;然后再去调⽤平台 DOM 的操作去创建
            ⼀个占位符元素。
             if (__WEEX__) {
             }else{
                createChildren(vnode, children, insertedVnodeQueue) //创建⼦元素createChildren
                if (isDef(data)) {
                  invokeCreateHooks(vnode, insertedVnodeQueue) // 归纳Vnode invokeCreateHooks
                }
                insert(parentElm, vnode.elm, refElm) // 生成DOM 递归调用插入父节点,insert
             }
    }
    
    return function patch (oldVnode, vnode, hydrating, removeOnly) {
        ......
        // create new node
        createElm(
          vnode,
          insertedVnodeQueue,
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )
        主要调用createElm去创建元素
        ....
    }
}
...

好了,其实我不想直接贴这么多代码的,但是这其中顺序还是要稍微捋顺一下,到一部分贴一部分也不太好,将就一下吧,主要patch.js确实是很重要的一个JS 文件,里面的方法关乎首次渲染,和数据更新时的对比处理,反正我的脑子不够用,就先把大致Vnode=>Dom给捋顺算了。

可以看出整个文件中,有几个函数,最后返回的函数是createPatchFunction,我们就从这开始

  • createPatchFunction,定义了⼀系列的辅助⽅法,最终返回了⼀个patch⽅法,就是_update的vm._patch,其中核心调用createElm。
  • createElm,通过虚拟节点创建真实的 DOM 并插⼊到它的⽗节点中,到核心部分,可以分一下三个步奏。
  • createChildren,遍历⼦虚拟节点,递归调⽤ createElm,
  • invokeCreateHooks 归纳Vnode,执⾏所有的 create 的钩⼦并把 vnode push 到insertedVnodeQueue 中。这里注意cbs.create[i]再createPatchFunction顶部定义处理。
  • insert,生成DOM 递归调用插入父节点,注意⼦元素会优先调⽤ insert ,所以整个 vnode 树节点的插⼊顺序是先⼦后⽗。
  • 使用nodeOps的appendChild也就是原生的appendChild讲Vnode渲染成了真实DOM,(终于完了。。。。。) 上个简单流程图吧

总结

1.通过简单的流程梳理,可以发现,再数据驱动实现过程中Vue整个框架也是一步步通过原型继承的方法将一个个方法实现执行。

2.所谓数据驱动也就是,将实际Dom使用核心方法(_render)和Vue自定义的Vnode类遍历处理生成Vnode,而后通过(_update)_path分类型遍历后推到一个队列中最后通过Insert 进行dom操作生成真是DOM。

最后上个大范围流程图吧

当然也送上大佬PDF的流程图,请自取

后记

这文章,其实也是琐琐碎碎,核心的diff也没说,(水平不够),这里推荐一下我再掘金看到的一篇不错讲diff的,这个老哥的文章也都很不错,推荐一看,"juejin.cn/post/684490…"但是也希望看完的你,能对这个数据驱动方面有个大致了解,也算事对Vue知其然也知其所以然吧。欢迎讨论,吹水哦。

这类文章应该不会再写了。这篇,和(生命周期方面还没发的)主要是之前在博客园写过,只是太糙了,掘金的排版方面很舒服,就优化一下搞过来了,关于不想也不会再去写或者是看费大量精力看源码这个问题,我想等最后一篇源码方面的文章最后做个我的想法的分享也希望大家讨论吧。