Appearance
ljf-ui 组件库实战总结
基于
vue-pnpm项目,pnpm monorepo 四包协作:ljf-ui(Vant 4.x 二次封装)、ljf-hooks(组合式 API)、ljf-utils(工具函数)、ljf-hooks-docs(VitePress 文档站)。
一、Monorepo 架构与包间依赖
vue-pnpm/
├── packages/
│ ├── ui/ ← ljf-ui(vant-cli 构建,组件库主体)
│ ├── hooks/ ← ljf-hooks(Vite 库模式,preserveModules)
│ ├── utils/ ← ljf-utils(Vite 库模式,preserveModules)
│ └── docs/ ← VitePress 文档(自定义 :::demo 插件)
└── pnpm-workspace.yaml → packages/**依赖链:ljf-ui → ljf-hooks(workspace:^)→ ljf-utils(workspace:^)
构建策略差异:
- ui 包:使用
@vant/cli构建(Vant 生态专属工具链),支持组件级文档站点 - hooks/utils 包:使用 Vite 库模式 +
preserveModules: true,每个 hook/util 独立文件,支持按需引入
hooks/utils 的打包配置(my-config.ts):
js
// 同时输出 ES/CJS 两种格式,每个模块独立文件
output: [
{ format: 'es', dir: './es', preserveModules: true, preserveModulesRoot: './' },
{ format: 'cjs', dir: './lib', preserveModules: true, preserveModulesRoot: './' }
]
// vite-plugin-dts 自动生成类型声明到 types/ 目录
plugins: [dts({ outDir: ['types'], staticImport: true })]二、withInstall — 组件注册工具
为组件添加 install 方法,支持 app.use(Component) 全局注册:
js
export const withInstall = (main, extra) => {
main.install = (app) => {
for (const comp of [main, ...Object.values(extra ?? {})]) {
app.component(comp.name, comp)
}
}
if (extra) {
for (const [key, comp] of Object.entries(extra)) {
main[key] = comp // 附加子组件为属性
}
}
return main
}扩展版 withInstallFunction:将函数注册为全局属性(如 $message):
js
export const withInstallFunction = (fn, name) => {
fn.install = (app) => {
fn._context = app._context // 保存应用上下文
app.config.globalProperties[name] = fn
}
return fn
}三、createNamespace — BEM 命名空间
封装 BEM 命名规则,统一组件类名生成:
js
export function createNamespace(name) {
const namespace = `van-${name}`
const createBEM = (suffix) => {
if (!suffix) return namespace
return suffix.startsWith('--')
? `${namespace}${suffix}` // 修饰符:van-button--primary
: `${namespace}__${suffix}` // 元素:van-button__icon
}
const classes = (...classes) => {
return classes.map(className => {
if (Array.isArray(className)) {
const [condition, truthy, falsy = null] = className
return condition ? truthy : falsy
}
return className
})
}
return { n: createBEM, classes }
}使用示例:
js
const { n, classes } = createNamespace('button')
n() // 'van-button'
n('icon') // 'van-button__icon'
n('--primary') // 'van-button--primary'
classes([isActive, 'active'], 'normal') // 条件类名四、useFontScale — 字体大小动态缩放
场景:移动端需要支持用户切换字体大小(默认/中/大),所有组件联动响应。
核心设计:模块级单例 ref + computed class 名 + CSS 变量覆盖:
js
const instanceRef = ref('default') // 模块级单例,所有组件共享
export default function useFontScale(options = {}) {
const { name, watch: optionsWatch, activated } = options
const fontClass = computed(() => name + '__' + instanceRef.value)
function setFontScale(fontScale) {
instanceRef.value = fontScale // 修改单例,所有组件自动响应
}
return { fontClass, fonstScale: ref(instanceRef.value), setFontScale }
}CSS 联动(组件内通过 class 覆盖 CSS 变量):
scss
.btn.ljf-button-scale__medium {
--ljf-mini-height: 32px;
--ljf-large-height: 48px;
font-size: 17px;
}
.btn.ljf-button-scale__large {
--ljf-mini-height: 36px;
--ljf-large-height: 58px;
font-size: 20px;
}技术要点:
- 模块级
ref实现跨组件状态共享(无需 Vuex/Pinia) - 通过 CSS class 切换覆盖 CSS 变量,避免逐属性计算
onActivated支持 keep-alive 场景
五、useCssVar — CSS 变量运行时操控
场景:组件需要在运行时动态修改 CSS 变量值(如主题切换),并在组件卸载时自动还原。
js
export default function useCssVar(config = { gloal: false, reset: false }) {
const { gloal, reset } = config
// 全局作用域 → :root,组件作用域 → getCurrentInstance()
const root = gloal ? document.querySelector(':root') : getCurrentInstance()
const styleList = {} // 记录原始值,用于还原
const setCssVar = (key, value) => {
if (!styleList[key]) styleList[key] = getCssVar(key) // 首次保存原始值
root.style.setProperty(key, value)
}
const getCssVar = (key) => rootStyle.getPropertyValue(key)
// 组件卸载时自动还原
onBeforeUnmount(() => {
if (reset) {
for (const key in styleList) {
root.style.setProperty(key, styleList[key])
}
}
})
return { setCssVar, getCssVar }
}技术要点:
getCurrentInstance().vnode.el获取组件根 DOM 元素- 首次 set 时自动保存原始值,
onBeforeUnmount批量还原 - 支持全局(
:root)和组件级两种作用域
六、useLinkage — 无限层级级联选择
场景:省市区选择、分类层级选择等需要动态 N 级联动。
核心设计:defineComponent + h() 动态生成每级选择组件:
js
export const useLinkage = (dom, list = [], options = {}) => {
const { levels = 3, isInfinite = false, maxLevels = 99 } = options
const activeList = reactive([]) // 每级选中项的 id
const componentList = [] // 动态组件列表
const computedList = [] // 每级的可选项(computed)
// 递归生成每级选择组件
const linkageSelect = (level) => defineComponent({
setup(props, context) {
computedList[level] = linkageComputed(computedList[level - 1], level)
return () => h(dom, {
list: computedList[level - 1].value,
activeList,
'onUpdate:modelValue': (e) => onLinkageChange(e, level)
})
}
})
// 联动计算:上级选中项的 children 作为下级的选项
const linkageComputed = (list, level) => computed(() => {
return list.value.find(t => t.id === activeList[level])?.children || []
})
// 无限模式:选中项有 children 时自动追加新组件
if (isInfinite && e?.children) {
componentListRef.value.push(markRaw(linkageSelect(newLevel)))
}
}技术要点:
markRaw避免响应式代理组件定义对象- 固定层级返回
{ Linkage1, Linkage2, Linkage3 },无限层级返回{ Linkage, componentListRef } - 级联重置:
reset()清空所有 activeList;init([id1, id2, id3])逐层初始化
七、防抖/节流高级实现
createFilterWrapper — 事件过滤器模式
将防抖/节流逻辑抽象为策略模式,调用者与策略解耦:
js
// 包装器:接收一个 filter 策略和目标函数
export function createFilterWrapper(filter, fn) {
return function (...args) {
return new Promise((resolve, reject) => {
Promise.resolve(
filter(() => fn.apply(this, args), { fn, thisArg: this, args })
).then(resolve).catch(reject)
})
}
}debounceFilter — 支持 maxWait
普通防抖可能导致无限延迟(持续触发永远不执行),maxWait 保证最大等待时间:
js
export function debounceFilter(ms, options = {}) {
let timer, maxTimer
const filter = (invoke) => {
if (timer) clearTimeout(timer)
// maxWait:到时间强制执行,清除普通 timer
if (maxDuration && !maxTimer) {
maxTimer = setTimeout(() => { clearTimeout(timer); resolve(invoke()) }, maxDuration)
}
// 普通 delay:到时间执行,清除 maxTimer
timer = setTimeout(() => { clearTimeout(maxTimer); resolve(invoke()) }, duration)
}
return filter
}双定时器互斥:普通 timer 触发时清除 maxTimer,maxTimer 触发时清除普通 timer。
throttleFilter — leading/trailing 策略
js
// 支持对象参数和位置参数两种调用方式
export function throttleFilter(options) { /* ... */ }
// throttleFilter({ delay: 300, trailing: true, leading: true })
// throttleFilter(300, true, true)时间戳 + 定时器混合:
leading:首次立即执行(时间戳判断elapsed > duration)trailing:最后一次延迟执行(setTimeout 补偿)
八、增强 toRefs — 支持 ref 替换
Vue 原生 toRefs 对 ref 对象无效。增强版使用 customRef 实现:
js
export function toRefs(objectRef, options = {}) {
if (!isRef(objectRef)) return _toRefs(objectRef)
const result = {}
for (const key in objectRef.value) {
result[key] = customRef(() => ({
get() { return objectRef.value[key] },
set(v) {
if (replaceRef) {
// 替换整个 ref 值(触发响应式更新)
const newObj = { ...objectRef.value, [key]: v }
Object.setPrototypeOf(newObj, Object.getPrototypeOf(objectRef.value))
objectRef.value = newObj
} else {
objectRef.value[key] = v // 直接修改属性
}
}
}))
}
return result
}技术要点:Object.setPrototypeOf 保持原型链(如 reactive 的 Proxy handler 不丢失)。
九、:::demo Markdown 容器插件
VitePress 文档中支持 :::demo 语法自动加载 Vue 组件示例:
Markdown 写法:
markdown
:::demo 按钮的基础用法
useDebounceFn/basic
:::插件解析流程:
markdown-it-container匹配:::demo块- 读取
packages/hooks/useDebounceFn/basic.vue源码 Prism.js动态加载语言包进行代码高亮- 传递给
<Demo>组件渲染(源码展示 + 实时预览)
js
// plugins.ts 核心逻辑
const source = fs.readFileSync(path.resolve('../../hooks/', `${sourceFile}.vue`), 'utf-8')
return `<Demo source="${encodeURIComponent(highlight(source, 'vue'))}" path="${sourceFile}">`文档时间戳自动更新:
js
// VitePress 自定义 Vite 插件,监听 demo 文件变更
configureServer(server) {
server.watcher.add(sourceFilePath)
server.watcher.on('change', async (filePath) => {
if (filePath.indexOf('demo') > -1) {
// 正则替换 index.md 中的更新时间
const newData = mdContent.replace(regex, `$1${currentTime}$3`)
await fs.promises.writeFile(mdFilePath, newData)
}
})
}十、PostCSS 移动端适配(px → vw)
按文件路径区分视口宽度,Vant 组件和自定义组件使用不同的基准:
js
postcsspxtoviewport8plugin({
viewportWidth: (file) => {
if (file.indexOf('vant') > 0) return 375 // Vant 基于 375px
return 375 // 自定义组件也可配置不同基准
},
unitPrecision: 5,
propList: ['*'],
mediaQuery: true,
exclude: [/node_modules\/@vant\/cli/]
})相关
- 组件库与 npm 包 — CSS 工程化、vue-loader、按需引入、npm 发布
- 微前端与 Monorepo — pnpm workspace 架构设计
- 组件设计 — Vue 组件设计模式