菜鸟初探Vue源码(三)-- 数据驱动

213 阅读4分钟

数据驱动作为Vue.js的核心思想之一,是指视图由数据生成,想要对视图做出修改时,不同于jQuery等前端库直接操作DOM,而是通过修改数据来影响视图。这样大大简化了代码量,在开发过程中只关心数据也使得代码逻辑变得非常清晰。如下:

<div id="app">
    {{message}}
</div>
const vm = new Vue({
    el: '#app',
    data: {
        message: 'Hello World'
    }
})

最终会在页面上渲染出 Hello World。接下来我们逐步分析这一过程是如何实现的。

通过上一篇的分析,当执行new Vue(options)时,会执行this._init(options),我们找到_init方法的定义:

export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm = this
    // ...此处省略多行代码
    // merge options 
    // ...此处省略多行代码,合并options到this.$options上
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm)
    initState(vm)
    initProvide(vm)
    callHook(vm, 'created')
    // ...此处省略多行代码
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

其中initState(vm)与我们上面的demo相关,进入initState(vm)方法中。

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  // initializing options accroding to corresponding `opts[keys]`
  // ...此处省略多行代码,分别处理props、methods
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  // ...处理computed、watch
}

在我们的demo中opts.data存在,调用initData(vm),进入initData(vm)方法中。

function initData (vm: Component) {
  let data = vm.$options.data

  // check if data is a function or object
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  
  // proxy data on instance
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  while (i--) {
    const key = keys[i]
    // ... 此处省略多行代码
    // 判断methods、props、data的字段如果有冲突,抛出警告,否则执行下面逻辑
    if (!isReserved(key)) {
         // 对data做proxy处理,this.xxx实际上相当于this._data.xxx
        proxy(vm, `_data`, key)
    }
  }
  // observe data 
  // 数据响应式,在此处暂且不关注
  observe(data, true /* asRootData */)
}

initData方法中,对data做了proxy处理,这样一来,访问this.xxx时实际上就相当于访问了this._data.xxx,而initData函数内部将data赋值给了this._data

export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

至此data的初始化完成,回到上面_init方法中,还有一个vm.$mount(vm.$options.el)没有执行,也就是挂载。接下来我们看挂载过程。 因为我们使用的是Runtime+Compiler版本的Vue.js,所以执行vm.$mount()时,进入src/platforms/web/entry-runtime-with-compiler.js

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el) //转化成DOM对象

  const options = this.$options
  // resolve template/el and convert to render function
  if (!options.render) {
    // 没有定义render函数,就把模板编译成render函数
    let template = options.template
    if (template) {
      // 定义了template
      // ...
    } else if (el) {
      // 未定义template 
      template = getOuterHTML(el)
    }
    if (template) {
      // 编译相关
      // ...
    }
  }
  // 定义了render函数,直接调用
  return mount.call(this, el, hydrating)
}

看到这儿会发现,Vueprototype上已经拥有了$mount方法(定义在runtime/index.js),在此将该方法保存到变量上,之后又重新定义了$mount方法。这又是什么原因呢?

因为在 Runtime+Compiler 版本中,我们可能定义了template字段,而在 Runtime-Only 版本中,只能定义render函数。当调用$mount方法时,如果定义了render函数,直接调用原来的$mount方法;如果没有,通过重新定义的$mount方法将template转为render函数之后,再调用原来的$mount方法。

我们再来进入runtime/index.js中的$mount方法。

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

很简单,内部执行了mountComponent方法。接下来进入该方法。

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  if (!vm.$options.render) {
    // 如果没有编译出render函数,创建一个空的VNode
    vm.$options.render = createEmptyVNode
  }
  callHook(vm, 'beforeMount')

  // 定义updateComponent函数
  let updateComponent = () => {
     vm._update(vm._render(), hydrating)
  }

  // 渲染Watcher
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) callHook(vm, 'beforeUpdate')
    }
  }, true /* isRenderWatcher */)

  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

mountComponent方法中定义了updateComponent函数(用作渲染的函数),随后执行了new Watcher(),简单看一下Watcher的定义

class Watcher{
    constructor (
        vm: Component,
        expOrFn: string | Function,
        cb: Function,
        options?: ?Object,
        isRenderWatcher?: boolean
    )
    if (typeof expOrFn === 'function') this.getter = expOrFn
    this.value = this.lazy ? undefined : this.get()
}
get () {
    const vm = this.vm
    let value = this.getter.call(vm, vm)
    return value
}

Watcher中将第二个参数赋值给了getter,随后调用get方法时执行了getter,等同于updateComponent函数被执行。也等同于vm._update(vm._render(),hydrating),那么接下来从vm._render开始探讨。

Vue.prototype._render = function (): VNode {
    const vm: Component = this
    const { render, _parentVnode } = vm.$options
    // render self
    // 在生产环境下vm._renderProxy = vm,开发环境下要做简单处理,具体见initProxy()方法 
    let vnode = render.call(vm._renderProxy, vm.$createElement)

    // if the returned array contains only a single node, allow it
    if (Array.isArray(vnode) && vnode.length === 1) {
      vnode = vnode[0]
    }
    return vnode
}

可以看出vm._render会返回一个值vnode。具体过程是,首先从$options解构出render函数,并调用。传入两个参数,vm._renderProxy在生产环境下直接用vm赋值,在开发环境下要经过initProxy方法处理(代码见init.js);vm.$createElement定义在initRender函数(已在init.js执行)中,在函数内调用了createElement方法

// 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)
// 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)

进入createElement方法

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方法

export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  // 对children做normalization,最终统一形式[vnode, vnode, ...]
  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }
  let vnode, ns
  // tag是字符串
  if (typeof tag === 'string') {
    let Ctor
    if (config.isReservedTag(tag)) {
      // 如果是保留标签
      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
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  }
  if (Array.isArray(vnode)) {
    return vnode
  } else if (isDef(vnode)) {
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    return createEmptyVNode()
  }
}

_createElement方法中,首先对children做 normalization,最终生成统一形式[vnode, vnode, ...],随后进入typeof tag === 'string'的逻辑,div属于保留标签,创建相应的vnode,最后返回。 至此,vm._render函数就执行完毕了(_createElement -> createElement -> $createElement -> render -> _render)。回退到vm._update(vm._render(),hydrating),还剩最后一步执行vm.updatevm._update定义在lifecycle.js中,代码如下:

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  }

我们找到vm.__patch__方法,定义如下:

Vue.prototype.__patch__ = inBrowser ? patch : noop

再找到patch方法,定义如下:

// module中定义了一些钩子函数,用于生成attrs、class、style...
const modules = platformModules.concat(baseModules)
// nodeOps定义了一些DOM操作方法
export const patch: Function = createPatchFunction({ nodeOps, modules })

再找到createPatchFunction方法,在createPatchFunction最后,我们看到返回了一个patch函数。

const hooks = ['create', 'activate', 'update', 'remove', 'destroy']
export function createPatchFunction (backend){
    let i, j
    const cbs = {}
    const { modules, nodeOps } = backend
    for (i = 0; i < hooks.length; ++i) {
        cbs[hooks[i]] = []
        for (j = 0; j < modules.length; ++j) {
            if (isDef(modules[j][hooks[i]])) {
                cbs[hooks[i]].push(modules[j][hooks[i]])
            }
        }
    }
    
    // ... 此处省略几百行代码,多为辅助函数
    
    return function patch (oldVnode, vnode, hydrating, removeOnly) {
        // ... 
        // create an empty node and replace it
        oldVnode = emptyNodeAt(oldVnode)
        // replacing existing element
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)
        
        createElm(
            vnode,
            insertedVnodeQueue,
            oldElm._leaveCb ? null : parentElm,
            nodeOps.nextSibling(oldElm)
        )
        
        // destroy old node
        if (isDef(parentElm)) {
            removeVnodes([oldVnode], 0, 0)
        }
        
        return vnode.elm
    }
}

Vue.js为什么要绕这么一大圈定义patch函数呢? 实际上此处是利用了函数科里化的技巧,因为Vue.js是支持多端的。在调用createPatchFunction时,把与平台相关的的参数传入,简化了真正patch函数的逻辑。

patch函数中,首先把传入的oldVnode(上面调用时传入的DOM)通过emptyNodeAt方法转化为vnode,随后根据vnode获取到当前元素(div)和父级元素(body),调用createElm方法。

function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) {
    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag
    if (isDef(tag)) {
        // 创建DOM
        vnode.elm = nodeOps.createElement(tag, vnode)
        // 创建children
        createChildren(vnode, children, insertedVnodeQueue)
        // 插入
        insert(parentElm, vnode.elm, refElm)
    } else if (isTrue(vnode.isComment)) {
        vnode.elm = nodeOps.createComment(vnode.text)
        insert(parentElm, vnode.elm, refElm)
    } else {
        vnode.elm = nodeOps.createTextNode(vnode.text)
        insert(parentElm, vnode.elm, refElm)
    }
}

createElm方法中,先根据标签创建相应 DOM 元素,如果有children,调用createChildren(函数内部递归调用了createElm),最终调用insert将整个新生成的插入父节点,此时页面已经渲染出了最终内容。但还没有结束,打开控制台,查看element会发现有两个div节点,我们还需调用removeVnodes删除旧的节点,至此整个页面的初次渲染就宣告结束了。

function createChildren (vnode, children, insertedVnodeQueue) {
    if (Array.isArray(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 insert (parent, elm, ref) {
    if (isDef(parent)) {
        if (isDef(ref)) {
            if (nodeOps.parentNode(ref) === parent) {
                nodeOps.insertBefore(parent, elm, ref)
            }
        } else {
            nodeOps.appendChild(parent, elm)
        }
    }
}

总结一下,从new Vue()开始,经历了init -> $mount -> compile/render -> vnode -> patch -> DOM,以上就是将一个简单节点从数据初始化至渲染到页面的基本流程。下一篇我们来讨论将组件渲染至页面的情况。