阅读视图

发现新文章,点击刷新页面。

Dart - 认识Records

第一章:没有 Records 的日子,我们经历了什么? 作为一名 Dart/Flutter 开发者,你一定经历过这样的“至暗时刻”: 你写了一个简单的工具函数,比如解析经纬度,或者从 API 响应中提

[Nuxt 4 实战] 躺着也能拿流量:Sitemap、Robots 与结构化数据的全自动 SEO 指南

前言

做独立开发,最怕的是“自我感动”——辛辛苦苦开发上线,结果只有自己访问。

对于 SonicToolLab 这种工具站,SEO(搜索引擎优化)是生存的根本。我们需要让 Google 知道我们有哪些工具,并且在搜索结果中展示得“漂亮”。

如果你还在手动写 sitemap.xml 或者手搓 JSON-LD 结构化数据,那就太 Out 了。Nuxt 4 配合官方的 SEO 模块,可以把这些枯燥的工作变成全自动化

📦 1. 一站式解决方案:Nuxt SEO Kit

以前我们需要安装 nuxt-simple-sitemapnuxt-simple-robotsnuxt-schema-org 等一堆插件。现在,官方推出了一个“全家桶”:@nuxtjs/seo

它不仅整合了上述所有功能,还提供了 best-practice 的默认配置。

安装

npx nuxi module add seo

配置 nuxt.config.ts

你只需要配置站点的基本信息,其他的模块会自动读取:

export default defineNuxtConfig({
  modules: ['@nuxtjs/seo'],
  
  site: {
    url: '[https://sonictoollab.dpdns.org](https://sonictoollab.dpdns.org)', // 你的线上域名
    name: 'SonicToolLab',
    description: '开发者首选的免费在线工具箱',
    defaultLocale: 'zh-CN', // 默认语言
  },

  // 可选:针对子模块的细粒度配置
  sitemap: {
    // 比如排除某些测试页面
    exclude: ['/test/**', '/admin/**']
  }
})

就这一步,你已经拥有了自动生成的 /sitemap.xml/robots.txt

🗺️ 2. Sitemap 的自动化与动态路由陷阱

启动项目后,访问 http://localhost:3000/sitemap.xml,你会发现所有静态页面(pages 目录下的文件)都已经躺在里面了。

但在 Nuxt 中,动态路由(Dynamic Routes)是个坑。 假如你有 /tools/[name].vue,Sitemap 模块默认可能不知道 [name] 具体有哪些值(json, base64, image-compress...),导致这些关键页面没被收录。

解决方案:手动喂数据

我们需要在配置里明确告诉 Sitemap 有哪些动态页面:

// nuxt.config.ts
export default defineNuxtConfig({
  sitemap: {
    sources: [
      // 假如你的工具列表是存放在 API 或当个文件里的
      '/api/sitemap-urls' 
    ],
    // 或者直接硬编码(适合工具数量不多的情况)
    urls: [
      '/tools/json-formatter',
      '/tools/image-to-base64',
      '/tools/qrcode-generator'
    ]
  }
})

这样,Google 爬虫就能顺着 Sitemap 爬取到每一个具体的工具页。

🤖 3. 结构化数据 (Schema.org) 的魔力

你有没有见过 Google 搜索结果里,有些网站带有星级评分软件价格或者问答列表?这就是富文本搜索结果 (Rich Snippets)

对于工具站,我们必须告诉搜索引擎:“我这是一个 SoftwareApplication”。

在 Nuxt 中,使用 useSchemaOrg 组合式函数即可轻松实现:

<script setup lang="ts">
useSchemaOrg([
  defineWebSite({
    name: 'SonicToolLab',
  }),
  defineSoftwareApplication({
    name: 'SonicToolLab',
    applicationCategory: 'DeveloperApplication',
    operatingSystem: 'Web',
    offers: {
      price: '0',
      priceCurrency: 'CNY',
    },
    aggregateRating: {
      ratingValue: '4.9',
      ratingCount: '88',
    },
  })
])
</script>

加上这段代码后,你的网站在 Google 眼里就不再是一堆 HTML,而是一个“免费、评分 4.9 的开发者应用”,这能大幅提升点击率(CTR)。

🖼️ 4. 社交分享卡片 (OG Image)

当用户把你的链接分享到 Twitter、Discord 或者微信时,如果没有一张漂亮的预览图,点击率会大打折扣。

@nuxtjs/seo 内置了 OG Image 生成功能。它甚至可以在服务端动态绘制图片(类似 Canvas),把当前页面的标题画在图片上。

<script setup>
defineOgImageComponent('NuxtSeo', {
  title: '在线 JSON 格式化工具',
  description: '免费、快速、支持深色模式',
  theme: '#00dc82', // Nuxt 绿
  colorMode: 'dark',
})
</script>

当你部署后,每个页面都会自动生成一张独一无二的分享卡片。

总结

做 SEO 就像种树,最好的时间是十年前,其次是现在

通过引入 @nuxtjs/seo,我们在 SonicToolLab 中实现了:

  1. 自动化的 Sitemap(确保收录)
  2. 标准的 Robots.txt(引导爬虫)
  3. 语义化的 Schema(提升展示效果)
  4. 动态的 OG Image(提升社交分享点击)

这一套组合拳打下来,基本上涵盖了技术 SEO 的 90%。剩下的,就是在这个架子上填充优质的内容了。

👉 SonicToolLab 在线体验

Vue 3 路由守卫中安全使用 Composition API 的最佳实践

日期: 2026-02-03
标签: Vue 3, Composition API, Router Guards, 架构设计, 最佳实践

📋 目录


背景与问题

业务场景

在企业级 SaaS 应用中,我们需要在用户登录后进行多种认证检查:

  1. 用户类型认证:个人用户需激活、企业用户需完成认证
  2. 密码过期检查:强制用户定期更新密码
  3. 权限验证:不同用户类型访问不同功能模块

最初的实现采用了"认证服务中心"(VerificationCenter)的设计模式,通过规则引擎统一管理所有认证逻辑。

遇到的问题

// ❌ 错误示例:在路由守卫中直接调用 Composition API
router.afterEach((to, from) => {
  const { checkAndShowAuthDialog } = useUserTypeAuth()
  checkAndShowAuthDialog()
})

报错信息

SyntaxError: Must be called at the top of a `setup` function
at useI18n (vue-i18n.js:314:17)
at useConfirm (useConfirm.ts:165:17)
at useUserTypeAuth (useUserTypeAuth.ts:72:23)

问题根源

Vue 3 的 Composition API(如 useI18nuseRouteruseRoute必须在 Vue 组件的 setup 函数顶层调用,而路由守卫运行在 Vue 组件上下文之外,直接调用会触发运行时错误。


核心问题分析

1. Composition API 的上下文限制

// Vue 3 内部实现(简化版)
let currentInstance: ComponentInternalInstance | null = null

export function getCurrentInstance(): ComponentInternalInstance | null {
  return currentInstance
}

export function useRouter(): Router {
  const instance = getCurrentInstance()
  if (!instance) {
    throw new Error('Must be called at the top of a setup function')
  }
  return instance.appContext.config.globalProperties.$router
}

关键点

  • Composition API 依赖 currentInstance 获取 Vue 实例上下文
  • 路由守卫执行时 currentInstancenull
  • 直接调用会抛出异常

2. 架构过度设计的反思

原有的 VerificationCenter 架构:

// 规则引擎模式
interface VerificationRule {
  id: string
  when: ('login' | 'appReady' | 'routeChange')[]
  shouldRun: (ctx: VerificationContext) => Promise<boolean>
  run: (ctx: VerificationContext) => Promise<void>
}

class VerificationCenter {
  private rules: Map<string, VerificationRule> = new Map()
  
  register(rule: VerificationRule) {
    this.rules.set(rule.id, rule)
  }
  
  async run(trigger: string) {
    for (const rule of this.rules.values()) {
      if (rule.when.includes(trigger)) {
        if (await rule.shouldRun(ctx)) {
          await rule.run(ctx)
        }
      }
    }
  }
}

问题分析

  • 优点:高度抽象、易扩展、规则解耦
  • 缺点
    • 只有 3 个规则,不需要如此复杂的架构
    • 增加认知负担,新人难以理解
    • 调用链路长,调试困难
    • 性能开销(函数调用、对象创建)
    • 违反 YAGNI 原则(You Aren't Gonna Need It)

解决方案设计

设计原则

  1. 简单性优于灵活性:当前需求简单,不需要过度设计
  2. SOLID 原则:单一职责、开闭原则
  3. 上下文隔离:路由守卫专用函数不依赖 Vue 上下文

架构对比

方案一:直接调用(✅ 采用)

// 路由守卫中直接调用
router.afterEach((to) => {
  checkAuthInRouterGuard()
  checkPasswordExpiredInRouterGuard(router)
})

优点

  • 代码简洁直观
  • 调用链路清晰
  • 易于调试和维护
  • 性能最优

方案二:保留规则引擎(❌ 放弃)

缺点

  • 过度设计,增加复杂度
  • 不符合当前业务规模
  • 维护成本高

技术方案

核心思路:为路由守卫创建独立的、不依赖 Composition API 的函数。

// 设计模式:Adapter Pattern(适配器模式)
// 将依赖 Composition API 的逻辑适配为独立函数

// 1. 组件内使用(依赖 Composition API)
export function useUserTypeAuth() {
  const router = useRouter()  // ✅ 在 setup 中调用
  const { t } = useI18n()      // ✅ 在 setup 中调用
  // ...
}

// 2. 路由守卫使用(不依赖 Composition API)
export function checkAuthInRouterGuard(): void {
  // ✅ 直接从 store 获取数据,不依赖 Vue 上下文
  const userType = getUserTypeFromStore()
  const user = getUserFromStore()
  
  // ✅ 使用安全的 i18n 包装器
  const t = getSafeI18n()
  
  // ✅ 动态导入 router 实例
  const handleAction = async () => {
    const { default: router } = await import('~/router')
    await router.push('/profile')
  }
}

代码实现

1. 安全的 i18n 包装器

// src/composables/ui/useConfirm.ts

/**
 * 安全获取 i18n 翻译函数
 * @description 尝试调用 useI18n(),失败则返回 fallback 翻译
 */
function getSafeI18n(): (key: string) => string {
  try {
    const { t } = useI18n()
    return t
  } catch {
    // 路由守卫等非 Vue 组件上下文中使用 fallback
    return (key: string) => {
      const fallbacks: Record<string, string> = {
        'common.confirmTitle': '提示',
        'common.ok': '确定',
        'common.cancel': '取消',
        'common.closeWindow': '关闭窗口',
        'ui.confirm.cancelTask': '取消任务',
        'ui.confirm.continueOperation': '继续操作',
      }
      return fallbacks[key] || key
    }
  }
}

export function useConfirm() {
  const t = getSafeI18n() // ✅ 安全调用
  
  const confirm = (message: string, options?: ConfirmOptions) => {
    return ElMessageBox.confirm(message, {
      title: options?.title || t('common.confirmTitle'),
      confirmButtonText: options?.okBtnText || t('common.ok'),
      cancelButtonText: t('common.cancel'),
      type: options?.type || 'info',
      // ...
    })
  }
  
  return { confirm, alert }
}

设计亮点

  • 优雅降级:有 Vue 上下文时使用 useI18n(),否则使用 fallback
  • 零侵入:不影响现有组件的使用方式
  • 类型安全:保持完整的 TypeScript 类型推导

2. 用户认证检查(路由守卫专用)

// src/composables/auth/useUserTypeAuth.ts

/**
 * 路由守卫中检查用户认证状态
 * @description 不依赖 Composition API,可在路由守卫中安全调用
 */
export function checkAuthInRouterGuard(): void {
  // 1. 从 store 获取数据(不依赖 Vue 上下文)
  const userType = getUserTypeFromStore()
  
  if (!needsAuthPrompt(userType)) {
    return
  }
  
  const user = getUserFromStore()
  if (!user) {
    return
  }
  
  // 2. 使用安全的 confirm(内部使用 getSafeI18n)
  const { confirm } = useConfirm()
  const t = getSafeI18n()
  
  // 3. 获取提示配置
  const config = getAuthPromptMessage(userType, user, t)
  
  if (!config.content) {
    return
  }
  
  // 4. 显示确认对话框
  void (async () => {
    try {
      if (config.showConfirmBtn) {
        await confirm(config.content, {
          title: config.title,
          type: 'warning',
          buttons: [
            {
              text: config.confirmText,
              type: 'primary',
              customClass: 'customer-button-default customer-primary-button customer-button',
              onClick: async () => {
                // ✅ 动态导入 router,避免循环依赖
                const { default: router } = await import('~/router')
                await executeAuthActionForService(userType, user, router)
              },
            },
            {
              text: config.cancelText,
              type: 'default',
              customClass: 'trans-bg-btn',
            },
          ],
        })
      } else {
        // 企业认证审核中:仅显示提示,使用文字按钮
        await confirm(config.content, {
          title: config.title,
          type: 'warning',
          buttons: [
            {
              text: config.confirmText,
              type: 'primary',
              link: true, // ✅ 文字按钮样式
            },
          ],
        })
      }
    } catch {
      // 用户取消操作
    }
  })()
}

// ==================== 辅助函数 ====================

/**
 * 从 Store 获取用户类型
 */
function getUserTypeFromStore(): number {
  const userStore = useUserStoreWithOut()
  return userStore.userInfo?.userType ?? 0
}

/**
 * 从 Store 获取用户信息
 */
function getUserFromStore(): any {
  const userStore = useUserStoreWithOut()
  return userStore.userInfo
}

/**
 * 判断是否需要显示认证提示
 */
function needsAuthPrompt(userType: number): boolean {
  const NEEDS_AUTH_PROMPT_USER_TYPES = [1, 2, 3, 4]
  return NEEDS_AUTH_PROMPT_USER_TYPES.includes(userType)
}

/**
 * 获取认证提示消息配置
 */
function getAuthPromptMessage(
  userType: number,
  user: any,
  t: (key: string) => string,
): AuthPromptConfig {
  // 个人用户:未激活
  if (userType === 1 && user.userStatus === 0) {
    return {
      title: t('register.personalActivation'),
      content: t('register.personalActivationTip'),
      confirmText: t('register.goActivate'),
      cancelText: t('common.cancel'),
      showConfirmBtn: true,
    }
  }
  
  // 企业用户:认证审核中
  if ([2, 3, 4].includes(userType) && user.verificationStatus === 1) {
    return {
      title: t('register.enterpriseCertification'),
      content: t('register.enterpriseCertificationPendingTip'),
      confirmText: t('common.ok'),
      cancelText: '',
      showConfirmBtn: false, // ✅ 仅显示提示,不需要确认按钮
    }
  }
  
  // 企业用户:认证被拒绝
  if ([2, 3, 4].includes(userType) && user.verificationStatus === 3) {
    return {
      title: t('register.enterpriseCertification'),
      content: t('register.enterpriseCertificationRejectedTip'),
      confirmText: t('register.goResubmit'),
      cancelText: t('common.cancel'),
      showConfirmBtn: true,
    }
  }
  
  return { title: '', content: '', confirmText: '', cancelText: '', showConfirmBtn: false }
}

设计亮点

  • 职责分离:数据获取、逻辑判断、UI 展示分离
  • 可测试性:纯函数设计,易于单元测试
  • 动态导入:避免循环依赖,按需加载
  • 错误处理:优雅处理用户取消操作

3. 密码过期检查(路由守卫专用)

// src/composables/auth/usePasswordExpired.ts

/**
 * 路由守卫中检查密码过期并显示重置弹窗
 * @description 不依赖 Composition API,可在路由守卫中安全调用
 * @param routerInstance - 路由实例
 */
export function checkPasswordExpiredInRouterGuard(routerInstance: Router): void {
  const route = routerInstance.currentRoute.value

  // 1. 跳过 blank 布局页面(登录、注册等)
  const isBlank = route?.meta?.layout === 'blank'
  if (isBlank) return

  // 2. 只在内部页面检查
  const category = route?.meta?.category
  if (category !== 'internal') return

  // 3. 检查 sessionStorage 标记
  const forceTokenReset = sessionStorage.getItem('vc_force_reset_pwd') === '1'
  const forceSelfReset = sessionStorage.getItem('vc_force_reset_pwd_self') === '1'

  if (forceTokenReset || forceSelfReset) {
    showResetPasswordDialogStandalone(routerInstance)
  }
}

/**
 * 显示密码重置弹窗(独立函数,不依赖 Composition API)
 * @param routerInstance - 路由实例
 */
function showResetPasswordDialogStandalone(routerInstance: Router): void {
  const container = document.createElement('div')
  document.body.appendChild(container)

  // ✅ 使用 createApp 动态挂载组件
  const app = createApp({
    render() {
      const useTokenMode = sessionStorage.getItem('vc_force_reset_pwd') === '1'
      return h(ResetPassWord, {
        size: 'large',
        force: true,
        useToken: useTokenMode,
        onSuccess: () => {
          // 清理标记
          try {
            sessionStorage.removeItem('vc_force_reset_pwd')
            sessionStorage.removeItem('vc_force_reset_pwd_self')
            sessionStorage.removeItem('vc_pwd_reset_token')
            sessionStorage.removeItem('vc_origin_password')
          } catch {}
          
          // 清理登录状态
          common.setWindowKeyValue('pwd_reset_token', undefined)
          common.removeLoginAuthToken()
          window.sessionStorage.clear()
          
          // 跳转到登录页
          routerInstance.replace(RouteConfig.Login.path)
          
          // 卸载组件
          app.unmount()
          if (container.parentNode)
            container.parentNode.removeChild(container)
        },
        onClose: () => {
          app.unmount()
          if (container.parentNode)
            container.parentNode.removeChild(container)
        },
      })
    },
  })

  // ✅ 注入全局 i18n(从 window 获取,避免依赖 useI18n)
  try {
    if ((window as any).i18n) {
      app.use((window as any).i18n)
    }
  } catch {}

  app.mount(container)

  // ✅ 监听路由变化,自动关闭弹窗
  try {
    const unwatch = routerInstance.afterEach((to: any) => {
      const isBlank = to?.meta?.layout === 'blank'
      if (isBlank) {
        try {
          sessionStorage.removeItem('vc_force_reset_pwd')
          sessionStorage.removeItem('vc_force_reset_pwd_self')
        } catch {}
        app.unmount()
        if (container.parentNode)
          container.parentNode.removeChild(container)
        unwatch()
      }
    })
  } catch {}
}

设计亮点

  • 动态挂载:使用 createApp + h() 渲染函数动态创建组件实例
  • 生命周期管理:自动清理 DOM 和事件监听器
  • 全局 i18n 注入:从 window 获取全局 i18n 实例,避免依赖 useI18n()
  • 路由监听:自动响应路由变化,关闭弹窗

4. 路由守卫集成

// src/router/index.ts

import { createRouter, createWebHashHistory } from 'vue-router'
import { checkPasswordExpiredInRouterGuard } from '~/composables/auth/usePasswordExpired'
import { checkAuthInRouterGuard } from '~/composables/auth/useUserTypeAuth'

const router = createRouter({
  history: createWebHashHistory(),
  routes,
})

// ==================== 路由后置守卫 ====================
router.afterEach((to, from) => {
  try {
    // Keep-Alive 缓存管理
    const keepAliveStore = useKeepAliveStoreWithOut()
    if (to.meta?.keepAlive && to.name) {
      keepAliveStore.addCachedView(to.name as string)
    }
    if (from.meta?.noCache && from.name) {
      keepAliveStore.deleteCachedView(from.name as string)
    }

    // 重置滚动位置
    window.scrollTo({ top: 0, left: 0, behavior: 'auto' })

    // 停止进度条
    setTimeout(() => {
      nprogressManager.done()
      CmcLoadingService.closeAll()
    }, 300)

    // ==================== 认证检查 ====================
    // 跳过 blank 布局页面(登录、注册等)
    if (to.meta?.layout !== 'blank') {
      // ✅ 用户认证检查(不依赖 Composition API)
      checkAuthInRouterGuard()

      // ✅ 密码过期检查(不依赖 Composition API)
      checkPasswordExpiredInRouterGuard(router)
    }
  } catch (error) {
    console.error('路由后置守卫执行失败:', error)
  }
})

export default router

设计亮点

  • 清晰的职责划分:缓存管理、滚动控制、认证检查分离
  • 错误边界:统一的 try-catch 错误处理
  • 条件执行:根据路由元信息决定是否执行检查

架构优化思考

1. YAGNI 原则的实践

You Aren't Gonna Need It(你不会需要它)

// ❌ 过度设计:为未来可能的需求预留扩展
class VerificationCenter {
  private rules: Map<string, VerificationRule> = new Map()
  private middleware: Middleware[] = []
  private eventBus: EventEmitter = new EventEmitter()
  
  async run(trigger: string, ctx: VerificationContext) {
    // 复杂的规则引擎逻辑
    // 中间件机制
    // 事件发布订阅
  }
}

// ✅ 简单设计:满足当前需求即可
export function checkAuthInRouterGuard(): void {
  // 直接实现业务逻辑
}

反思

  • 当前只有 3 个认证规则,不需要规则引擎
  • 未来如果真的需要扩展(如增加到 10+ 规则),再重构也不迟
  • 过早优化是万恶之源

2. 简单性 vs 灵活性

维度 规则引擎(复杂) 直接调用(简单)
代码行数 ~500 行 ~200 行
认知负担 高(需理解规则引擎) 低(直接阅读业务逻辑)
调试难度 困难(调用链长) 简单(调用链短)
扩展性 高(添加规则) 中(直接添加函数)
性能 中(函数调用开销) 高(直接调用)
适用场景 10+ 规则 3-5 规则

结论:在当前业务规模下,简单性优于灵活性。

3. 上下文隔离的设计模式

// 设计模式:Adapter Pattern(适配器模式)

// 1. 组件内使用(依赖 Vue 上下文)
export function useUserTypeAuth() {
  const router = useRouter()  // 依赖 Vue 上下文
  const { t } = useI18n()      // 依赖 Vue 上下文
  
  return {
    checkAndShowAuthDialog: () => {
      // 组件内逻辑
    }
  }
}

// 2. 路由守卫使用(不依赖 Vue 上下文)
export function checkAuthInRouterGuard(): void {
  // 适配器:将依赖 Vue 上下文的逻辑转换为独立函数
  const userType = getUserTypeFromStore()  // 直接访问 store
  const t = getSafeI18n()                  // 安全的 i18n 包装器
  
  // 业务逻辑
}

设计原则

  • 单一职责:每个函数只做一件事
  • 依赖倒置:依赖抽象(store、全局对象)而非具体实现(Vue 实例)
  • 开闭原则:对扩展开放(可添加新的检查函数),对修改封闭(不影响现有逻辑)

4. 错误处理策略

// ✅ 优雅降级
function getSafeI18n(): (key: string) => string {
  try {
    const { t } = useI18n()
    return t
  } catch {
    // 降级到 fallback 翻译
    return (key: string) => fallbacks[key] || key
  }
}

// ✅ 静默失败(用户取消操作)
void (async () => {
  try {
    await confirm(config.content, options)
  } catch {
    // 用户取消,不需要处理
  }
})()

// ✅ 全局错误边界
router.afterEach((to, from) => {
  try {
    checkAuthInRouterGuard()
    checkPasswordExpiredInRouterGuard(router)
  } catch (error) {
    console.error('路由后置守卫执行失败:', error)
    // 不阻断路由导航
  }
})

原则

  • 优雅降级:功能不可用时提供 fallback
  • 静默失败:用户主动取消的操作不需要错误提示
  • 全局边界:关键路径添加 try-catch,防止整个应用崩溃

最佳实践总结

✅ Do's(推荐做法)

  1. 为路由守卫创建独立函数

    // ✅ 独立函数,不依赖 Composition API
    export function checkAuthInRouterGuard(): void {
      const userType = getUserTypeFromStore()
      // ...
    }
    
  2. 使用安全的 i18n 包装器

    // ✅ 优雅降级
    function getSafeI18n(): (key: string) => string {
      try {
        const { t } = useI18n()
        return t
      } catch {
        return (key: string) => fallbacks[key] || key
      }
    }
    
  3. 动态导入避免循环依赖

    // ✅ 按需加载
    const handleAction = async () => {
      const { default: router } = await import('~/router')
      await router.push('/profile')
    }
    
  4. 从 Store 获取数据,不依赖 Vue 实例

    // ✅ 直接访问 store
    const userStore = useUserStoreWithOut()
    const userType = userStore.userInfo?.userType
    
  5. 使用 createApp 动态挂载组件

    // ✅ 独立的 Vue 应用实例
    const app = createApp({
      render() {
        return h(ResetPassWord, { /* props */ })
      }
    })
    app.mount(container)
    

❌ Don'ts(避免做法)

  1. 不要在路由守卫中直接调用 Composition API

    // ❌ 会报错
    router.afterEach(() => {
      const router = useRouter()  // 错误!
      const { t } = useI18n()     // 错误!
    })
    
  2. 不要过度设计

    // ❌ 3 个规则不需要规则引擎
    class VerificationCenter {
      private rules: Map<string, VerificationRule> = new Map()
      // 复杂的规则引擎逻辑
    }
    
  3. 不要忽略错误处理

    // ❌ 没有错误边界
    router.afterEach(() => {
      checkAuth()  // 如果出错会导致路由导航失败
    })
    
    // ✅ 添加错误边界
    router.afterEach(() => {
      try {
        checkAuth()
      } catch (error) {
        console.error(error)
      }
    })
    
  4. 不要忘记清理副作用

    // ❌ 没有清理 DOM 和事件监听器
    const app = createApp(Component)
    app.mount(container)
    
    // ✅ 清理副作用
    const unwatch = router.afterEach(() => {
      app.unmount()
      container.remove()
      unwatch()
    })
    

📊 性能优化建议

  1. 避免不必要的检查

    // ✅ 提前返回
    if (to.meta?.layout === 'blank') return
    if (to.meta?.category !== 'internal') return
    
  2. 使用 sessionStorage 缓存标记

    // ✅ 避免重复检查
    const forceReset = sessionStorage.getItem('vc_force_reset_pwd') === '1'
    if (!forceReset) return
    
  3. 动态导入按需加载

    // ✅ 只在需要时加载
    const { default: router } = await import('~/router')
    

🧪 可测试性建议

  1. 纯函数设计

    // ✅ 易于测试
    function needsAuthPrompt(userType: number): boolean {
      return [1, 2, 3, 4].includes(userType)
    }
    
    // 测试
    expect(needsAuthPrompt(1)).toBe(true)
    expect(needsAuthPrompt(5)).toBe(false)
    
  2. 依赖注入

    // ✅ 可注入 mock router
    export function checkPasswordExpiredInRouterGuard(
      routerInstance: Router
    ): void {
      // 使用注入的 router 实例
    }
    
  3. 职责分离

    // ✅ 数据获取、逻辑判断、UI 展示分离
    const userType = getUserTypeFromStore()      // 数据层
    const needsAuth = needsAuthPrompt(userType)  // 逻辑层
    if (needsAuth) showAuthDialog()              // UI 层
    

总结

核心要点

  1. 理解 Composition API 的上下文限制

    • 必须在 Vue 组件的 setup 函数顶层调用
    • 路由守卫运行在 Vue 上下文之外
  2. 为路由守卫创建独立函数

    • 不依赖 useRouteruseI18n 等 Composition API
    • 从 Store 或全局对象获取数据
    • 使用安全的 i18n 包装器
  3. 遵循 YAGNI 原则

    • 不要过度设计
    • 简单性优于灵活性
    • 满足当前需求即可
  4. 优雅的错误处理

    • 优雅降级(fallback)
    • 静默失败(用户取消)
    • 全局错误边界

适用场景

  • ✅ 路由守卫中需要使用 i18n、router 等 Composition API
  • ✅ 需要在非 Vue 组件上下文中执行 Vue 相关逻辑
  • ✅ 需要动态挂载组件(如弹窗、通知)
  • ✅ 需要简化过度设计的架构

参考资源


附录:完整代码示例

项目结构

src/
├── composables/
│   ├── auth/
│   │   ├── useUserTypeAuth.ts          # 用户认证(组件 + 路由守卫)
│   │   └── usePasswordExpired.ts       # 密码过期(组件 + 路由守卫)
│   └── ui/
│       └── useConfirm.ts               # 确认对话框(安全 i18n)
├── components/
│   └── ResetPassWord/
│       └── index.vue                   # 密码重置组件
├── router/
│   └── index.ts                        # 路由配置(集成认证检查)
└── store/
    └── core/
        └── user.ts                     # 用户状态管理

关键文件

完整代码已在上文的"代码实现"章节中展示,此处不再重复。


感谢阅读!如果这篇文章对你有帮助,欢迎分享和讨论。

Vue3/React 结合 pdfjs 实现拖拽盖章签名等操作,支持 PDF多页展示,导出图片与 PDF

PDF 拖拽盖章平台

在 AI 能基本实现百分之九十以上的前端代码时,不知道写这种前端工具还有没有人看?

我用相对详细的方式,完整拆解一个「PDF 拖拽盖章平台」的实现过程,覆盖多页渲染、拖拽盖章、撤销/还原、导出图片与 PDF、性能优化(懒渲染)等关键环节。示例包含 React 与 Vue3 两套实现,逻辑一致、写法不同。

CPT2602031359-1000x523.gif

目标与约束

目标

  • 支持上传多页 PDF。
  • 在预览区域拖拽印章,支持骑缝章。
  • 支持撤销 / 还原。
  • 支持导出图片和 PDF。
  • 大文件也能流畅渲染,不“卡成 PPT”。

主要约束

  • 浏览器对 canvas 尺寸有上限(不同浏览器略有差异)。
  • 长图导出容易失败,需要降级方案。
  • 大 PDF 一次性渲染会阻塞主线程。

核心思路:统一坐标系 + 多页 canvas

这里的关键是:把整份 PDF 当成一张“虚拟长画布”

  • 每一页各有一个 canvas,显示真实页面内容。
  • 所有盖章坐标都以“整份文档坐标系”为准。
  • 每页只要知道自己在整份文档中的位置(pagePositions),就能把盖章正确映射回去。

这样做有两个好处:

  1. 骑缝章天然支持:印章跨页,坐标也能跨页。
  2. 导出更稳定:导出时可自由选择“整图”或“逐页”。

核心依赖

  • pdfjs-dist:解析与渲染 PDF
  • pdf-lib:导出带印章的 PDF(图片型 PDF)

安装示例:

pnpm add pdfjs-dist pdf-lib


PDF 解析与页面尺寸获取

先读取文档并计算每页尺寸。这里只取尺寸,不渲染,避免一开始就卡死。

const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
const pdf = await loadingTask.promise;
const pages = [];
for (let pageIndex = 1; pageIndex <= pdf.numPages; pageIndex += 1) {
  const page = await pdf.getPage(pageIndex);
  const viewport = page.getViewport({ scale: PAGE_SCALE });
  pages.push({ width: viewport.width, height: viewport.height });
}

拿到 pages 后,就能计算整份文档尺寸和每页偏移量。

const docSize = useMemo(() => {
  const width = Math.max(...pdfPages.map((page) => page.width));
  const height = pdfPages.reduce(
    (sum, page, index) => sum + page.height + (index < pdfPages.length - 1 ? PAGE_GAP : 0),
    0
  );
  return { width, height };
}, [pdfPages]);

const pagePositions = useMemo(() => {
  let offsetY = 0;
  return pdfPages.map((page, index) => {
    const pos = { x: (docSize.width - page.width) / 2, y: offsetY };
    offsetY += page.height + (index < pdfPages.length - 1 ? PAGE_GAP : 0);
    return pos;
  });
}, [pdfPages, docSize.width]);

解释:

  • docSize 是整个虚拟画布大小。
  • pagePositions 是每页在虚拟画布中的左上角坐标。

预览区滚动与布局

多页 PDF 不可能全部撑开,所以预览区必须做“内部滚动”。

.pdf-stage {
  max-height: clamp(520px, 70vh, 820px);
  overflow: auto;
}

这样页面滚动只发生在 PDF 区域内,用户体验会舒服很多。


拖拽盖章实现

坐标换算

拖拽时需要把屏幕坐标转换成“文档坐标”。关键点就是 overlay 的矩形位置。

const rect = overlayRef.current.getBoundingClientRect();
const x = event.clientX - rect.left - template.width / 2;
const y = event.clientY - rect.top - template.height / 2;

const nextStamp = {
  instanceId: buildInstanceId(template.id),
  src: template.src,
  width: template.width,
  height: template.height,
  x: clamp(x, 0, docSize.width - template.width),
  y: clamp(y, 0, docSize.height - template.height),
};

实时拖动 + 撤销栈

拖动过程中只更新“临时状态”,拖动结束再写入历史栈,保证撤销栈干净。

// 实时更新
updateLiveStamps((prev) => prev.map(...));

// 拖动结束写入历史
if (drag.moved) commitStamps(liveStampsRef.current);

好处: 撤销时不是“细碎步进”,而是一次拖动一个记录。


性能优化:懒渲染 + 队列

渲染 PDF 是最容易卡顿的地方。解决方案是:

  • IntersectionObserver:只有当页面进入视口时才渲染。
  • 渲染队列:保证渲染顺序,不并发拖慢主线程。
  • 预渲染前两页:首屏更快。
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (!entry.isIntersecting) return;
      const index = Number(entry.target.dataset.index);
      queueRender(index);
    });
  },
  { root: stageElement, rootMargin: '240px 0px', threshold: 0.1 }
);

渲染队列逻辑:

const renderPage = async (index) => {
  const page = await pdfDoc.getPage(index + 1);
  const viewport = page.getViewport({ scale: PAGE_SCALE });
  const canvas = canvasRefs.current[index];
  const ctx = canvas.getContext('2d');
  canvas.width = viewport.width;
  canvas.height = viewport.height;
  await page.render({ canvasContext: ctx, viewport }).promise;
};

这样渲染压力被“分散到用户滚动过程”,不会一次性卡死。


导出图片(长图 + 逐页降级)

导出长图时,浏览器对 canvas 尺寸限制很严格。如果文档太长,直接导出会失败,因此需要检测并降级。

const isTooLarge =
  docSize.width > MAX_EXPORT_DIMENSION ||
  docSize.height > MAX_EXPORT_DIMENSION ||
  docSize.width * docSize.height > MAX_EXPORT_PIXELS;

if (isTooLarge) {
  // 改为逐页导出
}

逐页导出时,要把全局印章坐标换算到当前页坐标,这样骑缝章也不会丢。


导出 PDF(完整文件)

导出 PDF 用 pdf-lib 做合成:

  1. 每一页画布(含印章)转为 PNG。
  2. 插入到新 PDF 页。
  3. 生成 PDF 并下载。
const pdfDocument = await PDFDocument.create();
const pngImage = await pdfDocument.embedPng(pngBytes);
const pdfPage = pdfDocument.addPage([page.width, page.height]);
pdfPage.drawImage(pngImage, { x: 0, y: 0, width: page.width, height: page.height });

下载逻辑:

const pdfBytes = await pdfDocument.save();
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
link.download = `盖章结果-${new Date().toISOString().slice(0, 10)}.pdf`;
link.href = url;
link.click();

导出的 PDF 为“图片型 PDF”,兼容性高,但文字不可搜索。如果要保留矢量文字,需要更复杂的“原 PDF 叠加”方案。


扩展方向

  1. 矢量 PDF 导出:直接在原 PDF 叠加印章(更复杂,但可保留文字可搜索)。
  2. 通用库封装:提炼核心逻辑为 core,React/Vue 只是适配层。
  3. 企业场景扩展:模板库、权限管理、批量盖章。

如果你准备上线到业务系统,建议在此基础上增加:

  • 盖章操作日志
  • 导出前的预检查(页数、尺寸)
  • 失败重试和导出进度提示

这样体验会更接近商业级工具。

项目地址

github pdfstamp

使用dataZoom控制滚动条处理echart数据过多显示混乱的问题

问题描述

echarts图表的y轴上数据过多,每一行数据高度太短,拥挤在一起,导致图表显示不全。

7bbc5be6-f30f-4d36-982b-be0c8aa85aad.png

需求回顾

项目页面【景区销售排行】模块显示前10条数据,点击【查看全部】弹框显示所有数据。

下图为页面中排行模块的样子:

d4859e09-185c-41eb-a5d6-e9e9f3ef0770.png

问题解决

1、处理弹框中图表数据过多显示混乱的问题;

2、弹框中初始数据默认和页面一致,10条显示。

如何解决

在使用 ECharts 创建图表时,如果你发现 Y 轴上的数据过多,导致图表显示不全,你可以通过设置滚动条来改善这一情况。ECharts 提供了 dataZoom 组件来实现这一功能,它可以让你在 X 轴或 Y 轴上添加滚动条。可点击查看echart文档 dataZoom

具体步骤:

打开到源代码,找到与tooltip,grid,xAxis,yAxis,series,legend等同级的地方,添加如下dataZoom组件参数,

{
    tooltip : {},
    dataZoom : [
        {
            type: "slider",  // slider表示这里的是滑动条型数据区域缩放组件,如果是inside,表示内置型数据区域缩放组件
            yAxisIndex: 0, // 控制y轴滚动对象,[0] 可简写为0
            zoomLock: true, // 是否锁定选择区域(或叫做数据窗口)的大小,如果设置为 `true` 则锁定选择区域的大小,也就是说,只能平移,不能缩放
            width: 10, // dataZoom-slider 组件的宽度。竖直布局默认 30,水平布局默认自适应
            right: 10, // dataZoom-slider组件离容器右侧的距离, 值可以是像 `20` 这样的具体像素值,可以是像 '20%' 这样相对于容器宽度的百分比。
            top: 0, // dataZoom-slider组件离容器上侧的距离。
            bottom: 0, // dataZoom-slider组件离容器底侧的距离。
            startValue: 0, // 数据窗口范围的起始数值, 0代表数组索引值,第1条数据
            endValue: 9, // 数据窗口范围的结束数值, 9代表数组索引值,第10条数据
            handleSize: 0, // 两边手柄尺寸
            showDetail: false, // 拖拽时是否显示滚动条两侧的文字,默认为true
        },
    ]
}

修改后,效果如下图:

90a35cd0-4274-43f8-9499-e8031aca6966.png

如果想改变一下echarts图表中滚动条的样式,可以增加一些参数,如下:

{
    series : [],
    dataZoom : [
        {
                type: "slider", // slider表示这里的是滑动条型数据区域缩放组件,如果是inside,表示内置型数据区域缩放组件
                realtime: true, // 拖动时,是否实时更新系列的视图。如果设置为 `false`,则只在拖拽结束的时候更新,默认为true
                startValue: 0, // 数据窗口范围的起始数值, 0代表数组索引值,第1条数据
                endValue: 9, // 数据窗口范围的结束数值, 9代表数组索引值,第10条数据
                width: 10, // dataZoom-slider 组件的宽度。竖直布局默认 30,水平布局默认自适应
                height: "90%", // dataZoom-slider 组件的高度。水平布局默认 30,竖直布局默认自适应。
                top: "5%", // dataZoom-slider组件离容器上侧的距离。
                right: 0,  // dataZoom-slider组件离容器右侧的距离, 值可以是像 `20` 这样的具体像素值,可以是像 '20%' 这样相对于容器宽度的百分比。
                // orient: 'vertical', // 设置横向还是纵向, 但是官方不太建议如此使用,建议使用 yAxisIndex 具体指明
                yAxisIndex: [0], // 控制y轴滚动对象,[0] 可简写为0
                fillerColor: "#0093ff", // 滚动条选中范围的填充颜色
                borderColor: "rgba(17, 100, 210, 0.12)",  // 滚动条边框颜色
                backgroundColor: "#cfcfcf", // 滚动组件的背景颜色,及两边未选中的滑动条区域的颜色
                handleSize: 0, // 两边手柄尺寸
                showDataShadow: false, // 是否在 `dataZoom-silder` 组件中显示数据阴影。数据阴影可以简单地反应数据走势。默认auto
                showDetail: false, // 拖拽时是否显示滚动条两侧的文字,默认为true
                zoomLock: true, // 是否锁定选择区域(或叫做数据窗口)的大小,如果设置为 `true` 则锁定选择区域的大小,也就是说,只能平移,不能缩放
                // 移动手柄的样式配置
                moveHandleStyle: {
                  opacity: 0, // 这里opacity设置为0,相当于设置moveHandleSize为0
                },
        }
    ]
}

实现效果如下图:

b45f0844-9a43-4ec1-8247-7c19d7dd15f4.png

这里想重点提一下startValue,endValue,我通过这两个值来控制echart图表中可见视野内可展示的数据条数。

从文档中可以看到,我们其实还可以使用start,end来控制数据窗口范围,并且start,end优先级大于startValueendValue

start,end表示的是数据窗口范围的起始和结束百分比,是一个百分比数值,number类型,范围是:0 ~ 100。表示 0% ~ 100%。

startValue,endValue表示的是数据窗口范围的起始和结束数值,类型为[number,string,Date],一般设置为number类型的数组索引值即可,同时还可以设置为数组值本身。至于Date类型,不清楚,没去研究,有兴趣的可以自己去发现。

2dd75f0e-c029-4401-98f6-593329743f41.png

在我的项目中,我最终选择了startValue,endValue 来精确控制显示条数,而不是start,end,虽然后者也能解决拥挤的问题,但是没法精确到条数,导致弹框中的图表显示可能跟页面汇总的不一致,如果没有我这里这样的场景,其实用他们哪一个,就看你自己的意愿了。

题外话

我们在渲染图表X轴或者Y轴上的数据时,如果发现渲染的数据跟实际传的数据顺序相反,可在轴数据设置中增加inverse: true, inverse表示是否是反向坐标轴,默认值为false。

{
    yAxis: [
        {
            inverse: true,
            data: [],
            axisLabel: {}
        }
    ],
    xAxis: [
        {
            inverse: true,
            data: [],
            axisLabel: {}
        }
    ]
}

我们项目中前同事在开发的时候,编写的图表插件中并没有设置inverse: true,他发现渲染的数据都是反的,所以将传入的数据都使用数组的reverse方法倒序排列了一遍,达到了同样的效果。 不过后来我开发弹框页面的时候,这里引出了一个问题,按照我们上面提到的startValue,endValue设置

{
    startValue: 0, // 数据窗口范围的起始数值, 0代表数组索引值,第1条数据
    endValue: 9, // 数据窗口范围的结束数值, 9代表数组索引值,第10条数据
}

刚打开弹框,我们可以看到,图表中滚动条已经到底了,不是我们预想的从顶部开始,就算将startValue,endValue的值反过来设置同样如此。

b04a4dcb-4b9f-4334-b263-3afdcbc2eb23.png

那该怎么办呢?不绕弯子了,请看下面大屏幕,哦,不对,看下面代码:

{
    startValue: seriesData.length - 10, // seriesData为图表数组数据
    endValue: seriesData.length-1,
}

看到这,大家应该已经明白了吧。好了,不再惹人嫌了,今天就讲到这里,下回再见。

HTML常用元素以及意义

HTML 常用元素

!DOCTYPE

主要就是告诉标准通用标记语言解析器应该使用什么样的文档类型定义 Document Type Definition(DTD)来解析文档 常见的声明:

<!DOCTYPE html>
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

head

<head>  元素包含机器可读的文档相关信息(元数据),如文档的标题、脚本和样式表

meta

<meta>  元素表示那些不能由其他 HTML 元相关(meta-related)元素表示的元数据信息。


<meta charset="UTF-8">
<meta name="author" content="aaa@mail.abc.com">
<!-- SEO -->
<meta name="description" content="描述描述描述描述描述">
<meta name="keywords" content="关键字关键字关键字关键字关键字">
<!-- 视口 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">

title

<title> 元素 定义文档的标题,显示在浏览器的标题栏或标签页上。它只应该包含文本,若是包含有标签,则它包含的任何标签都将被忽略。

style

<style>  元素包含文档的样式信息或文档的部分内容。其中的 CSS 会应用于包含 <style> 元素的文档内容

script

<script>  元素用于嵌入可执行代码或数据,这通常用作嵌入或者引用 JavaScript 代码。<script> 元素也能在其他语言中使用。

属性:

  • async:对于普通脚本,如果存在 async 属性,那么普通脚本会被并行请求,并尽快解析和执行。该属性能够消除解析阻塞的 Javascript。解析阻塞的 Javascript 会导致浏览器必须加载并且执行脚本,之后才能继续解析
  • defer:这个布尔属性的设置是为了向浏览器表明,该脚本是要在文档被解析后,但在触发 DOMContentLoaded 事件之前执行的。
  • type 该属性表示所代表的脚本类型
  1. 属性未设置(默认),一个空字符串,或一个 JavaScript MIME 类型:text/javascript、application/json

    <script type="text/javascript">
    
  2. module 此值导致代码被视为 JavaScript 模块。其中的代码内容会延后处理。

    <script type="module">
        // 支持 ES6 模块化语法
        import { myFunction } from './module.js';
        export const myVar = 10;
    </script>
    
  3. importmap此值代表元素体内包含导入映射(importmap)表。导入映射表是一个 JSON 对象,开发者可以用它来控制浏览器在导入 javaScript 模块时如何解析模块标识符。

    <script type="importmap">
    {
      "imports": {
        "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
      }
    }
    </script>
    

link

<link>  元素规定了当前文档与某个外部资源的关系。该元素最常用于链接样式表,此外也可以被用来创建站点图标(比如 PC 端的“favicon”图标和移动设备上用以显示在主屏幕的图标) 。

  • rel 设定为 preload,表示浏览器应该预加载该资源,preload告诉浏览器立即下载指定的资源,因为该资源将在当前页面中很快被使用。它优先于prefetch,因为当前页面需要它。
  • rel 设定为 prefetchprefetch用于在浏览器空闲时加载可能在未来页面中使用的资源(例如,用户可能点击的链接)。
  • rel 设定为 dns-prefetch,dns-prefetch:仅提前进行DNS解析。
<link rel="dns-prefetch" href="https://cdn.example.com">

src和href的区别:

src (源):嵌入外部资源到当前文档中,把东西"拿进来"成为文档的一部分,src资源通常需要被浏览器解析/执行,替代原有的内容

href (超文本引用):建立当前文档与其他资源的关联,和外部资源"手拉手",相当于资源的引用,href的资源保持独立,不被嵌入

a

  • href 超链接所指向的 URL。链接不限于基于 HTTP 的 URL——它们可以使用浏览器支持的任何 URL 协议: 带 tel: URL 的电话号码。 带 mailto: URL 的电子邮件地址。 带 sms: URL 的短信。 带 javascript: URL 的可执行代码。 如果 web 浏览器不支持其他 URL 方案,网站可以使用 registerProtocolHandler()
  • target该属性指定在何处显示链接的 URL,作为浏览上下文的名称(标签、窗口或 <iframe>)。以下关键词对加载 URL 的位置有特殊含义: _self:当前浏览上下文。(默认) _blank:通常在新标签页打开,但用户可以通过配置选择在新窗口打开。 _parent:当前浏览环境的父级浏览上下文。如果没有父级框架,行为与 _self 相同。 _top:最顶级的浏览上下文(当前浏览上下文中最“高”的祖先)。如果没有祖先,行为与 _self 相同。

语义化元素

  1. <article> 元素表示文档、页面、应用或网站中具有独立分发或复用意义的自包含内容块,例如论坛帖子、杂志或报纸文章、博客条目、产品卡片、用户评论、交互式组件等独立内容项。
  2. <aside> 元素用于表示文档中内容仅与主内容间接相关的部分。通常以侧边栏或提示框的形式呈现。
  3. <summary> 元素 指定了 <details>元素展开盒子的内容的摘要,标题或图例。点击 <summary> 元素可以切换父元素 <details> 开启和关闭的状态。
  4. <details>  元素可创建一个组件,仅在被切换成展开状态时,它才会显示内含的信息。<summary>元素可为该部件提供概要或者标签。
  5. <dialog>  元素表示一个对话框或其他交互式组件,例如一个可关闭警告、检查器或者窗口。
  6. <fieldset>  元素用于对表单中的控制元素进行分组(也包括 label 元素)。
  7. <legend>  元素表示其父元素 <fieldset> 内容的标题。
  8. <figure>  元素代表一段独立的内容,可能包含 <figcaption>元素定义的说明元素。该插图、标题和其中的内容通常作为一个独立的引用单元。
  9. <figcaption> 元素是用来描述其父节点 <figure> 元素里的其余内容的标题或说明。为 <figure> 提供一个无障碍描述
  10. <footer>  元素表示其最近的祖先分段内容的页脚或分段根元素。<footer> 通常包含有关该部分作者、版权数据或相关文档链接的信息。
  11. <header> 元素表示介绍性内容,通常是一组介绍性或导航性辅助内容。它可能包含一些标题元素,也可能包含徽标、搜索表单、作者姓名和其他元素。
  12. <label> 元素(标签)表示用户界面中某个元素的说明。
  13. <main> 元素呈现了文档的 <body> 或应用的主体部分。主体部分由与文档直接相关,或者扩展于文档的中心主题、应用的主要功能部分的内容组成。
  14. <menu>  元素在 HTML 规范中被描述为 <ul>的语义替代,但浏览器将其视为与 <ul>没有区别(并通过无障碍树暴露)。它表示一个无序列表(由 <li> 元素表示)。
  15. <nav>元素表示页面的一部分,其目的是在当前文档或其他文档中提供导航链接。导航部分的常见示例是菜单,目录和索引。
  16. <progress>  元素用来显示一项任务的完成进度。虽然规范中没有规定该元素具体如何显示,浏览器开发商可以自己决定,但通常情况下,该元素都显示为一个进度条形式。
  17. <section> 元素 表示 HTML 文档中一个通用独立章节,它没有更具体的语义元素来表示。一般来说会包含一个标题。
  18. <time> 元素用来表示一个特定的时间段。该元素可包含 datetime 属性,用于将日期转换为机器可读格式,从而获得更好的搜索引擎结果或自定义功能(如提醒)

其他元素

  1. <form> 元素表示文档中的一个区域,此区域包含交互控件,用于向 Web 服务器提交信息。
  2. <button> 元素表示一个可点击的按钮,可以用在表单或文档其他需要使用简单标准按钮的地方。
  3. <canvas> 元素可被用来通过 JavaScript绘制图形及图形动画
  4. <div>  元素是流式内容的通用容器。
  5. <iframe>  表示嵌套的浏览上下文。它能够将另一个 HTML 页面嵌入到当前页面中
  6. <img> 元素将一张图像嵌入文档。
  7. <input>  元素用于为基于 Web 的表单创建交互式控件,以便接受来自用户的数据。取决于设备和用户代理不同,表单可以使用各种类型的输入数据和控件。<input> 元素是目前 HTML 中最强大、最复杂的元素之一,因为它有大量的输入类型和属性组合。
  8. <li> 元素用于表示列表中的项目。它必须包含在一个父元素中:有序列表(<ol>)、无序列表(<ul>)或菜单(<menu>)。在菜单和无序列表中,列表项通常使用项目符号显示。在有序列表中,通常在左侧显示一个升序计数器,如数字或字母。
  9. <ol> 元素 表示有序列表,通常渲染为一个带编号的列表。
  10. <ul>  元素表示无序的项目列表,通常渲染为项目符号列表。
  11. <option>  元素用于定义包含在 <select><optgroup>或 <datalist> 元素中的一项。
  12. <p> 元素表示文本的一个段落。在视觉媒体中,段落通常表现为用空行和/或首行缩进与相邻段落分隔的文本块,但 HTML 段落可以是相关内容的任何结构分组,如图像或表格字段。
  13. <select> 元素 表示一个提供选项菜单的控件:
  14. <span> 元素是一个通用的行级容器,本身不具备特殊含义
  15. <style>  元素包含文档的样式信息或文档的部分内容。其中的 CSS 会应用于包含 <style> 元素的文档内容。
  16. <template>元素是一种用于保存客户端内容机制,该内容在加载页面时不会呈现,但随后可以 (原文为 may be) 在运行时使用 JavaScript 实例化
  17. <textarea> 元素是一个多行纯文本编辑控件,适用于允许用户输入大量自由格式文本的场景
  18. <video> 元素用于在文档中嵌入媒体播放器,用于支持文档内的视频播放。
  19. <table>  元素表示表格数据——即在一个由包含数据的行和列组成的二维表格中呈现的信息。<tbody><td><tr><tfoot><thead>

大模型网关:大模型时代的智能交通枢纽|得物技术

一、导语

在人工智能技术快速演进的时代,大型语言模型和AI智能体已成为各类应用的核心组件,引发AI相关API流量的指数级增长。而大模型网关,正是这场变革中应运而生的智能交通枢纽。

随着DeepSeek、Qwen等开源模型及各类商用大模型的普及,企业AI应用场景日益丰富,从智能客服自动化到代码生成与软件开发,从金融法律分析到内容生成引擎,AI正深度融入企业核心业务流程。

这种深度融合使得企业不仅使用SaaS化的LLM服务,更在私有化环境中微调、部署LLM模型,形成混合云架构,随之带来了多LLM适配管理、成本失控、数据安全和可靠性保障等系列挑战。

二、大模型网关:AI流量的智能调度中心

大模型网关是为AI工作负载专门设计的网关解决方案。它作为连接业务与AI基础设施的统一端点,为应用程序和模型之间的AI流量提供全面的管控能力。

与传统API网关不同,大模型网关针对AI请求的特有模式进行了专门优化。传统API网关专注于通用数据流量,基于RESTful API和静态请求响应设计,而大模型网关则专门应对AI工作负载的特殊需求,比如,长时与流式响应、复杂输入输出、高资源消耗与批处理、上下文与状态管理、专属监控与计量、关注成本与业务效果,等等。

大模型网关的核心能力主要体现在几个维度:模型市场、模型体验、模型调度、模型成本和稳定性(可观测性、容量管理、模型流控、服务告警)。

其中稳定性是大模型网关的“压舱石”,确保服务高可用、可管理、可追溯。容量管理可根据业务流量预估预先配置好足够的模型TPM额度,避免突发流量导致服务不可用或影响别的业务使用。模型观测提供实时监控每个模型健康状态、响应延迟、成功率等关键指标。模型流控可做到Key和模型粒度的TPM、QPS流量控制,一旦业务请求突破限流阈值,将依据流控规则进行限流。服务告警能力目标是借助Flink实时计算能力提供用户分钟级别的模型服务异常实时告警能力。

三、自建缘由:得物AI部署的四大挑战

随着AI在得物的应用场景不断深入,其帮助公司提升效率和降低成本的潜力被广泛挖掘。然而,我们在这一过程中面临一系列严峻挑战。

避免资源浪费并提升效率

在实际场景中,得物需要同时使用很多个AI模型,包括开源模型、商用模型及自建模型,这些模型的API接口、数据格式和调用方式各异。

如果每个业务域单独建设接入能力,会导致技术栈碎片化和重复开发,形成一个个“烟囱”,造成公司资源浪费。另外,如果每个开发团队都需要直接与各种AI模型的API对接,开发者必须学习每个AI API和AI平台,实现供应商特定的代码,会显著降低开发效率。

保障内外部模型成本可控

据估算,得物12月份调用大模型的Token消耗量已达数千亿规模,是1月份用量的20.63倍,仅Token调用单月成本就是一笔相当大的金额。若业务各自直接对接公有云模型服务,可能导致模型使用和成本失控。因此,必须依托大模型流量统一入口建设一套完整的成本管控体系。

保障接入外部模型数据安全

将敏感信息传输到外部LLM提供商引发了关于数据隐私、法规合规性(如PIPL和GDPR)以及潜在数据泄露的担忧。为了保障数据安全,我们考虑自建。

保障模型服务运行稳定可靠

模型网关需解决以下核心稳定性问题:

  • 延迟与成功率波动:模型服务受底层算力限制,普遍存在低限流阈值,且响应延迟与调用成功率波动显著大于传统API。
  • 基于Key的容量管理:模型服务通常设置固定限流阈值,易导致不同业务场景间流量相互挤占,影响全局可用性。
  • 实时告警与可观测性:需在服务异常或触发限流时第一时间告警,同时完整记录请求日志(含Prompt及来源IP),便于问题追踪。
  • 基于Token的精准限流:应建立以Token数量(而非调用次数)为基准的配额管理机制,从根源防止资源滥用,保障业务平稳运行。

四、行业实践:大模型网关的多元解决方案

大模型网关作为大模型应用的关键中间层,近年来随着企业级AI应用部署的加速而快速发展,以实现AI能力的统一、高效、可控管理。

目前市场上有多种大模型网关解决方案,它们在商业模式和核心能力上都各有特色。这里笔者将各类网关产品进行了梳理与汇总,以便读者大致了解行业现状。

AI网关主要参与者及产品

五、实施策略:构建企业大模型网关的六步法

对比行业落地大模型网关的案例,针对得物实际业务情况,在内部落地大模型网关时,我们制定了六个方面的策略。

打造信息丰富的模型市场

随着大模型在得物内的广泛深入应用,从B/C端创新到后端效能提升,场景愈发丰富复杂。加之自研/自部署(如 Qwen、DeepSeek)与内外厂商模型层出不穷,模型供给呈现分散与混乱,业务选型门槛抬高,往往从调研、验证到上线需耗时1~2天,极大增加了业务接入AI的难度。

为此,网关对接入模型进行统一梳理,打造信息完备的“模型市场”。本质上是一个“AI 模型应用商店”,将分散混乱、高门槛的选型流程,重构为集中、透明、可量化评估的标准化路径。

大模型市场

通过统一模型市场,网关构建覆盖发现、评测、验证与集成的完整闭环:

  • 模型纳管:集中管理来自自研与内外部厂商的多样化模型,形成内部“模型货架”,消除信息碎片;原生支持托管与上云对接。
  • 评测对比:支持文本生成、图像理解/生成等对比测试,可将真实业务问题一键投递给多模型直观比拼,显著降低试用门槛。
  • 一站式接入:选定模型后即可查看 API 接入指南,完成“选型-试用-接入”的闭环,大幅提升对接效率。
  • 运营与推荐:提供模型推荐能力,按效果与性价比打标、置顶,缩短选型时间并助力降本。

通过建设模型市场,实现了模型接入的统一化与标准化,模型上架和接入效率显著提升。模型上架时间从1~2天降低到 10 分钟内,试用从 1 天降低到 5 分钟以内。

统一各业务模型服务入口

通过建设OpenAI like风格的统一访问API和模型服务调度能力,网关将绝大部分AI模型服务的访问集中到单一入口,使不同业务线无需关注后端模型的具体实现细节,也能实现不同厂商模型服务之间的容灾。

模型调度策略与OpenAI like风格API

建设全流程成本管控体系

在成本治理和优化上,围绕“源头管控、成本感知、模型调度、厂商折扣和成本监控”等方面着手闭环能力搭建。打通了从预算申请、模型选型、接入调用,到运行观测、成本结算的全链路,实现了精准的成本治理与优化。

具体表现为,在3、4季度token用量分别较前一季度增长2.52倍和2.16倍的情况下,每百万Token的成本分别降低50%+和45%+,降本额度也相当可观,达到数百万元,在保证业务体验的前提下有效压降了模型使用成本。

总体降本思路&策略

目前正在搭建精细化降本能力,其核心思路是:通过构建Key/模型/厂商/项目维度的成本大盘、构建各外部厂商各类别模型均价大盘(发现更有性价比的模型)、建设用量和成本每周主动推送机制、完善成本预警/告警体系等措施,促使业务依据成本和价格数据主动进行成本治理。

全流程降本能力体系

持续夯实稳定性架构能力

在架构能力建设上,围绕“高可用、可控成本、稳定体验”三大目标,重点建设了限流、调度和容灾三类核心架构能力,可实现分钟级容灾切换,为大规模、多模型、多业务场景下的稳定运行提供基础保障。

  • 容量管理与限流

通过建设模型容量的配置化管理机制,实现按Key/项目等维度的TPM容量管理体系;若业务流量(token)超过阈值,便触发限流及容量告警。

  • 模型调度与容灾

模型调度能力可帮助我们实现厂商间模型粒度的分钟级容灾。具体做法是:若检测到当前API Key配置有模型调度策略,则模型调度器便将请求按配置规则执行调度,并将选中的模型交付给模型路由模块,由路由模块封装后将请求转发到对应厂商的模型服务实现。

模型调度与路由

建设分钟级实时观测能力

在AI规模化应用时代,没有分钟级观测体系的模型网关,就像没有仪表盘和刹车的F1赛车——速度越快,风险越大,毁灭性越强。因此,必须建设完善的模型调用/用量/成本的分钟级观测(监控+告警)体系。

  • 模型调用/用量/性能观测。目前已实现Key和模型粒度的模型调用、token用量的监控大盘,如下图所示:

调用/用量监控分时(基于Key)

调用失败率和平均RT(基于Key)

RPM/TPM监控分时(基于Key)

端到端平均RT监控分时(基于Key)

用量监控分时(基于模型)

性能监控分时(基于模型)

  • 模型成本观测。模型成本观测方面,正在实现模型、Key、厂商、项目等粒度的实时监控大盘,以及日/周/月/指定时间维度汇总的成本数据大盘。
  • 模型异常告警。模型服务告警方面,当前正在基于Flink实施计算和Kafka事件订阅机制建设一套基于模型和Key的实时告警体系,让业务对自己的模型调用异常能在3分钟以内通过飞书告警感知。

建设Key生命周期管理能力

通过API Key管理产品化能力建设,实现了API Key申请工单及自动分发、Key场景/负责人/共享人/状态管理和黑名单功能,并依托API Key实现接口鉴权、预算管理(预算分配/预算消耗/预算预警)、容量管理、模型调度等核心功能及关键流程节点的规范化管理。

六、创新亮点:大模型网关的核心技术突破

模型网关瞄准“效率-成本-稳定性-安全&合规”着力平台建设,并继续在成本管控、模型接入效率、服务稳定性、模型监控/告警等方面持续创新:

  • 构建全流程成本管控体系。 通过预算与 API Key 申请自动化、用量监控、成本展示、超额预警与告警、智能调度、用量与成本大盘等能力,形成“预算申请—调用监控—预算预警—模型调度—费用查看”的闭环管控。
  • 实现跨厂商的模型级容灾。 通过在网关配置显式或默认的调度策略,将请求优先分配至性价比更高的模型,既实现不同厂商间的模型级容灾,也成为降本利器。
  • 实现厂商无感的接入体验。 网关统一分发 API Key 并提供统一的模型服务 API,业务无需关心各厂商的入参/出参差异,即可获得一致的接入体验并显著提升效率。
  • 便捷高效的模型选型体验。 依托模型市场、试用预算池、试用与效果对比、推荐板块等功能,在控成本前提下为用户提供快捷选型路径,助力在层出不穷的模型中快速锁定理想方案。
  • 分钟级用量与成本观测。 已构建近实时(分钟级)运行监控,覆盖调用量、失败次数/率、RT、TPM、RPM等关键指标;并在开发基于 Key 与模型粒度的成本实时观测与离线报表,支持按周将成本汇总推送给调用方。

七、应用收益:从成本节约到效能提升

得物部署大模型网关后,经过「模型网关升级」项目建设,取得如下效果:

(1) 网关平台从0~1搭建起来。 模型网关从单一的纯后台服务进化为面向管理员和研发/产品/运营用户的平台化产品;不再只是模型访问的“管道”,而是集模型集市、模型调度、成本治理、创新实验于一体的支撑整个组织进行AI创新的入口平台。

(2) 内外部模型100%纳管。 从0~1建成对接得物(KubeAI)百度/阿里/字节/华为/微软/谷歌模型服务的模型集市,完成内外部模型100%纳管,新模型上架/接入只需在平台一键配置,无需新写实现逻辑。

(3) 模型接入效率提升97%。 管理各云商和自建模型140个,单模型平均上架时间从1~2天降低到 10 分钟内,接入效率提升97+%;模型试用与效果评估过程从 1 天降低到 5 分钟以内,效率提升98%+。

(4) H2节省成本数百万元。 依托模型调度切换能力和统一API建设,以及降本方法论推广,Q3、Q4在用量均较前一季度翻倍的情况下,实现每百万token成本连续分别降低51.52%和48.37%。两季共实现降本额度达数百万元,并且随着用量增加降本收益将越来越明显。

八、未来展望:从大模型网关向AI网关演进

大模型网关的未来发展将向如下几个方向演进:

首先,模型网关继续承担大模型成本管控主体责任,继续通过强化数据分析能力推进精细化降本,落地Qwen系列自建模型通过云商托管方式降本。

其次,围绕标准化与生态兼容,网关将引入并适配 MCP(模型上下文协议);实现API同时兼容多厂商与多形态模型(文本、图像、语音、视频与多模态),在保持一致体验的前提下实现跨生态的无缝互通与扩展。

另外,API网关正从单纯的流量管理工具转变为AI编排平台,将在已有的模型调度能力基础上建设更强大的工作流与多模型协同机制,能根据成本、延迟、准确性,将请求分配给最优AI模型。

最终,模型网关将不再是一个“网关”,而是企业智能化的“神经中枢”——它不直接思考,但确保思考过程高效、安全、经济地发生。

结语:

未来的技术方向已经清晰——大模型网关不是API网关的替代品,而是其演进形态。随着AI逐步嵌入各类应用,企业选择可扩展的大模型网关平台,将避免被孤立在特定AI生态中,获得技术架构的长期竞争优势。

往期回顾

1.从“人治”到“机治”:得物离线数仓发布流水线质量门禁实践

2.AI编程实践:从Claude Code实践到团队协作的优化思考|得物技术

3.入选AAAI-PerFM|得物社区推荐之基于大语言模型的新颖性推荐算法

4.Galaxy比数平台功能介绍及实现原理|得物技术

5.得物App智能巡检技术的探索与实践

文 /禹极

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

RBAC 权限系统实战(三):细粒度的权限控制

前言

本文主要讲解 RBAC 后台系统中的按钮级权限控制

本文是《通俗易懂的中后台系统建设指南》系列的第十一篇文章,该系列旨在告诉你如何构建一个优秀的中后台管理系统。

RBAC 权限细粒度

在前两篇文章:RBAC 权限系统实战(一):页面级访问控制全解析RBAC 权限系统实战(二):权限信息管理的设计 中,我们在后台系统里已经实现了权限控制,但从权限细粒度的角度看,我们只做了“页面级”权限

在权限细粒度中,一般有这三种权限粒度:

  1. 页面/菜单级:用户是否能看到并访问该页面
  2. 功能/操作级:进入页面后,是否能执行某个动作(比如按钮权限)
  3. 数据级:可以操作、获取哪些数据、接口

在某些业务场景下,我们希望用户能看到/进入页面,但不一定能操作所有功能。比如同一个列表页:A 只能“查看”,B 可以“新增/编辑”,C 还能“删除/导出”

因此本文主要实现操作级权限控制,也常称为“按钮级权限控制”

权限码设计

第一篇权限文章中,我们在登录后,会请求用户信息接口,拿到用户的菜单路由数据再渲染访问

现在,还是基于这个接口返回的用户信息,我们要增加一个字段:permissionCodes,它是一个字符串数组,代表该用户拥有的操作权限

我这里使用的是 ApiFox 来模拟的接口和数据,ApiFox 文档可以访问:vue-clean-admin ApiFox 文档,内有关于”获取用户信息接口“的文档介绍

image.png

你可以看到,返回的权限码列表遵循一定的格式来确保语义清晰,我们约定,权限码的格式如下:

image.png

比如下面的菜单模块,关于新增、详情的权限码:

image.png

当然,不是说不遵守这个格式就不行,但我推荐这种格式。可以让我们在代码里更清楚地理解权限码含义,也方便后续维护

还要考虑一种情况:某个角色不管系统有多少权限都可以访问,比如超级管理员 superAdmin,对于这样的角色,我们做点特殊处理,比如用通配符 * 表示全部权限(如 *:*:*)。这时无需再做权限筛选,直接放行即可

按钮级权限实战

在操作级权限设计中,在 Vue 框架下,有三种实现按钮级权限的方式:组件式、自定义指令、函数式

  1. 组件式:编写 Vue 组件,插槽内容由权限码属性决定是否渲染
  2. 自定义指令:通过指令控制 DOM 来实现元素显隐
  3. 函数式:在函数中写权限筛选逻辑,上面两种方式都会依赖它

函数式

函数式写法最常见,把权限判断封装成工具函数或 hook 都可以。先看一个实现:

import { useUserStore } from '@/store/modules/user';
import { PermissionCode } from '#/type';
import { storeToRefs } from 'pinia';
import { isEmpty } from '@/utils';

export const useAuth = () => {
  const userStore = useUserStore();
  const { getPermissionCodes } = storeToRefs(userStore);

  //...

  /**
   * 判断是否有权限
   * @param code 权限码,可以是单个权限码字符串,也可以是权限码数组
   * @returns 是否有权限
   */
  const hasPermission = (code: PermissionCode): boolean => {
    // 如果是特殊通配符,直接放行
    if (getPermissionCodes.value.includes('*:*:*')) return true;

    // 空字符串、空数组情况,默认为无权限
    if (isEmpty(code)) return false;

    const codes = Array.isArray(code) ? code : [code];

    // 只要满足其中一个权限码即可
    return codes.some((c) => getPermissionCodes.value.includes(c));
  };

  return {
    hasPermission,
  };
};

use-auth.ts 找到实战代码

这里我把逻辑写成一个 hook,重点关注 hasPermission 方法。它接收权限码参数 code,返回一个布尔值,表示是否有权限

getPermissionCodes 表示当前用户拥有的权限码

code 参数既可以是单个字符串,也可以是数组。因为用户可以同时拥有多个权限码(如 user:adduser:edit),所以类型定义如下:

/**
 * 权限码类型
 */
export type PermissionCode<T = string | string[]> = T;

实际场景中,可配合 v-if 来控制元素显隐:

image.png

组件式

组件式很好理解:把“权限判断”封装成 Vue 组件,内部内容由权限码决定是否渲染。

这里用 AppAuth 组件示例:

<script setup lang="ts">
import { PermissionCode } from '@/types/common';
import { computed } from 'vue';
import { useAuth } from '@/hooks/useAuth';

defineOptions({
  name: 'AppAuth',
});

export interface AppAuthProps {
  /**
   * 权限码
   */
  codes: PermissionCode;
}

const props = withDefaults(defineProps<AppAuthProps>(), {
  codes: '',
});

const { hasPermission } = useAuth();

/**
 * 是否有权限
 */
const hasAuth = computed(() => {
  return hasPermission(props.codes);
});
</script>

<template>
  <slot v-if="hasAuth" />
  <slot v-else name="no-auth" />
</template>

app-auth.vue 找到代码实现

在频繁使用的场景下,最好全局注册该组件:

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import AppAuth from './components/AppAuth.vue'

const app = createApp(App)

// 全局注册
app.component('AppAuth', AppAuth)

app.mount('#app')

全局注册组件时要补上 TypeScript 的类型提示,我在 src/typings/app-components.d.ts 中添加了类型声明:

export {};

declare module 'vue' {
  export interface GlobalComponents {
    //...
    AppAuth: (typeof import('../components/common/app-auth/index'))['AppAuth'];
  }
}

然后就可以直接使用 AppAuth 组件了:

image.png

当用户没有权限时,会渲染 no-auth 插槽的内容,可以在 no-auth 插槽中自定义展示内容。

自定义指令

自定义指令也是一种很方便的实现方式,通过操作 DOM 来实现元素显隐

通过 app.directive 方法来注册 v-auth

// directives/auth.ts
import type { Directive } from 'vue';
import type { PermissionCode } from '@/types';
import { useAuth } from '@/hooks/useAuth';

export type AuthDirective = Directive<HTMLElement, PermissionCode>;

export const authDirective: AuthDirective = {
  mounted(el, binding) {
    const { hasPermission } = useAuth();
    if (!hasPermission(binding.value)) {
      el.remove();
    }
  },
  updated(el, binding) {
    const { hasPermission } = useAuth();
    if (!hasPermission(binding.value)) {
      el.remove();
    }
  },
};

directives/modules/auth.ts 找到代码实现

然后在 main.ts 中注册 v-auth 指令:

// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { authDirective } from './directives/auth';

const app = createApp(App);
app.directive('auth', authDirective);
app.mount('#app');

同样要补上全局指令的类型定义,在 src/typings/directive.d.ts 中添加类型声明:

import type { AuthDirective } from '@/directives/typing';

declare module 'vue' {
  export interface GlobalDirectives {
    vAuth: AuthDirective;
  }
}

然后就可以使用 v-auth 指令来实现权限控制:

image.png

菜单管理、角色管理

在权限实战第二篇:RBAC 权限系统实战(二):权限信息管理的设计 中,实现了菜单、角色管理的基本管理操作,比如菜单 CRUD、角色绑定权限等操作

从细粒度来看,我们现在多做了一层操作级权限,关于这两个模块,要进行一点小改动

在菜单管理中,新增、编辑菜单等操作中,新加一个”操作“的类型,以支持添加操作级权限信息

image.png

注意这里要填写的表单信息,是根据菜单类型来展示不同的字段,比如“操作”类型,需要填写权限码

然后,菜单列表的数据是这样的:

image.png

在角色管理模块中,主要关注”分配权限“的操作,允许给角色分配操作级权限

image.png

了解更多

系列专栏地址:GitHub 博客 | 掘金专栏 | 思否专栏

实战项目:vue-clean-admin

交流讨论

文章如有错误或需要改进之处,欢迎指正。

TinyVue 3.28.0 正式发布:稳定性大幅提升!

本文由体验技术团队TinyVue项目组原创。

一、前言

我们非常高兴地宣布,最近,TinyVue发布了 v3.28.0🎉, 这个版本带来了:

  • 选择器组件家族全面重构 - 统一架构,性能提升
  • 主题动画全局配置- 一键定制,随心所欲
  • 65+Bug 及优化修复 - 稳定性大幅提升

详细的 Release Notes 请参考:github.com/opentiny/ti…

本次版本共有 11 位贡献者参与开发,其中 IKEYCY / neostfox 是新朋友,欢迎新朋友的加入👏,感谢新老朋友们对 TinyVue 的辛苦付出👏

  • IKEYCY- 新增贡献者✨
  • neostfox- 新增贡献者✨
  • shenjunjian
  • kagol
  • zzcr
  • gimmyhehe
  • Davont
  • discreted66
  • wuyiping0628
  • James-9696
  • gausszhou

同时,如果你在使用过程中遇到任何问题,或者有好的建议,欢迎:

二、升级指南

你可以更新 @opentiny/vue@3.28.0 进行体验!

# 安装最新版本

npm install @opentiny/vue@3.28.0

# 或使用 yarn

yarn add @opentiny/vue@3.28.0

如果遇到问题,可以:

查看 Issue - 在 GitHub 上搜索相关问题 提交 Issue - 如果问题未解决,提交新的 Issue

三、特性介绍

下面我们一起来看看都有哪些更新吧!

选择器组件"家族重组"

为什么需要重构?

Select 组件的现状和问题:

  • Select 组件中耦合了 Tree / Grid 两个重型组件,分别对应下拉树和下拉表格两个特性,render-type="tree" | "grid"
  • 下拉树和下拉表格并不是常态,普通的下拉列表才是常态,这就导致了大量只使用Select简单功能的业务包体积也很大,影响业务性能
  • 依赖了 Select 的组件,比如 Area,间接地等于依赖了 Select / Grid / Tree,导致包体积变大
  • 本来应该依赖基于 Select 组件的组件,比如 Pager,由于 Select 耦合了 tree/grid,因此只能自己实现一个 Select,造成重复代码

我们使用 Vite 创建一个空的 Vue 项目,对比下不同情况下构建产物体积情况:

产物体积(css+js, 单位kB) gzip之后的产物体积(单位kB)
不引入TinyVue组件 56 23
只引入Select组件 1777 424
只引入Tree组件 789 190
只引入Grid组件 1217 302
只引入Button 310 91
只引入Area组件(依赖Select) 1783 425

不引入TinyVue组件/只引入Select组件/只引入Tree组件的产物体积对比:

1.png

只使用 Area 组件(依赖了Select组件)的产物体积:

2.png 可以看到:

  • 只引入 Select 组件,产物里面却同时包含了 tree/grid 两个组件,导致产物体积很大
  • Area 组件本身只是一个很简单的组件,由于引入了 Select,导致产物体积也非常大

重构目标

本次重构主要达成以下目标:

  1. 从 Select 组件中**剥离 Tree / Grid 组件,让业务在单引Select组件时不再包含 tree/grid 两个重型组件
  2. 减少业务单引Select组件(包括TinyVue组件中依赖了Select的组件)时的包体积,优化性能
  3. 重构完不能引起破坏性变更,不能影响现有业务

重构方案

为了达成以上目标,我们设计并实行了以下重构方案:

  1. 开发一个新组件 BaseSelect,这个组件和 Select 组件的api和功能完全一致,只是移除了 tree/grid 相关api和功能
  2. BaseSelect 组件增加panel插槽,并设计好panel与reference的沟通机制,让用户可以在panel插槽放置任意内容,包括tree/grid等组件,从而实现下拉树、下拉表格等功能
  3. 基于 BaseSelect 封装 TreeSelect 组件,实现下拉树组件
  4. 基于 BaseSelect 封装 GridSelect 组件,实现下拉表格组件
  5. 重构 Select,移除原有的 tree/grid 功能,基于 BaseSelect / TreeSeelct / GridSelect 组件进行封装,全新的 Select 组件api和功能与原来的Select组件一模一样,不影响用户使用
  6. 开发全新select-wrapper包装器,包含原本select所有功能用于平替

重构后组件关系如下图:

3.png

业务性能优化

使用了 Select 组件的业务,如果想要优化性能,可以:

  • 只需要Select基本功能的业务,可以通过全局替换 tiny-selecttiny-base-select 来实现性能优化
  • 使用了Select组件下拉树功能的业务,可以通过全局替换 tiny-selecttiny-tree-select 来实现性能优化
  • 使用了Select组件下拉表格功能的业务,可以通过全局替换 tiny-selecttiny-grid-select 来实现性能优化
  • 如果业务同时使用了下拉树和下拉表格功能,则可以使用 SelectWrapper 组件

场景示例

仅使用base-select与select组件打包对比包体积减少50%以上

4.png

新增功能:懒加载支持

tree-select 现在支持懒加载,想象一下,一个包含 10,000 个节点的树形选择器,以前需要一次性加载所有数据,现在可以按需加载,性能提升不是一点点!

懒加载的使用场景

  1. 大数据量树形结构 - 当树节点数量超过 1000 个时,懒加载可以显著提升性能
  2. 动态数据加载 - 数据需要从服务器按需获取
  3. 减少初始加载时间 - 只加载用户需要查看的节点

主题动画:一键定制,随心所欲

全局动画配置

为 TinyVue 提供 全局动效配置能力,基于 LESS 与 CSS 变量,实现以下目标:

  1. 统一管理:所有动效集中维护,避免分散定义与重复工作。
  2. 全局可控:通过 CSS 变量统一控制动效的持续时间、延迟、速度等参数。
  3. 组件集成:组件可直接调用统一的动效类名或 @keyframes
  4. 动态可调:通过覆盖 CSS 变量即可在不同场景下切换动效风格。

全局变量定义

/packages/theme/src/base/vars.less 中统一定义动效变量:

:root {
  /* 蚂蚁线相关配置 */
  --tv-motion-ants-shift: 8px;
  --tv-motion-ants-speed: 0.8s;

  /* 其他动效参数... */
}

开发者可在组件主题文件中覆盖这些变量:

.copyed-borders {
  --tv-motion-ants-shift: 12px;
  --tv-motion-ants-speed: 1.2s;
}

也可通过在 /packages/theme/src/base/ 下创建 motion-theme.less 来切换全局动效风格:

:root {
  --tv-motion-ants-shift: 12px;
  --tv-motion-ants-speed: 1.2s;
}

动效分类与目录结构

所有动效存放在 /packages/theme/src/motion/ 目录下,按类型拆分:

motion/
  ├─ fade.less        // 淡入淡出
  ├─ slide.less       // 滑动
  ├─ zoom.less        // 缩放
  ├─ rotate.less      // 旋转
  ├─ bounce.less      // 弹跳
  ├─ scroll.less      // 滚动
  ├─ stroke.less      // 描边
  ├─ shine.less       // 闪烁
  ├─ ants.less        // 蚂蚁线
  ├─ arrow.less       // 箭头
  ├─ tab.less         // Tab 切换
  ├─ progress.less    // 进度条
  └─ index.less       // 统一引入

动效示例

1. 淡入淡出 (fade.less)

@keyframes fade-in {
  0%   { opacity: 0; }
  100% { opacity: 1; }
}

@keyframes fade-out {
  0%   { opacity: 1; }
  100% { opacity: 0; }
}

组件调用示例:

.@{fade-prefix-cls} {
  &-enter-active {
    animation: var(--tv-motion-fade-speed) fade-in ease-out both;
  }
  &-leave-active {
    animation: var(--tv-motion-fade-speed) fade-out ease-in both;
  }
}

5.gif2. 滑动 (slide.less)

@keyframes slide-left-in {
  0%   { opacity: 0; transform: translateX(var(--tv-motion-slide-offset-left)); }
  50%  { opacity: var(--tv-motion-slide-opacity-mid); transform: translateX(var(--tv-motion-slide-offset-left-mid)); }
  100% { opacity: 1; transform: translateX(0%); }
}

@keyframes slide-left-out {
  0%   { opacity: 1; transform: translateX(0%); }
  50%  { opacity: var(--tv-motion-slide-opacity-mid); transform: translateX(var(--tv-motion-slide-offset-left-mid)); }
  100% { opacity: 0; transform: translateX(var(--tv-motion-slide-offset-left)); }
}

组件调用示例:

.drawer-slide-left-enter-active {
  animation: slide-left-in var(--tv-motion-slide-speed) linear;
}
.drawer-slide-left-leave-active {
  animation: slide-left-out var(--tv-motion-slide-speed) linear;
}

6.gif3. 蚂蚁线 (ants.less,可配置)

@keyframes ants-x {
  0%   { background-position: 0 0; }
  100% { background-position: var(--tv-motion-ants-shift, 8px) 0; }
}

@keyframes ants-x-rev {
  0%   { background-position: 0 0; }
  100% { background-position: calc(-1 * var(--tv-motion-ants-shift, 8px)) 0; }
}

组件调用示例:

.@{grid-prefix-cls}-copyed-borders {
  --tv-motion-ants-shift: 13px;

  .@{grid-prefix-cls}-border-top {
    animation: ants-x var(--tv-motion-ants-speed) linear infinite;
  }
  .@{grid-prefix-cls}-border-right {
    animation: ants-y var(--tv-motion-ants-speed) linear infinite;
  }
  .@{grid-prefix-cls}-border-bottom {
    animation: ants-x-rev var(--tv-motion-ants-speed) linear infinite;
  }
  .@{grid-prefix-cls}-border-left {
    animation: ants-y-rev var(--tv-motion-ants-speed) linear infinite;
  }
}

7.gif

组件集成方式

  1. 全局引入 所有 @keyframestransition.lessmotion/* 中集中维护,统一加载。
  2. 局部调用 组件可通过 classNameanimation 调用指定动效。
  3. 可配置参数 开发者可通过覆盖 :root 变量调整动效时长、速度等参数。

四、其他重要更新

下拉菜单右键支持

dropdown 组件现在支持右键菜单触发了!这对于需要上下文菜单的场景非常有用。

8.gif

使用场景

右键菜单在很多业务场景中都非常常见:

  • 表格行操作 - 在表格行上右键显示操作菜单
  • 文件管理 - 文件列表的右键菜单
  • 编辑器 - 文本编辑器的上下文菜单
  • 图形界面 - 画布元素的右键菜单

支持的触发方式

  • click - 点击触发(默认)
  • hover - 悬停触发
  • contextmenu - 右键触发(新功能)
  • focus - 聚焦触发

Switch 组件宽度自定义

switch 组件现在支持自定义宽度了!不再局限于固定的尺寸。

9.png

使用场景

自定义宽度让你可以:

  • 适配不同设计风格 - 根据 UI 设计调整开关大小
  • 提升视觉层次 - 通过不同尺寸区分重要程度
  • 响应式设计 - 在不同屏幕尺寸下使用不同宽度
  • 样式定制 - 配合 CSS,你可以进一步定制开关的样式

Modal 头部拖拽

modal 组件现在支持设置 headerDragable 属性,让用户可以拖拽弹窗头部来移动弹窗位置。

10.gif

使用场景

拖拽功能特别适合:

  • 多窗口场景 - 用户可以自由调整弹窗位置,避免遮挡
  • 大屏幕应用 - 在宽屏显示器上,拖拽可以提升操作效率
  • 用户个性化 - 让用户按照自己的习惯摆放弹窗

注意事项

  • 拖拽功能只在弹窗未全屏时生效
  • 拖拽范围受视口限制,不会拖出屏幕
  • 可以通过 CSS 自定义拖拽时的样式

Drawer 按 ESC 关闭

drawer 组件现在支持通过按 `ESC` 键关闭,用户体验更加友好。

11.gif

使用场景

ESC 键关闭是用户习惯的操作方式:

  • 符合用户预期 - 大多数应用都支持 ESC 关闭
  • 提升操作效率 - 键盘操作比鼠标点击更快
  • 无障碍支持 - 方便键盘用户操作

其他关闭方式

Drawer 组件支持多种关闭方式:

  • 点击遮罩层关闭(默认)
  • 点击关闭按钮
  • 按 ESC 键关闭(新功能)
  • 调用 close() 方法

Tree Menu 节点点击增强

tree-menu 组件现在支持在文档中点击添加节点,交互更加直观。

使用场景

这个功能特别适合:

  • 可视化编辑 - 在文档中直接点击添加节点
  • 快速操作 - 提升节点添加的效率
  • 直观交互 - 所见即所得的编辑体验

Guide 组件触发条件优化

guide组件现在支持showStep属性,只有在showSteptrue` 时才会触发引导。

12.gif

使用场景

这个优化让你可以:

  • 条件触发 - 只在特定条件下显示引导
  • 避免干扰 - 不会在用户不需要时弹出
  • 灵活控制 - 根据业务逻辑动态控制引导显示

五、结语

TinyVue v3.28.0 版本的发布,实现了多项重要升级:对选择器组件家族进行了彻底重构,解耦了 Tree / Grid 等重型功能,显著降低了单个组件的体积;新增了全局主题动画配置,让动画效果可通过 CSS 变量随意定制;引入了懒加载、右键菜单、宽度自定义、弹窗拖拽、ESC 关闭等实用功能,进一步提升了开发体验和用户交互;同时修复了 65+ 个 Bug,整体稳定性大幅提升。通过这些改进,TinyVue 不仅在性能上实现了突破,也为开发者提供了更灵活、可维护的组件库,期待在未来的项目中为你带来更高效、更优雅的开发体验,让我们一起,让前端开发变得更简单、更高效!

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue源码:github.com/opentiny/ti…

欢迎进入代码仓库 Star🌟TinyVue、TinyEngine、TinyPro、TinyNG、TinyCLI、TinyEditor 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

JavaScript面向对象编程的演变

JavaScript 是产生“类”的?又是如何演变成“面向对象”的?Class语法糖背后隐藏着什么秘密?本篇文章将完整梳理 JavaScript 面向对象编程的发展历程。

前言:为什么JavaScript需要面向对象?

var name = '张三';
var age = 25;
var job = '工程师';

function sayHello(person) {
    console.log('你好,我是' + person.name);
}

在早期的 JavaScript 代码中,我们通常采用的是过程式编程。但随着应用复杂度增加,我们需要更好的代码组织方式,因此面向对象编程应运而生。

工厂模式:面向对象的雏形

什么是工厂模式?

工厂模式 是最简单的创建对象的方式,它就像一个“工厂”一样批量生产对象。我们来看一个简单的工厂模式示例:

// 创建Person对象的工厂
function createPerson(name, age, job) {
    // 1. 创建一个新对象
    var obj = {};
    
    // 2. 添加属性
    obj.name = name;
    obj.age = age;
    obj.job = job;
    
    // 3. 添加方法
    obj.sayHello = function() {
        console.log('你好,我是' + this.name + ',今年' + this.age + '岁');
    };
    
    obj.work = function() {
        console.log(this.name + '正在工作:' + this.job);
    };
    
    // 4. 返回对象
    return obj;
}

// 使用工厂模式创建对象
var person1 = createPerson('张三', 25, '前端工程师');
var person2 = createPerson('李四', 30, '后端工程师');

person1.sayHello(); // 你好,我是张三,今年25岁
person2.work();     // 李四正在工作:后端工程师

console.log(person1.sayHello === person2.sayHello); // false

但这种方式存在一个问题:每个对象都有独立的方法副本,浪费内存。

工厂模式的优点

  1. 简单易懂
  2. 可以创建多个相似对象
  3. 封装了创建过程

工厂模式的缺点

  1. 无法识别对象类型:person1 instanceof createPerson; // false
  2. 方法重复创建,内存浪费

构造函数模式:引入"类型"概念

什么是构造函数?

构造函数通过 new 关键字创建对象,解决了工厂模式的类型识别问题。我们来看一个简单的示例:

// 构造函数模式
function Person(name, age, job) {
    // 1. 创建一个新对象(隐式:this = {})
    // 2. 设置原型链(隐式:this.__proto__ = Person.prototype)
    // 3. 添加属性
    this.name = name;
    this.age = age;
    this.job = job;
    
    // 4. 添加方法(仍然有问题)
    this.sayHello = function() {
        console.log('你好,我是' + this.name);
    };
    // 5. 返回this(隐式:return this)
}

// 使用new关键字创建对象(实例)
var person1 = new Person('张三', 25, '工程师');
var person2 = new Person('李四', 30, '设计师');

person1.sayHello(); // 你好,我是张三
person2.sayHello(); // 你好,我是李四

console.log(person1 instanceof Person); // true
console.log(person1 instanceof Object); // true
console.log(person1.constructor === Person); // true

console.log(person1.sayHello === person2.sayHello); // false

从上述代码中,我们可以看出:构造函数模式中,可以识别对象类型了;但每个实例仍有独立的方法副本,内存浪费问题仍然存在。

new操作符的工作原理

function myNew(constructor, ...args) {
    // 1. 创建一个新对象
    const obj = {};
    // 2. 设置原型链:将新对象的__proto__指向构造函数的prototype
    obj.__proto__ = constructor.prototype;
    // const obj = Object.create(Constructor.prototype);  // 这种写法也是可以的
    
    // 3. 绑定this并执行构造函数
    const result = constructor.apply(obj, args);
    
    // 4. 返回结果(如果构造函数返回对象,则返回该对象,否则返回新对象)
    return result instanceof Object ? result : obj;
}

原型模式:解决方法共享问题

原型模式的处理方法是:将方法定义在原型上,实现共享。

function Person(name, age) {
    // 属性定义在实例上(每个实例独立)
    this.name = name;
    this.age = age;
}

// 方法定义在原型上(所有实例共享)
Person.prototype.sayHello = function() {
    console.log('你好,我是' + this.name + ',今年' + this.age + '岁');
};

Person.prototype.work = function() {
    console.log(this.name + '正在工作');
};

// 创建实例
const p1 = new Person('张三', 25);
const p2 = new Person('李四', 30);

p1.sayHello(); // 你好,我是张三,今年25岁
p2.sayHello(); // 你好,我是李四,今年30岁

// 现在方法是共享的!
console.log(p1.sayHello === p2.sayHello); // true

原型模式带来的问题

  1. 所有实例都会共享引用类型属性,如果在原型上定义引用类型,一个数据修改时,所有对象对应的数据都会修改
  2. 所有实例共享相同的原型属性,无法动态传递初始化参数

组合继承:结合构造函数和原型的优点

组合继承的实现

组合继承的实现:使用构造函数定义实例属性,使用原型定义共享方法。

function Parent(name) {
    this.name = name;
    this.colors = ['red', 'blue'];
}

Parent.prototype.sayName = function() {
    console.log(this.name);
};

function Child(name, age) {
    Parent.call(this, name); // 第一次调用:继承实例属性
    this.age = age;
}

Child.prototype = new Parent(); // 第二次调用:继承原型方法
Child.prototype.constructor = Child; // 修复constructor指向

组合继承是JavaScript中最常用的继承模式。

组合继承的缺点

父类构造函数被调用了两次:

  1. Parent.call(this, name); 第一次调用:继承实例属性
  2. Child.prototype = new Parent(); 第二次调用:继承原型方法

寄生组合继承:最理想的继承方式

寄生组合继承的实现

function inheritPrototype(child, parent) {
    // 创建父类原型的副本
    const prototype = Object.create(parent.prototype);
    
    // 修复constructor指向
    prototype.constructor = child;
    
    // 将副本设置为子类的原型
    child.prototype = prototype;
}

// 父类
function Animal(name) {
    this.name = name;
    this.colors = ['red', 'blue'];
}

Animal.prototype.sayName = function() {
    console.log('我是:' + this.name);
};

// 子类
function Dog(name, age) {
    // 继承实例属性(只调用一次父类构造函数)
    Animal.call(this, name);
    this.age = age;
}

// 继承原型方法(不使用new Parent(),避免第二次调用)
inheritPrototype(Dog, Animal);

// 添加子类特有方法
Dog.prototype.bark = function() {
    console.log(this.name + '在叫:汪汪!');
};

Class语法:ES6的语法糖

Class的基本语法

class Person {
    // 构造函数(对应ES5的构造函数)
    constructor(name, age) {
        // 实例属性
        this.name = name;
        this.age = age;
        this._secret = '这是我的秘密'; // 约定俗成的"私有"属性
    }
    
    // 实例方法(自动添加到原型上)
    introduce() {
        console.log(`大家好,我是${this.name},今年${this.age}岁`);
    }
    eat(food) {
        console.log(`${this.name}正在吃${food}`);
    }
    
    // getter和setter
    get secret() {
        return this._secret;
    }
    
    set secret(value) {
        this._secret = value;
    }
    
    // 静态方法(类方法)
    static createAnonymous() {
        return new Person('匿名', 0);
    }
}

Class语法背后的原型原理

Class 语法只是语法糖,底层仍然是原型继承,其本质是一个函数:

class Animal {
    constructor(name) {
        this.name = name;
    }
    
    speak() {
        console.log(this.name + ' makes a noise.');
    }
}
// Class实际上是一个函数
console.log(typeof Animal); // function

extends 继承

extends 基本语法

在 ES6 的 class 语法糖中,通过 extends 语法糖实现继承。

class Dog extends Animal {
    constructor(name) {
        super(name);
    }
    
    speak() {
        console.log(this.name + ' barks.');
    }
}

extends 继承的本质

extends 继承的本质,就等价于ES5的寄生组合继承:

function AnimalES5(name) {
    this.name = name;
}

AnimalES5.prototype.speak = function() {
    console.log(this.name + ' makes a noise.');
};

function DogES5(name) {
    AnimalES5.call(this, name);
}

// 设置原型链
DogES5.prototype = Object.create(AnimalES5.prototype);
DogES5.prototype.constructor = DogES5;

DogES5.prototype.speak = function() {
    console.log(this.name + ' barks.');
};

Class的高级特性

类表达式

const MyClass = class {
    constructor(value) {
        this.value = value;
    }
    
    getValue() {
        return this.value;
    }
};

const obj1 = new MyClass(42);
console.log(obj1.getValue()); // 42

私有字段

class BankAccount {
    // 私有字段(以#开头)
    #balance = 0;
    
    constructor(owner) {
        this.owner = owner;
    }
    
    // 通过公开方法访问私有字段
    getBalance() {
        return this.#balance;
    }
}

const account = new BankAccount('张三');

// console.log(account.#balance); // SyntaxError: 属性 "#balance" 在类 "BankAccount" 外部不可访问。
console.log(account.getBalance()); // 500

静态块

class Config {
    static dbConfig;
    static apiConfig;
    
    // 静态初始化块
    static {
        console.log('初始化静态配置...');
        this.dbConfig = {
            host: 'localhost',
            port: 3306,
            username: 'root'
        };
        
        this.apiConfig = {
            baseUrl: 'https://api.example.com',
            timeout: 5000
        };
    }
    
    static getConfig() {
        return {
            db: this.dbConfig,
            api: this.apiConfig
        };
    }
}

类的访问器属性

class Temperature {
    constructor(celsius) {
        this.celsius = celsius;
    }
    
    get fahrenheit() {
        return this.celsius * 1.8 + 32;
    }
    
    set fahrenheit(value) {
        this.celsius = (value - 32) / 1.8;
    }
    
    // 只读属性
    get kelvin() {
        return this.celsius + 273.15;
    }
}

Mixin模式(多重继承的替代方案)

const FlyMixin = (BaseClass) => class extends BaseClass {
    fly() {
        console.log(`${this.name} is flying!`);
    }
};

const SwimMixin = (BaseClass) => class extends BaseClass {
    swim() {
        console.log(`${this.name} is swimming!`);
    }
};

class Animal {
    constructor(name) {
        this.name = name;
    }
    
    eat() {
        console.log(`${this.name} is eating`);
    }
}

// 应用Mixin
class Duck extends SwimMixin(FlyMixin(Animal)) {
    quack() {
        console.log(`${this.name} says: Quack!`);
    }
}

面向对象编程的演进路线

工厂模式 → 构造函数模式 → 原型模式 → 组合继承 → 寄生组合继承 → Class语法
    ↓        ↓           ↓         ↓           ↓           ↓
创建对象    识别类型    共享方法    结合优点     最优方案       语法糖

结语

面向对象编程是 JavaScript 发展的重要里程碑。理解从工厂模式到 Class 语法的演进过程,不仅能让我们写出更好的代码,还能在遇到问题时快速定位和解决。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

[Nuxt 4 实战] 告别 Import 地狱:Nuxt 自动导入与目录结构优化指南

前言

做前端开发的同学一定有过这种痛苦:写一个组件,开头先写 10 行 import: 引入 Vue Hook、引入 UI 组件、引入工具函数、引入 Store...

// ❌ 传统的 Vue 开发体验
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import Button from '@/components/Button.vue'
import { formatDate } from '@/utils/date'
import { useUserStore } from '@/stores/user'
// ... 心累

在开发 SonicToolLab 的过程中,我最大的感受就是:Nuxt 4 让代码变“干净”了。整个项目开发下来,我几乎没有手动写过 import

今天就来复盘一下,如何利用 Nuxt 4 的工程化特性,把开发体验(DX)拉满。

🪄 1. 全自动导入:Composables 与 Utils

Nuxt 扫描目录的逻辑非常强大。默认情况下,它会自动扫描以下目录,并把导出的函数注册为全局可用:

  • components/
  • composables/
  • utils/

实战对比

假设我有一个日期格式化函数。

传统方式:

需要创建文件,导出,然后在每个需要用的页面手动导入。

Nuxt 方式:

直接在 utils 目录下创建 date.ts

TypeScript

// utils/date.ts
export const formatDate = (date: Date) => {
  return new Intl.DateTimeFormat('zh-CN').format(date)
}

然后在任何 .vue 文件中直接使用

Code snippet

<template>
  <div>{{ formatDate(new Date()) }}</div>
</template>

Nuxt 的引擎会在编译时自动分析代码,发现你用了 formatDate,它才会自动帮你生成 import 代码(Tree-shaking 依然有效,没用到的不会打包)。

📂 2. 组件的自动注册与命名规范

components/ 目录下的 Vue 组件也是自动导入的。

目录结构即组件名

Nuxt 推荐使用“目录嵌套”来命名组件,这比手动起名更直观。

Plaintext

components/
  |-- base/
  |    |-- Button.vue
  |-- tools/
       |-- Header.vue

在页面中使用时,组件名会自动拼接目录名:

Code snippet

<template>
  <BaseButton />
  
  <ToolsHeader />
</template>

实战技巧:

如果你想让组件名短一点,可以在 nuxt.config.ts 中配置 pathPrefix 为 false,但不建议在大型项目中这样做,容易命名冲突。

🌳 3. 嵌套目录的深度扫描

默认情况下,composablesutils 只扫描顶层文件。如果你的工具站逻辑很复杂,分了很多文件夹怎么办?

比如:

Plaintext

composables/
  |-- user/
       |-- auth.ts
  |-- tools/
       |-- json.ts

默认情况下 auth.ts 可能不会被自动导入。我们需要在配置文件中显式告诉 Nuxt 去扫描嵌套目录。

TypeScript

// nuxt.config.ts
export default defineNuxtConfig({
  imports: {
    dirs: [
      // 扫描 composables 下的所有子目录
      'composables/**',
      // 扫描 utils 下的所有子目录
      'utils/**'
    ]
  }
})

这样配置后,你的目录结构可以无限嵌套,依然享受自动导入的便利。

⚠️ 4. 避坑指南:重名危机

自动导入虽然爽,但最怕命名冲突

场景还原:

你定义了一个 utils/ref.ts,里面导出了一个 const ref = ...

此时,Vue 原生的 ref 就会被覆盖,导致整个项目报错。

避坑建议:

  1. 命名加前缀: 自定义的 hook 建议统一加 use 前缀(如 useMyTool)。
  2. 工具函数具体化: 尽量不要用 datemath 这种通用词做文件名,建议用 dateFormatter.ts
  3. 检查 .nuxt/imports.d.ts 如果你发现 IDE 提示类型不对,可以去 .nuxt 目录下看看自动生成的类型定义,通常能找到是谁覆盖了谁。

🧩 5. 类型安全的保证 (TypeScript)

有人担心: “不写 import,IDE 怎么知道类型?怎么跳转代码?”

Nuxt 4 在启动开发服务器 (nuxi dev) 时,会动态生成 tsconfig.json 和类型声明文件。

只要你使用 VS Code + Volar 插件,即使不写 import:

  • 鼠标悬停可以看到函数签名。
  • 按住 Ctrl 点击可以跳转到定义处。
  • 输入函数名时有自动补全。

这背后是 Nuxt 强大的类型生成引擎在工作,完全不需要我们操心。

总结

在开发 SonicToolLab 时,这一套“零 Import”的体验大大提升了我的编码速度。我不再需要在这个文件里找引用路径,也不用担心重构目录后路径报错。

我们要做的,就是把文件放在对的地方(Convention),剩下的交给框架(Configuration)。

👉 SonicToolLab 在线体验

JavaScript 对象创建完全指南:从对象字面量到 Class 继承

JavaScript 对象创建完全指南:从对象字面量到 Class 继承

第一章:深入理解JavaScript的面向对象

1.1 JavaScript的"独特"面向对象哲学

JavaScript采用了一种与众不同的面向对象编程(OOP)范式。要真正掌握它,我们需要先理解两个核心概念:

基于原型 vs 基于类

想象你要制作一批玩具猫:

  • 传统面向对象(Java/C++等): 就像用模具制造玩具:
    1. 先设计模具(类 Class)
    2. 用模具批量生产(实例化)
    3. 每个玩具都从同一个模具出来,结构相同
// Java示例(对比理解)
class Cat {  // 模具
    String name;
    String color;
    
    Cat(String name, String color) {
        this.name = name;
        this.color = color;
    }
    
    void eat() {
        System.out.println(name + "在吃东西");
    }
}

Cat garfield = new Cat("加菲猫", "黄色");  // 用模具生产
  • JavaScript面向对象: 更像细胞分裂或DNA复制:
    1. 有一个原型对象(就像DNA)
    2. 新对象从这个原型"继承"特性
    3. 可以随时修改原型,影响所有"后代"
// JavaScript原型示例
const catDNA = {  // 原型,像DNA模板
    type: "猫科动物",
    eat: function() {
        console.log(this.name + "在吃东西");
    }
};

const garfield = Object.create(catDNA);  // 基于DNA创建
garfield.name = "加菲猫";
garfield.color = "黄色";

1.2 为什么JavaScript选择这种模式?

JavaScript在1995年被创造时,设计者Brendan Eich有几个考虑:

  1. 轻量级:不需要复杂的类系统
  2. 动态性:运行时可以修改对象结构
  3. 简单性:基于已有的对象创建新对象更直观

这种设计使得JavaScript非常灵活,但也让初学者感到困惑。让我们一步步揭开它的神秘面纱。

第二章:最基础的创建方式——对象字面量

2.1 什么是对象字面量?

对象字面量是JavaScript中最直接的对象创建方式。就像手工制作工艺品,每个都是独立完成的。

// 创建一个猫对象——完全手工制作
const cat1 = {
    name: "加菲猫",      // 属性:名字
    color: "黄色",       // 属性:颜色
    type: "猫",          // 属性:种类
    age: 3,              // 属性:年龄
    
    // 方法:行为
    eat: function() {
        console.log(this.name + "在吃意大利面");
    },
    
    sleep: function() {
        console.log(this.name + "在沙发上睡觉");
    },
    
    // ES6简写方法
    meow() {
        console.log("喵喵~ 我是" + this.name);
    }
};

// 使用对象
cat1.eat();      // 输出:加菲猫在吃意大利面
cat1.meow();     // 输出:喵喵~ 我是加菲猫
console.log(cat1.age);  // 输出:3

2.2 对象字面量的工作原理

当你在JavaScript中写下 {},引擎会:

  1. 内存分配:在内存中分配空间
  2. 属性存储:将属性名和值存储为键值对
  3. 方法存储:函数也是对象,被存储在内存中
  4. 引用返回:返回这个对象的引用(内存地址)
// 深入了解对象结构
const cat = {
    name: "咪咪",
    color: "白色"
};

// 实际在内存中是这样的:
// cat → [内存地址] → {
//     name: "咪咪" (存储在内存某处)
//     color: "白色" (存储在内存另一处)
// }

2.3 动态添加和修改属性

对象字面量创建的对象是完全动态的:

const cat = { name: "小黄" };

// 动态添加属性
cat.color = "黄色";           // 添加颜色属性
cat["age"] = 2;              // 另一种添加方式
cat.owner = {                // 添加对象属性
    name: "小明",
    age: 10
};

// 动态添加方法
cat.eat = function(food) {
    console.log(this.name + "在吃" + food);
};

// 动态修改属性
cat.name = "大黄";           // 修改名字
cat.age += 1;                // 年龄增加

// 动态删除属性
delete cat.owner;            // 删除owner属性

// 检查属性是否存在
console.log("name" in cat);          // true
console.log(cat.hasOwnProperty("color"));  // true
console.log(cat.gender);             // undefined

2.4 对象字面量的优缺点分析

优点

  • 简单直观:语法清晰,易于理解
  • 快速创建:不需要复杂的定义过程
  • 灵活修改:可以随时添加、修改、删除属性
  • JSON兼容:JSON格式就是基于对象字面量

缺点

// 问题1:代码重复(创建多个相似对象时)
const cat1 = {
    name: "加菲猫",
    color: "黄色",
    type: "猫",
    eat: function() { console.log("吃鱼"); }
};

const cat2 = {
    name: "小白猫",      // 重复的属性定义
    color: "白色",       // 重复的属性定义  
    type: "猫",          // 重复的属性定义
    eat: function() { console.log("吃鱼"); }  // 重复的函数定义
};

// 问题2:类型关系不明确
console.log(cat1.constructor);  // Object,不是Cat
console.log(cat1 instanceof ?); // 没有类型概念

// 问题3:方法重复创建,浪费内存
console.log(cat1.eat === cat2.eat);  // false,两个相同的函数

2.5 适用场景

对象字面量最适合以下情况:

  1. 配置对象:一次性使用的设置
const config = {
    apiUrl: "https://api.example.com",
    timeout: 5000,
    retryTimes: 3,
    headers: {
        "Content-Type": "application/json"
    }
};
  1. 数据容器:临时存储数据
const formData = {
    username: "john_doe",
    email: "john@example.com",
    age: 25,
    interests: ["编程", "游戏", "音乐"]
};
  1. 命名空间:组织相关功能
const MathUtils = {
    PI: 3.141592653589793,
    
    add: function(a, b) {
        return a + b;
    },
    
    multiply: function(a, b) {
        return a * b;
    },
    
    randomInt: function(min, max) {
        return Math.floor(Math.random() * (max - min + 1)) + min;
    }
};

// 使用命名空间
console.log(MathUtils.PI);
console.log(MathUtils.add(5, 3));
  1. 枚举值:定义常量
const Colors = {
    RED: "#FF0000",
    GREEN: "#00FF00", 
    BLUE: "#0000FF",
    WHITE: "#FFFFFF",
    BLACK: "#000000"
};

// 使用枚举
button.style.backgroundColor = Colors.BLUE;

第三章:构造函数模式——真正的"制造工厂"

3.1 从手工制作到批量生产

当我们发现需要创建很多相似对象时,对象字面量就显得效率低下了。这时,构造函数模式应运而生。

现实比喻

  • 对象字面量:像手工雕刻,每只猫都要从头开始
  • 构造函数:像使用模具,一次设计,多次生产

3.2 构造函数的基本用法

// 定义一个猫的"模具"——构造函数
function Cat(name, color, age) {
    // new Cat() 被调用时,发生以下事情:
    // 1. 创建一个新的空对象 {}
    // 2. this 指向这个新对象
    // 3. 执行构造函数内的代码
    // 4. 返回这个新对象(默认情况)
    
    // 给新对象添加属性
    this.name = name;     // this.name 就是新对象的.name
    this.color = color;   // this.color 就是新对象的.color
    this.age = age || 1;  // 默认年龄为1
    
    // 给新对象添加方法
    this.eat = function(food) {
        console.log(this.name + "在吃" + food);
    };
    
    this.sleep = function(hours) {
        console.log(this.name + "要睡" + hours + "小时");
    };
    
    this.getInfo = function() {
        return this.name + "是一只" + this.age + "岁的" + this.color + "猫";
    };
}

// 使用构造函数创建实例(用模具生产)
const garfield = new Cat("加菲猫", "黄色", 5);
const doraemon = new Cat("哆啦A梦", "蓝色", 0);  // 机器猫,年龄为0
const helloKitty = new Cat("凯蒂猫", "白色", 10);

// 使用这些猫
garfield.eat("意大利面");        // 输出:加菲猫在吃意大利面
console.log(doraemon.getInfo()); // 输出:哆啦A梦是一只0岁的蓝色猫
helloKitty.sleep(12);           // 输出:凯蒂猫要睡12小时

3.3 深入理解 new 关键字

new 操作符是构造函数模式的核心,它背后做了很多工作:

// 手动模拟 new 操作符的工作
function customNew(constructor, ...args) {
    // 1. 创建一个空对象
    const obj = {};
    
    // 2. 设置原型链:新对象的__proto__指向构造函数的prototype
    obj.__proto__ = constructor.prototype;
    
    // 3. 绑定this并执行构造函数
    const result = constructor.apply(obj, args);
    
    // 4. 确保返回一个对象
    return typeof result === 'object' ? result : obj;
}

// 使用自定义的new
function Cat(name) {
    this.name = name;
}

const cat = customNew(Cat, "咪咪");
console.log(cat.name);  // "咪咪"
console.log(cat instanceof Cat);  // true

3.4 构造函数的重要特性

1. constructor 属性

每个通过构造函数创建的对象都有一个 constructor 属性,指向创建它的构造函数:

function Cat(name) {
    this.name = name;
}

const cat = new Cat("咪咪");

console.log(cat.constructor);           // 输出:function Cat(name) {...}
console.log(cat.constructor === Cat);   // 输出:true
console.log(cat instanceof Cat);        // 输出:true

// 通过constructor创建新对象
const cat2 = new cat.constructor("小黑");
console.log(cat2.name);  // 输出:小黑
2. 构造函数返回值

构造函数可以有返回值,但会影响 new 的行为:

function Cat(name) {
    this.name = name;
    
    // 情况1:返回基本类型(被忽略)
    // return 123;      // 忽略,仍然返回this
    // return "hello";  // 忽略,仍然返回this
    
    // 情况2:返回对象(替代新对象)
    // return { custom: "对象" };  // 返回这个对象,而不是this
}

const cat = new Cat("咪咪");
console.log(cat.name);  // 输出:咪咪
3. 静态属性和方法

构造函数本身也是对象,可以有自己的属性和方法:

function Cat(name, color) {
    this.name = name;
    this.color = color;
}

// 静态属性:属于构造函数本身,不属于实例
Cat.species = "猫科动物";
Cat.totalCats = 0;

// 静态方法:通过构造函数调用,不是实例调用
Cat.createFromJSON = function(jsonString) {
    Cat.totalCats++;  // 创建时计数
    const data = JSON.parse(jsonString);
    return new Cat(data.name, data.color);
};

Cat.getTotalCats = function() {
    return Cat.totalCats;
};

// 使用静态成员
console.log(Cat.species);  // 猫科动物

const jsonStr = '{"name":"小黄","color":"黄色"}';
const cat1 = Cat.createFromJSON(jsonStr);
console.log(cat1.name);       // 小黄
console.log(Cat.getTotalCats()); // 1

// 静态方法不能通过实例调用
const cat2 = new Cat("小白", "白色");
// cat2.getTotalCats();  // 错误:不是函数

3.5 构造函数模式的内存问题

构造函数模式有一个严重的缺陷:方法重复创建

function Cat(name) {
    this.name = name;
    
    // 问题:每次new Cat()都会创建新的函数对象
    this.eat = function() {
        console.log(this.name + "在吃东西");
    };
    
    this.sleep = function() {
        console.log(this.name + "在睡觉");
    };
}

const cat1 = new Cat("猫1");
const cat2 = new Cat("猫2");
const cat3 = new Cat("猫3");

// 每个实例都有自己独立的eat和sleep方法
console.log(cat1.eat === cat2.eat);      // false,不是同一个函数
console.log(cat2.sleep === cat3.sleep);  // false,不是同一个函数

// 内存浪费示意图:
// cat1 → {name, eat函数1, sleep函数1}
// cat2 → {name, eat函数2, sleep函数2}  
// cat3 → {name, eat函数3, sleep函数3}
// 每个函数都是独立的,占用独立的内存空间

// 创建100只猫,就有100个eat函数和100个sleep函数!
// 实际功能完全一样,这是巨大的内存浪费

3.6 防御性编程:防止忘记 new

// 方案1:使用严格模式 + 检查
function Cat(name) {
    // 严格模式下,this在普通调用时为undefined
    "use strict";
    
    if (this === undefined) {
        throw new Error("Cat构造函数必须使用new调用");
    }
    
    this.name = name;
}

// 方案2:自动纠正
function Cat(name) {
    // 如果this不是Cat的实例(说明没有用new调用)
    if (!(this instanceof Cat)) {
        // 自动调用new
        return new Cat(name);
    }
    
    this.name = name;
}

// 方案3:ES6 new.target
function Cat(name) {
    // new.target在new调用时指向构造函数,否则为undefined
    if (new.target !== Cat) {
        throw new Error("必须使用new调用Cat构造函数");
    }
    
    this.name = name;
}

// 测试
const cat1 = new Cat("正确");  // 正常
const cat2 = Cat("也正确");    // 自动纠正方案中正常,其他方案报错

3.7 构造函数的实际应用场景

1. 创建UI组件
function Button(text, color) {
    this.text = text;
    this.color = color;
    this.element = document.createElement("button");
    
    this.render = function() {
        this.element.textContent = this.text;
        this.element.style.backgroundColor = this.color;
        return this.element;
    };
    
    this.onClick = function(handler) {
        this.element.addEventListener("click", handler);
    };
}

const submitBtn = new Button("提交", "blue");
const cancelBtn = new Button("取消", "gray");

document.body.appendChild(submitBtn.render());
document.body.appendChild(cancelBtn.render());
2. 数据模型
function User(id, name, email) {
    this.id = id;
    this.name = name;
    this.email = email;
    this.isActive = true;
    this.createdAt = new Date();
    
    this.deactivate = function() {
        this.isActive = false;
    };
    
    this.getInfo = function() {
        return `${this.name} (${this.email}) - ${this.isActive ? '活跃' : '非活跃'}`;
    };
}

const user1 = new User(1, "张三", "zhang@example.com");
const user2 = new User(2, "李四", "li@example.com");

console.log(user1.getInfo());
user2.deactivate();
console.log(user2.getInfo());

3.8 构造函数模式的优缺点总结

优点

  • 类型标识:实例有明确的constructor属性
  • 代码复用:可以批量创建相似对象
  • 封装性:将创建逻辑封装在函数中
  • 灵活性:可以传参定制每个实例

缺点

  • 内存浪费:方法在每个实例中重复创建
  • 性能问题:大量实例时占用大量内存
  • 忘记new的风险:可能导致意外创建全局变量

为了解决内存浪费的问题,我们需要更高级的模式——原型模式。

第四章:原型模式——JavaScript面向对象的精髓

4.1 从构造函数的问题到原型的解决方案

问题的根源:内存浪费的数学计算

让我们先量化构造函数模式的内存问题:

function Cat(name) {
    this.name = name;
    this.eat = function() {
        console.log(this.name + "在吃东西");
    };
    this.sleep = function() {
        console.log(this.name + "在睡觉");
    };
    this.play = function() {
        console.log(this.name + "在玩耍");
    };
    // 假设每个方法平均占用1KB内存
}

// 创建1000只猫
let totalMemory = 0;
for (let i = 0; i < 1000; i++) {
    const cat = new Cat(`猫${i}`);
    // 每只猫有3个方法,每个方法1KB
    totalMemory += 3; // KB
}

console.log(`总内存占用: ${totalMemory}KB = ${totalMemory / 1024}MB`);
// 输出:总内存占用: 3000KB = 2.93MB

// 但实际上,这1000个eat方法功能完全相同!
// 它们在内存中有1000个副本,这是巨大的浪费

原型的核心思想:共享

想象一个图书馆系统:

  • 构造函数模式:每人都买一本相同的书(浪费钱和空间)
  • 原型模式:图书馆有一本书,大家都可以借阅(节约资源)
// 图书馆(原型)模式
const Library = {
    // 共享的书籍(方法)
    books: {
        "吃的方法": function(cat) {
            console.log(cat.name + "在吃东西");
        },
        "睡的方法": function(cat) {
            console.log(cat.name + "在睡觉");
        }
    },
    
    // 借阅方法
    borrow: function(bookName, cat) {
        const method = this.books[bookName];
        return function() {
            method(cat);
        };
    }
};

// 每个人(实例)不需要自己买书,只需记住借阅凭证
const cat1 = { name: "加菲猫" };
const cat2 = { name: "小白猫" };

cat1.eat = Library.borrow("吃的方法", cat1);
cat2.eat = Library.borrow("吃的方法", cat2);

cat1.eat(); // 加菲猫在吃东西
cat2.eat(); // 小白猫在吃东西

// 关键:两个eat方法都指向Library.books["吃的方法"]
// 但通过闭包绑定了各自的cat

JavaScript的原型系统就是基于这种"共享"思想构建的,但实现更优雅。

4.2 原型(Prototype)的详细机制

4.2.1 每个函数都有原型属性

function Cat(name) {
    this.name = name;
}

// 每个函数创建时,都会自动获得一个prototype属性
console.log(typeof Cat.prototype); // "object"
console.log(Cat.prototype); // 一个空对象,但有constructor属性
console.log(Cat.prototype.constructor === Cat); // true

// prototype对象的内容
console.log(Cat.prototype);
// 输出大致如下:
// {
//     constructor: function Cat(name) {...},
//     __proto__: Object.prototype
// }

4.2.2 实例与原型的关系

function Cat(name) {
    this.name = name;
}

// 在原型上添加共享方法
Cat.prototype.eat = function() {
    console.log(this.name + "在吃东西");
};

Cat.prototype.sleep = function() {
    console.log(this.name + "在睡觉");
};

// 创建实例
const garfield = new Cat("加菲猫");
const doraemon = new Cat("哆啦A梦");

// 神奇的事情发生了!
garfield.eat(); // "加菲猫在吃东西"
doraemon.eat(); // "哆啦A梦在吃东西"

console.log(garfield.eat === doraemon.eat); // true ✓
// 内存中只有一个eat函数,两个实例共享它!

4.2.3 原型链的查找机制

当访问一个对象的属性时,JavaScript引擎会:

// 详细步骤演示
function Cat(name) {
    this.name = name;
}

Cat.prototype.type = "猫";
Cat.prototype.eat = function() {
    console.log(this.name + "在吃鱼");
};

const cat = new Cat("咪咪");

// 访问 cat.eat() 时发生的事情:
console.log("=== 查找 cat.eat() 的过程 ===");

// 第一步:检查cat自身是否有eat属性
console.log("1. cat自身有eat吗?", cat.hasOwnProperty("eat")); // false

// 第二步:如果没有,查找cat.__proto__(即Cat.prototype)
console.log("2. cat.__proto__有eat吗?", Cat.prototype.hasOwnProperty("eat")); // true

// 第三步:找到!执行Cat.prototype.eat,但this指向cat
// 相当于:Cat.prototype.eat.call(cat)

// 第四步:如果Cat.prototype也没有,继续查找Cat.prototype.__proto__(Object.prototype)
// 第五步:如果还没有,继续查找Object.prototype.__proto__(null)
// 第六步:找到null,返回undefined

// 可视化原型链:
// cat 
//   → [[Prototype]]: Cat.prototype
//        → [[Prototype]]: Object.prototype
//             → [[Prototype]]: null

4.2.4 __proto__ vs prototype 的区别

这是初学者最容易混淆的概念,让我们彻底搞清楚:

function Cat(name) {
    this.name = name;
}

const cat = new Cat("咪咪");

console.log("=== 关键区别 ===");

// 1. prototype是函数的属性
console.log("Cat有prototype吗?", Cat.hasOwnProperty("prototype")); // true
console.log("cat有prototype吗?", cat.hasOwnProperty("prototype")); // false

// 2. __proto__是对象的属性(每个对象都有)
console.log("cat有__proto__吗?", cat.hasOwnProperty("__proto__")); // false(但可以访问)
console.log("cat.__proto__存在吗?", cat.__proto__ !== undefined); // true

// 3. 重要关系:实例的__proto__指向构造函数的prototype
console.log("cat.__proto__ === Cat.prototype?", cat.__proto__ === Cat.prototype); // true

// 4. 构造函数的prototype的constructor指向构造函数本身
console.log("Cat.prototype.constructor === Cat?", Cat.prototype.constructor === Cat); // true

// 记忆口诀:
// - 函数(构造函数)有prototype(用于构建原型链)
// - 对象(实例)有__proto__(用于查找原型链)
// - 实例的__proto__指向构造函数的prototype

4.2.5 原型链的可视化

// 让我们画一个原型链的完整图
function Animal(name) {
    this.name = name;
}

function Cat(name, color) {
    Animal.call(this, name);
    this.color = color;
}

// 设置原型链
Cat.prototype = Object.create(Animal.prototype);
Cat.prototype.constructor = Cat;

const cat = new Cat("咪咪", "白色");

console.log("=== 完整的原型链 ===");
console.log("cat自身属性:", Object.keys(cat)); // ["name", "color"]

console.log("cat.__proto__ === Cat.prototype?", cat.__proto__ === Cat.prototype); // true
console.log("Cat.prototype.__proto__ === Animal.prototype?", Cat.prototype.__proto__ === Animal.prototype); // true
console.log("Animal.prototype.__proto__ === Object.prototype?", Animal.prototype.__proto__ === Object.prototype); // true
console.log("Object.prototype.__proto__ === ?", Object.prototype.__proto__); // null

// 原型链:cat → Cat.prototype → Animal.prototype → Object.prototype → null

4.3 原型模式的完整实现

4.3.1 标准原型模式写法

// 步骤1:定义构造函数(处理实例特有属性)
function Cat(name, color, age) {
    // 实例特有属性放在构造函数中
    this.name = name;
    this.color = color;
    this.age = age || 1;
    this.createdAt = new Date(); // 每个实例创建时间不同
    this.id = Math.random().toString(36).substr(2, 9); // 每个实例唯一ID
}

// 步骤2:在原型上添加共享方法和属性
Cat.prototype = {
    // 重要:必须设置constructor,否则会丢失
    constructor: Cat,
    
    // 共享属性(所有猫都一样)
    species: "猫科动物",
    kingdom: "动物界",
    hasTail: true,
    
    // 共享方法(所有猫都一样)
    eat: function(food) {
        console.log(`${this.name}(${this.color})在吃${food}`);
        this.energy += 10;
        return this; // 支持链式调用
    },
    
    sleep: function(hours) {
        console.log(`${this.name}要睡${hours}小时`);
        this.energy += hours * 5;
        return this;
    },
    
    play: function() {
        console.log(`${this.name}在玩毛线球`);
        this.energy -= 5;
        return this;
    },
    
    getInfo: function() {
        return `${this.name}${this.color}色,${this.age}岁`;
    },
    
    // 计算属性
    get isKitten() {
        return this.age < 1;
    },
    
    get energyLevel() {
        if (this.energy > 80) return "精力充沛";
        if (this.energy > 50) return "状态良好";
        if (this.energy > 20) return "有点疲惫";
        return "需要休息";
    }
};

// 步骤3:添加原型属性(初始化值)
Cat.prototype.energy = 100; // 初始能量

// 步骤4:使用
const cat1 = new Cat("加菲猫", "黄色", 5);
const cat2 = new Cat("小白猫", "白色", 0.5);

console.log(cat1.getInfo()); // "加菲猫,黄色色,5岁"
console.log(cat2.getInfo()); // "小白猫,白色色,0.5岁"

console.log(cat1.isKitten); // false
console.log(cat2.isKitten); // true

// 链式调用
cat1.eat("鱼").sleep(8).play();
console.log(cat1.energyLevel); // "状态良好"

// 验证共享性
console.log(cat1.eat === cat2.eat); // true
console.log(cat1.species === cat2.species); // true

4.3.2 动态修改原型

function Cat(name) {
    this.name = name;
}

const cat1 = new Cat("第一批猫");
const cat2 = new Cat("第二批猫");

console.log("=== 动态修改原型的效应 ===");

// 1. 在创建实例后添加原型方法
Cat.prototype.eat = function() {
    console.log(this.name + "在吃东西");
};

// 所有已存在的实例都能立即使用新方法!
cat1.eat(); // "第一批猫在吃东西"
cat2.eat(); // "第二批猫在吃东西"

// 2. 覆盖原型方法
Cat.prototype.eat = function() {
    console.log(this.name + "在大吃特吃!");
};

cat1.eat(); // "第一批猫在大吃特吃!"
cat2.eat(); // "第二批猫在大吃特吃!"

// 3. 在原型上添加属性
Cat.prototype.species = "猫";
console.log(cat1.species); // "猫"
console.log(cat2.species); // "猫"

// 4. 重要:如果实例已有同名属性,会遮蔽原型属性
cat1.species = "超级猫"; // 在实例上创建属性
console.log(cat1.species); // "超级猫"(实例属性优先)
console.log(cat2.species); // "猫"(还是原型属性)

// 5. 删除实例属性,恢复访问原型
delete cat1.species;
console.log(cat1.species); // "猫"(又访问到原型了)

// 6. 谨慎:修改内置原型
if (!Array.prototype.sum) {
    Array.prototype.sum = function() {
        return this.reduce((a, b) => a + b, 0);
    };
}

console.log([1, 2, 3].sum()); // 6

// 但注意:修改内置原型有风险,可能与其他库冲突

4.3.3 原型的性能优势

让我们用数据说话:

// 性能测试:构造函数模式 vs 原型模式
function TestConstructor(name) {
    this.name = name;
    this.method1 = function() { return this.name; };
    this.method2 = function() { return this.name.toUpperCase(); };
    this.method3 = function() { return this.name.length; };
    this.method4 = function() { return this.name.repeat(2); };
    this.method5 = function() { return this.name.split('').reverse().join(''); };
}

function TestPrototype(name) {
    this.name = name;
}

TestPrototype.prototype.method1 = function() { return this.name; };
TestPrototype.prototype.method2 = function() { return this.name.toUpperCase(); };
TestPrototype.prototype.method3 = function() { return this.name.length; };
TestPrototype.prototype.method4 = function() { return this.name.repeat(2); };
TestPrototype.prototype.method5 = function() { return this.name.split('').reverse().join(''); };

// 内存占用测试
console.log("=== 内存占用对比 ===");

const count = 10000;
let memoryConstructor = 0;
let memoryPrototype = 0;

// 估算:每个函数对象约1KB,每个字符串属性约0.1KB
// 构造函数模式:10000实例 × (5个方法 × 1KB + 1个属性 × 0.1KB) ≈ 51000KB ≈ 49.8MB
// 原型模式:10000实例 × (1个属性 × 0.1KB) + 5个方法 × 1KB ≈ 1005KB ≈ 0.98MB

console.log(`创建${count}个实例的理论内存占用:`);
console.log(`构造函数模式:约 ${(count * (5 * 1024 + 102) / 1024 / 1024).toFixed(1)}MB`);
console.log(`原型模式:约 ${((count * 102 + 5 * 1024) / 1024 / 1024).toFixed(1)}MB`);
console.log(`节约:约 ${((count * 5 * 1024) / 1024 / 1024).toFixed(1)}MB`);

// 实际测试
console.log("\n=== 实际创建时间测试 ===");

console.time("构造函数模式");
const consInstances = [];
for (let i = 0; i < count; i++) {
    consInstances.push(new TestConstructor(`实例${i}`));
}
console.timeEnd("构造函数模式");

console.time("原型模式");
const protoInstances = [];
for (let i = 0; i < count; i++) {
    protoInstances.push(new TestPrototype(`实例${i}`));
}
console.timeEnd("原型模式");

4.4 属性访问的优先级和查找

4.4.1 属性遮蔽(Property Shadowing)

function Cat(name) {
    this.name = name;
}

Cat.prototype.type = "普通猫";
Cat.prototype.color = "未知";

const cat = new Cat("咪咪");

console.log("=== 属性遮蔽实验 ===");

// 1. 初始状态:访问原型属性
console.log("初始 - cat.type:", cat.type);          // "普通猫"
console.log("初始 - cat.color:", cat.color);        // "未知"
console.log("初始 - cat.hasOwnProperty('type'):", cat.hasOwnProperty("type"));  // false
console.log("初始 - cat.hasOwnProperty('color'):", cat.hasOwnProperty("color")); // false

// 2. 在实例上添加同名属性(遮蔽)
cat.type = "超级猫";
cat.color = "白色";

console.log("\n遮蔽后 - cat.type:", cat.type);          // "超级猫"(实例属性)
console.log("遮蔽后 - cat.color:", cat.color);          // "白色"(实例属性)
console.log("遮蔽后 - cat.hasOwnProperty('type'):", cat.hasOwnProperty("type"));  // true
console.log("遮蔽后 - cat.hasOwnProperty('color'):", cat.hasOwnProperty("color")); // true

// 3. 原型属性仍然存在
console.log("\n原型属性 - Cat.prototype.type:", Cat.prototype.type);  // "普通猫"
console.log("原型属性 - Cat.prototype.color:", Cat.prototype.color);  // "未知"

// 4. 删除实例属性(解除遮蔽)
delete cat.type;
delete cat.color;

console.log("\n删除后 - cat.type:", cat.type);          // "普通猫"(又访问到原型)
console.log("删除后 - cat.color:", cat.color);          // "未知"(又访问到原型)
console.log("删除后 - cat.hasOwnProperty('type'):", cat.hasOwnProperty("type"));  // false
console.log("删除后 - cat.hasOwnProperty('color'):", cat.hasOwnProperty("color")); // false

4.4.2 in 操作符 vs hasOwnProperty

function Cat(name) {
    this.name = name;
}

Cat.prototype.species = "猫";

const cat = new Cat("咪咪");

console.log("=== in 和 hasOwnProperty 的区别 ===");

// 1. hasOwnProperty:只检查对象自身属性
console.log("cat.hasOwnProperty('name'):", cat.hasOwnProperty("name"));     // true
console.log("cat.hasOwnProperty('species'):", cat.hasOwnProperty("species")); // false
console.log("cat.hasOwnProperty('toString'):", cat.hasOwnProperty("toString")); // false

// 2. in 操作符:检查整个原型链
console.log("\n'name' in cat:", "name" in cat);         // true
console.log("'species' in cat:", "species" in cat);     // true(在原型链上找到)
console.log("'toString' in cat:", "toString" in cat);   // true(在Object.prototype上)
console.log("'notExist' in cat:", "notExist" in cat);   // false

// 3. 实用函数:区分自身属性和继承属性
function getPropertySource(obj, prop) {
    if (obj.hasOwnProperty(prop)) {
        return "自身属性";
    } else if (prop in obj) {
        return "原型链继承";
    } else {
        return "不存在";
    }
}

console.log("\n属性来源分析:");
console.log("name:", getPropertySource(cat, "name"));       // 自身属性
console.log("species:", getPropertySource(cat, "species")); // 原型链继承
console.log("toString:", getPropertySource(cat, "toString")); // 原型链继承
console.log("age:", getPropertySource(cat, "age"));         // 不存在

4.4.3 遍历属性的不同方法

function Cat(name, color) {
    this.name = name;
    this.color = color;
}

Cat.prototype.species = "猫";
Cat.prototype.eat = function() {};

// 在原型链上添加不可枚举属性
Object.defineProperty(Cat.prototype, "hidden", {
    value: "隐藏属性",
    enumerable: false
});

const cat = new Cat("咪咪", "白色");

console.log("=== 不同遍历方法的区别 ===");

// 1. for...in:遍历所有可枚举属性(包括原型链)
console.log("for...in 遍历:");
for (let key in cat) {
    console.log(`  ${key}: ${cat[key]}`);
}
// 输出:
//   name: 咪咪
//   color: 白色
//   species: 猫
//   eat: function() {}

// 2. Object.keys():只遍历自身可枚举属性
console.log("\nObject.keys():");
console.log(Object.keys(cat)); // ["name", "color"]

// 3. Object.getOwnPropertyNames():自身所有属性(包括不可枚举)
console.log("\nObject.getOwnPropertyNames():");
console.log(Object.getOwnPropertyNames(cat)); // ["name", "color"]

// 4. 获取所有属性(包括原型链的)
function getAllProperties(obj) {
    const props = {};
    
    // 遍历原型链
    let current = obj;
    while (current && current !== Object.prototype) {
        Object.getOwnPropertyNames(current).forEach(prop => {
            if (!props[prop] && prop !== "constructor") {
                props[prop] = {
                    value: current[prop],
                    own: current === obj,
                    enumerable: Object.getOwnPropertyDescriptor(current, prop).enumerable
                };
            }
        });
        current = Object.getPrototypeOf(current);
    }
    
    return props;
}

console.log("\n所有属性分析:");
const allProps = getAllProperties(cat);
for (let prop in allProps) {
    const info = allProps[prop];
    console.log(`${prop}: ${info.own ? "自身" : "继承"} ${info.enumerable ? "可枚举" : "不可枚举"}`);
}

4.5 原型模式的最佳实践

4.5.1 安全的原型扩展

// 不安全的写法:直接覆盖prototype
function Cat(name) {
    this.name = name;
}

// ❌ 不推荐:完全覆盖prototype,丢失constructor
Cat.prototype = {
    eat: function() {
        console.log(this.name + "在吃东西");
    }
};

console.log(new Cat().constructor); // Object,不是Cat!

// ✅ 推荐写法1:逐个添加
function Cat(name) {
    this.name = name;
}

Cat.prototype.eat = function() {
    console.log(this.name + "在吃东西");
};

Cat.prototype.sleep = function() {
    console.log(this.name + "在睡觉");
};

// ✅ 推荐写法2:使用Object.assign,但保留constructor
function Cat(name) {
    this.name = name;
}

Object.assign(Cat.prototype, {
    // 显式设置constructor
    constructor: Cat,
    
    eat: function() {
        console.log(this.name + "在吃东西");
    },
    
    sleep: function() {
        console.log(this.name + "在睡觉");
    }
});

// ✅ 推荐写法3:使用class(ES6)
class Cat {
    constructor(name) {
        this.name = name;
    }
    
    eat() {
        console.log(this.name + "在吃东西");
    }
    
    sleep() {
        console.log(this.name + "在睡觉");
    }
}

4.5.2 原型方法的注意事项

function Cat(name) {
    this.name = name;
    this.foods = []; // 引用类型属性
}

Cat.prototype = {
    constructor: Cat,
    
    // ✅ 正确:使用this访问实例属性
    eat: function(food) {
        console.log(this.name + "在吃" + food);
        this.foods.push(food);
    },
    
    // ❌ 危险:在原型方法中修改共享状态
    badMethod: function() {
        // 如果访问Cat.prototype上的属性,会影响到所有实例!
        if (!this.sharedFoods) {
            this.sharedFoods = []; // 这会在原型上创建属性!
        }
        this.sharedFoods.push("鱼");
    },
    
    // ✅ 正确:使用闭包创建私有变量
    getCounter: (function() {
        let privateCounter = 0;
        
        return function() {
            privateCounter++;
            return privateCounter;
        };
    })()
};

const cat1 = new Cat("猫1");
const cat2 = new Cat("猫2");

cat1.eat("鱼");
cat2.eat("肉");

console.log(cat1.foods); // ["鱼"]
console.log(cat2.foods); // ["肉"]

cat1.badMethod();
cat2.badMethod();
console.log(cat1.sharedFoods); // ["鱼", "肉"] 
console.log(cat2.sharedFoods); // ["鱼", "肉"] 
// 注意:sharedFoods实际上在原型上,两个实例共享!

4.5.3 原型链的深度控制

// 原型链不宜过深,一般建议不超过3层
// 猫 → 动物 → 生物 → Object.prototype → null (5层,已较深)

// 过深原型链的性能问题
console.time("创建深层原型链");
function Level1() {}
function Level2() {}
function Level3() {}
function Level4() {}
function Level5() {}
function Level6() {}
function Level7() {}
function Level8() {}
function Level9() {}
function Level10() {}

Level2.prototype = Object.create(Level1.prototype);
Level3.prototype = Object.create(Level2.prototype);
Level4.prototype = Object.create(Level3.prototype);
Level5.prototype = Object.create(Level4.prototype);
Level6.prototype = Object.create(Level5.prototype);
Level7.prototype = Object.create(Level6.prototype);
Level8.prototype = Object.create(Level7.prototype);
Level9.prototype = Object.create(Level8.prototype);
Level10.prototype = Object.create(Level9.prototype);

Level10.prototype.value = "在最底层";

const obj = new Level10();

// 查找value需要遍历10层原型链!
let value = obj.value;
console.timeEnd("创建深层原型链");

// 性能测试:多次访问
console.time("访问深层属性10000次");
for (let i = 0; i < 10000; i++) {
    const temp = obj.value;
}
console.timeEnd("访问深层属性10000次");

// 对比:浅层原型链
function Shallow() {}
Shallow.prototype.value = "在原型上";
const shallowObj = new Shallow();

console.time("访问浅层属性10000次");
for (let i = 0; i < 10000; i++) {
    const temp = shallowObj.value;
}
console.timeEnd("访问浅层属性10000次");

4.6 原型模式的实际应用

4.6.1 插件系统设计

// 使用原型模式实现可扩展的插件系统
function PluginSystem() {
    this.plugins = [];
    this.hooks = {};
}

PluginSystem.prototype = {
    constructor: PluginSystem,
    
    // 注册插件
    register: function(plugin) {
        if (typeof plugin.install === 'function') {
            plugin.install(this);
        }
        this.plugins.push(plugin);
        return this;
    },
    
    // 添加钩子
    addHook: function(name, callback) {
        if (!this.hooks[name]) {
            this.hooks[name] = [];
        }
        this.hooks[name].push(callback);
        return this;
    },
    
    // 触发钩子
    triggerHook: function(name, ...args) {
        if (this.hooks[name]) {
            this.hooks[name].forEach(callback => {
                callback.apply(this, args);
            });
        }
        return this;
    },
    
    // 获取所有插件
    getPlugins: function() {
        return this.plugins.slice(); // 返回副本
    }
};

// 插件示例
const LoggerPlugin = {
    name: 'Logger',
    install: function(system) {
        system.addHook('beforeAction', function(action) {
            console.log(`[Logger] 即将执行: ${action}`);
        });
        
        system.addHook('afterAction', function(action, result) {
            console.log(`[Logger] 执行完成: ${action}, 结果:`, result);
        });
    }
};

const ValidatorPlugin = {
    name: 'Validator',
    install: function(system) {
        system.addHook('beforeAction', function(action) {
            if (!action) {
                throw new Error('Action不能为空');
            }
            console.log(`[Validator] 验证通过: ${action}`);
        });
    }
};

// 使用插件系统
const system = new PluginSystem();
system.register(LoggerPlugin)
     .register(ValidatorPlugin);

system.triggerHook('beforeAction', '用户登录');
// 输出:
// [Logger] 即将执行: 用户登录
// [Validator] 验证通过: 用户登录

// 所有PluginSystem实例共享原型方法,但各有自己的插件和钩子

4.6.2 表单验证系统

function Validator(rules) {
    this.rules = rules || {};
    this.errors = {};
    this.isValid = true;
}

Validator.prototype = {
    constructor: Validator,
    
    // 添加验证规则
    addRule: function(field, ruleFn, message) {
        if (!this.rules[field]) {
            this.rules[field] = [];
        }
        this.rules[field].push({
            validate: ruleFn,
            message: message
        });
        return this;
    },
    
    // 验证字段
    validateField: function(field, value) {
        if (!this.rules[field]) {
            return true;
        }
        
        let isValid = true;
        this.errors[field] = [];
        
        for (let rule of this.rules[field]) {
            if (!rule.validate(value)) {
                isValid = false;
                this.errors[field].push(rule.message);
            }
        }
        
        if (!isValid) {
            this.isValid = false;
        }
        
        return isValid;
    },
    
    // 验证整个表单
    validateForm: function(data) {
        this.errors = {};
        this.isValid = true;
        
        for (let field in this.rules) {
            this.validateField(field, data[field]);
        }
        
        return this.isValid;
    },
    
    // 获取错误信息
    getErrors: function() {
        return this.errors;
    },
    
    // 获取字段的错误
    getFieldError: function(field) {
        return this.errors[field] || [];
    },
    
    // 静态方法:常用验证规则
    static rules: {
        required: function(value) {
            return value !== undefined && value !== null && value !== '';
        },
        
        minLength: function(min) {
            return function(value) {
                return value && value.length >= min;
            };
        },
        
        maxLength: function(max) {
            return function(value) {
                return !value || value.length <= max;
            };
        },
        
        email: function(value) {
            const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
            return !value || emailRegex.test(value);
        }
    }
};

// 使用示例
const formValidator = new Validator();

// 添加验证规则
formValidator
    .addRule('username', 
        Validator.rules.required, 
        '用户名不能为空')
    .addRule('username',
        Validator.rules.minLength(3),
        '用户名至少3个字符')
    .addRule('email',
        Validator.rules.email,
        '邮箱格式不正确');

// 验证数据
const formData = {
    username: 'ab',
    email: 'invalid-email'
};

const isValid = formValidator.validateForm(formData);
console.log('验证结果:', isValid); // false
console.log('错误信息:', formValidator.getErrors());
// 输出:{ username: ['用户名至少3个字符'], email: ['邮箱格式不正确'] }

4.7 原型模式的优缺点总结

优点:

  1. 内存高效:方法只在原型上存储一次,所有实例共享
  2. 动态性:可以随时修改原型,影响所有实例
  3. 代码复用:共享方法减少重复代码
  4. 继承基础:为JavaScript的继承机制提供基础

缺点:

  1. 理解难度:原型链概念对初学者不友好
  2. 共享状态的陷阱:如果原型上有引用类型属性,所有实例共享
  3. 构造函数与原型分离:定义分散在两个地方
  4. 性能考虑:过深原型链会影响属性查找速度

适用场景:

  1. 需要创建大量相似对象:如游戏中的NPC、UI组件
  2. 对象有大量共享方法:如数据模型、工具类
  3. 需要动态扩展对象能力:如插件系统
  4. 实现继承机制:构建对象层次结构

4.8 原型模式与构造函数的结合

在实际开发中,我们总是将构造函数和原型模式结合使用:

// 最佳实践:构造函数 + 原型模式
function Cat(name, color, age) {
    // 构造函数:处理实例特有属性
    this.name = name;
    this.color = color;
    this.age = age || 1;
    this.id = Date.now() + Math.random(); // 唯一ID
    this.createdAt = new Date();
}

// 原型:处理共享方法和属性
Cat.prototype = {
    constructor: Cat, // 重要:保持constructor指向
    
    // 共享属性
    species: '猫科动物',
    hasTail: true,
    legs: 4,
    
    // 共享方法
    eat(food) {
        console.log(`${this.name}在吃${food}`);
        return this; // 支持链式调用
    },
    
    sleep(hours) {
        console.log(`${this.name}睡了${hours}小时`);
        return this;
    },
    
    getInfo() {
        return `${this.name}${this.color}色,${this.age}岁`;
    },
    
    // 计算属性
    get isKitten() {
        return this.age < 1;
    },
    
    // 静态方法(通过实例访问)
    static: {
        createKitten(name, color) {
            return new Cat(name, color, 0.5);
        }
    }
};

// 使用示例
const cat = new Cat('咪咪', '白色', 2);
cat.eat('鱼').sleep(8);
console.log(cat.getInfo());
console.log('是小猫吗?', cat.isKitten);

// 验证原型链
console.log(cat instanceof Cat); // true
console.log(cat.constructor === Cat); // true
console.log(cat.hasOwnProperty('name')); // true
console.log(cat.hasOwnProperty('eat')); // false

这种组合模式是现代JavaScript开发中最常用的对象创建方式,它结合了两种模式的优点,避免了各自的缺点。


Zustand:打造 React 应用的“中央银行”级状态管理

在 React 的开发江湖中,状态管理(State Management)始终是一个绕不开的核心话题。如果说 React 组件是构成应用社会的“个体家庭”,那么状态管理就是维持社会运转的“经济系统”。

对于简单的父子组件通信,useStateprops 就像是家庭内部的现金流转,简单直接。但当应用规模扩大,多个没有任何血缘关系(非父子层级)的组件需要共享数据时,我们往往会陷入“Prop Drilling”(属性透传)的泥潭。

这时,我们需要一个**“中央银行”**。

这就是 Zustand(德语“状态”之意)。它是一个基于 Hooks 的、轻量级的、无样板代码(Boilerplate-free)的状态管理库。它比 Redux 更简单,比 Context API 更高效。

本文将结合实际代码案例(计数器、待办事项、用户认证),带你深入理解 Zustand 的设计哲学与实战技巧。

一、 核心概念:为什么选择 Zustand?

在深入代码之前,我们需要理解 Zustand 试图解决什么问题。

“如果说国家需要有中央银行,那么前端项目就需要中央状态管理系统。”

1. 组件 = UI + State

在现代前端架构中,UI 只是数据的投影。公式

UI=f(State)UI = f(State)

揭示了本质。Zustand 的作用就是将 StateState 从组件树中抽离出来,存入一个全局的 Store(仓库)中进行专业管理。

2. 轻量与直观

Redux 强制要求你编写 Action Types、Reducers、Selectors,并使用 Provider 包裹整个应用。而 Zustand 不仅无需 Provider,其核心逻辑更是极致精简:

  • 全局共享: 状态一旦创建,任何组件均可访问。
  • 基于 Hooks: 使用方式几乎等同于 useState,符合 React 直觉。
  • 自动合并: 默认进行浅合并(Shallow Merge),简化了更新逻辑。

工程目录结构如下:

src/
  ├── store/           # 状态管理的“中央银行”
  │    ├── user.ts     # 负责用户身份、登录状态
  │    ├── todo.ts     # 负责业务数据流
  │    └── counter.ts  # 负责基础工具或计数逻辑
  ├── components/      # UI 组件
  └── types/           # TypeScript 类型定义

二、 起步:构建你的第一个 Store

让我们通过 counter.ts 来看看 Zustand 是如何定义“规矩”的。

1. 定义状态契约 (TypeScript Interface)

在 TypeScript 项目中,第一步永远是定义类型。这相当于为“中央银行”制定法律,规定了存储什么数据,以及允许什么操作。

// counter.ts
interface CounterState {
    count: number;          // 数据状态
    increment: () => void;  // 修改动作:增加
    decrement: () => void;  // 修改动作:减少
    reset: () => void;      // 修改动作:重置
}

2. 创建 Store (create)

使用 create 函数构建 Store。这里有一个关键的模式:状态和修改状态的方法(Actions)是在一起定义的。这体现了高内聚的设计思想。

// counter.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

export const useCounterStore = create<CounterState>()(
    persist(
        (set) => ({
            // 1. 初始状态
            count: 0,
            
            // 2. 修改状态 (Actions)
            // set 函数接收当前 state,返回新的部分 state
            increment: () => set((state) => ({ count: state.count + 1 })),
            decrement: () => set((state) => ({ count: state.count - 1 })),
            reset: () => set({ count: 0 }),
        }),
        {
            name: 'counter', // 持久化存储的 key
        }
    )
)

代码解析:

  • set: 这是 Zustand 提供的核心方法。你不需要像 Redux 那样 dispatch 一个对象,直接调用 set 即可更新状态。
  • persist: 这是一个中间件(Middleware)。它自动将状态同步到 localStorage。当你刷新页面时,计数器的数值不会归零,而是从本地存储恢复。

三、 进阶:处理复杂数据结构 (Array & Object)

现实世界的应用远比计数器复杂。看看 todo.ts,我们如何处理数组和对象更新。

1. 不可变更新 (Immutable Updates)

虽然 Zustand 使用起来很简单,但它遵循 React 的不可变数据原则。在更新数组或对象时,我们不能直接 push 或修改属性,而是需要返回一个新的对象/数组。

// todo.ts - 添加待办事项
addTodo: (text: string) => set((state) => ({
    // 使用展开运算符 (...) 创建新数组
    todos: [...state.todos, {
        id: Date.now(),
        text: text.trim(),
        completed: false
    }]
})),

2. 映射与过滤

对于更新列表中的某一项(如切换完成状态)或删除某一项,标准的数组方法 mapfilter 是最佳拍档。

// todo.ts - 切换状态
toggleTodo: (id: number) => set((state) => ({
    todos: state.todos.map(todo => 
        // 找到目标 ID,复制原对象并覆盖 completed 属性
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
    )
})),

// todo.ts - 删除
removeTodo: (id: number) => set((state) => ({
    todos: state.todos.filter(todo => todo.id !== id)
}))

这种写法既保持了数据的纯净性,又让 React 能够精确地感知到状态变化,从而触发必要的重渲染。

四、 架构模式:模块化与持久化

在大型应用中,我们不应该将所有状态塞进一个巨大的 Store。Zustand 鼓励创建多个独立的 Store,按功能切分。

1. 领域驱动的 Store 切分

在提供的代码中,你可以清晰地看到三个文件分别管理三个领域:

  • counter.ts: 基础计数逻辑(工具类状态)。
  • todo.ts: 业务数据逻辑(列表、增删改查)。
  • user.ts: 全局会话逻辑(登录、注销、用户信息)。

这种结构类似于后端的微服务或数据库表设计,互不干扰,清晰明了。

2. 用户认证状态管理 (user.ts)

用户登录状态是典型的“全局共享”数据。一旦登录,Header 组件需要显示头像,设置页需要显示资料,购物车需要校验权限。

// user.ts
export const useUserStore = create<UserState>()(
    persist(
        (set) => ({
            isLogin: false,
            user: null,
            login: (user) => set({ isLogin: true, user: user }),
            logout: () => set({ isLogin: false, user: null}),
        }),
        { name: 'user' }
    )
)

结合 persist 中间件,这实现了一个极简的“记住我”功能。用户关闭浏览器再打开,只要 localStorage 中有数据,isLogin 依然为 true

五、 实战:在 React 组件中消费状态

有了“银行”,组件如何“取钱”?App.tsx 展示了极简的消费方式。

1. Hooks 方式调用

你不需要 HOC(高阶组件),不需要 connect,不需要 <Provider>

// App.tsx
import { useCounterStore } from './store/counter'
import { useTodoStore } from './store/todo'

function App() {
  // 就像使用 useState 一样自然
  const { count, increment, decrement, reset } = useCounterStore();
  
  // 获取 Todo 相关的状态和方法
  const { todos, addTodo, toggleTodo, removeTodo } = useTodoStore();
  
  // ...
}

2. 性能优化:按需选取 (Selectors)

虽然在 App.tsx 中我们直接解构了整个 Store 的返回值,但在生产环境中,最佳实践是只选取你需要的状态。这能避免不必要的重渲染。

例如,如果一个组件只需要显示 count,而不需要 increment 方法:

// 推荐写法:只订阅 count 的变化
const count = useCounterStore((state) => state.count);

如果 Store 中还有其他无关属性更新了,只要 count 没变,这个组件就不会重新渲染。这是 Zustand 高性能的关键所在。

3. UI 交互逻辑

App.tsx 展示了清晰的逻辑分层:

  1. UI 层<input>, <button>, List 渲染。
  2. 本地状态层inputValue (使用 useState 管理输入框的临时状态,因为这是 UI 细节,不需要放入全局 Store)。
  3. 全局业务层:点击 Add 时,调用 addTodo(inputValue)
// 典型的 UI 触发 Action 流程
const handleAdd = () => {
    if (inputValue.trim() === '') return;
    addTodo(inputValue.trim()); // 调用全局 Store 的方法
    setInputValue('');          // 重置本地 UI 状态
}

六、 总结与展望

Zustand 以其极简的 API 设计和强大的功能(中间件、TypeScript 支持、DevTools)成为了 React 状态管理的新宠。

回顾我们学到的:

  1. 创建 (Create) : 使用 create 定义 Store,将数据和操作封装在一起。
  2. 持久化 (Persist) : 利用中间件轻松实现数据本地存储。
  3. 消费 (Use) : 在组件中通过 Hooks 轻松获取状态和方法。
  4. 架构 (Structure) : 按领域拆分 Store,保持代码整洁。

如果你的项目觉得 Context API 难以维护,又觉得 Redux 过于繁琐,那么 Zustand 无疑是最佳的中间路线。它真正做到了像“中央银行”一样,安全、高效、井井有条地管理着应用的数据资产。

uni-app开发,为什么 H5 正常、微信小程序频繁踩坑?

前言

最近用 uni-app 写了一个项目,本地用 H5 模式开发和调试时一切正常,页面、交互、数据更新都没有问题。 但当代码部署到 微信小程序 后,却陆续出现了一些诡异的问题: 有的逻辑在 H5 能跑,小程序却完全不生效;有的组件在 H5 表现正常,小程序却怎么调都不对。

一开始我以为是自己代码写得有问题,直到踩坑越来越多才发现—— H5 和微信小程序的差异,远比我最初想象的要大得多。

所以想写篇博客记录一下开发中遇到的坑以及是如何解决的

同一套 uni-app / Vue 代码,H5 跑得好好的,到了微信小程序却各种翻车。

v-show 不生效、table 里 ref 调不到、输入框值怎么都回不去……

这些问题并不是偶发 bug,而是微信小程序底层架构与浏览器完全不同导致的必然结果。

本文从实际场景出发,把我在实际开发中高频踩到的坑系统整理一遍,树立一下小程序思维


一、核心结论先行

一句话总结微信小程序的本质:

微信小程序 = 双线程 + setData + 自定义组件

而 H5 是:

浏览器 + DOM + 同步更新

你在开发中遇到的 80% 问题,都可以回到这条差异上来解释。


二、什么是「双线程模型」?

1. H5(浏览器)的运行方式

在浏览器中:

  • JS 可以直接操作 DOM
  • 数据变化 → 视图几乎同步更新
  • v-modelinput.value 都是“最终裁判”

可以理解为:

JS 线程
  ↓
DOM
  ↓
页面立刻变化

这是一个单一运行环境


2. 微信小程序的运行方式

微信小程序为了安全性和可控性,彻底禁用了 DOM,改成了双线程架构:

┌──────────────┐
│  逻辑层 JS   │  ← 你的 Vue / JS 代码
└───────▲──────┘
        │ setData(JSON)
┌───────▼──────┐
│  视图层 WXML │  ← 渲染、组件内部状态
└──────────────┘

特点:

  • 逻辑层和视图层完全隔离
  • 只能通过 setData 传 JSON 数据
  • 所有 UI 更新都是异步的

JS 不能直接改 UI,只能“请求”视图层更新。


三、为什么「H5 能跑,小程序翻车」?

下面这些你踩过的坑,其实都源于同一个原因。


四、v-show 在 th / td 不生效

1. H5 中的认知

<td v-show="show">库存</td>

在浏览器里:

  • 本质是 display: none
  • DOM 仍然存在
  • table 布局可以正确处理

2. 小程序中为什么不行?

原因有两个:

  1. 小程序没有真实的 table DOM

    • table / tr / td 只是被编译成普通 view
    • 表格布局是 uni-ui 模拟的
  2. 表格结构在视图层一次性计算

    • v-show 只改逻辑层状态
    • 列宽、行结构不会重新计算

结果就是:

数据变了,但表格结构不重算

✅ 正确做法

<th v-if="showCol">库存</th>
<td v-if="showCol">{{ row.stock }}</td>

在 table 结构里:只用 v-if,不要用 v-show


五、table 点击事件里调用 ref 不生效

1. 常见写法

openPopup() {
  this.$refs.popupRef.open()
}

H5:大多能跑

小程序:经常无效


2. 真正原因

在小程序中,事件链路是:

点击 td(视图层)
↓
事件传到逻辑层
↓
你同步调用 ref
↓
popup 组件:
  - 可能还没渲染
  - 可能被 v-show 隐藏

ref 在小程序中是异步可用的。


✅ 正确用法

openPopup() {
  this.$nextTick(() => {
    this.$refs.popupRef?.open()
  })
}

并且:

  • popup 尽量用 v-if
  • 不要指望 ref 在同一个 tick 里可用

其实 $refs + $nextTick 在微信小程序依然不生效的真实原因

很多人在 H5 中已经形成了一个条件反射式写法

openPopup() {
  this.$nextTick(() => {
    this.$refs.popupRef?.open()
  })
}

H5 / WebView 下,这种写法基本 100% 生效; 但在 微信小程序(包括 uni-app)中,这段代码依然可能完全不生效

❌ 为什么有时候 $nextTick + ref 在小程序里也不可靠?

核心原因只有一句话:

$nextTick 只能保证 Vue 虚拟 DOM 更新完成,不能保证小程序组件实例已经 ready

在微信小程序中:

  • 组件是 自定义组件(不是 DOM)
  • 渲染发生在 渲染层线程
  • 父组件逻辑运行在 逻辑层线程
  • 组件实例的创建、挂载、通信,都需要经过 setData 的异步桥接

因此会出现以下真实现象:

你以为 实际发生
$nextTick 后 ref 一定存在 ref 变量存在,但组件实例尚未 ready
this.$refs.popupRef.open() 调用时组件方法尚未注入
控制台不报错 UI 没任何反应

✅ 小程序中最稳定、推荐的做法:状态驱动 + emit 回传

不要用父组件主动“命令式”调用子组件方法,而是:

👉 用状态控制子组件,用事件通知父组件

父组件

<Popup
  :visible="popupVisible"
  @close="popupVisible = false"
/>
data() {
  return { popupVisible: false }
},
methods: {
  openPopup() {
    this.popupVisible = true
  }
}

子组件

props: { visible: Boolean },
watch: {
  visible(val) {
    val ? this.open() : this.close()
  }
},
methods: {
  close() {
    this.$emit('close')
  }
}

🧠 一句话总结

  • H5:可以命令式(ref / 调方法)
  • 小程序:必须声明式(状态驱动) v-show 在 th / td 不生效

六、输入框值更新失败(uni-easyinput)

1. 现象

  • H5:校验后值能回退
  • 小程序:输入框仍显示非法值

2. 根因

在小程序中:

组件内部 value(视图层)
≠
v-model 绑定值(逻辑层)

如果组件不是“完全受控”:

  • 视图层内部状态优先
  • 你 setData 只是建议

✅ 正确组合

<uni-easyinput
  v-model="row.qty"
  :controlled="true"
  @change="checkQty"
/>

关键点:

  • 使用 change 而不是 input
  • 开启 controlled

七、input / change / blur 的正确选择

事件 建议 原因
input ❌ 少用 频繁、不可控
change ✅ 推荐 停止输入后的业务节点
blur ⚠️ UI 用 只是焦点变化

业务校验一律优先用 change


八、ref、v-for、响应式的隐藏雷区

1. ref + v-for

  • H5:ref 数组
  • 小程序:可能只有最后一个

👉 不要依赖 ref 操作列表项


2. 深层对象更新

list[index].count = 1

在小程序中不稳定,推荐:

this.list = this.list.map(item =>
  item.id === row.id ? { ...item, count: 1 } : item
)
//或者
const index=this.list.find(item.id===row.id)
this.$set(this.list[index], 'count', 1)

不可变更新在小程序更安全。


九、统一解释模型(记住这个)

你遇到的所有问题,其实都是:

逻辑层变了,但视图层没有按你预期同步

对应关系:

现象 根因
v-show 不生效 视图结构不重算
ref 调不到 组件未 ready
input 回写失败 内部状态优先

十、小程序开发「安全原则」

  1. 少用 input,多用 change
  2. 表格、弹窗优先 v-if
  3. ref 一律 nextTick
  4. 组件要么全受控,要么不受控
  5. 减少 setData 次数

十一、一句话总结

H5 是“我命令页面怎么变” 小程序是“我请求页面帮我变”

当你真正接受这一点,80% 的坑都会自动消失。

浅析Vite前端构建工具

浅析Vite前端构建工具

本篇文章意在帮助初学者了解Vite的工作流程以及学习常见配置

Vite简介

Vite(法语意为快速)是vue官方发布的新型前端构建工具,用于快速构建前端项目,显著提升开发体验,其特点就是快!

!!!注意:Vite是构建工具,虽然具有打包功能,但其打包能力来自其他打包工具,如在开发环境下使用esbuild进行打包,而在生产环境下则使用rollup进行打包,vite更注重的是前端的自动化流程。开发好的代码,通过自动化流程转换成生产环境代码。

为什么选择Vite

随着前端的发展,我们开始需要构建越来越大型的应用,需要处理的JavaScript代码呈指数级增长。

而传统的打包工具在构建这种大型项目时就会遇到性能问题,如项目启动速度缓慢,热更新(HMR)迟钝,这极大的影响了开发者的开发体验。

以下是常见的打包构建工具:

  • Webpack
  • rollup
  • parcel
  • gulp

这些工具在大型项目中都存在缓慢的服务启动和缓慢的热更新问题,而Vite恰好对这两个问题提出了解决方案,所以Vite在启动项目时的速度相当之快

所以Vite的优势就是:

  • 开发启动速度快
  • 热更新(HMR)响应迅速
  • 构建性能高效
  • 配置简单且扩展性强

Vite的打包构建策略

这里我们将简单梳理Vite的在开发阶段、热更新阶段,以及在生产阶段时的构建思路。

先来看vite官方文档中的两张图

前一张图是传统构建工具的打包构建流程图,它们在启动项目时采用的策略是对项目代码和其依赖进行全量打包后再启动服务。

后一张图是Vite的打包构建流程,Vite采用的策略是先启动服务,之后按需加载所需要的代码文件。

  • 图一

  • 图二

传统打包构建工具的问题

从不同的打包策略中我们可以分析出,造成传统打包工具服务启动缓慢,热更新响应速度慢的原因:

  • 必须打包后才能启动服务。当项目越大,代码文件越多时,打包需要处理的模块数量就越多,启动等待时间就会成先行指数级增长,大型项目启动耗时几分钟很常见。
  • 依赖处理无缓存。一些第三方依赖(如Vue、React、Axios、Lodash)通常体积大且变动少,每次启动/修改都会重复处理,就会造成资源浪费,进一步延长了启动时间。
  • 模块依赖链重新构建。当某个文件被修改时,工具不仅要重新编译这个文件,还要重新处理所有依赖它的上游模块,导致热更新速度慢。

开发环境打包

一开始Vite将应用中的模块区分为了两类,依赖源码

  • 依赖:大多是在开发时不会变动的第三方库(如loadash、elementPlus等)。

  • 源码:通常是开发者编写的代码,时常会被编辑,需要转换的文件(如Vue、CSS、JSX等)

开发环境下,Vite会使用esbuild对依赖进行打包(即依赖预构建)。

对于源码Vite通常将其打包工作交给了浏览器而不是打包,只在浏览器请求源码时进行转换并按需提供源码。

esbuild是go语言编写的打包工具,比使用JavaScript编写的打包工具速度快10-100倍。

热更新(HMR)

前面我们知道了,如果仅仅修改一个文件就要重新构建整个模块是及其低效的,而且体积越大时,更新的速度也会越慢。

所以Vite热更新的策略是精准、高效地更新变化的模块,避免全量页面刷新,同时尽可能保留应用状态。

整个热更新阶段,vite经历了以下几个步骤

  1. 首先开发服务器会实时监控文件修改。
  2. 监测到文件修改时,会通过模块依赖图,找到受变更影响的模块链。
  3. 针对变化模块(如 JS、CSS、Vue 等)快速生成更新代码。
  4. 然后通过 WebSocket 将更新指令发送给客户端。
  5. 客户端在接收指令后,动态替换旧模块、执行新代码,仅更新受影响部分,保留应用状态,避免全量刷新。

生产环境打包

Vite 在生产环境的打包构建思路与开发环境截然不同,核心目标是生成优化后的、可直接部署的静态资源,兼顾性能(加载速度、运行效率)和浏览器兼容性。

  1. 基于 Rollup 进行打包构建

首先VIte使用功能更成熟的Rollup 作为底层打包工具(开发阶段用的是esbuild,因为它对代码压缩、Tree-shaking 等优化更高效)。

  1. 配置Vite后可以进行的优化(生产环境下)
    • 自动将代码拆分为多个 chunk:
      • 入口 chunk:应用的主入口代码。
      • 公共 chunk:提取多个模块共享的代码(如工具函数、重复依赖),避免重复加载。
      • 异步 chunk:对 import() 动态导入的模块单独打包,实现 “按需加载”(如路由懒加载),减少初始加载体积。
    • Tree-Shaking 消除冗余代码:
      • 利用 Rollup 的 Tree-Shaking 能力,删除未被引用的代码(如未使用的函数、变量),减小最终 bundle 体积。
      • 对第三方库,优先使用其 ESM 版本(通过 package.jsonmoduleexports 字段),确保 Tree-Shaking 有效。
    • 对 JS、CSS 代码进行压缩:
      • JS:使用 terser 压缩(默认开启),移除空格、缩短变量名、合并语句等。
      • CSS:使用 cssnano 压缩,移除冗余样式、合并规则等。
    • 支持通过配置来开启代码混淆,提高逆向工程难度。
    • 优化图片、字体等静态资源体积:
      • 小资源(如小于 4KB 的图片)自动转为 base64 编码嵌入代码,减少网络请求
      • 大资源生成带哈希值的文件名(如 logo.8f3b7a.png),配合长期缓存策略(Cache-Control),提升二次加载速度。
    • 兼容性处理:
      • 通过 @vitejs/plugin-legacy 插件:生成兼容 ES5 的代码(使用 babel 转换语法),并且自动注入 polyfill(如 core-js),处理新 API 兼容问题。
      • 通过 postcss + autoprefixer自动添加CSS前缀,从而适配不同浏览器内核。
  2. 构建流程
    • 解析配置:读取 vite.config.js 中的生产环境配置(如 build 选项)。
    • 依赖预构建复用:复用开发环境预构建的第三方依赖(或重新构建,确保一致性),避免重复处理。
    • 模块打包:通过 Rollup 递归解析项目依赖,应用代码分割、Tree-Shaking 等优化,生成多个 chunk。
    • 资源处理:处理静态资源、压缩代码、生成哈希文件名。
    • 生成产物:输出最终文件到 dist 目录,包含 HTML、JS、CSS、静态资源等,并自动处理资源间的引用路径(如 HTML 中引入带哈希的 JS/CSS)。

依赖预构建

这里我们将了解在生产阶段时,Vite如何对依赖进行预构建,以及做了哪些优化。

目的

  • 更好的兼容性
  • 更好的性能

这里我们先简单创建一个Vite项目,方便后续讲解。

创建一个示例项目

首先,打开或者新建一个空的项目文件夹,在其根目录下打开控制台并执行以下命令初始化package.json文件。

npm init -y // -y:自动在package.json中填入默认配置,无需手动配置
或
pnpm init // pnpm无需 -y,默认进行配置

然后安装Vite

npm i vite -D // -D, --save-dev的缩写,作用是将依赖包安装为 “开发环境依赖”
或
pnpm add vite -D

然后初始化项目结构:

video-test
├─ node_modules
├─ main.js
├─ index.html  // 项目打包入口文件
├─ package-lock.json // 使用pnpm命令时是pnpm-lock.yaml
├─ package.json

在index.html中引入main.js

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <!-- type="moudle"作用是告诉浏览器按照 ESM 规则解析和执行代码 -->
    <script type="moudle" src="./main.js"></script>    
</body>
</html>

依赖打包

Vite在启动开发服务器时,会对依赖进行预构建:Vite会使用esbuild对第三方依赖对其进行打包。打包的过程中就会执行兼容性处理路径解析文件缓存优化浏览器缓存优化,然后对源码进行一系列处理后交给浏览器打包。

兼容性处理

在开发时,很多第三方库或项目代码可能使用 CommonJS(CJS)、UMD 等非 ESM 模块化语法,而浏览器原生 ESM 只能识别 import/export 语法,无法直接运行 CJS 模块(如 requiremodule.exports)。

例如lodash库,执行以下命令引入lodash库:

npm i lodash 
或
pnpm add lodash

打开node_modules\lodash\_apply.js,我们可以看到lodash使用了非ESM标准的导入导出方式

Vite的开发服务器将所有代码视为原生 ES 模块,所以必须要处理兼容性问题。Vite采用策略是当浏览器对某个模块发送请求时使用esbuild对其进行转换操作,不仅解决了兼容性问题,还避免了全量打包带来的性能消耗。

路径解析

1.无法识别的路径

浏览器并不知道要到node_modules目录下去找对应依赖,即对于非相对路径和绝对路径的导入是无法识别的。

import axios from 'axios' // 没有使用绝对路径和相对路径,浏览器不知道怎么获取

为了处理无法识别例如'axios'这种裸模块名,Vite会在依赖构建阶段对其进行路径映射为具体的文件路径:

  • 分析依赖的入口文件(通过 package.jsonmainmodule 字段),确定该模块名实际对应的文件(如 node_modules/axios/dist/axios.esm.js)。
  • 预构建后的依赖会放到.node_modules/.vite/.deps目录下,并生成一个映射表(deps_map),记录裸模块名与实际路径的对应关系。

然后在浏览器解析到import axios from 'axios'而对Vite开发服务器发送请求时,Vite在拦截到该请求后,根据依赖构建阶段时创建的映射表将其路径重写 为具体的预构建文件路径(如 /node_modules/.vite/deps/axios.js),并返回给浏览器,使得浏览器能正确加载模块。

2.相对路径和别名路径的处理

对于项目内的相对路径(如 import './utils'),Vite 会自动补全文件后缀(.js.ts 等)和目录索引文件(index.js),避免浏览器因路径不全导致的 404 错误。同时支持通过 vite.config.js 配置 resolve.alias 自定义路径别名(如 @/utils),进一步简化路径编写。

文件缓存

打包后的代码会放到node_modules/.vite文件下面进行文件缓存(同时转换为ESM标准),并且将嵌套引用的依赖打包为一个文件,以减少浏览器的http请求(例如lodash库作为第三方依赖会被打包成一个js文件),并在之后的开发服务启动时使用这个缓存直到第三方依赖发生修改才重新进行依赖预构建,减少了依赖打包构建次数。

  • node_modules/.vite 目录下的缓存文件会包含版本哈希标识(如 react.js?v=123abc),哈希值由依赖内容、版本号及 Vite 配置共同生成,确保依赖或配置变更时,缓存会自动失效并触发重新构建。
  • 缓存目录中还会生成 _metadata.json 文件,记录依赖的构建信息(如入口、依赖链),用于 Vite 启动时快速校验缓存是否有效,减少重复扫描开销。

浏览器缓存

同时对于已预构建的依赖请求使用 HTTP 头 max-age=31536000, immutable 进行强缓存,以提高开发期间页面重新加载的性能。同样如果依赖源没有发生改变,这些请求将永远不会再次访问开发服务器,而是直接从级存中读取。而对于源码。

源码交给浏览器打包

  • 源码(如业务 JS、TS、Vue 组件等)会被 Vite 实时转换为 ESM 格式(例如将 TS 转 JS、处理 CSS 模块),但不进行合并打包,而是保留模块间的引用关系(如 import './component')。
  • 浏览器加载这些 ESM 模块时,会根据 import 语句主动发起子请求,由 Vite 服务器实时返回对应模块的转换结果,本质是将 “打包合并” 的工作分散到浏览器的原生模块加载过程中,减少开发服务器的计算压力,实现 “按需编译”。

配置智能提示

在添加配置项的时候会给出配置项联想,如图所示在配置对象中敲ser就会出现提示框

官方给了三种智能提示的配置方法,推荐使用下面这种方式

import { defineConfig } from 'vite'

export default defineConfig({
//...
})

准备配置文件

准备好以下几个文件

vite.prod.config.js

import { defineConfig } from 'vite'
// 生产环境配置
export default defineConfig({  
  build: {
      
  }
})

vite.dev.config.js

import { defineConfig } from 'vite'
//开发环境配置
export default defineConfig({ 
  server: { 
  
  }
})

vite.base.config.js

import { defineConfig } from 'vite'
// 公共配置
export default defineConfig({                        
})

将上诉的配置文件汇总,采用策略模式,在不同环境下引入不同的配置

vite.config.js

import { defineConfig } from "vite"
import viteBaseConfig from './vite.base.config.js' //基础配置
import viteDevConfig from './vite.dev.config.js' // 开发环境配置
import viteProdConfig from './vite.prod.config.js' // 生产环境配置

// 策略模式
const envResolver = {
  // 这里的展开运算符和Object.assign()都是浅拷贝的方法
  "build": () => ({...viteBaseConfig, ...viteProdConfig}),
  "serve": () => Object.assign({},viteBaseConfig, viteDevConfig)
}
export default defineConfig( ({command}) => {
  // command: 根据命令(pnpm dev | pnpm serve) 返回 ("build" | "serve")
  console.log("command:", command);

  return envResolver[command]()
})

环境变量(env)

环境变量:是在操作系统或应用程序运行时存在的一组动态键值对(key-value),用于存储影响程序行为的配置信息。它们独立于程序代码本身,通过外部传递给程序,使程序能在不同环境(如开发、测试、生产)中灵活调整行为,而无需修改代码。

loadEnv

是Vite的一个专门加载和处理环境变量的API,类似于dotenv库 dotenv 是一个非常实用的第三方库,主要用于从 .env 文件中加载环境变量到 process.env

loadEnv的三个参数

  • mode: 当前运行模式(如 development、production、test,注意拼写)
  • envDir: .env 相关文件所在目录(默认项目根目录)
  • prefix: 环境变量的过滤前缀(用于后续注入import.meta.env,默认VITE_)

loadEnv执行过程

  1. 根据mode加载指定目录下的环境文件,加载顺序(优先级从低到高):

    • .env (所有环境共享的基础配置)
    • .env.local (本地私有基础配置,覆盖.env,通常不上传仓库)
    • .env.${mode} (当前模式的专属配置,如.env.development)
    • .env.${mode}.local (当前模式的本地私有配置,覆盖.env.${mode},不上传仓库) 同时,系统环境变量的优先级最高,会覆盖所有文件中的同名变量
  2. 变量合并规则:后加载的配置会覆盖前序配置中的同名变量 例如mode="development"时,最终合并结果为:

    { ...基础.env, ...本地.env.local, ...开发环境.env.development, ...开发本地.env.development.local, ...系统环境变量 } // 展开运算符

  3. 函数返回值为合并后的所有环境变量(包含所有前缀/非前缀变量)

  4. loadEnv的返回值包含所有符合其自身prefix参数的环境变量(Node环境可见,用于配置逻辑) 但最终注入到import.meta.env的变量,由Vite的全局配置envPrefix(默认VITE_)决定:

    • 只有符合envPrefix前缀的变量,才会被注入到前端代码的import.meta.env中(浏览器环境可见);
    • envPrefix前缀的变量(即使被loadEnv返回),也不会暴露到前端,仅在Node环境的配置中可用(如后端密钥等敏感信息)。

手动加载机制

在配置 Vite 本身时(如自定义开发服务器端口、生产环境打包路径等),需要依赖环境变量动态调整配置。此时通过 loadEnv 手动加载 .env* 文件中的变量,可在配置逻辑中直接使用。

通常为了在大型项目中提高项目的可维护性,我们可以在不同环境下都维护一个环境变量文件,即创建以下三个文件:

  • .env:公共的环境变量文件
  • .env.development:开发环境下的环境变量文件
  • .env.production:生产环境下的环境变量文件

将这些配置文件放到项目根目录\config\env\

然后可以在vite.config.js中使用loadEnv方法统一获取,传递给不同环境下的配置文件

vite.config.js中修改:

import { defineConfig, loadEnv } from 'vite'
import viteDevConfig from './vite.dev.config.js'
import viteProdConfig from './vite.prod.config.js'
import viteBaseConfig from './vite.base.config.js'

// 策略模式
const endResolver = {
  // 添加一个参数
  "build": (env) => {
     console.log("生产环境");
     return Object.assign({}, viteBaseConfig, viteProdConfig(env))
  },
  // 添加一个参数
  "serve": (env) => {
     console.log("开发环境");
     return ({ ...viteBaseConfig, ...viteDevConfig(env)})
  } 
}

export default defineConfig(({command, mode}) => {
  // command: 根据命令(pnpm dev | pnpm serve) 返回 ("build" | "serve")
  // mode: vite会将命令(pnpm dev --mode delvelopment)中--mode后面的值development赋值给mode
  console.log("command:", command);
  console.log("mode:", mode);
  
  // 获取到对应环境下的环境变量,loadEnv的合并规则请查看执行过程中的描述
  const env = loadEnv(mode, './config/env', "")
  console.log(".env:", env);
  
  return  endResolver[command](env) // 将其作为参数传给对应的配置文件 
})

vite.prod.config.js

import { defineConfig } from 'vite'

export default (env) => defineConfig({
  server: {
    
  }
})

vite.dev.config.js

import { defineConfig } from 'vite'

export default (env) => defineConfig({
  build: {
    
  }
})

自动加载机制

Vite 会在项目构建或开发过程中,自动从指定目录加载环境变量文件,并将符合规则的变量注入到客户端代码的 import.meta.env。这一过程由 Vite 内部机制完成,无需开发者手动调用函数,核心依赖以下配置:

  • envDir 配置项:指定环境变量文件(如 .env.env.[mode])的存放目录(默认为项目根目录)。Vite 会根据当前运行模式(mode)自动从该目录加载对应的文件(如开发模式加载 .env.development)。
  • envPrefix 配置项:指定客户端可访问的环境变量前缀(默认为 VITE_)。只有带此前缀的变量会被注入到 import.meta.env,确保非必要变量不暴露给客户端。

所以我们需要在公共的配置文件中添加配置项

vite.base.config.js:

import { defineConfig } from 'vite'
import URL from ''
// 基础公共配置
export default defineConfig({
    envDir: './config/env' // 指定环境变量的加载目录
    envPrefix:'', // 前缀过滤
})

Vite对css的模块化处理

vite默认识别文件的方式

  • 新建index.css文件,在main.js中引入import 'index.css'
#app {
    background-color: pink;
    width: 200px;
    height: 200px;
}

效果:

我们发现了,在head标签里面新建了一个style标签,index.css 文件里里面的内容,都被写到了标签里面。

Vite 的开发服务器检测到 main.js 中导入了 index.css 时,会对该 CSS 文件进行特殊处理:它会将 CSS 内容转换为一个 JavaScript 模块,并在响应头中设置 Content-Type: text/javascript,浏览器就会认为这是一个 JS 脚本,从而让浏览器以js脚本的形式来加载后缀名为.css的文件。浏览器加载这个 JS 模块后,会执行其中的逻辑 —— 动态创建 <style> 标签,并将原本的 CSS 内容插入到该标签中,最终使样式生效。

转化为js脚本来加载CSS,主要有以下几个好处:

  • 用于热更新
  • 用于css模块化。
  • 通过ESM 原生加载和避免开发时打包,提高了编译性能。

CSS模块化

CSS 模块化(CSS Modules)是一种用于解决传统 CSS 的 “全局作用域” 问题,即当多个文件中出现同名类名时,后加载的样式会覆盖先加载的样式,导致样式冲突。

普通 CSS 文件(非模块化,如 style.css

类名会被原样保留,属于全局作用域,具体处理逻辑:

  1. 开发环境:Vite 将 CSS 内容通过动态创建的 <style> 标签注入到页面,类名直接生效(如 .title 会被浏览器解析为全局类名)。
  2. 生产环境:CSS 会被提取为独立文件,类名保持原样,加载后仍为全局作用域。

特点:多个文件中的同名类名会发生冲突(后加载的样式覆盖先加载的)。

CSS 模块化文件(如 style.module.css

类名会被哈希化处理,生成唯一标识,限制在局部作用域,具体流程:

Vite会对后缀名为.module.css进行模块化处理。

  1. 类名转换:Vite 会对模块化 CSS 中的类名进行哈希处理(如 .titletitle_1a2b3c),确保全局唯一,避免冲突。
    • 转换规则可通过 Vite 配置自定义(如 [name]__[local]___[hash],其中 name 是文件名,local 是原类名)。
  2. 在 JS 中引用:导入模块化 CSS 后,会得到一个对象,键为原类名,值为哈希后的类名。

创建a.js、b.js、 a.module.css、b.moudle.css四个文件分别放入如下内容

a.js

import aModuleCss from './a.module.css'
console.log("aModuleCss:",aModuleCss);

b.js

import bModuleCss from './b.module.css'
console.log("bModuleCss:",bModuleCss);

a.module.css

.demo {
    width: 100px;
    height: 100px;
    background-color: pink;
}

b.moudle.css

.demo {
    width: 100px;
    height: 100px;
    background-color: skyblue;
}

然后在main.js中引入 a.js和b.js文件,启动服务,查看浏览器控制台

然后我们可以看到,vite创建了两个映射对象,我们写的样式会被处理成key:value的形式,以原类名作为键名,处理后的类名作为键值。

image-20251113222925461转存失败,建议直接上传图片文件

然后真实引入的样式的类名会被替换成处理后的键值。最后再创建两个元素添加处理后的类名,写入如下代码

a.js

import aModuleCss from './a.module.css'
console.log("aModuleCss:",aModuleCss);

const div = document.createElement('div')
div.className = aModuleCss.demo //原类名demo作为属性名,处理后的类名作为属性值
document.body.appendChild(div)

b.js

import bModuleCss from './b.module.css'
console.log("bModuleCss:",bModuleCss);

const div = document.createElement('div')
div.className = bModuleCss.demo
document.body.appendChild(div)

效果:

渲染效果:在 HTML 中最终应用的是哈希后的类名(如 <div class="_demo_10cvw_1">),并且仅对当前模块生效。

总结:开启css模块化之后,除了会进行默认的css处理,还会进行类名替换,防止全局类名冲突

css.modules配置行为

在 Vite 中,css.modules 配置项用于自定义 CSS Modules 的行为(CSS Modules 是一种通过局部作用域类名避免样式冲突的技术)。Vite 内置了对 CSS Modules 的支持,而 css.modules 就是用来精细化控制其规则的,下面详细讲解每个配置的作用、使用场景和示例:

基础前提

在 Vite 中,默认约定:文件名以 .module.css(或 .module.scss 等预处理器后缀)结尾的文件会被当作 CSS Modules 处理,类名会被自动转换为局部作用域(编译成哈希字符串,如 header -> header_1a2b3c);非 .module 结尾的 CSS 文件则默认是全局作用域(类名不变)。

css.modules 完整配置项详解

以下是 vite.config.jscss.modules 的所有可选配置,结合 Vite 实际使用场景说明:

interface CSSModulesOptions {
  getJSON?: (cssFileName: string,
    json: Record<string, string>,
    outputFileName: string,
  ) => void
  scopeBehaviour?: 'global' | 'local'
  globalModulePaths?: RegExp[]
  exportGlobals?: boolean
  generateScopedName?:
    | string
    | ((name: string, filename: string, css: string) => string)
  hashPrefix?: string
  /**
   * default: undefined
   */
  localsConvention?:
    | 'camelCase'
    | 'camelCaseOnly'
    | 'dashes'
    | 'dashesOnly'
    | ((
        originalClassName: string,
        generatedClassName: string,
        inputFile: string,
      ) => string)
}
1. scopeBehaviour:默认作用域行为
  • 类型'global''local'(字符串,二选一)

  • 默认值'local'

  • 作用

    :指定 CSS Modules 文件中类名的默认作用域(全局 / 局部)。

    • 'local'(默认):所有类名默认是局部的(会被编译成哈希,避免冲突)。
    • 'global':所有类名默认是全局的(不编译,和普通 CSS 一样),此时需要用 :local() 手动指定局部类名。
  • 示例

    modules: {
      scopeBehaviour: 'global' // 默认全局,需手动用 :local() 定义局部类
    }
    

    对应 CSS(style.module.css):

    /* 全局类(因为 scopeBehaviour 是 global) */
    .title { color: red; }
    
    /* 局部类(手动用 :local() 标记,会被编译) */
    :local(.content) { font-size: 16px; }
    
2. globalModulePaths:指定全局 CSS 文件路径
  • 类型:正则表达式数组(如 [/\.global\.css$/]

  • 作用:通过正则匹配文件路径,符合规则的 .module.css 文件会被当作全局 CSS 处理(类名不编译),忽略 .module 后缀的约定。

  • 使用场景:需要批量将某些 .module.css 文件设为全局(比如第三方组件的样式)。

  • 示例

    modules: {
      globalModulePaths: [/node_modules/]// 匹配 node_modules 下的 .module.css 文件,当作全局处理
      // 另一个例子:匹配所有带 .global. 的文件,如 home.global.module.css
      // globalModulePaths: [/\.global\./]
    }
    
3. exportGlobals:是否导出全局类名
  • 类型:布尔值(true / false

  • 默认值false

  • 作用:如果 CSS 中用 :global() 定义了全局类名,是否将其导出到模块的 styles 对象中(方便在 JS 中引用)。

  • 示例

    modules: { exportGlobals: true }
    

    CSS(style.module.css):

    /* 全局类 */
    :global(.global-title) { color: blue; }
    
    /* 局部类 */
    .local-content { font-size: 14px; }
    

    JS 中引用:

    import styles from './style.module.css';
    console.log(styles); 
    // 因为 exportGlobals: true,会包含全局类:
    // { globalTitle: 'global-title', localContent: 'local-content_1a2b3c' }
    

    此时可以用 className={styles.globalTitle} 引用全局类。

4. generateScopedName:自定义局部类名格式
  • 类型:字符串 或 函数

  • 默认值:开发环境是 '[name]__[local]___[hash:base64:5]'(如 style__content___1a2b3),生产环境是短哈希(如 1a2b3

  • 作用:自定义局部类名的编译结果(默认是哈希,可读性差,可通过此配置优化)。

  • 字符串模式

    (常用占位符):

    • [name]:CSS 文件名(不含后缀)
    • [local]:原始类名(如 .content 中的 content
    • [hash:长度]:哈希值(可指定长度,如 [hash:6] 生成 6 位哈希)
    • [hash:base64:长度]:base64 格式的哈希
  • 函数模式:接收 3 个参数,返回自定义类名字符串:

    • name:原始类名
    • filename:CSS 文件路径
    • css:CSS 内容
  • 示例

    modules: {
      // 字符串格式:文件名__原始类名--6位哈希
      generateScopedName: '[name]__[local]--[hash:6]',
    
      // 函数格式:自定义逻辑(如添加前缀)
      generateScopedName: (name, filename, css) => {
        const prefix = filename.split('/').pop().replace('.module.css', '');
        return `my-${prefix}-${name}-${Math.random().toString(36).slice(2, 8)}`;
      }
    }
    

    假设文件是header.module.css,类名是.title,编译后可能是:

    • 字符串模式:header__title--1a2b3c
    • 函数模式:my-header-title-xyz789
5. hashPrefix:哈希前缀
  • 类型:字符串

  • 作用:给生成的哈希值添加一个前缀,确保不同项目 / 环境的类名哈希不冲突(哈希计算会包含这个前缀)。

  • 示例

    modules: { hashPrefix: 'my-project-v1' }
    

    此时哈希值会基于my-project-v1计算,避免和其他项目的哈希重复。

6. localsConvention:JS 中类名的命名格式
  • 类型:字符串('camelCase' / 'camelCaseOnly' / 'dashes' / 'dashesOnly')或 函数

  • 默认值undefined(不转换,保持原始类名)

  • 作用:CSS 中类名常用横杠(如 .user-info),但 JS 中更习惯驼峰(userInfo),此配置用于自动转换导出到 JS 的类名格式。

  • 字符串选项说明:

    • 'camelCase':横杠转驼峰,同时保留原始类名(如 .user-info 可通过 styles.userInfostyles['user-info'] 访问)。
    • 'camelCaseOnly':只保留驼峰形式(styles.userInfo 有效,styles['user-info'] 无效)。
    • 'dashes':驼峰转横杠,同时保留原始类名(如 .userInfo 可通过 styles['user-info']styles.userInfo 访问)。
    • 'dashesOnly':只保留横杠形式(styles['user-info'] 有效,styles.userInfo 无效)。
  • 函数模式:自定义转换规则,接收 3 个参数:

    • originalClassName:原始类名(如 user-info
    • generatedClassName:编译后的类名(如 user-info_1a2b3c
    • inputFile:CSS 文件路径
  • 示例

    modules: {
      localsConvention: 'camelCase' // 横杠转驼峰,保留原始
    }
    

    CSS(style.module.css):

    .user-info { color: green; }
    

    JS 中引用:

    import styles from './style.module.css';
    // 两种方式都能访问
    console.log(styles.userInfo); // 有效(驼峰)
    console.log(styles['user-info']); // 有效(原始)
    
7. getJSON:处理类名映射关系
  • 类型:函数

  • 作用:当 CSS Modules 编译完成后,会生成一个 “原始类名 -> 编译后类名” 的映射对象(JSON),此函数用于自定义处理这个映射(如保存到文件、打印日志等)。

  • 参数:

    • cssFileName:原始 CSS 文件的路径(如 src/style.module.css
    • json:映射对象(如 { "user-info": "user-info_1a2b3c" }
    • outputFileName:Vite 自动生成的映射文件路径(通常不需要手动处理)
  • 使用场景:需要记录类名映射关系(如调试、自定义生成类型文件等)。

  • 示例

    modules: {
      getJSON: (cssFileName, json, outputFileName) => {
        console.log('CSS 文件:', cssFileName);
        console.log('类名映射:', json);
        // 可以将 json 保存到本地文件(需要 fs 模块)
        // const fs = require('fs');
        // fs.writeFileSync('./class-map.json', JSON.stringify(json, null, 2));
      }
    }
    
实际项目常用配置示例

结合开发和生产环境的需求,一个典型的 css.modules 配置可能是这样的

// vite.config.js
export default {
  css: {
    modules: {
      // 开发环境下保留可读性好的类名,生产环境用短哈希
      generateScopedName: process.env.NODE_ENV === 'development'
        ? '[name]__[local]--[hash:5]'
        : '[hash:6]',
      // 横杠转驼峰,方便 JS 中用 . 访问
      localsConvention: 'camelCase',
      // 添加哈希前缀,避免冲突
      hashPrefix: 'my-app',
      // 导出全局类名,方便引用
      exportGlobals: true
    }
  }
}
总结

css.modules 配置的核心目的是:控制 CSS 类名的编译规则(局部 / 全局、格式)和 JS 中的引用方式。通过合理配置,可以让 CSS Modules 更符合项目的开发习惯,同时避免样式冲突。日常开发中,generateScopedName(自定义类名)和 localsConvention(驼峰转换)是最常用的两个配置。

css.preprocessorOptions(预处理器)

preprocessorOptions 就是给项目的 CSS 预处理器(比如 SCSS、Less)提供一个 “全局配置” 的地方。在 Vite 处理 .scss.sass 文件之前,它会先读取这些配置,并应用到整个编译过程中。

作用

  1. 引入全局样式文件:你可能有一个 variables.scss 文件,里面定义了所有的颜色、字体、间距等变量;还有一个 mixins.scss 文件,里面是一些可复用的样式片段。你不想在每一个 .scss 文件的开头都写一遍 @import './variables.scss';,这太繁琐了。
  2. 定义全局变量 / 混合宏:有些简单的变量或混合宏(mixin),你可能不想专门为它创建一个文件,希望能直接在所有 SCSS 文件中使用。
  3. 修改 SCSS 编译器的行为:比如,让 SCSS 允许开发者使用 @debug 指令在控制台打印调试信息,或者想改变它处理 URL 的方式。

全局配置项目中sass的行为

在vite项目中使用sass预处理器需要先安装相关依赖

pnpm add sass sass-loader -D

安装完成后,不需要做任何其他配置。Vite 会自动检测到这些依赖,并在导入 .scss.sass 文件时,自动使用它们进行编译。

**1. additionalData **

这个属性允许你在每一个被编译的 SCSS/Sass 文件的开头,自动注入一段 SCSS 代码。

场景一:自动引入全局样式文件

假设你的项目结构如下:

src/
├── assets/
│   └── styles/
│       ├── variables.scss  // 定义了 $primary-color: #1890ff;
│       └── mixins.scss     // 定义了 @mixin flex-center { ... }
└── components/
    └── Button/
        └── index.scss

如果你不配置 additionalData,那么在 Button/index.scss 中,你必须这样写:

// Button/index.scss
@import '../../assets/styles/variables.scss';
@import '../../assets/styles/mixins.scss';

.button {
  color: $primary-color;
  @include flex-center;
}

如果项目大了,每个文件都要写这么一长串 @import,不仅麻烦,而且容易出错。

使用 additionalData 后,你的 vite.config.js 可以这样配置:

// vite.config.js
import { defineConfig } from 'vite';
import path from 'path'; // 需要引入 path 模块来处理路径

export default defineConfig({
  // ... 其他配置
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `
          @import "@/assets/styles/variables.scss";
          @import "@/assets/styles/mixins.scss";
        `,
      },
    },
  },
  // 为了让 @/ 能正确指向 src 目录,通常需要配置 resolve
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
    },
  },
});

注意:

  • @/ 是一个别名,代表 src/ 目录,这需要在 resolve.alias 中配置。
  • 引入的文件路径是相对于项目根目录的,或者相对于 alias 配置的路径。
  • 可以把这段注入的代码想象成一个 “隐形的” 头部,自动加到了每个 .scss 文件的最前面。

现在,你的 Button/index.scss 就可以变得非常干净

// Button/index.scss
.button {
  color: $primary-color; // 直接使用,无需手动导入
  @include flex-center;  // 直接使用
}

场景二:定义全局变量或混合宏

如果你只有一两个简单的全局变量,不想为此创建一个文件,可以直接写在 additionalData 里。

// vite.config.js
export default defineConfig({
  css: {
    preprocessorOptions: {
      scss: {
        additionalData:  `
          $primary-color: #1890ff
          $secondary-color: #41b883
          $font-size-base: 16px
          $border-radius: 4px
        `,
      },
    },
  },
});

这样,在任何 .scss 文件中,你都可以直接使用 background: $primary-color;

2. importer (高级用法)

这是一个函数,用于自定义 SCSS 的 @import 解析逻辑。当 SCSS 编译器遇到一个 @import 语句时,它会调用这个函数,让你有机会去自定义文件的查找和读取方式。

这通常用于一些比较复杂的场景,例如:

  • node_modules 中引入文件时,可以省略长长的路径。
  • 根据不同的环境(开发 / 生产)引入不同的主题文件。

示例:

假设你想让 @import 'my-theme'; 能够直接找到 src/themes/main.scss 文件。

// vite.config.js
import { defineConfig } from 'vite';
import path from 'path';
import fs from 'fs'; // 文件系统模块

export default defineConfig({
  css: {
    preprocessorOptions: {
      scss: {
        importer(url, prev, done) {
          // url 是 @import 的目标,比如 'my-theme'
          // prev 是当前文件的路径
          
          if (url === 'my-theme') {
            const themePath = path.resolve(__dirname, 'src/themes/main.scss');
            // 读取文件内容并返回
            return { contents: fs.readFileSync(themePath, 'utf8') };
          }
          
          // 如果不是我们关心的 url,就返回 null,让 SCSS 按默认方式处理
          done({ file: url });
        }
      },
    },
  },
});
3. outputStyle

这个配置项用来指定最终生成的 CSS 代码的风格。

  • 'expanded': 展开的风格,每个选择器和属性都单独一行,可读性最好(默认)。
  • 'compressed': 压缩后的风格,所有代码都挤在一行,没有空格和换行,文件体积最小。

注意:这个配置是作用于 SCSS 编译器本身的。在 Vite 中,开发环境下,最终的 CSS 输出还会受到 Vite 自身的 build.cssMinify 配置影响。通常,我们只在需要单独调试 SCSS 输出时才会关注这个选项。

css.devSourcemap(源映射)

devSourcemap 是开发环境中用于生成 CSS 源映射的配置,让浏览器开发者工具直接关联原始源码(如 SCSS、Less),而非编译后的压缩 CSS。

因为使用 CSS 预处理器(SCSS、Less、Stylus)时,编译后的代码与源码结构差异大,需源映射关联,以提高调试效率。

// vite.config.js
export default {
  css: {
    devSourcemap: true, // 开发环境开启源映射
    preprocessorOptions: {
      scss: { /* 预处理器配置 */ }
    }
  }
}

当我们不配置devSourcemap

// vite.base.config.js
import { defineConfig } from 'vite'
// 基础公共配置
export default defineConfig({
    css: { // 配置css行为
        devSourcemap: false
    }
})

启动开发服务器,如图当我们查看某一个元素的样式时,不会指出css来源

当配置devSourcemaptrue时,再次查看

推荐在开发环境下开始源码映射,生产环境建议关闭,避免暴露源码结构.

css.postcss

这是 Vite 中用于配置 PostCSS 处理器的核心选项。PostCSS 本身是一个用 JavaScript 工具和插件转换 CSS 代码的工具,可以通过其丰富的插件来实现自动添加浏览器前缀、CSS语法降级、代码质量检查与规范约束、使用未来 CSS 语法、代码压缩优化等功能。在 Vite 中内置并支持PostCSS,但是如果要使用它的插件需要安装相关依赖

常用的PostCSS插件

  • postcss-preset-envPostCSS预设包,预设了一些常用的插件的配置项(例如autoprefixer的自动补全)

  • autoprefixer: 根据package.jsonbrowserslist配置自动添加浏览器前缀(如-webkit--moz-

  • cssnano:压缩 CSS 代码,移除空格、注释、合并重复规则,减少文件体积

  • stylelint:检查 CSS 代码中的语法错误和风格问题(如属性排序、单位规范)

  • postcss-modules:实现CSS模块化,避免样式污染(类似Vue的<style scoped>)

**browserslist 配置 **

package.json中来配置兼容范围的好处就是,可以使得babelPostCSS处理语法兼容性的问题时的兼容范围一致

// package.json
{
"browserslist": ["cover: 99.5%"]
//"cover: 99.5%" 表示让 PostCSS 等工具适配全球 99.5% 的浏览器,
}

vite中配置

// vite.config.js
import { defineConfig } from 'vite'
import postcssPresetEnv from 'postcss-preset-env'
// import autoprefixer from 'autoprefixer'
export default defineConfig({
    css: {
       postcss: { // 配置postcss,完全支持在.postcss.config.js中的所能使用的配置项
            plugins: [ // 安装postcss插件
                postcssPresetEnv({})
                // autoprefixer({
                //     // 指定兼容 99.5%的浏览器。
                //     // browsers: ["cover 99.5%"],
                //     // 也可以在package.json中配置browerslist配置项
                // })
            ]
        },
    }
})

b.modules.css中:

.demo {
    width: 100px;
    height: 100px;
    background-color: skyblue;
}

.demo-content {
    width: 100px;
    height: 100px;
    background-color: green;
    display: flex; // 添加该行属性
}

Vite加载静态资源

静态资源

静态资源通常来说是网站中哪些不会随请求变化而变化的文件,浏览器可直接下载并渲染 / 使用,无需额外编译(或仅需前端构建工具预处理)。

常见的静态资源包括:

  • 图片:PNG、JPG、GIF、SVG、WebP等
  • 字体:TTF、OTF、WOFF、WOFF等
  • 媒体:MP4、WebM、MP3、AAC等
  • 样式:CSS文件
  • 脚本:不涉及服务端动态逻辑的JS文件(第三方库、前端工具脚本)
  • 其他:图标(favicon.ico)、JSON配置文件、静态HTML等

vite中的静态资源

Vite 中对静态资源处理的范围是静态资源的一个子集,主要识别一下两种为Vite所认为的静态资源

  • 当你在代码中通过 importrequire 导入一个非 JavaScript 文件时,Vite 会将其视为一个需要被处理的静态资源。
// main.js
import logo from './assets/logo.png'; // Vite 会处理 logo.png
import style from './style.css';     // Vite 会处理 style.css
  • Vite 会将项目根目录下 public 文件夹中的所有文件,视为无需处理的静态资源

生产环境下Vite对静态资源的处理行为

浏览器缓存

我们通过之前的学习已经知道了浏览器的缓存机制(尤其是强缓存,如 Cache-Control: max-age=31536000)会根据文件名来判断是否需要重新请求资源。所以呢当文件内容变动而文件名没有发生改变时,浏览器会继续使用缓存文件,而不是重新请求文件。

Vite 的hash命名处理

Vite 根据每一个文件的内容计算出一个哈希值,然后将这个哈希值作为文件的一部分对文件进行重命名(demo.[hash].js)

,所以一旦内容发生变动,哈希值就会发生变动。

与浏览器缓存机制的配合

利用浏览器缓存策略和Vite的哈希计算命名,就可以很好的控制浏览器的请求资源的行为:

  • 当文件没有发生改变时,文件名不变,浏览器使用缓存文件。
  • 当文件内容发生改变时,文件名发生改变,浏览器重新请求文件,并再次缓存。

例如在main.js的内容如下

console.log('hello world')

启动生产构建命令:pnpm build时,查看项目根目录下的dist目录下

当在最后一行添加注释后,重新构建

console.log('hello world')
// 新增注释

可以看到文件名发生了变化

.build.rollupOptions

在该配置项下可以配置Rollup的打包行为,因为生产环境下 Vite 使用 Rollup进行打包。

import { defineConfig } from 'vite'
// 生产环境配置
export default (env) => defineConfig({
    build: {
        assetsInlineLimit: 10 * 1024, 
        rollupOptions: { // 配置rollup的一些构建策略
            output: { // 控制输出行为
                assetFileNames: "[hash].[name].[Sext]"
                /** 处理打包后静态资源文件名
                 * * hash - Rollup根据文件内容计算的哈希值
                 * * name - 原文件名,如demo.js,name="demo"
                 * * .ext - 文件后缀名,如`.js、.vue...`
                 */
            },
        },
    },
})

运行一下pnpm build,查看dist目录下的变化,可以看到静态资源的文件名处理行为已经改为hash值在前了

.build.assetsInlineLimit

  • 默认值4096 (4KB)。
  • 生产环境下,对于小于或等于 assetsInlineLimit 的值,Vite 会将其直接内联到最终的 JS 或 CSS 文件中,而不是生成一个单独的文件。
import { defineConfig } from 'vite'

// 生产环境配置
export default (env) => defineConfig({
    build: {
        assetsInlineLimit: 10 * 1024,
    }
})

如果某个图片资源小于10KB,Vite就会将其转换为base64格式内联到项目中,反之则处理为静态资源。

对于有很多图标或者图片小于10KB的项目来说可以极大的减少http请求,提高网页性能。不过转换为base64字符串格式之后,体积一般会增大30%。

.build.outDir

  • 配置生产环境打包后的文件夹名
  • 默认值:"dist"
  • **类型:**字符串

.build.assetsDir

  • 配置生产环境打包后的静态资源文件夹名
  • 默认值:"assets"
  • **类型:**字符串

配置路径别名

通常在项目中问我们可以看到如下的导入方式

import SchedulStaff from '@/views/schedule/staff/index.vue'

可以看到路径开头是'@'不是相对路径也不是绝对路径的形式,这就是路径别名,用@来代替部分路径。

所以对于深层目录下的文件导入上层资源时,容易出现 ../../views/schedule/staff/index.vue 这类难以维护的路径,@ 可直接简化为 @/views/schedule/staff/index.vue

.resovle.alias

import { defineConfig } from 'vite'
import path from 'path' // node内置模块

export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, 'src'),
            "@assets": path.resolve(__diname, 'src/assets'),
            /**
             * path.resolve() - path模块提供的拼接路径的方法
             * ——dirname - node内置变量,获取当前配置文件所在目录的绝对路径
             */
}
}
})

配置完成后,在Vite项目中就可以使用@来代替指定的路径。

src/test.js中写入

console.log('路径别名配置成功')

main.js中引入

import '@/test.js'

启动项目,打开浏览器控制台:

路径提示以及TS项目中配置

默认情况下配置好了路径别名后,Vite能识别别名但是不会做出路径提示

可以在jsconfig.json或者tsconfig.json中写入如下配置

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"], // 与 Vite 配置一致
      "@assets/*": ["src/assets/*"]
    }
  }
}

以上配置有如下效果

  • 在项目中出现路径提示功能
  • TypeScript 项目,vite中配置别名后需要配合在 tsconfig.json 中补充路径映射,否则 TS 会提示 “模块找不到”:

两种路径风格

在用 Vue 脚手架工具时我们会发现,在配置路径别名的时候,路径配置用的时如下方式,fileURLToPath(new URL('src', import.meta.url))这种方式是现代的 ES Modules (ESM) 风格,而对于path.resolve(__dirname, 'src')CommonJS风格的方式。

  • import.meta.url: 这是 ESM 模块中的一个元数据属性。它返回当前模块文件的文件 URL 地址
  • new URL('src', import.meta.url): 这里使用了浏览器和 Node.js 都支持的 URL 构造函数。它的第二个参数是基准 URL。这个操作会将相对路径 'src' 解析为基于当前模块 URL 的一个绝对 URL。结果会是 file:///path/to/your/project/src
  • fileURLToPath(): 这是 Node.js url 模块提供的一个方法。它的作用是将一个 file: 协议的 URL 对象或字符串,转换为 Node.js 所使用的本地文件系统路径字符串(例如 C:\path\to\src/path/to/src)。
import { defineConfig } from 'vite'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
resolve: {
alias: {
            "@assets": fileURLToPath(new URL('src', import.meta.url)),
}
}
})

Vite使用的是ESM风格,所以 Vite也更推荐使用fileURLToPath(new URL('src', import.meta.url))

这种方式来进行路径配置。

Vite 插件

Vite 插件是基于 Rollup 插件接口 扩展的工具,用于增强 Vite 的核心能力(如模块转换、依赖处理、服务器中间件、构建优化等)。Vite 本身的很多核心功能(如 CSS 处理、TS 转译)也是通过内置插件实现的,第三方插件可以无缝集成,覆盖开发 / 构建全流程。

@vitejs/plugin-legacy

Vite 官方插件,核心作用是对 js 语法降级让生产环境的构建产物兼容旧版浏览器(比如 IE11、旧版 Chrome/Firefox)。

安装

// npm
npm install @vitejs/plugin-legacy --save-dev
// pnpm
pnpm add @vitejs/plugin-legacy -D

配置

import { defineConfig } from 'vite';
import legacy from '@vitejs/plugin-legacy';

export default defineConfig({
  plugins: [
    legacy({
      targets: ['defaults', 'not IE 11'] // 支持的目标浏览器(排除 IE11)
      // 也可以在package.json中配置browerslist配置项,统一css、js语法降级范围  
    })
  ]
});

使用:

配置完成后运行pnpm build命令Vite就会在对应的时机使用该插件进行JS语法降级

vite-plugin-mock

vite-plugin-mock 是一个在 Vite 项目中模拟后端 API 的插件,其内部依赖mock.js,它的核心作用是在开发环境中拦截前端发起的 API 请求,并返回预设的模拟数据,从而让前端开发可以不依赖真实的后端服务就能独立进行。

安装

npm install vite-plugin-mock --save-dev
pnpm add vite-plugin-mock -D

配置

import { defineConfig } from 'vite';
import { viteMockServe } from 'vite-plugin-mock'

export default defineConfig({
  plugins: [
viteMockServe({
            mockPath: './src/mock', // 模拟数据文件所在的目录
            localEnabled: true, // 开发环境是否启用 mock
            prodEnabled: false, // 生产环境是否启用 mock(一般关闭)
        })
  ]
});

使用

在src目录下创建src/mock/user.js

// user.js
export default [
  {
    url: '/api/users', // 要拦截的 API 路径
    method: 'get', // 请求方法
    response: () => {
      // 模拟返回的数据
      return {
        code: 200,
        message: 'success',
        data: [
          { id: 1, name: '张三' },
          { id: 2, name: '李四' },
        ],
      };
    },
  },
];

在main.js中请求

// main.js
fetch('/api/users').then(res => {
    console.log(res);
})

启动开发服务,查看浏览器请求预览

生产环境性能优化

分包策略:代码分割

对于vite中的代码分割的原理可以参考这一篇文章:深入解析 Vite 代码分割原理:从依赖入口点算法到动态导入优化 - WangHaoyu的文章 - 知乎。

作用:

在实际项目中,我们在生产环境打包时,对于那些第三方库和自己的代码可以分开打包,这样做的好处有以下几点:

  • 消除代码冗余,减小总打包体积

大型项目中,多个页面可能共用同一个组件(如弹窗、按钮),不分割的话,这个组件会被重复打包到每个页面的 bundle 里,导致总体积膨胀。

代码分割会把 “公共代码” 抽成独立 chunk,只打包一次,所有页面共享 ——总打包体积更小,且不会出现重复代码

  • 减少首屏加载时间,提升用户体验

不分割的代码会打包成一个巨大的bundle.js(可能几 MB 甚至十几 MB),用户打开页面时,必须等这个文件完全下载、解析、执行才能看到内容,体验极差(尤其是弱网 / 移动端)。

代码分割后,会优先加载「首屏必需的代码」(比如首页渲染、登录逻辑),非必需的代码(比如详情页、设置页、大型组件)延迟加载(按需加载)。

  • 利用浏览器缓存,减少用户重复下载

把稳定不变的代码(第三方依赖、公共组件)拆成独立 chunk(比如vendors~main.js),这些 chunk 的文件名通常会带哈希(如vendors~main.123abc.js)。

用户第一次访问时下载这些 chunk,之后只要依赖 / 公共组件不更新,哈希值不变,浏览器会直接用缓存的文件,不用重新下载。 后续你修改业务代码时,只有 “业务 chunk” 的哈希变化,用户只需下载这个小体积的更新文件,而非整个大 bundle——更新迭代时的用户下载成本大幅降低

build.rollupOptions.output.manualChunks
build: {
    rollupOptions: {
        output: {
            // 第一种接收一个对象
            // manualChunks: {
            // // rollup会将node_modules下的vue、axios...等数组中有的库打包到vendor开头的文件中
            //     vendor: ['vue', 'axios', ...],
            // }
            // 第二种接收一个函数
            manualChunks: id => {
                // 将node_modules下的文件全部打包到vendor开头的文件中
                if (id.includes('node_modules')) {
                    return 'vendor'
                }
            }
        }
    }
}
示例:

例如,没有配置前运行pnpm build命令,查看控制台输出

可以看到所有代码(包括第三方库、自己写的代码)都被打包为了index-Bhk5f-Te.js,在main.js中添加一行代码,如console.log(111),重新运行pnpm build

可以看到我们仅仅修改了一小段代码,而在生产打包时,却是将所有代码都打包了,重新生成了新的hash值,这样每次修改代码,都要全量打包是非常消耗性能的,一是没法利用浏览器缓存机制来减少请求体积,二是每次更新的体积较大(因为不是只更新代码修改部分)。

下面我们来看看添加了配置项的情况:

manualChunks: id => {
    if ( id.includes('node_modules')) {
        return 'vendor'
    }
}

运行pnpm build命令,查看控制台:

可以看到原先的代码文件被打包成了两个,并且node_modules下的文件被打包为了vendor-开头的文件。然后我们再次修改main.js中的代码,重新打包得到如下结果: 这次打包我们可以观察到,Vite在打包时仅仅对index-开头的文件进行了重新打包,对于vendor-开头的文件而是利用缓存,不进行重新打包,并且我们可以看到,重新打包的那部分代码体积是远小于没有变动的那部分体积的。

gzip压缩

引言:

在 Vite 中,gzip 压缩是前端性能优化的关键手段之一——核心作用是减小静态资源(JS/CSS/ 图片等)的传输体积,从而提升首屏加载速度、降低服务器带宽成本,尤其对大体积 JS bundle 或弱网环境效果显著。

原理:

gzip 是一种无损数据压缩算法(压缩后能完全还原原始内容),其核心逻辑是:

  1. 识别文件中重复出现的字符串 / 数据,用「索引 + 标记」替代重复内容(比如 JS 中大量重复的变量名、函数体、注释);
  2. 压缩后的文件后缀通常为 .gz(如 main.js.gzindex.css.gz);
  3. 浏览器请求资源时,会在 HTTP 请求头中携带 Accept-Encoding: gzip, deflate(表示支持 gzip 压缩);
  4. 服务器收到请求后,若存在对应的 .gz 压缩文件,会返回压缩文件,并在响应头中添加 Content-Encoding: gzip
  5. 浏览器收到压缩文件后,自动解压并执行 / 渲染。
vite-plugin-compression2

Viteb本身没有内置gzip压缩的功能,所以需要借助vite-plugin-compression2这个插件来实现

pnpm add vite-plugin-compression2 -D

基本使用方式:

import { defineConfig } from 'vite'
import { compression } from 'vite-plugin-compression2'

export default defineConfig({
  plugins: [
    // ...其他插件
    compression()
  ]
})

常见配置项

// vite.config.js
import { defineConfig } from 'vite';
import compression from 'vite-plugin-compression2';

export default defineConfig({
  plugins: [
    // 1. gzip 压缩(兼容老浏览器,优先级低)
    compression({
      algorithm: 'gzip', // 压缩算法
      ext: '.gz', // 压缩文件后缀
      threshold: 1024, // 1KB 以上才压缩(小文件没必要)
      deleteOriginFile: false, // 不删除原始文件(兼容不支持压缩的浏览器)
      include: /\.(js|css|html|svg|json|txt)$/, // 只压缩文本类资源
      exclude: /\.(png|jpg|jpeg|webp|mp4|woff2)$/, // 排除已压缩的资源(图片/视频/字体)
      compressionOptions: { level: 6 }, // 压缩等级(0-9,6 是平衡值)
      filename: '[path][base].gz', // 压缩文件命名格式(默认即可)
    }),
    // 2. brotli 压缩(现代浏览器优先,压缩率更高)
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      threshold: 1024,
      deleteOriginFile: false,
      include: /\.(js|css|html|svg|json|txt)$/,
      exclude: /\.(png|jpg|jpeg|webp|mp4|woff2)$/,
      compressionOptions: { level: 11 }, // brotli 最高等级 11,压缩率最优
    }),
  ],
});

使用注意

  1. 服务器 / CDN 需配置:支持根据 Accept-Encoding 请求头,返回对应的 .gz/.br 压缩文件(否则压缩产物白生成),并且如果CDN支持自动压缩,可以关闭压缩插件;
  2. 避免对无效压缩(如对png、jpg、woff等非文本类文件进行压缩,配置exclude避开),否则会浪费构建时间和存储空间。
  3. 小体积不要使用gzip压缩,因为浏览器解压也会消耗时间,所以对于小体积文件要避免进行压缩。
  4. 浏览器兼容:gzip 适配所有浏览器(含老版),brotli 覆盖 95%+ 现代浏览器,zstd 仅适合新版浏览器;
  5. 构建正常:vite build 能生成完整 dist 目录(无语法 / 依赖错误)。
示例:

正常运行构建命令pnpm build,我们其实可以看到控制台中的输出信息中给出了压缩前后的对比

红色部分是压缩前的体积,蓝色部分呢是采用gzip压缩后的体积。安装插件并使用:

可以看到压缩后的结果。

CDN优化

CDN 全称 Content Delivery Network,即内容分发网络,是一种经策略性部署的分布式网络架构,核心是通过在全球或区域内广泛部署边缘服务器节点,将源站的内容缓存到离用户最近的节点,让用户就近获取资源,以此解决网络带宽小、用户分布不均等导致的访问慢问题。

引入

在项目开发中,我们常会引入 Vue、axios、Lodash 等第三方库。默认打包流程中,这些库会被合并到生产环境的业务代码 bundle 中,最终由用户从业务服务器加载完整资源包。

假设有这样的场景:我们的业务服务器 B 部署在中国成都,而用户 A 位于美国纽约。当用户 A 访问网站时,所有资源(包括第三方库代码)都需要从成都的服务器 B 跨洋传输。由于地理距离遥远,网络链路长、延迟高,资源加载耗时久,可能出现页面白屏、交互卡顿等问题,严重影响用户体验。

这正是 CDN(内容分发网络)的核心优化场景 —— 我们无需自行在纽约部署服务器,而是可以借助成熟的公共 CDN 服务:将 Vue、axios 等通用性强的第三方库,托管到全球分布式的 CDN 节点(包括纽约附近的边缘节点)。当用户 A 访问网站时,会通过 CDN 的智能路由机制,自动从距离最近的纽约边缘节点加载这些第三方库资源,无需跨洋请求成都的服务器 B。

这样一来,第三方库的加载距离大幅缩短,网络延迟和传输耗时显著降低,配合业务代码的合理优化(如代码分割、本地缓存),用户 A 能更快地完成页面资源加载与渲染,网站访问速度和体验得到明显提升。

vite-plugin-cdn2
 pnpm add vite-plugin-cdn2 -D

配置示例

// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import cdn2 from 'vite-plugin-cdn2';

export default defineConfig({
  plugins: [
    vue(),
    cdn2({
      // 新增全局配置(统一控制启用状态、CDN 源等)
      options: {
        enabled: process.env.NODE_ENV === 'production',
        defaultCDN: 'jsdelivr',
        autoComplete: true, // 自动补全 path 和 var,无需手动写
      },
      modules: [
        { name: 'vue' }, // 仅需写 name,其余自动识别
        { name: 'axios' },
      ],
    }),
  ]
})

跨域

产生跨域的原因是由于浏览器的同源策略,不符合同源策略条件就会产生跨域。

同源策略

协议、域名(含子域名)、端口三者必须完全一致,只要有一个不同,就是跨域:

对比项 示例(当前页面:https://a.com:443 是否跨域?
协议不同 http://a.com(http vs https)
域名不同 https://b.com(a vs b)
子域名不同 https://api.a.com(主域 vs 子域)
端口不同 https://a.com:8080(443 vs 8080)
仅路径不同 https://a.com/user(路径不同) 否(同源)
仅查询参数不同 https://a.com?name=test(参数不同) 否(同源)

⚠️ 注意:「跨域」仅存在于浏览器端(浏览器的安全限制),服务端之间的请求(如 Node.js 调用接口、Nginx 反向代理)完全没有跨域问题 —— 这也是 代理方案 能解决跨域的核心原理。

例如

直接请求百度

axios.get('http://www.baidu.com').then(res => {
    console.log(res)
})

浏览器报错

开发环境:Proxy 代理

通过「中间服务器转发请求」,绕开浏览器同源策略。

1. 核心配置(vite.config.js)

假设前端项目地址:http://localhost:5173(Vite 默认端口),后端接口地址:https://api.b.com(跨域),配置如下:

export default defineConfig({
  server: {
    proxy: {
      // 1. 基础代理:匹配所有以 /api 开头的请求
      '/api': {
        target: 'https://www.baidu.com', // 后端接口根地址
        changeOrigin: true, // 关键:让后端认为请求来自代理服务器(而非浏览器),避免后端校验 Origin 失败
        rewrite: (path) => path.replace(/^\/api/, ''), // 可选:重写路径(如前端 /api/user → 后端 /user)
        // 可选:处理 HTTPS 接口(若后端是 HTTPS,需配置)
        secure: false, // 忽略 SSL 证书校验(开发环境可用,生产环境需开启 true)
        headers: {
          // 可选:添加自定义请求头(如授权、版本号)
          'X-Proxy': 'Vite-Proxy',
        },
      },
    },
  },
});
2. 前端请求示例
axios.get('/api').then(res => {
    console.log(res)
})

请求成功并且控制台没有报错信息:

3. 注意
  • 「后端报 Origin 不允许」:必开 changeOrigin: true(让代理服务器转发时,将 Origin 改为 target 地址,而非前端 localhost:5173);
  • 「HTTPS 接口请求失败」:开发环境配置 secure: false(忽略证书校验),生产环境需确保后端 SSL 证书有效,再改为 true
  • 「路径匹配错误」:若后端接口没有统一前缀(如直接是 https://api.b.com/user),前端无需加 /api,直接匹配 '/'(但不推荐,可能冲突);
  • 「代理不生效」:检查 Vite 是否重启(修改 proxy 后需重启 Vite),或请求路径是否匹配代理规则(如 /api/user 才会触发 /api 代理)。

生产环境:2 种核心方案(Nginx 代理 vs CORS 跨域头)

生产环境没有 Vite 代理,需通过服务端代理CORS 跨域头解决,两者各有适用场景:

方案 1:Nginx 反向代理(推荐,适合前后端分离部署)

原理和 Vite Proxy 一致:通过 Nginx 转发前端请求到后端,绕开浏览器跨域限制 ——优势是前端无需任何修改,跨域逻辑完全由服务端管控

  • 前后端分离部署(前端 Nginx 托管,后端独立部署);
  • 后端不允许直接对外暴露(需通过 Nginx 做权限控制、限流);
  • 需隐藏后端真实地址(提高安全性)。
方案 2:后端配置 CORS 跨域头(简单直接,适合后端可修改)

你的理解很准:通过后端返回 Access-Control-Allow-* 系列头,告诉浏览器「允许该源的跨域请求」——优势是无需配置 Nginx,后端单独搞定

核心 CORS 响应头(后端需返回)

响应头 作用说明 示例值
Access-Control-Allow-Origin 允许跨域的源(必填):* 表示所有源(不推荐,有安全风险),指定域名更安全 https://a.com(仅允许 a.com
Access-Control-Allow-Methods 允许的请求方法(必填) GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Headers 允许的自定义请求头(如 Token、Content-Type) Authorization,Content-Type
Access-Control-Allow-Credentials 是否允许携带 Cookie(可选):true 表示允许(此时 Origin 不能为 *) true
Access-Control-Max-Age 预检请求(OPTIONS)的缓存时间(可选):减少 OPTIONS 请求次数 86400(24 小时)

前端请求示例(需携带 Cookie 时)

axios.get('https://api.b.com/user', {
  withCredentials: true, // 关键:允许携带 Cookie(需和后端 Access-Control-Allow-Credentials: true 配合)
});

适用场景:

  • 后端可直接对外暴露(如公开 API);
  • 前后端部署在不同域名,且不想配置 Nginx;
  • 需携带 Cookie 进行身份验证(如单点登录)。

安全注意:

  • 禁止用 Access-Control-Allow-Origin: *(允许所有源跨域,有 CSRF 风险),必须指定具体域名;
  • 若允许携带 Cookie,Access-Control-Allow-Origin 不能为 *,且需配置 Access-Control-Allow-Credentials: true
  • 限制 Access-Control-Allow-MethodsAccess-Control-Allow-Headers 为必需值,避免过度开放。

其他跨域方案(特殊场景补充)

除了上面的核心方案,还有 2 种特殊场景的跨域方法,作为补充:

1. JSONP(仅支持 GET 请求,几乎淘汰)

原理:利用 <script> 标签不受同源策略限制的特性,通过动态创建 <script> 标签请求接口,后端返回回调函数包裹的 JSON 数据。

  • 缺点:仅支持 GET 方法、安全性差(可能注入恶意代码)、无法携带 Cookie;
  • 适用场景:极老的后端系统(不支持 CORS 和代理),现在几乎不用。
2. document.domain + iframe(仅适合同主域不同子域)

原理:若前端是 a.com,iframe 是 api.a.com(同主域不同子域),可通过 document.domain = 'a.com' 让两者同源,实现跨域通信。

  • 缺点:仅适合同主域、需操作 iframe,灵活性差;
  • 适用场景:老项目的 iframe 通信,现在很少用。

常见误区(必看,避免踩坑)

  1. 前端配置 CORS 头就能解决跨域:错!CORS 是后端返回的响应头,前端配置无效(浏览器只认后端返回的头);
  2. 生产环境用 Vite Proxy:错!Vite Proxy 仅用于开发环境,生产环境需用 Nginx 代理或 CORS;
  3. Access-Control-Allow-Origin: * 万能:错!有安全风险,且不支持携带 Cookie;
  4. 跨域请求不会发送:错!跨域请求会发送,浏览器会在「收到响应后」检查 CORS 头,若不允许则拦截响应(而非阻止请求发送);
  5. OPTIONS 请求是多余的:错!非简单请求(如 POST 带 JSON 数据、自定义头)会先发送 OPTIONS 预检请求,确认后端允许后再发送真实请求。

总结

跨域的核心是「浏览器同源策略限制」,解决方案的本质是「绕开或告知浏览器允许跨域」:

  • 开发环境:优先用 Vite Proxy(配置简单,无需后端配合);
  • 生产环境:优先用 Nginx 代理(前端无感知,安全性高)或 CORS 跨域头(后端单独搞定);
  • 避坑关键:分清环境、不滥用 *、理解 CORS 头的作用方(后端)。
❌