Vue3.0源码逐行解析(一)从创建一个实例开始

281 阅读5分钟

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.mount(vm.mount(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方法重写主要是因为Runtime+Compiler需要编译器,Runtimeonly不需要编译器。所以就把第一次写的mount 方法重写主要是因为 Runtime + Compiler 需要编译器 , Runtime-only 不需要编译器 。所以就把第一次写的 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。