1️.准备工作
目录结构
├── public
│ ├── index.html // 模版文件
├── src
│ ├── index.js // 测试页面
├── source
│ ├── vue // vue代码
├── webpack.config.js
复制代码配置resolve
让项目中import Vue from 'vue’指向source目录的vue。
// webpack.config.js
module.exports = (env) => {
return {
// …
resolve: {
modules: [path.resolve(__dirname, ‘source’), path.resolve(__dirname, ‘node_modules’)]
},
}
}
复制代码入口文件
接下来的篇幅我们将逐步实现initData、initComputed、initWatch、$mount。
function Vue(options) {
this._init(options)
}
Vue.prototype._init = function(options) {
let vm = this
vm.$options = options
initState(vm)
if (options.el) {
vm.$mount()
}
}
function initState (vm) {
const opts = vm.$options
if (opts.data) {
initData(vm)
}
if (opts.computed) {
initComputed(vm)
}
if (opts.watch) {
initWatch(vm)
}
}
复制代码2️.观察对象和数组
1️⃣观察对象
这一节的开始我们先要了解defineProperty,这里就不多介绍了。
初始化Vue实例的时候,会将用户配置的data传入observe函数中,然后遍历所有元素进行defineReactive,过程中遇到对象的话递归调用observe,这样就完成了整个data的重新定义。
这么做的原因是我们可以自定义属性的getter和setter,可以在里面定义一些依赖收集和视图更新的操作,这是响应式原理的开始。
observe
export function observe(data) {
// 如果不是对象直接返回,不需要观察
if (typeof data !== ‘object’ || data === null) {
return data
}
return new Observer(data)
}
复制代码Observer
class Observer {
constructor(data) {
this.walk(data)
}
walk(data) {
const keys = Object.keys(data)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const value = data[key]
defineReactive(data, key, value)
}
}
}
复制代码defineReactive
export function defineReactive(data, key, value) {
// 如果value是对象的话,需要继续观察一层
observe(value)
Object.defineProperty(data, key, {
get() {
console.log(‘获取数据’)
return value
},
set(newValue) {
if (newValue === value) return
console.log(‘更新视图’)
// 这里说明一下,defineReactive执行是一个闭包,当新的newValue进来后,修改value便能够共享到get里面。
value = newValue
}
})
}
复制代码另外,data传入的可能是对象或者函数,需要在数据传入时候处理一下。
function initData(vm) {
let data = vm.$options.data
// 判断data是否为函数,然后取出data赋值给vm._data
data = vm._data = typeof data === ‘function’ ? data.call(vm) : data || {}
// 将用户插入的数据,用Object.definedProperty重新定义
observe(vm._data)
}
复制代码顺便提一下,因为vue组件是可以复用的,传入一个对象的话会造成多个组件引用同一份数据造成污染,实际我们使用当中都是传入一个函数,每次初始化时都生成一个副本。
上面initData中我们取到data后把数据挂在vm._data中,后面的操作都是针对这组数据。
🧪测试一下。
const vm = new Vue({
el: ‘#app’,
data() {
return {
msg: ‘hello’
}
}
})
console.log(vm._data.msg)
vm._data.msg = ‘world’
复制代码这样访问和修改msg属性都会输出我们写的console。
2️⃣代理vm._data
你可能注意到平时我们使用vue都是能够从vm中直接获取data的数据,而不是像上面一样通过vm._data。
于是代理一下数据,这里仅代理第一层数据就可以了。
当访问vm.obj.name时,首先找到vm.obj也就是vm._data.obj,然后所有嵌套数据都能正常获取。
function proxy(vm, key, source) {
Object.defineProperty(vm, key, {
get() {
return vm[source][key]
},
set(newValue) {
vm[source][key] = newValue
}
})
}
function initData(vm) {
// …
// 把_data的属性映射到vm上
for (const key in data) {
proxy(vm, key, ‘_data’)
}
// …
}
复制代码👉戳这里看这小节代码
3️⃣观察数组
数据经过observe过后,对象的所有属性的访问和修改都能被监控到了,但是还没对数组处理,首先我们要劫持能修改数组数据的方法:
push
pop
unshift
shift
sort
reverse
splice
为了不污染全局的数组,我们把数组的原型拷贝一份,然后再修改新的原型。
const arrayProto = Array.prototype
const newArrayProto = Object.create(arrayProto)
methods.forEach(method => {
newArrayProto[method] = function (…args) {
// 调用原数组方法
const r = arrayProto[method].apply(this, args)
console.log(‘调用了数组的方法设置数据’)
return r
}
})
复制代码把newArrayProto设置给传入的数组,然后遍历数组,观察里面的所有元素。
class Observer {
constructor(data) {
if (Array.isArray(data)) {
// data.proto = newArrayProto
// __proto__不推荐使用 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/proto
Object.setPrototypeOf(data, newArrayProto)
for (let i = 0; i < data.length; i++) {
observe(data[i])
}
} else {
this.walk(data)
}
}
// …
}
复制代码对于数组新增的元素我们同样需要观察一波。
methods.forEach(method => {
newArrayProto[method] = function (…args) {
const r = arrayProto[method].apply(this, args)
// 对新增的元素进行观测
let inserted
switch (method) {
case 'push':
case 'shift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
}
observeArray(inserted)
return r
}
})
function observeArray(arr) {
for (let i = 0; i < arr.length; i++) {
observe(arr[i])
}
}
复制代码🧪测试一下
const vm = new Vue({
el: ‘#app’,
data() {
return {
arr: [{ a:1 }, 1, 2]
}
}
})
vm.arr[0].a = 2 // 数组里面嵌套的对象测试
vm.arr.push({ b: 1}) // 数组方法劫持测试
vm.arr[3].b = 2 // 数组方法新增元素测试
复制代码在vue中出于性能的考虑,并没有对数组的索引进行观察,我们直接修改数组索引例如arr[0] = 1这样是不会触发更新的,官网提供了一个Vue.KaTeX parse error: Unexpected character: '�' at position 33: …调用数组的splice方法。 �̲�戳这里看这小节代码 3️.mount
1️⃣渲染watcher
在initState过后,如果用户配置了el属性,会调用KaTeX parse error: Expected '}', got 'EOF' at end of input: …ptions) { vm.options = options
// 初始化data watch computed
initState(vm)
if (options.el) {
vm.KaTeX parse error: Expected 'EOF', got '}' at position 11: mount() }̲ } 复制代码mount做了两件事:
获取el元素并挂载在$el上。
实例化一个watcher去渲染页面。
function query(el) {
if (typeof el === ‘string’) {
return document.querySelector(el)
}
return el
}
Vue.prototype.KaTeX parse error: Expected '}', got 'EOF' at end of input: … let el = vm.options.el
el = vm.$el = query(el)
// 渲染/更新逻辑
const updateComponent = () => {
vm._update()
}
new Watcher(vm, updateComponent)
}
复制代码这里的watcher叫做渲染watcher,后面还有更多的watcher,如computed watcher。
在这一节暂时不需要去了解watcher的概念,你只需要知道new Watcher(vm, updateComponent)会执行一次updateComponent。
这里就简单声明一下这个Watcher类,不用细看,后面章节还会做很多扩展和详细说明这个类。
let id = 0 // 每个watcher的标识
class Watcher {
/**
*
- @param {*} vm 当前Vue实例
- @param {*} exprOrFn 表达式或者函数 vm.$watch(‘msg’, cb) 如’msg’
- @param {*} cb 表达式或者函数 vm.$watch(‘msg’, cb) 如cb
- @param {*} opts 其他的一些参数
*/
constructor(vm, exprOrFn, cb = () => {}, opts = {}) {
this.vm = vm
this.exprOrFn = exprOrFn
if (typeof exprOrFn === ‘function’) {
this.getter = exprOrFn
}
this.cb = cb
this.opts = opts
this.id = id++
this.get() // 创建watcher时候默认会调用一次get方法
}
get() {
this.getter()
}
}
export default Watcher
复制代码
2️⃣_update
下面我们继续写这一节的核心updateComponent里面的_update方法,这个方法会进行页面更新,实际上更新过程是比较复杂的,vue2.x引入了虚拟Dom,首先会把模版解析成Vdom,然后将Vdom渲染成真实的dom,数据更新后,生成一个新的Vdom,新旧Vdom进行Diff,然后变更需要修改的部分,完整的编译过程是比较复杂的,这里我们先不引入虚拟Dom,简单实现,后面会新开一篇文章整理虚拟Dom和diff。
使用createDocumentFragment把所有节点都剪贴到内存中,然后编译内存中的文档碎片。
Vue.prototype._update = function () {
const vm = this
const el = vm.$el
// 内存中创建文档碎片,然后操作文档碎片,完成替换后替换到页面,提高性能
const node = document.createDocumentFragment()
let firstChild
while (firstChild = el.firstChild) {
// appendChild 如果元素存在将会剪贴
node.appendChild(firstChild)
}
complier(node, vm)
el.appendChild(node)
console.log(‘更新’)
}
复制代码匹配页面中的{{}}文本,替换为真实的变量的值。
如果是元素节点继续调用complier进行编译。
// (?:.|\r?\n) 任意字符或者是回车
// 非贪婪模式 {{a}} {{b}}
保证识别到是两组而不是一组
const defaultReg = /{{((?:.|\r?\n)+?)}}/g
const utils = {
getValue(vm, expr) {
const keys = expr.split(’.’)
return keys.reduce((memo, current) => {
return memo[current]
}, vm)
},
complierText(node, vm) {
// 第一次渲染时给node添加自定义属性存放模版
if (!node.expr) {
node.expr = node.textContent
}
// 替换模版中的表达式,更新到节点的textContent中
node.textContent = node.expr.replace(defaultReg, (…args) => {
return utils.getValue(vm, args[1])
})
}
}
export function complier(node, vm) {
const childNodes = node.childNodes;
// 类数组转化为数组
[…childNodes].forEach(child => {
if (child.nodeType === 1) { // 元素节点
complier(child, vm)
} else if (child.nodeType === 3) { // 文本节点
utils.complierText(child, vm)
}
})
}
复制代码🧪测试一下,页面的变量都被正确替换了。
劫持了对象和数组,能够在getter和setter自定义我们需要的操作。
实现了简单的模版解析。
那么vue是如何知道页面是否需要更新,是不是任意一组data的数据修改都要重新渲染?当然不是,仅仅是那些被页面引用了的数据变更后才需要触发视图更新,并且vue中的更新都是组件级别的,需要精确记录数据是否被引用,被谁引用,从而决定是否更新,更新谁,这就是依赖收集的意义。
整个依赖收集的过程我认为是响应式原理最复杂也是最核心的,这里先从一个简单的订阅发布模式讲起。
举个🌰:
class Dep {
constructor() {
// 存放watcher观察者/订阅者
this.subs = []
}
addSub(watcher) {
this.subs.push(watcher)
}
notify() {
this.subs.forEach(watcher => watcher.update())
}
}
const dep = new Dep()
dep.addSub({
update() {
console.log(‘订阅者1’)
}
})
dep.addSub({
update() {
console.log(‘订阅者2’)
}
})
dep.notify()
复制代码这里有两个概念:
dep发布者/订阅容器
watcher观察者/订阅者
上面代码实例化一个dep,并往这个dep添加了两个watcher,当执行dep.notify,所有的watcher都会收到广播,并且执行自身的update方法。
所以依赖收集的大体思路是为每个属性声明一个dep,在属性的getter里面调用dep.addSub(),当页面访问该属性的时候,进行依赖收集,在setter里面调用dep.notify,当属性被修改时,通知视图更新。
现在问题是dep.addSub()的时候我们到底要添加什么。
往上翻一翻,在实现$mount的时候我们提到一个渲染watcher,并且声明了一个Watcher类。
现在稍微修改一下Watcher,新增一个update方法并且在getter调用前把当前watcher实例挂到Dep.target上。
import Dep from ‘./dep’
let id = 0 // 每个watcher的标识
class Watcher {
constructor(vm, exprOrFn, cb = () => {}, opts = {}) {
this.vm = vm
this.exprOrFn = exprOrFn
if (typeof exprOrFn === ‘function’) {
this.getter = exprOrFn
}
this.cb = cb
this.opts = opts
this.id = id++
this.get()
}
get() {
Dep.target = this // 这样写实际上有问题的,后面会讲到pushTarget和popTarget。
this.getter()
}
update() {
console.log(‘watcher update’)
this.get()
}
}
复制代码然后去修改defineReactive方法,添加addSub和dep.notify()。
export function defineReactive(data, key, value) {
// …
// 给每个属性都添加一个dep
const dep = new Dep()
Object.defineProperty(data, key, {
get() {
// 取数据的时候进行依赖收集
if (Dep.target) {
dep.addSub(Dep.target)
}
// …
},
set(newValue) {
// 数据变化,通知更新视图
dep.notify()
// …
}
})
}
复制代码🧪测试一下,可以看到2秒后修改数据,页面也重新渲染了。
setTimeout(() => {
vm.msg = ‘hello,guy’
}, 2000)
复制代码到这里我们梳理一下整个代码的执行流程:
new Vue()初始化数据后,重新定义了数据的getter,setter。
然后调用$mount,初始化了一个渲染watcher, new Watcher(vm, updateComponent)。
Watcher实例化时调用get方法,把当前的渲染watcher挂在Dep.target上,然后执行updateComponent方法渲染模版。
complier解析页面的时候取值vm.msg,触发了该属性的getter,往vm.msg的dep中添加Dep.target,也就是渲染watcher。
setTimeout2秒后,修改vm.msg,该属性的dep进行广播,触发渲染watcher的update方法,页面也就重新渲染了。
2️⃣依赖收集优化–Dep.target
⚡上面实现了最基本的依赖收集,但是还有很多需要优化。
在Watcher类中的get方法直接Dep.target = this是有问题的,我们先看修改后的代码。
class Watcher {
get() {
// 往Dep添加一个target,指向当前watcher
pushTarget(this)
this.getter()
// getter执行完毕后,把当前watcher从Dep.target中剔除
popTarget()
}
}
复制代码const stack = []
export function pushTarget(watcher) {
Dep.target = watcher
stack.push(watcher)
}
export function popTarget() {
stack.pop()
Dep.target = stack[stack.length - 1]
}
复制代码在Vue中渲染和更新都是组件级别的,一个组件一个渲染watcher,考虑以下代码。
渲染父组件时,此时stack = [root渲染watcher],Dep.target指向root渲染watcher。
当解析到MyComponent组件时,此时stack = [root渲染watcher,MyComponent渲染watcher ],Dep.target指向MyComponent渲染watcher。
MyComponent渲染完毕后,popTarget执行,此时stack = [root渲染watcher],Dep.target指向root渲染watcher。
然后继续渲染父组件的其他元素渲染。
明白了整个渲染流程,维护一个watcher stack的作用就很明显了,它保证了嵌套渲染时dep能够收集到正确的watcher。
👉戳这里看这小节代码
3️⃣依赖收集优化–过滤相同的watcher
⚡接下来继续优化,考虑以下代码:
addDep(dep) {
const id = dep.id
if (!this.depIds.has(id)) {
this.depIds.add(id)
this.deps.push(dep)
dep.addSub(this)
}
}
}
复制代码然后在defineReactive就不再是dep.addSub(Dep.target)直接添加watcher了,而是调用dep.depend(),让watcher取决定是否订阅这个dep。
export function defineReactive(data, key, value) {
Object.defineProperty(data, key, {
get() {
if (Dep.target) {
dep.depend()
}
// …
}
// …
}
复制代码这样调整过后,第一次获取msg的值时会在自身的dep中添加一个watcher,同时在watcher中记录这个dep的id,第二次获取msg的时候,watcher发现已经订阅过这个dep,便不再往dep添加同一个watcher。
4️⃣依赖收集优化–数组的依赖收集
⚡上面处理了那么多的依赖收集似乎对数组并没有用,对于数组的依赖收集我们需要单独处理,因为我们触发更新是在arr.push等方法中而不是像普通属性那样在setter中。
我们首先给每个观察过的对象(包括数组)都添加一个__ob__属性,返回observe实例本身,并给每一个observe实例都添加一个dep,它是专门给数组收集依赖的。
class Observe {
constructor(data) {
Object.defineProperty(data, ‘ob’, {
get: () => this
})
// 这个dep属性专门为数组设置
this.dep = new Dep()
// ...
}
}
复制代码添加过后,我们就可以在array的方法中,获取到这个dep。
methods.forEach(method => {
newArrayProto[method] = function (…args) {
this.ob.dep.notify()
// ...
}
})
复制代码然后我们需要用这个dep去收集依赖,先看代码。
export function defineReactive(data, key, value) {
// 仅当value为数组或者对象时才有返回值,返回值是一个Observe实例
// 这个Observe实例只是一个中介,关键是dep的传递。
const obs = observe(value)
const dep = new Dep()
Object.defineProperty(data, key, {
get() {
if (Dep.target) {
dep.depend()
// 当value为数组时,实例中有一个dep属性,这个dep的notify权限给了该数组的方法
if (obs) {
obs.dep.depend()
}
}
return value
}
})
}
复制代码假设当前的data长这样。
const new Vue({
data() {
return {
arr: [1, 2, 3]
}
}
})
复制代码arr这数组经过defineReactive就有了两个dep,第一个是存放在数组身上的dep,第二个是我们为每个属性都声明的dep,当页面引用了arr这个数据后,两个dep都会去收集watcher。arr.push(1),会触发第一个dep的notify,更新页面。而arr = [0]这样赋值会触发第二dep的notify,同样也会更新页面。
👉戳这里看这小节代码
最后我们来解决嵌套数组依赖收集的问题,考虑下面的数据。
const vm = new Vue({
data() {
return {
arr: [1, 2, 3, [1, 2]]
}
}
})
复制代码当我们修改数据,vm.arr[3].push(3)并不能正确更新,原因是与vm.arr[1] = 0一样我们没有观察数组的索引。
里面的嵌套数组[1, 2],在观察的过程中没有进入到defineReactive这个函数中。
处理的方法就是,在外层arr收集依赖的同时也帮子数组收集,这里新增一个dependArray方法。
上面我们给每个观察过的对象都添加过一个__ob__,里面嵌套的数组同样有这个属性,这时候只需要取到里面的dep,depend收集一下就可以,如果里面还有数组嵌套则需要继续调用dependArray。
export function defineReactive(data, key, value) {
Object.defineProperty(data, key, {
get() {
if (Dep.target) {
dep.depend()
if (obs) {
obs.dep.depend()
// 处理嵌套数组的依赖收集
dependArray(value)
}
}
return value
}
})
}
复制代码function dependArray(value) {
for (let i = 0; i < value.length; i++) {
const item = value[i]
item.ob && item.ob.dep.depend()
if (Array.isArray(item)) {
dependArray(item)
}
}
}
复制代码到这里,依赖收集的内容基本讲完了,代码的组织也跟源码差不多了,理解了上面的所有内容,下面研究computed和$watch就非常顺利了,因为它们都是基于Watcher这个类,只是新增一些缓存或者是回调函数而已。
👉戳这里看这小节代码
5.批量更新
1️⃣异步更新
考虑下面的代码,1秒后页面需要重新渲染多少次,并且msg的值是什么?
我们希望的结果是最终只渲染一次,并且msg值为最后设置的’fee’。
但结果是虽然页面显示的数据都是最新的,但是页面重新渲染了4次。
const vm = new Vue({
data() {
return {
msg: ‘hello, world’,
obj: {
a: ‘123’
}
}
}
})
setTimeout(() => {
vm.msg = ‘bar’
vm.msg = ‘foo’
vm.msg = ‘fee’
vm.obj.a = ‘goo’
}, 1000)
复制代码现在需要把同步的更新改成异步的更新,待同步代码执行完毕后再统一更新。
class Watcher {
update() {
console.log(‘update’)
queueWatcher(this)
},
run() {
console.log(‘run’)
this.get()
}
}
复制代码单纯的改成异步更新还不行,更新次数还是没变,我们还需要合并相同的watcher。
const queueIds = new Set()
let queue = []
function flaskQueue() {
if (!queue.length) return
queue.forEach(watcher => watcher.run())
queueIds.clear()
queue = []
}
function queueWatcher(watcher) {
const id = watcher.id
if (!queueIds.has(id)) {
queueIds.add(id)
queue.push(watcher)
// TODO replace with nextTick
setTimeout(flaskQueue, 0)
}
}
复制代码这样每次收到update通知都会向队列新增一个更新任务,待同步代码执行完毕后,清空队列,最终在页面输出的结果是,打印了4次update,1次run,符合我们的预期,最终只渲染一次。
👉戳这里看这小节代码
2️⃣nextTick
当我们修改了一组数据,并且希望在视图更新完毕之后进行一些操作,这时候会用到Vue.
n
e
x
t
T
i
c
k
(
c
b
)
。
v
m
.
m
s
g
=
′
h
i
′
v
m
.
nextTick(cb)。 vm.msg = 'hi' vm.
nextTick(cb)。vm.msg=′hi′vm.nextTick(() => {
console.log(‘视图更新完毕’)
})
console.log(‘我会先执行,因为我是同步代码’)
复制代码nextTick内部同样也是维护了一个事件队列,等同步事件执行完毕后清空,就像我们上面写到的queueWatcher一样,但是内部针对浏览器的api支持程度做了一些兼容和优化。
在异步队列中,微任务的优先级更高,所以优先使用Promise而不是setTimeout,另外还有几个异步的api,它们的优先级顺序分别是:
Promise(微任务)
MutationObserver(微任务)
setImmediate(宏任务)
setTimeout(宏任务)
const callbacks = []
function flushCallbacks() {
callbacks.forEach(cb => cb())
}
export default function nextTick(cb) {
callbacks.push(cb)
const timerFunc = () => {
flushCallbacks()
}
if (Promise) {
return Promise.resolve().then(flushCallbacks)
}
if (MutationObserver) {
const observer = new MutationObserver(timerFunc)
const textNode = document.createTextNode(‘1’)
observer.observe(textNode, { characterData: true })
textNode.textContent = ‘2’
return
}
if (setImmediate) {
return setImmediate(timerFunc)
}
setTimeout(timerFunc, 0)
}
复制代码nextTick实现后把queueWatcher的setTimeout也替换一下。
function queueWatcher(watcher) {
const id = watcher.id
if (!queueIds.has(id)) {
queueIds.add(id)
queue.push(watcher)
nextTick(flaskQueue)
}
}
复制代码回顾一下开始的代码,vm.msg触发渲染watcher的update方法,会向nextTick添加一个flaskQueue任务,而用户再调用vm.
n
e
x
t
T
i
c
k
(
c
b
)
,
会
再
向
n
e
x
t
T
i
c
k
添
加
一
个
任
务
,
所
以
最
终
会
先
渲
染
页
面
然
后
打
印
视
图
更
新
完
毕
。
v
m
.
m
s
g
=
′
h
i
′
v
m
.
nextTick(cb),会再向nextTick添加一个任务,所以最终会先渲染页面然后打印视图更新完毕。 vm.msg = 'hi' vm.
nextTick(cb),会再向nextTick添加一个任务,所以最终会先渲染页面然后打印视图更新完毕。vm.msg=′hi′vm.nextTick(() => {
console.log(‘视图更新完毕’)
})
复制代码👉戳这里看这小节代码
6️.KaTeX parse error: Can't use function '\textcircled' in math mode at position 14: \html@mathml{\̲t̲e̲x̲t̲c̲i̲r̲c̲l̲e̲d̲{\scriptsize R}…watch,第二种是在选项中配置watch属性。
const vm = new Vue({
data() {
return {
msg: ‘hello’
}
},
watch: {
msg(newVal, oldVal) {
console.log({ newVal, oldVal })
}
}
})
vm.$watch(‘msg’, function(newVal, oldVal) {
console.log({ newVal, oldVal })
})
复制代码除了配置一个handler函数外,还可以配置一个对象。
vm.
w
a
t
c
h
(
′
m
s
g
′
,
h
a
n
d
l
e
r
:
f
u
n
c
t
i
o
n
(
n
e
w
V
a
l
,
o
l
d
V
a
l
)
c
o
n
s
o
l
e
.
l
o
g
(
n
e
w
V
a
l
,
o
l
d
V
a
l
)
,
i
m
m
e
d
i
a
t
e
:
t
r
u
e
)
复
制
代
码
还
可
以
配
置
成
数
组
,
这
里
就
先
不
考
虑
数
组
,
我
们
先
实
现
了
核
心
的
功
能
。
事
实
上
我
们
只
需
实
现
一
个
v
m
.
watch('msg', { handler: function(newVal, oldVal) { console.log({ newVal, oldVal }) }, immediate: true }) 复制代码还可以配置成数组,这里就先不考虑数组,我们先实现了核心的功能。 事实上我们只需实现一个vm.
watch(′msg′,handler:function(newVal,oldVal)console.log(newVal,oldVal),immediate:true)复制代码还可以配置成数组,这里就先不考虑数组,我们先实现了核心的功能。事实上我们只需实现一个vm.watch就可以,因为选项里面配置的watch内部也是调用这个方法。
$watch函数干了两件事:
useDef中分离出handler和其他参数,兼容函数和对象的配置方式。
new一个Watcher,并且增加{ user: true }标记为用户watcher。
Vue.prototype.KaTeX parse error: Can't use function '\textcircled' in math mode at position 14: \html@mathml{\̲t̲e̲x̲t̲c̲i̲r̲c̲l̲e̲d̲{\scriptsize R}…watch内部原理
⚡接下来我们看Watcher内部如何实现。
首先把传入的表达式转化为函数,例如’msg’ 转化为 utils.getValue(vm, ‘msg’)。
这一步非常关键,因为new Watcher的时候默认调用一次get方法,然后执行getter函数,这个过程会触发msg的getter,让msg的dep添加一个用户watcher,完成依赖收集。
if (typeof exprOrFn === ‘function’) {
// 之前传入的updateComponent会走这里
this.getter = exprOrFn
} else if (typeof exprOrFn === ‘string’) {
// 后面实现$watch会走这里
this.getter = function () {
return utils.getValue(vm, exprOrFn)
}
}
复制代码然后我们希望在回调函数中返回一个新值,一个旧值,所以我们需要记录getter返回的值。
class Watcher {
constructor() {
// …
this.value = this.get()
},
get() {
pushTarget(this)
const value = this.getter()
popTarget()
return value
}
}
复制代码完成了依赖收集后,当msg改变后,就会触发这个用户watcher的run方法,所以我们修改一下这个方法,执行这个watcher的cb就完事。
class Watcher {
run() {
const newValue = this.get()
// 比较新旧值,执行用户添加的handler
if (newValue !== this.value) {
this.cb(newValue, this.value)
this.value = newValue
}
}
}
复制代码到最后再简单处理一下immediate参数,它的作用是让cb开始的时候执行一次。
class Watcher {
constructor() {
// …
if (this.immediate) {
this.cb(this.value)
}
}
}
复制代码
w
a
t
c
h
这
个
方
法
实
现
后
,
遍
历
选
项
中
的
w
a
t
c
h
配
置
,
逐
个
调
用
v
m
.
watch这个方法实现后,遍历选项中的watch配置,逐个调用vm.
watch这个方法实现后,遍历选项中的watch配置,逐个调用vm.watch。
export function initState (vm) {
const opts = vm.$options
if (opts.data) {
initData(vm)
}
if (opts.watch) {
initWatch(vm)
}
}
复制代码function initWatch(vm) {
const watch = vm.KaTeX parse error: Expected '}', got 'EOF' at end of input: …ch[key] vm.watch(key, useDef)
}
}
复制代码
3️⃣dep与watcher梳理
到这里我们再梳理一下dep和watcher的关系吧,以刚才的msg为🌰,假设页面中引用了msg,并且配置了vm.$watch和选项的watch。
const vm = new Vue({
data() {
return {
msg: ‘hello’
}
},
watch: {
msg(newVal, oldVal) {
console.log(‘msg监控watcher1’)
console.log({ newVal, oldVal })
}
}
})
vm.$watch(‘msg’, function(newVal, oldVal) {
console.log(‘msg监控watcher2’)
console.log({ newVal, oldVal })
})
复制代码
此时,当msg的值更新时,页面会重新渲染并且输出msg监控watcher1,msg监控watcher2。
👉戳这里看这小节代码
👉戳这里看这小节代码
7️.computed
1️⃣一个小目标
computed有以下特点:
每个computed都是一个watcher。
computed一开始不会执行,而是被引用之后才去计算返回值。
如果依赖不变,computed会返回缓存的值。
需要把computed定义在vm上。
我们现实现一个小目标,先把下面的computed正确地渲染到页面。
// html
// js
const vm = new Vue({
data() {
return {
firstName: ‘Forrest’,
lastName: ‘Lau’
}
},
computed() {
fullName() {
return this.firstName + this.lastName
}
}
})
复制代码首先为每个computed初始化一个watcher,然后把属性定义在vm上。
function initComputed(vm) {
const computed = vm.$options.computed
for (const key in computed) {
const watcher = new Watcher(vm, computed[key], () => {}, {})
// 把计算属性定义到vm上。
Object.defineProperty(vm, key, {
get() {
return watcher.value
}
})
}
}
复制代码然后我们修改Watcher里面的get的方法,让getter的执行时的this指向vm,这样fullName方法就能够正常执行,并取出firstName和lastName计算结果后挂在watcher的value上。
class Watcher {
get() {
pushTarget(this)
const value = this.getter.call(this.vm)
popTarget()
return value
}
}
复制代码👌页面上渲染出来了ForrestLau,下一个目标。
2️⃣lazy计算
目前所有computed都在初始化的时候就执行计算,我们希望是默认开始时不去计算,等页面引用的时候才去计算,所以我们添加一个lazy配置,默认不让getter执行,然后给Watcher添加一个evaluate方法,让页面取值的时候调用evaluate去计算。
function initComputed(vm) {
const computed = vm.$options.computed
for (const key in computed) {
const watcher = new Watcher(vm, computed[key], () => {}, { lazy: true })
Object.defineProperty(vm, key, {
get() {
watcher.evaluate()
return watcher.value
}
})
}
}
复制代码class Watcher {
constructor(opts) {
// 如果是计算属性,开始时默认不会去取值
this.value = this.lazy ? undefined : this.get()
}
evaluate() {
this.value = this.get()
}
}
复制代码
3️⃣computed缓存
👌我们再设置computed的缓存,首先在Watcher增加一个dirty属性标记当前computed watcher是否需要重新计算。
dirty默认为true,没有缓存需要计算,然后在evaluate后dirty变为false,仅当依赖更新时dirty才重新变为true。
class Watcher {
constructor(opts) {
this.dirty = this.lazy
}
evaluate() {
this.value = this.get()
this.dirty = false
}
update() {
if (this.lazy) {
// 计算属性watcher更新只需要把dirty改为true
// 当获取计算属性时便会重新evaluate
this.dirty = true
} else {
queueWatcher(this)
}
}
}
复制代码然后修改计算属性的getter方法。
function initComputed(vm) {
const computed = vm.$options.computed
for (const key in computed) {
const watcher = new Watcher(vm, computed[key], () => {}, { lazy: true })
Object.defineProperty(vm, key, {
get () {
if (watcher) {
// 只有当依赖变化的时候需要重新evaluate
if (watcher.dirty) {
watcher.evaluate()
}
}
return watcher.value
}
})
}
}
复制代码
4️⃣computed的依赖收集
到这里,初始化和取值都没有问题,但是当我们去修改firstName或者lastName时,发现页面并没有更新,因为这个两个属性的dep里面只有一个computed watcher,当firstName变更时,触发fullName computed watcher的update方法,只是把dirty变更为true。
我们需要为firstName和lastName都添加一个渲染watcher,这样当它们其中一个属性变更时,首先会将dirty设置为true,然后重新渲染,过程中去取fullName的值,发现dirty为true,于是调用evaluate重新计算,整个过程应该是这样才合理。
首先我们在Watcher中新增一个depend方法。
class Watcher {
depend() {
let i = this.deps.length
while (i–) {
this.deps[i].depend()
}
}
}
复制代码在computed的getter里面调用一下,然后发现上面的问题都解决了。
function initComputed(vm) {
const computed = vm.$options.computed
for (const key in computed) {
const watcher = new Watcher(vm, computed[key], () => {}, { lazy: true })
Object.defineProperty(vm, key, {
get () {
if (watcher) {
if (watcher.dirty) {
watcher.evaluate()
}
// 新增这个就可以了
if (Dep.target) {
watcher.depend()
}
}
return watcher.value
}
})
}
}
复制代码这个过程到底发生了什么?🤒️
之前我们在写依赖收集的时候,声明了一个stack来存放watcher,下面我们来看看在各个阶段stack里面的情况和Dep.target的指向。
关键看2、3步,当进行到第二步,开始执行evaluate方法时,会调用computed watcher的get方法,在取值之前pushTarget,往stack添加了一个computed watcher,并且让Dep.target指向这个computed watcher,然后去获取firstName和lastName,取值的过程中触发它们的setter,然后往它们的dep里头添加当前watcher,也就是Dep.target即fullName computed watcher。
所以这时dep存放的watcher情况是:
firstName dep: [fullName computed watcher]
lastName dep: [fullName computed watcher]
到了第三步,evaluate计算完成后,执行popTarget,在stack中把computed watcher移除,Dep.target的指针回到渲染watcher,然后到了关键的步骤,计算完毕执行下面这段代码,这个时候会去遍历fullName computed watcher的所有dep,然后调用它们自身的depend方法,此时Dep.target指向渲染watcher,执行depend后,顺利为firstName和lastName都添加了一个渲染watcher。
// 新增这个就可以了
if (Dep.target) {
watcher.depend()
}
复制代码所以这时dep存放的watcher情况是:
firstName dep: [fullName computed watcher, 渲染watcher]
lastName dep: [fullName computed watcher, 渲染watcher]
🌹🌹到这里computed已经完整的实现了,整个响应式的原理也完成了,这里有完整代码🌹🌹