298行代码带你理解Vue响应式原理和next-Tick原理,最后手写一个自己的小demo

1,346 阅读11分钟

前言


尽量直接地带你体验vue响应式原理next-Tick原理,简单易上手,边看边写出自己的一个vue小demo,相信大家都可以轻松的学会。 整体项目代码在我的github:github.com/zs171334708…
看完如果觉得有帮助的话,欢迎star!

观察者模式和vue中的思维导图对比

观察者模式的基本导图

vue响应式原理中较为全面的导图

1.vue入口

大家都知道vue的入口是一个Vue构造函数,因此我们先创建一个Vue构造函数,并且Vue的构造函数内部是调用了一个注册在Vue原型上的一个_init函数来进行初始化的

function Vue(options){
    this._init(options)
}

接下来就要看这个_init函数都做了什么工作,在源码中这个方法初始化了生命周期,事件,渲染相关的函数,数据等。而我们现在的关注点只是想知道vue的数据是怎么实现响应式的,因此我们只需要关注数据初始化的部分就可以了。

Vue.prototype._init = function (options) {
    //获得当前实例引用
    let vm = this;
    vm.$options = options;      //将用户传递过来的对象放到$options上方便取用
    initState(vm)   //初始化数据,对data进行响应式定义,计算属性和watch的初始化,是响应式核心的入口
}

2.数据响应式的入口

1.initState

接下来的initState函数才是真正的数据响应式的直接入口,源码在这里分别处理了data计算属性watch,我们目前只关心data的处理

function initState(vm){
    let opts = vm.$options;
    //初始化data
    if(opts.data) {
        initData(vm);
    }
}

2.initData

function initData(vm) {
    var data = vm.$options.data;
    data = vm._data = typeof data === 'function'        //在vm实例上创建一个_data属性表示私有的data
                        ? data.call(vm)                 //如果data是个函数就执行,获得返回值作为data
                        : data || {}                    //如果不是函数就直接当作对象处理
    //将对象上的_data中的属性代理到vm实例上,这样在函数作用域绑定了vue实例之后
    //函数内部就可以使用this.来访问data中的属性了
    for(let key in data) {
        proxy(vm, '_data', key)
    }
    observe(vm._data)               //对vm._data进行响应式定义
}
function proxy(vm,source,key){              //代理属性的工具函数
    Object.defineProperty(vm,key,{
        get(){
            return vm[source][key]
        },
        set(newValue){
            vm[source][key] = newValue
        }
    })
}

熟悉vue开发的人可能会注意到vue建议在定义data时使用函数而不是对象,因为组件是复用的,如果使用的是对象的话,可能一个组件渲染两次会共用同一个data,出现错误,函数则会每次创建一个新的data,没有问题。

另外应该大家也知道在vue的函数中this指向的是当前vue实例,也就是上述代码中的vm,当我们想要取到vue中data定义的某个属性时,我们就写this.xxx来取值或者方法,但是data的实际位置是在_data属性上,因此我们使用了一层代理,来将this._data.xxx写法简化为this.xxx

3.observe && class Observer

function observe(data){                  //响应式定义的入口函数
    if (typeof data!=='object' || data == null) {
        return   //不是对象或者是null就不需要观察
    }
    return new Observer(data)
}
class Observer{
    constructor(value) {
        this.value = value;
        Object.defineProperty(value, '__ob__',{          //把observer实例代理到被观察的对象的__ob__属性下
            value:this,
            enumerable: false,      //不可枚举
            writable: true,
            configurable: true
        })
        if(Array.isArray(value)) {          //如果类型是数组的话,我们暂时不考虑

        } else {                            //不是数组就是object
            this.walk(value)
        }
    }
    walk(obj){
        const keys = Object.keys(obj)
        for(let i =0,l = keys.length;i<l;i++){
            defineReactive(obj, keys[i])                //对于对象上的每个属性调用defineReactive方法
        }
    }
}

我们关键说一下这个Observer类,它在observer实例下加了一个__ob__属性,为了方便访问监听对象的Observer实例。我们目前只关心对于对象的监听,因此当value是对象的话,我们执行walk方法,对对象的每一个属性去调用defineReactive方法

4.defineReactive

function defineReactive(obj, key) {
    let value = obj[key]
    let dep = new Dep();
    Object.defineProperty(obj,key,{
        get(){
            if(Dep.target) {
                dep.addSub(Dep.target)                  //收集依赖
            }
            return value
        },
        set(newValue) {
            if(newValue === value) {
                return ;
            } else {
                observe(newValue);                      //观察新数据
                value = newValue;                       //更新数据
                dep.notify()                            //通知依赖更新
            }
        }
    })
}

这个defineReactive方法是实现响应式原理的关键,这个方法通过Object.definepropertyAPI实现了对数据的劫持,就是在数据进行赋值操作或者取值操作时我们可以在其后增加一系列的操作,这也是我们收集依赖和通知依赖更新的关键点。

我们可以看到,在这个defineReactive中,给被监听的对象的属性添加了一个私有的dep对象,当这个被监听的对象被取值时,执行dep的addSub方法,当被监听的对象的值修改时,执行dep的notify方法,接下来我们就解开这个dep的神秘面纱

5.class Dep

let id = 0
class Dep {
    constructor () {
        this.id = id++;
        this.subs = [];             //订阅的集合
        this.WatcherIdSet = {}      //订阅的id的简易散列,可以快速找出是否已经订阅避免重复
    }
    addSub(watcher) {
        if(!this.WatcherIdSet[watcher.id]) {            //如果没有添加的话
            this.WatcherIdSet[watcher.id] = true
            this.subs.push(watcher)
        }
    }
    notify(){                                           //通知所有dep更新
        for(let i = 0,l = this.subs.length;i<l;i++) {
            this.subs[i].update()                       //依次调用所有订阅者的update方法
        }
    }
}

dep的作用就是,收集所有依赖我数据的watcher实例作为依赖,当我的数值发生变化时,通知所有订阅我的watcher,这个通知他们的方式就是调用他们各自的update方法。

6.pushTarget && popTarget

const stack = []
function pushTarget(watcher) {               //将传入的watcher传入栈顶
    Dep.target = watcher;
    stack.push(watcher)
}
function popTarget(){                        //栈顶watcher退栈
    stack.pop();
    Dep.target = stack[stack.length -1]
}

这两个函数的作用是共同维护一个stack,并且确保Dep.target指向的是当前正在可能对data进行取值操作的watcher,将这个watcher暴露出来,方便被取值的data进行依赖收集。

7.class Watcher

看到这里可能有点迷糊了,前面说的watcher到底是个什么呢,接下来给你揭晓。

let id = 0
export class Watcher{
    /** 
    * 
    * @param {*} vm 当前组件的实例 new Vue 
    * @param {*} exprOrFn 用户可能传入的是一个表达式 也有可能是一个函数
    * @param {*} cb 用户传入的回调函数 vm.$watch
    * @param {*} opts 一些其他参数
    * @param {*} ifRender 是不是渲染watcher
    */
    constructor (vm,exprOrFn,cb = ()=>{},opts = {}, ifRender) {
        this.vm = vm;
        this.exprOrFn = exprOrFn
        if(typeof exprOrFn === 'function') {
            this.getter = exprOrFn
        }
        this.id = id++;
        this.get()
    }
    get(){
        pushTarget(this);
        this.getter.call(this.vm)                    //计算时方便其依赖数据收集依赖
        popTarget()
    }
    run() {                                     //执行回调
        this.getter.call(this.vm)
    }
    update(){                                   //被通知依赖发生了变化,调用run方法
        this.run()
    }
}

这里的getter方法,其实是执行的渲染流程,你可以简单的理解为,在这个方法里,执行了对data的取值操作,因此在取值操作前,要把当前的watcher实例暴露出来方便data收集依赖,getter方法执行完之后就将当前watcher退栈。

8.$mount

当你实现上述代码的时候,你会发现,data确实是被监听了,但是都没有收集到依赖。在vue的响应式原理中,所谓的依赖都是watcher实例,但是我们并没有创建watcher实例。在源码中挂载dom的时候会创建一个watcher实例,我们将其称为渲染watcher),接下来我们就简单地来模拟这个过程

在前面_init执行完毕之后还会执行$mount方法

Vue.prototype._init = function (options) {
    //获得当前实例引用
    let vm = this;
    vm.$options = options;      //将用户传递过来的对象放到$options上方便取用
    initState(vm)   //初始化数据,对data进行响应式定义,计算属性和watch的初始化,是响应式核心的入口
    
    if(vm.$options.el) {                //如果options有挂载点,执行mount方法
        vm.$mount()
    }
}
Vue.prototype.$mount = function () {
    let vm = this;
    let el = vm.$options.el;
    el = vm.$el = query(el);                //获得挂载点

    let updateComponent = () => {               //渲染watcher的回调函数
        vm._update();
    }
    new Watcher(vm, updateComponent, undefined, undefined, true)        //创建渲染watcher
}
function query(el){
    if(typeof el === 'string'){
        return document.querySelector(el);
    }
    return el; 
}

在$mount创建的渲染watcher回调中又调用了_update方法,我们接下来看一下这个_update方法的实现(模拟)

Vue.prototype._update = function () {               //模拟对dom的更新,源码中,diff算法是从这里进入的
    let vm = this;
    let el = vm.$el;

    let node = document.createDocumentFragment();           //创建文档碎片
    let firstChild = el.firstChild
    while(firstChild = el.firstChild) {
        node.appendChild(firstChild)            //把之前的dom放入文档碎片中
    }
    compiler(node, this)            //模拟模板编译

    el.appendChild(node)        //将处理好的文档碎片再放回来
}

模拟compile

const defaultRE = /\{\{((?:.|\r\n)+?)\}\}/g             //匹配双括号表达式
function compilerText(node,vm){
    if(!node.reg){
        node.reg = node.textContent
    }
    node.textContent = node.reg.replace(defaultRE,function(...args){            //正则匹配到双括号表达式中的内容
        let func = new Function (`with(this){
            return ${args[1]}
        }`)
        return func.call(vm)                //用表达式中的内容创建一个vm作用域的函数执行得出结果,将文本中的内容替换
    })
}
function compiler (node, vm){                //深度遍历文档节点
    let childNodes = node.childNodes;
    //将类数组转化成数组
    [...childNodes].forEach(child=>{
        if(child.nodeType == 1){//元素节点
            compiler(child,vm)
        } else if (child.nodeType == 3){//文本节点
            compilerText(child,vm)                  //如果是文本节点就处理
        }
    })
}

模拟这个的过程中我们并没有使用虚拟DOM,完全都是纯DOM操作,只是取到之前挂载点的DOM,将其子节点全部放入一个文档碎片中,递归遍历字节点,将子节点中文本节点双大括号进行了一次简单的处理,来替换成vue中响应式定义的变量的值,这样可以看到效果,近距离地感受数据响应式的一个变化。

图例流程

不知道大家理解完这个响应式的流程之后,无论是从代码角度,还是从这个流程图的角度,有没有想过一个问题,假如我们一次性执行了很多的对data中变量的值改变的操作,难道我们就要让updateComponent函数执行很多次、让更新dom执行很多次吗?有没有这个必要呢?答案是:当然没有这个必要,我们往往只需要在执行完一系列操作以后进行一次dom的更新就足够了。所以就有了next-Tick的出现,next-Tick保证了在一系列对数据的操作之后,像更新DOM这样的的操作才会执行,并且只执行一次

next-Tick原理

要理解next-Tick原理的话,首先要理解JavaScript事件循环机制,为了能让基础不是那么好的同学们也能轻松地学明白next-Tick原理,我在这里对事件循环机制举个不那么恰当的例子。

当然,这个例子和说法不够严谨,但是这样表达对于没有深入研究过eventloop的人来说更加容易理解

假如你去一个奶茶店买奶茶,到了奶茶店发现有很多人排队,但是你一点都不着急,干脆往旁边的座椅上一坐,排队买奶茶的人都买完走没了,然后你过去买了一杯奶茶走了。

JavaScript事件循环机制大致跟这个也很类似,首先js执行代码是单线程的,也就是一次只能执行一步操作,类似于,奶茶店只有一个卖奶茶的窗口,一次只能卖给一个人奶茶。异步操作就类似于你去奶茶店买奶茶,你可以等待其他人买完奶茶走人(等待同步任务处理完毕),然后你再去买奶茶(执行异步任务)。

next-Tick异步原理简单图例

实际上严格来说浏览器中的任务是没有异步同步之分的,我在这里所谓的异步同步只是为了字面上更容易理解。

next-Tick

next-Tick的整体思路就是,在项目执行的一开始就进行浏览器能力检测,确定我们接下来使用哪一种方式创建异步任务,确定了创建异步任务的方式之后,接下来所有的操作其实就是维护一个任务队列callbacks,当有任务需要执行时,开启pending,启动一个异步任务,将需要执行的任务放入任务队列中,最后等到同步任务执行完毕之后(这一步其实不是开发者能直接操控的,异步和同步的任务调度是由浏览器来做的),再将在这个任务队列callbacks中排队的方法逐一执行清空。值得注意的点是,当异步回调还没执行前,如果有新的异步任务需要执行,就不需要再创建异步任务,只需要让这个任务去callbacks中排队

next-Tick其实不只是响应式独有的,它是对于异步任务的一个集中管理,任何需要在本次事件循环结束前执行的事件都可以集中使用next-Tick来执行。

let callbacks = []                      //需要在nextTick执行的回调
let pending = false                     //是否正在pending中
let timerFunc
function flushCallbacks(){              //清空回调函数队列的函数
    pending = false
    callbacks.forEach(cb=>cb());
    callbacks = []
}
function nextTick(cb) {
    callbacks.push(cb)                  //把回调放入回调队列中
    if(!pending) {                      //如果不是pending状态就开启一个微任务
        pending = true
        timerFunc()
    }
}
if(Promise) {                                           //先进行浏览器能力判断,决定使用哪一种异步
    timerFunc = () => { 
        Promise.resolve().then(flushCallbacks)
    }
} else if (MutationObserver) {
    let observe = new MutationObserver(flushCallbacks);
    let textNode = document.createTextNode(1);
    observe.observe(textNode,{characterData:true});
    timerFunc = () => {
        textNode.textContent = 2;
    }
} else if (setImmediate) {
    timerFunc = () => {
        setImmediate(flushCallbacks);
    }
} else {
    timerFunc = () => {
        setTimeout(flushCallbacks, 0)
    }
}

在响应式中使用next-Tick

响应式原理中其实对这些watcher也使用了一个队列queue来进行管理,如果有watcher调用update方法,就会调用这个queueWatcher函数来将自己放入queue中,最后放入next-Tick队列中的回调函数只是flushQueue这一个函数,而这一个函数就可以清空掉所有等待排队被处理的watcher实例。


//需要清空的watcher队列
let queue = []
let has = {}
let waiting = false                 //是否等待
function flushQueue() {                         //清空队列的函数
    queue.forEach(watcher=> {
        watcher.run();
        has[watcher.id] = null
    })
    queue = []
    waiting = false
}
function queueWatcher(watcher) {                    //将watcher放入等待队列并启动nextTick
    let id = watcher.id
    if(has[id]==null) {
        has[id] = true;
        queue.push(watcher)
        if(!waiting) {
            waiting = true
            nextTick(flushQueue)
        }
    }
}

总结

可能很多地方写的不太好很罗嗦没说清楚,毕竟水平不太行。如果有什么不妥的地方欢迎留言指出,希望大家都可以学会自己想学的知识,加油!

另外,如果情况允许的话下期尽量添加上计算属性和对数组的处理实践。