学习VUE时遇到的一些问题

933 阅读9分钟

记录一些纠结了很久很久的问题

为什么data中定义的属性会直接出现在vm实例对象中 而不是被放到vm的data属性上?

按照VUE传递数据的方式 如果让我来暴露变量 我会通过这样的方式:

function myVue(config){
    this.data = config.data
}
const vm = new myVue({
    data: {
        msg: "xxx"
    }
})
//获取到实例里面的变量
console.log(vm.data.msg)

那么在vue中是如何处理的呢?

const vm = new myVue({
    data: {
        msg: "xxx"
    }
})
console.log(vm.data.msg)//undefined

可见在vm实例对象上并没有vm.data.msg这样的属性。直接打印vm之后发现 其实msg直接被就放在vm的实例对象上,使用vm.msg就可以直接获取到变量值。也就是说 创建vue实例的时候,vue会将data当中的成员带到vm实例上。

但是为什么要这么做呢?我能想到的几个原因如下

  1. 方便监控
    既然要实现响应式,那么我们得知道数据变化了才能去渲染页面。这时候就要去监控对象了。而且vue的配置对象中除了data,还有很多其他的对象需要监听(例如computed)。我们去监测一个对象(如监听vm)是否发生改变,比监听一个对象里面的很多个对象(如监听vm.data和vm.watch和vm.computed)是否发生改变更容易一些。
  2. 方便引用。
    我们要经常使用data中的数据,如果到时候满屏幕的data.xxx,那就很难看了。

总结: 目的是为了更方便监控数据变化,然后执行某个监听函数,实现响应式。
目前只想到这么多 以后有想到什么再补充一下

VUE是怎么监控数据变化的?

(其实本来是想写响应式原理 后来发现篇幅好像还挺大的..而且我也没有完全搞懂 还是以后再单独开一篇文章写吧 坑+1)

在vue2.0中,通过Object.defineProperty来监控数据的变化。在vue3.0中,通过proxy来监控数据的变化。(proxy下次抽时间搞明白了再模拟)

用Object.defineProperty实现监控数据

先给出一个基本结构:一个监听数据的变化

const data = {
    name: "小饼"
}
var value = data.name
//给data.name赋值的时候其实是在给data._name赋值
//如果在里面使用了data.name会造成死循环
Object.defineProperty(data, "name", {
    //读取name属性的时候执行的方法
    get() {
        console.log("读")
        //return的值就是读取到的值
        return value
    },
    //设置name属性的时候执行的方法
    //参数是被重新赋予的值
    set(newVal) {
        console.log("写")
        value = newVal
    }
})

这样子我们就实现了监听数据"小饼"的变化 接下来需要进行三点改进:

  1. 现在只能检测到data中某一个属性的改变 我们希望对所有属性进行监听 这时候就要对data进行遍历 得到所有的data属性并且进行监听
  2. 遍历的时候总不可能每次都把修改的变量放在value里面吧 所以我们要封装成一个函数 每次执行函数都是一个全新的作用域 把value放在函数里面就可以避免赋值的冲突
  3. 排除一些不必要的操作 如果一个属性改之前和改之后是完全一样的值 那还改个毛线

综上 我们可以得到改进之后的代码:

// 传入键和对应的变量 用于监听
function defineReactive(obj, key) {
    var value = obj[key]
    Object.defineProperty(obj, key, {
        get() {
            return value
        },
        set(newVal) {
            //如果修改之前和修改之后是一样的值 就不渲染页面
            if(value === newVal){
                return;
            }
            //模拟页面的渲染
            render()
            value = newVal
        }
    })
}
//简陋的模拟页面的渲染
function render(){
    console.log("页面渲染啦")
}
for (key in data) {
    defineReactive(data, key)
}

接下来又有一个问题 当data中的某个属性的值是一个对象的时候 我们无法监听到他的改变 无法触发写操作 问题如下:

const data = {
    name: "小饼",
    blog: {
        name: "快点吃饼"
    }
}
data.blog.name = "仙女"
console.log(data.blog.name) 
// 会触发data.blog的读操作 输出"仙女"
// 这时候能重新赋值 但是我们是直接拿出对象里面的属性来赋值的 
// 而不是通过defineProperty来赋值的 所以无法触发写操作

这时候我们就要进行递归了 让他能够观察对象中的对象

function defineReactive(obj, key) {
    var value = obj[key]
    //如果是对象就先去监控对象里面的每一个属性
    recursive(value)
    Object.defineProperty(obj, key, {
        get() {
            return value
        },
        set(newVal) {
            value = newVal
            render()
        }
    })
}

//遍历监控对象中的每一个属性
function recursive(obj) {
    //简单判断一下 没有区分null和Array
    if (typeof obj === "object") {
        for (key in obj) {
            defineReactive(obj, key)
        }
    }
}

这样就基本实现了利用defineProperty进行对数据的监听

利用defineProperty的特性 我们可以解答一个问题:

为什么vue不能监听到某些数组操作和对象操作?

在VUE中 不能响应式更新数据的操作有下面几种:

  1. 数组不存在的索引的改变
  2. 数组长度的改变
  3. 对象的增删

我们可以先用上面写好的代码测试一下能否进行这些操作:
首先测试他是否能够监听到对象的增删

//添加一个对象中没有的数据
data.blog.articleNum = 3  //这时没有执行写操作 也就说明没有执行页面渲染
//根本原因是使用for(let key in data)的时候并不会遍历到这个属性 自然也就无法监听
//删除一个对象中存在的数据
delete data.shan.age
//删除的时候不可能执行写操作 所以依然监听不到 也无法渲染页面

再测试他是否能监听到数组的操作

data.arr[0] = 1  //触发了写的操作 页面重新渲染了
data.arr[100] = 100 //没有触发写的操作 页面不会重新渲染 
//其实原理和对象是一样的 他可以监听已有的属性的变化 
//但是并没有办法遍历到新增的属性并且进行监听 

所以我们可以得出利用Object.defineProperty实现响应式的劣势

  1. 天生就需要进行递归
  2. 监听不到数组不存在的索引的改变
  3. 监听不到数组长度的改变
  4. 监听不到对象的增删

虽然这时候能够用下标去修改一个数组 也能被监听到 但是当我们在Vue中使用下标去修改数组中的某个数据的时候是没办法修改成功的 原因是数组的数据太多了 如果要遍历去监听数组中每一个数据的变化 是很浪费性能的 所以我们直接不监听数组的变化
所以我们再改进一下代码

function observer(data){
    //看到数组就返回
    if(Array.isArray(data)){
        return;
    }
    if(typeof data === "object"){
        for(let key in data){
            defineReactive(data, key, data[key])
        }
    }
}

但是你数组变了 不渲染页面也不行吧~所以VUE直接给我们提供了一套改造过的API 这些API都会在改变数据之后立即重新渲染页面

模拟数组变异方法

以push操作为例:

//保存原来的数组方法
const oldPush = Array.prototype.push
Array.prototype.push = function(){
    //首先要执行原本的数组方法 然后再执行我们加入的渲染操作
    //传入this和参数值
    oldPush.call(this, ...arguments);
    //重新渲染页面
    render()
}
//执行
data.arr.push(100)

但是要修改的原型方法不只这一个 我们要修改所有需要重写的原型方法 这时候要对数组的原型进行修改

//克隆一套原型链上的方法 在新的对象上去重写 对象的__proto__是Array.prototype
const arrayProto = Array.prototype
//因为我们重写的方法只是针对data中的数组 
//对于普通的数组使用原来提供的数组方法就可以了
//不要污染原来的原型方法
const arrayMethods = Object.create(arrayProto)
//要修改之前observer中的代码
function observer(data){
    if(Array.isArray(data)){
        //改变数组的原型 这样就能使用我们重写的原型方法了
        data.__proto__ = arrayMethods;
        return;
    }
    if(typeof data === "object"){...}
}
//指定要修改的方法名 通过遍历来修改里面的方法
["push", "pop", "shift", "unshift", "sort", "splice", "reverse"].forEach(method = >{
    arrayMethods[method] = function(){
        //先执行原来的方法
        arrayProto[method].call(this, ...arguments);
        //渲染页面
        render()
    }
})

模拟$set方法和$delete方法

既然都模拟变异方法了 $set$delete方法就一起模拟一下吧(抱着顺便写写的写法 不知不觉写这么长了...而且好像有点偏离主题了..)
$set修改数据的时候让他直接渲染页面 然后给他搞一个defineReactive监听一下 让他能够一修改就自动渲染页面 否则就要总是手动渲染

function $set(data, key, value){
    data[key] = value
    //监听设置的值 如果设置的是一个对象 还要递归监听对象中的值
    defineReactive(data, key)
    //渲染页面
    render()
    return value
}

//使用
const value = $set(data.blog, "otherBlog", "仙女")

但是如果是数组在$set 那就不需要使用defineReactive 直接使用splice就可以了 所以针对数组的情况还要单独判断一下

function $set(data, key, value){
    if(Array.isArray(Data)){
        //简单写写 这里只是修改值 如果要指定下标添加值就不能这么做了
        //这里就不用执行render了 因为splice方法里面会帮我们执行render
        data.splice(key, 1, value)
        return value
    }
    data[key] = value
    defineReactive(data, key, value)
    render()
    return value
}
//使用
$set(data.arr, 0, 100)

模拟$delete 一样的原理

function $delete(data, key){
    if(Array.isArray(Data)){
        data.splice(key, 1)
        return
    }
    delete data[key]
    render()
    return
}
//数组使用
$delete(data.arr, 0)
console.log(data.arr)
//对象使用
$delete(data.shan, "name")
console.log(data.shan)

更改数据后 页面会立刻重新渲染吗

测试:

for(let i = 0; i < 10000; i++){
 vm.msg = i
}
//如果每次改变数据都会重新渲染 那么页面相当于渲染了10000次 会卡死
//所以他实际上是等循环执行结束之后 等到i变成9999了 再去渲染页面

由此可得 vue会记录改变的数据 把原来的数据和最后一次改变进行对比 不一样就重新渲染 一样就不渲染了

事实上 如果vue是同步更新dom的 那么每一次更改数据就要渲染页面 而操作dom实际上非常浪费性能 但是如果把渲染页面作为异步操作 他就能先执行完同步任务 等到同步任务改完数据了 然后再去任务队列里面把渲染页面的任务拿出来执行 减少操作DOM的次数

因此vue更新DOM的操作是异步执行的

只要侦听到数据变化 VUE将开启一个异步队列 即使一个数据被多次改变 最终也只会把这个渲染任务推入到队列中一次 这样可以避免不必要的计算和DOM操作

任务执行的流程: 同步执行栈执行完毕后 会执行异步队列的任务 在异步队列中先执行微任务再执行宏任务 如果用户的浏览器支持微任务的话 VUE就把渲染页面的函数放到微任务中去 如果不支持就只能放到宏任务里面了

另一种方式证明页面是异步渲染的:

<div id="app">{{ msg }}</div>
const vm = new Vue({
  el: '#app',
  data: {
    msg: '小饼'
  }
})
vm.msg = '不知道说什么好';
console.log(vm.msg); // 输出不知道说什么好 说明此时数据已更改
console.log(vm.$el.innerHTML); // 输出小饼 说明此时页面还未重新渲染
//在宏任务中去执行 这时候微任务肯定执行完了
setTimeout(()=>{
    console.log(vm.$el.innerHTML); // 输出不知道说什么好 说明此时页面已重新渲染
})