实现一个简单的MVVM-3
这部分记录一下组件的实现原理。
组件的实现原理
我们可以将一个大的页面拆分为多个部分,每一个部分都可以作为单独的组件,这些组件共同组成完整的页面。组件化的实现同样需要渲染器的支持。
一个有状态组件就是一个选项对象:
1 2 3 4 5 6 7 8 const component = { name : "componenet" , data ( ) { return { foo : 1 } } }
如果从渲染器的内部实现来看,一个组件则是一个特殊类型的虚拟 DOM
节点。
1 2 3 4 const vnode = { type : component ... }
我们之前在patch
函数中留下过一个接口,现在我们来完善它:
1 2 3 4 5 6 7 8 9 10 11 ...else if (typeof type === object ) { if (!oldNode) { mountComponent (newNode, container, anchor) } else { patchComponent (oldNode, newNode, anchor) } } ...
渲染器有能力处理组件后,下一步我们要做的是,设计组件在用户层面的接口。组件本身是对页面内容的封装,它用来描述页面内容的一部分,一个组件必须包含一个渲染函数,即render
函数,并且渲染函数的返回值应该是虚拟
DOM。换句话说,组件的渲染函数就是用来描述组件所渲染内容的接口 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const component = { name : "componenet" , render ( ) { return { type : 'div' , children : 'text' } } }const compVNode = { type : component } renderer.render (CompVNode , document .querySelector ('#app' ))
接下来我们来实现mountComponent
函数,实现组件的初始渲染。
1 2 3 4 5 6 7 8 9 10 11 12 function mountComponent (newCompVNode, container, anchor ) { const componentOptions = newCompVNode.type as componentOptions const { render } = componentOptions const subTree = render () patch (undefined , subTree, container, anchor) }
组件状态与自更新
我们约定用户必须使用data
函数来定义组件自身的状态,同时可以在渲染函数中通过this
访问由data
函数返回的状态数据。当组件自身状态发生变化时,我们需要有能力触发组件更新,即组件的自更新。为此,我们需要将整个渲染任务包装到一个watchEffect
中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function mountComponent (newCompVNode, container, anchor ) { const componentOptions = newCompVNode.type const { render, data } = componentOptions const state = reactive (data ()) watchEffect (() => { const subTree = render.call (state) patch (undefined , subTree, container, anchor) }) }
一旦组件自身的响应式数据发生变化,组件就会自动重新执行渲染函数,从而完成更新。但是,由于watchEffect
的执行是同步的,因此当响应式数据发生变化时,与之关联的副作用函数会同步执行。换句话说,如果多次修改响应式数据的值,将会导致渲染函数执行多次,这实际上是没有必要的。
因此,我们需要设计一个机制,以使得无论对响应式数据进行多少次修改,副作用函数都只会重新执行一次。为此,我们需要实现一个调度器,当副作用函数需要重新执行时,我们不会立即执行它,而是将它缓冲到一个微任务队列中,等到执行栈清空后,再将它从微任务队列中取出并执行,我们就有机会对任务进行去重,从而避免多次执行副作用函数带来的性能开销。下面是简单实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 const queue = new Set <EffectFunction >()let isFlushing = false const p = Promise .resolve ()function queueJob (job : EffectFunction ) { queue.add (job) if (!isFlushing) { isFlushing = true p.then (() => { try { queue.forEach ((job : EffectFunction ) => job ()) } finally { isFlushing = false queue.clear () } }) } }
有了queueJob
函数之后,我们可以在创建渲染副作用时使用它:
1 2 3 4 5 6 watchEffect (() => { const subTree = render.call (state, state) patch (undefined , subTree, container, anchor) }, { scheduler : queueJob })
上面这段代码存在缺陷。可以看到,我们在watchEffect
函数内调用
patch
函数完成渲染时,第一个参数总是null
。这意味着,每次更新发生时都会进行全新的挂载,而不会打补丁,这是不正确的。正确的做法是:每次更新时,都拿新的subTree
与上一次组件所渲染的subTree
进行打补丁。为此,我们需要实现组件实例,用它来维护组件整个生命周期的状态,这样渲染器才能够在正确的时机执行合适的操作。
组件实例与组件的生命周期
组件实例本质上就是一个状态集合(或一个对象),它维护着组件运行过程中的所有信息,例如注册到组件的生命周期函数、组件渲染的子树(subTree
)、组件是否已经被挂载、组件自身的状态(data),等等。为了解决上一节中关于组件更新的问题,我们需要引入组件实例的概念:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 export interface ComponentInstance { state : any isMounted : boolean subTree : VNode | null }
然后修改mountComponent
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 function mountComponent (CompVNode : VNode , container : Container , anchor?: Node | null ) { ... const instance : ComponentInstance = { state, isMounted : false , subTree : null } newCompVNode.component = instance watchEffect (() => { const subTree = render.call (state, state) if (!instance.isMounted ) { patch (undefined , subTree, container, anchor) instance.isMounted = true } else { patch (instance.subTree as VNode , subTree, container, anchor) instance.subTree = subTree } }, { scheduler : queueJob }) }
我们可以在需要的时候,任意地在组件实例instance
上添加需要的属性。但需要注意的是,我们应该尽可能保持组件实例轻量,以减少内存占用。组件实例的instance.isMounted
属性可以用来区分组件的挂载和更新,我们可以在合适的时机调用组件对应的生命周期钩子。
props
与组件的被动更新
在虚拟 DOM 层面,组件的 props 与普通 HTML 标签的属性差别不大:
1 2 3 4 5 6 7 8 const vnode = { type : component, props : { title : "A Big Title" , other : "val" , } }
在编写组件时,我们需要显式地指定组件会接收哪些 props 数据:
1 2 3 4 5 6 7 8 9 10 11 const component = { name : "componenet" , props : { title : String }, render ( ) { type : 'div' , children : `count is: ${this .title} ` } }
对于一个组件来说,有两部分关于 props 的内容我们需要关心:
为组件传递的 props 数据,即组件的vnode.props
对象;
组件选项对象中定义的 props
选项,即MyComponent.props
对象。
我们结合这两个选项来解析需要的props
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 function mountComponent (compVNode, container, anchor ) { const componentOptions = compVNode.type as componentOptions const { render, data, props : propsOption,beforeCreate, created, beforeMount, mounted, beforeUpdate, updated } = componentOptions ... const [props, attrs] = resolveProps (propsOption, compVNode.props ) const instance : ComponentInstance = { state, props : shallowReactive (props), isMounted : false , subTree : null } ... }function resolveProps (options : any , propsData : object ) { 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 } }
这里需要注意两点:
在 Vue.js 3 中,没有定义在MyComponent.props
选项中的
props 数据将存储到attrs
对象中。
上述实现中没有包含默认值、类型校验等内容的处理。
处理完 props
数据后,我们再来讨论关于props
数据变化的问题。props
本质上是父组件的数据,当props
发生变化时,会触发父组件重新渲染。在更新过程中,渲染器发现父组件的subTree
包含组件类型的虚拟节点,所以会调用patchComponent
函数完成子组件的更新。我们把由父组件自更新所引起的子组件更新叫作子组件的被动更新。当子组件发生被动更新时,我们需要做的是:
检测子组件是否真的需要更新,因为子组件的 props 可能是不变的
如果需要更新,则更新子组件的 props、slots 等内容
实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 export function patchComponent (oldVNode, newVNode, anchor ) { const instance = ((newVNode as VNode ).component = (oldVNode as VNode ).component ) if (instance) { const { props } = instance if (hasPropsChanged (oldVNode, newVNode)) { const [ newProps ] = resolveProps ((newVNode.type as componentOptions).props , newVNode.props ) for (const k in newProps) { props[k] = newProps[k] } for (const k in props) { if (!(k in newProps)) { delete props[k] } } } } else { console .error ("instance does not exist." , oldVNode, newVNode) } }function hasPropsChanged (oldProps, newProps ) { const newKeys = Object .keys (newProps) const oldKeys = Object .keys (oldProps) if (newKeys.length !== oldKeys.length ) { return true } for (let i = 0 ; i < newKeys.length ; i++) { const key = newKeys[i] if (newProps[key] !== oldProps[key]) { return true } } return false }
需要注意的是,我们要将组件实例添加到新的组件vnode
对象上,即n2.component = n1.component
,否则下次更新时将无法取得组件实例;instance.props
对象本身是浅响应的,因此,在更新组件的props
时,只需要设置instance.props
对象下的属性值即可触发组件重新渲染。
由于props
数据与组件自身的状态数据都需要暴露到渲染函数中,并使得渲染函数能够通过this
访问它们,因此我们需要封装一个渲染上下文对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 const renderContext = 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 ('state does not exist' ) } }, set (target, key, value, receiver) { const { state, props } = target if (state && key in state) { state[key] = value return true } else if (key in props) { console .warn (`Attempting to mutate prop "${String (key)} ". Props are readonly.` ) return false } else { console .error ('state does not exist' ) return false } } })
setup
函数的作用与实现
组件的setup
函数是 Vue3 新增的组件选项,它有别于 Vue2
中存在的其他组件选项。这是因为setup
函数主要用于配合组合式API,为用户提供一个地方,用于建立组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子等能力。在组件的整个生命周期中,setup
函数只会在被挂载时执行一次,它的返回值可以有两种情况。
setup
函数接收两个参数。第一个参数是props
数据对象,第二个参数也是一个对象,通常称为setupContext
,我们可以通过setup
函数的第一个参数取得外部为组件传递的props
数据对象。同时,setup
函数还接收第二个参数setupContext
对象,其中保存着与组件接口相关的数据和方法:
slot
:插槽
emit
:一个函数,用来发射自定义事件
attrs
:当为组件传递props
时,那些没有显式地声明为
props 的属性会存储到attrs
对象中
expose
:一个函数,用来显式地对外暴露组件数据(API设计讨论中)
通常情况下,不建议将 setup 与 Vue.js 2 中其他组件选项混合使用。例如
data、watch、methods 等选项,我们称之为 “传统”组件选项。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 export function mountComponent (compVNode : VNode , container : Container , anchor?: Node | null ) { const componentOptions = compVNode.type as componentOptions let { render, data, setup, props : propsOption, beforeCreate, created, beforeMount, mounted, beforeUpdate, updated } = componentOptions ... const setupContext = { attrs } let setupState : any | null = null if (setup) { const setupResult = setup (shallowReadonly (instance.props ), setupContext) if (typeof setupResult === 'function' ) { if (render) { console .error ('render conflicts' ) render = setupResult } else { setupState = setupResult } } } compVNode.component = instance const renderContext = 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 ('state does not exist' ) } }, set (target, key, value, receiver) { const { state, props } = target if (state && key in state) { state[key] = value return true } else if (key in props) { console .warn (`Attempting to mutate prop "${String (key)} ". Props are readonly.` ) return false } else if (setupState && key in setupState) { setupState[key] = value return true } else { console .error ('state does not exist' ) return false } } }) ... }
组件事件与emit
的实现
emit
用来发射组件的自定义事件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 const component = { name : 'component' , setup (props, { emit } ) { emit ('change' , 1 , 2 ) } }const componentVNode = { type : component, props : { onChange : handler } }
在具体的实现上,发射自定义事件的本质就是根据事件名称去props
数据对象中寻找对应的事件处理函数并执行。整体实现并不复杂,只需要实现一个emit
函数并将其添加到setupContext
对象中,这样用户就可以通过setupContext
取得emit
函数了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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 (`[${eventName} ] ${eventName} does not exist` ) } }const setupContext = { attrs, emit }
这里有一点需要额外注意,我们在讲解props
时提到,任何没有显式地声明为
props
的属性都会存储到attrs
中。换句话说,任何事件类型的props
,即onXxx
类的属性,都不会出现在props
中。这导致我们无法根据事件名称在instance.props
中找到对应的事件处理函数。为了解决这个问题,我们需要在解析props
数据的时候对事件类型的props
做特殊处理,如下面的代码所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 export function resolveProps (options : Record <string | symbol , any >, propsData : object ) { const props = {} const attrs = {} for (const key in propsData) { if (key in options || key.startsWith ("on" )) { props[key] = propsData[key] } else { attrs[key] = propsData[key] } } return [ props, attrs ] }
插槽的工作原理与实现
组件的插槽指组件会预留一个槽位,该槽位具体要渲染的内容由用户插入。以下是component
组件的模板:
1 2 3 4 5 6 7 8 9 10 11 12 13 <template> <header> <slot name="header"/> </header> <div> <slot name="body"/> </div> <footer> <slot name="footer"/> </footer> </template>
当在父组件中使用 <component>
组件时,可以根据插槽的名字来插入自定义的内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <component> <template #header> <h1> title </h1> </template> <template #header> <p> context </p> </template> <template #header> <p> footer </p> </template> </component>
上面这段父组件模板会被编译成如下渲染函数:
1 2 3 4 5 6 7 8 9 10 11 function render ( ) { return { type : component, children : { header ( ) { return { type : 'h1' , children : 'title' } } ... 下同 } } }
组件模板中的插槽内容会被编译为插槽函数,而插槽函数的返回值就是具体的插槽内容。组件component
的模板则会被编译为如下渲染函数:
1 2 3 4 5 6 7 8 9 function render ( ) { return [ { type : 'header' , children : [this .$slots .header ()] }, ... 下同 ] }
可以看到,渲染插槽内容的过程,就是调用插槽函数并渲染由其返回的内容的过程。在运行时的实现上,插槽则依赖于setupContext
中的slots
对象:
1 2 3 4 5 const slots = compVNode.children || {}const setupContext = { attrs, emit, slots }
为了在 render
函数内和生命周期钩子函数内能够通过this.$slots
来访问插槽内容,我们还需要在renderContext
中特殊对待$slots
属性:
1 2 3 if (key === "$slots" ) return slots
注册生命周期
在 Vue3 中,有一部分组合式 API
是用来注册生命周期钩子函数的,例如onMounted
、onUpdated
。对于多个钩子函数,我们需要维护一个变量currentInstance
,用它来存储当前组件实例,每当初始化组件并执行组件的setup
函数之前,先将currentInstance
设置为当前组件实例,再执行组件的setup
函数,这样我们就可以通过currentInstance
来获取当前正在被初始化的组件实例,从而将那些通过onMounted
函数注册的钩子函数与组件实例进行关联。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 let currentInstance : ComponentInstance | null = null setCurrentInstance (instance)if (setup) { const setupResult = setup (shallowReadonly (instance.props ), setupContext) if (typeof setupResult === 'function' ) { if (render) { console .error ('render conflicts' ) render = setupResult } else { setupState = setupResult } } setCurrentInstance (null ) }function setCurrentInstance (instance : ComponentInstance | null ) { currentInstance = instance }
最后就是onMounted
函数的实现:
1 2 3 4 5 6 7 8 export function onMounted (fn : EffectFunction ) { if (currentInstance) { currentInstance.mounted .push (fn) } else { console .log ("onMounted can only be used in setup." ) } }
然后在对应生命周期逐个执行mounted
内的函数即可。其他生命周期钩子函数类似。
异步组件与函数式组件
异步组件
通常在异步加载组件时,我们还要考虑以下几个方面:
如果组件加载失败或加载超时,是否要渲染 Error 组件?
组件在加载时,是否要展示占位的内容?例如渲染一个 Loading 组件。
组件加载的速度可能很快,也可能很慢,是否要设置一个延迟展示 Loading
组件的时间?
如果组件在 200ms 内没有加载成功才展示 Loading
组件,这样可以避免由组件加载过快所导致的闪烁。
组件加载失败后,是否需要重试?
我们需要在框架层面为异步组件提供更好的封装支持,与之对应的能力如下:
允许用户指定加载出错时要渲染的组件。
允许用户指定 Loading 组件,以及展示该组件的延迟时间。
允许用户设置加载组件的超时时长。
组件加载失败时,为用户提供重试的能力。
异步组件的实现原理
defineAsyncComponent
函数
异步组件本质上是通过封装手段来实现友好的用户接口,从而降低用户层面的使用复杂度:
1 2 3 4 5 6 7 8 9 10 11 12 13 <template> <AsyncComp /> </template> <script> export default { components: { // 使用 defineAsyncComponent 定义一个异步组件,接收一个加载器作为参数 AsyncComp: defineAsyncComponent(() => import('CompA')) } } </script>
在上面这段代码中,我们使用defineAsyncComponent
来定义异步组件,并直接使用components
组件选项来注册它。defineAsyncComponent
的具体实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 function defineAsyncComponent (loader : Function ) { let innerComp : any = null return { name : 'AsyncComponentWrapper' , setup ( ) { const loaded = ref (false ) loader ().then ((c :any ) => { innerComp = c loaded.value = true }) return () => { return loaded.value ? { type : innerComp } : { type : Text , children : '' } } } } }
通常占位内容是一个注释节点。组件没有被加载成功时,页面中会渲染一个注释节点来占位。但这里我们使用了一个空文本节点来占位。
超时与Error
组件
既然存在网络请求,加载一个组件可能需要很长时间。因此,我们需要为用户提供指定超时时长的能力,当加载组件的时间超过了指定时长后,会触发超时错误。这时如果用户配置了
Error 组件,则会渲染该组件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 const AsyncComp = defineAsyncComponent ({ loader : () => import ('CompA.vue' ), timeout : 2000 , errorComponent : MyErrorComp })function defineAsyncComponent (options : AsyncComponentOptions ) { const { loader } = options let innerComp : any = null return { name : 'AsyncComponentWrapper' , setup ( ) { const loaded = ref (false ) const timeout = ref (false ) if (loader) { loader ().then ((c :any ) => { innerComp = c loaded.value = true }) } let timer : any = null if (options.timeout ) { timer = setTimeout (() => { timeout.value = true }, options.timeout ) } onUnmounted (() => clearTimeout (timer)) const placeholder = { type : Text , children : '' } return () => { if (loaded.value ) { return { type : innerComp } } else if (timeout.value ) { return options.errorComponent ? { type : options.errorComponent } : placeholder } return placeholder } } } }
我们希望有更加完善的机制来处理异步组件加载过程中发生的错误,当错误发生时,把错误对象作为Error
组件的props
传递过去,
以便用户后续能自行进行更细粒度的处理。除了超时之外,有能力处理其他原因导致的加载错误,例如网络失败等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 function defineAsyncComponent (options : AsyncComponentOptions ) { const { loader } = options let innerComp : any = null return { name : 'AsyncComponentWrapper' , setup ( ) { const loaded = ref (false ) const timeout = ref (false ) const error = ref (null ) if (loader) { loader ().then ((c :any ) => { innerComp = c loaded.value = true }) .catch ((err : any ) => error.value = err) } let timer : any = null if (options.timeout ) { timer = setTimeout (() => { const err = new Error (`Async component timed out after ${options.timeout} ms` ) error.value = err }, options.timeout ) } onUnmounted (() => clearTimeout (timer)) const placeholder = { type : Text , children : '' } return () => { if (loaded.value ) { return { type : innerComp } } else if (error.value && options.errorComponent ) { return { type : options.errorComponent , props : { error : error.value } } } return placeholder } } } }
延迟与 Loading 组件
我们经常会从加载开始的那一刻起就展示 Loading
组件。但在网络状况良好的情况下,异步组件的加载速度会非常快,这会导致Loading
组件刚完成渲染就立即进入卸载阶段,于是出现闪烁的情况。对于用户来说这是非常不好的体验。因此,我们需要为Loading
组件设置一个延迟展示的时间。
delay
,用于指定延迟展示Loading
组件的时长。
loadingComponent
,类似于errorComponent
选项,用于配置Loading
组件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 export function defineAsyncComponent (options : AsyncComponentOptions ) { const { loader } = options let innerComp : any = null return { name : 'AsyncComponentWrapper' , setup ( ) { const loaded = ref (false ) const e = new Error () const err = ref (false ) const error = ref (e) const loading = ref (false ) let loadingTimer : any = null if (options.delay ) { loadingTimer = setTimeout (() => { loading.value = true }, options.delay ) } else { loading.value = true } if (loader) { loader ().then ((c :any ) => { innerComp = c loaded.value = true }) .catch ((e : any ) => { error.value = e err.value = true }) .finally (() => { loaded.value = false clearTimeout (loadingTimer) }) } ... const placeholder = { type : Text , children : '' } return () => { if (loaded.value ) { return { type : innerComp } } else if (error.value && err.value && options.errorComponent ) { return { type : options.errorComponent , props : { error : error.value } } } else if (loading.value && options.loadComponent ) { return { type : options.loadComponent } } return placeholder } } } }
当异步组件加载成功后,会卸载加载组件并渲染异步加载组件,这里我们需要修改unmounted
函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 function unmount (vnode ) { if (vnode.type === "Fragment" ) { ... } else if (typeof vnode.type === 'object' ) { unmount (vnode.component .subTree ) return } const parent = vnode.el .parentNode if (parent) parent.removeChild (vnode.el ) }
重试机制
重试指的是当加载出错时,有能力重新发起加载组件的请求。在加载组件的过程中,发生错误的情况非常常见,尤其是在网络不稳定的情况下。异步组件加载失败后的重试机制,与请求服务端接口失败后的重试机制一样。
我们使用fetch
函数发送HTTP请求,并封装一个load
函数实现失败后的重试。load
函数内部调用了fetch
函数来发送请求,并得到一个Promise
实例。接着,添加catch
语句块来捕获该实例的错误。当捕获到错误时,我们有两种选择:要么抛出错误,要么返回一个新的Promise
实例,并把该实例的resolve
和reject
方法暴露给用户,让用户来决定下一步应该怎么做。这里,我们将新的Promise
实例的resolve
和reject
分别封装为retry
函数和fail
函数,并将它们作为onError
回调函数的参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 export function defineAsyncComponent (options : AsyncComponentOptions ) { const { loader } = options let innerComp : any = null let retries = 0 function load ( ) { if (loader) { return loader () .catch ((err : Error ) => { if (options.onError ) { return new Promise ((resolve, reject ) => { const retry = ( ) => { resolve (load ()) retries++ } const fail = ( ) => { reject (err) } options.onError && options.onError (retry, fail, retries) }) } else { throw err } }) } else { console .error ("loader does not exist." , options) } } return { name : 'AsyncComponentWrapper' , setup ( ) { ... if (loader) { load ().then ((c :any ) => { innerComp = c loaded.value = true }) .catch ((e : any ) => { error.value = e err.value = true }) .finally (() => { loaded.value = false clearTimeout (loadingTimer) }) } ... }
函数式组件
函数式组件的实现相对容易。一个函数式组件本质上就是一个普通函数,该函数的返回值是虚拟
DOM。
函数式组件没有自身状态,但它仍然可以接收由外部传入的props
。为了给函数式组件定义props
,我们需要在组件函数上添加静态的props
属性,如下面的代码所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 export interface FuncComponent extends Function { props : Record <string , any > }else if (typeof type === 'object' || typeof type === 'function' ) { if (!oldNode) { mountComponent (newNode, container, anchor) } else { patchComponent (oldNode, newNode) } }const isFunctional = typeof compVNode.type === 'function' let componentOptions = compVNode.type as componentOptionsif (isFunctional) { componentOptions.render = compVNode.type as FuncComponent componentOptions.props = (compVNode.type as FuncComponent ).props }
出于更加严谨的考虑,我们需要通过isFunctional
变量实现选择性地执行初始化逻辑,因为对于函数式组件来说,它无须初始化data
以及生命周期钩子。从这一点可以看出,函数式组件的初始化性能消耗小于有状态组件。
内建组件和模块
KeepAlive
组件
KeepAlive
一词借鉴于 HTTP 协议。在 HTTP
协议中,KeepAlive
又称 HTTP 持久连接(HTTP persistent
connection),其作用是允许多个请求或响应共用一个 TCP
连接,在没有KeepAlive
的情况下,一个 HTTP
连接会在每次请求/响应结束后关闭,当下一次请求发生时,会建立一个新的 HTTP
连接。频繁地销毁、创建 HTTP
连接会带来额外的性能开销,KeepAlive
就是为了解决这个问题而生的。
KeepAlive
的本质是缓存管理,再加上特殊的挂载/卸载逻辑。首先,KeepAlive
组件的实现需要渲染器层面的支持。这是因为被KeepAlive
的组件在卸载时,我们不能真的将其卸载,否则就无法维持组件的当前状态了。正确的做法是,将被KeepAlive
的组件从原容器搬运到另外一个隐藏的容器中,实现“假卸载”。当被搬运到隐藏容器中的组件需要再次被“挂载”时,我们也不能执行真正的挂载逻辑,而应该把该组件从隐藏容器中再搬运到原容器。这个过程对应到组件的生命周期,其实就是activated
和deactivated
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 export const KeepAlive = { _isKeepAlive : true , setup (props : Record <symbol | string , any >, { slots } ) { const cache = new Map <string | componentOptions | FuncComponent , VNode >() const instance : ComponentInstance = currentInstance as ComponentInstance (instance.keepAliveCtx as KeepAliveCtx ).move = (vnode : VNode , container : Container , anchor?: Node | null ) => { if (anchor) { container.insertBefore (vnode.el , anchor) } container.appendChild (vnode.el ) } (instance.keepAliveCtx as KeepAliveCtx ).createElement = (type : any ) => { document .createElement (type ) } const { move, createElement } = instance.keepAliveCtx as KeepAliveCtx const storageContainer = createElement ('div' ) (instance.keepAliveCtx as KeepAliveCtx )._deActivated = (vnode : VNode ): void => { move (vnode, storageContainer) } (instance.keepAliveCtx as KeepAliveCtx )._activated = (vnode : VNode , container : Container , anchor : Node ): void => { move (vnode, container, anchor) } return () => { let rawVNode = slots.default () if (typeof rawVNode.type !== 'object' ) { return rawVNode } const cachedVNode = cache.get (rawVNode.type ) if (cachedVNode) { rawVNode.component = cachedVNode.component rawVNode.component = (cachedVNode.component as ComponentInstance ).keptAlive = true } else { cache.set (rawVNode.type , rawVNode) } rawVNode.shouldKeepAlive = true rawVNode.keepAliveInstance = instance return rawVNode } } }
KeepAlive 组件与渲染器的结合非常深。首先,KeepAlive
组件本身并不会渲染额外的内容,它的渲染函数最终只返回需要被 KeepAlive
的组件,我们把这个需要被 KeepAlive
的组件称为“内部组件”。KeepAlive组件会对“内部组件”进行操作,主要是在“内部组件”的
vnode
对象上添加一些标记属性,以便渲染器能够据此执行特定的逻辑。这些标记属性包括如下几个。
shouldKeepAlive
:该属性会被添加到“内部组件”的vnode
对象上,这样当渲染器卸载“内部组件”时,可以通过检查该属性得知“内部组件”需要被KeepAlive
。于是,渲染器就不会真的卸载“内部组件”,而是会调用_deActivate
函数完成搬运工作
1 2 3 4 5 6 7 8 else if (typeof vnode.type === 'object' ) { if ((vnode as KeepAliveVNode ).shouldKeepAlive && (vnode as KeepAliveVNode ).keepAliveInstance ) { (vnode as KeepAliveVNode ).keepAliveInstance ?._deActivated (vnode) } ... }
include
和exclude
在默认情况下,KeepAlive
组件会对所有“内部组件”进行缓存。但有时候用户期望只缓存特定组件。为了使用户能够自定义缓存规则,我们需要让KeepAlive
组件支持两个props
,分别是include
和exclude
。其中,include
用来显式地配置应该被缓存组件,而exclude
用来显式地配置不应该被缓存组件。
为了简化问题,我们只允许为 include 和 exclude 设置正则类型的值。在
KeepAlive
组件被挂载时,它会根据“内部组件”的名称(即name
选项)进行匹配,如下面的代码所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 props : { include : RegExp , exclude : RegExp },if (typeof rawVNode.type !== 'object' ) { return rawVNode } const name = (rawVNode.type as componentOptions).name if (name && ( (props.include && !props.include .test (name)) || (props.exclude && props.exclude .test (name)) ) ) { return rawVNode }
在此基础上,我们可以任意扩充匹配能力。例如,可以将include
和exclude
设计成多种类型值,允许用户指定字符串或函数,从而提供更加灵活的匹配机制。
缓存管理
缓存的处理逻辑可以总结为:
如果缓存存在,则继承组件实例,并将用于描述组件的vnode
对象标记为keptAlive
,这样渲染器就不会重新创建新的组件实例;
如果缓存不存在,则设置缓存。
这里的问题在于,当缓存不存在的时候,总是会设置新的缓存。这会导致缓存不断增加,极端情况下会占用大量内存。为了解决这个问题,我们必须设置一个缓存阈值,当缓存数量超过指定阈值时对缓存进行修剪。
Vue.js
当前所采用的修剪策略叫作“最新一次访问”。首先,你需要为缓存设置最大容量,也就是通过KeepAlive
组件的
max
属性来设置:
1 2 3 <KeepAlive :max="2"> ... </KeepAlive>
“最新一次访问”的缓存修剪策略的核心在于,需要把当前访问(或渲染)的组件z作为最新一次渲染的组件,并且该组件在缓存修剪过程中始终是安全的,即不会被修剪。
Teleport
组件
Teleport
用于解决组件的内容无法跨越 DOM
层级渲染的为每桶。该组件可以将指定内容渲染到特定容器中,而不受 DOM
层级的限制。通过为 Teleport 组件指定渲染目标,即 to
属性的值,该组件就会直接把它的插槽内容渲染到目标下,而不会按照模板的 DOM
层级来渲染。
1 2 3 4 5 ...else if (typeof type === 'object' && (type as teleport)?._isTeleport ) { (type as teleport).process (newNode, oldNode, container, anchor) }
与KeepAlive
组件一样,Teleport
组件也需要渲染器的底层支持。首先我们要将 Teleport
组件的渲染逻辑从渲染器中分离出来,这么做有两点好处:
可以避免渲染器逻辑代码“膨胀”;
当用户没有使用Teleport
组件时,由于Teleport
的渲染逻辑被分离,因此可以利用TreeShaking
机制在最终的
bundle
中删除Teleport
相关的代码,使得最终构建包的体积变小。
1 2 3 4 5 6 7 export const Teleport = { _isTeleport : true , process (oldVNode : VNode , newVNode : VNode , container : Container , anchor?: Node | null ) { } }
我们调用patch
和patchChild
完成挂载和更新。其中更新时需要注意更新操作可能是由于Teleport
组件的to
属性值的变化引起的,因此,在更新时我们应该考虑这种情况。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 export const Teleport : teleport = { _isTeleport : true , process (oldVNode : VNode , newVNode : VNode , container : Container , anchor : Node | null ) { if (!oldVNode) { const target = typeof newVNode.props ?.to === 'string' ? document .querySelector (newVNode.props .to ) : newVNode.props .to (newVNode.children as VNode []).forEach ((child : VNode ) => patch (undefined , child, target, anchor)) } else { patchChild (oldVNode, newVNode, container) if (newVNode.props .to !== oldVNode.props .to ) { const newTarget = typeof newVNode.props .to === 'string' ? document .querySelector (newVNode.props .to ) : newVNode.props .to (newVNode.children as VNode []).forEach ((child : VNode ) => move (child, newTarget)) } } } }
其中move
函数的实现为:
1 2 3 4 5 6 7 8 9 10 11 12 13 export function move (vnode : VNode , container : Container , anchor?: Node | null ) { const target = vnode.component ? vnode.component .subTree .el : vnode.el if (anchor) { container.insertBefore (target, anchor) } container.appendChild (target) }
Transition
组件
Transition
组件的核心原理是:
当 DOM 元素被挂载时,将动效附加到该 DOM 元素上;
当 DOM 元素被卸载时,不要立即卸载 DOM 元素,而是等到附加到该 DOM
元素上的动效执行完成后再卸载它。
下面是Vue文档中的示意图:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 export const Transition : transition = { name : 'Transition' , setup (props, { slots } ) { return () => { const innerVNode = slots.default () innerVNode.transition = { beforeEnter (el : Container ) { el.classList .add ('c-enter-from' ) el.classList .add ('c-enter-active' ) }, enter (el : Container ) { nextFrame (() => { el.classList .remove ('c-enter-from' ) el.classList .add ('c-enter-to' ) el.addEventListener ('transitionend' , () => { el.classList .remove ('c-enter-to' ) el.classList .remove ('c-enter-active' ) }) }) }, leave (el : Container ) { el.classList .add ('c-leave-from' ) el.classList .add ('c-leave-active' ) document .body .offsetHeight nextFrame (() => { el.classList .remove ('c-leave-from' ) el.classList .add ('c-leave-to' ) el.addEventListener ('transitionend' , () => { el.classList .remove ('c-leave-to' ) el.classList .remove ('c-leave-active' ) el.parentNode ?.removeChild (el) }) }) performRemove () } } return innerVNode } } }function nextFrame (cb : () => void ) { requestAnimationFrame (() => { requestAnimationFrame (cb) }) }
浏览器的实现有一个 bug
,使用requestAnimationFrame
函数注册回调会在当前帧执行,除非其他代码已经调用了一次requestAnimationFrame
函数。通过嵌套一层requestAnimationFrame
函数的调用即可解决上述问题。
然后我们需要在合适的时机调用附加到该虚拟节点上的过渡相关的生命周期钩子函数,具体体现在mountElement
函数以及unmount
函数中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 function mountElement (vnode : VNode , container : Container , anchor?: Node | null ) { ... const needTransition = vnode.transition if (needTransition) { vnode.transition ?.beforeEnter (el) } ... if (needTransition) { vnode.transition ?.enter (el) } }function unmount (vnode : VNode | KeepAliveVNode ) { const needTransition = vnode.transition ... const parent = vnode.el .parentNode if (parent) { if (needTransition) { vnode.transition ?.leave (vnode.el ) } else { parent.removeChild (vnode.el ) } } }
我们硬编码了过渡状态的类名,例如enter-from
、enter-to
等。实际上,我们可以轻松地通过props
来实现允许用户自定义类名的能力,从而实现一个更加灵活的Transition
组件。另外,我们也没有实现“模式”的概念,即先进后出(in-out)或后进先出(out-in)。实际上,模式的概念只是增加了对节点过渡时机的控制,原理上与将卸载动作封装到performRemove
函数中一样,只需要在具体的时机以回调的形式将控制权交接出去即可。
总结
一个有状态组件就是一个选项对象。如果从渲染器的内部实现来看,一个组件则是一个特殊类型的虚拟
DOM
节点。组件本身是对页面内容的封装,它用来描述页面内容的一部分,一个组件必须包含一个渲染函数,即render
函数,并且渲染函数的返回值应该是虚拟
DOM。换句话说,组件的渲染函数就是用来描述组件所渲染内容的接口 。
当组件自身状态发生变化时,我们需要有能力触发组件更新,即组件的自更新。为此,我们需要将整个渲染任务包装到一个watchEffect
中。我们需要实现一个调度器,当副作用函数需要重新执行时,我们不会立即执行它,而是将它缓冲到一个微任务队列中,等到执行栈清空后,再将它从微任务队列中取出并执行,我们就有机会对任务进行去重,从而避免多次执行副作用函数带来的性能开销。
对于组件的 props
与组件的被动更新,副作用自更新所引起的子组件更新叫作子组件的被动更新。我们还介绍了渲染上下文(renderContext
),它实际上是组件实例的代理对象。在渲染函数内访问组件实例所暴露的数据都是通过该代理对象实现的。
组件的setup
函数是 Vue3 新增的组件选项,它有别于 Vue2
中存在的其他组件选项。这是因为setup
函数主要用于配合组合式API,为用户提供一个地方,用于建立组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子等能力。在组件的整个生命周期中,setup
函数只会在被挂载时执行一次,它的返回值可以有两种情况。
在具体的实现上,发射自定义事件的本质就是根据事件名称去props
数据对象中寻找对应的事件处理函数并执行。整体实现并不复杂,只需要实现一个emit
函数并将其添加到setupContext
对象中,这样用户就可以通过setupContext
取得emit
函数了。注意,我们需要在解析props
数据的时候对事件类型的props
做特殊处理。
我们定义了defineAsyncComponent
函数,用来定义异步组件。我们还实现了超时、加载、错误组件的选项,用于处理各类网络状况。函数式组件本质上是一个函数,其内部实现逻辑可以复用有状态组件的实现逻辑。