案例
<body>
<div id="app">
{{ message }}
</div>
</body>
<script src="vue.js"></script>
<script>
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
</script>
Vue
Vue 实际就是一个方法,必须使用 new 关键字调用
function Vue(options) {
if (!(this instanceof Vue)) {
warn$2('Vue is a constructor and should be called with the `new` keyword');
}
this._init(options);
}
参数 options 为:
{
el: "#app",
message: "Hello Vue!"
}
_init
调用 _init(options) 方法:
var vm = this;
vm._uid = uid++; // 开始时为 0
_init 方法中调用 mergeOptions 方法合并了传入的 options 和默认的 options,设置 vm.$options 为:
{
"components": {},
"data": ƒ mergedInstanceDataFn() // 方法执行的结果为:{message: 'Hello Vue!'}
"directives": {},
"filters": {},
"el": "#app",
"_base": ƒ Vue(options) // 入口的 vue 方法
...
}
使用 initProxy 做了数据代理,往实例上设置了很多属性,做了很多初始化(例如生命周期,事件...)工作,这些先不细看,在这个方法的最后,调用 $mount 方法渲染页面。
if (vm.$options.el) { // "#app"
vm.$mount(vm.$options.el);
}
$mount
Vue.prototype.$mount = function (el, hydrating) {
el = el && inBrowser ? query(el) : undefined;
return mountComponent(this, el, hydrating);
};
var mount = Vue.prototype.$mount; // mount 表示原型上的 $mount 方法
Vue.prototype.$mount = function (el, hydrating) {
...
el = el && query(el);
...
if (!options.render) { // 将 el 或者 template 转换为 render }
return mount.call(this, el, hydrating);
}
第一步根据传入的 #app 找到 html 上对应的元素(el 不可以是 html 或者 body 元素):
document.querySelector(el)
第二步会判断 vm 上是否有 render 函数,没有的话会将 template 属性通过 compileToFunctions 转换成 render 函数,如果没有 template ,将 el 通过 template = getOuterHTML(el) 转换为 template。
从这里可以了解,当我们直接写 template 的时候,template 中的元素是会覆盖掉我们 html 中 app 节点下的所有元素的,而不写 template 时,会根据 app 下的元素生成 render。
var app = new Vue({
el: '#app',
template: '<div>123</div>', // 这里的内容会覆盖 {{message}}
data: {
message: 'Hello Vue!'
}
})
此案例中首先根据 app 这个 el 转换为 template:
<div id="app">\n {{ message }}\n </div>
再通过 compile 编译成 render:
var compiled = compile(template, options);
在 Vue 2.0 版本中,所有 Vue 的组件的渲染最终都需要
render方法,无论我们是用单文件 .vue 方式开发组件,还是写了el或者template属性,最终都会转换成render方法。
最终得到的 render 为:
ƒ anonymous(
) {
with(this){return _c('div',{attrs:{"id":"app"}},[_v("\n "+_s(message)+"\n ")])}
}
最后一步会调用缓存起来的 mount 方法,这个方法中调用了 mountComponent 方法。
mountComponent
我们忽略掉这个方法中其他的操作,因为暂时看不懂,看到 mountComponent 核心就是先实例化一个渲染 Watcher,在它的回调函数中会调用 updateComponent 方法,在此方法中调用 vm._render 方法先生成虚拟 Node,最终调用 vm._update 更新 DOM。 Watcher 在这里起到两个作用,一个是初始化的时候会执行回调函数,另一个是当 vm 实例中的监测的数据发生变化的时候执行回调函数。
接下来我们要重点分析其中的细节,也就是最核心的 2 个方法:vm._render 和 vm._update
updateComponent = function () {
vm._update(vm._render(), hydrating);
};
vm._render
vm._render 最终是通过执行 createElement 方法并返回的是 vnode,它是一个虚拟 Node。
当前的 render 函数为:
ƒ anonymous(
) {
with(this){return _c('div',{attrs:{"id":"app"}},[_v("\n "+_s(message)+"\n ")])}
}
接着执行 render 函数
vnode = render.call(vm._renderProxy, vm.$createElement);
在 render 方法中调用了两个方法,一个是 _v :
target._v = createTextVNode;
createTextVNode(val) {
return new VNode(undefined, undefined, undefined, String(val));
}
一个是 _c,在初始化的时候,就调用过 initRender 方法,此方法中定义了两个将 render 方法转换为 vnode 的方法:
vm._c = function (a, b, c, d) { return createElement$1(vm, a, b, c, d, false); };
vm.$createElement = function (a, b, c, d) { return createElement$1(vm, a, b, c, d, true); };
vm._c 是被模板编译成的 render 函数使用,而 vm.$createElement 是用户手写 render 方法使用的, 它们支持的参数相同,并且内部都调用了 createElement$1 方法。
如果被 compileToFunctions 转换的 template 有其他子节点,那么在生成的 render 函数中的 children 中也会有 _c 方法,这样当我们执行 render 方法的时候,会先执行内层的 _c 方法,也就是将我们的子节点先转换为 vnode。也就是当我们执行父节点的 _c 方法时,子节点已经全部是 vnode 了。
虚拟 Dom
真正的 DOM 元素是非常庞大的,因为浏览器的标准就把 DOM 设计的非常复杂。当我们频繁的去做 DOM 更新,会产生一定的性能问题。Virtual DOM 就是用一个原生的 JS 对象去描述一个 DOM 节点,所以它比创建一个 DOM 的代价要小很多。在 Vue.js 中,Virtual DOM 是用 VNode 这么一个 Class 去描述,由于 VNode 只是用来映射到真实 DOM 的渲染,不需要包含操作 DOM 的方法,因此它是非常轻量和简单的。
createElement$1
在 createElement$1 方法中整理了参数,随后调用了 _createElement 方法:
context: 表示 VNode 的上下文环境,就是一个vue实例。tag:表示标签,在这个例子中,它的值是'div'。data:用于描述vnode的数据,在这个例子中,它的值是{attrs: {id: 'app'}}。children:表示当前VNode的子节点,它是任意类型的,它接下来需要被规范为标准的 VNode 数组。normalizationType:表示子节点规范的类型,类型不同规范的方法也就不一样,它主要是参考render函数是编译生成的还是用户手写的。
function _createElement(context, tag, data, children, normalizationType) { ... }
_createElement
这里的逻辑少了很多,因为案例过于简单,很多步骤没有执行到,需要后面再回顾
if (typeof tag === 'string') {
var Ctor = void 0;
ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag);
if (config.isReservedTag(tag)) {
...
vnode = new VNode(config.parsePlatformTagName(tag), data, children, undefined, undefined, context);
}
else if ((!data || !data.pre) &&
isDef((Ctor = resolveAsset(context.$options, 'components', tag)))) {
vnode = createComponent(Ctor, data, context, children, tag);
}
else {
vnode = new VNode(tag, data, children, undefined, undefined, context);
}
}
这个案例里面由于传入的 tag 是 div,所以直接走到了第一个分支,创建了一个 vnode:
{
children: [
{
tag: undefined,
text : "\n Hello Vue!\n ",
...
}
],
data: {attrs : {id: "app"} },
tag: "div",
...
}
vm._update
作用是把 VNode 渲染成真实的 DOM
_update 方法会在页面首次渲染和页面更新的时候执行,其中最主要的方法是 __patch__ 方法,在首次渲染的时候没有 prevVnode 节点,所以传入的 vm.$el,而在更新的时候传入的是 prevVnode。
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode);
}
目前只看首次渲染的情况下,是如何将 vnode 转换为真实 Dom 的,__patch__ 在浏览器环境下调用的就是 patch 方法。
Vue.prototype.__patch__ = inBrowser ? patch : noop;
patch 是最开始通过 createPatchFunction 方法创建返回的,它传入了一个对象,包含 nodeOps 参数和 modules 参数。其中,nodeOps 封装了一系列 DOM 操作的方法,modules 定义了一些模块的钩子函数的实现。
patch
按照目前使用的例子来说,传入 patch 方法的 oldVnode 为 <div id="app"> {{ message }} </div>,
传入的 vnode:通过 _render 方法生成的 vnode。
由于我们传入的 oldVnode 实际上是一个 DOM container,所以 isRealElement 为 true,接下来又通过 emptyNodeAt 方法把 oldVnode 转换成一个空的 VNode 对象
function emptyNodeAt(elm) {
return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm);
}
{
children: [],
data: {},
elm: div#app,
tag: "div",
...
}
然后得到节点的父元素 parentElm 为 body 元素。
var oldElm = oldVnode.elm;
var parentElm = nodeOps.parentNode(oldElm);
接下来会调用 createElm 方法,这个方法作用是通过虚拟节点创建真实的 DOM 并插入到它的父节点中。
createElm(
vnode, // 根据 el 生成的 vnode
insertedVnodeQueue, // []
parentElm, // html 中的 body 元素
nodeOps.nextSibling(oldElm) // oldElm 为 <div id="app"> {{ message }} </div>,这里取到的它的下一个兄弟节点为换行文本节点
);
createElm
- 由于目前没有使用组件,所以
createComponent中的内容可以暂时不看。 - 这个方法中分成了三种节点类型进行处理:
if (isDef(tag)) {
// 元素节点
...
createChildren(vnode, children, insertedVnodeQueue);
insert(parentElm, vnode.elm, refElm);
...
}
else if (isTrue(vnode.isComment)) {
// 注释节点
vnode.elm = nodeOps.createComment(vnode.text);
insert(parentElm, vnode.elm, refElm);
}
else {
// 文本节点
vnode.elm = nodeOps.createTextNode(vnode.text);
insert(parentElm, vnode.elm, refElm);
}
- 当为元素节点的时候会调用
createChildren方法遍历子虚拟节点,递归调用createElm。
function createChildren(vnode, children, insertedVnodeQueue) {
if (isArray(children)) {
{ checkDuplicateKeys(children); }
for (var i_1 = 0; i_1 < children.length; ++i_1) {
createElm(children[i_1], insertedVnodeQueue, vnode.elm, null, true, children, i_1);
}
} else if (isPrimitive(vnode.text)) {
nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)));
}
}
- 在最后都调用
insert(parentElm, vnode.elm, refElm)将生成的真实 DOM 进行插入。insert方法,其中会调用原生DOM的API进行DOM操作,使用insertBefore或者appendChild把DOM插入到父节点中,因为是递归调用,子元素会优先调用insert,所以整个vnode树节点的插入顺序是先子后父。
案例分析
此案例中首先进入元素节点的分支,根据 vnode 的 tag 属性创建一个占位元素,这里的 vnode.elm 是 <div></div>
vnode.elm = vnode.ns ? nodeOps.createElementNS(vnode.ns, tag) : nodeOps.createElement(tag, vnode);
接下来调用 createChildren 方法递归调用 createElm去创建子元素,这里的子节点是一个文本节点,所以会进入文本节点的分支:
// 传入的 parentElm 为刚创建的空元素 <div></div>
insert(parentElm, vnode.elm, refElm)
将子节点都转换为真实 DOM 之后,再将父节点添加到其父节点,也就是 <body> 元素中,最后的换行元素之前,此时我们可以看见页面上出现了两个文本节点:
销毁 oldVnode
在 patch 方法的最后会删除掉 oldVnode:
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0);
}
总结
看到这里,我们知道 vue 在渲染页面的时候,需要将 el 和 template 转换为 render ,render 要转换为 vnode,最后再转换为真实的 dom 插入页面。