Naive UI 组件库源码分析
介绍
2023 年 11 月 14 号开始研究这个组件库源码,目的是学习一些公共组件的写法(组件设计与实现)、TypeScript写法(基本写法与类型体操)、代码优化技巧等,期待学有所成吧
特点
- 超过 80+ 组件,组件丰富;
- 教程文档很全,啥配置都有说明;
- Naive UI 支持 tree shaking,组件、语言、主题均可 tree-shaking;
- 推荐周边使用的框架:一些推荐的宝藏 Admin 管理后台库(安排看起来):
资源 | 描述 |
---|---|
Naive UI Admin | 免费开源的中后台模板 |
Admin Work | 一款免费开源、功能强大、方便易用、漂亮的中后台管理系统模板 |
zce/fearless | 一个基于 Vue.js 3.x & TypeScript & Vite 的管理平台脚手架,包含基本的身份认证和鉴权 |
tsx-naive-admin | 基于 Vue 3 + Naive UI + TSX + Vite 的后台管理基本模板 |
Soybean Admin | 一个基于 Vue3 + Vite + Naive UI + TypeScript 的漂亮清爽的中后台模版 |
GoView | 开源、精美、便捷的「数据可视化」低代码开发平台 |
Vue Naive Admin | 基于 Vue3 + Vite4 + Pinia + Unocss + Naive UI 的轻量级后台管理模板 |
Celeris Web | 一个基于 Vue 3 和 Vite 的免费开源前端框架,具有 Naive UI 组件和 TypeScript 支持,采用 Monorepo 结构。它还集成了 OpenAI,为现代 Web 开发提供自然语言处理能力。 |
- 推荐免费的图标库:https://www.xicons.org/#/
感受
不好的地方:
- 部分函数钩子没有写注释;
- 组件 ref 实例没有写 TS 声明;
- computed 计算属性变量没有写 TS 声明;
- 模版标签行内样式比较多;
好处:
- 组件文件写法都是统一这种格式:
typescript
export default defineComponent({
name: 'SiteHeader',
...
return {
...
}
})
- css 样式写法很工整,没有嵌套写法,命令很规范;
组件设计与实现
- 一个组件的实现目录:
index.ts 导出文件为:组件的 props 属性对象、props 的类型、默认组件实例内容
typescript
// 比如
export { default as NAlert, alertProps } from './src/Alert'
export type { AlertProps } from './src/Alert'
// 具体值:
export const alertProps =
export type AlertProps =
export default defineComponent({})
- 组件传入属性引用类型声明(PropType,变量赋值属性,用于类型推导):
typescript
props: {
clsPrefix: {
type: String,
required: true
},
show: Boolean,
onClear: Function as PropType<(e: MouseEvent) => void>
},
- 直接解构使用对应的响应式 ref 变量:
typescript
const { value: container } = mergedContainerRef
- watchEffect 直接监听一个方法:
typescript
const mountStyle = (): void => {
let finalThemeHash = hashClassPrefix
const hashValue = hashRef ? hashRef.value : undefined
const themeHash = mergedThemeHashRef?.value
if (themeHash) finalThemeHash += '-' + themeHash
if (hashValue) finalThemeHash += '-' + hashValue
const { themeOverrides, builtinThemeOverrides } = props
if (themeOverrides) {
finalThemeHash += '-' + hash(JSON.stringify(themeOverrides))
}
if (builtinThemeOverrides) {
finalThemeHash += '-' + hash(JSON.stringify(builtinThemeOverrides))
}
themeClassRef.value = finalThemeHash
renderCallback = () => {
const cssVars = cssVarsRef.value
let style = ''
for (const key in cssVars) {
style += `${key}: ${cssVars[key]};`
}
c(`.${finalThemeHash}`, style).mount({
id: finalThemeHash,
ssr: ssrAdapter
})
renderCallback = undefined
}
}
watchEffect(() => {
mountStyle()
})
TSX 写法
注意:使用单括号解析动态参数值
基本用法
- 动态解析 class 写法:
typescript
<div class={`${clsPrefix}-base-clear`}>
// 多类写法
class={[
`${clsPrefix}-base-close`,
absolute && `${clsPrefix}-base-close--absolute`,
disabled && `${clsPrefix}-base-close--disabled`,
round && `${clsPrefix}-base-close--round`
]}
- 使用 default 函数表达式:
tsx
default: () => {
return this.show ? (
<div>...</div>
) : (
<div key="icon" class={`${clsPrefix}-base-clear__placeholder`}>
...
</div>
)
}
- setup 内,第一形参用不上的时候,声明使用下划线处理:
typescript
setup(_, { slot }) {
const isMountedRef = useIsMounted()
return () => (
<Transition name="icon-switch-transition" appear={isMountedRef.value}>
{ slot }
</Transition>
)
}
参考实现
tsx
render () {
const { clsPrefix } = this
return (
<div class={`${clsPrefix}-base-clear`}>
<NIconSwitchTransition>
{{
default: () => {
return this.show ? (
<div
key="dismiss"
class={`${clsPrefix}-base-clear__clear`}
onClick={this.onClear}
onMousedown={this.handleMouseDown}
data-clear
>
{resolveSlot(this.$slots.icon, () => [
<NBaseIcon clsPrefix={clsPrefix}>
{{
default: () => <ClearIcon />
}}
</NBaseIcon>
])}
</div>
) : (
<div key="icon" class={`${clsPrefix}-base-clear__placeholder`}>
{this.$slots.placeholder?.()}
</div>
)
}
}}
</NIconSwitchTransition>
</div>
)
}
- 图标组件:
typescript
import { h, defineComponent, type PropType, toRef } from 'vue'
import { useStyle } from '../../../_mixins'
import style from './styles/index.cssr'
export default defineComponent({
name: 'BaseIcon',
props: {
role: String,
ariaLabel: String,
ariaDisabled: {
type: Boolean,
default: undefined
},
ariaHidden: {
type: Boolean,
default: undefined
},
clsPrefix: {
type: String,
required: true
},
onClick: Function as PropType<(e: MouseEvent) => void>,
onMousedown: Function as PropType<(e: MouseEvent) => void>,
onMouseup: Function as PropType<(e: MouseEvent) => void>
},
setup (props) {
useStyle('-base-icon', style, toRef(props, 'clsPrefix'))
},
render () {
return (
<i
class={`${this.clsPrefix}-base-icon`}
onClick={this.onClick}
onMousedown={this.onMousedown}
onMouseup={this.onMouseup}
role={this.role}
aria-label={this.ariaLabel}
aria-hidden={this.ariaHidden}
aria-disabled={this.ariaDisabled}
>
{this.$slots}
</i>
)
}
})
- 最简单的一个标签渲染写法:
typescript
import { h, defineComponent } from 'vue'
export default defineComponent({
name: 'ArrowBack',
render () {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none"></path>
<path d="M19 11H7.83l4.88-4.88c.39-.39.39-1.03 0-1.42-.39-.39-1.02-.39-1.41 0l-6.59 6.59c-.39.39-.39 1.02 0 1.41l6.59 6.59c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L7.83 13H19c.55 0 1-.45 1-1s-.45-1-1-1z"></path>
</svg>
)
}
})
TypeScript 写法
- 导入所有 API 中,针对某个 API 类型声明导入处理:
typescript
import { h, defineComponent, type PropType, toRef } from 'vue'
- Vue 相关常用 TS 类型声明(注意前缀 type 关键词):
typescript
import type { PropType, CSSProperties, VNode, HTMLAttributes } from 'vue'
// 或者合并跟 Vue 所有属性写法,注意 type 关键词
import {
h,
ref,
onMounted,
computed,
defineComponent,
type PropType,
toRef,
provide,
nextTick,
type WatchStopHandle,
type CSSProperties,
watch,
onBeforeUnmount,
type ComponentPublicInstance,
type ComputedRef,
} from 'vue'
PropType 使用场景:用于直接赋值的变量内部声明 TS 方便类型推导:
typescript
const scrollbarProps = {
trigger: {
type: String as PropType<'none' | 'hover'>, // 方便类型推导
default: 'hover'
},
} as const
const Scrollbar = defineComponent({
name: 'Scrollbar',
props: scrollbarProps, // 直接赋值给变量
- 通过入参类型推断具体类型:
typescript
export function formatLength<
T extends number | string | null | undefined | any
> (
length: T,
{ c = 1, offset = 0, attachPx = true }: FormatLengthOptions = {}
): T extends null
? null
: T extends undefined
? undefined
: T extends string | number
? string
: T {
...
}
- 返回值类型设置运用 is:
typescript
export function isDocument (node: Node): node is Document {
return node.nodeName === '#document'
}
- 从对象映射获取指定类型的数据写法:
typescript
interface SmallerSizeMap {
tiny: 'mini'
small: 'tiny'
medium: 'small'
large: 'medium'
huge: 'large'
}
type SmallerSize<T extends keyof SmallerSizeMap> = SmallerSizeMap[T]
export function smallerSize<T extends keyof SmallerSizeMap>(
size: T
): SmallerSize<T> {
switch(size){
case 'tiny':
return 'mini' as any
case 'small':
return 'tiny' as any
case 'medium':
return 'small' as any
case 'large':
return 'medium' as any
case 'huge':
return 'large' as any
}
throw Error(`${size} has no smaller size.`)
}
- 获取对象对应 key 值的类型写法:
typescript
export function keysOf<T extends Record<string, unknown>>(obj: T): Array<keyof obj> {
return Object.keys(obj) as any
}
- 结合模板字符串使用动态的 TS 类型 key 值:
typescript
Extract<keyof T, `internal${string}`>
- 常量只读属性的设置:
typescript
export const anchorLinkProps = {
title: String,
href: String
} as const
代码优化技巧
- 函数点击事件命名:handleXXX
- findIndex 命中索引判断:
typescript
const index = collectionArray.findIndex(
(collectionElement) => collectionElement === element
)
if (~index) collectionArray.splice(index, 1)
find 遍历代码优化技巧:巧用解构对象形式判断值
typescript
const showDir = vNode.dirs?.find(({ dir }) => dir === vShow)
- ts/js 文件中,导出的变量或函数放在文件最底部
- 返回对象使用原来的对象变量整合 Object.assign 即可,不会产生新的变量占用空间
- 没太看懂这两行代码的作用:
公共服务
工具或钩子
- 封装 Composables 组合式函数写法:
typescript
/** 注释说明 */
export funciton useIsMobile () {
const breakpointRef = useBreakpoint()
return useMemo(() => {
return breakpointRef.value === 'xs'
})
}
相关学习资料:
使用方法讲解:
- 工具钩子函数 index.ts 直接将导入的函数钩子再导出写法(注意 export):
typescript
export {
useInjectionCollection,
useInjectionElementCollection,
useInjectionInstanceCollection
} from './use-collection'
export { useDeferredTrue } from './use-deferred-true'
export { useAdjustedTo } from './use-adjusted-to'
export { useHoudini } from './use-houdini'
export { useOnResize } from './use-resize'
export {
useLockHtmlScroll,
lockHtmlScrollRightCompensationRef
} from './use-lock-html-scroll'
export { useIsComposing } from './use-is-composing'
export { useReactivated } from './use-reactivated'
有趣的 npm 插件
- 帮助 Vue3 写组合式函数的包:
vooks 官方教程文档:https://vooks-07akioni.vercel.app/
- 自动引入 API:
- 自动加载组件:插件会自动解析模版中用到组件,并导入组件
其他
- 使用默认导出 naive 值和其他导出 NThemeEditor 值写法
typescript
import naive, { NThemeEditor } from '../src/index'
- 当一个 ts 文件只有一个变量值时,导出也只需要一个默认变量值即可,写法:
typescript
const zhCN: NLocale = {
...
}
export default zhCN
导入的时候,想重新定义一个变量接收写法:
typescript
export { default as zhCN } from '...'
- 导入 CSS 资源文件写法,默认导入整个文件所有内容:
typescript
import './styles/demo.css'
- 函数依赖注入写法(自给自足):
typescript
export const i18n = function (data) {
const localeReactive = inject('i18n', null)
return {
locale: toRef(localeReactive, 'locale'),
t (key) {
const { locale } = localeReactive
return data[locale][key]
}
}
}
i18n.provide = function (localeRef) {
const localeReactive = reactive({})
watchEffect(() => {
localeReactive.locale = localeRef.value
})
provide('i18n', localeReactive)
}
官方文档: 组合式 API:依赖注入 | Vue.js
- 路由文件设置,国际化设置
typescript
{
name: 'enComponents',
path: '/en-US/:theme/components',
component: () => import('../pages/Layout.vue'),
children: enComponentRoutes
},
{
name: 'zhComponents',
path: '/zh-CN/:theme/components',
component: () => import('../pages/Layout.vue'),
children: zhComponentRoutes
},
{
name: 'not-found',
path: '/:pathMatch(.*)*',
redirect: {
name: 'home',
params: {
lang: navigator.language === 'zh-CN' ? 'zh-CN' : 'en-US',
theme: 'os-theme'
}
}
}
- computed get/set 重写监听对象发生变化修改另一个值的写法:
typescript
// display mode
const _displayModeRef = ref(window.localStorage.getItem('mode') ?? 'debug')
const displayModeRef = computed({
get () {
return _displayModeRef.value
},
set (value) {
_displayModeRef.value = value
// 同时修改某个值
window.localStorage.setItem('mode', value)
}
})