本文通过不到百行代码,实现了一个极小的响应式Vue,在此过程中,希望读者能了解到
- Vue 实例 、响应式数据 、watcher 、dep 四者之间的关系
- dep 何时、如何收集 watcher
Vue 类
首先我们创建一个 Vue 类,要求传入的 options(即组件)有最基础的几个属性
class Vue {
constructor(options) {
const { data, render, methods, el } = options
this.$options = options
this._methods = methods || {}
this._render = render
this.$el = document.querySelector(el) // 选择要挂载的元素
}
}
描述组件
一般来说,Vue 单文件的单文件组件会将 template 编译成 render 函数,然后挂在组件对象上(引入 .vue 文件打印一下就知道了),这个 render 函数是一个返回 vnode 的函数,Vue 再根据 vnode 渲染,我们这里就直接写一个 极其简单的描述 Dom 的 element 对象返回就好了(vnode 由 element 生成,是 diff 中的一个单元),有 tag、attrs、children、events 四个属性
const ComponentA = {
el:'#container1',
render() {
// <div :class="color"><span>{{text}}</span></div>
return {
tag: 'div', // 元素标签
attrs: { class: 'red' }, // 属性
events: { click:()=>console.log(111) }, // 事件
// 子元素
children: [{ tag: 'span', children: ['i am a'] }]
}
}
}
创建真实 Dom
我们再实现一个相对应的生成真实 Dom 的函数
class Vue {
// ...
_createDom({ tag = 'div', attrs = {}, children = [], events = {} } = {}) {
const dom = document.createElement(tag)
for (let [key, value] of Object.entries(attrs)) {
dom.setAttribute(key, value)
}
for (let [event, callback] of Object.entries(events)) {
dom.addEventListener(event, callback.bind(this))
}
for (let child of children) {
if (typeof child === 'object' && child !== null)
dom.appendChild(this._createDom(child))
else dom.insertAdjacentHTML('beforeend', child)
}
return dom
}
}
然后简单地挂载一下
class Vue {
constructor({ data, render }) {
//...
+ this._mounted()
}
+ _mounted() {
+ this.$el.replaceChildren(this._createDom(this._render()))
+ }
}
执行 const vm1 = new Vue({render,el:'#container1' }),成功挂载
data 代理
之后我们处理一下 data 方法,我们知道,访问 data 和 method 时是使用 this.xxx 而不是 this.methods.xxx,实际上是 Vue 做了一层代理
class Vue {
constructor(options) {
// ...
this._data = data?.apply(this) ?? {}
proxy(this, '_methods')
proxy(this, '_data')
this._mounted()
}
}
function proxy(target, sourceKey) {
const data = target[sourceKey]
for (let key of Object.keys(data)) {
Object.defineProperty(target, key, {
get() {
return this[sourceKey][key]
},
set(val) {
this[sourceKey][key] = val
}
})
}
}
现在我们试一下,直接在 render 中访问 data 和 method 吧
const ComponentA = {
el: '#container1',
data() {
return {
color: 'red',
text: 'i am a'
}
},
methods: {
console() {
console.log(this)
}
},
render() {
// <div :class="color"><span>{{text}}</span></div>
with (this) {
return {
tag: 'div', // 元素标签
attrs: { class: color }, // 属性
events: { click: console }, // 事件
children: [{ tag: 'span', children: [text] }]
}
}
}
}
Vue 在生成 render 时会在外层包一个 with(this){} ,变量访问时候就会访问到 this 上,就不用写 this 了
const vm1 = new Vue(ComponentA)
点击之后,打印 vue 实例
观察者模式
Vue 官网这张图很清晰地描述了整个过程:
new Vue() 时:
- 使数据响应式,就是用 Object.defineProperty 设置每个 key 的 get 和 set 函数,利用闭包让每个键都有一个 dep 用于收集 watcher
- 创建一个 watcher 对应这个 Vue 实例的 render
- 在当前 watcher 环境下执行 render,当访问到响应式数据时,触发 get 函数,dep 就可以收集到当前 watcher
- 之后是挂载等操作...
当我们设置响应式数据时
- 触发 set 函数,dep 通知 watcher 更新
- watcher 执行当时传入的更新回调(对于 Vue 实例来说就是 update )
- 然后进入更新的操作...
这里需要理清楚,
-
一个 Vue 实例对应一个 watcher,任务是负责渲染;一个 computed 也会对应一个 watcher,任务是负责更新 computed 缓存
-
一个 key 对应一个 dep
-
一个响应式数据与它所属的 Vue 实例没有任何关系,能被收集是因为 render 上访问到了这个数据,比如 Vuex store 上的属性,它在多个 Vue render 中被访问,收集到了多个对应的 watcher,数据更新时,就会同时通知多个组件更新。
我们实现这两个重要类
class Watcher {
constructor(vm, get) {
this.vm = vm
this.get = get
Dep.target = this
get.call(vm)
Dep.target = null
}
update() {
this.get.call(this.vm)
}
}
class Dep {
static target = null
constructor() {
this.watchers = new Set()
}
add(watcher) {
if (!this.watchers.has(watcher)) this.watchers.add(watcher)
}
notify() {
for (let watcher of this.watchers) {
watcher.update()
}
}
}
Dep 实例上有个 Set 收集 watchers,调用 notify 时通知 watchers 更新,
Watcher 构造函数中,首先改变 Dep.target 为 this,然后执行传入的 get(Vue 就是 render 函数),如果执行过程中有访问到响应式数据,这个数据上的 dep 就会将 Dep.target 收集进 watchers 数组。
数据响应式
用 Object.defineProperty 递归地为每个键设置 get 函数并通过闭包私有 Dep 实例,在 watcher 调用环境下,收集调用者。
function reactive(obj) {
if (typeof obj !== 'object' || obj === null) return
for (let key of Object.keys(obj)) {
const dep = new Dep()
let value = obj[key]
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
get: function reactiveGetter() {
if (Dep.target) dep.add(Dep.target)
return value
},
set: function reactiveSetter(newValue) {
if (newValue === value) {
return
}
value = newValue
dep.notify()
}
})
reactive(value)
}
}
最后,我们将 data 变为响应式并用 watcher 来初次渲染
class Vue {
constructor(options) {
const { data, render, methods, el } = options
this.$options = options
this._methods = methods || {}
this._render = render
this.$el = document.querySelector(el) // 选择要挂载的元素
this._data = data.apply(this)
+ reactive(this._data)
proxy(this, '_methods')
proxy(this, '_data')
- this._mounted()
+ this._watcher = new Watcher(this,this._mounted)
}
// ...
}
响应式就实现了!是不是很简单呢?
测试一下
const Counter = {
el: '#container1',
data() {
return {
color: 'red',
count: 0
}
},
methods: {
toggle() {
this.color = this.color === 'red' ? 'blue' : 'red'
},
add() {
this.count++
}
},
render() {
/* <div>
<div :class="color" @click="add">{{count}}</div>
<button @click="toggle">toggle</button>
</div>
*/
with (this) {
return {
tag: 'div', // 元素标签
children: [
{ tag: 'div',attrs: { class: color } , events: { click: add }, children: [count] },
{ tag: 'button', events: { click: toggle }, children: ['toggle'] }
]
}
}
}
}
new Vue(Counter)
成功!
最后,我还想补充一个 store 的例子以加深你对响应式的理解
class Vue {
constructor(options) {
- const { data, render, methods, el } = options
+ const { data, render, methods, el, store } = options
+ this.$store = store
// ...
}
const store = { count: 0 }
reactive(store)
function createCounter(el) {
return {
store,
el,
render() {
with (this) {
return {
tag: 'button',
events: { click: () => $store.count++ },
children: [$store.count]
}
}
}
}
}
new Vue(createCounter('#container1'))
new Vue(createCounter('#container2'))
new Vue(createCounter('#container3'))
store.count 收集了三个 Vue 实例的 watcher,当 count 改变时,三个组件都会触发更新
全部代码
class Vue {
constructor(options) {
const { data, render, methods, el, store } = options
this.$store = store
this.$options = options
this._methods = methods || {}
this._render = render
this.$el = document.querySelector(el) // 选择要挂载的元素
this._data = data?.apply(this) ?? {}
reactive(this._data)
proxy(this, '_methods')
proxy(this, '_data')
this._watcher = new Watcher(this, this._mounted)
}
_mounted() {
this.$el.replaceChildren(this._createDom(this._render()))
}
_createDom({ tag = 'div', attrs = {}, children = [], events = {} } = {}) {
const dom = document.createElement(tag)
for (let [key, value] of Object.entries(attrs)) {
dom.setAttribute(key, value)
}
for (let [event, callback] of Object.entries(events)) {
dom.addEventListener(event, callback.bind(this))
}
for (let child of children) {
if (typeof child === 'object' && child !== null)
dom.appendChild(this._createDom(child))
else dom.insertAdjacentHTML('beforeend', child)
}
return dom
}
}
function proxy(target, sourceKey) {
const data = target[sourceKey]
for (let key of Object.keys(data)) {
Object.defineProperty(target, key, {
get() {
return this[sourceKey][key]
},
set(val) {
this[sourceKey][key] = val
}
})
}
}
class Watcher {
constructor(vm, get) {
this.vm = vm
this.get = get
Dep.target = this
get.call(vm)
Dep.target = null
}
update() {
this.get.call(this.vm)
}
}
class Dep {
static target = null
constructor() {
this.watchers = new Set()
}
add(watcher) {
if (!this.watchers.has(watcher)) this.watchers.add(watcher)
}
notify() {
for (let watcher of this.watchers) {
watcher.update()
}
}
}
function reactive(obj) {
if (typeof obj !== 'object' || obj === null) return
for (let key of Object.keys(obj)) {
const dep = new Dep()
let value = obj[key]
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
get: function reactiveGetter() {
if (Dep.target) dep.add(Dep.target)
return value
},
set: function reactiveSetter(newValue) {
if (newValue === value) {
return
}
value = newValue
dep.notify()
}
})
reactive(value)
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<style>
.red {
color: red;
}
.blue {
color: blue;
}
</style>
</head>
<body>
<div id="container1"></div>
<div id="container2"></div>
<div id="container3"></div>
</body>
</html>
小结
- Vue 通过 watcher 执行第一次 render 函数,在此过程中访问到的响应式数据的 key 上的 dep 将这个 watcher 收集。
- 若响应式数据更新,dep 通知 watcher 去 render
感谢阅读,欢迎在评论区留言~