记录一些纠结了很久很久的问题
为什么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实例上。
但是为什么要这么做呢?我能想到的几个原因如下
- 方便监控
既然要实现响应式,那么我们得知道数据变化了才能去渲染页面。这时候就要去监控对象了。而且vue的配置对象中除了data,还有很多其他的对象需要监听(例如computed)。我们去监测一个对象(如监听vm)是否发生改变,比监听一个对象里面的很多个对象(如监听vm.data和vm.watch和vm.computed)是否发生改变更容易一些。 - 方便引用。
我们要经常使用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
}
})
这样子我们就实现了监听数据"小饼"的变化 接下来需要进行三点改进:
- 现在只能检测到data中某一个属性的改变 我们希望对所有属性进行监听 这时候就要对data进行遍历 得到所有的data属性并且进行监听
- 遍历的时候总不可能每次都把修改的变量放在value里面吧 所以我们要封装成一个函数 每次执行函数都是一个全新的作用域 把value放在函数里面就可以避免赋值的冲突
- 排除一些不必要的操作 如果一个属性改之前和改之后是完全一样的值 那还改个毛线
综上 我们可以得到改进之后的代码:
// 传入键和对应的变量 用于监听
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中 不能响应式更新数据的操作有下面几种:
- 数组不存在的索引的改变
- 数组长度的改变
- 对象的增删
我们可以先用上面写好的代码测试一下能否进行这些操作:
首先测试他是否能够监听到对象的增删
//添加一个对象中没有的数据
data.blog.articleNum = 3 //这时没有执行写操作 也就说明没有执行页面渲染
//根本原因是使用for(let key in data)的时候并不会遍历到这个属性 自然也就无法监听
//删除一个对象中存在的数据
delete data.shan.age
//删除的时候不可能执行写操作 所以依然监听不到 也无法渲染页面
再测试他是否能监听到数组的操作
data.arr[0] = 1 //触发了写的操作 页面重新渲染了
data.arr[100] = 100 //没有触发写的操作 页面不会重新渲染
//其实原理和对象是一样的 他可以监听已有的属性的变化
//但是并没有办法遍历到新增的属性并且进行监听
所以我们可以得出利用Object.defineProperty实现响应式的劣势
- 天生就需要进行递归
- 监听不到数组不存在的索引的改变
- 监听不到数组长度的改变
- 监听不到对象的增删
虽然这时候能够用下标去修改一个数组 也能被监听到 但是当我们在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); // 输出不知道说什么好 说明此时页面已重新渲染
})