从这一小节开始,正式进入
Vue
源码的核心,也是难点之一,响应式系统的构建。这一节将做为分析响应式构建过程源码的入门,主要分为两大块,第一块是针对响应式数据props,methods,data,computed,wather
初始化过程的分析,另外一块则是在保留源码设计理念的前提下,尝试手动构建一个基础的响应式系统。有了这两个基础内容的铺垫,下一篇进行源码具体细节的分析会更加驾轻就熟。vue
回顾一下以前的内容,咱们对Vue
源码的分析是从初始化开始,初始化_init
会执行一系列的过程,这个过程包括了配置选项的合并,数据的监测代理,最后才是实例的挂载。而在实例挂载前还有意忽略了一个重要的过程,数据的初始化(即initState(vm)
)。initState
的过程,是对数据进行响应式设计的过程,过程会针对props,methods,data,computed
和watch
作数据的初始化处理,并将他们转换为响应式对象,接下来咱们会逐步分析每个过程。node
function initState (vm) {
vm._watchers = [];
var opts = vm.$options;
// 初始化props
if (opts.props) { initProps(vm, opts.props); }
// 初始化methods
if (opts.methods) { initMethods(vm, opts.methods); }
// 初始化data
if (opts.data) {
initData(vm);
} else {
// 若是没有定义data,则建立一个空对象,并设置为响应式
observe(vm._data = {}, true /* asRootData */);
}
// 初始化computed
if (opts.computed) { initComputed(vm, opts.computed); }
// 初始化watch
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch);
}
}
复制代码
简单回顾一下props
的用法,父组件经过属性的形式将数据传递给子组件,子组件经过props
属性接收父组件传递的值。算法
// 父组件
<child :test="test"></child>
var vm = new Vue({
el: '#app',
data() {
return {
test: 'child'
}
}
})
// 子组件
Vue.component('child', {
template: '<div>{{test}}</div>',
props: ['test']
})
复制代码
所以分析props
须要分析父组件和子组件的两个过程,咱们先看父组件对传递值的处理。按照以往文章介绍的那样,父组件优先进行模板编译获得一个render
函数,在解析过程当中遇到子组件的属性,:test=test
会被解析成{ attrs: {test: test}}
并做为子组件的render
函数存在,以下所示:数组
with(){..._c('child',{attrs:{"test":test}})}
复制代码
render
解析Vnode
的过程遇到child
这个子占位符节点,所以会进入建立子组件Vnode
的过程,建立子Vnode
过程是调用createComponent
,这个阶段咱们在组件章节有分析过,在组件的高级用法也有分析过,最终会调用new Vnode
去建立子Vnode
。而对于props
的处理,extractPropsFromVNodeData
会对attrs
属性进行规范校验后,最后会把校验后的结果以propsData
属性的形式传入Vnode
构造器中。总结来讲,props
传递给占位符组件的写法,会以propsData
的形式做为子组件Vnode
的属性存在。下面会分析具体的细节。浏览器
// 建立子组件过程
function createComponent() {
// props校验
var propsData = extractPropsFromVNodeData(data, Ctor, tag);
···
// 建立子组件vnode
var vnode = new VNode(
("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')),
data, undefined, undefined, undefined, context,
{ Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },
asyncFactory
);
}
复制代码
先看检测props
规范性的过程。**props
编译后的结果有两种,其中attrs
前面分析过,是编译生成render
函数针对属性的处理,而props
是针对用户自写render
函数的属性值。**所以须要同时对这两种方式进行校验。app
function extractPropsFromVNodeData (data,Ctor,tag) {
// Ctor为子类构造器
···
var res = {};
// 子组件props选项
var propOptions = Ctor.options.props;
// data.attrs针对编译生成的render函数,data.props针对用户自定义的render函数
var attrs = data.attrs;
var props = data.props;
if (isDef(attrs) || isDef(props)) {
for (var key in propOptions) {
// aB 形式转成 a-b
var altKey = hyphenate(key);
{
var keyInLowerCase = key.toLowerCase();
if (
key !== keyInLowerCase &&
attrs && hasOwn(attrs, keyInLowerCase)
) {
// 警告
}
}
}
}
}
复制代码
重点说一下源码在这一部分的处理,HTML对大小写是不敏感的,全部的浏览器会把大写字符解释为小写字符,所以咱们在使用DOM
中的模板时,cameCase(驼峰命名法)的props
名须要使用其等价的 kebab-case
(短横线分隔命名) 命代替。 即: <child :aB="test"></child>
须要写成<child :a-b="test"></child>
框架
刚才说到分析props
须要两个过程,前面已经针对父组件对props
的处理作了描述,而对于子组件而言,咱们是经过props
选项去接收父组件传递的值。咱们再看看子组件对props
的处理:async
子组件处理props
的过程,是发生在父组件_update
阶段,这个阶段是Vnode
生成真实节点的过程,期间会遇到子Vnode
,这时会调用createComponent
去实例化子组件。而实例化子组件的过程又回到了_init
初始化,此时又会经历选项的合并,针对props
选项,最终会统一成{props: { test: { type: null }}}
的写法。接着会调用initProps
, initProps
作的事情,简单归纳一句话就是,将组件的props
数据设置为响应式数据。函数
function initProps (vm, propsOptions) {
var propsData = vm.$options.propsData || {};
var loop = function(key) {
···
defineReactive(props,key,value,cb);
if (!(key in vm)) {
proxy(vm, "_props", key);
}
}
// 遍历props,执行loop设置为响应式数据。
for (var key in propsOptions) loop( key );
}
复制代码
其中proxy(vm, "_props", key);
为props
作了一层代理,用户经过vm.XXX
能够代理访问到vm._props
上的值。针对defineReactive
,本质上是利用Object.defineProperty
对数据的getter,setter
方法进行重写,具体的原理能够参考数据代理章节的内容,在这小节后半段也会有一个基本的实现。oop
initMethod
方法和这一节介绍的响应式没有任何的关系,他的实现也相对简单,主要是保证methods
方法定义必须是函数,且命名不能和props
重复,最终会将定义的方法都挂载到根实例上。
function initMethods (vm, methods) {
var props = vm.$options.props;
for (var key in methods) {
{
// method必须为函数形式
if (typeof methods[key] !== 'function') {
warn(
"Method \"" + key + "\" has type \"" + (typeof methods[key]) + "\" in the component definition. " +
"Did you reference the function correctly?",
vm
);
}
// methods方法名不能和props重复
if (props && hasOwn(props, key)) {
warn(
("Method \"" + key + "\" has already been defined as a prop."),
vm
);
}
// 不能以_ or $.这些Vue保留标志开头
if ((key in vm) && isReserved(key)) {
warn(
"Method \"" + key + "\" conflicts with an existing Vue instance method. " +
"Avoid defining component methods that start with _ or $."
);
}
}
// 直接挂载到实例的属性上,能够经过vm[method]访问。
vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm);
}
}
复制代码
data
在初始化选项合并时会生成一个函数,只有在执行函数时才会返回真正的数据,因此initData
方法会先执行拿到组件的data
数据,而且会对对象每一个属性的命名进行校验,保证不能和props,methods
重复。最后的核心方法是observe
,observe
方法是将数据对象标记为响应式对象,并对对象的每一个属性进行响应式处理。与此同时,和props
的代理处理方式同样,proxy
会对data
作一层代理,直接经过vm.XXX
能够代理访问到vm._data
上挂载的对象属性。
function initData(vm) {
var data = vm.$options.data;
// 根实例时,data是一个对象,子组件的data是一个函数,其中getData会调用函数返回data对象
data = vm._data = typeof data === 'function'? getData(data, vm): data || {};
var keys = Object.keys(data);
var props = vm.$options.props;
var methods = vm.$options.methods;
var i = keys.length;
while (i--) {
var key = keys[i];
{
// 命名不能和方法重复
if (methods && hasOwn(methods, key)) {
warn(("Method \"" + key + "\" has already been defined as a data property."),vm);
}
}
// 命名不能和props重复
if (props && hasOwn(props, key)) {
warn("The data property \"" + key + "\" is already declared as a prop. " + "Use prop default value instead.",vm);
} else if (!isReserved(key)) {
// 数据代理,用户可直接经过vm实例返回data数据
proxy(vm, "_data", key);
}
}
// observe data
observe(data, true /* asRootData */);
}
复制代码
最后讲讲observe
,observe
具体的行为是将数据对象添加一个不可枚举的属性__ob__
,标志对象是一个响应式对象,而且拿到每一个对象的属性值,重写getter,setter
方法,使得每一个属性值都是响应式数据。详细的代码咱们后面分析。
和上面的分析方法同样,initComputed
是computed
数据的初始化,不一样之处在于如下几点:
computed
能够是对象,也能够是函数,可是对象必须有getter
方法,所以若是computed
中的属性值是对象时须要进行验证。computed
的每一个属性,要建立一个监听的依赖,也就是实例化一个watcher
,watcher
的定义,能够暂时理解为数据使用的依赖自己,一个watcher
实例表明多了一个须要被监听的数据依赖。除了不一样点,initComputed
也会将每一个属性设置成响应式的数据,一样的,也会对computed
的命名作检测,防止与props,data
冲突。
function initComputed (vm, computed) {
···
for (var key in computed) {
var userDef = computed[key];
var getter = typeof userDef === 'function' ? userDef : userDef.get;
// computed属性为对象时,要保证有getter方法
if (getter == null) {
warn(("Getter is missing for computed property \"" + key + "\"."),vm);
}
if (!isSSR) {
// 建立computed watcher
watchers[key] = new Watcher(vm,getter || noop,noop,computedWatcherOptions);
}
if (!(key in vm)) {
// 设置为响应式数据
defineComputed(vm, key, userDef);
} else {
// 不能和props,data命名冲突
if (key in vm.$data) {
warn(("The computed property \"" + key + "\" is already defined in data."), vm);
} else if (vm.$options.props && key in vm.$options.props) {
warn(("The computed property \"" + key + "\" is already defined as a prop."), vm);
}
}
}
}
复制代码
显然Vue
提供了不少种数据供开发者使用,可是分析完后发现每一个处理的核心都是将数据转化成响应式数据,有了响应式数据,如何构建一个响应式系统呢?前面提到的watcher
又是什么东西?构建响应式系统还须要其余的东西吗?接下来咱们尝试着去实现一个极简风的响应式系统。
Vue
的响应式系统构建是比较复杂的,直接进入源码分析构建的每个流程会让理解变得困难,所以我以为在尽量保留源码的设计逻辑下,用最小的代码构建一个最基础的响应式系统是有必要的。对Dep,Watcher,Observer
概念的初步认识,也有助于下一篇对响应式系统设计细节的分析。
咱们以MyVue
做为类响应式框架,框架的搭建不作赘述。咱们模拟Vue
源码的实现思路,实例化MyVue
时会传递一个选项配置,精简的代码只有一个id
挂载元素和一个数据对象data
。模拟源码的思路,咱们在实例化时会先进行数据的初始化,这一步就是响应式的构建,咱们稍后分析。数据初始化后开始进行真实DOM
的挂载。
var vm = new MyVue({
id: '#app',
data: {
test: 12
}
})
// myVue.js
(function(global) {
class MyVue {
constructor(options) {
this.options = options;
// 数据的初始化
this.initData(options);
let el = this.options.id;
// 实例的挂载
this.$mount(el);
}
initData(options) {
}
$mount(el) {
}
}
}(window))
复制代码
首先引入一个类Observer
,这个类的目的是将数据变成响应式对象,利用Object.defineProperty
对数据的getter,setter
方法进行改写。在数据读取getter
阶段咱们会进行依赖的收集,在数据的修改setter
阶段,咱们会进行依赖的更新(这两个概念的介绍放在后面)。所以在数据初始化阶段,咱们会利用Observer
这个类将数据对象修改成相应式对象,而这是全部流程的基础。
class MyVue {
initData(options) {
if(!options.data) return;
this.data = options.data;
// 将数据重置getter,setter方法
new Observer(options.data);
}
}
// Observer类的定义
class Observer {
constructor(data) {
// 实例化时执行walk方法对每一个数据属性重写getter,setter方法
this.walk(data)
}
walk(obj) {
const keys = Object.keys(obj);
for(let i = 0;i< keys.length; i++) {
// Object.defineProperty的处理逻辑
defineReactive(obj, keys[i])
}
}
}
复制代码
咱们能够这样理解,一个Watcher
实例就是一个依赖,数据不论是在渲染模板时使用仍是在用户计算时使用,均可以算作一个须要监听的依赖,watcher
中记录着这个依赖监听的状态,以及如何更新操做的方法。
// 监听的依赖
class Watcher {
constructor(expOrFn, isRenderWatcher) {
this.getter = expOrFn;
// Watcher.prototype.get的调用会进行状态的更新。
this.get();
}
get() {}
}
复制代码
那么哪一个时间点会实例化watcher
并更新数据状态呢?显然在渲染数据到真实DOM
时能够建立watcher
。$mount
流程前面章节介绍过,会经历模板生成render
函数和render
函数渲染真实DOM
的过程。咱们对代码作了精简,updateView
浓缩了这一过程。
class MyVue {
$mount(el) {
// 直接改写innerHTML
const updateView = _ => {
let innerHtml = document.querySelector(el).innerHTML;
let key = innerHtml.match(/{(\w+)}/)[1];
document.querySelector(el).innerHTML = this.options.data[key]
}
// 建立一个渲染的依赖。
new Watcher(updateView, true)
}
}
复制代码
watcher
若是理解为每一个数据须要监听的依赖,那么Dep
能够理解为对依赖的一种管理。数据能够在渲染中使用,也能够在计算属性中使用。相应的每一个数据对应的watcher
也有不少。而咱们在更新数据时,如何通知到数据相关的每个依赖,这就须要Dep
进行通知管理了。而且浏览器同一时间只能更新一个watcher
,因此也须要一个属性去记录当前更新的watcher
。而Dep
这个类只须要作两件事情,将依赖进行收集,派发依赖进行更新。
let uid = 0;
class Dep {
constructor() {
this.id = uid++;
this.subs = []
}
// 依赖收集
depend() {
if(Dep.target) {
// Dep.target是当前的watcher,将当前的依赖推到subs中
this.subs.push(Dep.target)
}
}
// 派发更新
notify() {
const subs = this.subs.slice();
for (var i = 0, l = subs.length; i < l; i++) {
// 遍历dep中的依赖,对每一个依赖执行更新操做
subs[i].update();
}
}
}
Dep.target = null;
复制代码
咱们看看数据拦截的过程。前面的Observer
实例化最终会调用defineReactive
重写getter,setter
方法。这个方法开始会实例化一个Dep
,也就是建立一个数据的依赖管理。在重写的getter
方法中会进行依赖的收集,也就是调用dep.depend
的方法。在setter
阶段,比较两个数不一样后,会调用依赖的派发更新。即dep.notify
const defineReactive = (obj, key) => {
const dep = new Dep();
const property = Object.getOwnPropertyDescriptor(obj);
let val = obj[key]
if(property && property.configurable === false) return;
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
get() {
// 作依赖的收集
if(Dep.target) {
dep.depend()
}
return val
},
set(nval) {
if(nval === val) return
// 派发更新
val = nval
dep.notify();
}
})
}
复制代码
回过头来看watcher
,实例化watcher
时会将Dep.target
设置为当前的watcher
,执行完状态更新函数以后,再将Dep.target
置空。这样在收集依赖时只要将Dep.target
当前的watcher push
到Dep
的subs
数组便可。而在派发更新阶段也只须要从新更新状态便可。
class Watcher {
constructor(expOrFn, isRenderWatcher) {
this.getter = expOrFn;
// Watcher.prototype.get的调用会进行状态的更新。
this.get();
}
get() {
// 当前执行的watcher
Dep.target = this
this.getter()
Dep.target = null;
}
update() {
this.get()
}
}
复制代码
一个极简的响应式系统搭建完成。在精简代码的同时,保持了源码设计的思想和逻辑。有了这一步的基础,接下来深刻分析源码中每一个环节的实现细节会更加简单。
这一节内容,咱们正式进入响应式系统的介绍,前面在数据代理章节,咱们学过Object.defineProperty
,这是一个用来进行数据拦截的方法,而响应式系统构建的基础就是数据的拦截。咱们先介绍了Vue
内部在初始化数据的过程,最终得出的结论是,不论是data,computed
,仍是其余的用户定义数据,最终都是调用Object.defineProperty
进行数据拦截。而文章的最后,咱们在保留源码设计思想和逻辑的前提下,构建出了一个简化版的响应式系统。完整的功能有助于咱们下一节对源码具体实现细节的分析和思考。