vue|源码篇|1.0版本数据观测 依赖收集

629 阅读5分钟

前言

项目组个人分享系列-- 参加公司的技术分享,打算以后每个月分享一次并整理发布,只要能对部分同学有所帮助,就不枉我少睡得那些觉了zzz 时间原因,只写了1.0版,未涉及虚拟dom(用的是文档碎片),下次一定补上2.0VDOM版,其实也挺好,循序渐进才是王道

我是本文目标

  1. 代码级别解释vue的两大核心概念(数据观测 依赖收集)
  2. 异步更新及$nextTick原理解析
  3. 顺带提出Vue开发时的一些小建议

实现MVVM:数据驱动视图(数据劫持)

问题

我们更改数据之后,如何触发渲染视图的逻辑 (顺带解释Vue文档中说的两个缺陷及解决:1. 对象属性的增删无法被监听 2.数组索引改值无法被监听)

目标:观测数据,即当数据改变,要执行某个方法(渲染页面的方法)

核心思路:观察者设计模式 + Object.defineProperty

  1. 定义核心方法observe:给一个对象,对对象中所有属性的取值赋值进行劫持(赋值执行渲染逻辑,取值待会再说)

    /**
     * 将数据设置为响应式
     * @param {*} data
     */
    export function observe(data) {
        if(typeof data !== 'object' || data == null){
            return;
        }
        return new Observer(data);
    }
    
  2. 核心类Observer observe方法的本质:递归实现Object.definPro 监听所有data (获得提示:不要层级过深,纯渲染数据不要给vue托管)

    此处要对对象和数组区分对待,因为监听数组的key(也就是索引)性能过差
     if (Array.isArray(data)) {
                // 只能拦截数组的方法,但对数组中的每一项 无法监听 需要观测
                data.__proto__ = arrayMethods;
                observerArray(data)
            } else {
                this.walk(data)
            }
    
    1. 对象直接监听即可

      img

       walk(data) {
              let keys = Object.keys(data);
              for (let i = 0; i < keys.length; i++) {
                  let key = keys[i];
                  let value = data[keys[i]];
                 	// 对define的封装
                  defineReactive(data, key, value)
      
              }
          }
      
    2. 数组的观测分为两部分,数组本身、及数组内元素

      1. 数组本身的监听采用对七个方法进行装饰者模式重写(注意对新增的val也进行监听)
      2. 数组内元素的监听直接遍历递归observe即可

      img

    import { observe } from "./index.js";
    
    // 拦截用户调用的push shift unshift pop reverse sort splice concat
    
    let oldArrayProtoMethods = Array.prototype;
    
    export let arrayMethods = Object.create(oldArrayProtoMethods);
    
    let methods = ['push','shift','unshift', 'pop', 'reverse', 'sort', 'splice', 'concat'];
    /**
     * 循环数组 对数组中的每一项进行观测
     * @param {*} inserted
     */
    export function observerArray(inserted) {
        for (let i  = 0; i < inserted.length; i++) {
            observe(inserted[i])
        }
    }
    
    methods.forEach(method=>{
    
        arrayMethods[method] = function (...args){
            let r = oldArrayProtoMethods[method].apply(this,args)
    
            // todo
            let inserted;
            switch (method) {
                case 'push':
                case 'unshift':
                    inserted = args;break;
                case "splice":
                    inserted = args.slice(2)
                default:
                    break;
            }
    
            if(inserted) observerArray(inserted)
            console.log('数组更新方法 == 去渲染页面');
            return r;
    
        }
    })
    
附送:缺陷的解决: 新增属性无法被观测====$set(target,key,val)

判断target 类型

  1. 为数组 splice
  2. 为对象 defineReactive (其实就是Object.definePro)
export function set (target: Array<any> | Object, key: any, val: any): any {
  // 1.是开发环境 target 没定义或者是基础类型则报错
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  // 2.如果是数组 Vue.set(array,1,100); 调用我们重写的splice方法 (这样可以更新视图)
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }

  const ob = (target: any).__ob__
  // 4.如果是Vue实例 或 根数据data时 报错
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  // 5.如果不是响应式的也不需要将其定义成响应式属性
  if (!ob) {
    target[key] = val
    return val
  }
  // 6.将属性定义成响应式的
  defineReactive(ob.value, key, val)
  ob.dep.notify() // 7.通知视图更新
  return val
}
小结
  1. 尽可能减少vue托管数据,或者减少数据的层级,递归性能很差
    1. 不变数据采用Object.freeze 如 后台项目渲染iview表格
    2. 接口数据扁平化,用啥取啥 如 日常调用接口不要直接给vue,而是过滤一下(分享自己的封装mapUtil)
  2. vue监听缺陷可以使用set解决,本质上还是set解决,本质上还是splice,最好记住数组七个方法(4+2+1),以免出现问题佷蒙
  3. 忘记通过索引更改数组这回事儿吧,如果你在用vue的话
多则惑少则得,如果只能记住一句话:拥抱扁平等于拥抱性能
/**
 * 最终版映射 避免增加多余属性,vue无用监听浪费性能
 * target 源 可以是对象或是数组
 * keyArr  组成可以有三种: 1.字符串  为所需要的key 2. 数组[key,newKey] 取出需要属性的同时重命名  如data.course.title -> data.title   可传递 ["course.title","title"] 3. 数组 [fn] 只存在一个函数,会传递给它源对象和目标返回对象的指针
 */
export function mapUtil(target,keyArr){
    if(Object.prototype.toString.call(target) == '[object Object]'){
        return objMap(originItem,keyArr)
    }
    else if(Object.prototype.toString.call(target) == "[object Array]") {
            return arr.map(originItem=>{
            return objMap(originItem,keyArr)
        })
    }
}
/**
* 对象映射 避免增加多余属性,vue无用监听浪费性能
* originItem 原对象
* keyArr  组成可以有三种: 1.字符串  为所需要的key 2. 数组[key,newKey] 取出需要属性的同时重命名  如data.course.title -> data.title   可传递 ["course.title","title"] 3. 数组 [fn] 只存在一个函数,会传递给它源对象和目标返回对象的指针
*/
export function objMap(originItem,keyArr){
    let mapItem = {};
    for (let index = 0; index < keyArr.length; index++) {
            const key = keyArr[index];
            // 同时重命名
            if(typeof key == "object"){
                try {
                        mapItem[key[1]] = key[0].split(".").reduce((prev,cur)=>{if(!!prev[cur]){return prev[cur]}else{throw new Error(cur + '不存在')}},originItem)
                } catch (error) {
                    console.log(error);
                    mapItem[key[1]] = ""

                }
            }
            else if(typeof key == 'function'){
                key(originItem,mapItem)
            }
            else {
                    mapItem[key] = originItem[key];
            }
    }
    return mapItem
}

实现页面渲染:$mount compilier 渲染watcher

核心思路:vue 1.0 1. 文档碎片fragment采集 2. complier 遍历vue语法(解析器);vue2.3 1. vnode替换文档碎片 2. 组件化更新

本次时间原因只讲vue1.0

目标:根据数据渲染页面
核心思路: 获取挂载点中的html结构,将{{attr}}这种形式的内容匹配并用vm.data.attr替换,然后挂载到页面上(正则匹配+文档碎片挂载避免多次操作真实dom)
  1. 获取挂载点及内部html
  2. 定义compiler方法,用定义数据与html进行比对替换,返回新的html
    1. 正则小tip ?:指匹配不捕获
  3. 挂载到页面上(this.$mount)
核心代码
  • 初始化时判断如果是根组件就挂载
 if(vm.$options.el){
        vm.$mount();
    }
  • $mount方法其实就是执行了下render方法,这里引入了Watcher,这个watcher在vue中被称为【渲染watcher】,我们目前只把他理解为直接执行第二个参数就好,后面依赖收集解释
/**
 * 渲染页面 将组件进行挂载
 */
Vue.prototype.$mount = function() {
    let vm = this;
    let el = vm.$options.el;
    el = vm.$el = query(el);

    // 渲染时通过watcher进行渲染
    let updateComponent = ()=>{
        this._update()
    }
    // 渲染watcher
    new Watcher(vm,updateComponent)
}

Vue.prototype._update = function (){
    // console.log('更新操作')
    // 用用户传入的数据 去更新视图
    let vm = this;
    let el = vm.$el;

    // ps:虚拟dom重写区
    // 要循环这个元素 将里面的内容 换成我们的数据
    let node = document.createDocumentFragment();
    let firstChild;
    while(firstChild = el.firstChild){ // 每次拿到第一个元素就将这个元素放入到文档碎片中
        node.appendChild(firstChild); // appendChild 是具有移动的功能 
    }
    // todo 对文本进行替换
    compiler(node,vm);
    el.appendChild(node);
    // 需要匹配{{}}的方式来进行替换

}
  • 处理html(compiler)的核心代码


const defaultRE = /\{\{((?:.|\r?\n)+?)\}\}/g
/**
 * 
 * @param {*} node 文档碎片
 * @param {*} vm
 * child.nodeType  1 dom节点   3 文本节点 
 */
export function compiler(node,vm){ // node 就是文档碎片 
    let childNodes = node.childNodes; // 只有第一层 只有儿子 没有孙子
    // 将类数组转化成数组
    [...childNodes].forEach(child=>{ // 一种是元素 一种是文本 
        if(child.nodeType == 1){ //1 元素  3表示文本
            compiler(child,vm); // 编译当前元素的孩子节点
        }else if(child.nodeType == 3){
            util.compilerText(child,vm);
        }
    });
}

ps:重点注意下渲染watcher进行页面渲染时一定以取值,也就是会触发属性的get,这点对依赖收集很重要,静看下文

小结
  1. 无论是哪个框架,或者vue1.0的文档碎片、vue2.0的vnode,都是为了说明一件事:真实dom少操作

  2. 计算机名句:任何解决不了或者解决很麻烦的问题,都可以用向上封装一层的思想来解决,比如这里的文档碎片(但很垃圾,vnode才是真的体现了这句话)

多则惑少则得,如果只能记住一句话:正则很重要 无论webpack还是vue、react生成AST树必存在;推荐「js正则迷你书」

实现依赖收集:属性和依赖(需执行函数)的一对多关系

问题

如果我们使用类似watch、或者$watch之类的API呢?这就要求属性改变不止渲染视图,还需要执行其他函数,如何实现?

目标:数据改变除重新渲染外,还需要执行用户定义逻辑

核心思路:一对多解耦合用发布订阅,引入Dep和Watcher的概念,Watcher是依赖,可以理解为函数;Dep则是承载每个属性对应依赖的容器,可以理解为数组;则有 属性 : dep : watcher== 1: 1 : n

  1. 在属性被取值时订阅当前的函数 也就是Watcher,将之存入此属性对应的Dep实例中
  2. 在属性取值时发布,执行当前属性对应的dep中的所有watcher
  3. 注意对象和数组的依赖触发逻辑的不同,如前所言,数组本身的观测只是重写了方法
延伸问题:
  1. 一个属性对应一个dep很简单 ,在属性的define里形成一个闭包就好了;但dep怎么收集属性需要的watcher呢?

    回想上面的思路,在我们取值的时候,触发get,那也就是说,我们只要在get中能拿到需要存的watcher,就可以存入了;怎么存呢?这就是我们的Watcher是一个对象而不是直接一个函数的原因,它内部并不是直接执行函数的,而是先将当前watcher(也就是自身)挂载到Dep类的target属性上,这样在get时如果Dep.target存入,属性对应实例就将其存入自己的dep中,完成手机

  2. 如何避免存入多个相同的watcher?比如我取了多次相同的属性,自然会导致多次收集操作

    每个watcher和dep都有自己的id,id自增,存入时如果id相同则不存入

核心代码
  1. 对象依赖的收集

    1. 依赖存入Dep.target
    class Watcher {
      constructor (
        vm: Component,
        expOrFn: string | Function, // updateComponent
        cb: Function,
        options?: ?Object,
        isRenderWatcher?: boolean
      ) {
        this.vm = vm
        if (isRenderWatcher) {
          vm._watcher = this
        }
        this.cb = cb
        this.id = ++uid // uid for batching
        // 将updateComponent 放到this.getter上
        this.getter = expOrFn
        this.get() // 执行get方法
      }
      get () {
        pushTarget(this)  // Dep.target = 渲染watcher
        let value
        const vm = this.vm
    
          value = this.getter.call(vm, vm) // 开始取值 那么在get方法中就可以获取到这个全局变量Dep.target        
    
        if (this.deep) {
            traverse(value)
          }
        popTarget() // 结束后进行清理操作
        
        return value
      }
    }
    
    1. dep存入当前依赖
const dep = new Dep()
get: function reactiveGetter () {
    const value = getter ? getter.call(obj) : val
    if (Dep.target) { // 如果有watcher 将dep和watcher对应起来
        dep.depend()
    }
    return value
}
set: function reactiveSetter (newVal) {
    dep.notify();    // 当属性更新时通知dep中的所有watcher进行更新操作
}
小结
  1. 解耦合/1:n 优先考虑订阅发布,设计模式很重要,推荐【大话设计模式】

多则惑少则得,如果只能记住一句话:依赖收集其实就是一个事的发生会导致多个事的连环发生,后者为依赖,前者需收集

异步更新

为了防止多次更改同一个属性或者多次修改不同属性(他们依赖的watcher相同) 会导致频繁更新渲染

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  // 1.判断watcher是否已经存放了
  if (has[id] == null) {
    has[id] = true
  // 2.将watcher存放到队列中
    queue.push(watcher)
    // queue the flush
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue) // 在下一队列中清空queue
    }
  }
}

img