element-plus安装
跳过PUPPETEER安装
export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1调试
生成sourcemap
在internal/build/build.config.ts配置中设置sourcemap:true
执行
pnpm build
pnpm build:theme运行dev
在源码上设置断点
新建javaScript Debug Terminal
pnpm devvue中的渲染函数 & JSX
基本用法
TIP
h() 是 hyperscript 的简称——意思是“能生成 HTML (超文本标记语言) 的 JavaScript”。这个名字来源于许多虚拟 DOM 实现默认形成的约定。一个更准确的名称应该是 createVNode(),但当你需要多次使用渲染函数时,一个简短的名字会更省力
Vue 提供了一个 h() 函数用于创建 vnodes:
import { ref, h } from 'vue'
export default {
props: {
/* ... */
},
setup(props) {
const count = ref(1)
// 返回渲染函数
return () => h('div', props.msg + count.value)
}
}WARNING
Vnodes 必须唯一,组件树中的 vnodes 必须是唯一的。下面是错误示范:
function render() {
const p = h('p', 'hi')
return h('div', [
// 啊哦,重复的 vnodes 是无效的
p,
p
])
}如果你真的非常想在页面上渲染多个重复的元素或者组件,你可以使用一个工厂函数来做这件事。比如下面的这个渲染函数就可以完美渲染出 20 个相同的段落:
function render() {
return h(
'div',
Array.from({ length: 20 }).map(() => {
return h('p', 'hi')
})
)
}JSX / TSX
TIP
调用h函数生成vnode写法复杂,所以就有了jsx/tsx写法,更加的直观和语义化,同理vue的template模版语法也是类似效果
JSX 是 JavaScript 的一个类似 XML 的扩展,有了它,我们可以用以下的方式来书写代码:
const vnode = <div id={dynamicId}>hello, {userName}</div>TIP
Vue 的类型定义也提供了 TSX 语法的类型推导支持。当使用 TSX 语法时,确保在 tsconfig.json 中配置了 "jsx": "preserve",这样的 TypeScript 就能保证 Vue JSX 语法转换过程中的完整性。
渲染插槽
在渲染函数中,插槽可以通过 setup() 的上下文来访问。每个 slots 对象中的插槽都是一个返回 vnodes 数组的函数:
export default {
props: ['message'],
setup(props, { slots }) {
return () => [
// 默认插槽:
// <div><slot /></div>
h('div', slots.default()),
// 具名插槽:
// <div><slot name="footer" :text="message" /></div>
h(
'div',
slots.footer({
text: props.message
})
)
]
}
}等价 JSX 语法:
// 默认插槽
<div>{slots.default()}</div>
// 具名插槽
<div>{slots.footer({ text: props.message })}</div>传递插槽
向组件传递子元素的方式与向元素传递子元素的方式有些许不同。我们需要传递一个插槽函数或者是一个包含插槽函数的对象而非是数组,插槽函数的返回值同一个正常的渲染函数的返回值一样——并且在子组件中被访问时总是会被转化为一个 vnodes 数组。
// 单个默认插槽
h(MyComponent, () => 'hello')
// 具名插槽
// 注意 `null` 是必需的
// 以避免 slot 对象被当成 prop 处理
h(MyComponent, null, {
default: () => 'default slot',
foo: () => h('div', 'foo'),
bar: () => [h('span', 'one'), h('span', 'two')]
})等价 JSX 语法:
// 默认插槽
<MyComponent>{() => 'hello'}</MyComponent>
// 具名插槽
<MyComponent>{{
default: () => 'default slot',
foo: () => <div>foo</div>,
bar: () => [<span>one</span>, <span>two</span>]
}}</MyComponent>插槽以函数的形式传递使得它们可以被子组件懒调用。这能确保它被注册为子组件的依赖关系,而不是父组件。这使得更新更加准确及有效。
作用域插槽
为了在父组件中渲染作用域插槽,需要给子组件传递一个插槽。注意该插槽现在拥有一个 text 参数。该插槽将在子组件中被调用,同时子组件中的数据将向上传递给父组件
// 父组件
export default {
setup() {
return () => h(MyComp, null, {
default: ({ text }) => h('p', text)
})
}
}记得传递 null 以避免插槽被误认为 prop:
// 子组件
export default {
setup(props, { slots }) {
const text = ref('hi')
return () => h('div', null, slots.default({ text: text.value }))
}
}等同于 JSX:
<MyComponent>{{
default: ({ text }) => <p>{ text }</p>
}}</MyComponent>tabs组件原理
使用案例
<template>
<div class="play-container">
<el-tabs v-model="activeName" class="demo-tabs" @tab-click="handleClick">
<el-tab-pane label="User" name="first">User</el-tab-pane>
<el-tab-pane label="Config" name="second">Config</el-tab-pane>
<el-tab-pane label="Role" name="third">Role</el-tab-pane>
<el-tab-pane label="Task" name="fourth">Task</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { TabsPaneContext } from 'element-plus'
const activeName = ref('first')
const handleClick = (tab: TabsPaneContext, event: Event) => {
console.log(tab, event)
}
</script>el-tabs作为父组件来管理整个tabs组件
在setup钩子函数中初始化 children数组来收集el-tab-pane组件,初始化注册el-tab-pane组件的registerPane方法,初始化当前激活的modelValue,使用provide往子组件注入相关属性和方法
TIP
提供的响应式状态使后代组件可以由此和提供者建立响应式的联系。当提供注入响应式的数据时,建议尽可能将任何对响应式状态的变更都保持在供给方组件中。这样可以确保所提供状态的声明和变更操作都内聚在同一个组件内,使其更容易维护。
provide(tabsRootContextKey, {
props,// 组件属性
currentName,//当前激活的modelValue
registerPane,//注册el-tab-pane组件方法
unregisterPane,//删除el-tab-pane组件方法
nav$,// TabNav组件实例
})Details
深度解析useOrderedChildren
在Tab组件中,虽然每个 tab-pane 在其 setup 函数中直接调用了 tabsRoot.registerPane(pane) ,但仍然需要排序的原因如下:
组件复用和重新排序 :
在复杂的应用场景中, tab-pane 可能会被动态添加、删除或重新排序,排序机制确保了即使在这些情况下,标签页仍然能够保持正确的显示顺序。
addChild
源码如下:
const addChild = (child: T) => {
children.value[child.uid] = child
triggerRef(children)
onMounted(() => {
const childNode = child.getVnode().el! as Node
const parentNode = childNode.parentNode!
if (!nodesMap.has(parentNode)) {
nodesMap.set(parentNode, [])
const originalFn = parentNode.insertBefore.bind(parentNode)
parentNode.insertBefore = <T extends Node>(
node: T,
anchor: Node | null,
) => {
// Schedule a job to update `orderedChildren` if the root element of child components is moved
const shouldSortChildren = nodesMap
.get(parentNode)!
.some((el) => node === el || anchor === el)
if (shouldSortChildren) triggerRef(children)
return originalFn(node, anchor)
}
}
nodesMap.get(parentNode)!.push(childNode)
})
}- 每添加一个tabpane组件都会设置children对象,child.uid为key,child为value,同时因为children是浅响应式,需要手动触发更新,
- 在onMounted钩子函数中拦截tabpane组件动态排序,应为在vue diff算法中如果是组件位置变化是通过InsertBefore来操作DOM的,所以通过拦截该API来监听tabpane组件是否有移动
- 如果有的话,则强制刷新tab-nav组件
sortChildren
源码如下:
export const flattedChildren = (
children: FlattenVNodes | VNode | VNodeNormalizedChildren,
): FlattenVNodes => {
const vNodes = isArray(children) ? children : [ children ]
const result: FlattenVNodes = []
vNodes.forEach((child) => {
if (isArray(child)) {
result.push(...flattedChildren(child))
} else if (isVNode(child) && child.component?.subTree) {
result.push(child, ...flattedChildren(child.component.subTree))
} else if (isVNode(child) && isArray(child.children)) {
result.push(...flattedChildren(child.children))
} else if (isVNode(child) && child.shapeFlag === 2) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
result.push(...flattedChildren(child.type()))
} else {
result.push(child)
}
})
return result
}其核心作用是深度优先遍历,递归展开嵌套的VNode(虚拟节点)结构,生成一维的VNode数组 ,便于后续统一处理子节点。
遍历递归处理每个子节点 :
- 情况1:子节点是数组
- 递归调用 flattedChildren 展开,将结果合并到 result
- 情况2:子节点是VNode且包含子组件的子树 ( child.component?.subTree )该情况表示这是个组件vnode
- 将当前VNode本身加入结果,并递归展开其子组件的子树( child.component.subTree )
- 情况3:子节点是VNode且其子节点是数组 (如普通元素的 children 数组)该情况表示这是个普通元素vnode
- 递归展开子节点数组,合并到 result
- 情况4:子节点是VNode且是函数式组件 ( child.shapeFlag === 2 ,Vue中 ShapeFlags.FUNCTIONAL_COMPONENT 的枚举值为2)
- 调用函数式组件的类型( child.type() )获取其返回的VNode,再递归展开
- 其他情况 (如文本节点、注释节点等基础VNode)
- 直接将子节点加入结果
const nodes = flattedChildren(vm.subTree).filter(
(n): n is VNode =>
isVNode(n) &&
(n.type as any)?.name === childComponentName &&
!!n.component,
)
const uids = nodes.map((n) => n.component!.uid)
return uids.map((uid) => children[uid]).filter((p) => !!p)
}过滤出tabpane组件,按实际渲染出的tabpane组件顺序来排序orderedChildren
orderedChildren.value = getOrderedChildren(
vm,
childComponentName,
children.value,
)
const getOrderedChildren = <T>(
vm: ComponentInternalInstance,
childComponentName: string,
children: Record<number, T>,
): T[] => {排序组件
源码如下
const IsolatedRenderer = (props: { render: () => VNode[] }) => {
return props.render()
}
// TODO: Refactor `el-description` before converting this to a functional component
const ChildrenSorter = defineComponent({
setup (_, { slots }) {
return () => {
sortChildren()
return slots.default
? // Create a new `ReactiveEffect` to ensure `ChildrenSorter` doesn't track any extra dependencies
h(IsolatedRenderer, {
render: slots.default,
})
: null
}
},
})TIP
单独声明函数式组件 IsolatedRenderer 并通过它来渲染内容,主要目的是 创建一个独立的响应式作用域 ,避免 ChildrenSorter 组件意外追踪到不必要的依赖。
这是因为 Vue 的组件渲染过程会自动追踪响应式依赖,而 ChildrenSorter 的核心功能是在渲染前执行 sortChildren() 来排序子组件,它本身不应该依赖于其他响应式数据。通过 IsolatedRenderer 封装插槽的渲染逻辑,可以确保 ChildrenSorter 只关注排序逻辑,不被额外的响应式变化触发重渲染,从而提高性能并避免潜在的依赖追踪问题。
:::
如何管理el-tab-pane组件 和el-tab-nav组件
在el-tabs组件中先渲染el-tab-pane组件 ,等el-tab-pane组件注册完后再根据el-tab-pane组件动态生成el-tab-nav组件,所以一开始是el-tab-pane组件在前,el-tab-nav组件在后的,通过css order:-1把el-tab-nav组件显示在el-tab-pane组件之前。然后在el-tabs组件根元素的onVnodeMounted钩子函数去交换2个组件DOM元素位置
渲染el-tab-pane组件
const panels = (
<div class={ns.e('content')}>{renderSlot(slots, 'default')}</div>
)获取当前实例、获取插槽(插槽中可自定义el-tab-nav组件的label内容,然后让el-tabs组件动态渲染到el-nav组件的对应插槽内)
inject获取注入的父组件内容
初始化active计算属性表示当前el-tab-pane是否激活(通过判断currentName 和props.name属性是否相等)
通过active计算属性来初始化loaded属性表示是否加载
初始化shouldBeRender计算属性来判断组件是否应该被渲染(用于懒加载)
(通过!props.lazy || loaded.value || active.value来控制元素v-if)
初始化el-tab-pane组件信息
typescriptconst pane = reactive({ uid: instance.uid,// 组件唯一ID getVnode: () => instance.vnode,// 组件的vnode slots,// 插槽 props,// 组件属性 paneName,// props.name属性 active,//当前el-tab-pane是否激活 index, isClosable, isFocusInsidePane, })调用registerPane方法注册el-tab-pane组件
typescripttabsRoot.registerPane(pane)如果用户自定义label插槽的话,还要在组件更新的时候通知el-tab-nav组件更新
typescriptonBeforeUpdate(() => { if (slots.label) tabsRoot.nav$.value?.scheduleRender() })
渲染tab-nav组件
const tabNav = () => (
<TabNav
ref={nav$}
currentName={currentName.value}
editable={props.editable}
type={props.type}
panes={panes.value}
stretch={props.stretch}
onTabClick={handleTabClick}
onTabRemove={handleTabRemove}
/>
)
const header = (
<div
class={[
ns.e('header'),
isVertical.value && ns.e('header-vertical'),
ns.is(props.tabPosition),
]}
>
{createVNode(PanesSorter, null, {
default: tabNav,
$stable: true,
})}
{newButton}
</div>
)TIP
$stable的作用
表示该组件的子树在渲染过程中不会因数据变化而产生副作用(如动态子节点、事件监听等)。这允许 Vue 在虚拟 DOM 的 diff 算法中跳过该组件的深度遍历,提升渲染性能
- inject获取注入的父组件内容
- 通过遍历渲染的el-tab-pane组件动态渲染出tab-item元素
- 初始化tabRefsMap响应式变量,用来收集动态渲染出ab-item根元素,通过props传给el-tab-bar组件,让其可以根据元素大小设置动画效果
渲染el-tab-bar组件
- 通过遍历渲染el-tab-pane组件获取当前激活的tab-item元素,根据元素渲染的宽高和offsetLeft 来设置tab-bar位置和大小