将一个大的页面分为多个组件,每个组件都可以完成独立的功能并且可以被复用。
从用户角度,是一个描述对象
const myComponent1 = {
name:'myComponent1',
data(){
return {name:'wjt'}
}
}
从渲染器角度,是一个特殊的虚拟DOM
const myComponent2 = {
// type:Fragment, //描述片段
type:Text, //描述节点
props:{
}
}
在patch函数中定义对应策略
function patch(n1,n2,container,anchor){
if(n1 && n1.type !== n2.type){
unmount(n1)
n1 = null
}
const {type} = n2
if(typeof type === 'string'){
}else if(type === Text){
//作为文本元素处理
}else if(type === Fragment){
//作为片段处理
}
if(!n1){
//挂载组件
mountComponent(n2,container,anchor)
}else{
//更新组件
patchComponent(n1,n2,anchor)
}
}
模拟一个组件,创建一个用来描述组件的对象,并且使用渲染器进行渲染
//组件
const myComponent = {
name:'myComponent',
render(){
return {
type:'div',
children:'我是文本内容'
}
}
}
//描述组件的vNode对象,type属性值作为组件的选项对象
const CompVNode = {
type:myComponent
}
renderer.render(CompVNode,document.querySelector('#app'))
//渲染组件的功能函数
function mountComponent(vnode,container,anchor){
//通过vnode获取组件的选项对象,即vnode.type
const componentOptions = vnode.type
//获取组件的渲染函数render
const {render} = componentOptions
//执行渲染函数,获取组件要渲染的内容,即render函数返回的虚拟DOM
const subTree = render()
//最后调用patch函数来挂载组件所描述的内容,即subTree
patch(null,subTree,container,anchor)
}
模拟组件
const myComponent = {
name:'myComponent',
data(){
return{
foo:'hello world'
}
},
render(){
return{
type:'div',
children:`foo的值是${this.foo}`
}
}
}
挂载组件函数
function mountComponent(vnode,container,anchor){
const componentOptions = vnode.type
const {render,data} = componentOptions
//调用data函数得到原始数据,调用reactive函数将其包装为响应式数据
const state = reactive(data())
//调用render函数时,将this设置为state,所以render函数内部可以通过this访问组件自身状态数据
const subTree = render.call(state,state)
patch(null,subTree,container,anchor)
}
上面的代码完成了两件事:
1.执行data函数,对执行结果使用reactive函数进行包装,将组件的data数据变成响应式数据state
2.调用render函数时,将this指向state
当组件自身状态发生变化时,需要去触发组件的更新,所以需要调用effect函数
function mountComponent(vnode,container,anchor){
const componentOptions = vnode.type
const {render,data} = componentOptions
const state = reactive(data())
//调用effect函数,将函数注册响应式
effect(()=>{
const subTree = render.call(state,state)
patch(null,subTree,container,anchor)
})
}
一旦自身的响应式数据发生变化,组件就会自动执行渲染函数,从而完成更新。但是,由于effect的执行是同步的,因此当响应式数据发生变化时,与之关联的副作用函数会同步执行。
如果多次修改响应式数据的值,将会导致渲染函数多次执行,这是影响性能的。所以需要一个机制,以使得无论对响应式数据进行多少次修改,副作用函数都只会执行一次。
实现一个调度器,当副作用函数重新执行时,不会立即执行,而是缓冲到一个微任务队列中,等到执行栈清空后,再将它从微任务队列中取出并执行。有了缓存机制,就可以对任务进行去重,从而避免了多次执行。
//任务缓存队列,用一个Set数据结构来表示,这样就可以对任务进行去重
const queue = new Set()
//一个标志,代表是否正在刷新任务队列
let isFlushing = false
//创建一个立即resolve的Promise实例
const p = Promise.resolve()
//调度器的主要函数,用来将一个任务添加到缓冲队列中,并开始刷新队列
function queueJob(job){
//将job添加到任务队列queue中
queue.add(obj)
//如果还没有开始刷新队列,则刷新之
if(!isFlushing){
//将该标志设置为true以避免重复刷新
isFlushing = true
//在微任务中刷新缓冲队列
p.then(()=>{
try{
//执行队列中的任务
queue.forEach(job=>job())
} finally{
//重置状态
isFlushing = false
queue.clear = 0
}
})
}
}
调度器本质上是利用了微任务的异步执行机制,实现了对副作用函数的缓冲。
当响应式数据发生变化,副作用函数不会立即同步执行,而是会被queueJob函数调度,最后在一个微任务中执行。
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)
},{
//指定该副作用函数的调度器为queueJob即可
scheduler:queueJob
})
}
不过上面diamante也有缺陷:在effect函数内调用patch函数完成渲染时,第一个参数总是null。这意味着,每次更新都会进行全新的挂载,而不会打补丁,这是不正确的。正确的做法是:每次更新时,都拿subTree与上一次组件所渲染的subTree进行打补丁。所有需要实现组件实例,用来维护整个组件的生命周期状态,这样渲染器才能在正确的实际执行合适的操作。
组件的实例本质上就是一个状态集合(对象),维护着组件运行过程中的所有信息。
function mountComponent(vnode,container,anchor){
const componentOptions = vnode.type
const {render,data} = componentOptions
const state = reactive(data())
//定义组件实例,一个组件实例本质上就是一个对象,包含了与组件相关的所有状态信息
const instance = {
//组件自身的状态数据,即data
state,
//表示组件是否被挂载,初始值为false
isMounted:false,
//组件所渲染的内容
subTree:null
}
//将组件实例设置到vnode上,用于后续更新
vnode.component = instance
effect(()=>{
//调用组件的渲染函数,获得子树
const subTree = render.call(state,state)
//检查组件是否已经被挂载
if(!instance.isMounted){
//初次挂载,调用patch函数第一个参数传递null
patch(null,subTree,container,anchor)
//终重点:将组件实例的isMounted设置为true,这样当更新发生时就不会再次进行挂载操作,而是会执行更新
instance.isMounted = true
}else{
//当isMounted为true时,说明组件已经被挂载,只需要完成自更新即可
//所以在调用patch函数时,第一个参数为组件上一次渲染的子树
//需要使用新的子节点树对上一次渲染的子节点树进行更换补丁
patch(instance.subTree,subTree,container,anchor)
}
//更新组件实例的子树
instance.subTree = subTree
},{scheduler:queueJob})
}
组件实例的instance.isMounted可以区分组件的挂载和更新。所以可以在适当的时机调用组件对应的生命周期钩子
function mountComponent(vnode,container,anchor){
const componentOptions = vnode.type
//从组件选项对象中取得组件的生命周期函数
const { render,data,beforeCreate,created,beforeMount,mounted,beforeUpdate,updated } = componentOptions
//调用beforeCreate钩子
beforeCreate && beforeCreate()
const state = reactive(data())
const instance = {
state,
isMounted:false,
subTree:null
}
vnode.component = instance
//在这里调用created钩子
created && created.call(state)
effect(()=>{
const subTree = render.call(state,state)
if(!instance.isMounted){
//在这里调用beforeMount钩子
baforeMount && beforeMount.call(state)
patch(null,subTree,container,anchor)
instance.isMounted = true
//在这里调用mounted钩子
mounted && mounted.call(state)
}else{
//在这里调用beforeUpdate钩子
beforeUpdate && beforeUpdate.call(state)
patch(instance.subTree,subTree,container,anchor)
//在这里调用updated钩子
updated && updated.call(state)
}
instance.subTree = subTree
},{scheduler:queueJob})
}
在虚拟DOM层面,组件的props与普通HTML标签的属性差别不不大。
假设有如下模板
<MyComponent title="A Big Title" :other="val">MyComponent>
对应的虚拟DOM是
const vnode = {
type:myComponent,
props:{
title:'A big Title',
other:this.val
}
}
对应的组件描述
const MyComponent = {
name:'MyComponent',
//组件接受名为title的props,并且该props的类型为String
props:{
title:String
},
render(){
return {
type:div,
children:`props data` //访问props数据
}
}
}
对于一个组件来说,有两部分关于props的内容需要关心
为组件传递的props数据,即组件的vnode.props对象
组件选项中定义的props选项,即MyComponent.props对象
function mountComponent(vnode,container,anchor){
const componentOptions = vnode.type
//从组件选项对象中取出props定义,即propsOption
const {render,data,props:propsOption,beforeCreate/*其他属性*/} = componentOptions
beforeCreate && beforeCreate()
const state = reactive(data())
//调用resolveProps函数解析出最终的props数据与attrs数据
const [props,attrs] = resolveProps(propsOption,vnode.props)
const instance = {
state,
//将解析出的props数据包装为shallowReactive并定义到组件实例上
props:shallowReactive(props),
isMounted:false,
subTree:null
}
vnode.component = instance
//省略部分代码
}
//resolveProps函数用于解析组件props和attrs数据
function resolveProps(options,propsData){
const props = {}
const attrs = {}
for(const key in propsData){
if(key in options){
//如果为组件传递的props数据在组件自身的props选项中有定义,则将其视为合法的props
props[key] = propsData[key]
}else{
//否则将其视为attrs
attrs[key] = propsData[key]
}
}
//最后返回props与attrs数据
return [props,attrs]
}
没有定义在MyComponent.props选项的props数据将存储到attrs对象中
处理完props数据后,需要关注props数据变化的问题
props的值发生变化时,父组件的渲染函数会重新执行,也会产生新的虚拟DOM。
接下来,组件会进行自更新。在更新过程中,渲染器发现父组件的subTree包含组件类型的虚拟节点,所以会调用patchComponent函数完成子组件的更新
function patch(n1,n2,container,anchor){
if(n1 && n1.type !== n2.type){
unmount(n1)
n1 = null
}
const {type} = n2
if(typeof type === 'string'){
//文本类型节点
}else if(type === Fragment){
//Fragment类型节点
}else if(typeof type === 'object'){
//vnode.type的值是选项对象,作为组件来处理
if(!n1){
mountComponent(n2,container,anchor)
}else{
//更新组件
patchComponent(n1,n2,anchor)
}
}
}
因为父组件的更新导致子组件的更新称之为子组件的被动更新,当子组件被动更新时,需要做的是:
1.检测子组件是否真的需要更新,因为子组件的props可能是不变的。
2.如果需要更新,更新子组件的props及solts等
function patchComponent(n1,n2,anchor){
//获取组件实例,即n1.component,同时让新的组件虚拟节点n2.component也指向组件实例
const instance = (n2.component = n1.component)
//获取当前的props数据
const {props} = instance
//调用hasPropsChanged检测为子组件传递的props是否发生变化,如果没有变化,则不需要更新
if(hasPropsChanged(n1.props,n2.props)){
//调用resolveProps函数重新获取props数据
const [nextProps] = resolveProps(n2.type.props,n2.props)
//更新props
for(const k in nextProps){
props[k] = nextProps[k]
}
//删除不存在的props
for(const k in props){
if(!(k in nextProps)){
delete props[k]
}
}
}
}
//长度不同或者属性键值对不上,就是发生了变化
function hasPropsChanged(prevProps,nextProps){
const nextKeys = Object.keys(nextProps)
//如果新旧props的数量变了,则说明有变化
if(nextKeys.length !== Object.keys(prevProps.length)){
return true
}
for(let i = 0;i<nextKeys.length;i++){
const key = nextKeys[i]
//有不相等的props,则说明有变化
if(nextProps[key] !== prevProps[key]){
return true
}
}
return false
}
由于props数据和组件自身的状态数据都是需要暴露到渲染函数中,并使得渲染函数能够通过this访问,因此需要封装一个渲染上下文对象
function mountComponent(vnode,container,anchor){
//省略部分代码
const instance = {
state,
props:shallowReactive(props),
isMounted:false,
subTree:null
}
vnode.component = instance
//创建渲染上下文对象,本质上是组件实例的代理
const renderContext = new Proxy(instance,{
get(t,k,r){
//取得组件自身状态与props数据
const {state,props} = t
//先尝试读取自身状态数据
if(state && k in state){
return state[k]
}else if(k in props){
//如果自身没有该数据,从props中读取
return props[k]
}else{
console.error('该属性不存在')
}
},
set(t,k,v,r){
const { state,props} = t
if(state && k in state){
state[k] = v
}else if(k in props){
console.warn('该属性为只读')
}else{
console.error('不存在')
}
}
})
}
setup主要是配合组合式api,用于创建各种组合逻辑,响应式数据,函数,注册生命周期等。只会在挂载的时候执行一次,返回值有两种:
1.返回一个函数,该函数将作为组件的render函数
//这种方式通常以模板来表达其渲染内容的情况。如果组件以模板来表达渲染内容,那么setup函数不可以再返回函数,否则会与模板编译生成的渲染函数产生冲突
const Comp = {
setup(){
//setup函数可以返回一个函数,该函数将作为组件的渲染函数
return (()=>{
return {type:'div',children:'hello'}
})
}
}
2.返回一个对象,该对象包含的的数据将暴露给模板使用
const Comp = {
setup(){
const count = ref(0)
return {
count
}
},
render(){
return {type:'div',children:`count is ${this.count}`}
}
}
可以看到,setup函数暴露的数据可以在渲染函数中通过this来访问
另外,setup函数接两个参数。第一个参数是props数据对象,第二个参数也是一个对象
const Comp = {
props:{
foo:String
},
setup(props,setupContext){
props.foo //访问传入props数据
//setupContext中包含与组件接口有关的重要数据
const {solts,emit,attrs,expose} = setupContext
//...
}
}
实现setup组件选项
function mountComponent(vnode,container,anchor){
//从组件的vnode获取对应的配置
const componentOptions = vnode.type
//从组件选项中取出setup函数
let {render,data,setup,/**其他选项*/} = componentOptions
//执行组件创建前生命周期钩子
beforeCreate && beforeCreate()
//state为经过响应式注册的响应式数据
const state = data?reactive(data()):null
//获取组件中的props和attrs等属性
const [props,attrs] = resolveProps(propsOption,vnode.props)
//一个组件的综合描述对象,包含组件所有的信息和状态
const instance = {
state,
props:shallowReactive(props),
isMounted:false,
subTree:null
}
//setupContext,因为目前还没涉及到emit和slots,暂时只把attrs放进去
const setupContext = {attrs}
//调用setup函数,将只读版本的props作为第一个参数传递,避免用户意外地修改props值
//将setupContext作为第二个参数传递
const setupResult = setup(shallowReadonly(instance.props),setupContext)
//setupState用来存储由setup返回的数据
let setupState = null
//如果setup函数的返回值是函数,则将其作为渲染函数
if(typeof setupResult === 'function'){
//存在歧义,报告冲突
if(render){
console.error('setup返回的是渲染函数,render选项会被忽略')
}
render = setupResult
}else{
//如果setup的返回值不是函数,则作为数据状态赋值给setupState
setupState = setupResult
}
//将组件的描述信息对象赋值给vnode中component属性
vnode.component = instance
//使用proxy使组件各种状态的读取和修改都可以监测到
//renderContext也就是我们完成的渲染上下文
const renderContext = new Proxy(instance,{
get(target,key,result){
const {state,props} = target
if(state && key in state){
return state[key]
}else if(key in props){
return props[key]
}else if(setupState && key in setupState){
//渲染上下文需要增加对setupState的支持
return setupState[key]
}else{
console.error('不存在')
}
},
set(target,key,value,result){
const {state,props} = target
if(state && key in state){
state[key] = value
}else if(key in props){
console.warn('props的属性是只读')
}else if(setupState && key in setupState){
//渲染上下文需要增加对setupState的支持
setupState[key] = value
}else{
console.error('不存在')
}
}
})
}
上面是setup函数的最小实现,需要注意
1.setupState是一个对象,目前只存在attrs
2.根据setup函数的返回值类型决定如何处理。如果返回值为函数,直接作为组件的渲染函数。这里为了避免歧义,需要先检查组件选项中是否已经存在render选项,如果存在,需要打印警告信息
3.渲染上下文renderContext应该正确的处理setupState,因为setup函数返回的数据状态也应该暴露到渲染环境
emit 用来触发组件的自定义事件
const MyComponent = {
name: 'MyComponent',
setup(props, { emit }) {
// 发射 change 事件,并传递给事件处理函数两个参数
emit('change', 1, 2)
return () => {
return // ...
}
}
}
使用该组件时,可以监听由emit函数发射的自定义事件
<MyComponent @change="handler" />
上面的模板对应的虚拟DOM为
const CompVNode = {
type: MyComponent,
props: {
onChange: handler
}
}
自定义事件change被编译名字为onChange的属性,并且存储在props数据对象中。这实际上是一种约定。
在具体的实现上,触发自定义事件的本质就是根据事件名称去props数据对象中寻找对应的事件处理函数并执行,如下
function mountComponent(vnode, container, anchor) {
// 省略部分代码
//组件信息状态集合
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null
}
// 定义 emit 函数,它接收两个参数
// event: 事件名称
// payload: 传递给事件处理函数的参数
function emit(event, ...payload) {
// 根据约定对事件名称进行处理,例如 change --> onChange
const eventName = `on${event[0].toUpperCase() + event.slice(1)}`
// 根据处理后的事件名称去 props 中寻找对应的事件处理函数
const handler = instance.props[eventName]
if (handler) {
// 调用事件处理函数并传递参数
handler(...payload)
} else {
console.error('事件不存在')
}
}
// 将 emit 函数添加到 setupContext 中,用户可以通过 setupContext 取得 emit 函数
const setupContext = { attrs, emit }
// 省略部分代码
}
实现一个emit函数,并且将其添加到setupContext对象中,这样用户就可以通过setupContext取得emit函数了。会根据预订对事件名称进行转换,以便能够在props数据对象中找到对应的事件处理函数。
最后,调用事件处理函数并透传参数即可。
另外需要注意的是,没有任何显示的声明为props的属性都会存储到attrs中,换句话说,任何事件类型的props,即onXXX属性,都不会出现在props中。这导致我们无法根据事件名称在instance.props中找到对应的事件处理函数。
为了解决这个问题,需要在解析props数据的时候对事件类型的props做特殊处理,如下
function resolveProps(options, propsData) {
const props = {}
const attrs = {}
for (const key in propsData) {
// 以字符串 on 开头的 props,无论是否显式地声明,都将其添加到 props 数据中,而不是添加到 attrs 中
if (key in options || key.startsWith('on')) {
props[key] = propsData[key]
} else {
attrs[key] = propsData[key]
}
}
return [ props, attrs ]
}
模板中使用插槽
MyComponent组件:
<template>
<header><slot name="header" /></header>
<div>
<slot name="body" />
</div>
<footer><slot name="footer" /></footer>
</template>
父组件使用MyComponent组件:
<MyComponent>
<template #header>
<h1>我是标题</h1>
</template>
<template #body>
<section>我是内容</section>
</template>
<template #footer>
<p>我是注脚</p>
</template>
</MyComponent>
父组件的模板会被编译成如下的渲染函数
// 父组件的渲染函数
function render() {
return {
type: MyComponent,
// 组件的 children 会被编译成一个对象
children: {
header() {
return { type: 'h1', children: '我是标题' }
},
body() {
return { type: 'section', children: '我是内容' }
},
footer() {
return { type: 'p', children: '我是注脚' }
}
}
}
}
组件模板中的插槽内容会被编译成为插槽函数,而插槽函数的返回值就是具体的插槽内容,组件MyComponent的模板会被编译成如下渲染函数
// MyComponent 组件模板的编译结果
function render() {
return [
{
type: 'header',
children: [this.$slots.header()]
},
{
type: 'body',
children: [this.$slots.body()]
},
{
type: 'footer',
children: [this.$slots.footer()]
}
]
}
渲染插槽内容的过程,就是调用插槽函数并渲染由其返回的内容的过程
在运行时的实现上,插槽则依赖于setupContext中的slots对象
function mountComponent(vnode, container, anchor) {
// 省略部分代码
// 直接使用编译好的 vnode.children 对象作为 slots 对象即可
const slots = vnode.children || {}
// 将 slots 对象添加到 setupContext 中
const setupContext = { attrs, emit, slots }
}
最基本的slots的实现非常简单。只需要将vnode.children作为slots对象,然后将slots对象添加到setupContext对象中。为了在render函数内和生命周期钩子函数内能够通过this.$slots来访问插槽内容,还需要在renderContext中特殊对待slots属性
function mountComponent(vnode, container, anchor) {
// 省略部分代码
const slots = vnode.children || {}
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
// 将插槽添加到组件实例上
slots
}
// 省略部分代码
//渲染上下文
const renderContext = new Proxy(instance, {
get(target, key, result) {
const { state, props, slots } = target
// 当 key 的值为 $slots 时,直接返回组件实例上的 slots
if (key === '$slots') return slots
// 省略部分代码
},
set (target, key, value, result) {
// 省略部分代码
}
})
// 省略部分代码
}
//对渲染上下文renderContext代理对象的get拦截函数做了特殊处理,当读取的键是 $slots时,直接返回组件实例上的slots对象,这样就可以通过this.$slots来访问插槽内容了
在vue中,部分组合API是用来注册生命周期钩子函数的,例如
import { onMounted } from 'vue'
const MyComponent = {
setup() {
onMounted(() => {
console.log('mounted 1')
})
// 可以注册多个
onMounted(() => {
console.log('mounted 2')
})
// ...
}
}
在setup函数中,调用onMounted函数即可注册mounted钩子函数,并且可以多次调用onMounted函数来注册多个钩子函数,这些函数会在组件被挂载之后再执行。
问题在于,在A组件的setup函数中调用onMounted函数会将该钩子函数注册到A组件上;在B组件调用就注册到B组件上
方法:实现一个变量currentInstance,用来存储当前的组件实例,每次当初始化组件并执行组件的setup函数之前,先将currentInstance设置为当前组件实例,再执行组件的setup函数,这样就可以通过currentInstance来获取当前正在被初始化的组件实例,从而将那些通过onMounted函数注册的钩子函数与组件实例进行关联。
全局变量,存储当前正在被初始化的组件实例
let currentInstance = null
// 该方法接收组件实例作为参数,并将该实例设置为 currentInstance
function setCurrentInstance(instance) {
currentInstance = instance
}
有了变量和修改变量的方法,就可以修改mountComponent函数了
function mountComponent(vnode, container, anchor) {
// 省略部分代码
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
slots,
// 在组件实例中添加 mounted 数组,用来存储通过 onMounted 函数注册的生命周期钩子函数
mounted: []
}
// 省略部分代码
// setup
const setupContext = { attrs, emit, slots }
// 在调用 setup 函数之前,设置当前组件实例
setCurrentInstance(instance)
// 执行 setup 函数
const setupResult = setup(shallowReadonly(instance.props), setupContext)
// 在 setup 函数执行完毕之后,重置当前组件实例
setCurrentInstance(null)
// 省略部分代码
}
以onMounted为例,为了存储由onMounted函数注册的生命周期钩子,需要在组件实例对象上添加instance.mounted数组,因为可以多次调用onMounted函数用来注册不同的生命周期函数,这些生命周期函数都会存储在instance.mounted数组中
//实现onMounted函数
function onMounted(fn) {
if (currentInstance) {
// 将生命周期函数添加到 instance.mounted 数组中
currentInstance.mounted.push(fn)
} else {
console.error('onMounted 函数只能在 setup 中调用')
}
}
通过currentInstance获取当前组件实例,并将生命周期钩子函数添加到当前实例对象的instance.mounted数组中即可。如果当前实例不存在,提示报错。
最后,在合适的时机调用这些注册到instance.mounted数组中的生命周期钩子函数
function mountComponent(vnode, container, anchor) {
// 省略部分代码
effect(() => {
const subTree = render.call(renderContext, renderContext)
if (!instance.isMounted) {
// 省略部分代码
// 遍历 instance.mounted 数组并逐个执行即可
instance.mounted && instance.mounted.forEach(hook => hook.call(renderContext))
} else {
// 省略部分代码
}
instance.subTree = subTree
}, {
scheduler: queueJob
})
}
其余生命周期函数如onUpdate等同理