Vue双向绑定原理与实现
2019-07-22 本文已影响84人
charoner
前言
实现Vue的数据的双向绑定 是采用数据劫持结合发布者-订阅者模式的方式 通过Object.defineProperty()来劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应的监听回调 需要实现以下几点:
1 实现一个 Observer 观察者对data中的数据进行监听,若有变化,通知相应的订阅者
2 实现一个 Compile 编译器将页面中子节点拷贝到DocumentFragment对象中,然后对每个元素节点的指令进行扫描与解析
3 实现一个 Watcher 用来连接Observer和Compile,并为每个属性绑定相应的订阅者,当数据发生变化时,执行相应的回调函数,从而更新视图。
4 new MVVM的类实例对象 作为入口整合使用
准备工作
下面是文件目录,由于浏览器不支持 es6 的export default / import 语法所以用webpack来打包解析es6语法
711563808099_.pic.jpg
实现vue的类
- 新创建一个Vue的类,接收 new实例对象传过来的参数, el:vue实例被挂载的Dom对象, _data: 接收了初始化的data数据
- 利用Object.defineProperty()的方法利用set、get 的方法对指定的属性进行数据代理,也就是将示例对象上的属性 映射到内部_data对象属性上去 实现 MVVM.xxx => MVVM._data.xxx
- 接下来实现一个Observer 对data中的属性进行数据劫持将属性与发布订阅者绑定
- 进行模版指令解析
vue.js
import Observer from './Observer' //观察者
import Compiler from './Compiler' //编译模版
class Vue {
constructor(options) {
this.$options = options
//保存挂载元素的ID
this.$el = this.$options.el
//保存实例对象中的data对象数据
this._data = this.$options.data
//遍历属性添加数据代理
Object.keys(this._data).forEach(key => {
this._proxy(key)
})
//通过数据劫持实现双向绑定 通知发布订阅
new Observer(this._data)
//模版指令解析
new Compiler(this.$el, this)
}
//将实例对象上的属性 映射到内部_data对象属性上面 (实现 MVVM.xxx => MVVM._data.xxx)
_proxy(key) {
//属性描述符
Object.defineProperty(this, key, {
//读取属性值时调用
get() {
//返回_data 中属性名的对应的属性值
return this._data[key]
},
//监听设置属性调用
set(value) {
//赋值到_data属性名对应的属性值
this._data[key] = value
}
})
}
}
export default Vue
实现Observer 观察者
- 接收并保存vue实例对象传递过来的数据
- 通过Object.keys()的方法返回对象所有属性名组成的数组,遍历对每个属性执行数据劫持实现双向绑定。在每次遍历中都会new一个Dep()的实例对象,并且Dep()的实例对象与属性是一一对应的关系
- 在对属性进行数据劫持的同时触发get()方法会去判断当前是否存在Watcher对象,存在就调用Dep中的listen方法将当前的Watcher对象添加到订阅者数组中,并返回当前的value值
- 在通过MVVM.name = 'xxx' 赋值的过程中会触发set()函数,简单判断后赋值的同时调用myDep 中的notify()方法遍历执行Watcher中的update()方法进行页面对应视图的更新
Observer.js
import Dep from "./Dep";
class Observer {
constructor(data) {
//保存数据
this.data = data
//对数据中所有属性设置setter getter 通过数据劫持实现双向绑定
Object.keys(data).forEach(key => {
this._bind(data, key, data[key])
})
}
_bind(data, key, value) {
//new Dep对象 保存订阅者 遍历调用订阅者的update对象 (myDep实例对象与data中的属性一一对应)
let myDep = new Dep()
//属性描述符
Object.defineProperty(data, key, {
//获取属性值
get() {
//获取属性值 判断是否存在wathcer实例对象 存在将其保存到Dep当中
if (Dep.target) myDep.listen(Dep.target)
return value
},
//监视属性值的变化
set(newValue) {
if (newValue === value) return
//赋值
value = newValue
//观察者改变完数据 通知发布订阅
myDep.notify()
}
})
}
}
export default Observer
发布订阅
- 初始化 target 保存Watcher订阅者对象 ,list 存放多个Watcher订阅者对象
- 实现了一个 listen()方法用于将Watcher订阅者对象添加到list当中
- 实现了一个 notify()方法遍历订阅者对象调用其 update()的方法进行页面视图的更新
Dep .js
class Dep {
constructor() {
this.target = null
this.list = []
}
//添加订阅者 watcher
listen(subs) {
this.list.push(subs)
}
//调用watcher中的update方法更新视图
notify() {
this.list.forEach(item => {
item.update()
})
}
}
export default Dep
实现Compile
- 通过页面元素的id获取当前元素的节点对象并保存,保存当前的vue的实例对象
- 创建通过 createFragment()方法创建了一个Dom碎片对象, 循环将this.$el中的子节点依次转移到 fragment对象中经过compileElement()编译元素节点之后return 在次添加到页面的节点对象
- 通过判断元素的节点类型的值再做对应的编译解析操作
- 当数据类型是元素节点并且包含 v-model 的属性指令时直接将绑定的属性名对应的属性值赋值到元素节点的vlaue值实现视图更新, 并且给元素添加 'input' 的监听事件,事件发生时将value值赋值给 对象中对应属性名的属性值
- 当数据类型为文本类型 {{ name }} 时,通过正则解析判断当前格式获取到 'name' 属性名 new Watcher()实例对象初始化调用update()方法实现视图更新
Compiler.js
import Watcher from "./Watcher";
const reg = /\{\{(.*)\}\}/
class Compiler {
constructor(el, vm) {
//保存根据获取到ID对应的页面节点对象
this.$el = document.querySelector(el)
//保存当前的MVVM对象
this.$vm = vm
//将页面中的子节点转移到fragment中
this.fragment = this.createFragment()
//将fragment 转移回到页面当中
this.$el.appendChild(this.fragment)
}
createFragment() {
//创建Dom碎片对象
let fragment = document.createDocumentFragment(), child
//将原生节点拷贝到fragment
while (child = this.$el.firstChild) {
//编译元素节点
this.compileElement(child)
//将节点加入fragment对象
fragment.appendChild(child)
}
return fragment
}
//编译元素节点
compileElement(node) {
//元素节点
if (node.nodeType == 1) {
//获取当前元素节点的属性值数组
let attr = node.attributes
//判断属性值数组中是否存在v-model
if (attr.hasOwnProperty('v-model')) {
let self = this
//获取属性为 v-model 的元素节点 并获取节点值
let name = attr['v-model'].nodeValue
//初始化显示输入框内容
node.value = this.$vm[name]
//给输入框添加input事件监听
node.addEventListener('input', function(e){
self.$vm[name] = e.target.value
})
}
}
//文本节点
if (node.nodeType == 3) {
//判断是否符合 双大括号表达式 {{ }}
if (reg.test(node.nodeValue)) {
//获取表达式内容
let name = RegExp.$1
//去空格
name = name.trim()
//new一个Watcher对象更新双大括号表达式内容
new Watcher(node, name, this.$vm)
}
}
}
}
export default Compiler
实现Watcher
- 接收并保存初始化编译模版传递过来的 节点元素,属性名(‘name’) 当前的vue实例对象
- 将自身在触发get()方法时添加到Dep中的list数组后清空
- 初始化调用update()方法实现视图更新
Watcher.js
import Dep from './Dep'
//页面上所有订阅数据的地方 (watcher 与页面的表达式一一对应)
class Watcher {
constructor(node, name, vm) {
this.node = node
this.name = name
this.vm = vm
//将Dep.target 赋值为 this (Watcher的示例对象)
Dep.target = this
//调用update() 方法更新节点数据
this.update()
Dep.target = null
}
update() {
//将当前的标签节点下的值改成 vm对象中对应属性的值
//触发Observer中对MVVM对象监听的name属性添加的get属性 从而将当前的{{}}的表达式注入到 Dep中
this.node.nodeValue = this.vm[this.name]
}
}
export default Watcher
vue实例对象
main.js入口文件
import Vue from './vue'
const MVVM = new Vue({
el: "#app",
data: {
name: 'hello world',
}
})
window.MVVM = MVVM
HTML代码
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="app">
<input type="text" v-model="name" />
{{ name }}
</div>
<script src="./dist/main.js"></script>
</body>
</html>
总结
至此就实现了一个简单的数据双向绑定的例子,主要的目的是为了更好的理解双向绑定的实现原理与设计思想