vue的组件化

2024-06-28
vue

Before

在上一篇渲染器中,我们了解了虚拟DOM,以及渲染器如何将虚拟DOM渲染为真实DOM。 下面这段代码就是用虚拟DOM的结构来表述一个真实DOM。

Copy
const title = {
    type: 'h1',
    props: {
         onClick: 'handler'   
    },
    children: 'content'
}
全屏

当我们编写复杂页面时,描述页面结构的虚拟DOM的代码量会很多,也就是编写的模板会很大,这时候就需要有组件化的能力,将一个页面拆分为多个组件。

组件

那么什么是组件呢?组件是对一组DOM元素的封装,对于渲染器来说,它就是一个特殊类型的虚拟节点。

Copy
const MyComponent = {
    name: "MyComponent",
    data() {
        return { foo: 1 }
    }
}
const vnode = {
    type: MyComponent
}
全屏

在渲染器中,我们判断如果type值的类型为Object,就调用组件的挂载和更新方法。

Copy
function patch(n1, n2, container) {
    if (n1 && n1.type !== n2.type) {
        unmount(n1)
        n1 = null
    }
    const { type } = n2
    if (typeof type === 'string') {
        // 普通元素
    } else if (typeof type === 'object') {
        // 组件
        if (!n1) {
            mountComponent(n2, container)
        } else {
            patchComponent(n1, n2)
        }
    } else {
        // 其他类型  
    }
}
全屏

这样渲染器就能够处理组件了。那么组件应该具备哪些能力来供用户使用呢。
首先它是用来描述页面内容的,所以必须得有一个渲染函数即render函数来返回虚拟DOM。
其次组件需要拥有自身状态,且是支持响应式的,当数据更新后,组件你能够进行自更新。
组件还需要拥有自身的生命周期,包括挂载前后,更新前后以及卸载前后等等,提供这些节点的钩子供用户使用。
组件还需要支持参数传入,传入的参数也需要支持响应式,当参数更新后,组件能够进行自更新。
除了参数,组件还要支持自定义事件。
当然还有我们常用到的插槽slot。

接下来我们就逐一来看这些功能是如何实现。

render

组件中包含渲染函数render,它执行的返回值是虚拟DOM。

Copy
const MyComponent = {
    name: 'MyComponent',
    render() {
        return {
            type: 'div',
            children: 'my component'
        }  
    }
}
const CompVNode = {
    type: MyComponent
}
全屏

我们通过执行render函数拿到虚拟DOM,在通过渲染器中的patch函数就能挂载组件了。

Copy
function mountComponent(vnode, container) {
    const componentOptions = vnode.type
    const { render } = componentOptions
    const subTree = render()
    patch(null, subTree, container)
}
全屏

data

规定用户使用data函数来定义组件自身的状态,在渲染函数中支持使用this来访问data函数返回的状态数据。

Copy
const MyComponent = {
    name: 'MyComponent',
    data() {
        return { 
            foo : 1 
        }
    },
    render() {
        return {
            type: 'div',
            children: `foo的值为${this.foo}`
        }
    }
}
const CompVNode = {
    type: MyComponent
}
全屏

要实现这个能力我们需要使用reactive函数将data函数返回的对象包装为响应式数据state。 然后再调用render函数时,将其this的指向设置为响应式数据state,并将state作为render的第一个参数传进去。
当然我们还需要考虑到多次修改响应式数据,应当都只重新渲染一次,这就需要用到我们之前讲过的调度器,创建一个用于缓存的为任务队列,将任务进行去重后再执行。

具体代码可以看component.js

基于当前的实现,需要更新时我们只能卸载旧的组件,挂载新的组件,正确的做法应当是patch,拿新的subTree跟上一次渲染的subTree进行比较更新。 所以我们需要实现组件实例,用它来维护组件整个生命周期的状态,帮助渲染器在正确的时机执行相应的操作。

组件实例

实例的本质就是一个JavaScript对象,在这个对象中存储我们需要的相关信息,包括组件自身的状态数据、是否挂载以及渲染函数执行返回的subTree。 当然我们也可以在实例上添加任何我们想要的属性,但是原则是尽可能地轻量。

Copy
function mountComponent(vnode, container) {
    ...
    const instance = {
        state,
        isMounted: false,
        subTree: null
    }
    effect(() => {
        const subTree = render.call(state, state)
        if (!instance.isMounted) {
            patch(null, subTree, container)
            instance.isMounted = true
        } else {
            patch(instance.subTree, subTree. container)
        }
        instance.subTree = subTree
    }, {
        scheduler: queueJob
    })
    
}
全屏

未挂载是执行挂载操作,然后将挂载标志置为true,已挂载时就执行更新操作,拿到实例中存储的上一次渲染的subTree进行比较更新。

具体代码可以看instance.js

我们使用isMounted标志位区分出了挂载和更新,那么就可以在正确的时机调用组件的生命周期钩子。
在挂载语句前后分别执行beforeMount和mounted钩子。在更新语句前后执行beforeUpdate和updated钩子。

props

传入组件的参数在虚拟DOM层面与普通标签的属性差别不大,都是存在props对象中。

Copy
<MyComponent title="title" :other="val"/>
全屏
Copy
const vnode = {
    type: MyComponent,
    props: {
        title: 'title',
        other: this.val,
    }
}
全屏

在编写组件是,我们需要显示地指定组件会接收哪些参数。

Copy
const MyComponent = {
    name: 'MyComponent',
    props: {
        title: String
    },
    render() {
        return {
            type: 'div',
            children: `the title is ${this.title}`
        }
    }
}
全屏

所以处理组件的参数时,我们需要考虑两部分内容,首先是组件的vnode.props对象,其次是组件选项对象中定义的props选项,也就是MyComponent.props对象。
如果vnode.props对象中的key在MyComponent.props对象中有定义,那么就是合法的props存储在props对象中,否则就视为普通HTNK标签的属性存储在attrs对象中。

Copy
function resolveProps(options, propsData) {
    const props = {}
    const attrs = {}
    for (const key in propsData) {
        if (key in options) {
            props[key] = propsData[key]
        } else {
            attrs[key] = propsData[key]
        }
    }
    return [props, attrs]
}
全屏

得到了处理好的props,但是现在渲染函数的作用域绑定的还是state,无法得到props中的数据,所以我们需要定义一个渲染上下文对象。 当获取状态时,首先从state中取,没有再从props中取。赋值时也同样的先给state赋值,如果state中没有对应key,再往props上赋值。

Copy
const renderContent = new Proxy(instance, {
    get(target, key, receiver) {
        const {state, props} = target
        if (state && key in state) {
            return state[key]
        } else if (key in props) {
            return props[key]
        } else {
            console.error('不存在')
        }
    }, 
    set(target, key, newValue, receiver) {
        ...
    }
})
全屏

得到了渲染上下文对象,我们就可以在渲染函数以及生命周期函数调用时绑定渲染上下文对象。

得到处理好的props还有渲染上下文对象,我们来讨论当props数据变化的问题。
props本质上是父组件的数据,当其发生变化会触发父组件重新渲染,而父组件下包含组件类型的虚拟节点,所以会调用patchComponent触发子组件的额更新。 这种父组件子更新引起的子组件的更新称之为子组件的被动更新。我们需要检查子组件是否需要更新,因为子组件的props可能是不变的。
当props确实改变了,我们就需要用新的父组件的props以及子组件自身定义的props获得处理好的props,然后逐一更新到instance对象的props属性上去。 如果新的props中的key在旧的props中不存在,还需要进行delete。

具体代码可以看props.js

setup

setup是vue23新增的组件选项,它用于配合组合式API,提供给用户一个地方来创建响应式数据、创建通用函数、注册生命周期钩子等等。 在组件的整个生命周期中,setup函数只在被挂载时执行一次,它的返回值有两种情况:

  1. 返回一个函数,这个函数将作为组件的render函数
  2. 返回一个对象,该对象中的数据将暴露给模板使用,在渲染函数中可以使用this来访问

setup函数接收两个参数,第一个是外部为组件传递的props参数对象。 第二个通常成为setupContext对象,其中保存了与组件接口相关的数据和方法,包括slots、emit、attrs、expose。
知道了setup提供的能力,实现起来也非常简单。 我们将instance用shallowReadonly作为第一个参数,包装slots、emit、attrs、expose等组装起来作为setupContext,调用setup函数,得到结果存入setupResult。
如果setupResult是个函数,如果此时render有值就报错,然后把其赋值给render。
如果setupResult是个对象,我们就需要把它也暴露到渲染环境中,所以得进一步处理渲染上下文,当取值时,如果props中也没有对应key,就从setupResult中获取。 赋值时,如果props中也没有对应key,就给setupResult赋值。

具体代码可以看setup.js

emit

上面代码中的setupContext,目前我们只实现了attrs,接下来我们来实现emit。
emit用来发射组件的自定义事件。

Copy
const MyComponent = {
    name: 'MyComponent',
    setup(props, { emit }) {
        emit('change', 1, 2)
        return () => {}
    },
}
全屏

在虚拟DOM中,事件同样存储在props对象中,我们定义所有on开头的属性为事件,

Copy
const CompVNode = {
    type: MyComponent,
    props: {
        onChange: handler
    }
}
全屏

对应的在resoleProps中也需要对所有on开头的属性进行处理,无论其是否在组件的props定义中,都将其放入props对象。 如此我们就可以定义emit函数,并把它放到setupContext对象中。

Copy
function emit(event, ...payload) {
    const eventName = `on${event[0].toUpperCase()}${event.slice(1)}`
    const handler = instance.props[eventName]
    if (handler) {
        handler(...payload)
    } else {
        console.error('事件不存在')
    }
}
const setupContext = { attrs, emit }
...
全屏

slots

插槽,顾名思义是组件预留一个槽位,槽位中具体要渲染的内容由用户插入。

Copy
const CompVNode = {
    type: MyComponent,
    children: {
        header() {
            return {
                type: 'h1',
                children: 'I am header'
            }
        }
    }
}
全屏

子组件的模板如下代码:

Copy
<template>
    <div>
        <slot name="header" />
    </div>
</template>
全屏

它编译后的渲染函数如下:

Copy
const MyComponent = {
    name: 'MyComponent',
    render() {
        return [
            {
                type: 'div',
                children: [this.slots.header()]
            }
        ]
    }
}
全屏

可以看出,渲染插槽内容的过程,就是调用插槽函数并渲染其返回的内容,了解了这些我们就可以对插槽进行实现。 从虚拟DOM中获取到children,即为slots,将其放入组件实例以及setupContext中。然后进一步优化渲染上下文对象, 当key为slots时,就返回slots,这样用户就可以通过this.slots来访问插槽内容了。

完整代码可以看setup.js

注册生命周期

最后来讲一下再setup中注册生命周期钩子函数是如何实现的。

Copy
const MyComponent = {
    name: 'MyComponent',
    setup() {
      onMounted(() => {
          console.log('注册1')
      })   
      onMounted(() => {
          console.log('注册2')
      })  
    },
}
全屏

这里要注意两个问题: 第一个是mounted生命周期钩子函数可以注册多个。 第二个则是A组件调用onMounted注册的生命周期钩子函数需要注册到A组件上,而B组件调用onMounted注册的生命周期钩子函数需要注册到B组件上。
所以我们需要先创建一个变量currentInstance来存储当前的实例对象,每当初始化组件执行setup函数之前,将currentInstance的值置为当前组件的实例值。 这样执行setup函数时,就能获取到当前的组件实例,从而将注册的钩子函数与当前组件实例进行关联。
执行onMounted时,将传入的函数存储进currentInstance的mounted数组中,当执行完挂载后,从实例的mounted属性值中取出来逐一执行即可。

完整代码可以看lifeCycle.js

异步组件

理论上,一步组件的实现不需要框架层面的支持,用户完全可以自行实现。

Copy
import App from 'App.vue'
createApp(App).mount('#app')
全屏

改为异步渲染:

Copy
const loader = () => import('App.vue')
loader().then(App => createApp(App).mount('#app'))
全屏

使用动态导入语句import()即可实现异步方式渲染页面。

那如果只想异步渲染部分页面该怎么实现呢,我们只需要有能力异步渲染某个组件即可。

Copy
<template>
    <CompA />
    <component :is="asyncComp" />
</template>
<script setup>
import { shallowRef } from 'vue'
import CompA from 'CompA.vue'
export default {
    components: { CompA },
    setup() {
        const asyncComp = shallowRef(null)
        import('CompB.vue').then(CompB => asyncComp.value = CompB)
        return {
           asyncComp 
        }
    }
}
</script>
全屏

利用动态组件component绑定asyncComp变量,通过import()动态导入来异步加载CompB组件,组件加载成功后将asyncComp的值设置为CompB,就实现了组件的渲染。
但是要实现完善的组件异步加载和渲染还需要以下能力:

  1. 允许用户指定加载出错时渲染的组件
  2. 允许用户指定Loading组件,以及展示该组件的延迟时间
  3. 允许用户设置异步组件的加载超时时长
  4. 组件加载失败时,为用户提供重试的能力

vue中使用defineAsyncComponent来实现异步加载组件,下面我们就来介绍它是如何实现上述功能。

defineAsyncComponent

defineAsyncComponent是一个高阶组件,它接收一个一步组件加载器作为参数,返回一个包装组件。

Copy
<template>
  <AsyncComp />
</template>
<script>
const AsyncComp = defineAsyncComponent(() => import('MyComponent.vue'))
</script>
全屏

初步实现一下defineAsyncComponent函数:

Copy
function defineAsyncComponent(loader) {
    let innerCompo = null
    return {
        name: 'AsyncComponentWrapper',
        setup() {
            const loaded = ref(false)
            loader().then(c => {
                loaded.value = true
                innerCompo = c
            })
            return () => {
                return loaded.value ? { type: innerCompo } : { type: 'div', children: ''} 
            }
        }
    }
} 
全屏

加载完成则返回组件,否则返回一个占位内容。

超时处理

用户可以指定加载的超时时长timeout,当超过该时长,则展示指定的内容errorComponent, 能捕捉超时以及其它的错误,同时将错误信息作为参数传入errorComponent组件:

Copy
const AsyncComp = defineAsyncComponent({
    loader: () => import('MyComponent.vue'),
    errorComponent: ErrorComponent,
    timeout: 3000,
})
全屏

实现起来并不复杂,我们需要创建变量error来记录错误信息,给加载器loader添加catch语句,记录加载错误信息。 如果用户传入timeout,则使用setTimeout创建定时器,当超时后,将超时错误赋值给error。 正确加载则返回加载出的组件,有错误信息且用户传入errorComponent,则返回用户传入的errorComponent,并将error的值放入props,否则展示默认站位内容。 加载完成后清除定时器。

完整代码可以看defineAsyncComponent.js

loading

异步组件的加载受网络波动的影响比较大,加载过程可能很快,也可能很慢,当加载很慢,我们就需要展示Loading组件来提供更好的用户体验。 但如果从加载开始展示Loading组件,但加载速度很快,就会出现闪烁,所以我们需要为Loading组件设置一个延时,当超过一定时间没有加载完成,再展示Loading组件。

Copy
const AsyncComp = defineAsyncComponent({
    loader: () => import('MyComponent.vue'),
    loadingComponent: LoadingComponent,
    delay: 200,
})
全屏

实现方案与超时处理相似,增加一个loading标志来记录是否展示Loading组件, 如果传了delay,则创建定时器,一定时长后将loading标志置为true,没有传delay则直接置为true。 当加载完成后将loading标志置为false,并清除定时器。 在return中判断,若还未加载成功,没有错误信息,loading标志为true且用户传入了loadingComponent,则返回loadingComponent组件。

完整代码可以看defineAsyncComponent.js

重试

加载异步组件过程中,发生错误是非常常见的,所以提供重试机制非常重要。
defineAsyncComponent支持你传入onError参数自定义捕获到错误之后的操作。

Copy
const AsyncComp = defineAsyncComponent({
    loader: () => import('MyComponent.vue'),
    onError: onError,
})
全屏

实现方案就是新建一个load方法,执行loader并进行catch,在其中判断,如果用户没有传onError参数,就抛出错误,否则就返回一个新的Promise实例。 在这个promise实例中调用onError方法,传入的参数有三个:retry、fail和retries。
retries是重试次数,初始值为0。
retry是一个函数,在其中继续调用load方法使用promise的resolve包裹,然后对retries进行自增。 fail也是一个函数,在其中调用promise的reject方法。
然后在返回的组件setup选项中使用load函数进行加载。
这样一来用户可以根据重试次数来决定是否继续重试,当执行retry时会调用load再次加载并记录一次重试,如果重试成功了,就会进入load的then方法中。 如果超出一定次数执行fail,就会调用reject方法,进入load的catch方法中。

完整代码可以看defineAsyncComponent.js

函数式组件

函数式组件本质上就是一个普通函数,它执行后的返回值是虚拟DOM。它没有自身的状态,但是可以接收外部传进来的props。
在有状态组件的基础上,兼容函数式组件就非常的简单了,只需要在patch和mountComponent函数中增加对function类型的支持就可以了。
当然函数式组件不需要初始化data以及生命周期,我们需要在这些操作上新加判断。
可以看出函数式组件在初始化性能的消耗上会小于有状态的组件。

完整代码可以看function.js

Reference

1. 异步组件
2. Loading 用户体验 - 加载时避免闪烁

<<上一篇vue的渲染器
vue的编译器下一篇>>