本文已参与「新人创作礼」活动,一起开启掘金创作之路。
一、引言
昨天晚上做了个噩梦,场景如下:
面试官:你来讲讲vue的响应式原理吧
我:vue2是通过Object.defineProperty来实现响应式的
面试官:没了吗?
我:没了
面试官:......
我:......
吓得的我突然从梦中惊起,我还在想我的vue知识去哪了,还好只是个梦。
三、正文
今天我们来做一个简易Vue来耍耍,在上手之前呢要了解Vue响应式的原理,那我们就要先弄清
发布订阅模式以及三个家伙Observer、Dep、Watcher。
发布订阅模式
概念:订阅者们把自己想要订阅的事件在调度中心进行注册,当发布者在调度中心进行发布的时候,会通知所有的订阅者。
例子:你在掘金中关注某了位up,当up发布文章的时候,掘金就会把此文章推送给你。这样你就是订阅者,up就是发布者,而掘金平台就是调度中心。
这样我们就可以理解Vue响应式中的三巨头了。
Observer
发布订阅中的发布者,负责对数据进行
get、set重写,通知调度中心进行更新。
Dep
发布订阅中的调度中心,负责收集所有的订阅者。
Watcher
发布订阅中的订阅者,在vue初始化的时候会实例各种
watcher。
工作原理
- Observer:通过递归重写
data中每一个属性的getter、setter,每一个属性都会有属于自己的Dep,当get触发会将Dep上面的静态属性(watcher)加到实例dep中,当set触发时,会调用实例dep进行notify通知所有的watcher更新。 - Dep:收集
watcher,在notify方法中调用所有的watcher的update方法。 - Watcher:在进行实例化的时候会将自身挂载到
Dep上面的静态属性target中,然后通过触发get将自身放到该属性专属的dep实例中。
这里的Dep静态属性target以及属性中的
getter是怎么将watcher放入dep实例中可能会比较绕,我们来用代码来实现这些操作。
四、代码
Observer
import Dep from './dep.js'
export default class Observer {
constructor(value) {
this.value = value
this.walk(value)
}
walk(obj) {
//循环data对象重写setter和getter
for (const key in obj) {
defineReactive(obj,key,obj[key])
}
}
}
//将传进去的对象进行改造
function observe(value) {
if (typeof value === 'object') {
return new Observer(value)
}
}
export function defineReactive(obj, key, val) {
const dep = new Dep()
//递归
observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: () => {
//如果Dep上面的target是有值的就将target加入dep中,这里的触发时机是在Watcher.js中
if (Dep.subTarget) {
dep.addSub(Dep.subTarget)
}
return val
},
set: (value) => {
val = value
dep.notify()
}
})
}
Dep
let uid = 0
export default class Dep {
static subTarget = null
constructor() {
this.id = ++uid
//存放watcher的数组
this.subs = []
}
//新增watcher
addSub(watcher) {
if (Dep.subTarget !==null) {
this.subs.push(watcher)
}
}
//响应更新
notify() {
this.subs.forEach(watcher => {
watcher.update()
})
}
}
export function addTarget(watcher) {
Dep.subTarget = watcher
}
export function removeTarget() {
Dep.subTarget = null
}
Watcher
import { addTarget, removeTarget } from './dep.js'
import { parsePath } from '../utils/lang.js'
let uid = 0
export default class Watcher {
//cb是传入方法,在执行update的时候会执行cb
constructor(vm, exp, cb) {
this.vm = vm
this.exp = exp
this.cb = cb
this.id = ++uid
//处理传进来的字符串,有可能是xxx.aabb
this.getter = parsePath(this.exp)
this.get()
}
//触发get
get() {
//将自身挂载到Dep的target上
addTarget(this)
//重点,为了触发getter 将Dep的target加进去
const value = this.getter(this.vm)
//移除target
removeTarget(this)
return value
}
//更新
update() {
this.cb(this.getter(this.vm))
}
}
//str可能会是xxx.aabb 所以进行处理
function parsePath(str) {
const arr = str.split('.')
return function (obj) {
for (let i = 0; i < arr.length; i++) {
if (!obj) return
obj = obj[arr[i]]
}
return obj
}
}
四、后续工作
我们完成了响应式部分,但是我们发现并没有使用啊,接下来我们来做
Vue和html模板编译的部分。
Vue
import Observer from './core/observer/Observer.js'
import Compiler from './compiler/index.js'
export default class Vue {
constructor(options) {
this._data = options.data()
this._methods = options.methods
for (const key in this._data) {
Object.defineProperty(this, key, {
get: function () {
return this._data[key]
},
set: function (value) {
this._data[key] = value
}
})
}
for (const key in this._methods) {
Object.defineProperty(this, key, {
get: function () {
return this._methods[key].bind(this)
},
set: function (value) {
this._methods[key] = value.bind(this)
}
})
}
//处理数据
new Observer(this._data)
this.el = document.querySelector(options.el)
//模板解析(解析html标签)
new Compiler(this.el, this)
//执行created方法
options.created?.call(this)
}
}
Compiler
import Watcher from '../core/observer/watcher.js'
import {parsePath} from '../core/utils/lang.js'
export default class Compiler {
constructor(dom, vm) {
this.vm = vm;
this._compileElement(dom)
}
_compileElement(el) {
let childs = Array.from(el.childNodes)
childs.forEach(node => {
if (node.childNodes && node.childNodes.length > 0) {
if (node.tagName === 'BUTTON') {
this._compile(node)
}
this._compileElement(node)
} else {
this._compile(node)
}
})
}
//根据节点的类型解析
_compile(node) {
if (node.nodeType === 1) {
this._compileAttr(node)
} else if (node.nodeType === 3) {
this._compileText(node)
}
}
//文本解析 获取{{}}中的key,通过key拿到值 重新赋值给textContent
// 新建Watcher,当数据更新时更改textContent的值
_compileText(node) {
let reg = /\{\{(.*)\}\}/
let content = node.textContent
if (reg.test(content)) {
let key = RegExp.$1
node.textContent = parsePath(key)(this.vm)
new Watcher(this.vm, key, val => {
node.textContent = val
})
}
}
//获取标签中v-model的key值,新建input监听,当有更改时直接更改vm中的数据
//新增Watcher ,当数据有变动的时候更改value值 实现双向绑定
// 获取标签中的@click,新增点击事件,点击的时候触发vm中对应的methods方法
_compileAttr(node) {
let nodeAttr = Array.from(node.attributes)
nodeAttr.forEach(attr => {
if (attr.name === 'v-model') {
node.value = parsePath(attr.nodeValue)(this.vm)
node.addEventListener('input', () => {
this._modifyValue(attr.nodeValue, node.value)
})
new Watcher(this.vm, attr.nodeValue, val => {
node.value = val
})
} else if (attr.name === '@click') {
node.addEventListener('click', (e) => {
this.vm[attr.nodeValue].call(this.vm)
})
}
})
}
_modifyValue(exp,value) {
let arr = exp.split('.')
let obj = this.vm
for (let i = 0; i < arr.length; i++) {
let current = obj[arr[i]]
if (Object.prototype.toString.call(current) === '[object Object]') {
obj = current
} else {
obj[arr[i]] = value
}
}
}
}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app">
<div style="height:20px">{{obj.value}}</div>
<input type="input" v-model="obj.value" />
<button @click='setValue'>{{button}}</button>
</div>
</body>
</html>
<script type="module">
import Vue from "../src/vue.js";
const vue = new Vue({
data() {
return {
button:'重置',
obj:{
value:'demo'
}
};
},
methods: {
setValue() {
this.obj.value = '666666'
},
},
el: "#app",
});
</script>
五、效果展示
六、总结
这期的内容可能代码偏多,主要需要理解发布订阅的核心思想,Object.defineProperty的用法以及一些dom的操作,在众多源码中,Vue的源码还是比较清晰易懂的,在模板解析那部分只是简单的解析标签内容,像VNode什么的都没有涉及到,以后我也会研究研究虚拟节点以及Diff的实现并且分享给大家,能够学习源码自己做出来点东西也是很快乐的,大家也要多动手多实践呀~
今天的分享就是这些啦~~
听说喜欢点赞的你,今年年终奖拿到手软😍