扩展
扩展
event
我们平时开发工作中,处理组件间的通讯,原生的交互,都离不开事件。==对于一个组件元素,我们不仅仅可以绑定原生的 DOM 事件,还可以绑定自定义事件==,非常灵活和方便。那么接下来我们从源码角度来看看它的实现原理。
为了更加直观,我们通过一个例子来分析它的实现:
let Child = {
template: '<button @click="clickHandler($event)">' + "click me" + "</button>",
methods: {
clickHandler(e) {
console.log("Button clicked!", e);
this.$emit("select");
},
},
};
let vm = new Vue({
el: "#app",
template:
"<div>" +
'<child @select="selectHandler" @click.native.prevent="clickHandler"></child>' +
"</div>",
methods: {
clickHandler() {
console.log("Child clicked!");
},
selectHandler() {
console.log("Child select!");
},
},
components: {
Child,
},
});
编译
先从编译阶段开始看起,在 parse
阶段,会执行 processAttrs
方法,它的定义在 src/compiler/parser/index.js
中:
export const onRE = /^@|^v-on:/;
export const dirRE = /^v-|^@|^:/;
export const bindRE = /^:|^v-bind:/;
function processAttrs(el) {
const list = el.attrsList;
let i, l, name, rawName, value, modifiers, isProp;
for (i = 0, l = list.length; i < l; i++) {
name = rawName = list[i].name;
value = list[i].value;
if (dirRE.test(name)) {
el.hasBindings = true;
modifiers = parseModifiers(name);
if (modifiers) {
name = name.replace(modifierRE, "");
}
if (bindRE.test(name)) {
// ..
} else if (onRE.test(name)) {
name = name.replace(onRE, "");
addHandler(el, name, value, modifiers, false, warn);
} else {
// ...
}
} else {
// ...
}
}
}
function parseModifiers(name: string): Object | void {
const match = name.match(modifierRE);
if (match) {
const ret = {};
match.forEach((m) => {
ret[m.slice(1)] = true;
});
return ret;
}
}
在对标签属性的处理过程中,判断如果是指令,首先通过 parseModifiers
解析出修饰符,然后判断如果事件的指令,则执行 addHandler(el, name, value, modifiers, false, warn)
方法,它的定义在 src/compiler/helpers.js
中:
export function addHandler(
el: ASTElement,
name: string,
value: string,
modifiers: ?ASTModifiers,
important?: boolean,
warn?: Function
) {
modifiers = modifiers || emptyObject;
// warn prevent and passive modifier
/* istanbul ignore if */
if (
process.env.NODE_ENV !== "production" &&
warn &&
modifiers.prevent &&
modifiers.passive
) {
warn(
"passive and prevent can't be used together. " +
"Passive handler can't prevent default event."
);
}
// check capture modifier
if (modifiers.capture) {
delete modifiers.capture;
name = "!" + name; // mark the event as captured
}
if (modifiers.once) {
delete modifiers.once;
name = "~" + name; // mark the event as once
}
/* istanbul ignore if */
if (modifiers.passive) {
delete modifiers.passive;
name = "&" + name; // mark the event as passive
}
// normalize click.right and click.middle since they don't actually fire
// this is technically browser-specific, but at least for now browsers are
// the only target envs that have right/middle clicks.
if (name === "click") {
if (modifiers.right) {
name = "contextmenu";
delete modifiers.right;
} else if (modifiers.middle) {
name = "mouseup";
}
}
let events;
if (modifiers.native) {
delete modifiers.native;
events = el.nativeEvents || (el.nativeEvents = {});
} else {
events = el.events || (el.events = {});
}
const newHandler: any = {
value: value.trim(),
};
if (modifiers !== emptyObject) {
newHandler.modifiers = modifiers;
}
const handlers = events[name];
/* istanbul ignore if */
if (Array.isArray(handlers)) {
important ? handlers.unshift(newHandler) : handlers.push(newHandler);
} else if (handlers) {
events[name] = important ? [newHandler, handlers] : [handlers, newHandler];
} else {
events[name] = newHandler;
}
el.plain = false;
}
addHandler
函数看起来长,实际上就做了 3 件事情,首先根据 modifier
修饰符对事件名 name
做处理,接着根据 modifier.native
判断是一个纯原生事件还是普通事件,分别对应 el.nativeEvents
和 el.events
,最后按照 name
对事件做归类,并把回调函数的字符串保留到对应的事件中。
在我们的例子中,父组件的 child
节点生成的 el.events
和 el.nativeEvents
如下:
el.events = {
select: {
value: "selectHandler",
},
};
el.nativeEvents = {
click: {
value: "clickHandler",
modifiers: {
prevent: true,
},
},
};
子组件的 button
节点生成的 el.events
如下:
el.events = {
click: {
value: "clickHandler($event)",
},
};
然后在 codegen
的阶段,会在 genData
函数中根据 AST 元素节点上的 events
和 nativeEvents
生成 data
数据,它的定义在 src/compiler/codegen/index.js
中:
export function genData(el: ASTElement, state: CodegenState): string {
let data = "{";
// ...
if (el.events) {
data += `${genHandlers(el.events, false, state.warn)},`;
}
if (el.nativeEvents) {
data += `${genHandlers(el.nativeEvents, true, state.warn)},`;
}
// ...
return data;
}
对于这两个属性,会调用 genHandlers
函数,定义在 src/compiler/codegen/events.js
中:
export function genHandlers(
events: ASTElementHandlers,
isNative: boolean,
warn: Function
): string {
let res = isNative ? "nativeOn:{" : "on:{";
for (const name in events) {
res += `"${name}":${genHandler(name, events[name])},`;
}
return res.slice(0, -1) + "}";
}
const fnExpRE = /^\s*([\w$_]+|\([^)]*?\))\s*=>|^function\s*\(/;
const simplePathRE =
/^\s*[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['.*?']|\[".*?"]|\[\d+]|\[[A-Za-z_$][\w$]*])*\s*$/;
function genHandler(
name: string,
handler: ASTElementHandler | Array<ASTElementHandler>
): string {
if (!handler) {
return "function(){}";
}
if (Array.isArray(handler)) {
return `[${handler.map((handler) => genHandler(name, handler)).join(",")}]`;
}
const isMethodPath = simplePathRE.test(handler.value);
const isFunctionExpression = fnExpRE.test(handler.value);
if (!handler.modifiers) {
if (isMethodPath || isFunctionExpression) {
return handler.value;
}
/* istanbul ignore if */
if (__WEEX__ && handler.params) {
return genWeexHandler(handler.params, handler.value);
}
return `function($event){${handler.value}}`; // inline statement
} else {
let code = "";
let genModifierCode = "";
const keys = [];
for (const key in handler.modifiers) {
if (modifierCode[key]) {
genModifierCode += modifierCode[key];
// left/right
if (keyCodes[key]) {
keys.push(key);
}
} else if (key === "exact") {
const modifiers: ASTModifiers = (handler.modifiers: any);
genModifierCode += genGuard(
["ctrl", "shift", "alt", "meta"]
.filter((keyModifier) => !modifiers[keyModifier])
.map((keyModifier) => `$event.${keyModifier}Key`)
.join("||")
);
} else {
keys.push(key);
}
}
if (keys.length) {
code += genKeyFilter(keys);
}
// Make sure modifiers like prevent and stop get executed after key filtering
if (genModifierCode) {
code += genModifierCode;
}
const handlerCode = isMethodPath
? `return ${handler.value}($event)`
: isFunctionExpression
? `return (${handler.value})($event)`
: handler.value;
/* istanbul ignore if */
if (__WEEX__ && handler.params) {
return genWeexHandler(handler.params, code + handlerCode);
}
return `function($event){${code}${handlerCode}}`;
}
}
genHandlers
方法遍历事件对象 events
,对同一个事件名称的事件调用 genHandler(name, events[name])
方法,它的内容看起来多,但实际上逻辑很简单,首先先判断如果 handler
是一个数组,就遍历它然后递归调用 genHandler
方法并拼接结果,然后判断 hanlder.value
是一个函数的调用路径还是一个函数表达式, 接着对 modifiers
做判断,对于没有 modifiers
的情况,就根据 handler.value
不同情况处理,要么直接返回,要么返回一个函数包裹的表达式;对于有 modifiers
的情况,则对各种不同的 modifer
情况做不同处理,添加相应的代码串。
那么对于我们的例子而言,父组件生成的 data
串为:
{
on: {"select": selectHandler},
nativeOn: {"click": function($event) {
$event.preventDefault();
return clickHandler($event)
}
}
}
子组件生成的 data
串为:
{
on: {"click": function($event) {
clickHandler($event)
}
}
}
那么到这里,编译部分完了,接下来我们来看一下运行时部分是如何实现的。其实 Vue 的事件有 2 种,一种是原生 DOM 事件,一种是用户自定义事件,我们分别来看。
DOM 事件
还记得我们之前在 patch
的时候执行各种 module
的钩子函数吗,当时这部分是略过的,我们之前只分析了 DOM 是如何渲染的,而 DOM 元素相关的属性、样式、事件等都是通过这些 module
的钩子函数完成设置的。
所有和 web 相关的 module
都定义在 src/platforms/web/runtime/modules
目录下,我们这次只关注目录下的 events.js
即可。
在 patch
过程中的创建阶段和更新阶段都会执行 updateDOMListeners
:
let target: any;
function updateDOMListeners(oldVnode: VNodeWithData, vnode: VNodeWithData) {
if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {
return;
}
const on = vnode.data.on || {};
const oldOn = oldVnode.data.on || {};
target = vnode.elm;
normalizeEvents(on);
updateListeners(on, oldOn, add, remove, vnode.context);
target = undefined;
}
首先获取 vnode.data.on
,这就是我们之前的生成的 data
中对应的事件对象,target
是当前 vnode
对于的 DOM 对象,normalizeEvents
主要是对 v-model
相关的处理,我们之后分析 v-model
的时候会介绍,接着调用 updateListeners(on, oldOn, add, remove, vnode.context)
方法,它的定义在 src/core/vdom/helpers/update-listeners.js
中:
export function updateListeners(
on: Object,
oldOn: Object,
add: Function,
remove: Function,
vm: Component
) {
let name, def, cur, old, event;
for (name in on) {
def = cur = on[name];
old = oldOn[name];
event = normalizeEvent(name);
/* istanbul ignore if */
if (__WEEX__ && isPlainObject(def)) {
cur = def.handler;
event.params = def.params;
}
if (isUndef(cur)) {
process.env.NODE_ENV !== "production" &&
warn(
`Invalid handler for event "${event.name}": got ` + String(cur),
vm
);
} else if (isUndef(old)) {
if (isUndef(cur.fns)) {
cur = on[name] = createFnInvoker(cur);
}
add(
event.name,
cur,
event.once,
event.capture,
event.passive,
event.params
);
} else if (cur !== old) {
old.fns = cur;
on[name] = old;
}
}
for (name in oldOn) {
if (isUndef(on[name])) {
event = normalizeEvent(name);
remove(event.name, oldOn[name], event.capture);
}
}
}
updateListeners
的逻辑很简单,遍历 on
去添加事件监听,遍历 oldOn
去移除事件监听,关于监听和移除事件的方法都是外部传入的,因为它既处理原生 DOM 事件的添加删除,也处理自定义事件的添加删除。
对于 on
的遍历,首先获得每一个事件名,然后做 normalizeEvent
的处理:
const normalizeEvent = cached(
(
name: string
): {
name: string,
once: boolean,
capture: boolean,
passive: boolean,
handler?: Function,
params?: Array<any>,
} => {
const passive = name.charAt(0) === "&";
name = passive ? name.slice(1) : name;
const once = name.charAt(0) === "~"; // Prefixed last, checked first
name = once ? name.slice(1) : name;
const capture = name.charAt(0) === "!";
name = capture ? name.slice(1) : name;
return {
name,
once,
capture,
passive,
};
}
);
根据我们的的事件名的一些特殊标识(之前在 addHandler
的时候添加上的)区分出这个事件是否有 once
、capture
、passive
等修饰符。
处理完事件名后,又对事件回调函数做处理,对于第一次,满足 isUndef(old)
并且 isUndef(cur.fns)
,会执行 cur = on[name] = createFnInvoker(cur)
方法去创建一个回调函数,然后在执行 add(event.name, cur, event.once, event.capture, event.passive, event.params)
完成一次事件绑定。我们先看一下 createFnInvoker
的实现:
export function createFnInvoker(fns: Function | Array<Function>): Function {
function invoker() {
const fns = invoker.fns;
if (Array.isArray(fns)) {
const cloned = fns.slice();
for (let i = 0; i < cloned.length; i++) {
cloned[i].apply(null, arguments);
}
} else {
return fns.apply(null, arguments);
}
}
invoker.fns = fns;
return invoker;
}
这里定义了 invoker
方法并返回,由于一个事件可能会对应多个回调函数,所以这里做了数组的判断,多个回调函数就依次调用。注意最后的赋值逻辑, invoker.fns = fns
,每一次执行 invoker
函数都是从 invoker.fns
里取执行的回调函数,回到 updateListeners
,当我们第二次执行该函数的时候,判断如果 cur !== old
,那么只需要更改 old.fns = cur
把之前绑定的 involer.fns
赋值为新的回调函数即可,并且 通过 on[name] = old
保留引用关系,这样就保证了事件回调只添加一次,之后仅仅去修改它的回调函数的引用。
updateListeners
函数的最后遍历 oldOn
拿到事件名称,判断如果满足 isUndef(on[name])
,则执行 remove(event.name, oldOn[name], event.capture)
去移除事件回调。
了解了 updateListeners
的实现后,我们来看一下在原生 DOM 事件中真正添加回调和移除回调函数的实现,它们的定义都在 src/platforms/web/runtime/modules/event.js
中:
function add(
event: string,
handler: Function,
once: boolean,
capture: boolean,
passive: boolean
) {
handler = withMacroTask(handler);
if (once) handler = createOnceHandler(handler, event, capture);
target.addEventListener(
event,
handler,
supportsPassive ? { capture, passive } : capture
);
}
function remove(
event: string,
handler: Function,
capture: boolean,
_target?: HTMLElement
) {
(_target || target).removeEventListener(
event,
handler._withTask || handler,
capture
);
}
add
和 remove
的逻辑很简单,就是实际上调用原生 addEventListener
和 removeEventListener
,并根据参数传递一些配置,注意这里的 hanlder
会用 withMacroTask(hanlder)
包裹一下,它的定义在 src/core/util/next-tick.js
中:
export function withMacroTask(fn: Function): Function {
return (
fn._withTask ||
(fn._withTask = function () {
useMacroTask = true;
const res = fn.apply(null, arguments);
useMacroTask = false;
return res;
})
);
}
实际上就是强制在 DOM 事件的回调函数执行期间如果修改了数据,那么这些数据更改推入的队列会被当做 macroTask
在 nextTick
后执行。
自定义事件
除了原生 DOM 事件,Vue 还支持了自定义事件,并且自定义事件只能作用在组件上,如果在组件上使用原生事件,需要加 .native
修饰符,普通元素上使用 .native
修饰符无效,接下来我们就来分析它的实现。
在 render
阶段,如果是一个组件节点,则通过 createComponent
创建一个组件 vnode
,我们再来回顾这个方法,定义在 src/core/vdom/create-component.js
中:
export function createComponent(
Ctor: Class<Component> | Function | Object | void,
data: ?VNodeData,
context: Component,
children: ?Array<VNode>,
tag?: string
): VNode | Array<VNode> | void {
// ...
const listeners = data.on;
data.on = data.nativeOn;
// ...
const name = Ctor.options.name || tag;
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ""}`,
data,
undefined,
undefined,
undefined,
context,
{ Ctor, propsData, listeners, tag, children },
asyncFactory
);
return vnode;
}
我们只关注事件相关的逻辑,可以看到,它把 data.on
赋值给了 listeners
,把 data.nativeOn
赋值给了 data.on
,这样所有的原生 DOM 事件处理跟我们刚才介绍的一样,它是在当前组件环境中处理的。而对于自定义事件,我们把 listeners
作为 vnode
的 componentOptions
传入,它是在子组件初始化阶段中处理的,所以它的处理环境是子组件。
然后在子组件的初始化的时候,会执行 initInternalComponent
方法,它的定义在 src/core/instance/init.js
中:
export function initInternalComponent(
vm: Component,
options: InternalComponentOptions
) {
const opts = (vm.$options = Object.create(vm.constructor.options));
// ....
const vnodeComponentOptions = parentVnode.componentOptions;
opts._parentListeners = vnodeComponentOptions.listeners;
// ...
}
这里拿到了父组件传入的 listeners
,然后在执行 initEvents
的过程中,会处理这个 listeners
,定义在 src/core/instance/events.js
中:
export function initEvents(vm: Component) {
vm._events = Object.create(null);
vm._hasHookEvent = false;
// init parent attached events
const listeners = vm.$options._parentListeners;
if (listeners) {
updateComponentListeners(vm, listeners);
}
}
拿到 listeners
后,执行 updateComponentListeners(vm, listeners)
方法:
let target: any;
export function updateComponentListeners(
vm: Component,
listeners: Object,
oldListeners: ?Object
) {
target = vm;
updateListeners(listeners, oldListeners || {}, add, remove, vm);
target = undefined;
}
updateListeners
我们之前介绍过,所以对于自定义事件和原生 DOM 事件处理的差异就在事件添加和删除的实现上,来看一下自定义事件 add
和 remove
的实现:
function add(event, fn, once) {
if (once) {
target.$once(event, fn);
} else {
target.$on(event, fn);
}
}
function remove(event, fn) {
target.$off(event, fn);
}
实际上是利用 Vue 定义的事件中心,简单分析一下它的实现:
export function eventsMixin(Vue: Class<Component>) {
const hookRE = /^hook:/;
Vue.prototype.$on = function (
event: string | Array<string>,
fn: Function
): Component {
const vm: Component = this;
if (Array.isArray(event)) {
for (let i = 0, l = event.length; i < l; i++) {
this.$on(event[i], fn);
}
} else {
(vm._events[event] || (vm._events[event] = [])).push(fn);
// optimize hook:event cost by using a boolean flag marked at registration
// instead of a hash lookup
if (hookRE.test(event)) {
vm._hasHookEvent = true;
}
}
return vm;
};
Vue.prototype.$once = function (event: string, fn: Function): Component {
const vm: Component = this;
function on() {
vm.$off(event, on);
fn.apply(vm, arguments);
}
on.fn = fn;
vm.$on(event, on);
return vm;
};
Vue.prototype.$off = function (
event?: string | Array<string>,
fn?: Function
): Component {
const vm: Component = this;
// all
if (!arguments.length) {
vm._events = Object.create(null);
return vm;
}
// array of events
if (Array.isArray(event)) {
for (let i = 0, l = event.length; i < l; i++) {
this.$off(event[i], fn);
}
return vm;
}
// specific event
const cbs = vm._events[event];
if (!cbs) {
return vm;
}
if (!fn) {
vm._events[event] = null;
return vm;
}
if (fn) {
// specific handler
let cb;
let i = cbs.length;
while (i--) {
cb = cbs[i];
if (cb === fn || cb.fn === fn) {
cbs.splice(i, 1);
break;
}
}
}
return vm;
};
Vue.prototype.$emit = function (event: string): Component {
const vm: Component = this;
if (process.env.NODE_ENV !== "production") {
const lowerCaseEvent = event.toLowerCase();
if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
tip(
`Event "${lowerCaseEvent}" is emitted in component ` +
`${formatComponentName(
vm
)} but the handler is registered for "${event}". ` +
`Note that HTML attributes are case-insensitive and you cannot use ` +
`v-on to listen to camelCase events when using in-DOM templates. ` +
`You should probably use "${hyphenate(
event
)}" instead of "${event}".`
);
}
}
let cbs = vm._events[event];
if (cbs) {
cbs = cbs.length > 1 ? toArray(cbs) : cbs;
const args = toArray(arguments, 1);
for (let i = 0, l = cbs.length; i < l; i++) {
try {
cbs[i].apply(vm, args);
} catch (e) {
handleError(e, vm, `event handler for "${event}"`);
}
}
}
return vm;
};
}
非常经典的事件中心的实现,把所有的事件用 vm._events
存储起来,当执行 vm.$on(event,fn)
的时候,按事件的名称 event
把回调函数 fn
存储起来 vm._events[event].push(fn)
。当执行 vm.$emit(event)
的时候,根据事件名 event
找到所有的回调函数 let cbs = vm._events[event]
,然后遍历执行所有的回调函数。当执行 vm.$off(event,fn)
的时候会移除指定事件名 event
和指定的 fn
当执行 vm.$once(event,fn)
的时候,内部就是执行 vm.$on
,并且当回调函数执行一次后再通过 vm.$off
移除事件的回调,这样就确保了回调函数只执行一次。
所以对于用户自定义的事件添加和删除就是利用了这几个事件中心的 API。需要注意的事一点,vm.$emit
是给当前的 vm
上派发的实例,之所以我们常用它做父子组件通讯,是因为它的回调函数的定义是在父组件中,对于我们这个例子而言,当子组件的 button
被点击了,它通过 this.$emit('select')
派发事件,那么子组件的实例就监听到了这个 select
事件,并执行它的回调函数——定义在父组件中的 selectHandler
方法,这样就相当于完成了一次父子组件的通讯。
总结
那么至此我们对 Vue 的事件实现有了进一步的了解,Vue 支持 2 种事件类型,原生 DOM 事件和自定义事件,它们主要的区别在于添加和删除事件的方式不一样,并且自定义事件的派发是往当前实例上派发,但是可以利用在父组件环境定义回调函数来实现父子组件的通讯。另外要注意一点,只有组件节点才可以添加自定义事件,并且添加原生 DOM 事件需要使用 native
修饰符;而普通元素使用 .native
修饰符是没有作用的,也只能添加原生 DOM 事件。
编译
DOM 事件
自定义事件
Vue 还支持了自定义事件,并且自定义事件只能作用在组件上
1.在 render
阶段,如果是一个组件节点,则通过 createComponent
创建一个组件 vnode
2.在事件相关的逻辑,它把 data.on
赋值给了 listeners
,把 data.nativeOn
赋值给了 data.on
,这样所有的原生 DOM 事件处理一样,是在当前组件环境中处理的
3.对于自定义事件,我们把 listeners
作为 vnode
的 componentOptions
传入,它是在子组件初始化阶段中处理的,所以它的处理环境是子组件
4.在子组件的初始化的时候,会执行 initInternalComponent
方法,拿到了父组件传入的 listeners
,然后在执行 initEvents
的过程中,会处理这个 listeners
5.拿到 listeners
后,执行 updateComponentListeners(vm, listeners)
方法,对于自定义事件和原生 DOM 事件处理的差异就在事件添加和删除的实现上,实际上是利用 Vue 定义的事件中心
6.把所有的事件用 vm._events
存储起来,当执行 vm.$on(event,fn)
的时候,按事件的名称 event
把回调函数 fn
存储起来 vm._events[event].push(fn)
7.当执行 vm.$emit(event)
的时候,根据事件名 event
找到所有的回调函数 let cbs = vm._events[event]
,然后遍历执行所有的回调函数。
8.当执行 vm.$off(event,fn)
的时候会移除指定事件名 event
和指定的 fn
当执行 vm.$once(event,fn)
的时候,内部就是执行 vm.$on
,并且当回调函数执行一次后再通过 vm.$off
移除事件的回调,这样就确保了回调函数只执行一次
9.对于用户自定义的事件添加和删除就是利用了这几个事件中心的 API,vm.$emit
是给当前的 vm
上派发的实例,之所以我们常用它做父子组件通讯,是因为它的回调函数的定义是在父组件中,当子组件的 button
被点击了,它通过 this.$emit('select')
派发事件,那么子组件的实例就监听到了这个 select
事件,并执行它的回调函数——定义在父组件中的 selectHandler
方法,这样就相当于完成了一次父子组件的通讯
v-model
在理解 Vue 的时候都把 Vue 的数据响应原理理解为双向绑定,但实际上这是不准确的,我们之前提到的数据响应,都是通过数据的改变去驱动 DOM 视图的变化,而双向绑定除了数据驱动 DOM 外, DOM 的变化反过来影响数据,是一个双向关系,在 Vue 中,我们可以通过 v-model
来实现双向绑定。
v-model
即可以作用在普通表单元素上,又可以作用在组件上,它其实是一个语法糖,接下来我们就来分析 v-model
的实现原理。
表单元素
为了更加直观,我们还是结合示例来分析:
let vm = new Vue({
el: "#app",
template:
"<div>" +
'<input v-model="message" placeholder="edit me">' +
"<p>Message is: {{ message }}</p>" +
"</div>",
data() {
return {
message: "",
};
},
});
这是一个非常简单 demo,我们在 input
元素上设置了 v-model
属性,绑定了 message
,当我们在 input
上输入了内容,message
也会同步变化。接下来我们就来分析 Vue 是如何实现这一效果的,其实非常简单。
也是先从编译阶段分析,首先是 parse
阶段, v-model
被当做普通的指令解析到 el.directives
中,然后在 codegen
阶段,执行 genData
的时候,会执行 const dirs = genDirectives(el, state)
,它的定义在 src/compiler/codegen/index.js
中:
function genDirectives(el: ASTElement, state: CodegenState): string | void {
const dirs = el.directives;
if (!dirs) return;
let res = "directives:[";
let hasRuntime = false;
let i, l, dir, needRuntime;
for (i = 0, l = dirs.length; i < l; i++) {
dir = dirs[i];
needRuntime = true;
const gen: DirectiveFunction = state.directives[dir.name];
if (gen) {
// compile-time directive that manipulates AST.
// returns true if it also needs a runtime counterpart.
needRuntime = !!gen(el, dir, state.warn);
}
if (needRuntime) {
hasRuntime = true;
res += `{name:"${dir.name}",rawName:"${dir.rawName}"${
dir.value
? `,value:(${dir.value}),expression:${JSON.stringify(dir.value)}`
: ""
}${dir.arg ? `,arg:"${dir.arg}"` : ""}${
dir.modifiers ? `,modifiers:${JSON.stringify(dir.modifiers)}` : ""
}},`;
}
}
if (hasRuntime) {
return res.slice(0, -1) + "]";
}
}
genDrirectives
方法就是遍历 el.directives
,然后获取每一个指令对应的方法 const gen: DirectiveFunction = state.directives[dir.name]
,这个指令方法实际上是在实例化 CodegenState
的时候通过 option
传入的,这个 option
就是编译相关的配置,它在不同的平台下配置不同,在 web
环境下的定义在 src/platforms/web/compiler/options.js
下:
export const baseOptions: CompilerOptions = {
expectHTML: true,
modules,
directives,
isPreTag,
isUnaryTag,
mustUseProp,
canBeLeftOpenTag,
isReservedTag,
getTagNamespace,
staticKeys: genStaticKeys(modules),
};
directives
定义在 src/platforms/web/compiler/directives/index.js
中:
export default {
model,
text,
html,
};
那么对于 v-model
而言,对应的 directive
函数是在 src/platforms/web/compiler/directives/model.js
中定义的 model
函数:
export default function model(
el: ASTElement,
dir: ASTDirective,
_warn: Function
): ?boolean {
warn = _warn;
const value = dir.value;
const modifiers = dir.modifiers;
const tag = el.tag;
const type = el.attrsMap.type;
if (process.env.NODE_ENV !== "production") {
// inputs with type="file" are read only and setting the input's
// value will throw an error.
if (tag === "input" && type === "file") {
warn(
`<${el.tag} v-model="${value}" type="file">:\n` +
`File inputs are read only. Use a v-on:change listener instead.`
);
}
}
if (el.component) {
genComponentModel(el, value, modifiers);
// component v-model doesn't need extra runtime
return false;
} else if (tag === "select") {
genSelect(el, value, modifiers);
} else if (tag === "input" && type === "checkbox") {
genCheckboxModel(el, value, modifiers);
} else if (tag === "input" && type === "radio") {
genRadioModel(el, value, modifiers);
} else if (tag === "input" || tag === "textarea") {
genDefaultModel(el, value, modifiers);
} else if (!config.isReservedTag(tag)) {
genComponentModel(el, value, modifiers);
// component v-model doesn't need extra runtime
return false;
} else if (process.env.NODE_ENV !== "production") {
warn(
`<${el.tag} v-model="${value}">: ` +
`v-model is not supported on this element type. ` +
"If you are working with contenteditable, it's recommended to " +
"wrap a library dedicated for that purpose inside a custom component."
);
}
// ensure runtime directive metadata
return true;
}
也就是说我们执行 needRuntime = !!gen(el, dir, state.warn)
就是在执行 model
函数,它会根据 AST 元素节点的不同情况去执行不同的逻辑,对于我们这个 case 而言,它会命中 genDefaultModel(el, value, modifiers)
的逻辑,稍后我们也会介绍组件的处理,其它分支同学们可以自行去看。我们来看一下 genDefaultModel
的实现:
function genDefaultModel(
el: ASTElement,
value: string,
modifiers: ?ASTModifiers
): ?boolean {
const type = el.attrsMap.type;
// warn if v-bind:value conflicts with v-model
// except for inputs with v-bind:type
if (process.env.NODE_ENV !== "production") {
const value = el.attrsMap["v-bind:value"] || el.attrsMap[":value"];
const typeBinding = el.attrsMap["v-bind:type"] || el.attrsMap[":type"];
if (value && !typeBinding) {
const binding = el.attrsMap["v-bind:value"] ? "v-bind:value" : ":value";
warn(
`${binding}="${value}" conflicts with v-model on the same element ` +
"because the latter already expands to a value binding internally"
);
}
}
const { lazy, number, trim } = modifiers || {};
const needCompositionGuard = !lazy && type !== "range";
const event = lazy ? "change" : type === "range" ? RANGE_TOKEN : "input";
let valueExpression = "$event.target.value";
if (trim) {
valueExpression = `$event.target.value.trim()`;
}
if (number) {
valueExpression = `_n(${valueExpression})`;
}
let code = genAssignmentCode(value, valueExpression);
if (needCompositionGuard) {
code = `if($event.target.composing)return;${code}`;
}
addProp(el, "value", `(${value})`);
addHandler(el, event, code, null, true);
if (trim || number) {
addHandler(el, "blur", "$forceUpdate()");
}
}
genDefaultModel
函数先处理了 modifiers
,它的不同主要影响的是 event
和 valueExpression
的值,对于我们的例子,event
为 input
,valueExpression
为 $event.target.value
。然后去执行 genAssignmentCode
去生成代码,它的定义在 src/compiler/directives/model.js
中:
/**
* Cross-platform codegen helper for generating v-model value assignment code.
*/
export function genAssignmentCode(value: string, assignment: string): string {
const res = parseModel(value);
if (res.key === null) {
return `${value}=${assignment}`;
} else {
return `$set(${res.exp}, ${res.key}, ${assignment})`;
}
}
该方法首先对 v-model
对应的 value
做了解析,它处理了非常多的情况,对我们的例子,value
就是 messgae
,所以返回的 res.key
为 null
,然后我们就得到 ${value}=${assignment}
,也就是 message=$event.target.value
。然后我们又命中了 needCompositionGuard
为 true 的逻辑,所以最终的 code
为 if($event.target.composing)return;message=$event.target.value
。
code
生成完后,又执行了 2 句非常关键的代码:
addProp(el, "value", `(${value})`);
addHandler(el, event, code, null, true);
这实际上就是 input
实现 v-model
的精髓,通过修改 AST 元素,给 el
添加一个 prop
,相当于我们在 input
上动态绑定了 value
,又给 el
添加了事件处理,相当于在 input
上绑定了 input
事件,其实转换成模板如下:
<input
v-bind:value="message"
v-on:input="message=$event.target.value">
其实就是动态绑定了 input
的 value
指向了 messgae
变量,并且在触发 input
事件的时候去动态把 message
设置为目标值,这样实际上就完成了数据双向绑定了,所以说 v-model
实际上就是语法糖。
再回到 genDirectives
,它接下来的逻辑就是根据指令生成一些 data
的代码:
if (needRuntime) {
hasRuntime = true;
res += `{name:"${dir.name}",rawName:"${dir.rawName}"${
dir.value
? `,value:(${dir.value}),expression:${JSON.stringify(dir.value)}`
: ""
}${dir.arg ? `,arg:"${dir.arg}"` : ""}${
dir.modifiers ? `,modifiers:${JSON.stringify(dir.modifiers)}` : ""
}},`;
}
对我们的例子而言,最终生成的 render
代码如下:
with (this) {
return _c("div", [
_c("input", {
directives: [
{
name: "model",
rawName: "v-model",
value: message,
expression: "message",
},
],
attrs: { placeholder: "edit me" },
domProps: { value: message },
on: {
input: function ($event) {
if ($event.target.composing) return;
message = $event.target.value;
},
},
}),
_c("p", [_v("Message is: " + _s(message))]),
]);
}
关于事件的处理我们之前的章节已经分析过了,所以对于 input
的 v-model
而言,完全就是语法糖,并且对于其它表单元素套路都是一样,区别在于生成的事件代码会略有不同。
v-model
除了作用在表单元素上,新版的 Vue 还把这一语法糖用在了组件上,接下来我们来分析它的实现。
组件
为了更加直观,我们也是通过一个例子分析:
let Child = {
template:
"<div>" +
'<input :value="value" @input="updateValue" placeholder="edit me">' +
"</div>",
props: ["value"],
methods: {
updateValue(e) {
this.$emit("input", e.target.value);
},
},
};
let vm = new Vue({
el: "#app",
template:
"<div>" +
'<child v-model="message"></child>' +
"<p>Message is: {{ message }}</p>" +
"</div>",
data() {
return {
message: "",
};
},
components: {
Child,
},
});
可以看到,父组件引用 child
子组件的地方使用了 v-model
关联了数据 message
;
而子组件定义了一个 value
的 prop
,并且在 input
事件的回调函数中,通过 this.$emit('input', e.target.value)
派发了一个事件,为了让 v-model
生效,这两点是必须的。
接着我们从源码角度分析实现原理,还是从编译阶段说起,对于父组件而言,
在编译阶段会解析 v-model
指令,依然会执行 genData
函数中的 genDirectives
函数,接着执行 src/platforms/web/compiler/directives/model.js
中定义的 model
函数,并命中如下逻辑:
else if (!config.isReservedTag(tag)) {
genComponentModel(el, value, modifiers);
return false
}
genComponentModel
函数定义在 src/compiler/directives/model.js
中:
export function genComponentModel(
el: ASTElement,
value: string,
modifiers: ?ASTModifiers
): ?boolean {
const { number, trim } = modifiers || {};
const baseValueExpression = "$$v";
let valueExpression = baseValueExpression;
if (trim) {
valueExpression =
`(typeof ${baseValueExpression} === 'string'` +
`? ${baseValueExpression}.trim()` +
`: ${baseValueExpression})`;
}
if (number) {
valueExpression = `_n(${valueExpression})`;
}
const assignment = genAssignmentCode(value, valueExpression);
el.model = {
value: `(${value})`,
expression: `"${value}"`,
callback: `function (${baseValueExpression}) {${assignment}}`,
};
}
genComponentModel
的逻辑很简单,对我们的例子而言,生成的 el.model
的值为:
el.model = {
callback: "function ($$v) {message=$$v}",
expression: '"message"',
value: "(message)",
};
那么在 genDirectives
之后,genData
函数中有一段逻辑如下:
if (el.model) {
data += `model:{value:${el.model.value},callback:${el.model.callback},expression:${el.model.expression}},`;
}
那么父组件最终生成的 render
代码如下:
with (this) {
return _c(
"div",
[
_c("child", {
model: {
value: message,
callback: function ($$v) {
message = $$v;
},
expression: "message",
},
}),
_c("p", [_v("Message is: " + _s(message))]),
],
1
);
}
然后在创建子组件 vnode
阶段,会执行 createComponent
函数,它的定义在 src/core/vdom/create-component.js
中:
export function createComponent(
Ctor: Class<Component> | Function | Object | void,
data: ?VNodeData,
context: Component,
children: ?Array<VNode>,
tag?: string
): VNode | Array<VNode> | void {
// ...
// transform component v-model data into props & events
if (isDef(data.model)) {
transformModel(Ctor.options, data);
}
// extract props
const propsData = extractPropsFromVNodeData(data, Ctor, tag);
// ...
// extract listeners, since these needs to be treated as
// child component listeners instead of DOM listeners
const listeners = data.on;
// ...
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ""}`,
data,
undefined,
undefined,
undefined,
context,
{ Ctor, propsData, listeners, tag, children },
asyncFactory
);
return vnode;
}
其中会对 data.model
的情况做处理,执行 transformModel(Ctor.options, data)
方法:
// transform component v-model info (value and callback) into
// prop and event handler respectively.
function transformModel(options, data: any) {
const prop = (options.model && options.model.prop) || "value";
const event = (options.model && options.model.event) || "input";
(data.props || (data.props = {}))[prop] = data.model.value;
const on = data.on || (data.on = {});
if (isDef(on[event])) {
on[event] = [data.model.callback].concat(on[event]);
} else {
on[event] = data.model.callback;
}
}
transformModel
逻辑很简单,给 data.props
添加 data.model.value
,并且给data.on
添加 data.model.callback
,对我们的例子而言,扩展结果如下:
data.props = {
value: message,
};
data.on = {
input: function ($$v) {
message = $$v;
},
};
其实就相当于我们在这样编写父组件:
let vm = new Vue({
el: "#app",
template:
"<div>" +
'<child :value="message" @input="message=arguments[0]"></child>' +
"<p>Message is: {{ message }}</p>" +
"</div>",
data() {
return {
message: "",
};
},
components: {
Child,
},
});
子组件传递的 value
绑定到当前父组件的 message
,同时监听自定义 input
事件,当子组件派发 input
事件的时候,父组件会在事件回调函数中修改 message
的值,同时 value
也会发生变化,子组件的 input
值被更新。
这就是典型的 Vue 的父子组件通讯模式,父组件通过 prop
把数据传递到子组件,子组件修改了数据后把改变通过 $emit
事件的方式通知父组件,所以说组件上的 v-model
也是一种语法糖。
另外我们注意到组件 v-model
的实现,子组件的 value prop
以及派发的 input
事件名是可配的,可以看到 transformModel
中对这部分的处理:
function transformModel(options, data: any) {
const prop = (options.model && options.model.prop) || "value";
const event = (options.model && options.model.event) || "input";
// ...
}
也就是说可以在定义子组件的时候通过 model
选项配置子组件接收的 prop
名以及派发的事件名,举个例子:
let Child = {
template:
"<div>" +
'<input :value="msg" @input="updateValue" placeholder="edit me">' +
"</div>",
props: ["msg"],
model: {
prop: "msg",
event: "change",
},
methods: {
updateValue(e) {
this.$emit("change", e.target.value);
},
},
};
let vm = new Vue({
el: "#app",
template:
"<div>" +
'<child v-model="message"></child>' +
"<p>Message is: {{ message }}</p>" +
"</div>",
data() {
return {
message: "",
};
},
components: {
Child,
},
});
子组件修改了接收的 prop
名以及派发的事件名,然而这一切父组件作为调用方是不用关心的,这样做的好处是我们可以把 value
这个 prop
作为其它的用途。
总结
表单元素
1.从编译阶段分析,首先是 parse
阶段, v-model
被当做普通的指令解析到 el.directives
中,然后在 codegen
阶段,执行 genData
的时候,会执行 const dirs = genDirectives(el, state)
2.genDrirectives
方法就是遍历 el.directives
,然后获取每一个指令对应的方法 const gen: DirectiveFunction = state.directives[dir.name]
,这个指令方法实际上是在实例化 CodegenState
的时候通过 option
传入的,这个 option
就是编译相关的配置
3.对于 v-model
而言,对应的 directive
函数是在web/compiler/directives/model.js
中定义的 model
函数
4.执行 needRuntime = !!gen(el, dir, state.warn)
就是在执行 model
函数,它会根据 AST 元素节点的不同情况去执行不同的逻辑
5.命中 genDefaultModel(el, value, modifiers)
的逻辑,genDefaultModel
函数先处理了 modifiers
,它的不同主要影响的是 event
和 valueExpression
的值,event
为 input
,valueExpression
为 $event.target.value
。然后去执行 genAssignmentCode
去生成代码
6.genAssignmentCode 首先对 v-model
对应的 value
做了解析,它处理了非常多的情况,对我们的例子,value
就是 messgae
,所以返回的 res.key
为 null
,然后我们就得到 ${value}=${assignment}
,也就是 message=$event.target.value
7.又命中了 needCompositionGuard
为 true 的逻辑,所以最终的 code
为 if($event.target.composing)return;message=$event.target.value
8.code
生成完后,又执行了 2 句非常关键的代码:
addProp(el, "value", `(${value})`);
addHandler(el, event, code, null, true);
实际上就是 input
实现 v-model
的精髓,通过修改 AST 元素,给 el
添加一个 prop
,相当于我们在 input
上动态绑定了 value
,又给 el
添加了事件处理,相当于在 input
上绑定了 input
事件
<input
v-bind:value="message"
v-on:input="message=$event.target.value">
其实就是动态绑定了 input
的 value
指向了 messgae
变量,并且在触发 input
事件的时候去动态把 message
设置为目标值,这样实际上就完成了数据双向绑定了,所以说 v-model
实际上就是语法糖
9.genDirectives
,它接下来的逻辑就是根据指令生成一些 data
的代码,最终生成的 render
代码
对于 input
的 v-model
而言,完全就是语法糖,并且对于其它表单元素套路都是一样,区别在于生成的事件代码会略有不同
组件
1.从编译阶段说起,对于父组件而言,在编译阶段会解析 v-model
指令,依然会执行 genData
函数中的 genDirectives
函数,接着执行
compiler/directives/model.js
中定义的 model
函数 然后执行 genComponentModel(el, value, modifiers);
2.genComponentModel
函数,生成的 el.model
,在 genDirectives
之后,genData
函数中
3.父组件最终生成的 render
函数
4.然后在创建子组件 vnode
阶段,会执行 createComponent
函数,其中会对 data.model
的情况做处理,执行 transformModel(Ctor.options, data)
方法
5.transformModel
逻辑很简单,给 data.props
添加 data.model.value
,并且给data.on
添加 data.model.callback
其实就相当于我们编写父组件
6.子组件传递的 value
绑定到当前父组件的 message
,同时监听自定义 input
事件,当子组件派发 input
事件的时候,父组件会在事件回调函数中修改 message
的值,同时 value
也会发生变化,子组件的 input
值被更新
6.Vue 的父子组件通讯模式,父组件通过 prop
把数据传递到子组件,子组件修改了数据后把改变通过 $emit
事件的方式通知父组件,所以说组件上的 v-model
也是一种语法糖
组件 v-model
的实现,子组件的 value prop
以及派发的 input
事件名是可配的
可以在定义子组件的时候通过 model
选项配置子组件接收的 prop
名以及派发的事件名
slot
Vue 的组件提供了一个非常有用的特性 —— slot
插槽,它让组件的实现变的更加灵活。我们平时在开发组件库的时候,为了让组件更加灵活可定制,经常用插槽的方式让用户可以自定义内容。插槽分为普通插槽和作用域插槽,它们可以解决不同的场景
普通插槽
为了更加直观,我们还是通过一个例子来分析插槽的实现:
slot 实际上就相当于占位,然后对 slot 对应的内容进行替换
let AppLayout = {
template:
'<div class="container">' +
'<header><slot name="header"></slot></header>' +
"<main><slot>默认内容</slot></main>" +
'<footer><slot name="footer"></slot></footer>' +
"</div>",
};
let vm = new Vue({
el: "#app",
template:
"<div>" +
"<app-layout>" +
'<h1 slot="header">{{title}}</h1>' +
"<p>{{msg}}</p>" +
'<p slot="footer">{{desc}}</p>' +
"</app-layout>" +
"</div>",
data() {
return {
title: "我是标题",
msg: "我是内容",
desc: "其它信息",
};
},
components: {
AppLayout,
},
});
这里我们定义了 AppLayout
子组件,它内部定义了 3 个插槽,2 个为具名插槽,一个 name
为 header
,一个 name
为 footer
,还有一个没有定义 name
的是默认插槽。 <slot>
和 </slot>
之前填写的内容为默认内容。我们的父组件注册和引用了 AppLayout
的组件,并在组件内部定义了一些元素,用来替换插槽,那么它最终生成的 DOM 如下:
<div>
<div class="container">
<header><h1>我是标题</h1></header>
<main><p>我是内容</p></main>
<footer><p>其它信息</p></footer>
</div>
</div>
编译
还是先从编译说起,我们知道==编译是发生在调用 vm.$mount
的时候==,所以编译的顺序是先编译父组件,再编译子组件。
首先==编译父组件,在 parse
阶段,会执行 processSlot
处理 slot
==,它的定义在 src/compiler/parser/index.js
中:
function processSlot(el) {
if (el.tag === "slot") {
el.slotName = getBindingAttr(el, "name");
if (process.env.NODE_ENV !== "production" && el.key) {
warn(
`\`key\` does not work on <slot> because slots are abstract outlets ` +
`and can possibly expand into multiple elements. ` +
`Use the key on a wrapping element instead.`
);
}
} else {
let slotScope;
if (el.tag === "template") {
slotScope = getAndRemoveAttr(el, "scope");
/* istanbul ignore if */
if (process.env.NODE_ENV !== "production" && slotScope) {
warn(
`the "scope" attribute for scoped slots have been deprecated and ` +
`replaced by "slot-scope" since 2.5. The new "slot-scope" attribute ` +
`can also be used on plain elements in addition to <template> to ` +
`denote scoped slots.`,
true
);
}
el.slotScope = slotScope || getAndRemoveAttr(el, "slot-scope");
} else if ((slotScope = getAndRemoveAttr(el, "slot-scope"))) {
/* istanbul ignore if */
if (process.env.NODE_ENV !== "production" && el.attrsMap["v-for"]) {
warn(
`Ambiguous combined usage of slot-scope and v-for on <${el.tag}> ` +
`(v-for takes higher priority). Use a wrapper <template> for the ` +
`scoped slot to make it clearer.`,
true
);
}
el.slotScope = slotScope;
}
const slotTarget = getBindingAttr(el, "slot");
if (slotTarget) {
el.slotTarget = slotTarget === '""' ? '"default"' : slotTarget;
// preserve slot as an attribute for native shadow DOM compat
// only for non-scoped slots.
if (el.tag !== "template" && !el.slotScope) {
addAttr(el, "slot", slotTarget);
}
}
}
}
当解析到标签上有 slot
属性的时候,会给对应的 AST 元素节点添加 slotTarget
属性,
然后在 codegen
阶段,在 genData
中会处理 slotTarget
,
相关代码在 src/compiler/codegen/index.js
中:
if (el.slotTarget && !el.slotScope) {
data += `slot:${el.slotTarget},`;
}
会给 data
添加一个 slot
属性,并指向 slotTarget
,之后会用到。
在我们的例子中,父组件最终生成的代码如下:
with (this) {
return _c(
"div",
[
_c("app-layout", [
_c("h1", { attrs: { slot: "header" }, slot: "header" }, [
_v(_s(title)),
]),
_c("p", [_v(_s(msg))]),
_c("p", { attrs: { slot: "footer" }, slot: "footer" }, [_v(_s(desc))]),
]),
],
1
);
}
接下来编译子组件,同样在 parser
阶段会执行 processSlot
处理函数,它的定义在 src/compiler/parser/index.js
中:
function processSlot(el) {
if (el.tag === "slot") {
el.slotName = getBindingAttr(el, "name");
}
// ...
}
当遇到 slot
标签的时候会给对应的 AST 元素节点添加 slotName
属性,然后在 codegen
阶段,会判断如果当前 AST 元素节点是 slot
标签,则执行 genSlot
函数,它的定义在 src/compiler/codegen/index.js
中:
function genSlot(el: ASTElement, state: CodegenState): string {
const slotName = el.slotName || '"default"';
const children = genChildren(el, state);
let res = `_t(${slotName}${children ? `,${children}` : ""}`;
const attrs =
el.attrs &&
`{${el.attrs.map((a) => `${camelize(a.name)}:${a.value}`).join(",")}}`;
const bind = el.attrsMap["v-bind"];
if ((attrs || bind) && !children) {
res += `,null`;
}
if (attrs) {
res += `,${attrs}`;
}
if (bind) {
res += `${attrs ? "" : ",null"},${bind}`;
}
return res + ")";
}
我们先不考虑 slot
标签上有 attrs
以及 v-bind
的情况,那么它生成的代码实际上就只有:
const slotName = el.slotName || '"default"';
const children = genChildren(el, state);
let res = `_t(${slotName}${children ? `,${children}` : ""}`;
这里的 slotName
从 AST 元素节点对应的属性上取,默认是 default
,而 children
对应的就是 slot
开始和闭合标签包裹的内容。来看一下我们例子的子组件最终生成的代码,如下:
with (this) {
return _c(
"div",
{
staticClass: "container",
},
[
_c("header", [_t("header")], 2),
_c("main", [_t("default", [_v("默认内容")])], 2),
_c("footer", [_t("footer")], 2),
]
);
}
在编译章节我们了解到,_t
函数对应的就是 renderSlot
方法,它的定义在 src/core/instance/render-heplpers/render-slot.js
中:
/**
* Runtime helper for rendering <slot>
*/
export function renderSlot(
name: string,
fallback: ?Array<VNode>,
props: ?Object,
bindObject: ?Object
): ?Array<VNode> {
const scopedSlotFn = this.$scopedSlots[name];
let nodes;
if (scopedSlotFn) {
// scoped slot
props = props || {};
if (bindObject) {
if (process.env.NODE_ENV !== "production" && !isObject(bindObject)) {
warn("slot v-bind without argument expects an Object", this);
}
props = extend(extend({}, bindObject), props);
}
nodes = scopedSlotFn(props) || fallback;
} else {
const slotNodes = this.$slots[name];
// warn duplicate slot usage
if (slotNodes) {
if (process.env.NODE_ENV !== "production" && slotNodes._rendered) {
warn(
`Duplicate presence of slot "${name}" found in the same render tree ` +
`- this will likely cause render errors.`,
this
);
}
slotNodes._rendered = true;
}
nodes = slotNodes || fallback;
}
const target = props && props.slot;
if (target) {
return this.$createElement("template", { slot: target }, nodes);
} else {
return nodes;
}
}
render-slot
的参数 name
代表插槽名称 slotName
,fallback
代表插槽的默认内容生成的 vnode
数组。先忽略 scoped-slot
,只看默认插槽逻辑。如果 this.$slot[name]
有值,就返回它对应的 vnode
数组,否则返回 fallback
。那么这个 this.$slot
是哪里来的呢?我们知道子组件的 init
时机是在父组件执行 patch
过程的时候,那这个时候父组件已经编译完成了。并且子组件在 init
过程中会执行 initRender
函数,initRender
的时候获取到 vm.$slot
,相关代码在 src/core/instance/render.js
中:
export function initRender(vm: Component) {
// ...
const parentVnode = (vm.$vnode = options._parentVnode); // the placeholder node in parent tree
const renderContext = parentVnode && parentVnode.context;
vm.$slots = resolveSlots(options._renderChildren, renderContext);
}
vm.$slots
是通过执行 resolveSlots(options._renderChildren, renderContext)
返回的,它的定义在 src/core/instance/render-helpers/resolve-slots.js
中:
/**
* Runtime helper for resolving raw children VNodes into a slot object.
*/
export function resolveSlots(
children: ?Array<VNode>,
context: ?Component
): { [key: string]: Array<VNode> } {
const slots = {};
if (!children) {
return slots;
}
for (let i = 0, l = children.length; i < l; i++) {
const child = children[i];
const data = child.data;
// remove slot attribute if the node is resolved as a Vue slot node
if (data && data.attrs && data.attrs.slot) {
delete data.attrs.slot;
}
// named slots should only be respected if the vnode was rendered in the
// same context.
if (
(child.context === context || child.fnContext === context) &&
data &&
data.slot != null
) {
const name = data.slot;
const slot = slots[name] || (slots[name] = []);
if (child.tag === "template") {
slot.push.apply(slot, child.children || []);
} else {
slot.push(child);
}
} else {
(slots.default || (slots.default = [])).push(child);
}
}
// ignore slots that contains only whitespace
for (const name in slots) {
if (slots[name].every(isWhitespace)) {
delete slots[name];
}
}
return slots;
}
resolveSlots
方法接收 2 个参数,第一个参数 chilren
对应的是父 vnode
的 children
,在我们的例子中就是 <app-layout>
和 </app-layout>
包裹的内容。第二个参数 context
是父 vnode
的上下文,也就是父组件的 vm
实例。
resolveSlots
函数的逻辑就是遍历 chilren
,拿到每一个 child
的 data
,然后通过 data.slot
获取到插槽名称,这个 slot
就是我们之前编译父组件在 codegen
阶段设置的 data.slot
。接着以插槽名称为 key
把 child
添加到 slots
中,如果 data.slot
不存在,则是默认插槽的内容,则把对应的 child
添加到 slots.defaults
中。这样就获取到整个 slots
,它是一个对象,key
是插槽名称,value
是一个 vnode
类型的数组,因为它可以有多个同名插槽。
这样我们就拿到了 vm.$slots
了,回到 renderSlot
函数,const slotNodes = this.$slots[name]
,我们也就能根据插槽名称获取到对应的 vnode
数组了,这个数组里的 vnode
都是在父组件创建的,这样就实现了在父组件替换子组件插槽的内容了。
对应的 slot
渲染成 vnodes
,作为当前组件渲染 vnode
的 children
,之后的渲染过程之前分析过,不再赘述。
我们知道在普通插槽中,父组件应用到子组件插槽里的数据都是绑定到父组件的,因为它渲染成 vnode
的时机的上下文是父组件的实例。但是在一些实际开发中,我们想通过子组件的一些数据来决定父组件实现插槽的逻辑,Vue 提供了另一种插槽——作用域插槽,接下来我们就来分析一下它的实现原理。
作用域插槽
为了更加直观,我们也是通过一个例子来分析作用域插槽的实现:
let Child = {
template:
'<div class="child">' + '<slot text="Hello " :msg="msg"></slot>' + "</div>",
data() {
return {
msg: "Vue",
};
},
};
let vm = new Vue({
el: "#app",
template:
"<div>" +
"<child>" +
'<template slot-scope="props">' +
"<p>Hello from parent</p>" +
"<p>{{ props.text + props.msg}}</p>" +
"</template>" +
"</child>" +
"</div>",
components: {
Child,
},
});
最终生成的 DOM 结构如下:
<div>
<div class="child">
<p>Hello from parent</p>
<p>Hello Vue</p>
</div>
</div>
我们可以看到子组件的 slot
标签多了 text
属性,以及 :msg
属性。父组件实现插槽的部分多了一个 template
标签,以及 scope-slot
属性,其实在 Vue 2.5+ 版本,scoped-slot
可以作用在普通元素上。这些就是作用域插槽和普通插槽在写法上的差别。
在编译阶段,仍然是先编译父组件,同样是通过 processSlot
函数去处理 scoped-slot
,它的定义在在 src/compiler/parser/index.js
中:
function processSlot(el) {
// ...
let slotScope;
if (el.tag === "template") {
slotScope = getAndRemoveAttr(el, "scope");
/* istanbul ignore if */
if (process.env.NODE_ENV !== "production" && slotScope) {
warn(
`the "scope" attribute for scoped slots have been deprecated and ` +
`replaced by "slot-scope" since 2.5. The new "slot-scope" attribute ` +
`can also be used on plain elements in addition to <template> to ` +
`denote scoped slots.`,
true
);
}
el.slotScope = slotScope || getAndRemoveAttr(el, "slot-scope");
} else if ((slotScope = getAndRemoveAttr(el, "slot-scope"))) {
/* istanbul ignore if */
if (process.env.NODE_ENV !== "production" && el.attrsMap["v-for"]) {
warn(
`Ambiguous combined usage of slot-scope and v-for on <${el.tag}> ` +
`(v-for takes higher priority). Use a wrapper <template> for the ` +
`scoped slot to make it clearer.`,
true
);
}
el.slotScope = slotScope;
}
// ...
}
这块逻辑很简单,读取 scoped-slot
属性并赋值给当前 AST 元素节点的 slotScope
属性,接下来在构造 AST 树的时候,会执行以下逻辑:
if (element.elseif || element.else) {
processIfConditions(element, currentParent);
} else if (element.slotScope) {
currentParent.plain = false;
const name = element.slotTarget || '"default"';
(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] =
element;
} else {
currentParent.children.push(element);
element.parent = currentParent;
}
可以看到对于拥有 scopedSlot
属性的 AST 元素节点而言,是不会作为 children
添加到当前 AST 树中,而是存到父 AST 元素节点的 scopedSlots
属性上,它是一个对象,以插槽名称 name
为 key
。
然后在 genData
的过程,会对 scopedSlots
做处理:
if (el.scopedSlots) {
data += `${genScopedSlots(el.scopedSlots, state)},`;
}
function genScopedSlots(
slots: { [key: string]: ASTElement },
state: CodegenState
): string {
return `scopedSlots:_u([${Object.keys(slots)
.map((key) => {
return genScopedSlot(key, slots[key], state);
})
.join(",")}])`;
}
function genScopedSlot(
key: string,
el: ASTElement,
state: CodegenState
): string {
if (el.for && !el.forProcessed) {
return genForScopedSlot(key, el, state);
}
const fn =
`function(${String(el.slotScope)}){` +
`return ${
el.tag === "template"
? el.if
? `${el.if}?${genChildren(el, state) || "undefined"}:undefined`
: genChildren(el, state) || "undefined"
: genElement(el, state)
}}`;
return `{key:${key},fn:${fn}}`;
}
genScopedSlots
就是对 scopedSlots
对象遍历,执行 genScopedSlot
,并把结果用逗号拼接,而 genScopedSlot
是先生成一段函数代码,并且函数的参数就是我们的 slotScope
,也就是写在标签属性上的 scoped-slot
对应的值,然后再返回一个对象,key
为插槽名称,fn
为生成的函数代码。
对于我们这个例子而言,父组件最终生成的代码如下:
with (this) {
return _c(
"div",
[
_c("child", {
scopedSlots: _u([
{
key: "default",
fn: function (props) {
return [
_c("p", [_v("Hello from parent")]),
_c("p", [_v(_s(props.text + props.msg))]),
];
},
},
]),
}),
],
1
);
}
可以看到它和普通插槽父组件编译结果的一个很明显的区别就是没有 children
了,data
部分多了一个对象,并且执行了 _u
方法,在编译章节我们了解到,_u
函数对的就是 resolveScopedSlots
方法,它的定义在 src/core/instance/render-heplpers/resolve-slots.js
中:
export function resolveScopedSlots(
fns: ScopedSlotsData, // see flow/vnode
res?: Object
): { [key: string]: Function } {
res = res || {};
for (let i = 0; i < fns.length; i++) {
if (Array.isArray(fns[i])) {
resolveScopedSlots(fns[i], res);
} else {
res[fns[i].key] = fns[i].fn;
}
}
return res;
}
其中,fns
是一个数组,每一个数组元素都有一个 key
和一个 fn
,key
对应的是插槽的名称,fn
对应一个函数。整个逻辑就是遍历这个 fns
数组,生成一个对象,对象的 key
就是插槽名称,value
就是函数。这个函数的执行时机稍后我们会介绍。
接着我们再来看一下子组件的编译,和普通插槽的过程基本相同,唯一一点区别是在 genSlot
的时候:
function genSlot(el: ASTElement, state: CodegenState): string {
const slotName = el.slotName || '"default"';
const children = genChildren(el, state);
let res = `_t(${slotName}${children ? `,${children}` : ""}`;
const attrs =
el.attrs &&
`{${el.attrs.map((a) => `${camelize(a.name)}:${a.value}`).join(",")}}`;
const bind = el.attrsMap["v-bind"];
if ((attrs || bind) && !children) {
res += `,null`;
}
if (attrs) {
res += `,${attrs}`;
}
if (bind) {
res += `${attrs ? "" : ",null"},${bind}`;
}
return res + ")";
}
它会对 attrs
和 v-bind
做处理,对应到我们的例子,最终生成的代码如下:
with (this) {
return _c(
"div",
{ staticClass: "child" },
[_t("default", null, { text: "Hello ", msg: msg })],
2
);
}
_t
方法我们之前介绍过,对应的是 renderSlot
方法:
export function renderSlot(
name: string,
fallback: ?Array<VNode>,
props: ?Object,
bindObject: ?Object
): ?Array<VNode> {
const scopedSlotFn = this.$scopedSlots[name];
let nodes;
if (scopedSlotFn) {
props = props || {};
if (bindObject) {
if (process.env.NODE_ENV !== "production" && !isObject(bindObject)) {
warn("slot v-bind without argument expects an Object", this);
}
props = extend(extend({}, bindObject), props);
}
nodes = scopedSlotFn(props) || fallback;
} else {
// ...
}
const target = props && props.slot;
if (target) {
return this.$createElement("template", { slot: target }, nodes);
} else {
return nodes;
}
}
我们只关注作用域插槽的逻辑,那么这个 this.$scopedSlots
又是在什么地方定义的呢,原来在子组件的渲染函数执行前,在 vm_render
方法内,有这么一段逻辑,定义在 src/core/instance/render.js
中:
if (_parentVnode) {
vm.$scopedSlots = _parentVnode.data.scopedSlots || emptyObject;
}
这个 _parentVNode.data.scopedSlots
对应的就是我们在父组件通过执行 resolveScopedSlots
返回的对象。所以回到 genSlot
函数,我们就可以通过插槽的名称拿到对应的 scopedSlotFn
,然后把相关的数据扩展到 props
上,作为函数的参数传入,原来之前我们提到的函数这个时候执行,然后返回生成的 vnodes
,为后续渲染节点用。
后续流程之前已介绍过,不再赘述,那么至此,作用域插槽的实现也就分析完毕。
总结
普通插槽和作用域插槽,它们有一个很大的差别是数据作用域,
普通插槽是在父组件编译和渲染阶段生成 vnodes
,所以数据的作用域是父组件实例,子组件渲染的时候直接拿到这些渲染好的 vnodes
。
而对于作用域插槽,父组件在编译和渲染阶段并不会直接生成 vnodes
,而是在父节点 vnode
的 data
中==保留一个 scopedSlots
对象,存储着不同名称的插槽以及它们对应的渲染函数==,只有==在编译和渲染子组件阶段才会执行这个渲染函数生成 vnodes
==,由于是在子组件环境执行的,所以对应的数据作用域是子组件实例。
简单地说,两种插槽的目的都是让子组件 slot
占位符生成的内容由父组件来决定,但数据的作用域会根据它们 vnodes
渲染时机不同而不同。
普通插槽
1.编译是发生在调用 vm.$mount
的时候,所以编译的顺序是先编译父组件,再编译子组件
首先编译父组件,在 parse
阶段,会执行 processSlot
处理 slot
2.当解析到标签上有 slot
属性的时候,会给对应的 AST 元素节点添加 slotTarget
属性,然后在 codegen
阶段,在 genData
中会处理 slotTarget
3.会给 data
添加一个 slot
属性,并指向 slotTarget
父组件最终生成
with (this) {
return _c("div", [], 1);
}
4.接下来编译子组件,同样在 parser
阶段会执行 processSlot
处理函数
5.当遇到 slot
标签的时候会给对应的 AST 元素节点添加 slotName
属性,然后在 codegen
阶段,会判断如果当前 AST 元素节点是 slot
标签,则执行 genSlot
函数
不考虑 slot
标签上有 attrs
以及 v-bind
的情况,那么它生成的代码实际上就只有
const slotName = el.slotName || '"default"';
const children = genChildren(el, state);
let res = `_t(${slotName}${children ? `,${children}` : ""}`;
6.slotName
从 AST 元素节点对应的属性上取,默认是 default
,而 children
对应的就是 slot
开始和闭合标签包裹的内容 子组件最终生成的代码
with (this) {
return _c(
"div",
{
staticClass: "container",
},
[
_c("header", [_t("header")], 2),
_c("main", [_t("default", [_v("默认内容")])], 2),
_c("footer", [_t("footer")], 2),
]
);
}
7._t
函数对应的就是 renderSlot
方法,render-slot
的参数 name
代表插槽名称 slotName
,fallback
代表插槽的默认内容生成的 vnode
数组
默认插槽逻辑 如果 this.$slot[name]
有值,就返回它对应的 vnode
数组,否则返回 fallback
==子组件的 init
时机是在父组件执行 patch
过程的时候,那这个时候父组件已经编译完成了。并且子组件在 init
过程中会执行 initRender
函数==,initRender
的时候获取到 vm.$slot
8.vm.$slots
是通过执行 resolveSlots(options._renderChildren, renderContext)
返回的
resolveSlots方法接收 2 个参数,第一个参数
chilren对应的是父
vnode的
children
例子中就是 <app-layout>
和 </app-layout>
包裹的内容。第二个参数 context
是父 vnode
的上下文,也就是父组件的 vm
实例
9.resolveSlots
函数的逻辑就是遍历 chilren
,拿到每一个 child
的 data
,然后通过 data.slot
获取到插槽名称,这个 slot
就是我们之前编译父组件在 codegen
阶段设置的 data.slot
。接着以插槽名称为 key
把 child
添加到 slots
中,如果 data.slot
不存在,则是默认插槽的内容,则把对应的 child
添加到 slots.defaults
中。这样就获取到整个 slots
,它是一个对象,key
是插槽名称,value
是一个 vnode
类型的数组,因为它可以有多个同名插槽
10.拿到了 vm.$slots
了,回到 renderSlot
函数,const slotNodes = this.$slots[name]
,我们也就能根据插槽名称获取到对应的 vnode
数组了,这个数组里的 vnode
都是在父组件创建的,这样就实现了在父组件替换子组件插槽的内容了。
对应的 slot
渲染成 vnodes
,作为当前组件渲染 vnode
的 children
普通插槽中,父组件应用到子组件插槽里的数据都是绑定到父组件的,因为它渲染成 vnode
的时机的上下文是父组件的实例
作用域插槽
在一些实际开发中,我们想通过子组件的一些数据来决定父组件实现插槽的逻辑,Vue 提供了另一种插槽——作用域插槽
1.在编译阶段,仍然是先编译父组件,同样是通过 processSlot
函数去处理 scoped-slot
2.读取 scoped-slot
属性并赋值给当前 AST 元素节点的 slotScope
属性,构造 AST 树
3.==对于拥有 scopedSlot
属性的 AST 元素节点而言,是不会作为 children
添加到当前 AST 树中,而是存到父 AST 元素节点的 scopedSlots
属性上,它是一个对象,以插槽名称 name
为 key
==
4.然后在 genData
的过程,会对 scopedSlots
做处理 ,在genScopedSlots
就是对 scopedSlots
对象遍历,执行 genScopedSlot
,并把结果用逗号拼接,而 genScopedSlot
是先生成一段函数代码,并且函数的参数就是我们的 slotScope
,也就是写在标签属性上的 scoped-slot
对应的值,然后再返回一个对象,key
为插槽名称,fn
为生成的函数代码
父组件最终生成的代码
with (this) {
return _c(
"div",
[
_c("child", {
scopedSlots: _u([
{
key: "default",
fn: function (props) {
return [
_c("p", [_v("Hello from parent")]),
_c("p", [_v(_s(props.text + props.msg))]),
];
},
},
]),
}),
],
1
);
}
5.普通插槽父组件编译结果的一个很明显的区别就是没有 children
了,data
部分多了一个对象,并且执行了 _u
方法,_u
函数对的就是 resolveScopedSlots
方法
6.resolveScopedSlots 中 fns 是一个数组,每一个数组元素都有一个 key
和一个 fn
,key
对应的是插槽的名称,fn
对应一个函数。整个逻辑就是遍历这个 fns
数组,生成一个对象,对象的 key
就是插槽名称,value
就是函数
7.组件的编译,和普通插槽的过程基本相同,唯一一点区别是在 genSlot
的时候,它会对 attrs
和 v-bind
做处理,对应到我们的例子
with (this) {
return _c(
"div",
{ staticClass: "child" },
[_t("default", null, { text: "Hello ", msg: msg })],
2
);
}
8._t
方法对应的是 renderSlot
方法,在子组件的渲染函数执行前,在 vm_render
方法内
if (_parentVnode) {
vm.$scopedSlots = _parentVnode.data.scopedSlots || emptyObject;
}
这个 _parentVNode.data.scopedSlots
对应的就是我们在父组件通过执行 resolveScopedSlots
返回的对象。所以回到 genSlot
函数,我们就可以通过插槽的名称拿到对应的 scopedSlotFn
,然后把相关的数据扩展到 props
上,作为函数的参数传入,原来之前我们提到的函数这个时候执行,然后返回生成的 vnodes
,为后续渲染节点用
普通插槽和作用域插槽的实现,它们有一个很大的差别是数据作用域,普通插槽是在父组件编译和渲染阶段生成 vnodes
,所以数据的作用域是父组件实例,子组件渲染的时候直接拿到这些渲染好的 vnodes
。而对于作用域插槽,父组件在编译和渲染阶段并不会直接生成 vnodes
,而是在父节点 vnode
的 data
中保留一个 scopedSlots
对象,存储着不同名称的插槽以及它们对应的渲染函数,==只有在编译和渲染子组件阶段才会执行这个渲染函数生成 vnodes
,由于是在子组件环境执行的,所以对应的数据作用域是子组件实例==。
简单地说,两种插槽的目的都是让子组件 slot
占位符生成的内容由父组件来决定,但数据的作用域会根据它们 vnodes
渲染时机不同而不同。
keep-alive
内置组件
<keep-alive>
是 Vue 源码中实现的一个组件,也就是说 Vue 源码不仅实现了一套组件化的机制,也实现了一些内置组件,它的定义在 src/core/components/keep-alive.js
中:
export default {
name: "keep-alive",
abstract: true,
props: {
include: patternTypes,
exclude: patternTypes,
max: [String, Number],
},
created() {
this.cache = Object.create(null);
this.keys = [];
},
destroyed() {
for (const key in this.cache) {
pruneCacheEntry(this.cache, key, this.keys);
}
},
mounted() {
this.$watch("include", (val) => {
pruneCache(this, (name) => matches(val, name));
});
this.$watch("exclude", (val) => {
pruneCache(this, (name) => !matches(val, name));
});
},
render() {
const slot = this.$slots.default;
const vnode: VNode = getFirstComponentChild(slot);
const componentOptions: ?VNodeComponentOptions =
vnode && vnode.componentOptions;
if (componentOptions) {
// check pattern
const name: ?string = getComponentName(componentOptions);
const { include, exclude } = this;
if (
// not included
(include && (!name || !matches(include, name))) ||
// excluded
(exclude && name && matches(exclude, name))
) {
return vnode;
}
const { cache, keys } = this;
const key: ?string =
vnode.key == null
? // same constructor may get registered as different local components
// so cid alone is not enough (#3269)
componentOptions.Ctor.cid +
(componentOptions.tag ? `::${componentOptions.tag}` : "")
: vnode.key;
if (cache[key]) {
vnode.componentInstance = cache[key].componentInstance;
// make current key freshest
remove(keys, key);
keys.push(key);
} else {
cache[key] = vnode;
keys.push(key);
// prune oldest entry
if (this.max && keys.length > parseInt(this.max)) {
pruneCacheEntry(cache, keys[0], keys, this._vnode);
}
}
vnode.data.keepAlive = true;
}
return vnode || (slot && slot[0]);
},
};
可以看到 ==<keep-alive>
组件的实现也是一个对象,注意它有一个属性 abstract
为 true,是一个抽象组件==,Vue 的文档没有提这个概念,实际上它在组件实例建立父子关系的时候会被忽略,发生在 initLifecycle
的过程中:
// locate first non-abstract parent
let parent = options.parent;
if (parent && !options.abstract) {
while (parent.$options.abstract && parent.$parent) {
parent = parent.$parent;
}
parent.$children.push(vm);
}
vm.$parent = parent;
<keep-alive>
在 created
钩子里定义了 this.cache
和 this.keys
,本质上它就是去缓存已经创建过的 vnode
。它的 props
定义了 include
,exclude
,它们可以字符串或者表达式,include
表示只有匹配的组件会被缓存,而 exclude
表示任何匹配的组件都不会被缓存,props
还定义了 max
,它表示缓存的大小,因为我们是缓存的 vnode
对象,它也会持有 DOM,当我们缓存很多的时候,会比较占用内存,所以该配置允许我们指定缓存大小。
<keep-alive>
直接实现了 render
函数,而不是我们常规模板的方式,执行 <keep-alive>
组件渲染的时候,就会执行到这个 render
函数,接下来我们分析一下它的实现。
首先获取第一个子元素的 vnode
:
const slot = this.$slots.default;
const vnode: VNode = getFirstComponentChild(slot);
由于我们也是在 <keep-alive>
标签内部写 DOM,所以可以先获取到它的默认插槽,然后再获取到它的第一个子节点。<keep-alive>
只处理第一个子元素,所以一般和它搭配使用的有 component
动态组件或者是 router-view
,这点要牢记。
然后又判断了当前组件的名称和 include
、exclude
的关系:
// check pattern
const name: ?string = getComponentName(componentOptions);
const { include, exclude } = this;
if (
// not included
(include && (!name || !matches(include, name))) ||
// excluded
(exclude && name && matches(exclude, name))
) {
return vnode;
}
function matches(
pattern: string | RegExp | Array<string>,
name: string
): boolean {
if (Array.isArray(pattern)) {
return pattern.indexOf(name) > -1;
} else if (typeof pattern === "string") {
return pattern.split(",").indexOf(name) > -1;
} else if (isRegExp(pattern)) {
return pattern.test(name);
}
return false;
}
matches
的逻辑很简单,就是做匹配,分别处理了数组、字符串、正则表达式的情况,也就是说我们平时传的 include
和 exclude
可以是这三种类型的任意一种。并且我们的组件名如果满足了配置 include
且不匹配或者是配置了 exclude
且匹配,那么就直接返回这个组件的 vnode
,否则的话走下一步缓存:
const { cache, keys } = this;
const key: ?string =
vnode.key == null
? // same constructor may get registered as different local components
// so cid alone is not enough (#3269)
componentOptions.Ctor.cid +
(componentOptions.tag ? `::${componentOptions.tag}` : "")
: vnode.key;
if (cache[key]) {
vnode.componentInstance = cache[key].componentInstance;
// make current key freshest
remove(keys, key);
keys.push(key);
} else {
cache[key] = vnode;
keys.push(key);
// prune oldest entry
if (this.max && keys.length > parseInt(this.max)) {
pruneCacheEntry(cache, keys[0], keys, this._vnode);
}
}
这部分逻辑很简单,如果命中缓存,则直接从缓存中拿 vnode
的组件实例,并且重新调整了 key 的顺序放在了最后一个;否则把 vnode
设置进缓存,最后还有一个逻辑,如果配置了 max
并且缓存的长度超过了 this.max
,还要从缓存中删除第一个:
function pruneCacheEntry(
cache: VNodeCache,
key: string,
keys: Array<string>,
current?: VNode
) {
const cached = cache[key];
if (cached && (!current || cached.tag !== current.tag)) {
cached.componentInstance.$destroy();
}
cache[key] = null;
remove(keys, key);
}
除了从缓存中删除外,还要判断如果要删除的缓存并的组件 tag
不是当前渲染组件 tag
,也执行删除缓存的组件实例的 $destroy
方法。
最后设置 vnode.data.keepAlive = true
注意,<keep-alive>
组件也是为观测 include
和 exclude
的变化,对缓存做处理:
watch: {
include (val: string | RegExp | Array<string>) {
pruneCache(this, name => matches(val, name))
},
exclude (val: string | RegExp | Array<string>) {
pruneCache(this, name => !matches(val, name))
}
}
function pruneCache (keepAliveInstance: any, filter: Function) {
const { cache, keys, _vnode } = keepAliveInstance
for (const key in cache) {
const cachedNode: ?VNode = cache[key]
if (cachedNode) {
const name: ?string = getComponentName(cachedNode.componentOptions)
if (name && !filter(name)) {
pruneCacheEntry(cache, key, keys, _vnode)
}
}
}
}
逻辑很简单,观测他们的变化执行 pruneCache
函数,其实就是对 cache
做遍历,发现缓存的节点名称和新的规则没有匹配上的时候,就把这个缓存节点从缓存中摘除。
组件渲染
到此为止,我们只了解了 <keep-alive>
的组件实现,但并不知道它包裹的子组件渲染和普通组件有什么不一样的地方。我们关注 2 个方面,首次渲染和缓存渲染。
同样为了更好地理解,我们也结合一个示例来分析:
let A = {
template: '<div class="a">' + "<p>A Comp</p>" + "</div>",
name: "A",
};
let B = {
template: '<div class="b">' + "<p>B Comp</p>" + "</div>",
name: "B",
};
let vm = new Vue({
el: "#app",
template:
"<div>" +
"<keep-alive>" +
'<component :is="currentComp">' +
"</component>" +
"</keep-alive>" +
'<button @click="change">switch</button>' +
"</div>",
data: {
currentComp: "A",
},
methods: {
change() {
this.currentComp = this.currentComp === "A" ? "B" : "A";
},
},
components: {
A,
B,
},
});
首次渲染
我们知道 Vue 的渲染==最后都会到 patch
过程,而组件的 patch
过程会执行 createComponent
方法==,它的定义在 src/core/vdom/patch.js
中:
function createComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
let i = vnode.data;
if (isDef(i)) {
const isReactivated = isDef(vnode.componentInstance) && i.keepAlive;
if (isDef((i = i.hook)) && isDef((i = i.init))) {
i(vnode, false /* hydrating */);
}
// after calling the init hook, if the vnode is a child component
// it should've created a child instance and mounted it. the child
// component also has set the placeholder vnode's elm.
// in that case we can just return the element and be done.
if (isDef(vnode.componentInstance)) {
initComponent(vnode, insertedVnodeQueue);
insert(parentElm, vnode.elm, refElm);
if (isTrue(isReactivated)) {
reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);
}
return true;
}
}
}
createComponent
定义了 isReactivated
的变量,它是==根据 vnode.componentInstance
以及 vnode.data.keepAlive
的判断==,
==第一次渲染的时候,vnode.componentInstance
为 undefined
,vnode.data.keepAlive
为 true,因为它的父组件 <keep-alive>
的 render
函数会先执行,那么该 vnode
缓存到内存中,并且设置 vnode.data.keepAlive
为 true==,
因此 isReactivated
为 false
,那么走正常的 init
的钩子函数执行组件的 mount
。==当 vnode
已经执行完 patch
后,执行 initComponent
函数:==
function initComponent(vnode, insertedVnodeQueue) {
if (isDef(vnode.data.pendingInsert)) {
insertedVnodeQueue.push.apply(insertedVnodeQueue, vnode.data.pendingInsert);
vnode.data.pendingInsert = null;
}
vnode.elm = vnode.componentInstance.$el;
if (isPatchable(vnode)) {
invokeCreateHooks(vnode, insertedVnodeQueue);
setScope(vnode);
} else {
// empty component root.
// skip all element-related modules except for ref (#3455)
registerRef(vnode);
// make sure to invoke the insert hook
insertedVnodeQueue.push(vnode);
}
}
这里会==有 vnode.elm
缓存了 vnode
创建生成的 DOM 节点==。所以==对于首次渲染而言,除了在 <keep-alive>
中建立缓存,和普通组件渲染没什么区别==。
所以对我们的例子,初始化渲染 A
组件以及第一次点击 switch
渲染 B
组件,都是首次渲染。
缓存渲染
当我们从 B
组件再次点击 switch
切换到 A
组件,就会命中缓存渲染。
我们之前分析过,==当数据发送变化,在 patch
的过程中会执行 patchVnode
的逻辑,它会对比新旧 vnode
节点,甚至对比它们的子节点去做更新逻辑,但是对于组件 vnode
而言,是没有 children
的==,那么对于 <keep-alive>
组件而言,如何更新它包裹的内容呢?
原来 ==patchVnode
在做各种 diff 之前,会先执行 prepatch
的钩子函数==,它的定义在 src/core/vdom/create-component
中:
const componentVNodeHooks = {
prepatch(oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
const options = vnode.componentOptions;
const child = (vnode.componentInstance = oldVnode.componentInstance);
updateChildComponent(
child,
options.propsData, // updated props
options.listeners, // updated listeners
vnode, // new parent vnode
options.children // new children
);
},
// ...
};
prepatch
核心逻辑就是==执行 updateChildComponent
方法==,它的定义在 src/core/instance/lifecycle.js
中:
export function updateChildComponent(
vm: Component,
propsData: ?Object,
listeners: ?Object,
parentVnode: MountedComponentVNode,
renderChildren: ?Array<VNode>
) {
const hasChildren = !!(
renderChildren ||
vm.$options._renderChildren ||
parentVnode.data.scopedSlots ||
vm.$scopedSlots !== emptyObject
);
// ...
if (hasChildren) {
vm.$slots = resolveSlots(renderChildren, parentVnode.context);
vm.$forceUpdate();
}
}
updateChildComponent
方法主要是去更新组件实例的一些属性,这里我们重点关注一下 slot
部分,
由于 <keep-alive>
组件本质上支持了 slot
,所以它执行 prepatch
的时候,需要对自己的 children
,也就是这些 slots
做重新解析,并触发 <keep-alive>
组件实例 $forceUpdate
逻辑,也就是重新执行 <keep-alive>
的 render
方法,
==这个时候如果它包裹的第一个组件 vnode
命中缓存,则直接返回缓存中的 vnode.componentInstance
,在我们的例子中就是缓存的 A
组件,接着又会执行 patch
过程,再次执行到 createComponent
方法==,我们再回顾一下:
function createComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
let i = vnode.data;
if (isDef(i)) {
const isReactivated = isDef(vnode.componentInstance) && i.keepAlive;
if (isDef((i = i.hook)) && isDef((i = i.init))) {
i(vnode, false /* hydrating */);
}
// after calling the init hook, if the vnode is a child component
// it should've created a child instance and mounted it. the child
// component also has set the placeholder vnode's elm.
// in that case we can just return the element and be done.
if (isDef(vnode.componentInstance)) {
initComponent(vnode, insertedVnodeQueue);
insert(parentElm, vnode.elm, refElm);
if (isTrue(isReactivated)) {
reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);
}
return true;
}
}
}
==这个时候 isReactivated
为 true,并且在执行 init
钩子函数的时候不会再执行组件的 mount
过程==了,相关逻辑在 src/core/vdom/create-component.js
中:
const componentVNodeHooks = {
init(vnode: VNodeWithData, hydrating: boolean): ?boolean {
if (
vnode.componentInstance &&
!vnode.componentInstance._isDestroyed &&
vnode.data.keepAlive
) {
// kept-alive components, treat as a patch
const mountedNode: any = vnode; // work around flow
componentVNodeHooks.prepatch(mountedNode, mountedNode);
} else {
const child = (vnode.componentInstance = createComponentInstanceForVnode(
vnode,
activeInstance
));
child.$mount(hydrating ? vnode.elm : undefined, hydrating);
}
},
// ...
};
这也就是被 <keep-alive>
包裹的组件在有缓存的时候就不会在执行组件的 created
、mounted
等钩子函数的原因了。回到 createComponent
方法,在 isReactivated
为 true 的情况下会执行 reactivateComponent
方法:
function reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
let i;
// hack for #4339: a reactivated component with inner transition
// does not trigger because the inner node's created hooks are not called
// again. It's not ideal to involve module-specific logic in here but
// there doesn't seem to be a better way to do it.
let innerNode = vnode;
while (innerNode.componentInstance) {
innerNode = innerNode.componentInstance._vnode;
if (isDef((i = innerNode.data)) && isDef((i = i.transition))) {
for (i = 0; i < cbs.activate.length; ++i) {
cbs.activate[i](emptyNode, innerNode);
}
insertedVnodeQueue.push(innerNode);
break;
}
}
// unlike a newly created component,
// a reactivated keep-alive component doesn't insert itself
insert(parentElm, vnode.elm, refElm);
}
前面部分的逻辑是解决对 reactived
组件 transition
动画不触发的问题,可以先不关注,最后通过执行 insert(parentElm, vnode.elm, refElm)
就把缓存的 DOM 对象直接插入到目标元素中,这样就完成了在数据更新的情况下的渲染过程。
生命周期
之前我们提到,==组件一旦被 <keep-alive>
缓存,那么再次渲染的时候就不会执行 created
、mounted
等钩子函数,但是我们很多业务场景都是希望在我们被缓存的组件再次被渲染的时候做一些事情,好在 Vue 提供了 activated
钩子函数,它的执行时机是 <keep-alive>
包裹的组件渲染的时候==,接下来我们从源码角度来分析一下它的实现原理。
在渲染的最后一步,会执行 invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
函数执行 vnode
的 insert
钩子函数,它的定义在 src/core/vdom/create-component.js
中:
const componentVNodeHooks = {
insert(vnode: MountedComponentVNode) {
const { context, componentInstance } = vnode;
if (!componentInstance._isMounted) {
componentInstance._isMounted = true;
callHook(componentInstance, "mounted");
}
if (vnode.data.keepAlive) {
if (context._isMounted) {
// vue-router#1212
// During updates, a kept-alive component's child components may
// change, so directly walking the tree here may call activated hooks
// on incorrect children. Instead we push them into a queue which will
// be processed after the whole patch process ended.
queueActivatedComponent(componentInstance);
} else {
activateChildComponent(componentInstance, true /* direct */);
}
}
},
// ...
};
这里判断如果是被 <keep-alive>
包裹的组件已经 mounted
,那么则执行 queueActivatedComponent(componentInstance)
,否则执行 activateChildComponent(componentInstance, true)
。我们先分析非 mounted
的情况,activateChildComponent
的定义在 src/core/instance/lifecycle.js
中:
export function activateChildComponent(vm: Component, direct?: boolean) {
if (direct) {
vm._directInactive = false;
if (isInInactiveTree(vm)) {
return;
}
} else if (vm._directInactive) {
return;
}
if (vm._inactive || vm._inactive === null) {
vm._inactive = false;
for (let i = 0; i < vm.$children.length; i++) {
activateChildComponent(vm.$children[i]);
}
callHook(vm, "activated");
}
}
可以看到这里就是执行组件的 acitvated
钩子函数,并且递归去执行它的所有子组件的 activated
钩子函数。
那么再看 queueActivatedComponent
的逻辑,它定义在 src/core/observer/scheduler.js
中:
export function queueActivatedComponent(vm: Component) {
vm._inactive = false;
activatedChildren.push(vm);
}
这个逻辑很简单,把当前 vm
实例添加到 activatedChildren
数组中,等所有的渲染完毕,在 nextTick
后会执行 flushSchedulerQueue
,这个时候就会执行:
function flushSchedulerQueue() {
// ...
const activatedQueue = activatedChildren.slice();
callActivatedHooks(activatedQueue);
// ...
}
function callActivatedHooks(queue) {
for (let i = 0; i < queue.length; i++) {
queue[i]._inactive = true;
activateChildComponent(queue[i], true);
}
}
也就是遍历所有的 activatedChildren
,执行 activateChildComponent
方法,通过队列调的方式就是把整个 activated
时机延后了。
有 activated
钩子函数,也就有对应的 deactivated
钩子函数,它是发生在 vnode
的 destory
钩子函数,定义在 src/core/vdom/create-component.js
中:
const componentVNodeHooks = {
destroy(vnode: MountedComponentVNode) {
const { componentInstance } = vnode;
if (!componentInstance._isDestroyed) {
if (!vnode.data.keepAlive) {
componentInstance.$destroy();
} else {
deactivateChildComponent(componentInstance, true /* direct */);
}
}
},
};
对于 <keep-alive>
包裹的组件而言,它会执行 deactivateChildComponent(componentInstance, true)
方法,定义在 src/core/instance/lifecycle.js
中:
export function deactivateChildComponent(vm: Component, direct?: boolean) {
if (direct) {
vm._directInactive = true;
if (isInInactiveTree(vm)) {
return;
}
}
if (!vm._inactive) {
vm._inactive = true;
for (let i = 0; i < vm.$children.length; i++) {
deactivateChildComponent(vm.$children[i]);
}
callHook(vm, "deactivated");
}
}
和 activateChildComponent
方法类似,就是执行组件的 deacitvated
钩子函数,并且递归去执行它的所有子组件的 deactivated
钩子函数。
总结
通过分析我们知道了 <keep-alive>
组件是一个抽象组件,它的实现通过自定义 render
函数并且利用了插槽,并且知道了 <keep-alive>
缓存 vnode
,了解组件包裹的子元素——也就是插槽是如何做更新的。且在 patch
过程中对于已缓存的组件不会执行 mounted
,所以不会有一般的组件的生命周期函数但是又提供了 activated
和 deactivated
钩子函数。另外我们还知道了 <keep-alive>
的 props
除了 include
和 exclude
还有文档中没有提到的 max
,它能控制我们缓存的个数。
内置组件
1.<keep-alive>
组件的实现也是一个对象,它有一个属性 abstract
为 true,是一个抽象组件
2.<keep-alive>
在 created
钩子里定义了 this.cache
和 this.keys
,本质上它就是去缓存已经创建过的 vnode
3.它的 props
定义了 include
,exclude
,它们可以字符串或者表达式,include
表示只有匹配的组件会被缓存,而 exclude
表示任何匹配的组件都不会被缓存,props
还定义了 max
,它表示缓存的大小
4.<keep-alive>
直接实现了 render
函数,而不是我们常规模板的方式,执行 <keep-alive>
组件渲染的时候,就会执行到这个 render
函数
5.在 <keep-alive>
标签内部写 DOM,所以可以先获取到它的默认插槽,然后再获取到它的第一个子节点。<keep-alive>
只处理第一个子元素,所以一般和它搭配使用的有 component
动态组件或者是 router-view
6.判断了当前组件的名称和 include
、exclude
的关系 ,matches
做匹配,分别处理了数组、字符串、正则表达式的情况,我们平时传的 include
和 exclude
可以是这三种类型的任意一种
并且我们的组件名如果满足了配置 include
且不匹配或者是配置了 exclude
且匹配,那么就直接返回这个组件的 vnode
,否则的话走下一步缓存
7.如果命中缓存,则直接从缓存中拿 vnode
的组件实例,并且重新调整了 key 的顺序放在了最后一个;
否则把 vnode
设置进缓存,最后还有一个逻辑,如果配置了 max
并且缓存的长度超过了 this.max
,还要从缓存中删除第一个
8.除了从缓存中删除外,还要判断如果要删除的缓存并的组件 tag
不是当前渲染组件 tag
,也执行删除缓存的组件实例的 $destroy
方法。
最后设置 vnode.data.keepAlive = true
9.<keep-alive>
组件也是为观测 include
和 exclude
的变化,对缓存做处理
10.观测他们的变化执行 pruneCache
函数,其实就是对 cache
做遍历,发现缓存的节点名称和新的规则没有匹配上的时候,就把这个缓存节点从缓存中摘除
组件渲染
1.Vue 的渲染最后都会到 patch
过程,而组件的 patch
过程会执行 createComponent
方法
2.createComponent
定义了 isReactivated
的变量,它是根据 vnode.componentInstance
以及 vnode.data.keepAlive
的判断,第一次渲染的时候,vnode.componentInstance
为 undefined
,vnode.data.keepAlive
为 true,==因为它的父组件 <keep-alive>
的 render
函数会先执行,那么该 vnode
缓存到内存中,并且设置 vnode.data.keepAlive
为 true==,因此 isReactivated
为 false
,那么走正常的 init
的钩子函数执行组件的 mount
。当 vnode
已经执行完 patch
后,执行 initComponent
函数
vnode.elm
缓存了 vnode
创建生成的 DOM 节点。所以对于首次渲染而言,除了在 <keep-alive>
中建立缓存,和普通组件渲染没什么区别。
缓存渲染
1.当数据发送变化,在 patch
的过程中会执行 patchVnode
的逻辑,它会对比新旧 vnode
节点,甚至对比它们的子节点去做更新逻辑,但是对于组件 vnode
而言,是没有 children
的
2.patchVnode
在做各种 diff 之前,会先执行 prepatch
的钩子函数,prepatch
核心逻辑就是执行 updateChildComponent
方法
3.updateChildComponent
方法主要是去更新组件实例的一些属性,在 slot
部分,由于 <keep-alive>
组件本质上支持了 slot
,所以它执行 prepatch
的时候,需要对自己的 children
,也就是这些 slots
做重新解析,并触发 <keep-alive>
组件实例 $forceUpdate
逻辑,也就是重新执行 <keep-alive>
的 render
方法,这个时候如果它包裹的第一个组件 vnode
命中缓存,则直接返回缓存中的 vnode.componentInstance
,接着又会执行 patch
过程,再次执行到 createComponent
方法
4.==执行 createComponent,这个时候 isReactivated
为 true==,并且在执行 init
钩子函数的时候不会再执行组件的 mount
过程了
被 <keep-alive>
包裹的组件在有缓存的时候就不会在执行组件的 created
、mounted
等钩子函数
5.createComponent
方法,在 isReactivated
为 true 的情况下会执行 reactivateComponent
方法
最后通过执行 insert(parentElm, vnode.elm, refElm)
就把缓存的 DOM 对象直接插入到目标元素中,这样就完成了在数据更新的情况下的渲染过程
生命周期
组件一旦被 <keep-alive>
缓存,那么再次渲染的时候就不会执行 created
、mounted
等钩子函数,但是我们很多业务场景都是希望在我们被缓存的组件再次被渲染的时候做一些事情,好在 Vue 提供了 activated
钩子函数,它的执行时机是 <keep-alive>
包裹的组件渲染的时候
1.在渲染的最后一步,会执行 invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
函数执行 vnode
的 insert
钩子函数
2.判断如果是被 <keep-alive>
包裹的组件已经 mounted
,那么则执行 queueActivatedComponent(componentInstance)
,否则执行 activateChildComponent(componentInstance, true)
3.非 mounted
的情况,执行activateChildComponent
,==然后执行组件的 acitvated
钩子函数,并且递归去执行它的所有子组件的 activated
钩子函数==
4.已经 mounted
的情况,执行queueActivatedComponent
,把当前 vm
实例添加到 activatedChildren
数组中,等所有的渲染完毕,在 nextTick
后会执行 flushSchedulerQueue
,遍历所有的 activatedChildren
,执行 activateChildComponent
方法,通过队列调的方式就是把整个 activated
时机延后了
5.有 activated
钩子函数,也就有对应的 deactivated
钩子函数,它是发生在 vnode
的 destory
钩子函数,
对于 <keep-alive>
包裹的组件而言,它会执行 deactivateChildComponent(componentInstance, true)
方法,
和 activateChildComponent
方法类似,就是执行组件的 deacitvated
钩子函数,并且递归去执行它的所有子组件的 deactivated
钩子函数