1.1 Vue的引入
Vue
的使用按照官方的说法支持CDN
和NPM
两种方式,CDN
的方式是以script
的方式将打包好的vue.js
引入页面脚本中,而NPM
的方式是和诸如 webpack
或 Browserify
模块打包器配置使用,以npm install vue
的方式引入,这也是我们开发应用的主要形式。而从单纯分析源码思路和实现细节的角度来讲,打包后的vue.js
在分析和提炼源码方面会更加方便,所以这个系列的源码分析,使用的是打包后的vue
脚本,版本号是v2.6.8
1.1.1 基础使用
分析的开始当然是vue
的基础使用,我们引入了vue.js
并且new
了一个Vue
实例,并将它挂载到#app
上,这是最基础的用法。
<div id="app"></div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.8/dist/vue.js"></script>
<script>
var vm = new Vue({
el: '#app',
data: {
message: '选项合并'
},
})
</script>
虽然这一节的重点是阐述Vue
的选项配置,从选项配置入手也是我们从零开始品读源码最容易开始的思路,但是为了分析的完整性,避免后续出现未知的概念,有必要先大致了解一下vue
在脚本引入之后分别做了什么。
1.1.2 Vue构造器
打包后的源码是遵从UMD
规范的,它是commonjs
和amd
的整合。而Vue
的本质是一个构造器,并且它保证了只能通过new
实例的形式去调用,而不能直接通过函数的形式使用。
(function (global, factory) {
// 遵循UMD规范
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.Vue = factory());
}(this, function () { 'use strict';
···
// Vue 构造函数
function Vue (options) {
// 保证了无法直接通过Vue()去调用,只能通过new的方式去创建实例
if (!(this instanceof Vue)
) {
warn('Vue is a constructor and should be called with the `new` keyword');
}
this._init(options);
}
return Vue
})
1.1.3 定义原型属性方法
Vue之所以能适应基础的开发场景,除了经常提到的支持组件化开发,以及完善的响应式系统等外,还有重要的一点是它提供了丰富的api
方法,不管是静态还是原型方法,它们都丰富到足以满足我们日常基础的开发需求。所以熟练阅读vue-api文档并精准使用api
方法是迈向熟练开发的前提。接下来我们看看这些方法属性是在哪里定义的,注意,该小节会忽略大部分属性方法具体的实现,这些详细的细节会贯穿在后续系列的分析中。
首先是原型上的属性方法,在构造函数的定义之后,有这样五个函数,他们分别针对不同场景定义了Vue
原型上的属性和方法。
// 定义Vue原型上的init方法(内部方法)
initMixin(Vue);
// 定义原型上跟数据相关的属性方法
stateMixin(Vue);
//定义原型上跟事件相关的属性方法
eventsMixin(Vue);
// 定义原型上跟生命周期相关的方法
lifecycleMixin(Vue);
// 定义渲染相关的函数
renderMixin(Vue);
我们一个个看,首先initMixin
定义了内部在实例化Vue
时会执行的初始化代码,它是一个内部使用的方法。
function initMixin (Vue) {
Vue.prototype._init = function (options) {}
}
stateMixin
方法会定义跟数据相关的属性方法,例如代理数据的访问,我们可以在实例上通过this.$data
和this.$props
访问到data,props
的值,并且也定义了使用频率较高的this.$set,this.$delte
等方法。
function stateMixin (Vue) {
var dataDef = {};
dataDef.get = function () { return this._data };
var propsDef = {};
propsDef.get = function () { return this._props };
{
dataDef.set = function () {
warn(
'Avoid replacing instance root $data. ' +
'Use nested data properties instead.',
this
);
};
propsDef.set = function () {
warn("$props is readonly.", this);
};
}
// 代理了_data,_props的访问
Object.defineProperty(Vue.prototype, '$data', dataDef);
Object.defineProperty(Vue.prototype, '$props', propsDef);
// $set, $del
Vue.prototype.$set = set;
Vue.prototype.$delete = del;
// $watch
Vue.prototype.$watch = function (expOrFn,cb,options) {};
}
eventsMixin
会对原型上的事件相关方法做定义,文档中提到的vm.$on,vm.$once,vm.$off,vm.$emit
也就是在这里定义的。
function eventsMixin(Vue) {
// 自定义事件监听
Vue.prototype.$on = function (event, fn) {};
// 自定义事件监听,只触发一次
Vue.prototype.$once = function (event, fn) {}
// 自定义事件解绑
Vue.prototype.$off = function (event, fn) {}
// 自定义事件通知
Vue.prototype.$emit = function (event, fn) {
}
lifecycleMixin,renderMixin
两个都可以算是对生命周期渲染方法的定义,例如$forceUpdate
触发实例的强制刷新,$nextTick
将回调延迟到下次 DOM
更新循环之后执行等。
// 定义跟生命周期相关的方法
function lifecycleMixin (Vue) {
Vue.prototype._update = function (vnode, hydrating) {};
Vue.prototype.$forceUpdate = function () {};
Vue.prototype.$destroy = function () {}
}
// 定义原型上跟渲染相关的方法
function renderMixin (Vue) {
Vue.prototype.$nextTick = function (fn) {};
// _render函数,后面会着重讲
Vue.prototype._render = function () {};
}
1.1.4 定义静态属性方法
除了原型方法外,Vue
还提供了丰富的全局api
方法,这些都是在initGlobalAPI
中定义的。
/* 初始化构造器的api */
function initGlobalAPI (Vue) {
// config
var configDef = {};
configDef.get = function () { return config; };
{
configDef.set = function () {
warn(
'Do not replace the Vue.config object, set individual fields instead.'
);
};
}
// 通过Vue.config拿到配置信息
Object.defineProperty(Vue, 'config', configDef);
// 工具类不作为公共暴露的API使用
Vue.util = {
warn: warn,
extend: extend,
mergeOptions: mergeOptions,
defineReactive: defineReactive###1
};
// Vue.set = Vue.prototype.$set
Vue.set = set;
// Vue.delete = Vue.prototype.$delete
Vue.delete = del;
// Vue.nextTick = Vue.prototype.$nextTick
Vue.nextTick = nextTick;
// 2.6 explicit observable API
Vue.observable = function (obj) {
observe(obj);
return obj
};
// 构造函数的默认选项默认为components,directive,filter, _base
Vue.options = Object.create(null);
ASSET_TYPES.forEach(function (type) {
Vue.options[type + 's'] = Object.create(null);
});
// options里的_base属性存储Vue构造器
Vue.options._base = Vue;
extend(Vue.options.components, builtInComponents);
// Vue.use()
initUse(Vue);
// Vue.mixin()
initMixin$1(Vue);
// 定义extend扩展子类构造器的方法
// Vue.extend()
initExtend(Vue);
// Vue.components, Vue.directive, Vue.filter
initAssetRegisters(Vue);
}
看着源码对静态方法的定义做一个汇总。
- 为源码里的
config
配置做一层代理,可以通过Vue.config
拿到默认的配置,并且可以修改它的属性值,具体哪些可以配置修改,可以先参照官方文档。 - 定义内部使用的工具方法,例如警告提示,对象合并等。
- 定义
set,delet,nextTick
方法,本质上原型上也有这些方法的定义。 - 对
Vue.components,Vue.directive,Vue.filter
的定义,这些是默认的资源选项,后续会重点分析。 - 定义
Vue.use()
方法 - 定义
Vue.mixin()
方法 - 定义
Vue.extend()
方法
现在我相信你已经对引入Vue
的阶段有了一个大致的认识,在源码分析的初期阶段,我们不需要死磕每个方法,思路的实现细节,只需要对大致的结构有基本的认识。有了这些基础,我们开始进入这个章节的主线。
1.2 构造器的默认选项
我们回到最开始的例子,在实例化Vue
时,我们会将选项对象传递给构造器进行初始化,这个选项对象描述了你想要的行为,例如以data
定义实例中的响应式数据,以computed
描述实例中的计算属性,以components
来进行组件注册,甚至是定义各个阶段执行的生命周期钩子等。然而Vue
内部本身会自带一些默认的选项,这些选项和用户自定义的选项会在后续一起参与到Vue
实例的初始化中。
在initGlobalAPI
方法中有几行默认选项的定义。Vue
内部的默认选项会保留在静态的options
属性上,从源码看Vue
自身有四个默认配置选项,分别是component,directive, filter
以及返回自身构造器的_base
。
var ASSET_TYPES = [
'component',
'directive',
'filter'
];
// 原型上创建了一个指向为空对象的options属性
Vue.options = Object.create(null);
ASSET_TYPES.forEach(function (type) {
Vue.options[type + 's'] = Object.create(null);
});
Vue.options._base = Vue;
很明显我们开发者对这几个选项是非常熟悉的,components
是需要注册的组件选项,directives
是需要注册的指令,而filter
则代表需要注册的过滤器。从代码的实现细节看,Vue
为components
提供了keepAlive,transition,transitionGroup
的内置组件,为directives
提供了v-model,v-show
的内置指令,而过滤器则没有默认值。
// Vue内置组件
var builtInComponents = {
KeepAlive: KeepAlive
};
var platformComponents = {
Transition: Transition,
TransitionGroup: TransitionGroup
};
// Vue 内置指令,例如: v-model, v-show
var platformDirectives = {
model: directive,
show: show
}
extend(Vue.options.components, builtInComponents);
extend(Vue.options.components, platformComponents); // 扩展内置组件
extend(Vue.options.directives, platformDirectives); // 扩展内置指令
其中extend
方法实现了对象的合并,如果属性相同,则用新的属性值覆盖旧值。
// 将_from对象合并到to对象,属性相同时,则覆盖to对象的属性
function extend (to, _from) {
for (var key in _from) {
to[key] = _from[key];
}
return to
}
因此做为构造器而言,Vue
默认的资源选项配置如下:
Vue.options = {
components: {
KeepAlive: {}
Transition: {}
TransitionGroup: {}
},
directives: {
model: {inserted: ƒ, componentUpdated: ƒ}
show: {bind: ƒ, update: ƒ, unbind: ƒ}
},
filters: {}
_base
}
1.3 选项检验
介绍完Vue
自身拥有的选项后,我们回过头来看看,实例化Vue
的阶段发生了什么。从构造器的定义我们很容易发现,实例化Vue
做的核心操作便是执行_init
方法进行初始化。初始化操作会经过选项合并配置,初始化生命周期,初始化事件中心,乃至构建数据响应式系统等。而关键的第一步就是对选项的合并。合并后的选项会挂载到实例的$options
属性中。(你可以先在实例中通过this.$options
访问最终的选项)
function initMixin (Vue) {
Vue.prototype._init = function (options) {
var vm = this;
// a uid
// 记录实例化多少个vue对象
vm._uid = uid$3++;
// 选项合并,将合并后的选项赋值给实例的$options属性
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor), // 返回Vue构造函数自身的配置项
options || {},
vm
);
};
}
从代码中可以看到,选项合并的重点是将用户自身传递的options
选项和Vue
构造函数自身的选项配置合并。我们看看mergeOptions
函数的实现。
function mergeOptions (parent,child,vm) {
{
checkComponents(child);
}
if (typeof child === 'function') {
child = child.options;
}
// props,inject,directives的校验和规范化
normalizeProps(child, vm);
normalizeInject(child, vm);
normalizeDirectives(child);
// 针对extends扩展的子类构造器
if (!child._base) {
// extends
if (child.extends) {
parent = mergeOptions(parent, child.extends, vm);
}
// mixins
if (child.mixins) {
for (var i = 0, l = child.mixins.length; i < l; i++) {
parent = mergeOptions(parent, child.mixins[i], vm);
}
}
}
var options = {};
var key;
for (key in parent) {
mergeField(key);
}
for (key in child) {
if (!hasOwn(parent, key)) {
mergeField(key);
}
}
function mergeField (key) {
// 拿到各个选择指定的选项配置,如果没有则用默认的配置
var strat = strats[key] || defaultStrat;
// 执行各自的合并策略
options[key] = strat(parent[key], child[key], vm, key);
}
// console.log(options)
return options
}
**选项合并过程中更多的不可控在于不知道用户传递了哪些配置选项,这些配置是否符合规范,是否达到合并配置的要求。因此每个选项的书写规则需要严格限定,原则上不允许用户脱离规则外来传递选项。**因此在合并选项之前,很大的一部分工作是对选项的校验。其中components,prop,inject,directive
等都是检验的重点。
1.3.1 components规范检验
如果项目中需要使用到组件,我们会在vue
实例化时传入组件选项以此来注册组件。因此,组件命名需要遵守很多规范,比如组件名不能用html
保留的标签(如:img,p
),也不能包含非法的字符等。这些都会在validateComponentName
函数做校验。
// components规范检查函数
function checkComponents (options) {
// 遍历components对象,对每个属性值校验。
for (var key in options.components) {
validateComponentName(key);
}
}
function validateComponentName (name) {
if (!new RegExp(("^[a-zA-Z][\\-\\.0-9_" + (unicodeRegExp.source) + "]*$")).test(name)) {
// 正则判断检测是否为非法的标签,例如数字开头
warn(
'Invalid component name: "' + name + '". Component names ' +
'should conform to valid custom element name in html5 specification.'
);
}
// 不能使用Vue自身自定义的组件名,如slot, component,不能使用html的保留标签,如 h1, svg等
if (isBuiltInTag(name) || config.isReservedTag(name)) {
warn(
'Do not use built-in or reserved HTML elements as component ' +
'id: ' + name
);
}
}
1.3.2 props规范检验
Vue
的官方文档规定了props
选项的书写形式有两种,分别是
- 数组形式
{ props: ['a', 'b', 'c'] }
, - 带校验规则的对象形式
{ props: { a: { type: 'String', default: 'prop校验' } }}
从源码上看,两种形式最终都会转换成对象的形式。
// props规范校验
function normalizeProps (options, vm) {
var props = options.props;
if (!props) { return }
var res = {};
var i, val, name;
// props选项数据有两种形式,一种是['a', 'b', 'c'],一种是{ a: { type: 'String', default: 'hahah' }}
// 数组
if (Array.isArray(props)) {
i = props.length;
while (i--) {
val = props[i];
if (typeof val === 'string') {
name = camelize(val);
// 默认将数组形式的props转换为对象形式。
res[name] = { type: null };
} else {
// 规则:保证是字符串
warn('props must be strings when using array syntax.');
}
}
} else if (isPlainObject(props)) {
for (var key in props) {
val = props[key];
name = camelize(key);
res[name] = isPlainObject(val)
? val
: { type: val };
}
} else {
// 非数组,非对象则判定props选项传递非法
warn(
"Invalid value for option \"props\": expected an Array or an Object, " +
"but got " + (toRawType(props)) + ".",
vm
);
}
options.props = res;
}
1.3.3 inject的规范校验
provide/inject
这对组合在我们日常开发中可能使用得比较少,当我们需要在父组件中提供数据或者方法给后代组件使用时可以用到provide/inject
,注意关键是后代,而不单纯指子代,这是有别于props
的使用场景。官方把它被称为依赖注入,依赖注入使得组件后代都能访问到父代注入的数据/方法,且后代不需要知道数据的来源。重要的一点,依赖提供的数据是非响应式的。
基本的使用如下:
// 父组件
var Provider = {
provide: {
foo: 'bar'
},
// ...
}
// 后代组件
var Child = {
// 数组写法
inject: ['foo'],
// 对象写法
inject: {
foo: {
from: 'foo',
default: 'bardefault'
}
}
}
inject
选项有两种写法,数组的方式以及对象的方式,和props
的校验规则一致,最终inject
都会转换为对象的形式存在。
// inject的规范化
function normalizeInject (options, vm) {
var inject = options.inject;
if (!inject) { return }
var normalized = options.inject = {};
//数组的形式
if (Array.isArray(inject)) {
for (var i = 0; i < inject.length; i++) {
// from: 属性是在可用的注入内容中搜索用的 key (字符串或 Symbol)
normalized[inject[i]] = { from: inject[i] };
}
} else if (isPlainObject(inject)) {
// 对象的处理
for (var key in inject) {
var val = inject[key];
normalized[key] = isPlainObject(val)
? extend({ from: key }, val)
: { from: val };
}
} else {
// 非法规则
warn(
"Invalid value for option \"inject\": expected an Array or an Object, " +
"but got " + (toRawType(inject)) + ".",
vm
);
}
}
1.3.4 directive的规范校验
我们先看看指令选项的用法,Vue
允许我们自定义指令,并且它提供了五个钩子函数bind, inserted, update, componentUpdated, unbind
,具体的用法可以参考官方-自定义指令文档,而除了可以以对象的形式去定义钩子函数外,官方还提供了一种函数的简写,例如:
{
directives: {
'color-swatch': function(el, binding) {
el.style.backgroundColor = binding.value
}
}
}
函数的写法会在bind,update
钩子中触发相同的行为,并且不关心其他钩子。这个行为就是定义的函数。因此在对directives
进行规范化时,针对函数的写法会将行为赋予bind,update
钩子。
function normalizeDirectives (options) {
var dirs = options.directives;
if (dirs) {
for (var key in dirs) {
var def###1 = dirs[key];
// 函数简写同样会转换成对象的形式
if (typeof def###1 === 'function') {
dirs[key] = { bind: def###1, update: def###1 };
}
}
}
}
1.3.5 函数缓存
这个内容跟选项的规范化无关,当读到上面规范检测的代码时,笔者发现有一段函数优化的代码值得我们学习。它将每次执行函数后的值进行缓存,当再次执行的时候直接调用缓存的数据而不是重复执行函数,以此提高前端性能,这是典型的用空间换时间的优化,也是经典的偏函数应用。
function cached (fn) {
var cache = Object.create(null); // 创建空对象作为缓存对象
return (function cachedFn (str) {
var hit = cache[str];
return hit || (cache[str] = fn(str)) // 每次执行时缓存对象有值则不需要执行函数方法,没有则执行并缓存起来
})
}
var camelizeRE = /-(\w)/g;
// 缓存会保存每次进行驼峰转换的结果
var camelize = cached(function (str) {
// 将诸如 'a-b'的写法统一处理成驼峰写法'aB'
return str.replace(camelizeRE, function (_, c) { return c ? c.toUpperCase() : ''; })
});
1.4 子类构造器
选项校验介绍完后,在正式进入合并策略之前,还需要先了解一个东西:子类构造器。为什么需要先提到子类构造器呢?
按照前面的知识,Vue
内部提供了四个默认选项,关键的三个是components,directives,filter
。那么当我们传递一个选项配置到Vue
进行初始化,所需要合并的选项好像也仅仅是那关键的三个默认选项而已,那么源码中大篇幅做的选项合并策略又是针对什么场景呢?答案就是这个子类构造器。
Vue
提供了一个Vue.extend
的静态方法,它是基于基础的Vue
构造器创建一个“子类”,而这个子类所传递的选项配置会和父类的选项配置进行合并。这是选项合并场景的由来。
因此有不要先了解子类构造器的实现。下面例子中,我们创建了一个Child
的子类,它继承于父类Parent
,最终将子类挂载到#app
元素上。最终获取的data
便是选项合并后的结果。
var Parent = Vue.extend({
data() {
test: '父类',
test1: '父类1'
}
})
var Child = Parent.extend({
data() {
test: '子类',
test2: '子类1'
}
})
var vm = new Child().$mount('#app');
console.log(vm.$data);
// 结果
{
test: '子类',
test1: '父类1',
test2: '子类1'
}
Vue.extend
的实现思路很清晰,创建了一个Sub
的类,这个类的原型指向了父类,并且子类的options
会和父类的options
进行合并,mergeOptions
的其他细节接下来会重点分析。
Vue.extend = function (extendOptions) {
extendOptions = extendOptions || {};
var Super = this;
var name = extendOptions.name || Super.options.name;
if (name) {
validateComponentName(name); // 校验子类的名称是否符合规范
}
// 创建子类构造器
var Sub = function VueComponent (options) {
this._init(options);
};
Sub.prototype = Object.create(Super.prototype); // 子类继承于父类
Sub.prototype.constructor = Sub;
Sub.cid = cid++;
// 子类和父类构造器的配置选项进行合并
Sub.options = mergeOptions(
Super.options,
extendOptions
);
return Sub // 返回子类构造函数
};
- 深入剖析Vue源码 - 选项合并(上)
- 深入剖析Vue源码 - 选项合并(下)
- 深入剖析Vue源码 - 数据代理,关联子父组件
- 深入剖析Vue源码 - 实例挂载,编译流程
- 深入剖析Vue源码 - 完整渲染过程
- 深入剖析Vue源码 - 组件基础
- 深入剖析Vue源码 - 组件进阶
- 深入剖析Vue源码 - 响应式系统构建(上)
- 深入剖析Vue源码 - 响应式系统构建(中)
- 深入剖析Vue源码 - 响应式系统构建(下)
- 深入剖析Vue源码 - 来,跟我一起实现diff算法!
- 深入剖析Vue源码 - 揭秘Vue的事件机制
- 深入剖析Vue源码 - Vue插槽,你想了解的都在这里!
- 深入剖析Vue源码 - 你了解v-model的语法糖吗?
- 深入剖析Vue源码 - Vue动态组件的概念,你会乱吗?
- 彻底搞懂Vue中keep-alive的魔法(上)
- 彻底搞懂Vue中keep-alive的魔法(下)