数据驱动及 Vue 挂载原理
一、数据驱动
Vue 的核心思想是数据驱动和组件化。其中数据驱动指视图是由数据驱动生成的,通过修改数据的方式来更新视图,而不直接操作 DOM。
相比传统的前端开发(如 jQuery)直接修改 DOM,大大简化了代码量。特别是当交互复杂的时候,只关心数据的修改会让代码的逻辑变的非常清晰。
<div id="app">
{{ message }}
</div>
那么 Vue 是如何将模板和数据渲染成最终的 DOM 呢?
二、new Vue 发生了什么
new 关键字在 JS 中用来实例化一个对象,而 Vue 作为一个类,在源码中用 Function 来实现,参数是用户传入的 options
配置项(data、methods 等),通过 new 关键字来初始化 Vue,在其中调用 this._init
方法:
src/core/instance/index.ts
// https://github.com/vuejs/vue/blob/main/src/core/instance/index.ts#L9
function Vue(options) {
if (__DEV__ && !(this instanceof Vue)) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options) // 这里的 _init 通过下面的 initMixin 来定义
}
initMixin(Vue) // 定义 _init
stateMixin(Vue) // 定义 $set $get $delete $watch 等
eventsMixin(Vue) // 定义 $on $once $off $emit
lifecycleMixin(Vue) // 定义 _update $forceUpdate $destroy
renderMixin(Vue) // 定义 _render 返回虚拟 DOM
export default Vue as unknown as GlobalAPI
1、init 初始化
src/core/instance/init.ts
// https://github.com/vuejs/vue/blob/main/src/core/instance/init.ts#L16
export function initMixin(Vue: typeof Component) {
Vue.prototype._init = function (options?: Record<string, any>) {
const vm: Component = this
// a uid
vm._uid = uid++
let startTag, endTag
/* istanbul ignore if */
if (__DEV__ && config.performance && mark) {
startTag = `vue-perf-start:${vm._uid}`
endTag = `vue-perf-end:${vm._uid}`
mark(startTag)
}
// a flag to mark this as a Vue instance without having to do instanceof
// check
vm._isVue = true
// avoid instances from being observed
vm.__v_skip = true
// effect scope
vm._scope = new EffectScope(true /* detached */)
vm._scope._vm = true
// merge options 合并属性,判断初始化的是否是组件
// 这里合并主要是 mixins 或 extends 的方法
if (options && options._isComponent) {
// optimize internal component instantiation
// since dynamic options merging is pretty slow, and none of the
// internal component options needs special treatment.
initInternalComponent(vm, options as any)
} else {
// 合并 Vue 属性
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor as any),
options || {},
vm
)
}
/* istanbul ignore else */
if (__DEV__) {
// 初始化 proxy 拦截器
initProxy(vm)
} else {
vm._renderProxy = vm
}
// expose real self
vm._self = vm
initLifecycle(vm) // 初始化组件生命周期标志位
initEvents(vm) // 初始化组件事件侦听
initRender(vm) // 初始化渲染方法
callHook(vm, 'beforeCreate', undefined, false /* setContext */)
initInjections(vm) // 在初始化 data、props 之前初始化依赖注入内容
initState(vm) // 初始化 props〡data〡method〡watch〡methods
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')
/* istanbul ignore if */
if (__DEV__ && config.performance && mark) {
vm._name = formatComponentName(vm, false)
mark(endTag)
measure(`vue ${vm._name} init`, startTag, endTag)
}
// 挂载元素
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
}
可以看到,Vue 在 init
中初始化数据,然后通过 $mount 方法来解析 template 和挂载 DOM 元素。
2、$mount 挂载实例
$mount 方法在多个文件中都有定义,如:
2-1、compiler $mount
下面来分析 compiler 版本的 $mount 实现:
src/platforms/web/runtime-with-compiler.ts
// https://github.com/vuejs/vue/blob/main/src/platforms/web/runtime-with-compiler.ts#L20
// ...
import Vue from './runtime/index'
// ...
// 缓存原型上的 $mount 渲染组件方法
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
// 获取或查询元素
el = el && query(el)
// vue 不允许直接挂载到 body 或页面根节点上
if (el === document.body || el === document.documentElement) {
__DEV__ &&
warn(
`Do not mount Vue to <html> or <body> - mount to normal elements instead.`
)
return this
}
const options = this.$options
// 如果没有定义 render 方法
// 把 el 或 template 字符串转为 render 方法
if (!options.render) {
let template = options.template
// 存在 template 模板时解析 vue 模板文件
if (template) {
if (typeof template === 'string') {
if (template.charAt(0) === '#') {
template = idToTemplate(template)
/* istanbul ignore if */
if (__DEV__ && !template) {
warn(
`Template element not found or is empty: ${options.template}`,
this
)
}
}
} else if (template.nodeType) {
template = template.innerHTML
} else {
if (__DEV__) {
warn('invalid template option:' + template, this)
}
return this
}
} else if (el) {
// 通过选择器获取元素内容
template = getOuterHTML(el)
}
if (template) {
/* istanbul ignore if */
if (__DEV__ && config.performance && mark) {
mark('compile')
}
/**
* 1.将 template 解析为 AST Tree
* 2.将 Ast Tree 转换成 render 语法字符串
* 3.生成 render 方法
*/
const { render, staticRenderFns } = compileToFunctions(
template,
{
outputSourceRange: __DEV__,
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
},
this
)
options.render = render
options.staticRenderFns = staticRenderFns
/* istanbul ignore if */
if (__DEV__ && config.performance && mark) {
mark('compile end')
measure(`vue ${this._name} compile`, 'compile', 'compile end')
}
}
}
// 调用原先原型上的 $mount 方法挂载
return mount.call(this, el, hydrating)
}
$mount 主要做了以下事情:
- 限制 Vue 不能挂载到根节点上;
- 如果没有定义
render
方法,则将 template 解析为 AST 语法树,再转为render
语法字符串,生成render
方法; - 最后,调用原先原型上的 $mount 方法挂载。
注意,这里挂载实例后调用的 $mount 方法不是 compiler $mount,而是渲染组件方法 mountComponent
:
2-2、mountComponent 渲染组件
src/platforms/web/runtime/index.ts
// https://github.com/vuejs/vue/blob/main/src/platforms/web/runtime/index.ts#L36
// public mount method
Vue.prototype.$mount = function (
// 挂载的元素
el?: string | Element,
// 和服务端渲染相关
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
// 渲染组件
return mountComponent(this, el, hydrating)
}
其中,mountComponent
渲染组件方法如下:
src/core/instance/lifecycle.ts
// https://github.com/vuejs/vue/blob/main/src/core/instance/lifecycle.ts#L146
export function mountComponent(
vm: Component,
el: Element | null | undefined,
hydrating?: boolean
): Component {
vm.$el = el
// 如果没有获取解析的 render 函数,则抛出警告
// render 是解析模板文件生成的
if (!vm.$options.render) {
// @ts-expect-error invalid type
vm.$options.render = createEmptyVNode
if (__DEV__) {
/* 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 {
// 没有获取到 vue 的模板文件
warn(
'Failed to mount component: template or render function not defined.',
vm
)
}
}
}
// 执行 beforeMount 钩子
callHook(vm, 'beforeMount')
let updateComponent
/* istanbul ignore if */
if (__DEV__ && 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)
const vnode = vm._render()
mark(endTag)
measure(`vue ${name} render`, startTag, endTag)
mark(startTag)
vm._update(vnode, hydrating)
mark(endTag)
measure(`vue ${name} patch`, startTag, endTag)
}
} else {
// 定义更新函数
updateComponent = () => {
// 调用 vm._render 方法生成虚拟节点
// 调用 vm._update 更新 DOM
vm._update(vm._render(), hydrating)
}
}
const watcherOptions: WatcherOptions = {
before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}
if (__DEV__) {
watcherOptions.onTrack = e => callHook(vm, 'renderTracked', [e])
watcherOptions.onTrigger = e => callHook(vm, 'renderTriggered', [e])
}
// we set this to vm._watcher inside the watcher's constructor
// since the watcher's initial patch may call $forceUpdate (e.g. inside child
// component's mounted hook), which relies on vm._watcher being already defined
// 监听当前组件状态,当有数据变化时,更新组件
new Watcher(
vm,
updateComponent,
noop,
watcherOptions,
true /* isRenderWatcher */
)
hydrating = false
// flush buffer for flush: "pre" watchers queued in setup()
const preWatchers = vm._preWatchers
if (preWatchers) {
for (let i = 0; i < preWatchers.length; i++) {
preWatchers[i].run()
}
}
// manually mounted instance, call mounted on self
// mounted is called for render-created child components in its inserted hook
if (vm.$vnode == null) {
vm._isMounted = true
// 数据更新引发的组件更新
callHook(vm, 'mounted')
}
return vm
}
mountComponent
主要做了以下事情:
- 实例化一个 Watcher,在初始化和数据变化时执行回调函数;
- 其中调用 updateComponent 方法渲染页面视图;
3、updateComponent 更新函数
上面的 updateComponent 中调用了 vm._render
方法来生成 vnode,然后调用 vm._update 转为真实 DOM,并更新到页面中。
3-1、render 生成虚拟节点
src/core/instance/render.ts
// https://github.com/vuejs/vue/blob/main/src/core/instance/render.ts#L103
// 定义 vue 原型上的 render 方法
Vue.prototype._render = function (): VNode {
const vm: Component = this
// render 函数来自于组件的 option
const { render, _parentVnode } = vm.$options
if (_parentVnode && vm._isMounted) {
vm.$scopedSlots = normalizeScopedSlots(
vm.$parent!,
_parentVnode.data!.scopedSlots,
vm.$slots,
vm.$scopedSlots
)
if (vm._slotsProxy) {
syncSetupSlots(vm._slotsProxy, vm.$scopedSlots)
}
}
// set parent vnode. this allows render functions to have access
// to the data on the placeholder node.
vm.$vnode = _parentVnode!
// render self
let vnode
try {
// There's no need to maintain a stack because all render fns are called
// separately from one another. Nested component's render fns are called
// when parent component is patched.
setCurrentInstance(vm)
currentRenderingInstance = vm
// 调用 render 方法传入 createElement 参数,生成 vNode
vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e: any) {
handleError(e, vm, `render`)
// return error render result,
// or previous vnode to prevent render error causing blank component
/* istanbul ignore else */
if (__DEV__ && vm.$options.renderError) {
try {
vnode = vm.$options.renderError.call(
vm._renderProxy,
vm.$createElement,
e
)
} catch (e: any) {
handleError(e, vm, `renderError`)
vnode = vm._vnode
}
} else {
vnode = vm._vnode
}
} finally {
currentRenderingInstance = null
setCurrentInstance()
}
// if the returned array contains only a single node, allow it
if (isArray(vnode) && vnode.length === 1) {
vnode = vnode[0]
}
// return empty vnode in case the render function errored out
if (!(vnode instanceof VNode)) {
if (__DEV__ && isArray(vnode)) {
warn(
'Multiple root nodes returned from render function. Render function ' +
'should return a single root node.',
vm
)
}
vnode = createEmptyVNode()
}
// set parent
vnode.parent = _parentVnode
return vnode
}
3-2、update 转为真实 DOM
_update 主要功能是调用 patch
,将 vnode 转为真实 DOM,并更新到页面中:
src/core/instance/lifecycle.ts
// https://github.com/vuejs/vue/blob/main/src/core/instance/lifecycle.ts#L62
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el
const prevVnode = vm._vnode
// 设置当前激活的作用域
const restoreActiveInstance = setActiveInstance(vm)
vm._vnode = vnode
// Vue.prototype.__patch__ is injected in entry points
// based on the rendering backend used.
if (!prevVnode) {
// initial render
// 执行具体的挂载逻辑
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
restoreActiveInstance()
// update __vue__ reference
if (prevEl) {
prevEl.__vue__ = null
}
if (vm.$el) {
vm.$el.__vue__ = vm
}
// if parent is an HOC, update its $el as well
let wrapper: Component | undefined = vm
while (
wrapper &&
wrapper.$vnode &&
wrapper.$parent &&
wrapper.$vnode === wrapper.$parent._vnode
) {
wrapper.$parent.$el = wrapper.$el
wrapper = wrapper.$parent
}
// updated hook is called by the scheduler to ensure that children are
// updated in a parent's updated hook.
}
三、总结
Vue 将模板和数据渲染成最终 DOM 的原理是:
- 在 new Vue 时调用
_init
方法来初始化数据; - 然后调用 $mount 进行页面的挂载,挂载时将 template 解析为 AST 语法树,再转成
render
语法字符串生成render
方法; - 接着调用
mountComponent
方法渲染组件,在其中执行render
方法生成虚拟 DOM,然后调用 update 方法转为真实 DOM 结构并更新到页面中。