Vue 团队于 2020 年 9 月 18 日晚 11 点半发布了 Vue 3.0 版本,对于喜欢折腾源码的我来说不安分的心开始躁动了 , Vue2.x的源码像一个娓娓道来的家常故事易懂易学。Vue3.0的故事像个跌宕起伏的悬疑剧看着让人抓脑又直掉哈喇子。 都不像尤大的风格(抖个机灵)。
v3.0.0 One Piece 原文: Releases · vuejs/vue-next
QC.L 翻译: QC.L:[官宣] Vue 3.0 — One Piece 发布
这篇文章我们会从对比Vue2、Vue3应用实例的创建开始, 对比两种架构风格结束,下篇文章开始进行逐行的分析。
创建一个实例
创建一个新的Vue应用程序实例提供一个在页面上已存在的 DOM 元素作为 Vue 实例的挂载目标。
Vue2.x
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
Vue3.0
Vue.createApp(/* options */)
Vue.createApp(/* options */).mount('#app')
可以看到,Vue.js 3.0 初始化应用的方式和 Vue.js 2.x 差别并不大,本质上都是把组件挂载到 id 为 app 的 DOM 节点上。
先来看下Vue2.x它的内部实现:
(function(global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.Vue = factory());
})(this, function() {
function initMixin(Vue) {
Vue.prototype._init = function(options) {
//...
//选项合并
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
);
//...
//组件挂载
if (vm.$options.el) {
vm.$mount(vm.$options.el);
}
}
}
var Vue = function(options) {
this._init(options);
}
//模板以编译成render function或组件手写render function执行此函数
function mountComponent(vm, el, hydrating) {
//...
//_update() 把Virtual DOM映射成真正的DOM
updateComponent = function() {
vm._update(vm._render(), hydrating);
};
//创建渲染函数观察者
new Watcher(vm, updateComponent, noop, {
before: function before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate');
}
}
}, true /* isRenderWatcher */ );
//...
}
function lifecycleMixin(Vue) {
Vue.prototype._update = function(vnode, hydrating) {
//...
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */ );
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode);
}
//...
}
}
function createPatchFunction() {
//...
//initial render || 数据更新diff算法
return function patch() {
//...
}
}
//渲染相关的一些配置,比如更新属性的方法, 操作指令的方法
var patch = createPatchFunction({
nodeOps: nodeOps,
modules: modules
});
Vue.prototype.__patch__ = inBrowser ? patch : noop;
//Runtime + Compiler 、Runtime-only "public mount method"
Vue.prototype.$mount = function(el, hydrating) {
el = el && inBrowser ? query(el) : undefined;
return mountComponent(this, el, hydrating)
};
var mount = Vue.prototype.$mount;
//Runtime + Compiler 如果你需要在客户端编译模板 就将需要加上编译器
Vue.prototype.$mount = function(el, hydrating) {
//...
//编译模板入口文件
var ref = compileToFunctions(template, {
shouldDecodeNewlines: shouldDecodeNewlines,
shouldDecodeNewlinesForHref: shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this);
//...
return mount.call(this, el, hydrating)
}
initMixin(Vue);
reurn Vue;
});
从代码可以看出来 Vue2.x 中通过 new Vue(/*options*****/) 创建程序实例把 options 对象传递给 _init 进行一系列初始化操作,进入 vm.options.el) 组件挂载。
在组件挂载阶段会把 template 通过 compileToFunctions 编译模板,编译成 render function 再去调用 mountComponent。如果组件中直接写了 render function 则会直接调用 mountComponent。
mountComponent函数中有两个关键的方法 _render 函数的作用是返回生成的虚拟节点VNode, _update 函数的作用是把 vm._render 函数生成的虚拟节点渲染成真正的 DOM。
更详细的讲_update 会调用 __patch__ 方法,看注释都能知道 initial render & updates ,initial render 指的是初次渲染把 VNode 对象渲染到真实的 DOM tree 上去。
当数据发生会变化时,生成一个新的VNode对象,然后调用__patch__方法,比较新生成的VNode和旧的VNode,最后将差异(变化的节点)更新到真实的DOM tree 上。
默认屏蔽了响应式系统如:render() 调用进行依赖收集 Watcher 渲染函数观察者 讲解后续补充。
Vue.js 3.0 的情况稍微复杂点(后续文章Vue.js 3.0就简称Vue3),Vue3导入了一个 createApp 这是个入口函数,它是 Vue.js 对外暴露的一个函数,我们来看一下它的内部实现:
const createApp = ((...args) => {
const app = ensureRenderer().createApp(...args);
//...
const { mount } = app;
app.mount = (containerOrSelector) => {
const container = normalizeContainer(containerOrSelector);
if (!container)
return;
const component = app._component;
if (!isFunction(component) && !component.render && !component.template) {
component.template = container.innerHTML;
}
// clear content before mounting
container.innerHTML = '';
const proxy = mount(container);
//...
return proxy;
};
return app;
});
从代码中可以看出 createApp 主要做了两件事情:创建 app 对象和重写 app.mount 方法。这里的做法跟Vue2.x 的 $mount 设计思路差不多。我们就具体来分析一下它们。
1. app对象
首先,我们使用 ensureRenderer().createApp() 来创建 app 对象 :
const app = ensureRenderer().createApp(...args);
ensureRenderer() 用来创建一个渲染器对象,进入到他内部代码:
let renderer;
function createRenderer(options) {
return baseCreateRenderer(options);
}
//返回createApp方法
function baseCreateRenderer(options, createHydrationFns) {
//创建或者更新组件
const patch = ( /*...*/ ) => {
//...
}
function render(vnode, container) {
// 组件渲染的核心逻辑
}
return {
render,
createApp: createAppAPI(render)
}
}
function ensureRenderer() {
return renderer || (renderer = createRenderer(rendererOptions));
}
function createAppAPI(render) {
// createApp createApp 方法接受的两个参数:根组件的对象和 prop
return function createApp(rootComponent, rootProps = null) {
const app = {
_component: rootComponent,
_props: rootProps,
mount(rootContainer) {
// 创建根组件的 vnode
const vnode = createVNode(rootComponent, rootProps)
// 利用渲染器渲染 vnode
render(vnode, rootContainer)
app._container = rootContainer
return vnode.component.proxy
}
}
return app
}
}
从代码可以看出来在 Vue3 中通过 Vue.createApp(/* options */) 返回的app对象,app对象的属性主要来源于createAppAPI 返回对象,值得注意的是在createApp 中对 createAppAPI返回对象的 mount 方法进行了重新, 这是为什么一会来讲。
你可能会问为什么要写的这么复杂? 我上一次看到这样利用闭包和函数柯里化的技巧来进行封装还是在 Vue2.x compileToFunctions 。
compileToFunctions 编译器入口函数,他把代码设计的如此繁琐的目的是在于利用createCompilerCreator 函数创建出针对于不同平台的编译器 , compileToFunctions不包含任何特定平台相关的逻辑。 Vue2.5.1源码:github.com/vuejs/vue/b…
在回归到 Vue3 createApp 复杂性的问题上来。 通过代码我们知道调用 ensureRenderer() 函数创建渲染器,当用户只依赖响应式包就不会创建渲染器,因此可以通过 tree-shaking 的方式移除核心渲染逻辑相关的代码。
ensureRenderer 的调用实际是用户在执行 createApp 的时候触发的,如果你只从 Vue 里引入 reactivity 相关 API,而不执行 createApp,就不会执行 ensureRenderer,也就不会创建渲染器,渲染器相关代码就会在打包过程中通过 tree-shaking 移除。
mount 函数的一些改变
看过Vue2.x 的源码就知道在Vue2中 mount 方法定义为 "public mount method", 第二次写 $mount 方法中增加了编译器的入口函数用于模板编译,最终还是会去调用 "public mount method"。
//"public mount method"
Vue.prototype.$mount = function(el, hydrating) {
el = el && inBrowser ? query(el) : undefined;
return mountComponent(this, el, hydrating)
};
var mount = Vue.prototype.$mount;
Vue.prototype.$mount = function(el, hydrating) {
//模板编译
var ref = compileToFunctions(template, {
//...
}, this);
var render = ref.render;
var staticRenderFns = ref.staticRenderFns;
options.render = render;
options.staticRenderFns = staticRenderFns;
return mount.call(this, el, hydrating)
};
在 Vue3 中对于 mount 方法做了一些改动但是为了兼容 Vue2 所以我们还是能看到一些Vue2 的影子。
Vue3 mount方法关注点不再是 Runtime-only || Runtime + Compiler 而是提供跨平台渲染支持, createApp 函数内部的 app.mount 方法是一个标准的可跨平台的组件渲染流程:
function createApp(rootComponent, rootProps = null) {
const app = {
_component: rootComponent,
_props: rootProps,
mount(rootContainer) {
// 创建根组件的 vnode
const vnode = createVNode(rootComponent, rootProps)
// 利用渲染器渲染 vnode
render(vnode, rootContainer)
app._container = rootContainer
return vnode.component.proxy
}
}
}
标准的跨平台渲染流程是先创建 vnode,再渲染 vnode。此外参数 rootContainer 也可以是不同类型的值,比如,在 Web 平台它是一个 DOM 对象,而在其他平台(比如 Weex 和小程序)中可以是其他类型的值。所以这里面的代码不应该包含任何特定平台相关的逻辑,也就是说这些代码的执行逻辑都是与平台无关的。因此我们需要在外部重写这个方法,来完善 Web 平台下的渲染逻辑。
app.mount 重写:
const createApp = ((...args) => {
const app = ensureRenderer().createApp(...args);
//...
const { mount } = app;
app.mount = (containerOrSelector) => {
// 标准化容器
const container = normalizeContainer(containerOrSelector);
if (!container)
return;
const component = app._component;
if (!isFunction(component) && !component.render && !component.template) {
component.template = container.innerHTML;
}
// clear content before mounting
container.innerHTML = '';
//真正的挂载
const proxy = mount(container);
container.removeAttribute('v-cloak');
container.setAttribute('data-v-app', '');
return proxy;
};
return app;
});
首先是通过 normalizeContainer 标准化容器,然后做一个 if 判断,如果组件对象没有定义 render 函数和 template 模板,则取容器的 innerHTML 作为组件模板内容;接着在挂载前清空容器内容,最终再调用 app.mount 的方法走标准的组件渲染流程。
在这里,重写的逻辑都是和 Web 平台相关的,所以要放在外部实现。此外,这么做的目的是既能让用户在使用 API 时可以更加灵活,也兼容了 Vue.js 2.x 的写法,比如 app.mount 的第一个参数就同时支持选择器字符串和 DOM 对象两种类型。
从 app.mount 开始,才算真正进入组件渲染流程,那么接下来,我们就重点看一下核心渲染流程做的两件事情:创建 vnode 和渲染 vnode。