前言
我们在上一篇中了解到在 Vue Vapor 中因为需要兼容原来 Vue3 的 API,所以还是必须存在组件这个概念。
如何渲染组件
我们知道在普通 Vue3 项目中的组件本质就是一个对象,如下面代码所示:
javascript
const MyComponent = {
setup() {
return { name: '掘金签约作者' }
},
render() {
}
}
跟 Vue Vapor 不一样的是普通 Vue3 项目是通过虚拟DOM 来渲染的,那么从虚拟DOM 的角度来看,一个组件则是一个特殊类型的虚拟DOM 节点,其 render 函数返回的就是该组件的虚拟DOM,又因为组件本身就是对页面内容的封装,而 render 函数返回的虚拟DOM则是具体描述所封装的页面内容。如下面代码所示:
javascript
const App = {
setup() {
return { name: '掘金签约作者' }
},
render() {
// 非 Vue Vapor 组件的 render 函数返回的是虚拟DOM
return {
type: 'div',
children: 'cobyte'
}
}
}
Vue Vapor 中组件的 render 函数返回的则是真实DOM,然后再通过运行时中的 render 函数挂载到具体的父节点上。而组件上的 render 函数则更像是该组件的页面内容的原生 JavaScript 操作 DOM 进行渲染页面的具体过程。如下面代码所示:
javascript
const App = {
setup() {
return { name: '掘金签约作者' }
},
render(ctx) {
// 生成创建 button 标签的函数
const _tmpl$ = template('<button></button>')
// 真正进行创建模板内容的地方
const el = _tmpl$()
// 使用 JavaScript 原生操作 DOM API 操作页面
el.textContent = _ctx.name
return el
}
}
通过上面的对比,我们可以看到 Vue Vapor 组件的 render 函数是使用原生 JavaScript 操作该组件页面的真实 DOM 的具体过程,而普通 Vue3 组件的 render 函数则是生成该组件页面抽象描述的一个对象结构,俗称:虚拟DOM。
根据前面我们所学的知识知道在 Vue Vapor 中可以通过 Vapor 运行时中的 render 方法进行渲染 Vue Vapor 的组件。代码示例如下:
javascript
const root = document.getElementById('app')
render(App, root)
而关于 Vue Vapor 的 render 方法我们在前面已经介绍过了,这里就不再作深入介绍了,最后会通过 mountComponent 方法实现对组件的渲染,这个跟普通 Vue3 中是一致的,也是为了保持跟普通 Vue3 一致的 API,所以在 Vue Vapor 中也存在这个步骤。
其实上述讲的是 Vue Vapor 根组件的渲染原理,子组件的渲染则有些不同了,在普通 Vue3 的组件渲染中,根组件和子组件的渲染都是一样的,因为不管根组件和是子组件都是一个特别的虚拟DOM 节点,在渲染组件的时候,会通过该组件的 render 方法生成组件的虚拟DOM,再通过渲染器去进行渲染即可。而在 Vue Vapor 的组件中的 render 方法返回的则是该组件的真实 DOM,同时 render 方法是一组使用原生 JavaScript 操作该组件页面真实 DOM 的代码集合。所以从原生 JavaScript 操作真实DOM 的角度来说,渲染一个子组件就是通过子组件的 render 方法生成该组件的真实DOM,再把该组件的真实DOM添加到父级元素节点上。
我们现在有如下一个子组件:
javascript
const ChildComponent = {
setup() {
const name = ref('Cobyte')
return { name }
},
render(_ctx) {
// 生成创建 span 标签
const _tmpl$ = template('<span></span>')
// 真正进行创建模板内容的地方
const el = _tmpl$()
// 使用 JavaScript 原生操作 DOM API 操作页面
el.textContent = _ctx.name.value
return el
}
}
那么根据我们上述的理论我们可以在父组件的 render 方法中挂载子组件,代码实现如下:
javascript
const App = {
setup() {
},
render() {
// 生成创建 button 标签
const _tmpl$ = template('<button></button>')
// 真正进行创建模板内容的地方
const el = _tmpl$()
// 通过一个 createComponent 的方法创建子组件
const n1 = createComponent(ChildComponent)
// 再通过 insert 方法挂载子组件的真实DOM 到父级元素节点上
insert(n1, el)
return el
}
}
根据上面的代码我们可以知道可以通过一个叫 createComponent 的方法创建子组件的真实DOM,再通过 insert 方法将子组件的真实DOM 挂载到对应的父级元素节点上。其中 insert 方法就是我们上一篇中实现过的,而 createComponent 方法则需要我们在这一篇中进行实现。
我们在 runtime-vapor/src 目录下创建一个 apiCreateComponent.js 文件,然后 createComponent 函数的具体实现如下:
javascript
import { createComponentInstance } from './component'
export function createComponent(
comp,
rawProps
) {
// 创建组件实例
const instance = createComponentInstance(
comp,
rawProps
)
const { component } = instance
// 判断是状态组件还是函数组件
const setupFn = typeof component === 'function' ? component : component.setup
// 获取 setup 方法的执行结果
const state = setupFn && setupFn()
// 执行 render 函数获取 DOM 结果,并赋值给组件实例的 instance.block 属性
instance.block = component.setup ? component.render(state) : state
return instance
}
我们可以看到子组件的渲染过程跟我们上一篇文章中所讲的组件渲染流程是一样,先判断组件是状态组件还是函数组件,然后执行 setup 方法获取到状态 state,如果是状态组件则把 state 作为组件 render 方法的参数,这样就可以在渲染函数内访问组件自身状态了,然后执行组件的 render 的方法获取 DOM 结果,最后赋值给组件实例的 instance.block 属性。
最后我们需要修改一下 insert 方法:
diff
export function insert(block, parent, anchor = null) {
+ // 如果存在 block 则说明是组件
+ block = block.block ? block.block : block
parent.insertBefore(block, anchor)
}
这样我们就实现了子组件的渲染流程了。
重构迭代
我们上面 createComponent 方法中有一部代码和 render 中的代码功能是重复的,所以我们可以将这一部分代码进行提取进行重构,让我们的代码更简洁,逻辑更清晰。
我们将这一部分代码重构成一个叫 setupComponent 的方法并把它设置在 runtime-vapor\src\render.js 文件中,代码如下:
javascript
export function setupComponent(instance) {
const { component } = instance
// 判断是状态组件还是函数组件
const setupFn = typeof component === 'function' ? component : component.setup
// 获取 setup 方法的执行结果
const state = setupFn && setupFn()
// 执行 render 函数获取 DOM 结果,并赋值给组件实例的 instance.block 属性
instance.block = component.setup ? component.render(state) : state
}
createComponent 方法重构修改如下:
diff
import { createComponentInstance } from './component'
+import { setupComponent } from './render'
export function createComponent(
comp,
rawProps
) {
// 创建组件实例
const instance = createComponentInstance(
comp,
rawProps
)
+ setupComponent(instance)
- const { component } = instance
- // 判断是状态组件还是函数组件
- const setupFn = typeof component === 'function' ? component : component.setup
- // 获取 setup 方法的执行结果
- const state = setupFn && setupFn()
- // 执行 render 函数获取 DOM 结果,并赋值给组件实例的 instance.block 属性
- instance.block = component.setup ? component.render(state) : state
return instance
}
mountComponent 方法重构如下:
diff
function mountComponent(
instance,
container
) {
instance.container = container
+ setupComponent(instance)
- const { component } = instance
- // 判断是状态组件还是函数组件
- const setupFn =
- typeof component === 'function' ? component : component.setup
- // 获取 setup 方法的执行结果
- const state = setupFn && setupFn()
- // 执行 render 函数获取 DOM 结果
- const block = instance.block = component.setup ? component.render(state) : state
+ const block = instance.block
// 挂载组件DOM元素到到父级元素上
insert(block, instance.container)
// 设置已经挂载的标记
instance.isMounted = true
}
响应式变量自动脱 ref
我们在上述例子中如果是一个使用 ref 创建的响应式变量在 render 函数中使用的话,是需要通过 .value 来读取的,而实际上我们平时在 Vue3 模板中读取 ref 创建的响应式变量时是不需要通过 .vulue 来读取的。因此我们需要自动脱 ref 的能力,也就是如果我们访问一个属性的值时一个 ref 创建的响应式对象的话,就自动返回 .value 的内容。其实在 Vue3 中就提供了 proxyRefs 这个函数来实现这个功能。
diff
+ import { proxyRefs } from '@vue/reactivity'
function setupComponent(instance) {
// 省略 ...
// 执行 render 函数获取 DOM 结果
- const block = instance.block = component.setup ? component.render(state) : state
+ const block = instance.block = component.setup ? component.render(proxyRefs(state)) : state
// 省略 ...
}
这样我们就可以在组件的 render 函数中读取 ref 创建的响应式变量时不需要通过 .vulue 了。
diff
const ChildComponent = {
setup() {
const name = ref('Cobyte')
return { name }
},
render(_ctx) {
// 省略 ..
- el.textContent = _ctx.name.value
+ el.textContent = _ctx.name
return el
}
}
对应的在模板中也是这个原理,因为模板最终也会被编译成一个 render 函数。这就是为什么我们可以在模板中直接访问一个 ref 的值,而无须通过 .value 属性来访问。
而 proxyRefs 的实现原理也很简单,它是通过 Proxy 实现代理,当访问的属性是一个 ref 创建的变量时候就返回 .value 的内容,否则就直接返回。
javascript
function proxyRefs(objectWithRefs) {
return new Proxy(objectWithRefs, {
get(target, key, receiver) {
// 读取属性的值
const value = Reflect.get(target, key, receiver)
// 通过 ref 创建的响应式变量会存在一个 __v_isRef 的属性,可以通过 是否存在 __v_isRef 判断是否 ref 创建的响应式变量
return value.__v_isRef ? value.value : value
}
})
}
同样地读取可以自动脱 ref,那么设置的时候也可以脱 ref,原理也很简单,我们上面已经通过 Proxy 进行了代理,只需要在 set 中进行判断即可,如果是设置的属性是一个 ref 创建的变量时就通过 .value 设置,否则就直接设置。
javascript
function proxyRefs(objectWithRefs) {
return new Proxy(objectWithRefs, {
get(target, key, receiver) {
// 读取属性的值
const value = Reflect.get(target, key, receiver)
// 通过 ref 创建的响应式变量会存在一个 __v_isRef 的属性,可以通过 是否存在 __v_isRef 判断是否 ref 创建的响应式变量
return value.__v_isRef ? value.value : value
},
set(target, key, val) {
// 读取属性的值
const oldValue = target[key]
// 判断是否是 ref 创建的响应式变量,同时判断新设置的值不是一个 ref,因为如果设置的旧属性值是一个 ref 的话,新设置的值也是一个 ref 的话,它们的数据结构是相同的那么直接替换即可,不需要设置到 .value 上了。
if(oldValue.__v_isRef && !val.__v_isRef){
oldValue.value = val
// 一定要显式地返回 true,不然会报错
return true
} else {
return Reflect.set(target, key, val)
}
}
})
}
我们在设置的时候需要判断是否是 ref 创建的响应式变量,同时判断新设置的值不是一个 ref,因为如果设置的旧属性值是一个 ref 的话,新设置的值也是一个 ref 的话,它们的数据结构是相同的那么直接替换即可,不需要设置到 .value 上了。
至此我们在这一小节中实现了在组件的 render 函数中自动脱 ref 的功能,也就是在组件的 render 函数中访问 ref 创建的响应式变量时不需要通过 .value 来实现,同时我们还深入了解了实现这个功能的 proxyRefs 函数的实现原理。
总结
我们在这一篇中实现了 Vue Vapor 的组件渲染,普通 Vue3 组件的渲染是通过运行组件的 render 函数生成组件的虚拟DOM,然后再通过渲染器把组件的虚拟DOM 渲染到父级节点元素上,而 Vue Vapor 组件也是通过运行组件的 render 函数进行渲染的,但 Vue Vapor 组件的 render 函数返回的不是虚拟DOM,而是真实DOM,然后把组件的真实DOM 挂载到父级节点元素上。这就是 Vue Vapor 组件和 Vue 虚拟DOM 版本的组件的区别之一。