vue源码解析之『我的数据去哪了』

之前学习vue的时候就对$set很感兴趣,但奈何一直都是“小打小闹”,本以为莫非这玩意根本用不到而渐渐淡忘没想到最近项目中接二连三的出现类似的问题让我不得不重视起来。决心探探这“小雷音寺”。

本文主要通过源码解决两个疑惑:

  1. 可以在created中直接挂载属性到data中?这么做有啥好处?
  2. 为什么很多时候数据改变了但是并没有在页面上展示出来?或者说数据就没被更改。数据去哪了?

先解答:
1:created时其实已经可以访问data,数据响应式触发完成。在这里首次挂载属性可以避免这个属性变成响应式的从而增加性能损耗,而且可以全局访问this.xxx
2:数据流和视图流的问题,本质也是数据响应式的问题。解决方案:①新开缓存对象使得属性不是首次挂载的,可以检查数据确保存储的对象存在时某属性就存在;②set、update、splice等强制更新

见微知著:new Vue()时都做了什么?

首先找到vue的构造函数

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

options是用户传递过来的配置项,如data、methods等常用的方法

vue构建函数调用_init方法,这里不能直接找到这个方法,但是往下看会发现文件下方定定义了很多初始化方法:

initMixin(Vue);     // 定义 _init
stateMixin(Vue);    // 定义 $set $get $delete $watch 等
eventsMixin(Vue);   // 定义事件  $on  $once $off $emit
lifecycleMixin(Vue);// 定义 _update  $forceUpdate  $destroy
renderMixin(Vue);   // 定义 _render 返回虚拟dom

首先看 initMixin 方法,发现该方法在Vue原型上定义了_init方法

Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // a uid
    vm._uid = uid++
    let startTag, endTag
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        startTag = `vue-perf-start:${vm._uid}`
        endTag = `vue-perf-end:${vm._uid}`
        mark(startTag)
    }

    // a flag to avoid this being observed
    vm._isVue = true
    // merge options
    // 合并属性,判断初始化的是否是组件,这里合并主要是 mixins 或 extends 的方法
    if (options && options._isComponent) {
        // optimize internal component instantiation
        // since dynamic options merging is pretty slow, and none of the
        // internal component options needs special treatment.
        initInternalComponent(vm, options)
    } else { // 合并vue属性
        vm.$options = mergeOptions(
            resolveConstructorOptions(vm.constructor),
            options || {},
            vm
        )
    }
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
        // 初始化proxy拦截器
        initProxy(vm)
    } else {
        vm._renderProxy = vm
    }
    // expose real self
    vm._self = vm
    // 初始化组件生命周期标志位
    initLifecycle(vm)
    // 初始化组件事件侦听
    initEvents(vm)
    // 初始化渲染方法
    initRender(vm)
    callHook(vm, 'beforeCreate')
    // 初始化依赖注入内容,在初始化data、props之前
    initInjections(vm)
    // 初始化props/data/method/watch/methods
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')

    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        vm._name = formatComponentName(vm, false)
        mark(endTag)
        measure(`vue ${vm._name} init`, startTag, endTag)
    }
    // 挂载元素
    if (vm.$options.el) {
        vm.$mount(vm.$options.el)
    }
}

OK可以停一下了。从上面的代码中可以发现三件事:

  • 在调用 beforeCreate 之前,数据初始化并未完成,像data、props这些属性无法访问到
  • 到了 created 的时候,数据已经初始化完成,能够访问data、props这些属性,但这时候并未完成dom的挂载,因此无法访问到dom元素
  • 然后是挂载方法vm.$mount

上面这三点完全可以作为一些面试题诸如“为什么beforeCreate中无法访问this.xxx”、“created中可以访问dom吗”的完美答案。不过个人觉得更应该专注于“它对优化代码质量的作用”。

到这里我们还大概知道了另一件事:“ 因为created中数据初始化(其中最重要的是响应式)已经完成,所以如果在此生命周期中直接将一个新属性挂载到this上,那么它将不是响应式的! ”。

什么是响应式数据?
笔者在另一篇文章中有过说明:“响应式原理是一种单向行为:它是数据到 DOM (也就是view视图)的映射”。这里可以简单的归结为“ 和渲染流相关的数据 ”。

那么使用起来就很明了了:我们可以把一些非主动直接触发的数据或者是不需要在页面上展示的数据在created生命周期中首次定义。这样既可以在js中获取到又不必担心页面上的展示问题。
比如 —— 在笔者近期的项目中对缓存函数就是这样使用的:

//js文件
export function cachedMemory(fn, wait = 10){
    const cache = Object.create(null);
    let last = Date.now();
    return async (...args) => {
        const _args = JSON.stringify(args);
        let now = Date.now();
        const hit = cache[_args];
        if(now - last < wait*1000 && hit) {   //缓存10s,因为场景不同,这里一般来说是要每次点击都请求的,但是如果点的特别快就会造成短时间内大量重复请求
            return hit;
        }
        // 只缓存成功的promise,失败直接重新请求
        last = now;
        return (cache[_args] = await fn.apply(fn, args));
    }
}
//调用方
<script>
import { cachedMemory } from 'xxx.js';
export default {
	data() {
		return {}
	},
	created() {
        this.requestListResData = cachedMemory(data => {   //注册缓存函数
            return this.$$APIS.getMarkupId(data);
        })
        this.init();
    },
    methods: {
    	xxx() {
    		const data = await this.requestListResData(item_data);
    		//下一步操作
    	}
    }
}
</script>

步入正题:$set和数据响应

依然提前“透题”:既然数据响应式一定是和defineProperty相关(vue3换成proxy了,但基本逻辑不变)。那么$set中也一定是主动触发了这个函数!

由上面的生命周期的例子可以知道:vue对于this,或者说(响应式的)data上没有声明过的属性是不会有响应式的;那么依然大胆推广开来 —— “初始时没有在某个对象上存在的属性,渲染(该对象)时不会将其作为响应式数据!”

事实证明,这个假设是完全正确的。
官方文档中说:“如果在实例创建之后添加新的属性到实例上,它不会触发视图更新”。

why?
简单来说,vue初始流程走完以后会在你要渲染的对象中绑上一个__ob__原型,里面至少会有一个id,这相当于一个缓存;和一个value对象,保存响应式处理的数据。在后面会有用到。
如果是再次挂载的属性,新的属性并不会在 ob的value 中出现。如果将这个属性拿到页面上渲染则再次改变时并不会触发!(但数据会改变)
ob

笔者遇到这个问题是因为在使用elementui时一堆数据中点击每一条数据前面的checkbox发现竟然没有点击效果!但断点后发现表示选中状态的属性值已然改变,,,开始还以为是数据没有渲染或者中间 深拷贝 了源数据导致更改的不是源数据。

所以,响应式的数据一定要保证对象初始化时就已经存在,可以给一个毫不相干的默认值嘛。(这是笔者所遇问题的解决方法,应该也可以说是大部分类似问题的解决方法吧)

笔者的项目中并没有用到set方法是因为我在改变了数组属性后再去调用$set竟然没有效果。我发现,这里面有一个内部的缓存问题 —— 在笔者的项目中,每一行是一个单独的组件,所有行是一个大组件。(上面的解决方法就是直接在大组件的‘源数据’处做修改)大组件中循环数组给小组件传入每一个item
在这种情况下,即使用set触发了响应式,将新增数组挂载到ob原型的value对象上,也不能渲染到页面上!
这时候可以尝试将小组件的数据重新“拿一遍”就好。不过太过麻烦。一个组件中有太多的数据流向简直是灾难!

但是,个例的情况并不能掩盖set的优秀。让我们看下在set中做了什么:

set源码分析

if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
    ) {
    warn('Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}')
}
// 数组
if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
}
// 对象
if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
}
const ob = (target: any).__ob__
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
}
if (!ob) {
    target[key] = val
    return val
}
defineReactive(ob.value, key, val)
ob.dep.notify()

代码中我们只看最重要的两点:
一个是

const ob = (target: any).__ob__

拿到 ob 原型,证实了我刚刚说的一点。如果一个对象有这个__ob__属性,那么就说明这个对象是响应式对象,我们修改里面已有属性的时候就会触发页面渲染。

另一个是最后两步

defineReactive(ob.value, key, val)
ob.dep.notify()

这个是vue.set()真正处理对象的地方。defineReactive(ob.value, key, val)是尤大封装的方法,内部逻辑就是循环调用defineProperty给新加的属性添加依赖,以后再直接修改这个新的属性的时候就会触发页面渲染。 ob.dep.notify()这句代码的意思是触发当前的依赖(观察者模式),所以页面就会进行重新渲染。

有的文章不知道在哪抄的,竟然说可以直接用xxx.splice()解决问题,先不说splice方法可不可以丢失参数。这么做也只是触发了数组的“重新改变”。
与此原理相似的是在开头处做:let xxx=[...xxx]; 如果你的这个“开头处”是数组的第一次渲染,也就是需要响应式属性的“一次挂载”,那么确实可以。关于数据是“一次挂载”还是“二次挂载”的,这个需要注意!
当然,上面的说法是相对于vue2来说的,因为他本质上是因为Object.defineProperty API无法监听到数组/对象内部(新)元素的改变导致的。但是在vue3中尤大将其替换为更高维度的Proxy API。这个问题就不会出现了。

误区一

有些人可能会以为“既然$nextTick会强制触发下一次渲染,那么直接在nextTick方法中改变数据不就可以了?”

其实简单来说可以认为“vue知道该渲染数据了但是不知道你改变了哪些数据”;复杂点说其实这根本就是两码事,它们(数据流和渲染流)之间还有一个顺序和时间问题。

一般笔者在vue的项目中是这样使用这个API的:在接口数据和封装的组件内部有数据交互时,在组件中暴露一个方法。外部在$nextTick中调用此方法并传入数据 ——

//子组件
<script>
export default {
    methods: {
    	$_setData(list) {
            this.list = list || [];
        },
    }
}
</script>
//父组件中
this.$xxx.$_setData(purchaseList);

误区二

得益于js对象的强大特性,我们不仅可以随意地给对象增删属性,还可以对其进行 深拷贝浅拷贝。这可能是一些问题的导火索 —— (源)数据为什么没改变?

前面加了。这很简单,也很关键。深拷贝开辟了一个新的内存地址,最简单也是最常用的如:JSON.parse(JSON.stringify(xxx)) 可以创造一个完全的新对象。在这个对象上做的修改不会“反馈到”xxx数据中!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

恪愚

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值