vue2组件实现原理
vue2组件原理
前面几章讲了vue2的响应式原理、异步更新原理、首次渲染原理、更新渲染原理,没了解的可以先去看看。这章主要讲vue2的组件原理,先看一个使用了组件的简单案例:
案例
先看一个简单的局部组件使用案例,全局组件先放着,后面讲
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
<script src="../../dist/vue.js"></script>
</head>
<body>
<div id="app">
<div>
<child-component :name="childName"></child-component>
</div>
</div>
<script>
const childComponent = {
props: {
name: {
type: String
}
},
template: `
<head>
<p> I am component</p>
<div>{{ name }}</div>
</head>
`
}
new Vue({
el: '#app',
components: {
childComponent
},
data() {
return {
childName: '张三'
}
}
})
</script>
</body>
</html>
回顾下我之前讲的首次渲染流程:
new Vue(options)
-> this._init
初始化 -> this.$mount
将template
编译成 render
-> mountComponent
创建渲染watcher
-> this._render
调用刚刚的render
生成 VNode
-> this._update
-> patch
-> createElm
生成真实dom
并挂载
先看this._init
// core/instance/init.js
Vue.prototype._init = function (options?: Object) {
// 实例
const vm: Component = this
// 省略部分无关代码
// 当前是子组件, 暂时忽略
if (options && options._isComponent) {
// 子组件的部分处理
initInternalComponent(vm, options)
// 当前是根组件,new Vue()是根组件,所以我们先看这里
} else {
// 合并构造函数的options和当前实例的options,并挂载到实例的$options上
vm.$options = mergeOptions(
// 获取定义在构造函数上的options配置
// 全局的组件、指令、过滤器配置就是直接定义在Vue上的,也就是构造函数上
// 所以这里就将全局的配置和当前配置合并
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
// 省略部分无关代码
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
// 获取定义在构造函数上的options配置
function resolveConstructorOptions (Ctor: Class<Component>) {
let options = Ctor.options
// 省略部分代码
return options
}
new Vue(options)
后,调用根组件的this._init
,然后合并构造函数的options和传入的options,赋值给 this.$options
,此时this.$options
长这样:
可以看到,我们写的childComponent
已经挂载在$options.componets
上了,同时vue2内置的3个全局组件之前是定义在构造函数上的,现在也挂载在了$options.components
的原型链上了。
继续调完根实例的this._init
后就是调this.$mount
将template
编译成 render
。
看下此时生成的render
函数的结构:
function anonymous() {
with(this){
return _c(
'div',
{attrs:{"id":"app"}},
[
_c(
'div',
[
// 子组件编译后的渲染函数, 重点!!!
_c('child-component',{attrs:{"name":childName}})
],
1
)
]
)
}
}
可以看到子组件被编译成了_c('child-component',{attrs:{"name":childName}})
,讲首次渲染那章时有讲过_c
就是 createElement
// core/vdom/create-element.js
// 省略部分开发环境代码和其他功能代码
// createElement会处理下入参后,调用_createElement并添加参数vm
// 此时的参数顺序就 vm, 'child-component', {attrs:{"name": '张三'}}
function _createElement (
context: Component,
tag?: string | Class<Component> | Function | Object,
data?: VNodeData,
children?: any,
normalizationType?: number
): VNode | Array<VNode> {
// 省略xxxxx
let vnode
if (typeof tag === 'string') {
let Ctor
if (config.isReservedTag(tag)) {
// html节点
// 省略xxxxx
} else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
// 组件节点, 重点看这里!!!!
// 此时的tag是‘child-component’,字符串且非html节点
// 调用resolveAsset就是去 this.$options.components 上匹配 是否存在 tag
// 匹配成功即返回组件的配置,它可能是子组件的选项配置对象(本案例就是这种),也可能是
// 子组件的构造函数(引入其他单文件组件时是这种)
// 最后调用 createComponent 去生成子组件 Vnode
vnode = createComponent(Ctor, data, context, children, tag)
} else {
// 未知节点
// 省略xxxxx
}
} else {
// 直接试 component options 或者 constructor 的情况, 暂时忽略
vnode = createComponent(tag, data, context, children)
}
// 返回 vnode
if (isDef(vnode)) {
return vnode
} else {
return createEmptyVNode()
}
}
export function resolveAsset (
options: Object,
type: string,
id: string,
warnMissing?: boolean
): any {
if (typeof id !== 'string') {
return
}
const assets = options[type]
// 直接匹配
if (hasOwn(assets, id)) return assets[id]
// 驼峰、中划线 转换后 去匹配
const camelizedId = camelize(id)
if (hasOwn(assets, camelizedId)) return assets[camelizedId]
const PascalCaseId = capitalize(camelizedId)
if (hasOwn(assets, PascalCaseId)) return assets[PascalCaseId]
const res = assets[id] || assets[camelizedId] || assets[PascalCaseId]
return res
}
根据上面的注释,可以知道,此时子组件child-component
会匹配成功,并且Ctor
返回的就是
我们之前写的子组件的选项参数配置
然后就是调用createComponent
生成vnode
// core/vdom/create-component.js
export function createComponent (
Ctor: Class<Component> | Function | Object | void,
data: ?VNodeData,
context: Component,
children: ?Array<VNode>,
tag?: string
): VNode | Array<VNode> | void {
if (isUndef(Ctor)) {
return
}
// 如果还是个选项配置对象, 就调用Vue.extend生成组件构造函数
// Vue.extend 是不是很熟
// 官网中介绍就是: 使用基础 Vue 构造器,创建一个“子类”。参数是一个包含组件选项的对象。
// 就是 选项配置转构造函数
if (isObject(Ctor)) {
Ctor = Vue.extend(Ctor)
}
// 简化省略下代码,这里逻辑有点多涉及到
// 异步组件、函数组件、抽象组件、v-model语法糖转换 等处理逻辑
data = data || {}
// 处理props,就是将 data -> {attrs:{"name":'张三'}} 中
// attrs中的key 和 Ctor中有的子组件的配置选项中的 props中的key 做比对
// 找出来并赋值给 propsData
// 此时propsData -> {"name":'张三'}
const propsData = extractPropsFromVNodeData(data, Ctor, tag)
const listeners = data.on
// 注册一些组件运行中需要的hook
// installComponentHooks(data)
// 这里简化一下,只留一个初始化的钩子 init
// 这里很重要,init方法就是后面渲染时调用用来生成子组件实例和真实dom的
data.hook = {
init(vnode) {
const child = vnode.componentInstance = createComponentInstanceForVnode(vnode)
child.$mount()
}
}
// 创建vnode并返回
const name = Ctor.options.name || tag
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
data, undefined, undefined, undefined, context,
{ Ctor, propsData, listeners, tag, children },
asyncFactory
)
return vnode
}
此时生成的VNode
的简化结构:
{
tag: "vue-component-1-child-component",
data: {
attrs: {},
on: undefined,
},
children: undefined,
text: undefined,
key: undefined,
componentOptions: {
propsData: {
name: "张三",
},
listeners: undefined,
tag: "child-component",
children: undefined,
}
}
还留了个问题,上面 子组件的选项配置转构造函数,我们使用的是 Vue.extend(options)
,看下
// core/global-api/extend.js
Vue.extend = function (extendOptions: Object): Function {
extendOptions = extendOptions || {}
const Super = this
const name = extendOptions.name || Super.options.name
// 定义子类构造函数
const Sub = function VueComponent (options) {
this._init(options)
}
// 子类继承父类
Sub.prototype = Object.create(Super.prototype)
Sub.prototype.constructor = Sub
Sub.cid = cid++
// 合并当前选项参数配置和全局的配置 (全局组件之类的配置)
Sub.options = mergeOptions(
Super.options,
extendOptions
)
Sub['super'] = Super
// 后面全是 将父类的静态属性方法 拷贝到 子类, 让子类也能使用这些方法
Sub.extend = Super.extend
Sub.mixin = Super.mixin
Sub.use = Super.use
ASSET_TYPES.forEach(function (type) {
Sub[type] = Super[type]
})
if (name) {
Sub.options.components[name] = Sub
}
Sub.superOptions = Super.options
Sub.extendOptions = extendOptions
Sub.sealedOptions = extend({}, Sub.options)
return Sub
}
可以看到 extend 方法就是继承,让子组件能直接使用父类的各种方法。不懂的可以去查看下js的继承和原型链之类的文章。
回到上面 通过_render
生成了vnode
,接下来就是调用_update
根据vnode
生成真实dom。回顾下之前首次渲染的逻辑
Vue.prototype._update = function (vnode: VNode) {
const vm: Component = this
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
}
首次渲染就是调用patch
生成dom
// core/vdom/patch.js
// 省略部分代码
function patch (oldVnode, vnode, hydrating, removeOnly) {
// 只有旧节点,没有新节点,则毁旧节点
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
// 没有旧节点, 直接渲染新节点
if (isUndef(oldVnode)) {
createElm(vnode)
} else {
// oldVnode是否是真实节点,存在即上面首次渲染时直接传$el的情况
const isRealElement = isDef(oldVnode.nodeType)
// oldVnode是虚拟节点,且和新建节点是同一节点
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 对比新旧节点
patchVnode(oldVnode, vnode)
} else {
// oldVnode是真实节点,即根节点首次渲染
if (isRealElement) {
// 根据oldVnode的真实dom,生成一个无子节点的空节点赋给oldVnode
oldVnode = emptyNodeAt(oldVnode)
}
// 获取旧真实dom,和其父dom
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// 这里!!!!
// 无论是首次渲染还是,新旧节点不一致的情况,都会根据新vnode创建新的dom
createElm(
vnode,
parentElm,
nodeOps.nextSibling(oldElm)
)
// 最后销毁旧节点
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
// 返回生成好的新真实dom
return vnode.elm
}
首次渲染就是调用createElm
function createElm (
vnode,
insertedVnodeQueue,
parentElm
) {
vnode.isRootInsert = !nested // for transition enter check
// 创建子组件,并挂载子组件, 子组件的逻辑后面的章节会讲
if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
return
}
// 省略
}
继续接着是 调用 createComponent
function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
let i = vnode.data
if (isDef(i)) {
// 重点!!! 这里就是调用刚刚在生成组件vnode时注册的init构造
/**
data.hook = {
init(vnode) {
const child = vnode.componentInstance = createComponentInstanceForVnode(vnode)
child.$mount()
}
}
**/
if (isDef(i = i.hook) && isDef(i = i.init)) {
i(vnode)
}
// 实例生成成功
if (isDef(vnode.componentInstance)) {
// 挂载真实dom
vnode.elm = vnode.componentInstance.$el
insert(parentElm, vnode.elm, refElm)
return true
}
}
}
function createComponentInstanceForVnode (
// we know it's MountedComponentVNode but flow doesn't
vnode: any,
// activeInstance in lifecycle state
parent: any
): Component {
const options: InternalComponentOptions = {
_isComponent: true,
_parentVnode: vnode,
parent
}
// check inline-template render functions
const inlineTemplate = vnode.data.inlineTemplate
if (isDef(inlineTemplate)) {
options.render = inlineTemplate.render
options.staticRenderFns = inlineTemplate.staticRenderFns
}
return new vnode.componentOptions.Ctor(options)
}
至此组件虚拟dom转真实dom并挂载成功。
还有个问题,父组件传给子组件的props -> name: '张三',是怎么处理了?
上面我们在调用createComponent
生成vnode
时将 父组件传给子组件的 props,赋值到了 propsData
变量,然后传给 VNode 类的倒数第二个参数,去生成组件的vnode
。
// 传propsData
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
data, undefined, undefined, undefined, context,
{ Ctor, propsData, listeners, tag, children },
asyncFactory
)
// 生成VNode
class VNode {
...
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
...
this.componentOptions = componentOptions
...
}
}
也就是将{ Ctor, propsData, listeners, tag, children }
等数据放到了vnode.componentOptions
里。
然后在组件初始化调_init
的是把 componentOptions
中 propsData,isteners, tag, children
等信息赋值到组件的this.$options
上,
Vue.prototype._init = function (options) {
...
if (options && options._isComponent) {
initInternalComponent(vm, options)
}
...
initState(this)
}
function initInternalComponent (vm, options) {
const opts = vm.$options = Object.create(vm.constructor.options)
// doing this because it's faster than dynamic enumeration.
const parentVnode = options._parentVnode
opts.parent = options.parent
opts._parentVnode = parentVnode
const vnodeComponentOptions = parentVnode.componentOptions
opts.propsData = vnodeComponentOptions.propsData
opts._parentListeners = vnodeComponentOptions.listeners
opts._renderChildren = vnodeComponentOptions.children
opts._componentTag = vnodeComponentOptions.tag
if (options.render) {
opts.render = options.render
opts.staticRenderFns = options.staticRenderFns
}
}
最后再在initState
里调 initProps
function initProps (vm: Component, propsOptions: Object) {
const propsData = vm.$options.propsData || {}
const props = vm._props = {}
// cache prop keys so that future props updates can iterate using Array
// instead of dynamic object key enumeration.
const keys = vm.$options._propKeys = []
const isRoot = !vm.$parent
// root instance props should be converted
if (!isRoot) {
toggleObserving(false)
}
for (const key in propsOptions) {
keys.push(key)
const value = validateProp(key, propsOptions, propsData, vm)
defineReactive(props, key, value)
if (!(key in vm)) {
proxy(vm, `_props`, key)
}
}
toggleObserving(true)
}
在initProps
里校验好父组件传给子组件的propsData
并赋值给对应的props
总结
整体流程的代码有点多,流程有点长,可能没看懂,不慌最后我们总结下整体流程,看完后可以再回过头看看
组件的渲染流程:
父组件调this._init
初始化父组件 -> 将父组件选项配置中的components
内容挂载到组件的this.$options
上 -> 接着调this.$mount
-> 调compileToFunctions
将父组件的template
编译成render
-> new watcher
生成父组件的渲染wacther
并首次渲染执行一次 -> 调_render
利用 createElement
将render
函数解析并生成父组件的vnode
树 -> 解析过程中遇到在父组件的this.$options.components
上配置过的tag
时 ->利用配置的component
信息去调用createComponent
生成子组件的vnode
-> 此时配置的componet
信息,如果直接就是子组件的构造函数(即components
里直接传的就是子组件的构造函数,例如我们直接引入一个单位件组件) 就会直接利用构造函数Ctor
生成vnode
,如果配置信息还是只是个选项配置对象(本案例的情况)就会调用 Vue.extend
方法继承全局Vue
的构造函数生成子组件的构造函数,返回回来龙利用这个构造函数生成vnode
-> 生成过程中将能利用vnode
生成对应组件实例以及生成真实dom
并挂载的方法init
钩子预先放在vnode
上 ->生成完父组件的所有vnode
后,调用父组件的this,_update
方法-> 调用patch
-> 调用createElm
去利用vnode
生成真实dom
->此时遇到子组件的vnode
就会调用刚刚预先挂载在vnode
上的init
钩子生成子组件的真实dom
并挂载 -> init
钩子主要逻辑就是 -> 调用createComponentInstanceForVnode
利用挂载在vnode
上的子组件构造函数Ctor
去new 生成子组件的实例 -> 在调用子组件的$mount
去生成子组件的真实dom
并挂载在父组件的对应位置。
组件传参原理:
- 在上面将父组件
template
编译成render
的过程中,会将父组件传给子组件的props
解析为具体数据并挂载在子组件的attrs
上。 - 在调用
createComponet
生成子组件vnode
时,调用extractPropsFromVNodeData
方法根据在子组选项配置对象中的props
里配置过的key
将attr
中相同key
的数据,集中并赋值给propsData
然后挂载到生成的子组件vnode
上 - 在
createEle
生成真实dom
阶段,调用createComponentInstanceForVnode
去new Ctor
生成子组件实例的过程中,调子组件的_init
->initState
->initProps
时将propsData
中的数据赋值给子组件的_props
,并响应式化_props
,然后将_props
直接代理到子组件的this
,至此父组件的数据传递到了子组件里。
组件更新原理:
值得一提的是,我们在响应式化_props
时,其实只给最外层的数据做了get\set
处理。那么修改props值时就存在两种情况。
-
父组件传给子组件的数据是原始数据类型,也就是本身只有第一层。父组件里更改这个值会直接触发子组件的
_props
的set,导致子组件重新渲染。 -
父组件传给子组件的数据是引用类型,也就是对象或者数组。父组件更改这个值时,此时又分两种情况。
- 父组件直接将整个引用给改了,也就是把整个对象换了个值,这就和第一种情况一样,会直接触发子组件的
_props
的set,导致子组件重新渲染。 - 父组件没有改掉整个引用,而是只改了这个对象跟深层次的值时,因为引用没变,所以此时不会触发子组件的
_props
的set,也就不会通过这种方式导致子组件重新渲染。但是我们实际使用过程中发现这种情况是会导致子组件重新渲染的,那么vue2是怎么实现的呢?其实原理就是,配置在父组件上的数据本身是有被递归设置响应式化的,那么传给子组件后,子组件使用该值的深层属性时,就会触发对应值的get,这时候直接就将子组件的渲染watcher
给收集了,然后在父组件改变其值触发其set时,直接就触发了子组件的渲染watcher
更新。
- 父组件直接将整个引用给改了,也就是把整个对象换了个值,这就和第一种情况一样,会直接触发子组件的
那么当父组件没有更改传给子组件的props的值,但是修改了其他数据导致父组件重新渲染时,子组件会重新渲染吗?
答案是不会,当父组件更新时,对比子组件vnode
时,由于子组件没有变化,所以会触发sameVnode
,然后调用patchVnode
复用子组件vnode
,然后更新子组件的属性,这其中包括将
propsData
的值赋值给_props
,触发_props
的set
,但由于值没变,所以不会触发子组件的重新渲染。
所以vue的渲染级别是组件级的。
最后贴下updateChildComponent
的简化代码
// core/instance/lifecycle.js
export function updateChildComponent (
vm: Component,
propsData: ?Object,
listeners: ?Object,
parentVnode: MountedComponentVNode,
renderChildren: ?Array<VNode>
) {
// update props
if (propsData && vm.$options.props) {
// 避免递归响应式化
toggleObserving(false)
const props = vm._props
const propKeys = vm.$options._propKeys || []
for (let i = 0; i < propKeys.length; i++) {
const key = propKeys[i]
const propOptions: any = vm.$options.props
// 将propsData的值赋值给 _props, 同时触发其set
props[key] = validateProp(key, propOptions, propsData, vm)
}
toggleObserving(true)
// keep a copy of raw propsData
vm.$options.propsData = propsData
}
}
最后简单讲讲vue中的异步组件
异步组件就是在父组件首次渲染时,然后创建组件vnode
的过程中判断当前组件是异步组件,则返回一个空vnode
先占位,然后调用异步方法,请求异步组件,待异步组件返回后调用父组件的$forceUpdate
重新渲染整个父组件包括子组件。
转载自:https://juejin.cn/post/7190249194043473975