手写 — 简单的vue数据响应(一)
最后更新于:2022-04-02 08:12:00
[TOC]
>[success] # 手写 -- 简单的vue数据响应模型(一)
~~~
1.首先前需要先分析生成的'Vue' 实例上挂载了那些关于响应式数据相关的属性
// 声明一个简单vue 实例
const vm = new Vue({
el: '#app',
data: {
msg: '测试',
info: {
name: 'w'
}
}
})
console.log(vm)
~~~
* 上面实例控制台打印参数
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/c7/10/c7101a986a6e74a96e4c8a9f59b6ada4_755x650.png)
>[danger] ##### 对这部分参数说明
~~~
1.为什么Vue 会把上面案例中的data属性中的变量(msg和info)挂载到Vue 实例上?
Vue 构造函数内部需要把 data 中的成员转换成 getter 和 setter 注入到 Vue实例,
这样可以直接通过 this.msg, this.info使用
2.'$options':简单认为把构造函数的参数记录到 $options 当中
3.'$el':$el 对应选项中的 el, 我们设置 el 选项时,可以是选择器,也可以是一个DOM对象。
如果是选择器,vue 构造函数内部需要把这个选择器转换成对应的DOM对象
4.'$data':data 选项中的成员被记录到 $data 中并且转换成 getter 和 setter,
$data 中的 setter 是真正监视数据变化的地方
5.'_data' :和 $data 指向的是同一个对象,_ 开头的是私有成员,$ 开头的是公共成员
~~~
>[info] ## 分解实现一个简单数据响应
~~~
1.
~~~
>[danger] ##### 实现vue 部分
~~~
1.根据上面分析,需要将实例化时候传入的构造函数进行处理
1.1.将初始化实例传入的配置绑定在Vue 实例的'$opition'
1.2.将初始化实例传入对象参数中'el' 字段绑定在Vue 实例的'$el' 上
1.3.将初始化实例传入对象参数中的data中值绑定在Vue 实例上(具备getter 和setter)
1.4.将初始化实例传入对象参数中的data绑定在Vue 实例上'$data'上(具备getter 和setter)
2.设计自己 Vue 类根据上面分析我们类需要'$options','$data','$el' 这些属性,以及一个'_proxyData'
方法将data上的属性绑定在Vue实例上
~~~
~~~
class Vue {
constructor(options) {
this.$options = options || {}
this.$data = options.data || {}
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
// 将data 数据挂载到Vue 实例上
this._proxyData(this.$data)
// 数据劫持
new Observe(this.$data)
}
_proxyData(data) {
// 遍历data中的属性
Object.keys(data).forEach((key) => {
// 将data 中的属性注入到vue实例中
Object.defineProperty(this, key, {
enumerable: true,
constructor: true,
get() {
return data[key]
},
set(newValue) {
if (newValue === data[key]) return
console.log(1)
data[key] = newValue
}
})
})
}
}
~~~
>[danger] ##### Observe --监听data中的所有属性的变化
~~~
1.Observe 对数据对象的所有属性进行监听,负责把整个$data 中的属性都转换成响应式数据
2.因为可以能会出现 {info:{name:'w'}} 这里不仅仅要把info变成响应式的也需要把,name变成响应式
的因此数据需要递归简单的说'data中的某个属性也是对象,把该属性转化成响应式数据'
~~~
~~~
//数据劫持
class Observe {
constructor(data) {
this.walk(data)
}
// 循环遍历data对象的所有属性,作为递归的一个判断条件
walk(data) {
if (typeof data !== 'object' || !data) return
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key])
})
}
defineReactive(data, key, val) {
let that = this
// 负责收集依赖,并发送通知
// let dep = new Dep()
this.walk(val)
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
// 将需要的观察者 收集到发布者中
// Dep.target && dep.addSub(Dep.target)
return val
},
set(newValue) {
if (newValue === val) return
val = newValue
console.log(2)
// 如果赋值的 是对象需要从新进行getter 和setter 绑定
that.walk(newValue)
// 发送通知
// dep.notify()
}
})
}
}
~~~
* 上面代码中get 里面为什么没有直接使用 data[key]
~~~
1.上面代码get 方法中使用的'return val' 为什么 不能是'return data[key]'?
原因:obj[key]访问自身的相应属性,会触发自身的get 函数 造成死循环等同下面效果
class Test {
get name() {
return this.name
}
}
const test = new Test()
test.name
~~~
>[danger] ##### 关于 Observe 和_proxyData 疑问
~~~
1.讲解一个我自己误区很久的问题'_proxyData' 和'Observe ',二者都是做响应数据区别在哪里?
首先'_proxyData' 是给实类挂载data上的属性方便后续直接this的形式调用,Observe 是将整个data
中所有数据进行数据响应的绑定
2.新的问题'Observe ' 使用了递归形式保证'data中的某个属性也是对象,把该属性转化成响应式数据',
那'_proxyData' 不递归?
先看'_proxyData'关于响应数据代码
Object.keys(data).forEach((key) => {
// 将data 中的属性注入到vue实例中
Object.defineProperty(this, key, {
enumerable: true,
constructor: true,
get() {
return data[key]
},
set(newValue) {
if (newValue === data[key]) return
console.log(1)
data[key] = newValue
}
})
})
这里明确第一点,这里的this 是vue实例,他仅仅是将data这些key绑定在了vue实例,此时的$data属性
并没有转换成响应的数据,'Observe' 才是本质上做到了将整个'$data'数据转换成了响应数据
('data中的某个属性也是对象,把该属性转化成响应式数据')
这里还有一个点注意'_proxyData'中的get 和set 本质上操作的实际是'$data',这样当在实例上data值进行操作
或者改变的时候由于进行了数据绑定会渐渐的触发$data上的数据绑定
因为'_proxyData' 会间接触发'$data',因此本质上只要'$data'只要所有数据都转换成响应数据即可
3.现在有个数据
const vm = {name:'www',info:{age:12}}
当进行vm.name = 'sss' 赋值操作的时候,先触发'_proxyData' 上的set方法,set方法内部可以看成是
'$data[key] = newValue',$data 又被'Observe' 做了数据响应出发了'Observe' 中的set
现在如果进行vm.info.age = 16 这时候注意了虽然'_proxyData' 只将第一层key和vue实例做了数据响应
此时vm.info 会触发'_proxyData'的get 因此实际得到是$data['info']此时会触发'Observe' 关于key为info
的get触发$data['info']中的age由于在'Observe'做了递归处理因此相当于$data['info'].age = 16直接触发
了age的set
总结:'_proxyData' 是给vue实例挂载data上的属性,虽然只对了最外一层做了数据响应,但是他们的get和set
实际触发的是'$data',我们使用了'Observe'将'$data'做了深度的数据响应,因此'_proxyData'会通过最外层
间接触发'$data'那些进行深层绑定的数据响应。'_proxyData' 只是为了让data绑定在vue实例上让开发者
方便使用,但是整个是数据调用和数据变化发送通知都是要在'Observe' 中完成,'_proxyData' 这样可以间接
调用'Observe','Observe' 也可根据数据变化发送通知(观察者)
~~~
>[danger] ##### Compiler -- 模板指令解析
~~~
1.上面的方法已经对数据方面处理好了,接下需要对html 页面上 'v-'指令 以及'{{ ... }}' 模板语法和处理的数据
可以绑定在一起进行渲染整个'Compiler '主要工作'编译模板,解析指令、差值表达式'这三个方面进行处理
2.'Compiler ' 期待解决的问题'负责页面的首次渲染','当数据变化后重新渲染视图'
3.设计结构
*data
el - vue中传入的$el
vm - Vue实例
*methods - 都是DOM操作
compile(el) - 遍历DOM对象的所有节点,判断节点并解析相应东西
compileElement(node) - 解析元素节点中的指令
compileText(node) - 解析文本的插值表达式
isDirective(attrName) - 判断属性是否为指令
isTextNode(node) - 判断是否为文本节点
isElementNode(node) - 判断是否为元素节点
4.
';
{{ 我是文本节点 }}
, 首先明确p 标签是元素节点,里面的表达式是文本节点 想要区分它们 node 对象有一个属性是'nodeType ',3是文本节点 ,1是元素节点 ~~~ ~~~ // compiler.js // 解析Vue并进行DOM操作 class Compiler { // 构造函数传入vue实例 constructor(vm) { // 创建相应属性 this.el = vm.$el this.vm = vm // Vue实例调用Compiler则开始处理DOM this.compile(this.el) } // 判断元素属性是否是指令 isDirective(attrName) { // 判断属性是否以 ‘v-’开头 return attrName.startsWith('v-') } // 判断节点是否是文本节点 isTextNode(node) { // 节点有nodeType 节点类型以及nodeValue 节点值 // nodeType值为 3是文本节点 1是元素节点 return node.nodeType === 3 } // 判断节点是否是元素节点 isElementNode(node) { return node.nodeType === 1 } // 编译模板,处理文本节点和元素节点 compile(el) { // 获取所有的子节点 childNodes为子节点 children为子元素 let childNodes = el.childNodes // 将伪数组转换为数组 并遍历数组中的每一个节点 // 此处仅为一层子节点 Array.from(childNodes).forEach(node => { // 此处箭头函数中的this为compiler实例 // 处理文本节点 if (this.isTextNode(node)) { this.compileText(node) } else if (this.isElementNode(node)) { // 处理元素节点 this.compileElement(node) } // 判断node节点,是否有子节点,如果有子节点,要递归调用compile if (node.childNodes && node.childNodes.length) { this.compile(node) } }) } // 调用对应指令的方法 update(node, key, attrName) { let updateFn = this[attrName + 'Updater'] // 指令方法存在则调用该方法 updateFn && updateFn.call(this, node, this.vm[key], key) } // 编译元素节点,处理指令 compileElement(node) { // console.log(node.attributes) // 遍历所有的属性节点 node.attributes获取属性节点 Array.from(node.attributes).forEach(attr => { // 获取属性名字 let attrName = attr.name // 判断是否是指令 if (this.isDirective(attrName)) { // v-text --> text 截取字符串 attrName = attrName.substr(2) // 获取属性值 let key = attr.value this.update(node, key, attrName) } }) } // 编译文本节点,处理差值表达式 compileText(node) { // 以对象类型打印 // console.dir(node) // {{ msg }} // . 匹配任意字符不包括换行 | + 前面的内容出现一或多次 | ?非贪婪模式 尽快结束匹配 | () 分组含义 提取括号中的匹配内容 let reg = /\{\{(.+?)\}\}/ // 文本节点中的内容 使用 node.textContent / node.nodeValue 获取 let value = node.textContent if (reg.test(value)) { // $num 为第num个小括号()分组的内容 let key = RegExp.$1.trim() // 将正则匹配内容替换为Vue实例属性中的值 node.textContent = value.replace(reg, this.vm[key]) // 创建watcher对象,当数据改变更新视图 new Watcher(this.vm, key, (newValue) => { node.textContent = newValue }) } } // 处理 v-text 指令 textUpdater(node, value, key) { // 将节点的文本内容更改为Vue属性值 node.textContent = value new Watcher(this.vm, key, (newValue) => { node.textContent = newValue }) } // v-model modelUpdater(node, value, key) { // 表单元素的值 value 等于Vue属性值 node.value = value new Watcher(this.vm, key, (newValue) => { node.value = newValue }) //双向绑定 监听节点中的输入框 node.addEventListener('input', () => { // 触发属性set机制 发布者通知所有的观察者执行函数 更新DOM视图 this.vm[key] = node.value }) } } ~~~ >[danger] ##### 观察者 ~~~ 1.现在要做的就是数据改变了如何和视图形成关联,利用观察者模式,数据改变了 就通知它在视图时候对应的订阅者,触发渲染更新视图 2.Dep(Dependency) -- 发布者'收集依赖,添加观察者(watcher),通知所有观察者' data subs - 存储所有的watcher methods addSub(sub) - 添加观察者 notify() - 发布通知 3.Watcher -- 观察者'当数据变化触发依赖,dep通知所有的watcher实例更新视图' '自身实例化的时候往dep对象中添加自己' data vm - Vue实例 key - data中的属性名称 cb - 回调函数,如何更新视图 oldValue - 数据变化之前的值 methods update() - 更新视图 ~~~ * Dep ~~~ // dep.js // 发布者 class Dep { constructor () { // 存储所有的观察者 this.subs = [] } // 添加观察者 addSub (sub) { // 不为空且拥有update方法 if (sub && sub.update) { this.subs.push(sub) } } // 发送通知 notify () { // 遍历所有的观察者并更新视图 this.subs.forEach(sub => { sub.update() }) } } ~~~ * Watcher ~~~ // watcher.js // 观察者 class Watcher { constructor (vm, key, cb) { // vue实例 this.vm = vm // data中的属性名称 this.key = key // 回调函数负责更新视图 this.cb = cb // 把watcher对象记录到Dep类的静态属性target Dep.target = this // 触发get方法,在get方法中会调用addSub this.oldValue = vm[key] Dep.target = null } // 当数据发生变化的时候更新视图 update () { // 获取Vue中的属性 let newValue = this.vm[this.key] // 属性值未变化 if (this.oldValue === newValue) { return } this.cb(newValue) } } ~~~ >[danger] ##### 总结 ~~~ 1.当创建vue 实例的时候,会将data中的数据挂载到实例上,并且会调用'Observer'将data中的所有 属性成员转成gettter、setter,并且每个属性都有自己的'Dep'观察者,在getter时候添加订阅者, 在setter 时候发送通知(只有赋值的时候视图是需要发生变化因此此时发送通知告诉订阅者), 所有的订阅者也就是Watcher在视图第一次渲染的时就行了触发订阅,'Compiler' 是处理模板渲染, 这个类第一次初始化是在vue实例创建时候执行,'Compiler'这时候会把最初的模板渲染出来例如 这种模板语法'{{ ... }}'解析成对应的data值在以他的'compileText' 方法为例里面会执行 new Watcher(this.vm, key, (newValue) => { node.textContent = newValue }) 这里要注意'Watcher'里面几个小操作,他会给Dep观察者类方法target属性加一个当前'Watcher'对象 Dep.target = this,紧接着执行this.oldValue = vm[key],这样会触发'vm[key]'的getter属性,间接 触发了$data中的getter ,$data中的getter在'Observer'进行的这里的getter 有一段代码 'Dep.target && dep.addSub(Dep.target)',因为在上面此时的Dep.target 记录就是当前的订阅者'watcher' 对象,成功将'watcher'这个订阅者和'dep'发布者绑定在一起 2.根据上面的分析可以知道如果这个属性没有在页面上使用,那在这个属性getter中不会有和视图相关的 观察者模型 4.MVVM 框架解决了视图和状态的同步问题,因此很好理解数据变化的时候 视图也需要变化 ~~~ ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/8c/3a/8c3ace9ef69e23b2696be0836d9942c8_1127x535.png)