跳到主要内容

扩展

扩展

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.nativeEventsel.events,最后按照 name 对事件做归类,并把回调函数的字符串保留到对应的事件中。

在我们的例子中,父组件的 child 节点生成的 el.eventsel.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 元素节点上的 eventsnativeEvents 生成 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 的时候添加上的)区分出这个事件是否有 oncecapturepassive 等修饰符。

处理完事件名后,又对事件回调函数做处理,对于第一次,满足 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
);
}

addremove 的逻辑很简单,就是实际上调用原生 addEventListenerremoveEventListener,并根据参数传递一些配置,注意这里的 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 事件的回调函数执行期间如果修改了数据,那么这些数据更改推入的队列会被当做 macroTasknextTick 后执行。

自定义事件

除了原生 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 作为 vnodecomponentOptions 传入,它是在子组件初始化阶段中处理的,所以它的处理环境是子组件。

然后在子组件的初始化的时候,会执行 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 事件处理的差异就在事件添加和删除的实现上,来看一下自定义事件 addremove 的实现:

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 事件。

编译

image-20220826202725589

image-20220826202746382

DOM 事件

image-20220826210313496

自定义事件

Vue 还支持了自定义事件,并且自定义事件只能作用在组件上

1.在 render 阶段,如果是一个组件节点,则通过 createComponent 创建一个组件 vnode

2.在事件相关的逻辑,它把 data.on 赋值给了 listeners,把 data.nativeOn 赋值给了 data.on,这样所有的原生 DOM 事件处理一样,是在当前组件环境中处理的

3.对于自定义事件,我们把 listeners 作为 vnodecomponentOptions 传入,它是在子组件初始化阶段中处理的,所以它的处理环境是子组件

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,它的不同主要影响的是 eventvalueExpression 的值,对于我们的例子,eventinputvalueExpression$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.keynull,然后我们就得到 ${value}=${assignment},也就是 message=$event.target.value。然后我们又命中了 needCompositionGuard 为 true 的逻辑,所以最终的 codeif($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">

其实就是动态绑定了 inputvalue 指向了 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))]),
]);
}

关于事件的处理我们之前的章节已经分析过了,所以对于 inputv-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

而子组件定义了一个 valueprop,并且在 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,它的不同主要影响的是 eventvalueExpression 的值,eventinputvalueExpression$event.target.value。然后去执行 genAssignmentCode 去生成代码

6.genAssignmentCode 首先对 v-model 对应的 value 做了解析,它处理了非常多的情况,对我们的例子,value 就是 messgae,所以返回的 res.keynull,然后我们就得到 ${value}=${assignment},也就是 message=$event.target.value

7.又命中了 needCompositionGuard 为 true 的逻辑,所以最终的 codeif($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">

其实就是动态绑定了 inputvalue 指向了 messgae 变量,并且在触发 input 事件的时候去动态把 message 设置为目标值,这样实际上就完成了数据双向绑定了,所以说 v-model 实际上就是语法糖

9.genDirectives,它接下来的逻辑就是根据指令生成一些 data 的代码,最终生成的 render 代码

对于 inputv-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 个为具名插槽,一个 nameheader,一个 namefooter,还有一个没有定义 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 代表插槽名称 slotNamefallback 代表插槽的默认内容生成的 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 对应的是父 vnodechildren,在我们的例子中就是 <app-layout></app-layout> 包裹的内容。第二个参数 context 是父 vnode 的上下文,也就是父组件的 vm 实例。

resolveSlots 函数的逻辑就是遍历 chilren,拿到每一个 childdata,然后通过 data.slot 获取到插槽名称,这个 slot 就是我们之前编译父组件在 codegen 阶段设置的 data.slot。接着以插槽名称为 keychild 添加到 slots 中,如果 data.slot 不存在,则是默认插槽的内容,则把对应的 child 添加到 slots.defaults 中。这样就获取到整个 slots,它是一个对象,key 是插槽名称,value 是一个 vnode 类型的数组,因为它可以有多个同名插槽。

这样我们就拿到了 vm.$slots 了,回到 renderSlot 函数,const slotNodes = this.$slots[name],我们也就能根据插槽名称获取到对应的 vnode 数组了,这个数组里的 vnode 都是在父组件创建的,这样就实现了在父组件替换子组件插槽的内容了。

对应的 slot 渲染成 vnodes,作为当前组件渲染 vnodechildren,之后的渲染过程之前分析过,不再赘述。

我们知道在普通插槽中,父组件应用到子组件插槽里的数据都是绑定到父组件的,因为它渲染成 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 属性上,它是一个对象,以插槽名称 namekey

然后在 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 和一个 fnkey 对应的是插槽的名称,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 + ")";
}

它会对 attrsv-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,而是在父节点 vnodedata 中==保留一个 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 代表插槽名称 slotNamefallback 代表插槽的默认内容生成的 vnode 数组

默认插槽逻辑 如果 this.$slot[name] 有值,就返回它对应的 vnode 数组,否则返回 fallback

==子组件的 init 时机是在父组件执行 patch 过程的时候,那这个时候父组件已经编译完成了。并且子组件在 init 过程中会执行 initRender 函数==,initRender 的时候获取到 vm.$slot

8.vm.$slots 是通过执行 resolveSlots(options._renderChildren, renderContext) 返回的

resolveSlots方法接收 2 个参数,第一个参数chilren对应的是父vnodechildren

例子中就是 <app-layout></app-layout> 包裹的内容。第二个参数 context 是父 vnode 的上下文,也就是父组件的 vm 实例

9.resolveSlots 函数的逻辑就是遍历 chilren,拿到每一个 childdata,然后通过 data.slot 获取到插槽名称,这个 slot 就是我们之前编译父组件在 codegen 阶段设置的 data.slot。接着以插槽名称为 keychild 添加到 slots 中,如果 data.slot 不存在,则是默认插槽的内容,则把对应的 child 添加到 slots.defaults 中。这样就获取到整个 slots,它是一个对象,key 是插槽名称,value 是一个 vnode 类型的数组,因为它可以有多个同名插槽

10.拿到了 vm.$slots 了,回到 renderSlot 函数,const slotNodes = this.$slots[name],我们也就能根据插槽名称获取到对应的 vnode 数组了,这个数组里的 vnode 都是在父组件创建的,这样就实现了在父组件替换子组件插槽的内容了。

对应的 slot 渲染成 vnodes,作为当前组件渲染 vnodechildren

普通插槽中,父组件应用到子组件插槽里的数据都是绑定到父组件的,因为它渲染成 vnode 的时机的上下文是父组件的实例

作用域插槽

在一些实际开发中,我们想通过子组件的一些数据来决定父组件实现插槽的逻辑,Vue 提供了另一种插槽——作用域插槽

1.在编译阶段,仍然是先编译父组件,同样是通过 processSlot 函数去处理 scoped-slot

2.读取 scoped-slot 属性并赋值给当前 AST 元素节点的 slotScope 属性,构造 AST 树

3.==对于拥有 scopedSlot 属性的 AST 元素节点而言,是不会作为 children 添加到当前 AST 树中,而是存到父 AST 元素节点的 scopedSlots 属性上,它是一个对象,以插槽名称 namekey==

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 和一个 fnkey 对应的是插槽的名称,fn 对应一个函数。整个逻辑就是遍历这个 fns 数组,生成一个对象,对象的 key 就是插槽名称,value 就是函数

7.组件的编译,和普通插槽的过程基本相同,唯一一点区别是在 genSlot 的时候,它会对 attrsv-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,而是在父节点 vnodedata 中保留一个 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.cachethis.keys,本质上它就是去缓存已经创建过的 vnode。它的 props 定义了 includeexclude,它们可以字符串或者表达式,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,这点要牢记。

然后又判断了当前组件的名称includeexclude 的关系:

// 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 的逻辑很简单,就是做匹配,分别处理了数组、字符串、正则表达式的情况,也就是说我们平时传的 includeexclude 可以是这三种类型的任意一种。并且我们的组件名如果满足了配置 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> 组件也是为观测 includeexclude 的变化,对缓存做处理:

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.componentInstanceundefinedvnode.data.keepAlive 为 true,因为它的父组件 <keep-alive>render 函数会先执行,那么该 vnode 缓存到内存中,并且设置 vnode.data.keepAlive 为 true==,

因此 isReactivatedfalse,那么走正常的 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> 包裹的组件在有缓存的时候就不会在执行组件的 createdmounted 等钩子函数的原因了。回到 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> 缓存,那么再次渲染的时候就不会执行 createdmounted 等钩子函数,但是我们很多业务场景都是希望在我们被缓存的组件再次被渲染的时候做一些事情,好在 Vue 提供了 activated 钩子函数,它的执行时机是 <keep-alive> 包裹的组件渲染的时候==,接下来我们从源码角度来分析一下它的实现原理。

在渲染的最后一步,会执行 invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch) 函数执行 vnodeinsert 钩子函数,它的定义在 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 钩子函数,它是发生在 vnodedestory 钩子函数,定义在 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,所以不会有一般的组件的生命周期函数但是又提供了 activateddeactivated 钩子函数。另外我们还知道了 <keep-alive>props 除了 includeexclude 还有文档中没有提到的 max,它能控制我们缓存的个数。

内置组件

1.<keep-alive> 组件的实现也是一个对象,它有一个属性 abstract 为 true,是一个抽象组件

2.<keep-alive> created 钩子里定义了 this.cachethis.keys,本质上它就是去缓存已经创建过的 vnode

3.它的 props 定义了 includeexclude,它们可以字符串或者表达式,include 表示只有匹配的组件会被缓存,而 exclude 表示任何匹配的组件都不会被缓存,props 还定义了 max,它表示缓存的大小

4.<keep-alive> 直接实现了 render 函数,而不是我们常规模板的方式,执行 <keep-alive> 组件渲染的时候,就会执行到这个 render 函数

5.在 <keep-alive> 标签内部写 DOM,所以可以先获取到它的默认插槽,然后再获取到它的第一个子节点。<keep-alive> 只处理第一个子元素,所以一般和它搭配使用的有 component 动态组件或者是 router-view

6.判断了当前组件的名称和 includeexclude 的关系 ,matches 做匹配,分别处理了数组、字符串、正则表达式的情况,我们平时传的 includeexclude 可以是这三种类型的任意一种

并且我们的组件名如果满足了配置 include 且不匹配或者是配置了 exclude 且匹配,那么就直接返回这个组件的 vnode,否则的话走下一步缓存

7.如果命中缓存,则直接从缓存中拿 vnode 的组件实例,并且重新调整了 key 的顺序放在了最后一个;

否则把 vnode 设置进缓存,最后还有一个逻辑,如果配置了 max 并且缓存的长度超过了 this.max,还要从缓存中删除第一个

8.除了从缓存中删除外,还要判断如果要删除的缓存并的组件 tag 不是当前渲染组件 tag,也执行删除缓存的组件实例的 $destroy 方法。

最后设置 vnode.data.keepAlive = true

9.<keep-alive> 组件也是为观测 includeexclude 的变化,对缓存做处理

10.观测他们的变化执行 pruneCache 函数,其实就是对 cache 做遍历,发现缓存的节点名称和新的规则没有匹配上的时候,就把这个缓存节点从缓存中摘除

组件渲染

1.Vue 的渲染最后都会到 patch 过程,而组件的 patch 过程会执行 createComponent 方法

2.createComponent 定义了 isReactivated 的变量,它是根据 vnode.componentInstance 以及 vnode.data.keepAlive 的判断,第一次渲染的时候,vnode.componentInstanceundefinedvnode.data.keepAlive 为 true,==因为它的父组件 <keep-alive>render 函数会先执行,那么该 vnode 缓存到内存中,并且设置 vnode.data.keepAlive 为 true==,因此 isReactivatedfalse,那么走正常的 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> 包裹的组件在有缓存的时候就不会在执行组件的 createdmounted 等钩子函数

5.createComponent 方法,在 isReactivated 为 true 的情况下会执行 reactivateComponent 方法

最后通过执行 insert(parentElm, vnode.elm, refElm) 就把缓存的 DOM 对象直接插入到目标元素中,这样就完成了在数据更新的情况下的渲染过程

生命周期

组件一旦被 <keep-alive> 缓存,那么再次渲染的时候就不会执行 createdmounted 等钩子函数,但是我们很多业务场景都是希望在我们被缓存的组件再次被渲染的时候做一些事情,好在 Vue 提供了 activated 钩子函数,它的执行时机是 <keep-alive> 包裹的组件渲染的时候

1.在渲染的最后一步,会执行 invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch) 函数执行 vnodeinsert 钩子函数

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 钩子函数,它是发生在 vnodedestory 钩子函数,

对于 <keep-alive> 包裹的组件而言,它会执行 deactivateChildComponent(componentInstance, true) 方法,

activateChildComponent 方法类似,就是执行组件的 deacitvated 钩子函数,并且递归去执行它的所有子组件的 deactivated 钩子函数