前面章节主要通过initData函数在初始化data选项的过程来学习了 Vue 的响应式系统。
主要讲了响应式系统中的工厂函数observe和监听器Observer以及如何定义响应式属性的defineReactive函数。因为响应式的原理是使用Object.defineProperty来定义对象数据的,所以还需要对数组类型的数据进行特殊处理,重写数组的一些常用方法,使得数组中的元素也和对象里面的数据一样,可以收集依赖和触发依赖。
现在来看看 Vue 的观察者以及响应式数据是如何收集和触发依赖的。
在完成所有选项的初始化之后,这时候就需要挂载组件了。查看Vue.prototype._init函数最后几行代码
// ! 如果设置了 el,就把实例对象挂载在该元素上
if (vm.$options.el) {
vm.$mount(vm.$options.el);
}$mount#
现在我们去查看 Vue 实例对象的$mount方法的代码,因为挂载组件的$mount和平台涉及相关,这里只学习 Web 平台的代码。另外,$mount方法还和版本相关,Runtime 版本中的$mount方法不涉及编译器代码,只有完成版本中的$mount方法才涉及到编译器代码。
先查看 Runtime 版本中的$mount函数,在platforms/web/runtime/index.js文件中
// ! 安装 Web 平台的挂载方法 $mount (这里是 runtime 版本)
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined;
return mountComponent(this, el, hydrating); // ! 挂载组件
};首先重写设置el的值,先判断是否传入el参数,如果传入的话,再判断当前的宿主环境是否是浏览器环境。如果在浏览器环境中,使用query函数并传入el,否则el为undefined。
看下query函数的代码。
export function query(el: string | Element): Element {
if (typeof el === "string") {
const selected = document.querySelector(el);
if (!selected) {
process.env.NODE_ENV !== "production" &&
warn("Cannot find element: " + el);
return document.createElement("div");
}
return selected;
} else {
return el;
}
}这里的el的类型可以是字符串也可以是Element,如果el是字符串的话,说明el应该是一个CSS选择器,调用原生的document.querySelector方法获取 DOM,如果找不到 DOM ,在非生产环境中会报错。
如果el是一个Element,则直接返回它。最后输出到的el一定是Element类型。$mount函数最后再调用mountComponent函数,并传入三个参数,分别是 Vue 的实例对象this、获取到的元素el以及布尔值hydrating。
先不去管mountComponent函数,现在查看完整版本的$mount方法,在platforms/web/entry-runtime-with-compiler.js文件中。
const mount = Vue.prototype.$mount; // ! 缓存 runtime 版本的 $mount 方法
// ! 重写 $mount 方法 with compiler
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && query(el);
// ...
return mount.call(this, el, hydrating); // ! 调用 runtime mount 方法
};在文件中先声明常量mount缓存 Runtime 版本的$mount,然后重写了Vue.prototype.$mount方法,看下重写方法的第一行代码,还是通过query获取DOM。再看最后一行的代码,调用 Runtime 版本的$mount,并传入相同的参数。
现在看下重写方法中间的代码。
// ! 不能挂载在 body 和 html 标签上
if (el === document.body || el === document.documentElement) {
process.env.NODE_ENV !== "production" &&
warn(
`Do not mount Vue to <html> or <body> - mount to normal elements instead.`
);
return this;
}判断获取的元素是不是document.body或者document.documentElement,如果是的话在非生产环境中会报错。因为 Vue 的实例对象挂载在元素上是会替换掉这个元素的,这样的话就会把body和html标签元素替换掉,而这两个元素是不应该被替换掉的。
继续看下面的代码。
const options = this.$options;
// resolve template/el and convert to render function
// ! 如果没有渲染函数,使用 template 或者 el 构建渲染函数
if (!options.render) {
// ...
}声明变量option存储合并后的选项vm.$options,然后判断渲染函数options.render是否存在,如果不存在,则使用template或者el选项构建渲染函数。
这里先提前说下后面代码的主要逻辑,先判断是否存在template选项,如果存在,则使用template选项的值获取template。如果不存在,判断是否存在el选项,如果存在el选项则使用el选项获取template的值。总之一定要获取到template,而且它都值必须是Element类型,这样才能挂载组件。
现在看下如何使用template。
let template = options.template
// ! 获取 template 的值
// ! 优先从 template 选项中获取
if (template) {
if (typeof template === 'string') {
// ! id 选择器
if (template.charAt(0) === '#') {
template = idToTemplate(template)
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !template) {
warn(
`Template element not found or is empty: ${options.template}`,
this
)
}
}
// ...
}首先声明变量options存储options.template的值。
然后判断template是不是一个字符串,如果是的话,再判断它是不是以#开头,如果是的话,就可以确定template应该是一个CSS的id选择器,调用idToTemplate函数通过这个选择器获取元素。
看下idToTemplate函数的代码。
// ! 通过 id 获取元素的 innerHTML
const idToTemplate = cached((id) => {
const el = query(id);
return el && el.innerHTML;
});这个函数通过id选择器来获取元素,并返回元素的innerHTML。
如果获取不到元素的innerHTML作为template的值,在非生产环境中会报错,提示找不到模板元素或者模板元素是空的。
继续看下面的代码。
// ! 元素类型
else if (template.nodeType) {
template = template.innerHTML
}如果template有nodeType属性,说明它是一个元素,直接获取它的innerHtml的值。
继续看下面的代码。
// ! 其他无效的 template 报错
else {
if (process.env.NODE_ENV !== 'production') {
warn('invalid template option:' + template, this)
}
return this
}继续分析template的其他情况,即无效的template。此时如果在非生产环境中会报错,提示template选项是无效的。
下面看使用el的情况。
if (template) {
// ...
// ! 其次从 el 选项获取
} else if (el) {
template = getOuterHTML(el);
}通过template无法获取有效的元素时,如果有el选项,通过调用getOuterHTML函数获取元素。
注意这里的el是Element类型,为什么不是字符串类型的呢?如果在 Runtime 版本时,el可以是字符串类型,即它是一个CSS选择器,然后通过选择器可以获取元素。
但是在完整版本中,这里需要一个Element类型的template,在前面的逻辑中没有通过template选项获取到template的值,所以这里必须是一个Element类型的值。
看下getOuterHTML函数的代码。
function getOuterHTML(el: Element): string {
if (el.outerHTML) {
return el.outerHTML;
} else {
const container = document.createElement("div");
container.appendChild(el.cloneNode(true));
return container.innerHTML;
}
}判断el元素是否有outerHTML,如果有的话,直接返回它的outerHTML。如果没有的话,创建一个div容器,然后把el元素的克隆值放入容器中,最后返回容器的innerHTML,也就是返回el元素。
为什么不直接创建一个元素再返回它呢?因为有的元素可能没有innerHTML或者outerHTML属性,比如SVG标签元素在IE 9-11时就没有innerHTML和outerHTML属性。此时,如果el选项是SVG标签元素就会出错。如果先创建一个div容器元素,然后把一个克隆元素放进去,再返回容器元素的innerHTML就不会有问题,因为这个容器元素的innerHTML是一定存在的。
继续看下面的代码。
if (template) {
/* istanbul ignore if */
// ! 测试编译性能之开始
if (process.env.NODE_ENV !== "production" && config.performance && mark) {
mark("compile");
}
// ...
/* istanbul ignore if */
// ! 测试编译性能之结束
if (process.env.NODE_ENV !== "production" && config.performance && mark) {
mark("compile end");
measure(`vue ${this._name} compile`, "compile", "compile end");
}
}在获取到template元素后,执行下面的编译操作。
这里在非生产环境中测试compile的性能。
再看下性能测试的代码。
// ! 生成渲染函数
const { render, staticRenderFns } = compileToFunctions(
template,
{
outputSourceRange: process.env.NODE_ENV !== "production",
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters, // ! 分隔符配置,是个数组,默认是 ["{{", "}}"]
comments: options.comments, // ! 评论配置
},
this
);
options.render = render;
options.staticRenderFns = staticRenderFns;通过调用compileToFunctions函数生成了渲染函数render和静态渲染函数staticRenderFns。然后把它们存储在options中,即存储在vm.$options中。
小结,完整版本的$mount方法继承了 Runtime 版本的方法,然后把模板字符串编译成渲染函数,并把渲染方法存储在vm.$options中。
mountComponent#
下面我们来看下mountComponent函数,$mount方法最终都会调用的这个函数,它才是真正挂载组件的方法,这个函数在core/instance/lifecycle.js文件中。
// ! 挂载组件的函数
export function mountComponent(
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el; // ! 更新 $el 属性
// ...
}使用传进来的参数el更新vm.$el的值。注意,这里vm.$el的值也不是最后真正挂载的元素,后面再讲解。
继续看下面的代码。
if (!vm.$options.render) {
vm.$options.render = createEmptyVNode; // ! render 会生成一个空的 VNode
if (process.env.NODE_ENV !== "production") {
/* istanbul ignore if */
if (
(vm.$options.template && vm.$options.template.charAt(0) !== "#") ||
vm.$options.el ||
el
) {
warn(
"You are using the runtime-only build of Vue where the template " +
"compiler is not available. Either pre-compile the templates into " +
"render functions, or use the compiler-included build.",
vm
);
} else {
warn(
"Failed to mount component: template or render function not defined.",
vm
);
}
}
}判断 Vue 的实例对象中是否存在渲染函数render,如果不存在,渲染函数会被赋值为一个生成一个空的VNode(虚拟节点)的函数,然后在非生产环境中,判断template是否以#开头或者存在el,如果是的话,会警告在 Runtime 环境中无法编译元素。如果不是的话,会警告template或者渲染函数未定义。
继续看下面的代码。
callHook(vm, "beforeMount"); // ! 挂载组件之前,调用 beforeMount 钩子函数在挂载组件之前,执行beforeMount生命周期钩子函数。
继续看下面的代码。
// ! 更新组件的函数
let updateComponent;
/* istanbul ignore if */
if (process.env.NODE_ENV !== "production" && config.performance && mark) {
updateComponent = () => {
const name = vm._name;
const id = vm._uid;
const startTag = `vue-perf-start:${id}`;
const endTag = `vue-perf-end:${id}`;
mark(startTag); // ! 开始统计 _render 函数性能
const vnode = vm._render(); // ! 生成 VNode
mark(endTag);
measure(`vue ${name} render`, startTag, endTag);
mark(startTag); // ! 开始统计 _update 函数性能
vm._update(vnode, hydrating); // ! 将 VNode 渲染到真实`DOM`中
mark(endTag);
measure(`vue ${name} patch`, startTag, endTag);
};
} else {
// ...
}先声明一个公共的变量updateComponent用来存储函数,在生产环境和非生产环境中这个函数有一点不一样,因为在非生产环境中需要测量函数里面的vm._render方法和vm._update方法的性能。
这两个方法的作用分别是:
vm._render:调用vm.$options.render函数并返回生成的虚拟节点(vnode)。vm._update:把vm._render函数生成的虚拟节点渲染成真正的元素。
先看下生产环境的中updateComponent函数的代码,只有一行代码。
updateComponent = () => {
vm._update(vm._render(), hydrating); // ! 把 _render 生成的 VNode 渲染成真实的`DOM`
};逻辑很简单,调用vm._update方法把vm._render方法生成的VNode渲染成真实的元素。
继续看下面的代码。
// ! 生成观察者实例
new Watcher(
vm,
updateComponent, // ! 更新组件的函数,执行时 -> 执行 vm.$options.render -> getter -> 收集依赖
noop,
{
before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, "beforeUpdate"); // ! 传入 beforeUpdate 生命周期钩子函数,在更新前组件前调用
}
},
},
true /* isRenderWatcher */
);通过new操作符生成一个观察者Watcher实例对象。
到这里,响应式系统的观察者也终于露出水面了。