June 01, 2019

动手实现简单版 vue 计算属性computed

在使用 vue 的时候,了解到计算属性很好用,可以延迟计算直到调用才返回真实的数据,而且计算属性依赖的值没有发生改变的情况,就不会重新执行函数计算;比较好奇是怎么实现的,但是没有去了解原理性相关,最近去看一下源码实现,大概直到具体的实现。下面就是根据自己的了解,手动实现一个简单的计算属性:

思考

我们知道 vue2.x 是基于Object.defineProperty来劫持数据的,那么挂载到vm.data的属性值就很好理解,在gettersetter的函数里面做一层简单的代理,那么计算属性为啥可以从一个函数变成一个数值,而且可以知道依赖的数据值?大概是因为计算属性的函数执行的时候,会触发到data属性的getter,那么我们就可以在这里做手脚,就知道当前的计算属性依赖了多少data数据了。

v1.0

我们来看一段的代码,声明datacomputed数据,劫持data数据方法,初始化计算属性方法等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// data 数据
var data = {
    foo: 123,
    bar: 'bar'
}

// data 的代理对象
var _data = {}

// 计算属性数据
var computedData = {
    fooMap () {
        return data.foo + 1
    },
    barMap () {
        return data.bar + ' baz'
    }
}

// 是否在收集数据
var isDep = false
// 当前收集的回调函数
var notify

// foo 的回调函数列表
var fooNotify = []

// 回调函数对应的字段
var notifyProp


// 劫持数据方法
function defineProperty (obj) {
    for (let key in obj) {
        // 缓存原有的数据
        _data[key] = obj[key]

        Object.defineProperty(obj, key, {
            get () {
                // 判断当前调用方法是否在收集当中
                if (isDep) {
                    // 计算属性对应的方法与计算属性对应的key值加入到缓存
                    fooNotify.push([notify, notifyProp])
                }
                return _data[key]
            },
            set (value) {
                // 更改缓存的数值
                _data[key] = value
                // 计算属性对应的方法重新计算,重新赋值
                fooNotify.forEach(item => {
                    computedData[item[1]] = item[0]()
                })
            }
        })
    }
    return obj
}

// 初始化计算属性
function initComputed (computed) {
    // 依赖收集开始
    isDep = true

    for (let key in computed) {
        let method = computed[key]

        // 把当前的计算属性方法赋值到全局变量
        notify = method
        notifyProp = key
        // 通过函数计算获取数据,获得计算属性的值
        computed[key] = method()
    }

    // 依赖收集结束
    isDep = false
}

定义好方法与数据,我们可以尝试着使用:

1
2
3
4
5
6
7
8
// 1. 劫持数据
defineProperty(data)
// 2. 初始化计算属性
initComputed(computedData) 
// 执行完这一步,computedData 的数据就变成了:{ fooMap: 124, barMap: 'bar baz' }
// 3. 更改 data.foo 的值
data.foo = 1234
// 执行完这一步,computedData 为: {fooMap: 1235, barMap: 'bar baz'}

从上面的结果得到,可以实现计算属性一个很重要的一个特点:依赖数据发生改变,则计算属性发生改变;但是缺点也是很明显的,变量都是全局变量;依赖数据发生改变的回调的方法也是放到全局的数组;我们在接下来的v2版本修好这种情况。现在我们大概看到computeddata的观察者关系:

v2.0

这一个版本我们主要 fix 部分全局变量,把计算属性与数据归类到同一个对象,这个版本改动不多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// 是否在收集数据
var isDep = false
// 当前收集的回调函数
var notify

// foo 的回调函数列表
var fooNotify = []

// 回调函数对应的字段
var notifyProp

var instance = {
    data: {
        foo: 123,
        bar: 'bar'
    },
    computed: {
        fooMap () {
            return instance.data.foo + 1
        },
        barMap () {
            return instance.data.bar + ' baz'
        }
    }
}

// 劫持对象
function defineProperty (vm) {
    let data = vm.data

    vm._data = {}

    for (let key in data) {
        // 缓存原有的数据
        vm._data[key] = data[key]

        Object.defineProperty(data, key, {
            get () {
                if (isDep) {
                    fooNotify.push([notify, notifyProp])
                }
                return vm._data[key]
            },
            set (value) {
                vm._data[key] = value
                fooNotify.forEach(item => {
                    vm.computed[item[1]] = item[0]()
                })
            }
        })
    }
    return data
}

// 初始化计算属性
function initComputed (vm) {
    // 依赖收集开始
    isDep = true

    for (let key in vm.computed) {
        let method = vm.computed[key]

        notify = method
        notifyProp = key
        // 通过函数计算获取数据
        vm.computed[key] = method()
    }

    // 依赖收集结束
    isDep = false
}

// 劫持 data 数据
defineProperty(instance)
// 初始化计算属性
initComputed(instance)

在完成v2.0版本之后,我们把计算属性与数据合成到一个对象;但是只能实现一个计算属性的应用,如果有多个计算属性的话,就控制不了,因为存放计算属性的数组只有一个,在v3版本,需要处理这种情况。

v3.0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
// 观察者列表
class ObserverList {
    constructor () {
        this.list = []
    }
    add (item) {
        this.list.push(item)
    }
    count () {
        return this.list.length
    }
    getByIndex (index) {
        return this.list[index]
    }
}

// 被观察者
class Watcher {
    constructor () {
        this.observer = new ObserverList()
    }
    addObserver (observer) {
        this.observer.add(observer)
    }
    notify () {
        let len = this.observer.count()

        for (let i = 0; i < len; i++) {
            this.observer.getByIndex(i).update()
        }
    }
}

// 观察者
class Observer {
    constructor (update) {
        this.update = update
    }
}

// 数据依赖
class Dep {}

Dep.target = ''

// 劫持对象
function defineProperty (vm) {
    let data = vm.data

    vm._data = {}

    for (let key in data) {
        // 缓存原有的数据
        vm._data[key] = data[key]

        Object.defineProperty(data, key, {
            get () {
                // 只有在依赖收集的时候,才需要添加 watcher,普通数据调用不需要添加 watcher
                if (Dep.target) {
                    // 当前数据字段还没有 watcher ,则新建一个
                    let watcher = vm._watcher[key] || new Watcher()

                    // 加入到依赖数组
                    watcher.addObserver(Dep.target)
                    vm._watcher[key] = watcher
                }
                return vm._data[key]
            },
            set (value) {
                vm._data[key] = value

                // 有对应的 watcher 那么则提示更新,调用watcher的notify方法
                if (vm._watcher[key]) {
                    vm._watcher[key].notify()
                }
            }
        })
    }
    return data
}

// 初始化计算属性
function initComputed (vm) {
    vm._watcher = {}

    for (let key in vm.computed) {
        let method = vm.computed[key]

        // 新建 observer,并标识收集依赖开始
        Dep.target = new Observer(() => {
            vm.computed[key] = method()
        })

        // 通过函数初始化计算数据,并且获取到所有依赖
        vm.computed[key] = method()
        // 依赖收集结束
        Dep.target = undefined
    }
}

var instance = {
    data: {
        foo: 123,
        bar: 'bar',
        baz: 'bazbaz'
    },
    computed: {
        fooMap () {
            let num = instance.data.foo + 1
            let str = instance.data.bar + '...str'

            return num + str
        },
        barMap () {
            return instance.data.bar + ' baz'
        }
    }
}

// 劫持 data 数据
defineProperty(instance)
// 初始化计算属性
initComputed(instance)

// 验证处理:
console.log(instance.computed.fooMap) // 124bar...str
console.log(instance.computed.barMap) // bar baz
// 赋值数据
instance.data.bar = 'new bar value'
console.log(instance.computed.fooMap) // 124new bar value...str
console.log(instance.computed.barMap) // new bar value baz  
// done.

这个版本更改的地方比较多,加入观察者模式的处理;

在初始化计算属性的时候,为每个计算属性新建一个观察者,新建一个观察者传入的参数是一个函数,这个函数会在依赖的数据发生改变的时候执行;函数的内容就是为计算属性的值重新计算:

1
2
3
4
 // 新建 observer,并标识收集依赖开始
Dep.target = new Observer(() => {
    vm.computed[key] = method() // method 是指计算属性对应的方法
})

在劫持数据的时候,数据的get触发的时候,如果是在依赖收集的过程中(也就是数据被计算属性调用),那么就会为这个数据添加watcher;并且把当前正在收集依赖的计算属性对应的observer实例加入到watcher中

1
2
3
4
5
6
7
8
if (Dep.target) {
    // 当前数据字段还没有 watcher ,则新建一个
    let watcher = vm._watcher[key] || new Watcher()

    // 加入到依赖数组
    watcher.addObserver(Dep.target)
    vm._watcher[key] = watcher
}

数据的set触发的时候,那么就需要通知对应观察者,计算属性对应的值就可以更新。

1
2
3
4
// 有对应的 watcher 那么则提示更新,调用watcher的notify方法
if (vm._watcher[key]) {
    vm._watcher[key].notify()
}

通过引入观察者的类别,处理多个计算属性;现在我们基本完善好v1.0版本全局变量的问题;除此之外,计算属性也有一个比较重要的特点是:惰性求值。当没有调用计算属性的时候,是不会触发计算;而且如果单个计算属性调用数据多次的时候,会存在watcher添加多次observer,这些下一个版本继续增加或优化。

v4.0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
// 观察者列表
class ObserverList {
    constructor () {
        this.list = []
    }
    add (item) {
        this.list.push(item)
    }
    count () {
        return this.list.length
    }
    getByIndex (index) {
        return this.list[index]
    }
}

// 被观察者
class Watcher {
    constructor () {
        this.dep = new Set()
        this.observer = new ObserverList()
    }
    addObserver (observer) {
        // 已经加入了到依赖,返回,不做处理
        if (!this.dep.has(observer.id)) {
            this.observer.add(observer)
            this.dep.add(observer.id)
        }
    }
    notify () {
        let len = this.observer.count()

        for (let i = 0; i < len; i++) {
            let ob = this.observer.getByIndex(i)
            ob.dirty = true
            ob.update()
        }
    }
}

// 观察者
let _uid = 0
class Observer {
    constructor (update) {
        this.id = _uid++
        this.update = update
    }
}

// 数据依赖
class Dep {}

Dep.target = ''

// 劫持对象
function defineProperty (vm) {
    let data = vm.data

    vm._data = {}
    vm._watcher = {}

    for (let key in data) {
        // 缓存原有的数据
        vm._data[key] = data[key]

        Object.defineProperty(data, key, {
            get () {
                if (Dep.target) {
                    // 当前数据字段还没有 watcher ,则新建一个
                    let watcher = vm._watcher[key] || new Watcher()

                    // 加入到依赖数组
                    watcher.addObserver(Dep.target)
                    vm._watcher[key] = watcher
                }
                return vm._data[key]
            },
            set (value) {
                vm._data[key] = value

                // 有 watcher 那么则提示更新
                if (vm._watcher[key]) {
                    vm._watcher[key].notify()
                }
            }
        })
    }
    return data
}

// 初始化计算属性
function initComputed (vm) {
    vm._computedWatcher = {}

    for (let key in vm.computed) {
        let method = vm.computed[key]

        vm._computedWatcher[key] = {
            dirty: true,
            value: undefined,
            getter: method,
            // 这个属性的观察者
            ob: undefined
        }

        Object.defineProperty(vm.computed, key, {
            get () {
                let cache = vm._computedWatcher[key]

                if (!cache.dirty) {
                    return cache.value
                } else {
                    // 该属性没有指定的观察者,则新建
                    if (!cache.ob) {
                        // 新建 observer,并标识收集依赖开始
                        Dep.target = cache.ob = new Observer(() => {
                            cache.value = cache.getter()
                        })
                    }
                    cache.dirty = false
                    cache.value = cache.getter()
                }

                console.log('calc new cache')
                return cache.value
            }
        })
        // 通过函数初始化计算数据,并且获取到所有依赖
        vm.computed[key] = method()
        // 依赖收集结束
        Dep.target = undefined
    }
}

var instance = {
    data: {
        foo: 123,
        bar: 'bar',
        baz: 'bazbaz'
    },
    computed: {
        fooMap () {
            let num = instance.data.foo + 1
            let str = instance.data.bar + '...str'

            return num + str
        },
        barMap () {
            return instance.data.bar + ' baz'
        }
    }
}

在这一个版本,主要新增了,vm._computedWatcher,缓存每一个计算属性的一些记录,结构如下:

1
2
3
4
5
6
vm._computedWatcher[key] = {
    dirty: true, // 表示当前数据是否为“脏”,当为“脏”的时候,则需要重新计算
    value: undefined, // 缓存计算属性的返回值
    getter: method, // 计算属性对应的计算方法
    ob: undefined // 这个属性的观察者
}

dirtytrue的情况主要是两种,一种初始化的时候,另外一个种是依赖的数据已经发生了改变。为了验证这种情况,我们在计算属性的get方法打log,如果被调用的时候就会log出来:

1
2
3
4
5
6
7
8
9
// 劫持 data 数据
defineProperty(instance)
// 初始化计算属性
initComputed(instance) // 这个时候并没有 log:calc new cache

// 获取计算属性
instance.computed.fooMap
// calc new cache
// return 124bar...str

由此可以看出,惰性求值是可以的。另外可以注意到为每个观察者的实例添加一个id,在watcher添加观察者的时候判断观察者列表是否已经包含当前观察者,可以实现简单的观察者去重。

总结

至此,一个简单的计算属性就可以实现起来,虽然使用起来与vue有区别,例如数据与计算属性都挂载到vm对象;并且例子的健壮性也需要提高,没有考虑到一些特殊的情况,例如如何监听数组的变化,这些也需要实现;还有一些例如sync特性没有实现;但是大部分常用功能都能够实现,而且思路上理解清晰就完成了部分任务;这个时候再去看 vue 的源码应该会理解起来更加快。end.