前言
我们在学习vue的时候是否有过这样的疑问,为什么当响应式数据发生变化时视图会发生变化,又如何将一个普通的数据变成一个响应式数据,本篇文章是我最近对vue源码学习的总结,我将通过手写vue响应式原理,带你实现一个基础版的vue。
插值语法
我们先来看一段代码:
<div id="app">
{{ name }}
</div>
const vm = new Vue({
el: '#app',
data: {
name: 'andy'
}
})
效果:
这段代码会将页面中使用插值语法
{{ name }}
的地方,替换成data中对应的属性值。我们现在知道它做了什么,接下来就要想它要怎么做了,实现思路也很简单,那就是遍历页面中的DOM节点,找到使用了插值语法的节点,再将对应的属性值替换掉插值语法就可以了。
遍历之前需要先把要遍历的区域交给Vue进行管理,我们创建一个Vue实例,并将el和data选项传进去,el对应的节点就是Vue实例的管理区域。
class Vue {
constructor(options) {
if(this.isElement(options.el)) {
// 这种情况是el本身就是一个选择器,如el: document.querySelector('#app')
this.$el = options.el
}else {
this.$el = document.querySelector(options.el)
}
this.$data = options.data
}
// 判断el是否为一个元素节点
isElement(node) {
return node.nodeType === 1
}
}
创建一个Compiler类,根据$el
和$data
对Vue管理的区域进行编译,除了插值语法,还需要对指令(v-xxx)
进行编译,因此在查找的时候需要对节点进行判断。
class Vue{
constructor(options) {
...
new Compiler(this)
}
}
class Compiler {
constructor(vm) {
this.vm = vm
let fragment = this.nodeToFragment(this.vm.$el)
// 将数据和视图进行编译
this.buildTemplate(fragment)
this.vm.$el.appendChild(fragment)
}
nodeToFragment(app) {
let fragment = document.createDocumentFragment()
let node = app.firstChild
while(node) {
fragment.appendChild(node)
node = app.firstChild
}
return fragment
}
buildTemplate(fragment) {
let nodeList = [...fragment.childNodes]
nodeList.forEach(node => {
if(this.vm.isElement(node)) {
// node是一个元素节点,对元素进行编译,处理指令
this.buildElement(node)
// 并且递归调用这个函数,处理元素节点的嵌套内容
this.buildTemplate(node)
}else {
// node是一个文本节点,对文本进行编译,处理插值语法
this.buildText(node)
}
})
}
buildText(node) {
let content = node.textContent
// 利用正则表达式判断文本是不是插值语法
let reg = /\{\{.+?\}\}/ig
if(reg.test(content)) {
DiretiveUtils['content'](node, content, this.vm)
}
}
}
buildElement(node) {
let attrs = [...node.attributes]
attrs.forEach(attr => {
let { name, value } = attr
// 判断元素节点的属性是否以v-开头
if(name.startsWith('v-')) {
let [_, directive] = name.split('-')
// 后续将在工具对象中封装响应的命令函数
DiretiveUtils[directive]()
}
})
}
// 该对象主要用于封装处理Vue指令和插值语法的工具函数
const DiretiveUtils = {
getValue(vm, value) {
return value.split('.').reduce((data, key) => {
return data[key.trim()]
}, vm.$data)
},
getContent(vm, content) {
let reg = /\{\{(.+?)\}\}/ig
return content.replace(reg, (...args) => {
return this.getValue(vm, args[1])
})
},
content(node, content, vm) {
let reg = /\{\{(.+?)\}\}/ig
// 将插值语法替换成对应的属性值
let val = content.replace(reg, (...args) => {
return this.getContent(vm, content)
})
node.textContent = val
}
}
到这里就简单实现了插值语法,将app内的子节点移到fragment
代码片段中,然后对这些子节点进行遍历,如果子节点是一个文本节点,则进一步判断该文本是不是插值语法,是的话就调用插值语法的工具函数,将插值语法替换成$data
中对应的属性值;如果子节点是一个元素节点,就获取元素节点的属性,判断属性中是否有指令,有的话则调用对应的指令工具函数,并递归调用buildTemplate
,对该元素节点的嵌套节点做处理。fragment
中的子节点都处理完毕之后,就将代码片段中的节点添加回app中。
这一步只是实现了简单的插值语法,但是数据并不是响应式的,我们可以看下图,数据改变之后,视图还是没发生变化,要实现响应式我们必须要监测到数据的变化。
Object.defineProperty
Vue2的响应式中使用Object.defineProperty
对Vue实例中的数据进行劫持,我们定义一个Observe
的类,负责对data中的属性设置getter和setter。数据的劫持应该在编译之前实现,因此要在编译之前就创建Observe类的实例。
class Vue {
constructor(options) {
...
new Observe(this.$data)
new Compiler(this)
}
}
class Observe {
constructor(obj) {
this.observe(obj)
}
observe(obj) {
if(obj && typeof obj === 'object') {
for(let attr in obj) {
this.defineReactive(obj, attr, obj[attr])
}
}
}
defineReactive(obj, attr, value) {
// 判断某个属性值是否为一个对象,如果是则需要对这个对象里面的属性进行劫持
this.observe(value)
Object.defineProperty(obj, attr, {
get: () => {
return value
},
set: (newValue) => {
console.log('触发setter')
if(newValue !== value) {
// 判断赋予的新值是否为一个对象,如果是对象也需要被劫持
this.observe(newValue)
value = newValue
}
}
})
}
}
这段代码完成了对data数据的劫持,在数据劫持的时候我们要考虑两个问题:
一个是如果某个属性的属性值是一个对象的话,我们要让这个嵌套对象内部的属性也具有getter和setter,因此每遍历一个属性,要先递归调用observe方法,判断该属性的属性值是否为一个对象,如果是的话也需要对该对象的属性进行劫持。
还有一个需要考虑的问题是,后续对某个属性进行赋值时,如果赋予的值是一个对象,那么我们也要让这个新赋予的对象内部的属性都具有getter和setter,因此在进行赋值之前也要调用一次observe方法,判断新的属性值是否为对象,是的话也要对这个对象内部的属性进行劫持。
如何实现数据响应式
到这里已经完成了对数据的监测,可以开始实现数据的响应式了。
实现思路就是,我们可以在对属性进行劫持的时候,给每个属性创建一个订阅者Dep。在初次编译的时候,在模板中使用了响应式数据的地方创建一个观察者Watcher,并将观察者推送到订阅者的subs中。当某个属性发生变化的时候,就通知依赖该属性的观察者修改节点的内容。
Watch类的实现(观察者)
class Watcher {
// vm: Vue实例,attr: 属性,cb: 属性变化后的回调
constructor(vm, attr, cb) {
this.vm = vm
this.attr = attr
this.cb = cb
this.oldValue = this.getOldValue()
}
getOldValue() {
Dep.target = this
let oldValue = DiretiveUtils.getValue(this.vm, this.attr)
Dep.target = null
return oldValue
}
update() {
let newValue = DiretiveUtils.getValue(this.vm, this.attr)
if(this.oldValue !== this.newValue) {
this.cb(newValue, this.oldValue)
}
}
}
Dep类的实现(订阅者)
class Dep {
constructor() {
// 订阅列表
this.subs = []
}
// 将观察者添加到订阅列表
addSub(watcher) {
this.subs.push(watcher)
}
// 通知观察者执行更新方法
notify() {
this.subs.forEach(watcher => watcher.update())
}
}
模拟响应式原理
实现步骤:
- 在数据劫持的时候给每个属性创建一个Dep实例,每个属性都对应着不同的Dep,分别存储在不同的闭包中。
- 编译阶段在使用了响应式属性的地方会创建一个Watcher实例,在创建的时候会触发属性的getter,利用getter将这个Watcher存放到属性对应的Dep的订阅列表中。
- 当属性被修改的时候,会触发属性的setter,setter会调用订阅列表中观察者的update方法,依赖于该属性的观察者会依次修改DOM的内容。
代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
{{ name }}
<p>{{ name }}</p>
<p>{{ time.h }}: {{ time.m }}: {{ time.s }}</p>
</div>
</body>
<script src="./myVue.js"></script>
<script type="text/javascript">
const vm = new Vue({
el: '#app',
data: {
name: 'andy',
time: {
h: 12,
m: 23,
s: 34
},
}
})
</script>
</html>
class Vue {
constructor(options) {
...
new Observe(this.$data)
new Compiler(this)
}
}
class Observe {
constructor(obj) {
this.observe(obj)
}
observe(obj) {
if(obj && typeof obj === 'object') {
for(let attr in obj) {
this.defineReactive(obj, attr, obj[attr])
}
}
}
defineReactive(obj, attr, value) {
// 判断某个属性值是否为一个对象,如果是则需要对这个对象里面的属性进行劫持
this.observe(value)
// 创建一个订阅者,该订阅者将存储在闭包里面
let dep = new Dep()
Object.defineProperty(obj, attr, {
get: () => {
// 将依赖于该属性的观察者 添加到 该属性的订阅列表
Dep.target && dep.addSub(Dep.target)
return value
},
set: (newValue) => {
console.log('触发setter')
if(value !== newValue) {
this.observe(newValue)
value = newValue
// 属性被修改时,通知订阅列表中的观察者修改DOM
dep.notify()
}
}
})
}
}
class Compiler {
constructor(vm) {
this.vm = vm
let fragment = this.nodeToFragment(this.vm.$el)
this.buildTemplate(fragment) // 将数据和视图进行编译
this.vm.$el.appendChild(fragment)
}
buildTemplate(fragment) {
let nodeList = [...fragment.childNodes]
nodeList.forEach(node => {
if(this.vm.isElement(node)) {
this.buildElement(node)
this.buildTemplate(node)
}else {
this.buildText(node)
}
})
}
buildText(node) {
let content = node.textContent
let reg = /\{\{.+?\}\}/ig
if(reg.test(content)) {
DiretiveUtils['content'](node, content, this.vm)
}
}
...
}
const DiretiveUtils = {
// 获取$data中,key对应的value
getValue(vm, value) { // value: "name", ['name'].reduce()
return value.split('.').reduce(( data, key ) => {
return data[key.trim()]
}, vm.$data)
},
// 将插值语法替换成$data中的值
getContent(vm, content) {
let reg = /\{\{(.+?)\}\}/ig
return content.replace(reg, (...args) => {
return this.getValue(vm, args[1])
})
},
content(node, content, vm) {
// content: {{ xxx }}
let reg = /\{\{(.+?)\}\}/ig
let val = content.replace(reg, (...args) => {
// 触发getter,将该Watcher添加到该属性的订阅列表
new Watcher(vm, args[1], (newValue, oldValue) => {
node.textContent = this.getContent(vm, content)
})
return this.getValue(vm, args[1])
})
node.textContent = val
},
}
到这里Vue的响应式已经实现了,我们到页面上看下效果,以下是最开始的页面:
当我们修改属性值,页面也会发生变化:
观察者和订阅者的关系如下:
双向数据绑定(v-model)
双向数据绑定就是v-model
指令,指的是视图和数据要同步更新。在这里我们继续完善Compiler内部的buildElement方法,若指令为v-model
,则调用DiretiveUtils对象中的model方法。
<div id="app">
{{ name }}
<input type="text" v-model="name">
<input type="text" v-model="time.h">
</div>
class Compiler {
constructor(vm) {
...
}
buildElement(node) {
let attrs = [...node.attributes]
attrs.forEach(attr => {
let { name, value } = attr
if(name.startsWith('v-')) {
let [ _, directive ] = direactiveName.split('-')
DiretiveUtils[directive](node, value, this.vm)
}
})
}
}
const DiretiveUtils = {
...,
setVal(vm, value, newValue) {
value.split('.').reduce((data, key, index, arr) => {
if(index === arr.length - 1) {
data[key] = newValue
}
return data[key]
}, vm.$data)
},
model(node, value, vm) {
// value: name time.h
// 数据 => 视图:数据改变时,视图也发生变化
new Watcher(vm, value, (newValue, oldValue) => {
node.value = newValue
})
// 初始化
node.value = this.getValue(vm, value)
// 视图 => 数据:视图发生变化时,数据也要变化
node.addEventListener('input', (e) => {
let newValue = e.target.value
// 赋值的时候需要给value的最后一个属性进行赋值
this.setVal(vm, value, newValue)
})
},
}
其实双向数据绑定无非就是在做两件事,一件事是当数据发生改变时,我们要去更新视图;另外一件事是当视图发生改变时,我们要去更新数据。
当数据发生变化时,我们要做的事和之前是一样的,编译到元素节点存在v-model
指令时,我们会为这个节点创建一个Watcher实例,接着利用getter将该Watcher添加到对应属性的订阅列表中,当数据发生变化时,setter会依次调用依赖于该属性的Watcher改变视图。
我们为输入框绑定了一个input事件,当改变输入框中的内容时,就将最新的值赋给v-model绑定的属性,从而实现视图改变时,数据也同步更新。
我们在页面中看下效果,以下是初始的页面状态:
数据到视图的变化:
我们刷新一下页面,测试一下视图到数据的变化:
事件绑定(v-on)
如果要添加事件,会在data中传入methods的配置,我们需要修改一下buildElement函数,再在DiretiveUtils对象中添加相应的方法,用来调用methods中传入的回调。
<body>
<div id="app">
{{ name }}
<input type="text" v-model="name">
<input type="text" v-model="time.h">
<button v-on:click="clickHandler">点击</button>
</div>
</body>
<script type="text/javascript">
const vm = new Vue({
el: '#app',
data: {
name: 'andy',
time: {
h: 12,
m: 23,
s: 34
},
},
methods: {
clickHandler() {
console.log('123')
}
},
})
</script>
class Compiler {
constructor(vm) {
...
}
...
buildElement(node) {
let attrs = [...node.attributes]
attrs.forEach(attr => {
let { name, value } = attr
if(name.startsWith('v-')) {
let [direactiveName, direactiveType] = name.split(':')
let [ _, directive ] = direactiveName.split('-')
DiretiveUtils[directive](node, value, this.vm, direactiveType)
}
})
}
}
const DiretiveUtils = {
on(node, value, vm, type) {
node.addEventListener(type, (e) => {
vm.$methods[value].call(vm, e)
})
}
}
我们在页面上看下效果:
当我们按下点击按钮,clickHandler被触发,在控制台打印出'123'。
计算属性
我们在使用计算属性的时候,会在创建Vue实例时传入computed的配置选项,我们需要将computed内的属性映射到$data中,并对该属性进行劫持。
我们看下面代码中计算属性返回的this.n,其实现在是访问不到的,因为data中的属性都在data中的属性映射到Vue实例中。
<body>
<div id="app">
{{ name }}
<p>{{ n }}---{{ dbN }}</p>
</div>
</body>
<script type="text/javascript">
const vm = new Vue({
el: '#app',
data: {
name: 'andy',
n: 18
},
computed: {
dbN() {
return this.n*2
}
}
})
</script>
class Vue {
constructor(options) {
...
this.$data = options.data
// 计算属性
this.$computed = options.computed
this.computedToData()
// 需要把$data映射到this上
this.proxyDataVm()
}
computedToData() {
for(let attr in this.$computed) {
Object.defineProperty(this.$data, attr, {
get: () => {
return this.$computed[attr].call(this)
}
})
}
}
proxyDataVm() {
for(let attr in this.$data) {
Object.defineProperty(this, attr, {
get: () => {
return this.$data[attr]
}
})
}
}
}
我们在页面中看下效果:
当我们修改n的值,dbN也会发生变化:
完整代码
class Vue {
constructor(options) {
// 将options.el绑定到Vue实例的$el上
if(this.isElement(options.el)) {
this.$el = options.el
}else {
this.$el = document.querySelector(options.el)
}
// 把options.data绑定到Vue实例的 $data
this.$data = options.data
// 计算属性
this.$computed = options.computed
this.computedToData()
// 需要把data映射到this上
this.proxyDataVm()
this.$methods = options.methods
// 先完成数据的劫持
new Observe(this.$data)
// 然后在需要根据$el和$data对Vue管理的区域进行编译操作
new Compiler(this)
}
computedToData() {
for(let attr in this.$computed) {
Object.defineProperty(this.$data, attr, {
get: () => {
return this.$computed[attr].call(this)
}
})
}
}
proxyDataVm() {
for(let attr in this.$data) {
Object.defineProperty(this, attr, {
get: () => {
return this.$data[attr]
}
})
}
}
isElement(node) {
// 判断el是否为一个DOM对象
return node.nodeType === 1
}
}
// 编译指令和插值语法
class Compiler {
constructor(vm) {
this.vm = vm
let fragment = this.nodeToFragment(this.vm.$el)
this.buildTemplate(fragment) // 将数据和视图进行编译
this.vm.$el.appendChild(fragment)
}
// 模板的指令和插值表达式的查找
buildTemplate(fragment) {
let nodeList = [...fragment.childNodes]
nodeList.forEach(node => {
if(this.vm.isElement(node)) {
this.buildElement(node)
// 如果node是一个元素,则继续向下编译
this.buildTemplate(node)
}else {
this.buildText(node)
}
})
}
// 对元素进行编译,处理指令
buildElement(node) {
let attrs = [...node.attributes]
attrs.forEach(attr => {
let { name, value } = attr
if(name.startsWith('v-')) {
let [direactiveName, direactiveType] = name.split(':')
let [ _, directive ] = direactiveName.split('-')
DiretiveUtils[directive](node, value, this.vm, direactiveType)
}
})
}
// 对文本进行编译,处理插值表达式
buildText(node) {
let content = node.textContent
let reg = /\{\{.+?\}\}/ig
if(reg.test(content)) {
DiretiveUtils['content'](node, content, this.vm)
}
}
nodeToFragment(app) {
let fragment = document.createDocumentFragment()
let node = app.firstChild
while(node) {
fragment.appendChild(node)
node = app.firstChild
}
return fragment
}
}
class Observe {
constructor(obj) {
this.observe(obj)
}
observe(obj) {
if(obj && typeof obj === 'object') {
for(let attr in obj) {
this.defineReactive(obj, attr, obj[attr])
}
}
}
defineReactive(obj, attr, value) {
// 判断某个属性值是否为一个对象,如果是则需要对这个对象里面的属性进行劫持
this.observe(value)
// 创建一个订阅者,该订阅者将存储在闭包里面
let dep = new Dep()
Object.defineProperty(obj, attr, {
get: () => {
// 将依赖于该属性的观察者 添加到 该属性的订阅列表
Dep.target && dep.addSub(Dep.target)
return value
},
set: (newValue) => {
console.log('触发setter')
if(value !== newValue) {
// 判断新赋予的值是否是对象,如果是对象,也需要被劫持
this.observe(newValue)
value = newValue
// 属性被修改时,通知订阅列表中的观察者修改DOM
dep.notify()
}
}
})
}
}
class Watcher {
// vm: Vue实例
// attr:属性
// cb:属性变化后的回调
constructor(vm, attr, cb) {
this.vm = vm
this.attr = attr
this.cb = cb
this.oldValue = this.getOldValue()
}
getOldValue() {
Dep.target = this
let oldValue = DiretiveUtils.getValue(this.vm, this.attr)
Dep.target = null
return oldValue
}
update() {
let newValue = DiretiveUtils.getValue(this.vm, this.attr)
if(this.oldValue !== this.newValue) {
this.cb(newValue, this.oldValue)
}
}
}
class Dep {
constructor() {
this.subs = []
}
// 将观察者添加到订阅列表
addSub(watcher) {
this.subs.push(watcher)
}
// 发布通知,执行观察者的更新方法
notify() {
this.subs.forEach(watcher => watcher.update())
}
}
const DiretiveUtils = {
// 获取$data中,key对应的value
getValue(vm, value) { // value: "name", ['name'].reduce()
return value.split('.').reduce(( data, key ) => {
return data[key.trim()]
}, vm.$data)
},
// 将插值语法替换成$data中的值
getContent(vm, content) {
let reg = /\{\{(.+?)\}\}/ig
return content.replace(reg, (...args) => {
return this.getValue(vm, args[1])
})
},
setVal(vm, value, newValue) {
value.split('.').reduce((data, key, index, arr) => {
if(index === arr.length - 1) {
data[key] = newValue
}
return data[key]
}, vm.$data)
},
model(node, value, vm) {
// value name time.h
// 数据 => 视图:数据改变时,视图也发生变化
new Watcher(vm, value, (newValue, oldValue) => {
node.value = newValue
})
// 初始化
node.value = this.getValue(vm, value)
// 视图 => 数据:视图发生变化时,数据也要变化
node.addEventListener('input', (e) => {
let newValue = e.target.value
// 赋值的时候需要给value的最后一个属性进行赋值
this.setVal(vm, value, newValue)
})
},
content(node, content, vm) {
// content: {{ xxx }}
let reg = /\{\{(.+?)\}\}/ig
let val = content.replace(reg, (...args) => {
new Watcher(vm, args[1], (newValue, oldValue) => {
// node.textContent = newValue
node.textContent = this.getContent(vm, content)
// node.textContent = this.getValue(vm, args[1])
})
return this.getValue(vm, args[1])
})
// let val = this.getContent(vm, content)
node.textContent = val
},
on(node, value, vm, type) {
node.addEventListener(type, (e) => {
vm.$methods[value].call(vm, e)
})
}
}