一、前言
Vue3从2020年9月发布至今已有三年半的时间了,经过大大小小的迭代和优化,目前已成为Vue开发的首选版本。Vue2也在去年年底停止维护,所以现在学习Vue3已成为Vue技术栈的一门必备功课。
二、项目入手
初始化Vue3项目:
vue create my-vue3-project
此时可得到这样一个项目:
根据经验,我们很容易知道main.js就是真实的入口文件:
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
const app = createApp(App)
app.mount('#app')
#app是html页面上一个元素的id,这段代码逻辑为:
- 引入createApp
- 引入样式文件style.css
- 引入单文件组件App.vue
- 调用createApp传入组件App,并执行mount将其挂载到#app元素上
所以,createApp是一个接受Vue组件并返回一个带有amount方法的对象。
1、createApp
在main.js中,通过Ctrl+右键,可以自动跳转到node_modules下找到createApp对应类型声明文件,但这不是源码,源码的话还是得去官方仓库查看,对应的源码目录在core/packages/runtime-dom/src/index.ts(vue2的源码项目名为vue,vue3的项目名为core)。
createApp的源码如下:
export const createApp = ((...args) => {
const app = ensureRenderer().createApp(...args)
if (__DEV__) {
injectNativeTagCheck(app)
injectCompilerOptionsCheck(app)
}
const { mount } = app
app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
const container = normalizeContainer(containerOrSelector)
if (!container) return
const component = app._component
if (!isFunction(component) && !component.render && !component.template) {
// __UNSAFE__
// Reason: potential execution of JS expressions in in-DOM template.
// The user must make sure the in-DOM template is trusted. If it's
// rendered by the server, the template should not contain any user data.
component.template = container.innerHTML
// 2.x compat check
if (__COMPAT__ && __DEV__) {
for (let i = 0; i < container.attributes.length; i++) {
const attr = container.attributes[i]
if (attr.name !== 'v-cloak' && /^(v-|:|@)/.test(attr.name)) {
compatUtils.warnDeprecation(
DeprecationTypes.GLOBAL_MOUNT_CONTAINER,
null,
)
break
}
}
}
}
// clear content before mounting
container.innerHTML = ''
const proxy = mount(container, false, resolveRootNamespace(container))
if (container instanceof Element) {
container.removeAttribute('v-cloak')
container.setAttribute('data-v-app', '')
}
return proxy
}
return app
}) as CreateAppFunction<Element>
可以看出createApp是对ensureRenderer().createApp(...args)返回的app实例的amount方法进行重写,增加了对传入参数(需要挂载的节点,上述说到的App.vue)的校验(是否能找到指定元素)和元素清空的方法。但核心仍是ensureRenderer()和他返回的createApp()。
2、ensureRenderer
进入ensureRenderer方法,发现其也只是调用了createRenderer方法:
function ensureRenderer() {
return (
renderer ||
(renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
)
}
当我看到这里时,会有疑问: 问题①:ensureRenderer方法作用是什么,为什么需要他,直接返回createRenderer方法不行吗? 我们先了解一下vue3的机制: vue3的渲染器(renderer)是负责将vue组件渲染成实际的DOM的模块,渲染器的创建和初始化是比较复杂的过程,涉及多个步骤和选项。为了保持代码的简洁和模块化,vue3的源码将渲染器的创建和初始化封装在一起,就是createRenderer方法。 问题②:那为什么需要ensureRenderer方法呢?
- 懒加载:在大多数情况下,我们可能不需要立即创建渲染器,而是在真正需要渲染组件时才创建。这样可以延迟渲染器的创建,节省资源并提高性能
- 单例模式:通常情况下,我们只需要一个渲染器来处理整个应用程序的渲染工作。ensureRenderer 方法确保了在需要渲染器时只会创建一个,并且后续调用都会返回相同的实例,这就是单例模式的应用
- 缓存:ensureRenderer 方法可能会缓存已创建的渲染器实例,以便下次调用时直接返回缓存的实例(返回的renderer始终是同一个),而不是重新创建一个新的。这样可以提高性能,并且保证了渲染器的存在性和唯一性
首先,createRenderer 接收一个 rendererOptions 参数并返回一个 Renderer | HydrationRenderer 类型的实例。
rendererOptions 是 patchProp 和 nodeOps 两个对象的合集,包含了insert(parent.insertBefore)、remove(parent.removeChild)、createElement等DOM的操作方法,以及 patchProp 节点属性对比方法。
而 createRenderer 是通过 baseCreateRenderer 来创建渲染器(位于packages\runtime-core\src\renderer.ts )。
3、baseCreateRenderer
baseCreateRenderer 的代码就有两千行左右,除了创建渲染器的逻辑外,还定义了与更新和渲染相关的方法,其中就包含diff算法,也就是patch函数。
baseCreateRenderer 最后返回了 render 和 hydrate 两个属性,以及一个 createAppAPI 返回的方法createApp。
<!-- 先展示跟return直接相关的代码,省略其余代码(太多了) -->
function baseCreateRenderer(
options: RendererOptions,
createHydrationFns?: typeof createHydrationFunctions
): any {
const render: RootRenderFunction = (vnode, container, namespace) => {
if (vnode == null) {
if (container._vnode) {
unmount(container._vnode, null, null, true)
}
} else {
patch(container._vnode || null, vnode, container, null, null, null, namespace)
}
flushPreFlushCbs()
flushPostFlushCbs()
container._vnode = vnode
}
const internals: RendererInternals = {
p: patch,
um: unmount,
m: move,
r: remove,
mt: mountComponent,
mc: mountChildren,
pc: patchChildren,
pbc: patchBlockChildren,
n: getNextHostNode,
o: options
}
let hydrate: ReturnType<typeof createHydrationFunctions>[0] | undefined
return {
render,
hydrate,
createApp: createAppAPI(render, hydrate)
}
}
createHydrationFns:作用是创建用于服务端渲染(SSR)的注水函数(hydration functions)。 在服务端渲染中,首次渲染是在服务器端完成的,服务器会生成一份完整的 HTML 页面,并将 Vue 组件的状态序列化到 HTML 中。当浏览器加载这个页面时,Vue 需要从服务器生成的 HTML 中提取出组件状态,并将其恢复到客户端渲染模式。这个过程就是所谓的“注水”,因为它是将组件状态“注入”到客户端渲染的 HTML 中。
createHydrationFns 函数的作用就是创建用于执行这个注水过程的函数。它返回一个对象,其中包含了两个函数:hydrate 和 hydrateState。
- hydrate 函数用于将服务器生成的 HTML 与客户端的 Vue 实例进行关联,使得客户端的 Vue 实例能够接管服务器渲染的 HTML,并进行进一步的交互和更新。
- hydrateState 函数用于提取 HTML 中的组件状态,并将其恢复到客户端的 Vue 实例中,以保持组件状态的一致性。
我们这里并不是服务端渲染,那么 createHydrationFns 参数是没有值的,hydrate 也就是 undefined;render 作为主要渲染方法,主要负责更新和卸载,将虚拟 DOM(vnode)渲染到指定的容器(container)中。
render 函数接收三个参数,vnode(虚拟节点)、container(组件实例)、namespace(命名空间),这段代码主要逻辑很简单,先判断传入的 vnode 若为null,则表示要销毁当前容器中的内容(更新后的内容是清空挂载元素),则调用 container._vnode 判断容器中是否有内容,如有,则调用 unmount 来销毁之前的虚拟DOM(执行卸载操作);如果传入的 vnode 不为null,表示要渲染新的内容到容器中 ,则调用 patch 方法,将新的虚拟DOM(vnode)与之前的虚拟DOM(container._vnode)进行对比和更新,然后执行相应的副作用函数队列,最后把传入的 vnode 更新到实例的 _vnode(container._vnode) 属性上作为下次对比的旧节点数据。
总结: 这段代码实现了一个简单的渲染函数,负责将虚拟 DOM 渲染到指定的容器中,并处理了销毁旧内容和触发预置回调函数的逻辑 。
4、createAppAPI
通过之前的学习,我们可以知道 createAppAPI 返回的是一个函数,且是用来创建Vue单页应用根实例的方法。
let uid = 0
export function createAppAPI<HostElement>(
render: RootRenderFunction<HostElement>,
hydrate?: RootHydrateFunction,
): CreateAppFunction<HostElement> {
return function createApp(rootComponent, rootProps = null) {
if (!isFunction(rootComponent)) {
rootComponent = extend({}, rootComponent)
}
if (rootProps != null && !isObject(rootProps)) {
__DEV__ && warn(`root props passed to app.mount() must be an object.`)
rootProps = null
}
const context = createAppContext()
const installedPlugins = new WeakSet()
let isMounted = false
const app: App = (context.app = {
_uid: uid++,
_component: rootComponent as ConcreteComponent,
_props: rootProps,
_container: null,
_context: context,
_instance: null,
version,
get config() {
return context.config
},
set config(v) {
if (__DEV__) {
warn(
`app.config cannot be replaced. Modify individual options instead.`,
)
}
},
use(plugin: Plugin, ...options: any[]) {
if (installedPlugins.has(plugin)) {
__DEV__ && warn(`Plugin has already been applied to target app.`)
} else if (plugin && isFunction(plugin.install)) {
installedPlugins.add(plugin)
plugin.install(app, ...options)
} else if (isFunction(plugin)) {
installedPlugins.add(plugin)
plugin(app, ...options)
} else if (__DEV__) {
warn(
`A plugin must either be a function or an object with an "install" ` +
`function.`,
)
}
return app
},
mixin(mixin: ComponentOptions) {
if (__FEATURE_OPTIONS_API__) {
if (!context.mixins.includes(mixin)) {
context.mixins.push(mixin)
} else if (__DEV__) {
warn(
'Mixin has already been applied to target app' +
(mixin.name ? `: ${mixin.name}` : ''),
)
}
} else if (__DEV__) {
warn('Mixins are only available in builds supporting Options API')
}
return app
},
component(name: string, component?: Component): any {
if (__DEV__) {
validateComponentName(name, context.config)
}
if (!component) {
return context.components[name]
}
if (__DEV__ && context.components[name]) {
warn(`Component "${name}" has already been registered in target app.`)
}
context.components[name] = component
return app
},
directive(name: string, directive?: Directive) {
if (__DEV__) {
validateDirectiveName(name)
}
if (!directive) {
return context.directives[name] as any
}
if (__DEV__ && context.directives[name]) {
warn(`Directive "${name}" has already been registered in target app.`)
}
context.directives[name] = directive
return app
},
mount(
rootContainer: HostElement,
isHydrate?: boolean,
namespace?: boolean | ElementNamespace,
): any {
if (!isMounted) {
// #5571
if (__DEV__ && (rootContainer as any).__vue_app__) {
warn(
`There is already an app instance mounted on the host container.\n` +
` If you want to mount another app on the same host container,` +
` you need to unmount the previous app by calling \`app.unmount()\` first.`,
)
}
const vnode = createVNode(rootComponent, rootProps)
// store app context on the root VNode.
// this will be set on the root instance on initial mount.
vnode.appContext = context
if (namespace === true) {
namespace = 'svg'
} else if (namespace === false) {
namespace = undefined
}
// HMR root reload
if (__DEV__) {
context.reload = () => {
// casting to ElementNamespace because TS doesn't guarantee type narrowing
// over function boundaries
render(
cloneVNode(vnode),
rootContainer,
namespace as ElementNamespace,
)
}
}
if (isHydrate && hydrate) {
hydrate(vnode as VNode<Node, Element>, rootContainer as any)
} else {
render(vnode, rootContainer, namespace)
}
isMounted = true
app._container = rootContainer
// for devtools and telemetry
;(rootContainer as any).__vue_app__ = app
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
app._instance = vnode.component
devtoolsInitApp(app, version)
}
return getExposeProxy(vnode.component!) || vnode.component!.proxy
} else if (__DEV__) {
warn(
`App has already been mounted.\n` +
`If you want to remount the same app, move your app creation logic ` +
`into a factory function and create fresh app instances for each ` +
`mount - e.g. \`const createMyApp = () => createApp(App)\``,
)
}
},
unmount() {
if (isMounted) {
render(null, app._container)
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
app._instance = null
devtoolsUnmountApp(app)
}
delete app._container.__vue_app__
} else if (__DEV__) {
warn(`Cannot unmount an app that is not mounted.`)
}
},
provide(key, value) {
if (__DEV__ && (key as string | symbol) in context.provides) {
warn(
`App already provides property with key "${String(key)}". ` +
`It will be overwritten with the new value.`,
)
}
context.provides[key as string | symbol] = value
return app
},
runWithContext(fn) {
const lastApp = currentApp
currentApp = app
try {
return fn()
} finally {
currentApp = lastApp
}
},
})
if (__COMPAT__) {
installAppCompatProperties(app, context, render)
}
return app
}
}
createAppContext 方法返回一个 AppContext 类型的对象,其中包含了app、config、provides等多个属性,其中还有一个 mixins 数组去兼容Vue2。
这个对象的格式跟Vue2构造函数最初生成的app实例属性基本一致。
然后声明了一个 Set 变量 installedPlugins ,用来确保不会重复安装插件。
最后,返回根实例对象 app 。
与上文的进入 createApp 相对应的是,这里的 app实例 已经默认定义了一个 mount 方法,但这个 mount方法比较简单,只是在 isMounted 为false的情况下(还未首次挂载),通过传入的 render 或者 hydrate 方法进行渲染,修改挂载状态,最后创建一个根组件的 exposed 暴漏给 Proxy 对象代理。