目录
1,初始化数据
首先要创建一个Vue的实例vm,由于我们需要构建一个类来将所有的方法耦合在一起,以前我们会用 class Vue{ } 的方法,将方法功能 xxx() 写在类里面,但是一般不会这么做。Vue本身采用构造函数来扩展方法 function Vue{ },最后暴露这个Vue,就是new Vue。
<script>
// 响应式的数据变化,数据变化了可以监控到数据的变化
// 数据的取值和更改值 我们要监控到
const vm = new Vue({
data() {
return { //代理数据
name:'zf',
age:20,
address:{
num:30,
content:'回龙观'
}
}
},
})
console.log(vm);
</script>
我们在new Vue时,会传入一个对象options,就是用户的选项,根据以上,这里的options指的只有data,若有computed,则还包括computed等等。
拿到选项后,要做的就是Vue的初始化,这里就会原型的方式 Vue.prototype._init 写一个初始化的功能,用于初始化操作,new Vue的时候,就会自动调用初始化。
由于后期功能会越来越多,我们希望每个功能都是独立的,所以将初始化的功能单独放在 init.js 文件中,而且由于和Vue构造函数不是在同个文件中,会拿不到Vue,这里也需要在 init.js 文件中暴露另一个函数 initMixin ,将初始化的功能写在里面,再在入口文件index.js 中引入。
new Vue的时候自动调用初始化 initMixin ,这时传入options参数,将options绑定到this上,这个this下文使用vm代替,也就是将用户的选项挂载到实例上,方便后续其他函数需要拿到options。(扩展:为什么用$options?我们使用的vue的时候 $nextTick $data $attr.....,在前面加上$符是默认这些就是Vue自己的属性,假设在data中,有一个$name的属性,则vm是拿不到这个$name的。)
import { initMixin } from "./init"
// 将所有的方法都耦合在一起
function Vue(options){ //options就是用户的选项,目前这里只有data,当new Vue里有别的例如computed,那也包含computed
// 默认就调用了init,将用户选项传过来,去调用初始化
this._init(options)
}
initMixin(Vue); //扩展了init方法
export default Vue
import { initState } from "./state";
export function initMixin(Vue){ //就是给Vue增加init方法的
Vue.prototype._init = function(options){ //用于初始化操作
// vue vm.$options 就是获取用户的配置
const vm = this ;
vm.$options = options //将用户的选项挂载到实例上
// 接下来就是对数据进行处理,也就是初始化状态 props,data,computed....
initState(vm);
}
/* Vue.prototype.xxx = function(){
当这里要拿options的时候就拿不到了,所以要在上面vm.$options = options
} */
}
接下来要做的就是初始化数据 initState,要先看有没有data属性,如果有,就对data进行初始化,再对数据进行劫持。
2,属性劫持
export function initState(vm){
// 因为vm身上有$options,所以这里可以拿到
const opts = vm.$options; //获取所有的选项,要进行数据劫持
if(opts.data){
initData(vm); //对data进行初始化
}
}
给vm增加一个属性_data,也就是将对象data放在实例上,并且下面会对对象进行观测,所以读取data属性的时候需要用vm._data.xxx,所以接下来要做的就是把vm.xxx代理到vm._data.xxx
function initData(vm){
let data = vm.$options.data; //data可能是函数也可能是对象
data = typeof data === 'function'? data.call(vm) : data; //如果是函数,就让data这个函数执行,返回对象,但是这样this有问题,我们希望this指向vm实例。否则data就是数据
// console.log(data); //这里输出的是对象,因为data函数return的是对象
vm._data = data; //将返回的对象放到了_data上
// 对数据进行劫持 vue2里采用了一个api defineProperty
observe(data) //观测的方法,从这里开始就是响应式模块
// 将vm._data 用vm来代理就可以
for(let key in data){
proxy(vm,'_data',key);
}
}
export function observe(data){
// 对这个对象进行劫持
if(typeof data !== 'object' || data == null){
return; //只对对象进行劫持
}
return new Observer(data);
}
如果一个对象被劫持过了,那就不需要再被劫持了,要判断一个对象是否被劫持过,可以增添一个实例,用实例来判断是否被劫持过。所以在内部创造一个类 class Observer,专门观测数据,如果这个数据被观测过,则这个实例就是这个类。
3,对象的响应式原理
class Observer{
constructor(data){
// Object.defineProperty只能劫持已经存在的属性,后增的或者删除的不知道(vue里面会为此单独写一些api $set $delete)
// 遍历对象
this.walk(data);
}
walk(data){ //循环对象 对属性依次劫持
// 拿到key后重新定义属性,调用defineReactive方法,把某个数据定义成响应式的
Object.keys(data).forEach(key=>defineReactive(data,key,data[key])) //定义的数据是data,属性是key,值是data[key]
}
}
export function defineReactive(target,key,value){ //闭包, get和set的时候都能拿到value,当前函数的作用域和执行栈没有被销毁 属性劫持
// 当value是个对象时,例如data是个对象,里面再套个对象,所以这里需要再走observe
observe(value) //对所有的对象进行属性劫持
Object.defineProperty(target,key,{
// 取值和修改的时候都是用到value,也就是函数Object.defineProperty用到外部函数defineReactive的变量,所以这个变量不能被销毁,也就是闭包
get(){ //取值的时候,会执行get
return value
},
set(newValue){ //修改的时候,会执行set
if(newValue === value) return //如果newValue和value一样则不执行
value = newValue
}
})
}
如果 initData 函数没有操作 vm._data = data ,则此时打印vm只有用户的选项,没有劫持过的数据。因为只是把data传进去。加上 vm._data = data,则是把data这个对象挂载到实例上,并且对data进行观测 observe(data),观测的时候依旧会去循环对象,调用 defineReactive方法,将它定义成响应式。
此时当我们去取值必须用 vm._data.name,取值的时候会调用get方法。为了使取值时直接用 vm.name。我们定义了一个proxy方法,使得在 initData 方法中,调用proxy,相当于把这个对象的属性重新代理。
state.js
// 将vm._data 用vm来代理就可以
for(let key in data){
proxy(vm,'_data',key);
}
function proxy(vm,target,key){
Object.defineProperty(vm,key,{ //vm.name
get(){
return vm[target][key]; //vm._data.name
},
set(newValue){
vm[target][key] = newValue
}
})
}
总结:循环对象,用defineReactive 方法把属性重新定义成响应式,如果值还是对象的话,我们需要对这个对象进行递归操作,这样用户在取值和修改的时候我们可以监控到。但是我们这个对象被劫持完之后,为了能方便获取,此时我们把data放到了vm上,但是这样写还是比较麻烦。所以让用户到vm上取值的时候,就直接去vm._data上取值。
4,数组的函数劫持
用户平时修改数组,很少用索引来操作,当数组里面太多数据时,做循环和劫持性能较差,一般都是通过方法来修改,push,shift等等。所以这里我们就不走walk方法去循环,当data里是个数组时,hobby:['eat','drink',{a:1}],我们就在data上套一层判断是否是数组。如果是数组,就重写数组的方法。而且数组里面有可能还有对象,所以除了修改数组之外,还要对数组里的引用类型进行劫持。如果判断不是数组,就还是走walk方法,循环对象。
observe index.js
class Observer{
constructor(data){
//这个this指的是Observer的实例,把Observer的实例赋值到对象的自定义属性上
data.__ob__ = this; //给数据加了一个标识,如果数据上有_ob_,则说明这个属性被观测过
if(Array.isArray(data)){
// 这里可以重写数组中的方法,7个变异方法,是可以修改数组本身的
data.__proto__ = newArrayProto
this.observeArray(data); //如果数组中放的是对象,可以监控到对象的变化
}else{
// 遍历对象
this.walk(data);
}
}
walk(data){ //循环对象 对属性依次劫持
// 重新定义属性
Object.keys(data).forEach(key=>defineReactive(data,key,data[key]))
}
observeArray(data){ //观测数组
data.forEach(item => observe(item))
}
}
由于数组里面有可能还有对象,所以要对数组里的每一个值都进行观测,这里写一个方法observeArray,将里面的每一项都变成响应式的。这样通过 vm.hobby[2].a ,可以监控到对象的变化,但是用 vm.hobby.push('1') 只能触发get,无法触发修改。这里就需要重写当前数组的所有方法。例如重写一个push方法,给当前的数组重写一个对象__proto__,给当前data的原型链指向一个新的原型。(Array/String/Boolean/Object/Function.__proto__===Function.prototype)
data.__proto__ = {
push(){
console.log('重写的push')
}
}
但是这样做不合理,会把原始的push方法覆盖掉,所以要保留数组原有的特性,并且可以重写部分方法。新建 array.js 重写数组部分方法。
要先获取数组的原型 Array.prototype,但是不能用 Array.prototype.push = function(){ } 直接修改,这样会把原来的push方法覆盖。所以先将这个数组原型 oldArrayProto 拷贝一份,这样不会影响原来的,生成一个新的数组方法,也就是最后要暴露出去的newArrayProto。这时候 newArrayProto 可以通过原型链拿到 oldArrayProto,即 newArrayProto.__proto__ = oldArrayProto。在newArrayProto上重写方法比如 newArrayProto.push= function(){ },不会影响到 oldArrayProto,所以也不用担心会被覆盖掉。
声明一个关于 push pop等七个变异方法的数组,然后以属性的方式添加到新的原型上,重写的方法其实就是调用之前保留的旧原型方法,同时需要传参数,此时会导致this指向有误,要用 call 更正this。谁调用push,this指向谁。内部调用原来的方法,这种模式叫做函数的劫持。暴露之后data.__proto__ 就能拿到newArrayPoto,无论调七个方法的任意一个,都可以被监控到,这就实现数组的劫持。
但是这样操作,假设用 vm.hobby.unshift({a:1}) ,新增的对象没有被劫持到,原因是我们只是拦截了方法,并没有对新增的这些选项做处理,所以还需要对新增的数据再次进行劫持,声明一个数组 inserted,将能新增元素的三个方法 push,unshift,splice 所新增的数据放在 inserted,后续用obsereArray 对该数组进行观测,看新增的是不是对象。
此时,由于在不同的文件拿不到该方法,此时能拿到的只有this,即调用数组的arr,这个this和Observer里的data是同一个 ,所以在data上放一个自定义的属性__ob__把this放上去,即data.__ob__=this,这个this指的是Observer的实例,把Observer的实例赋值到对象的自定义属性__ob__上,这样 array.js 就可以拿到 this.__ob__,声明个变量ob接收,这样就可以直接调用观测数组的方法ob.observeArray(inserted)。
array.js
let oldArrayProto = Array.prototype; //获取数组的原型
// newArrayProto.__proto__ = oldArrayProto
export let newArrayProto = Object.create(oldArrayProto); //拷贝一份
let methods = [ //找到所有的变异方法,即能改变原数组的方法
'push',
'pop',
'shift',
'unshift',
'reverse',
'sort',
'splice'
// concat slice 都不会改变原数组
]
methods.forEach(method=>{
// arr.push(1,2,3)
newArrayProto[method] = function(...args){ //这里重写了数组的方法
// 直接调用的话push(),this不一样,要改成arr,谁调的push this指向谁
const result = oldArrayProto[method].call(this,...args) //这里继续用oldArraryProto是因为是在原有的方法上重写
// 需要对新增的数据再次进行劫持
let inserted;
let ob = this.__ob__; //这里的this和data是同一个,所以在data上放__ob__,这里也可以取到Observer的实例,再拿到observeArray
switch (method) {
case 'push': //arr.push(1,2,3) args放的就是追加的内容
case 'unshift':
inserted = args;
break;
case 'splice': //arr.splice(0,1,{a:1},{a:1})
inserted = args.slice(2)
default:
break;
}
console.log(inserted); //新增的内容
if(inserted){
//对新增的内容进行观测 inserted是个数组
ob.observeArray(inserted);
}
return result
}
})
这样同时也给数据加了一个标识,如果数据上有_ob_,则说明这个属性被观测过了,这样observe上也可以再加一个判断 。
export function observe(data){
// 对这个对象进行劫持
if(typeof data !== 'object' || data == null){
return; //只对对象进行劫持
}
if(data.__ob__ instanceof Observer){//说明这个对象被代理过了
return data.__ob__;
}
return new Observer(data);
}
当data不是数组而是对象时,也会添加__ob__标识,再走walk对这个对象做循环,循环时也会去遍历__ob__属性,就会产生递归。
所以要让循环时不能遍历到__ob__,就让这个属性变成不可枚举的,不可循环,不可取值。
//data.__ob__ = this; //给数据加了一个标识,如果数据上有_ob_,则说明这个属性被观测过
Object.defineProperty(data,'__ob__',{
value:this,
enumerable:false //将__ob__变成不可枚举(循环的时候无法获取到)
})
这就实现数组的劫持,核心就是重新数组的方法,并且观测数组中的每一项。如果是数组,我们需要针对数组新增的属性做判断,并把数组的每一项再进行观测。如果调的是concat等没有重写的方法,那调的就是数组原来的方法。