阅读 74

通过Vue源码研究其运行流程

研究源码的好处

今天我来研究一下vue源码,从中也有不少收获。

  1. 有利于日常工作中更恰当的使用vue,优化业务代码。
  2. 加深理解数据驱动,响应式原理、组件化、diff等底层原理。

从Vue实例开始

来看一个官方文档的示例

<div id="app">
  {{ message }}
</div>

var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!'
  }
})
复制代码

Vue的起步会new一个Vue实例,由实例来生成和更新HTML,呈现在浏览器上。根实例上,又会有很多子组件,子组件也是继承自Vue类的实例。所以Vue类是核心。

源码目录

看之前先熟悉一下源码目录。

src
├── compiler        # 编译相关 
├── core            # 核心代码 
├── platforms       # 不同平台的支持
├── server          # 服务端渲染
├── sfc             # .vue 文件解析
├── shared          # 共享代码
复制代码

Vue类的代码,就在core文件夹的instanc文件夹下。

Vue类的基本结构

查看src/core/instance/index.js文件。

import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'

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类。既然是导出一个类,却没有使用es6语法class,还是使用es5的构造函数方式,这样做的原因是Vue类内的代码太多了,需要作拆分,将不同逻辑的代码放在不同文件中。如这里把初始化逻辑拆分到init.js文件,然后initMixin(Vue)将初始化的方法挂在到Vue类上。

当new Vue时,执行构造函数,this._init(options)被调用。这个_init方法就在initMixin(Vue)被挂在Vue类上的。也就是在实例化之前,对于Vue类的建造,已经完成。

我来写个伪代码,initMixin、stateMixin、eventsMixin等拆分出去等代码,合并到一起,用class呈现一个Vue类的样子。

export default class Vue {
    constructor(options){
        this._init(options);
    }
    
    // init相关
    // 源码中是用Vue.prototype._init语法,将方法挂到类上
    _init(){ ...初始化逻辑 }
    
    
    // state相关
    
    get $data(){ ... }
    set $data(){ ... }
    get $props(){ ... }
    set $props(){ ... }
    
    $set() { ... }
    $delete(){ ... }
    
    $watch(){ ... }
    
    // event相关
    
    $on(){ ... }
    $once(){ ... }
    $off(){ ... }
    $emit(){ ... }
    
    // lifecycleMixin相关
    _update(){ ... }
    
    $forceUpdate(){ ... }
    $destroy(){ ... }
    
    // render相关
    
    $nextTick(){ ... }
    _render(){ ... }
    
    // 挂载相关
    $mount() { ... }
}
复制代码

首先看到是一种规范,下划线开头的方法,是私有方法。开头的方法,是公开的方法。我们用vue写项目时,应该都使用过this.emit/$nextTick等,就是调用vue实例的方法。

下面的代码也都是伪代码,是为说明源码的逻辑。重点是梳理源码的思路和运行流程。

研究初始化逻辑

日常写项目时,new Vue(options:最主要的是传入模版和数据),然后vue实例就帮我们渲染出了界面。

上面分析到new Vue中调用了_init方法。来看它的逻辑。

_init(options) {
    // 1,增强vm(vue实例,也就是this)。就是往vm对象上添加属性。
    // 如vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true) 给vm添加一个$createElement方法,用于创建VNode的。
    // 如将我们声明的data/props/computed里的字段代理到vm上。这是我们在项目里写代码时,data中定义a,在其他地方就可以用this.a可以访问到的原因。
    // 并且将字段都变成get/set形式属性。以便随后进行依赖收集和派发更新的操作。
    ...
    
    // 2,vue实例增强完毕后,它得干活了,生成虚拟VNode,然后更新DOM。
    // 干的活都在下面这句代码中。$options是_init中增强时得到的,$mount是在上面定义Vue类时定义好的。
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
}
复制代码

这里没贴出_init所有代码,细节可以大家自己去看,思路上_init做了两件事,增加vue实例和调用$mount生成DOM。

挂载中发生的事

接下来看看$mount方法执行中发生的事。

$mount() {
    // 主要逻辑如下
 
    // 1 定义一个回调
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
    
    // 2 利用Watcher执行回调
    new Watcher(vm, updateComponent,...)
}
复制代码

挂载中会实例化一个Watcher,Watcher的作用,一是初始化时调用updateComponent,二是数据发生变化时,执行updateComponent,也就是为了实现响应式。

updateComponent中,_render生成VNode,虚拟节点树。然后_update使用VNode,生成和更新DOM。

生成虚拟节点树

看_render中的逻辑。


_render(){
    // 主要逻辑如下
    var vnode = render(vm.$createElement);
    
    return vnode;
}

// render方法就是
// <div id="app">
//  {{ message }}
// </div>
// 这样的模版经过编译后的js语法。
render: function (createElement) {
  return createElement('div', {
     attrs: {
        id: 'app'
      },
  }, this.message)
}

复制代码

render最终会生成一个根vnode,根vnode节点里又有很多内置/自定义组件节点,是一个虚拟节点树。

生成真实DOM

_update 方法的作用是把 VNode 渲染成真实的 DOM。

_update(vnode){
    // 主要逻辑如下
    if(初始化) {
        // vm.$el存放的是DOM节点的id,这样patch就可以将生成的dom内容添加到根dom上了。
        patch(vm.$el,vnode);
    } else {
        // 更新
        patch(prevVnode, vnode)
    }
}
复制代码

_update方法是通过调用patch方法实现生成DOM的,分为初始化的情况和数据变化时更新的情况。我们先来看初始化时的情况。

// 平台相关的patch,在web上或是移动端上,patch方法肯定是不同的,因为涉及到操作DOM了,不同平台的DOM api肯定不一样
patch(){
    // 初始化主要逻辑
    
    createElm(vnode) 
}

createElm(vnode) {
    // 1,生成此节点的DOM对象
    vnode.elm = nodeOps.createElement(tag, vnode);
    // 2. 去遍历子节点
    createChildren(vnode, vnode.children)
    // 3. 将生成的DOM节点插入到界面中
    insert(vnode)
}

// 这里看到递归调用createElm,最终生成所有DOM
createChildren(vnode, children, insertedVnodeQueue) {
    for (let i = 0; i < children.length; ++i) {
      createElm(children[i])
    }
}

insert(vnode){
    // 将生成的DOM节点插入到界面中,使用DOM api
}

复制代码

小结

抛开数据更新流程和组件化相关逻辑,初始化的流程研究完成。

  1. 实例化一个Vue:new Vue(),调用_init()。
  2. _init:增强实例和调用$mount。
  3. $mount: 实例化Watcher监听数据变化,调用_render和_update。
  4. _render: 生成Vnode树。
  5. _update:利用Vnode树生成DOM并挂载到界面上。

组件化研究

上面分析的是最简单情况下,只有一个根Vue的初始化流程,现在增加一个因素,根Vue下,还有很多子组件,也是一个个Vue。

值得注意的是,日常我们写的组件,是一个由template + js对象 + css的.vue文件,由vue-loader转化成一个js对象,这个对象只是子组件的配置对象,并不是继承自Vue类的实例。下面我们就来看一下,源码是怎样使用我们写的配置对象(.vue文件生成)的。

一次轮回

根组件new Vue -> _init -> $mount -> _render —> _update。

子组件的逻辑从根组件的_render开始:

_render(){
    // 主要逻辑如下
    var vnode = render(vm.$createElement);
    
    return vnode;
}

render: function (createElement) {
    return createElement('div', {
     attrs: {
        id: 'app',
        children:[
            createElement('BlogList', children, this.blogList)
        ]
      },
    }, this.message);
}

createElement(tag, children, data) {
    let vnode;
    // div,span这种
    if(平台内置标签) {
        // 创建普通的vnode
        vnode = new VNode(tag, children, data);
    } else if (是自定义组件) {
        // 创建自定义组件的vnode
        vnode = createComponent(Ctor, data, children, tag)
    }
}

复制代码

_render的职责没有变,就是为了获得vnode。但在createElement这一层,如果遇到组件节点,会生成一个组件的vnode,添加到最终的虚拟node树上。

再看createComponent方法,生成的是怎样的组件VNode:

createComponent(Ctor){
    // 1,利用我们的配置对象,创建子组件Vue类
    Ctor = baseCtor.extend(Ctor) // 入参的Ctor就是我们编写的子组件.vue文件的内容。返回值是继承了Vue类的子类,它不但有基类的功能,还合并了.vue文件中定义的成员。
    
    // 2,将自组件类作为参数传递给vnode
    const vnode = new VNode(Ctor);
    
    return vnode;
}
复制代码

这里值得注意的是,我们的Vue子组件只是在此处组装成了类,但是并没有实例化它。实例化还要等到_update时。

从_render()拿到vnode后,接下里就看一下_update:

_update(vnode){
    // 初始化时的逻辑
    patch(vm.$el,vnode);
}

patch(){
    // 初始化主要逻辑
    
    createElm(vnode) 
}

createElm(vnode) {
    
    // 添加处理自定义组件逻辑
    if(createComponent(vnode)){
        return;
    }

    // 1,生成此节点的DOM对象
    vnode.elm = nodeOps.createElement(tag, vnode);
    // 2. 去遍历子节点
    createChildren(vnode, vnode.children)
    // 3. 将生成的DOM节点插入到界面中
    insert(vnode)
}

createComponent(vnode){
    if(自定义组件的vnode){
        // 1, 从vnode中拿到组件的类。
        const Ctor = vnode.componentOptions.Ctor; // 这个在_render过程中拼装的
        
        new Ctor(options); // Ctor就是Vue的子类,所以这句可以看成是new Vue(options)
        
        return true;
    }
    return false;
}

复制代码

new Ctor(options)可以看成是new Vue(options)。轮回开始了,又开始_init -> _init -> $mount -> _render —> _update。子组件也会走一遍这样的流程。

子组件走完这样的流程,那么它已经操作完DOM了。控制权又回到父组件。父组件继续_update。

总结

我们将组件的因素添加到源码流程分析之后,只是添加了一个轮回,子组件也是走这么一套流程。

响应式原理研究

研究过初始化流程之后,接下来该研究数据变更时,更新界面的逻辑了。我们也管这叫响应式。

首先看图,数据变化是在哪里影响到源码运行的:

是在 mount方法,mount方法如下:

$mount() {
    // 主要逻辑如下
 
    // 1 定义一个回调
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
    
    // 2 利用Watcher执行回调
    new Watcher(vm, updateComponent,...)
}
复制代码

这时Watcher起作用了,数据变化,Watcher重新调用updateComponent,然后->_render-> _update。

值得一提的时,如果是子组件的数据变化时,只会触发子组件的Watcher,更新范围就小很多。所以日常写业务代码时,如果某项数据,只跟这个子组件有关,就不要写在父组件或根组件上,这样就不会影响性能了。

数据更新还有两个问题:

  1. Vue怎样做的知道数据被更新的。
  2. 数据变化后,流程中发生了什么变化。这里面就涉及到经常被提及的diff了。

响应式对象

日常写代码,会有如下写法

export default {
    data() {
        return {
            blogList:[],
        }
    }
    
    methods:{
        onSearch(){
            this.blogList = await api.fetchBlogList();
        }
    }
}
复制代码

给blogList赋值,DOM就是响应更新。原因是Vue帮助我们将blogList转换成了响应式对象。这发生在_init中,增强Vue实例阶段,调用$mount之前。

_init(options) {
    // 1,增强vm
    ...
    initState(vm)
    ...
    
    // 2,调用$mount
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
}

initState(vm) {
    // 将data内的字段都变成响应式对象
    
    const keys = Object.keys(vm.data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(vm, keys[i])
    }
}

defineReactive(obj,key){
  Object.defineProperty(obj, key, { 
      get:function(){ ... },
      set:function(newVal){ ... }
  })
}

复制代码

将data变成响应式对象之后。在_render()时,要读取data生成vnode树,所以 会触发data的get函数:

const dep = new Dep()

get:function(){
    dep.depend();
    return value;
}
set:function(newVal){
    val = newVal
    dep.notify();
}

复制代码

这里有一个dep对象。在get的时候,做了一个依赖收集(dep.depend())的操作,然后在set的时候,也就是我们调用this.blogList = 数据; 时,做了一次派发更新。

我来画一下 get/set和dep,watcher对象的关系:

在数据初次render被get的时候,dep记录一下这个数据,当数据被set的时候,就触发dep关联的watcher,watcher触发_render -> _update,开始了界面的更新。

vnode的diff

对于_render()方法,初始化和更新没有区别,都是要生成新的vnode。不同的地方在于_update()。

_update(vnode){
    // 主要逻辑如下
    
    // 在vm上拿到上次的vnode树
    const prevVnode = vm._vnode;
    vm._vnode = vnode;
    if(初始化) {
        patch(vm.$el,vnode);
    } else {
        // 更新,对比前后两次的vnode树
        patch(prevVnode, vnode)
    }
}
复制代码

对比前后两次的vnode树,就是常说的diff流程。我们来看看

patch(prevVnode, vnode) {
    // 1. 如果新旧vnode不同 key、tag、data都不同
    // 创建新节点,在节点的父节点下添加新节点,删除旧节点
    createElm(); // 创建新节点,不是指创建vnode,而是用新vnode创建DOM
    vnode.parent.insert() // 将创建的DOM,添加到父节点下
    destroyElm(); // 删除旧的DOM
    
    
    // 2, 如果新旧vnode相同,相同是指 key、tag、data相同,但不代表它们的子节点,也都相同
    
    prepatch(oldVnode,vnode); // 更新vnode对应的vue实例的一些属性
    const oldCh = oldVnode.children // 旧vnode的子节点
    const ch = vnode.children // 新vnode的子节点
    
    if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly) // oldCh 与 ch 都存在且不相同时,使用 updateChildren 函数来更新子节点
    } else if (isDef(ch)) {
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) // 如果只有 ch 存在,表示旧节点不需要了。如果旧的节点是文本节点则先将节点的文本清除,然后通过 addVnodes 将 ch 批量插入到新节点 elm 下。
    } else if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1) // 如果只有 oldCh 存在,表示更新的是空节点,则需要将旧的节点通过 removeVnodes 全部清除。
    } else if (isDef(oldVnode.text)) {
        nodeOps.setTextContent(elm, '') // 当只有旧节点是文本节点的时候,则清除其节点文本内容。
    }
    
}
复制代码

这段逻辑开始复杂了,总结来说就是新旧节点相同的更新流程是去获取它们的 children,根据不同情况做不同的更新逻辑。

其中最复杂的是updateChildren,就是是相同vnode节点,还都有一堆子节点。这地方网上有很多分析,还多为图解。这里列两个我搜到的掘金上的文章,我就不重复叙述了:

详解vue的diff算法

图文详解 vue diff 核心方法 updateChildren

结语

到此,通过源码大致梳理了Vue的运行流程。最后贴一张分析的整体流程图 :

关注下面的标签,发现更多相似文章
评论