前言
上一节讲到渲染器负责将虚拟DOM渲染成真实DOM。但当我们编写复杂的页面时,用来描述页面结构的虚拟DOM的代码也会越多越复杂,或者是模板越大。此时,我们需要组件化的能力,可以将一个大的页面拆分成多个部分,每个部分都可以单独作为组件,共同组成完整的页面。所以我们还是从渲染器入手,看看组件是什么?如何渲染组件?有哪些组件?组件包含什么内容?
组件化的实现
虚拟节点描述组件
从用户角度来看,一个选项对象就是一个组件。 从渲染器的实现来看,组件是一个特殊类型的虚拟DOM节点。
js
// // MyComponent 是一个组件,它的值是一个选项对象
const MyComponent = {
name: 'MyComponent',
data() {
return { foo: 1}
}
}
// 该 vnode 用来描述组件,type 属性存储组件的选项对象
const vnode = {
type: MyComponent
// .....
}
组件用户层面的接口
所以用户应该如何编写组件?组件的选项对象必须要包含哪些内容?以及组件需要有哪些能力?
首先,组件本身是对内容页面的封装,它用来描述页面内容的一部分,因此,一个组件必须包含一个渲染函数,即render函数,返回组件对应的虚拟DOM。
然后,组件应该设计自身的状态,我们约定用户必须要使用data函数来定义组件自身状态,同时可以在渲染函数中通过this访问由data函数返回的状态数据。有了组件的状态后,当组件自身状态发生变化时,我们就可以将渲染任务包装到一个effect中,实现响应式更新。
最后,除了组件自身的状态,组件还可以接收外部的props数据(被动更新),包含methods、computed,生命周期等定义的数据和方法,这些内容在渲染器中应该如何处理呢?
js
import { h } from 'vue';
const MyComponent = {
name: 'MyComponent',
// 声明接收的 props
props: {
title: {
type: String,
default: '默认标题'
}
},
// 声明 emit 的事件
emits: ['update-foo', 'custom-event'],
// 响应式数据
data() {
return {
foo: 'hello world',
count: 0
};
},
// 计算属性
computed: {
reversedFoo() {
return this.foo.split('').reverse().join('');
},
displayText() {
return `foo: ${this.foo} | reversed: ${this.reversedFoo} | count: ${this.count}`;
}
},
// 方法
methods: {
handleClick() {
this.count++;
// 触发自定义事件,传递新值
this.$emit('update-foo', this.foo + '!');
this.$emit('custom-event', { count: this.count, time: Date.now() });
},
reset() {
this.foo = 'hello world';
this.count = 0;
}
},
// 生命周期钩子
beforeCreate() {
console.log('beforeCreate');
},
created() {
console.log('created: 组件实例已创建,data/props/computed/methods 可用');
},
beforeMount() {
console.log('beforeMount: 即将挂载,render 函数将被调用');
},
mounted() {
console.log('mounted: 组件已挂载到 DOM');
},
beforeUpdate() {
console.log('beforeUpdate: 数据变更,即将重新渲染');
},
updated() {
console.log('updated: 重新渲染完成');
},
beforeUnmount() {
console.log('beforeUnmount: 组件即将卸载');
},
unmounted() {
console.log('unmounted: 组件已卸载');
},
// 渲染函数:必须返回 VNode
render() {
return h('div', { class: 'my-component' }, [
h('h2', { class: 'title' }, `标题: ${this.title}`),
h('p', { class: 'info' }, this.displayText),
h('p', null, `反转 foo: ${this.reversedFoo}`),
h('button', {
onClick: this.handleClick
}, '更新 foo 并触发事件'),
h('button', {
onClick: this.reset,
style: { marginLeft: '10px' }
}, '重置')
]);
}
};
export default MyComponent;
除了以上选项外,在Vue3中,新增了setup选项,setup函数主要用于配合组合式API,为用户提供一个地方,用于建立组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子等能力。在整个的生命周期中,setup函数只会在挂载的时候被执行一次,setup函数的返回值有两种情况
- 返回一个函数,该函数将作为组件的
render函数
js
const Comp = {
setup() {
return () => {
return {type: 'div', children: 'hello'}
}
}
}
arduino
注:如果使用模板来表达渲染内容,那么setup函数不能返回函数,否则会和模板编译生成的渲染函数产生冲突
- 返回一个对象,该对象包含的数据将暴露给模板用
js
const Comp = {
setup() {
const count = ref(0)
return {
count
}
},
render() {
return { type: 'div', children: `count is: ${this.count}` }
}
}
另外setup函数接受两个参数,第一个数据是props数据对象,第二个参数也是对象,通常成为 setupContext
js
const Comp = {
props: {
foo: String
},
setup(props, setupContext) {
props.foo
const {slots, emit, attrs, expose} = setupContext
// ....
}
}
- slots:组件接收到的插槽。
- emit:一个函数,用来发射自定义事件。
- attrs:那些没有显式地声明为 props 的属性会存储到 attrs 对象中。
- expose:一个函数,用来显式地对外暴露组件数据。
渲染组件
有了组件结构之后,渲染器就可以完成组件的渲染了
js
const CompVNode = {
type: MyComponent
}
renderer.render(CompVNode, document.querySelector('#app'))
渲染器是如何渲染组件的?
js
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
const { render, data } = componentOptions
const state = reactive(data())
effect(() => {
const subTree = render.call(state, state)
patch(null, subTree, container, anchor)
},{
scheduler: queueJob
})
}
这是一个简单挂载组件的方案,但每次更新都需要全量的挂载,而不会打补丁。正确的做法应该是每次更新都拿新的subTree与上一次组件所渲染的subTree进行打补丁并维护组件的生命周期等信息让渲染器正确的时机执行合适的操作。
组件实例
为了实现组件的挂载和响应式更新,渲染器需要有组件运行过程中的所有信息,如组件是否已经被挂载、组件自身的状态、组件的生命周期、组件渲染的子树等等。我们统称这些为组件实例,本质上就是一个状态集合即一个对象。每个组件在页面时出现一次,就会创建一个独立的实例。
js
const instance = {
// 基础信息
type: MyComponent, // 组件定义对象(即你写的 { name, props, setup... })
subTree: currentVNode, // 当前 VNode
parent: parentInstance, // 父组件实例
// 状态相关
props: { title: 'Hello' }, // 解析后的 props
setupState: { count, inc }, // setup() 返回的对象(Composition API)
data: { foo: 'bar' }, // data() 返回的对象(Options API)
ctx: { // 组件上下文(this 的代理)
...methods,
$emit,
$slots,
...
},
// 插槽
slots: {
default: () => [/* VNode 数组 */],
header: () => [/* VNode 数组 */]
},
// 事件
emit: (event, ...args) => { /* 触发自定义事件 */ },
// 生命周期
isMounted: false,
mounted: [],
...
}
有了组件实例,就能驱动驱动该组件的渲染与更新了
scss
创建 VNode
↓
createComponentInstance()
↓
setupComponent()
(init props, slots, setup)
↓
renderComponentRoot()
(执行 render,生成 subTree)
↓
patch(subTree)
↓
挂载到 DOM (mounted)
│
│← 响应式更新 → 重新 render → patch (复用 instance)
│
↓
unmountComponent()
(beforeUnmount → unmounted)
↓
实例销毁(GC)
异步组件和函数式组件
在了解了组件的基本实现后,接着将组件的两个概念,异步组件和函数组件。 异步组件是指用异步的方式加载并渲染一个组件,如懒加载、代码切割、服务端下发组件等场景; 函数组件指允许使用一个普通函数定义组件,并使用该函数的返回值作为组件要渲染的内容。函数式组件的特点是无状态、编写简单且直观。(Vue2中函数式组件有明显性能优势,Vue3中差距不大)
异步组件
异步组件指在需要的时候才动态加载的组件,为了减少初始包体积(拆分独立的chunk、按需加载)、提升性能(避免一次性加载所有组件)、优化用户体验(配合Loading/Error管理加载状态反馈)。 在Vue3中,通过defineAsyncComponent创建异步组件,可以配置加载、错误、延迟等选项
js
const AsyncComp = defineAsyncComponent({
loader: () => import('./components/MyComponent.vue'),
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent,
delay: 200,
timeout: 3000
})
函数组件
函数组件是一种无状态、无实例的轻量组件,只接收props和context,返回虚拟DOM。在Vue2中函数组件通过functional:true定义,但Vue3中函数组件的概念被简化。在Vue3中就是一个纯函数,使用h函数或者JSX直接返回VNode
js
import { h } from 'vue'
const FunctionalComp = (props, { slots }) => {
return h('div', props.class, slots.default?.())
}
Vue2中是为了性能优化、减少内存占用(无实例)、简化逻辑(纯展示)而创造的。但在Vue3中性能差距缩小,通过Proxy响应式系统和渲染优化,使得普通函数性能接近函数组件。官方建议除非有明显的性能瓶颈否则不推荐使用函数组件。
内建组件和模块
接下来将讨论Vue中几个非常重要的内建组件和模块,如KeepAlive组件、Teleport组件、Transition组件。
KeepAlive
用于缓存动态组件的实例,避免在切换时反复销毁和重建。即缓存管理+特殊挂载/卸载逻辑。
KeepAlive的实现需要渲染器层面的支持,需要实现"假卸载"(被KeepAlive的组件从原容器搬运到另一个隐藏的容器中,实现"假卸载")以及再次"挂载"的时候也不能执行真正的挂载逻辑(隐藏容器中的组件被"挂载"时,再从隐藏容器中搬运到原容器)。对应到组件的生命周期就是activated和deactivated
最基本的KeepAlive实现
js
const keepAlive = {
// 独有属性,用于标识
__isKeepAlive: true,
setup(props, {slots}) {
const cache = new Map()
const instance = currentInstance
const { move, createElement } = instance.keepAliveCtx
// 创建隐藏容器
const storageContainer = createElment('div')
// 添加两个内部函数
instance._deAvtivate = (vnode) => {
move(vnode, storageContainer)
}
instance._activate = (vnode) => {
move(vnode, container, anchor)
}
return () => {
// 被KeepAlive包裹的组件
let rawVNode = slots.default()
if (typeof rawVNode.type !== 'object') {
return rawVNode // 非组件的虚拟节点无法被 KeepAlive
}
const cacheVNode = cache.get(rawVnode.type)
if (cacheVNode) {
// 有缓存,执行激活
rawVNode.component = cacheVNode.component
rawVNode.keptAlive = true
} else {
cache.set(rawNode.type, rawVNode)
}
rawVNode.shouldKeepAlive = true
rawVNode.keepAliveIntance = instance
return rawVNode
}
}
}
shouldKeepAlive:该属性会被添加到被KeepAlive的组件的vnode对象上,当渲染器"卸载组件"时,通过检查该属性,就不会真的卸载,而是调用_deActivate函数完成搬运工作; keepAliveInstance:被KeepAlive的组件会持有KeepAlive 组件实例 keptAlive:标识组件已经被缓存,这样重新渲染时就不会真的重新挂载
缓存管理策略
KeepAlive采用LRU(最近最少使用)缓存策略:
- 使用
CacheMap存储组件VNode - 使用
KeysSet实现LRU缓存策略 - 当缓存数量超过
max指定值时,将最久未访问的缓存实例销毁
KeepAlive缓存的是真实DOM吗?
<KeepAlive> 缓存的是组件实例(Component Instance)和对应的 VNode,而真实 DOM 节点在组件"失活"时并没有被销毁,而是被移动到一个隐藏的容器(storageContainer)中暂存,以便"激活"时快速恢复。
Teleport
将子元素渲染到当前 DOM 树之外的指定目标容器 中。Teleport 不创建额外的 DOM 包裹元素 ,它只是"传送"子节点,自身在 DOM 中不可见。 Teleport组件通过process方法处理内容的渲染位置,实现内容渲染到指定容器
Teleport组件被编译的虚拟DOM
js
{
type: Teleport, // 表示这是一个 Teleport 组件
props: {
to: '#modal-container', // 目标挂载点选择器
disabled: false // 是否禁用传送
},
children: [ /* 子 VNode 数组 */ ],
shapeFlag: 128 // 表示这是一个 Teleport 类型的 VNode
}
最基本的Teleport实现
js
const Teleport = {
__isTelePort: true,
process(n1, n2, container, anchor, internals) {
const { patch, patchChildren, move } = internals
// 挂载
if (!n1) {
const target = typeof n2.props.to === 'string' ? document.querySelector(n2.props.to) : n2.props.to
n2.children.forEach(c => patch(null, c, target, anchhor))
} else {
// 更新
patchChildren(n1, n2, container)
// 如果新旧to参数不同,则需要进行移动
if (n2.props.to !== n1.props.to) {
const newTarget = typeof n2.props.to === 'string' ? document.querySelector(n2.props.to) : n2.props.to
n2.children.forEach(c => move(c, newTarget))
}
}
}
}
Transition
为单个元素或组件 的插入/更新/移除过程提供过渡动画支持,自动应用 CSS 类名或 JavaScript 钩子。核心原理为当DOM元素被挂载是,将动效附加到该DOM元素;当DOM元素被卸载时,不要立即卸载DOM元素,而是等到附加到该DOM元素上的动效执行完成后再卸载它。
Transition组件通过CSS类名和JavaScript钩子控制动画过程:
- 通过CSS类名定义动画状态
- 通过JavaScript钩子控制动画逻辑
- 支持多种动画模式(in-out, out-in)
Vue的Transition组件在动画过程中会自动应用以下CSS类:
进入阶段类名:
v-enter-from: 进入开始时的状态v-enter-active: 进入过程的活动状态v-enter-to: 进入结束时的状态
离开阶段类名:
v-leave-from: 离开开始时的状态v-leave-active: 离开过程的活动状态v-leave-to: 离开结束时的状态
最基本的Transition实现
js
const Transition = {
name: 'Transition',
setup(props, {slots}) {
return () => {
const innerVNode = slots.default()
innerVNode.transition = {
beforeEnter(el) {
el.classList.add('enter-from')
el.classList.add('enter-active')
},
enter(el) {
// 在下一帧切换到结束状态
nextFrame(() => {
el.classList.remove('enter-from')
el.classList.add('enter-to')
// 监听 transitionend 事件完成收尾工作
el.addEventListener('transitionend', () => {
el.classList.remove('enter-to)
el.classList.remove('enter-active')
})
})
},
leave(el, performRemove) {
el.classList.add('leave-from')
el.classList.add('leave-active')
// 强制reflow,使得最初状态生效
document.body.offsetHeight
// 在下一帧切换状态
nextFrame(() => {
el.classList.remove('leave-from')
el.classList.add('leave-to')
// 监听 transitionend 事件完成收尾工作
el.addEventListener('transitionend', () => {
el.classList.remove('leave-to)
el.classList.remove('leave-active')
performRemove()
})
})
}
}
}
}
}
结语
以上内容都是比较底层的学习了,除此之外你可能还需要掌握组件应用,如组件通信,组件设计原则等知识。接下来会准备了解编译器,Vue.js 的模板和 JSX 都属于领域特定语言,它们的实现难度属于中、低级别。