希沃ENOW大前端javascript
公司官网:CVTE(广州视源股份)html
团队:CVTE旗下将来教育希沃软件平台中心enow团队前端
本文做者:vue
关于vue响应式的文章其实已经挺多了,不过大多都在浅尝辄止,基本就是简单介绍一下Object.defineProperty
,覆盖一下setter
作个小demo
就算解决,好一点的会帮你引入observe、watcher、dep
的概念,以及加入对Array
的特殊处理,因此本篇除了上述之外,更多的重心将放在setter
引起render
的机制与流程上,而后结合这个这个响应式机制解析vue
中的watch
和computed
语法实现java
文章分为两部分,第一部分会简单介绍vue实例构建流程,第二部分则深刻探究响应式实现。react
建议对照源码阅读文章,由于不少本文不少地方会直接指出文件路径,同时将省略部分代码而直述功能express
版本信息:数组
直入主题markdown
真正的vue
实例在core/instance/index
中能够找到数据结构
function Vue (options) {
....
this._init(options) // 这个方法在initMixin中定义
}
initMixin(Vue) // 挂载_init()
stateMixin(Vue) // 挂载状态处理方法(挂载data,methods等)
eventsMixin(Vue) // 挂载 事件 的方法($on,$off等)
lifecycleMixin(Vue) // 挂载 生命周期方法(update,destory)
renderMixin(Vue) // 挂载与渲染有关的方法($nextTick,_render)
复制代码
每一个方法能够按照代码逻辑来看,实现对应功能,这里拿initMixin
举例
篇幅有限,全部此处仅解释initMixin逻辑,剩余几个方法你们能够本身探索哦
initMixin
中仅仅挂载了_init()
方法,在_init
中,初始化了整个vue
的状态:
function _init(option) {
...
vm._uid = uid++ // 即component id
...
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm)
callHook(vm, 'created')
...
if (vm.$options.el) {
vm.$mount(vm.$options.el) // 开始挂载
}
}
复制代码
这里咱们能够看到几个beforeCreate
,created
和Mount
关键字,大概就可以猜到vu
e实例的部分生命周期方法就是在这里进行了挂载,再结合 vue官方文档的图示
关于初始化整个vue
的状态,能够举例来讲,例如initLifecycle
中就赋值了parent,children
,以及一些isMounted,isDestroy
的标识符。initRender
中就将attrs,listeners
响应化,等等,诸如此类。
从initMixin=>initState=>initData
,即可以看到挂载props,methods,data,computed,watch
了,
能够看到,此处先挂载了
props,methods
,而后是data
的顺序,其实再往下探究逻辑就能够知道,若是存在变量重名,优先级是props>methods>data
的,这也就解释了为何初始化的顺序是这样安排的
在initData
中,先是获取了data
数据,判断props,methods
变量重名问题,而后是走了一个代理,将变量名代理到vue
实例上,这样的话你的vue
实例中,使用this.x
指向就能够访问到this.data.x,
这类代理也用在了props
和methods
中
在
initData
获取数据中能够看到一个判断typeof data === 'function' ? getData(data, vm) : data || {}
, 支持两种方式获取,实际上若是是本身写这样一个逻辑是会藏有隐患的,若是你的data是直接使用对象,而js的复杂数据类型是地址引用,这意味着,你实例化了两个vue
对象,实际上他们的data引用地址是同一个地址,对其中一个vue data
的修改会触发另外一个vue数据的变更,带来的问题是巨大的
export function proxy (target: Object, sourceKey: string, key: string) {
sharedPropertyDefinition.get = function proxyGetter () {
return this[sourceKey][key]
}
sharedPropertyDefinition.set = function proxySetter (val) {
this[sourceKey][key] = val
}
Object.defineProperty(target, key, sharedPropertyDefinition)
}
复制代码
这个逻辑处理的设计也是很是巧妙,他覆盖了实例中对该key的访问,使用setter
和getter
将实际访问指向了this.data[key]
。
这里能够说一下computed的逻辑,实际上也是取巧使用了本来用于data的响应式逻辑,其实看到上面贴出来的proxy代码,大概就能猜到,既然proxy可以改变一个变量读取的指向,那么他也能创造一个虚假变量的指向,这个创造出来的这个变量实际上就是computed所使用的变量,将每次computed函数赋给getter,再加上响应式处理,就彻底实现了computed,
走到最后,就是observe(data)
,也就是开始处理vue数据的双向绑定
不一样于react
的单向数据流,vue
使用的双向绑定,单向数据流能够理解为当源头的数据发生变更则触发行为,固然这个变更是主动的,即你须要setState
才能触发,而双向绑定则能够抽象为,每个数据旁边都有一个监护人(一种处理逻辑),当数据发生变化,这个监护人就会响应行为,这个流程是被动发生的,只要该数据发生变更,就会经过监护人触发行为。
若是你以前有过了解,大概就会知道,js每一个数据的变更都是经过Object
原型链中的setter
去改变值,而若是你在他改变值以前,去通知监护人,就可以实现上述的逻辑,这一点不少博客文章都写的很是清楚了。
接着第一部分的initData
知道最后observe(data)
,这里开始正式处理响应式。
前面一直提到,经过Object
的原型链改变对象的默认行为:getter
和setter
,首先咱们须要知道,在js
中,读取一个对象的值并非直接读取,而是经过Object的原型链上的默认行为getter拿到对应的值,而改变这种行为其实是经过Object.defineProperty
,来从新定义一个对象的getter
和setter
,在/src/core/observer/index.js
中咱们能够看一个defineReactive
方法,他就是vue
用来实现这种行为的方法,也是这个响应式的核心
function defineReactive(obj, key, val, ... ) {
// 此处须要保留getter、setter是由于,开发者可能本身基于defineProperty已经作过一层覆盖,
// 而响应式又会覆盖一次,因此为了保留开发者本身的行为,此处须要兼容原有的getter、setter
const getter = property && property.get // 拿到默认的getter、setter行为
const setter = property && property.set
Object.defineProperty(obj, key, {
enumerable: true, // 是否能够被枚举出来(例如Object.keys(),for in)
configurable: true, // 是否能够被配置,是否能够被删除
get: function() {
const value = getter ? getter.call(obj) : val
...
return value
}
set: function(newVal) {
...
setter.call(obj, newVal)
}
})
}
复制代码
首先,咱们猜测一下,双向绑定的行为,数据可以响应行为的变化,而行为又可以操做数据的改变,虽然有部分教程会让你站在数据的角度去理解这种行为,实际上,咱们站在行为的角度上去理解是更加方便的。
咱们将一种行为定义为一个Watcher
,他有多是一个vue
文件的template
中的dom
节点渲染行为,也有多是computed
的计算值行为,总之,咱们从行为的角度出发,一个行为的发生,会伴随着对变量的读取(回想一下咱们在vue
文件中的template
写html
标签时,老是会使用{{obj.xxx}}
来读取某个变量并渲染),咱们想要实现,变量的改变也会带动这个行为的从新渲染,是否是咱们只须要在首次行为发生的周期内,在读取某个变量时,在这个变量内记录这个Watcher
,这样的话,下次变量的改变时,我只要触发我以前记录过的Watcher
就好了。因此,咱们只须要在一个Watcher
发生时,将其挂载到一个公共变量上,这样在读取一个值的时候,记录这个公共变量,就可以实现上述操做。
这里先不解释Dep的做用,能够将其抽象理解为一个被挂载在数据上的数组,每次这个数据被一个watch读取时,就会将这个watch记录下来
既然说到将一种行为定义为一个watcher
,那么能够在/src/core/observer/watcher.js
中看到Watcher
的实体类,而咱们以前一直所说的“行为”,实际上就是构造器的第二个参数expOrFn
,能够有表达式或者函数读取的两种模式
class Watcher {
constructor ( vm: Component, // vue实例 expOrFn: string | Function, // 行为 cb: Function, // 为watch服务 options?: ?Object, isRenderWatcher?: boolean // 判断是否为渲染watcher, )
}
复制代码
接着来看一种最典型的watcher行为,在/src/core/instance/lifecycle.js
中的moundComponent
方法中,能够看到一个实例化watcher
的方法
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
复制代码
能够看到,他将updateComponent
(能够抽象为渲染行为)传给Watcher
,而在Watcher
的实例化中,将会执行此方法,固然在执行以前,pushTarget(this)
,将这个watcher挂载到公共变量上然后开始执行渲染行为,
class Watch {
constructor(...) {
....
if (typeof expOrFn === 'function') {
this.getter = expOrFn
}
this.get();
}
get() {
pushTarget(this) // 挂载行为至公共Target
value = this.getter.call(vm, vm) // 开始执行行为,之因此会有返回值是为了computed服务
popTarget() // 取消挂载,避免下次读取变量时又会绑定此行为
}
}
复制代码
此时,若是此行为读取了某个响应式变量,那么该变量的getter
将会存储公共变量target
,当行为完成后就会取消行为的挂载,这个时候咱们再回过头来看前面的defineReactive
的逻辑
function defineReactive(obj, key) {
const dep = new Dep(); // 每一个数据都有一个本身的存储列表
const getter = property && property.get
const setter = property && property.set
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) { // 判断公共变量中是否挂载了行为(watcher)
dep.depend() // 将行为(watcher)加入dep(即此变量的存储行为列表)
...
}
return value
},
set: function reactiveSetter(newVal) {
const value = getter ? getter.call(obj) : val
if (newVal === value || (newVal !== newVal && value !== value)) {
return // 判断变量没有变化,则直接返回(后二者判断则是由于NaN!==NaN的特性)
}
if (setter) {
setter.call(obj, newVal) // 开始
} else {
val = newVal
}
dep.notify() // 通知本身这个数据的存储列表,数据发生改变,须要从新执行行为(watcher)
}
});
}
复制代码
这个时候就很清晰明了了,这就是不少博客文章所说的依赖收集,变量在get时经过公共变量Target
收集依赖(也就是本文所说的行为),在set
时,即变量数据发生改变时,触发更新notify
;
前文有大体介绍computed
的实现,实际上在介绍完Wacher以后就能够来详细介绍了,计算属性computed
并无实际的变量,他经过原型链覆盖创造了一个变量指向(src/core/instance/state.js
的initComputed
),回忆一下computed的两种写法
'fullName': function() {
return this.firstName + this.secondeName;
}
'fullName': {
get: function () {...},
set: function() {...},
}
复制代码
咱们再来看一下initComputed
function initComputed (vm: Component, computed: Object) {
const watchers = vm._computedWatchers = Object.create(null)
for (const key in computed) {
const userDef = computed[key]
// 对照着computed的两种写法,就能理解为何这里有这样的判断,
const getter = typeof userDef === 'function' ? userDef : userDef.get
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
{ lazy: true }
)
defineComputed(vm, key, userDef) // 经过defineProperty来创造一个挂载在vm上key(fullName)的指向
}
}
复制代码
能够看到,他将computed
的getter
方法,做为Watcher
的行为传递了进去,这样在执行getter
时,能够将此行为绑定至过程当中所读取到的变量(firstName
),如此,再下次firstName发生改变时,就会触发此Watcher
,从新运行getter方法,获得一个新的fullName
的值(还记得前文class Watch
中的value = this.getter.call(vm, vm)
吗?这个返回值就是computed
的返回值),这样就实现了computed
的逻辑
watch
的用法,是监听某个变量,当该变量发生变化时,执行特定的逻辑,
上文提到的两种Watcher
行为都是函数行为,可是Watcher
的行为是支持函数或者表达式的(expOrFn
),因此此处的exp(expression)
这里就是能够提现到的,咱们只须要在变量发生变化时,执行watch
定义的逻辑便可,
还记得前文代码defineReactive
中 set
方法通知依赖更新(dep.notify()
),虽然前文一直为了方便理解,将Dep描述为一种抽象的列表结构,仅用于依赖收集,但实际上他是一个单独的数据结构,
let uid = 0;
class Dep {
constructor() {
this.id = uid ++;
this.subs = []; // 真正用于收集依赖的数据
}
depend () { // 依赖收集
if (Dep.target) {
Dep.target.addDep(this)
}
}
addSub (sub: Watcher) {
this.subs.push(sub)
}
notify() { // 变量值发生变化,通知更新
// 遍历全部收集的依赖,注意触发更新,
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
...
}
Dep.target = null; // 这就是一直说的,用于挂载Watcher行为的公共变量
function pushTarget(target){ Dep.target = target };
function popTarget() { Dep.target = null };
复制代码
实际上这里的静态变量
target
以及pushTarget、popTarget
是通过简化的,由于渲染并非一个单一的行为,他是层层嵌套的行为,因此在绑定响应式时,也是须要区分该变量究竟是要绑定至哪一个行为(不然每一个变量都绑定最顶层的行为,一个变量的变化,将会引起整个页面的update
),所以真正的target是还有一个stack
栈结构,用于挂载多个嵌套的行为
能够看到,每次变量更新,都会触发watcher.update
,那么对于watch
监听的回调,就能够放到在update
中调用
class Watch {
constructor(vm, expOrFn, cb, ...) {
this.cb = cb // 这个cb就是watch监听的回调
}
update() {
this.run()
}
run() {
...
this.cb.call(this.vm, ...)
}
}
复制代码
至此,关于watch
监听的实现逻辑大体就是如此
关于依赖收集,实际上并非在get变量时,直接将
watcher
绑定至Dep
中,能够看到Dep.depend()
,他先通知行为(watcher
),叫他先绑定本身,而后watcher绑定完dep以后,才会回过头,告知Dep
要addSub()
,这里的逻辑像是一个圈
因此如今咱们回过头来看,前文说了,每一个数据都有一个“监护人”,来记录此数据所绑定的行为,那么这个“监护人”到底在哪里呢? 能够看到/src/core/observer/index.js
的class Observer
中,
class Observer {
constructor(val) {
...
def(value, '__ob__', this) // 对value定义__ob__属性,挂载此object
...
}
}
复制代码
对于每一份须要响应式处理的数据,都会挂载一个
Observer
实例,其内subs就是用于记录绑定此数据的Watcher
,同时也能够看到,这份数据的get、set
方法已是被重写过了,也就是前文的defineReactive
中的覆盖行为。
其实对于Array
的响应式是须要特殊处理的,由于他除了set、get
以外,还会对数组进行增减操做(splice
等),而这些操做是set没法捕捉的,因此覆盖get、set
显然没法实现数组的响应式,而vue
中采用的是直接覆盖数组的原型链中会对数据自己改变的方法(push、shift、splice
等),/src/core/observer/array.js
整个文件就是对数据的特殊处理 最新的vue3
中,使用了ES6
的proxy
特性来替代这种覆盖set、get
实现响应式行为,这种模式同时也可以处理Array
。
vue
的源码固然没有如此简单,不少东西文章都没有涉及到,譬如说,经过上面的逻辑其实你能够发现,dep
和watcher
实际上是互相引用的,而js
的垃圾回收是检测变量引用的机制,因此若是是简单的复制上文的逻辑,最终的这部分的内存实际上是没法被回收的,须要你手动清除,固然vue
中也作了这样的处理(每一个vm
下其实有一个watcherList
,用于记录这个示例中全部使用到的watcher
,再vm.destroy
时,经过遍历watcherList
,再销毁每个watcher
,而watcher
中又会本身销毁Dep
),可是限于篇幅缘由没法详细介绍了。