Skip to content

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-uiljf-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 原生 toRefsref 对象无效。增强版使用 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
:::

插件解析流程

  1. markdown-it-container 匹配 :::demo
  2. 读取 packages/hooks/useDebounceFn/basic.vue 源码
  3. Prism.js 动态加载语言包进行代码高亮
  4. 传递给 <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/]
})

相关