本文从vue系列的基本设计思路开始,到手写基本api的实现。让大家从实践中体会vue的数据驱动的神秘之处。
设计理念
大家都知道,vue是一个典型的MVVM框架。那什么是MVVM、在vue中又是怎么体现的呢?
MVVM
M
M代表的是模型Model。是展示在页面中的数据,在vue中指向的是data中的数据模型。
V
V代表的是视图View。是展示的页面,指向的是vue中的模板引擎(template模板)。
VM
VM代表的是ViewModel。不需要通过我们的操作将数据解析展示到视图上,以及数据发生改变,页面上的视图自动会发生相应改变。
那么vue是如何将视图和逻辑操作分开,这就很有必要提到vue中数据驱动的特点。那么这些数据又是如何在视图中展示的呢? vue通过数据响应式监听数据的变化并在视图中更新;
模板引擎提供描述视图的模板语法(类html。提供一些vue特有指令、插值);
渲染:将模板语法转为html(AST=>vdom=>dom)。
数据响应式
简单响应式
vue中的监听数据变化:
vue2: Object.defineProperty()
vue3: Proxy
两者简单的响应数据案例可以查看这篇文章。
<div id="app"></div> <script> function defineReactive(obj, key, val) { Object.defineProperty(obj, key, { get() { return val }, set(newVal) { if (val != newVal) val = newVal // 数据发生变化通知视图更新 update() } }) } function update() { content.innerHTML = `<h1>${obj.name}</h1>` } let obj = { name: 'clying' } let content = document.getElementById('app') update() // 响应式处理 defineReactive(obj, 'name', 'clying') setTimeout(() => { obj.name = 'deng' console.log(obj.name); }, 1000) </script>
运行代码,我们可以看到页面一开始展示clying
,经过1秒之后变为deng
。 通过这个简单的案例,我们可以实现简单的obj对象响应式地对页面进行渲染。
递归响应式
上述案例中只是对deep=1
的对象进行了数据监听。那么具有深度的obj对象又是如何监听变化的呢?
这时候就需要遍历obj对象,对对象中的每个属性进行数据监听。通过Object.keys
返回一个obj中所有元素为字符串的数组,对其进行setter和getter拦截。
// 先来一个具有深度的对象 let obj = { name: 'clying', arr: [ 1, { namearr: '2', }, ], children: { name1: 'deng', children: { name2: 'clying deng', }, }, } function defineReactive(obj, key, val) { observe(val) // 子属性可能仍为对象,在对其进行拦截 Object.defineProperty(obj, key, { get() { console.log('获取', key) return val }, set(newVal) { if (newVal !== val) val = newVal console.log('设置新值', key, obj[key]) }, }) } function observe(obj) { if (typeof obj !== 'object' || obj === null) return Object.keys(obj).forEach((key) => defineReactive(obj, key, obj[key])) } observe(obj) obj.children.name1 = 'l'
当设置obj.children.name1
时,先获取到children属性,发现children仍是一个对象,继续遍历。获取到children中的name1属性时,发现不是对像,对name1进行拦截,设置新值。
动态添加属性
当我想要根据上例,对obj动态添加一个age属性时,其实是没有作用的。defineProperty
无法检测新增属性,这也涉及到vue2的一个弊端。这就要额外使用到vue2中的set API方法。
function set(obj, key, val) { defineReactive(obj, key, val) } set(obj, 'age', 22) obj.age
可以看出set方法其实也是利用defineProperty
去添加新属性,只是需要用户手动调用。通过调用set,使obj中新属性age可以被拦截到。
注意set用法:
set方法对于接收的目标参数必须是响应式的,可以在源码set方法中看到,一开始就会去判断传入的目标值是否是原始值、undefined或null,如果是这些情况直接警告。如果想要删除属性,相同的需要手动调用delete方法。
数组响应式
defineProperty
方法其实是可以拦截到像arr[0] = 1
这种,通过index下标赋值的数组。但是它无法支持数组中push、pop等数组的原型方法。我们需要拦截数组的7个方法,重写他们,就是干!
因为我们只是简单的模仿,没有写Observe类。在此我将Observe类中拆成observe方法(判断是数组还是对象,分别监听)、observeArray循环遍历监听数组属性。
function observeArray(arr) { arr.forEach((_) => observe(_)) } function observe(obj) { if (typeof obj !== 'object' || obj == null) return if (Array.isArray(obj)) { obj.__proto__ = arrayMethods // 继承原型方法属性 observeArray(obj) } else { Object.keys(obj).forEach((key) => defineReactive(obj, key, obj[key])) } }
比较核心的还是在 obj.__proto__ = arrayMethods
中,使当前遍历到的数组的原型链可以指向我们重写的数组方法。
const methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse', ] let oldArrayMethods = Array.prototype let arrayMethods = Object.create(oldArrayMethods) //arrayMethods的原型指向Array数组的原型,可以获取数组原型方法 methodsToPatch.forEach((method) => { arrayMethods[method] = function (...args) { const result = oldArrayMethods[method].apply(this, args) let inserted // 当前用户插入的元素 switch (method) { case 'push': case 'unshift': inserted = args break // 3个 新增属性 splice 有删除 新增的功能 arr.splice(0,1,{name:1}) case 'splice': inserted = args.slice(2) default: break } // let ob = this.__ob__; // ob.observeArray(inserted); // 插入的是对象或者数组的话还需要再次递归监听 // update通知更新 return result } })
当我们通过obj.arr.push(1);obj.arr[1].namearr = 2
时,可以看到控制台输出:
说明在push数组方法,和修改数组值时,数组都可以走到defineProperty
中,被其拦截。
在此,数组插入的值可能是对象或数组时,仍需要对其插入的值进行监听。应该在Observe类中,先将这个实例保存(内部会含有observeArray方法)。然后,在arrayMethods中使用其observeArray方法,继续进行深度劫持。
至此,关于数据响应式就可以告一段落拉。如有不足,欢迎大家指正。