阅读视图

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

Vite 实战教程:alias/env/proxy 配置 + 打包优化避坑|Vue 工程化必备

【Vite】前端工程化实操:从路径别名到打包优化,彻底搞懂Vite核心配置,避开高频踩坑!

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱「面向搜索引擎写代码」的尴尬。

📑 文章目录

一、alias:让 import 更清晰

1.1 为什么需要 alias?

没有 alias 时,你会经常看到这样的写法:


import Button from '../../../components/Button.vue'
import { getUserInfo } from '../../../../api/user'

问题主要有两点:

  1. ../ 太多,路径难维护,容易写错

  2. 重构时移动文件,相对路径要全改一遍

用 alias 把常用目录映射成简短路径后,可以改成:


import Button from '@/components/Button.vue'
import { getUserInfo } from '@/api/user'

⬆ 返回目录

1.2 怎么配置?

vite.config.js(或 vite.config.ts)里配置:


// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      // 方式一:映射到 src 目录
      '@': fileURLToPath(new URL('./src', import.meta.url)),
      // 方式二:可以配多个
      '@components': fileURLToPath(new URL('./src/components', import.meta.url)),
      '@api': fileURLToPath(new URL('./src/api', import.meta.url)),
    },
  },
})

要点:

  • fileURLToPath + new URL():在 Node 的 ESM 环境下拿到正确的绝对路径

  • import.meta.url:当前配置文件所在目录

  • ./src:相对于配置文件所在目录的路径

⬆ 返回目录

1.3 常见踩坑

坑 1:忘记在 IDE 里配置路径提示

Vite 能正确解析,但 IDE 可能不认识 @,需要加 jsconfig.jsontsconfig.json


// jsconfig.json(用 JS 的项目)
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@api/*": ["src/api/*"]
    }
  },
  "include": ["src/**/*"]
}

// tsconfig.json(用 TS 的项目)
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"]
    }
  }
}

坑 2:alias 和 Vite 配置不一致

jsconfig/tsconfigpaths 要跟 vite.configalias 保持一致,否则可能出现:开发时没问题,打包后路径错误或 IDE 报错。

⬆ 返回目录

二、env:环境变量怎么用

2.1 为什么需要 env?

不同环境需要不同配置,例如:

  • 开发环境:本地 API 地址、调试开关

  • 生产环境:线上 API 地址、关闭调试

如果写死在代码里,每次发版都要手动改,容易出错。用 env 可以按环境自动切换。

⬆ 返回目录

2.2 基本规则

Vite 的环境变量规则:

  • 文件名必须是 .env.env.local.env.[mode].env.[mode].local 这类

  • 只有以 VITE_ 开头的变量会暴露给客户端

  • mode 默认是 development(dev)和 production(build)

⬆ 返回目录

2.3 典型文件结构


项目根目录/
├── .env                 # 所有环境都加载
├── .env.local           # 本地覆盖,一般加在 .gitignore
├── .env.development     # 开发环境
├── .env.production      # 生产环境
└── .env.staging         # 可选:预发环境

⬆ 返回目录

2.4 示例配置

.env (公共变量)


# API 基础路径(会被 .env.development / .env.production 覆盖)
VITE_APP_TITLE=我的项目

.env.development (开发)


VITE_API_BASE_URL=http://localhost:3000/api
VITE_USE_MOCK=true

.env.production (生产)


VITE_API_BASE_URL=https://api.yoursite.com
VITE_USE_MOCK=false

.env.local (本地覆盖,不提交)


# 比如你本机端口不同
VITE_API_BASE_URL=http://localhost:8080/api

⬆ 返回目录

2.5 在代码里怎么用


// 直接通过 import.meta.env 访问
console.log(import.meta.env.VITE_API_BASE_URL)
console.log(import.meta.env.VITE_USE_MOCK)
console.log(import.meta.env.MODE)  // 'development' | 'production'

如果要集中管理,可以再包一层:


// src/config/env.js
export const config = {
  apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
  useMock: import.meta.env.VITE_USE_MOCK === 'true',
  isDev: import.meta.env.DEV,
  isProd: import.meta.env.PROD,
}

⬆ 返回目录

2.6 常见踩坑

坑 1:没用 VITE_ 前缀


API_URL=xxx   # ❌ 客户端拿不到
VITE_API_URL=xxx  # ✅ 正确

坑 2:把 env 当布尔用


// env 读出来都是字符串
if (import.meta.env.VITE_USE_MOCK) { }  // 'true' 和 'false' 都是 truthy!
// 正确写法
if (import.meta.env.VITE_USE_MOCK === 'true') { }

坑 3:.env.local 被提交

.env.local 里常放本地密钥、端口等,要加到 .gitignore,不要提交。

⬆ 返回目录

三、proxy:解决开发环境跨域

3.1 为什么需要 proxy?

前端开发时往往是 localhost:5173,接口在 api.yoursite.com,浏览器会因同源策略限制产生跨域。

后端配 CORS 是一种方式,但有时后端不方便改,或者你想在本地连不同环境的接口,这时用 Vite 的 proxy 最方便:浏览器只请求同源的 dev 服务器,由 dev 服务器转发到真实接口。

⬆ 返回目录

3.2 基本配置


// vite.config.js
export default defineConfig({
  server: {
    port: 5173,
    proxy: {
      // 简单写法:/api 开头的请求转发到目标服务器
      '/api': {
        target: 'https://api.yoursite.com',
        changeOrigin: true,
      },
    },
  },
})

这样访问 http://localhost:5173/api/user/info 时,会被转发到 https://api.yoursite.com/api/user/info

⬆ 返回目录

3.3 更完整的配置示例


// vite.config.js
export default defineConfig({
  server: {
    port: 5173,
    open: true,
    proxy: {
      '/api': {
        target: 'https://api.yoursite.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''), // 转发时去掉 /api 前缀
        secure: false,
        configure: (proxy, options) => {
          proxy.on('proxyReq', (proxyReq, req, res) => {
            // 可选:加 token 等请求头
            // proxyReq.setHeader('Authorization', 'Bearer xxx')
          })
          proxy.on('proxyRes', (proxyRes, req, res) => {
            // 可选:处理响应
          })
        },
      },
      // 多个接口可以配多个代理
      '/upload': {
        target: 'https://upload.yoursite.com',
        changeOrigin: true,
      },
    },
  },
})

常用选项说明:

选项 作用
target 真实后端地址
changeOrigin 改请求头 Host,避免目标服务器校验失败
rewrite 重写请求路径,例如去掉 /api 前缀
secure 目标为 https 且证书有问题时,可设 false
⬆ 返回目录

3.4 和 env 配合

开发环境用 proxy,生产用完整 URL,可以这样配合 env:

.env.development


VITE_API_BASE_URL=/api

.env.production


VITE_API_BASE_URL=https://api.yoursite.com

src/api/request.js


const baseURL = import.meta.env.VITE_API_BASE_URL

export function request(url, options = {}) {
  return fetch(`${baseURL}${url}`, options)
}

开发时请求 /api/xxx,会被 proxy 转发;生产时直接请求完整域名。

⬆ 返回目录

3.5 常见踩坑

坑 1:忘记 changeOrigin

目标为域名时,建议设 changeOrigin: true,否则可能被后端拒绝。

坑 2:rewrite 把路径改错了

要清楚 rewrite 前后路径的对应关系,比如:


// 前端请求:/api/user/info
// 未 rewrite:https://api.xxx.com/api/user/info
// rewrite 去掉 /api:https://api.xxx.com/user/info
rewrite: (path) => path.replace(/^\/api/, ''),

要看后端实际路径再决定是否 rewrite。

坑 3:proxy 只在开发环境生效

server.proxy 只在 vite 开发服务器下生效,生产构建不会用到,生产环境依赖你配置的 VITE_API_BASE_URL 等。

⬆ 返回目录

四、打包优化

4.1 为什么需要打包优化?

不做优化时常见问题:

  • 单个 JS 过大,首屏加载慢

  • 第三方库和业务代码混在一起,缓存利用差

  • 未压缩的包体积大

Vite 默认已经做了不少优化,我们再针对常见场景补充一些配置。

⬆ 返回目录

4.2 代码分割(手动分包)


// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // Vue 全家桶单独打包
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
          // 体积较大的 UI 库单独打包
          'element-plus': ['element-plus'],
        },
      },
    },
  },
})

这样可以把 Vue、路由、状态管理和 UI 库拆成独立 chunk,利于缓存。

⬆ 返回目录

4.3 分包策略示例(按路由/模块)


// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // node_modules 里的包
            if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
              return 'vue-vendor'
            }
            if (id.includes('element-plus')) {
              return 'element-plus'
            }
            return 'vendor'
          }
        },
        chunkFileNames: 'js/[name]-[hash].js',
        entryFileNames: 'js/[name]-[hash].js',
        assetFileNames: '[ext]/[name]-[hash].[ext]',
      },
    },
    chunkSizeWarningLimit: 1000, // 单 chunk 超过 1000kb 时警告
  },
})

⬆ 返回目录

4.4 CDN 外链(可选)

把 Vue、Element Plus 等用 CDN 引入,减小打包体积:


// vite.config.js
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    rollupOptions: {
      external: ['vue', 'vue-router', 'pinia', 'element-plus'],
      output: {
        globals: {
          vue: 'Vue',
          'vue-router': 'VueRouter',
          pinia: 'Pinia',
          'element-plus': 'ElementPlus',
        },
      },
    },
  },
})

index.html 中用 <script> 引入对应 CDN,并确保全局变量名和 globals 一致。

注意:一般 SPA 不推荐全部 external,可以只 external 少数大库,其余照常打包。

⬆ 返回目录

4.5 压缩与产物清理


// vite.config.js
export default defineConfig({
  build: {
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,  // 生产环境去掉 console
        drop_debugger: true,
      },
    },
    cssCodeSplit: true,
    sourcemap: false,
  },
})

⬆ 返回目录

4.6 常见踩坑

坑 1:manualChunks 拆得太碎

拆出太多小 chunk 会多很多请求,反而影响性能,一般把体积大的依赖拆几块即可。

坑 2:忘记配 chunkSizeWarningLimit

默认 500kb 会报警,可按项目实际情况调大,例如 1000 或 1500。

坑 3:生产 sourcemap

生产环境建议关掉 sourcemap,否则包体积会明显增大。

⬆ 返回目录

五、完整配置示例

下面是一份整合了 alias、env、proxy 和打包优化的 vite.config.js 示例:


// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [vue()],

  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },

  server: {
    port: 5173,
    open: true,
    proxy: {
      '/api': {
        target: 'https://api.yoursite.com',
        changeOrigin: true,
      },
    },
  },

  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
        },
        chunkFileNames: 'js/[name]-[hash].js',
        assetFileNames: '[ext]/[name]-[hash].[ext]',
      },
    },
    chunkSizeWarningLimit: 1000,
    sourcemap: false,
  },
})

⬆ 返回目录

六、小结

配置项 作用 重点
alias 简化 import 路径 和 jsconfig/tsconfig 保持一致
env 按环境切换配置 必须 VITE_ 前缀,注意值是字符串
proxy 开发环境解决跨域 changeOrigin,和 env 配合使用
打包优化 减小体积、提升加载 合理分包,控制 chunk 数量和大小

建议在实际项目里按需启用和调整这些配置,有问题可以在评论区补充你的项目结构和错误信息,便于一起排查。

⬆ 返回目录


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战的方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

早点下班(Vue2.7版):旧项目也能少写 40%+ 异步代码

前段我在文章 早点下班:在 Vue3 中少写 40%+ 的异步代码 中分享了自己开发的 vue-asyncx,当时就有同学问:那 Vue 2 的老项目呢?就在今天,它来了!

vue-asyncx 发布 v1.11.0 版本,该版本向前兼容 Vue 2.7,同样帮你秒减 40%+ 异步代码。写法用法与在 Vue 3 中一模一样,毫不妥协

如果你还在维护 Vue 2.7 的老项目,每次写异步请求都要手动管理 loadingerrordata,还要处理竞态问题。那这篇文章,可能就是你的「下班加速器」🚀

少说废话,先看代码!

🎯 先来看个最常见的详情页查询

<!-- 老项目里的经典异步三板斧 + watch 联动 -->
<template>
  <div>
    <div v-if="queryDetailLoading">加载中...</div>
    <div v-else-if="queryDetailError">加载失败:{{ queryDetailError.message }}</div>
    <div v-else>
      <h2>{{ detail.name }}</h2>
      <p>{{ detail.desc }}</p>
    </div>
  </div>
</template>

<script>
export default {
  props: { id: String },
  data() {
    return {
      detail: null,
      queryDetailLoading: false,
      queryDetailError: null
    }
  },
  created() {
    this.queryDetail()
  },
  watch: {
    id: function () {  
      this.queryDetail()
    },
  },
  methods: {
    async queryDetail() {
      this.queryDetailLoading = true
      this.queryDetailError = null
      try {
        // 💥 有坑:快速切换 id,旧请求返回覆盖新数据(竞态)
        this.detail = await queryDetailApi(this.id)
      } catch (e) {
        this.queryDetailError = e
      } finally {
        this.queryDetailLoading = false
      }
    }
  }
}
</script>

👆 这段代码,你是不是写过 10 遍、20 遍、100 遍?

别急,现在,这些样板代码可以一键消失了

🔥 代码对比:少写 40%+ 是什么体验?

传统写法(~20 行)

export default {
  props: { id: String },
  data() {
    return {
      detail: null,
      queryDetailLoading: false,
      queryDetailError: null
    }
  },
  created() {
    this.queryDetail()
  },
  watch: {
    id: function () {  
      this.queryDetail()
    },
  },
  methods: {
    async queryDetail() {
      this.queryDetailLoading = true
      this.queryDetailError = null
      try {
        // 💥 有坑:快速切换 id,旧请求返回覆盖新数据(竞态)
        this.detail = await queryDetailApi(this.id)
      } catch (e) {
        this.queryDetailError = e
      } finally {
        this.queryDetailLoading = false
      }
    }
  }
}

vue-asyncx 写法(~8 行)✨

import { useAsyncData } from 'vue-asyncx'

export default {
  props: { id: String },
  setup(props) {
    const { 
      detail, 
      queryDetail, 
      queryDetailLoading, 
      queryDetailError 
    } = useAsyncData('detail', () => queryDetailApi(props.id), { 
      immediate: true,  // setup 执行时自动请求
      watch: () => props.id  // id 变化自动重新请求 + 竞态防护
    })
    return { detail, queryDetail, queryDetailLoading, queryDetailError }
  }
}

🎯 核心能力一览

  • ✅ 代码一屏变半屏,逻辑聚合在一起
  • loading / error / data 自动绑定,响应式更新
  • watch 依赖自动追踪props.id 变化自动重新请求
  • 竞态请求自动处理:快速切换 id 时,旧数据自动废弃,不串数据
  • ✅ 支持手动触发 queryDetail()、防抖节流
  • ✅ TypeScript 友好,智能类型推导

📦 两个核心 API,覆盖 90% 异步场景

1️⃣ useAsyncData:自动执行 + 数据绑定,查询类场景神器

import { useAsyncData } from 'vue-asyncx'

export default {
  props: { id: String },
  setup(props) {
    const { 
      detail,              // 异步数据(响应式)
      queryDetail,         // 手动查询函数(可选)
      queryDetailLoading,  // 加载状态
      queryDetailError,    // 错误状态
    } = useAsyncData('detail', () => queryDetailApi(props.id), {
      immediate: true,     // setup 执行时自动调用
      watch: () => props.id  // 监听 id 变化,自动重新请求
    })

    return { detail, queryDetail, queryDetailLoading, queryDetailError }
  }
}

✅ 适用场景:详情页加载、列表查询、参数变化自动刷新的数据获取

使用异步数据,就是 useAsyncData

2️⃣ useAsync:包装异步函数,自动管理状态

import { useAsync } from 'vue-asyncx'

export default {
  setup() {
    const { 
      submit,           // 包装后的异步函数(可直接绑定 @click)
      submitLoading,   // 加载状态(响应式)
      submitError,     // 错误状态(响应式)
    } = useAsync('submit', submitApi)

    return { submit, submitLoading, submitError }
  }
}

✅ 适用场景:表单提交、按钮操作、手动触发的异步任务

使用异步(函数),就是 useAsync


useAsyncDatauseAsync 的首个参数实际上是变量名。

  • 对于 useAsyncData,传入 'detail' 返回 detail、queryDetail、queryDetailLoading 等
  • 对于 useAsync 传入 'submit' 返回 submit、submitLoading 等

这种命名约定方式在代码可读性、团队协作性上有巨大优势,且在 TS 加持下不损失开发效率

进一步阅读:如何通过工程手段统一团队变量命名

🛠️ Vue 2.7 接入示例:详情页完整实战

常规详情信息获取 + 审批确认详情页

<!-- DetailPage.vue -->
<template>
  <div>
    <!-- 加载状态 -->
    <div v-if="queryDetailLoading" class="loading">
      <Spinner /> 加载中...
    </div>
    
    <!-- 错误状态 -->
    <div v-else-if="queryDetailError" class="error">
      <ErrorMsg :error="queryDetailError" />
      <button @click="queryDetail()">重试</button>
    </div>
    
    <!-- 数据展示 -->
    <div v-else class="detail">
      <h2>{{ detail.name }}</h2>
      <p>{{ detail.desc }}</p>
    </div>
    
    <!-- 确认操作 -->
    <button @click="confirm" v-loading="confirmLoading">确认</button>
  </div>
</template>

<script>
import { useAsyncData, useAsync } from 'vue-asyncx'
import { queryDetailApi, confirmApi } from '@/api/detail'

export default {
  props: { id: String },
  setup(props) {
    // 🎯 详情获取:一个调用搞定详情页查询 + 自动 watch + 竞态防护
    const { 
      detail, 
      queryDetail, 
      queryDetailLoading, 
      queryDetailError 
    } = useAsyncData('detail', () => queryDetailApi(props.id), {
      immediate: true,
      watch: () => props.id  // id 变化自动重新请求,旧请求自动取消
    })

    // 🎯 确认操作:手动触发的异步任务
    const { confirm, confirmLoading } = useAsync('confirm', 
      () => confirmApi(props.id).then(() => queryDetail())
    )

    return {
      detail,
      queryDetail,
      queryDetailLoading,
      queryDetailError,
      confirm,
      confirmLoading
    }
  }
}
</script>

用 Vue Options 实现相同功能:

  • 代码量轻松翻一倍
  • 代码交织、阅读需要上下跳转,无法做到:详情获取逻辑在一起,确认操作逻辑在一起

🚀 为什么支持 Vue 2.7?

很多团队不是不想升级 Vue 3,而是:

  • 老项目重构成本高,业务迭代紧
  • 老项目只是维护,偶尔有新需求,没有重构资源
  • Vue 2.7 也能用 Composition API,够用了

vue-asyncx 想你所想,让旧项目也能享受异步管理的优雅。

  • 用 options 也没有关系,加个 setup 属性,马上就能用,渐进式改造。
  • 后期老项目升级 Vue 版本,API 一模一样,一行代码都不用改

为什么要迁移 setup,怎么迁移 setup,安利下我的文章:

🧪 质量保障:敢在生产环境用的底气

很多人问:「新库稳不稳定?老项目敢不敢接?」

直接上数据👇

指标 数值 说明
📦 单元测试 300+ 覆盖所有 API 边界场景
🌐 E2E 测试 10+ 模拟真实用户使用流程
🔄 双版本测试 Vue 2.7 / 3 相同测试用例、流程,确保行为一致
🤖 CI/CD GitHub Actions 每次提交自动跑全量测试 Test Status codecov
📊 代码覆盖率 100% 分支/语句/函数全覆盖

🎯 基于这套测试体系,vue-asyncx

  • 在多个产线项目大规模使用,日调用 2w+,稳如老狗 🐶
  • 在单个大型项目中,总共 900+ .vue 文件,有 300+ .vue 文件使用超 500+ 次
  • 发布 2 年,20+ 版本更新,接口零破坏性变更(做兼容,我是认真的)

🔜 后续预告:兼容背后的「技术狠活」

支持双版本不是 if (vue2) {...} else {...} 那么简单。
后续我会深度拆解 双版本兼容的测试架构与实现方案——包括兼容策略选型、同一套用例双环境跑通、无头浏览器 E2E 验证、低版本 TS 适配等。

💡 如果你对这类“技术狠活”感兴趣,记得点个关注,更新不迷路~

🙋 现在就能试试!

npm install vue-asyncx

📚 文档 & 示例:
👉 GitHub: vue-asyncx
👉 NPM: vue-asyncx

版本要求:vue-asyncx 目前最低兼容到 Vue 2.7

💬 互动时间

1️⃣ 你的项目还在用 Vue 2.7 吗?详情页查询是不是也写过类似的 watch + 竞态防护?
2️⃣ 对双版本兼容方案有什么好奇的?评论区聊聊👇

✅ 觉得有用,别忘了:
⭐ 点个 Star 支持开源
👍 点赞 + 收藏,下次找得到
🔄 转发给还在手写 loading + watch 的同事,一起早点下班!

拍照记单词项目学习笔记

本次学习围绕“拍照记单词”项目展开,该项目是一款结合AI多模态技术、前端开发、后端部署的轻量化英语学习工具,核心功能是通过拍照或上传图片,调用大模型接口解析图片内容,提取核心英文单词、生成例句及场景化解释,同时支持音频播放,帮助用户在真实场景中高效记忆单词。通过对该项目的全面学习,我系统掌握了Vue3+TS前端开发、NestJS后端技术、多模态大模型接口调用、产品原型设计等相关知识,也深刻理解了AI时代下产品开发的思路与逻辑。本笔记将从项目背景、产品分析、技术架构、代码解析、问题总结与拓展思考六个维度,详细记录学习过程中的知识点、难点及收获,确保内容全面、逻辑清晰,为后续同类项目开发提供参考。

一、项目背景与AI时代发展趋势

1.1 AI时代的技术变革与产品机遇

随着人工智能技术的快速迭代,尤其是大模型、多模态技术的突破,互联网行业迎来了“所有产品值得用AI重新做一遍”的全新机遇。在AI时代,技术不再是单纯的工具,而是深度融入产品设计、用户体验的核心要素,能够极大地提升产品的效率、智能化水平和用户粘性。

项目中提到的“vibe coding”理念,正是AI时代开发模式的体现——代码和项目开发变得快速且靠谱,AI能够辅助开发者完成需求分析、代码编写、bug调试等一系列工作,降低开发门槛,提升开发效率。同时,“one person company(一人公司)”的概念也逐渐成为可能,借助AI工具,个人能够完成创意构思、产品规划、商业分析、用户共情等多个环节的工作,尤其是AI产品经理的角色,需要具备将AI技术与用户需求结合的能力,打造出贴合用户痛点的智能化产品。

1.2 单词学习类产品的市场现状与痛点

在英语学习领域,单词记忆是核心痛点之一,传统的单词记忆方式枯燥、低效,难以结合真实场景,导致用户记忆不牢固、遗忘速度快。当前市场上已有多款单词类APP,通过不同的模式解决用户的单词记忆需求,其中最具代表性的是百词斩和扇贝,通过对这两款产品的分析,能够为“拍照记单词”项目提供借鉴。

百词斩的核心优势的是“细分领域背单词”,将单词与形象的图片结合,通过视觉联想帮助用户记忆,例如“awkward(尴尬的)”“giraffe(长颈鹿)”等单词,搭配对应的场景图片,能够让用户快速建立单词与含义的关联,降低记忆难度。这种“图文结合”的模式,也为“拍照记单词”项目提供了核心灵感——借助图片解析,让单词记忆更贴近真实场景。

扇贝的核心优势是“智能间隔重复算法”,该算法能够精准规划用户的复习时间,确保单词在即将遗忘时被强化记忆,从而实现长期记忆。这种“科学复习”的理念,也是“拍照记单词”项目需要借鉴的点,后续可以通过优化产品功能,加入复习规划模块,提升用户的单词记忆效果。

尽管现有单词类APP各有优势,但仍存在明显的痛点:一是场景化不足,多数APP的单词记忆脱离真实生活场景,用户在实际使用中难以灵活运用;二是交互不够便捷,需要用户手动输入单词或选择单词本,操作繁琐;三是个性化不足,无法根据用户的英语水平(如A1~A2级别)定制单词难度和例句。而“拍照记单词”项目,正是针对这些痛点,结合AI多模态技术,打造出“拍照即记词、场景化学习、个性化适配”的轻量化工具,填补市场空白。

1.3 项目核心定位与价值

“拍照记单词”项目的核心定位是:一款面向有基础英语学习需求(A1~A2级别)、注重场景化记忆的轻量化单词学习工具,适用于跨国生活、旅游、点餐等真实场景,帮助用户快速识别图片中的核心单词,掌握单词的用法和场景应用。

项目的核心价值主要体现在三个方面:一是便捷性,用户无需手动输入单词,只需拍照或上传图片,即可快速获取单词、例句及解释,降低操作门槛;二是场景化,结合图片场景记忆单词,让用户在真实场景中理解单词的用法,提升单词运用能力;三是智能化,借助多模态大模型和TTS技术,实现单词解析、音频播放等功能,提升用户学习体验。同时,项目还注重无障碍访问,通过label for + input#id等技术,帮助使用读屏器的盲人用户使用,体现产品的包容性。

二、产品分析与原型设计

2.1 产品核心需求与场景分析

产品的核心需求来源于用户在真实场景中对单词识别和记忆的需求,具体可分为以下几类:

  1. 识别需求:用户在跨国旅游、点餐、购物时,遇到不认识的英文单词(如菜单上的菜品名称、商品标签、路标等),需要快速识别单词含义;

  2. 记忆需求:识别单词后,需要掌握单词的发音、例句及用法,实现快速记忆,便于后续运用;

  3. 便捷需求:操作流程简单,无需复杂步骤,拍照即可完成单词识别和学习,节省时间;

  4. 个性化需求:根据用户的英语水平(A1~A2级别),提供简单易懂的单词和例句,避免过于复杂的词汇和句式。

结合核心需求,产品的核心应用场景主要包括:

  1. 跨国生活场景:用户在国外生活时,遇到家具、日用品、食品等物品的英文标签,拍照即可识别单词,了解物品名称和用法;

  2. 旅游场景:在国外旅游时,识别路标、景点介绍、菜单等内容中的英文单词,解决语言沟通障碍;

  3. 日常学习场景:用户看到身边的物品,拍照识别对应的英文单词,结合场景记忆,提升单词积累效率。

产品的核心痛点是“足够痛、强需求”——用户在真实场景中遇到不认识的英文单词时,往往没有时间手动查询词典,需要快速、便捷的识别和学习方式,而“拍照记单词”项目正好解决了这一痛点,能够在用户需要时,快速提供单词解析和学习内容。

2.2 竞品分析与产品差异化

除了前文提到的百词斩、扇贝,“拍照记单词”项目的竞品还包括多邻国等APP。多邻国的核心优势是“游戏化学习”,通过趣味关卡、打卡等方式,提升用户的学习积极性,但其拍照记词功能较为简单,主要以单词识别为主,缺乏场景化解释和个性化适配。

与现有竞品相比,“拍照记单词”项目的差异化优势主要体现在以下几点:

  1. 场景化深度结合:以图片为核心,不仅识别单词,还会结合图片场景生成例句和解释,让用户在理解场景的基础上记忆单词,提升记忆效果;

  2. 个性化适配:针对A1~A2级别用户,提供简单易懂的单词和例句,避免过于复杂的内容,贴合用户的英语水平;

  3. 轻量化设计:核心功能聚焦于拍照记词,操作流程简单,无需注册登录(可后续优化),打开即可使用,适合碎片化学习;

  4. 无障碍访问:考虑到特殊用户群体的需求,加入无障碍设计,提升产品的包容性;

  5. 多模态技术应用:结合多模态大模型(kimi-shot、moonshot-v1-8k-vision-preview)和TTS技术,实现图片解析、音频播放等功能,提升产品的智能化水平。

2.3 产品原型设计思路

产品原型的核心是围绕“拍照/上传图片→解析图片→获取单词学习内容→音频播放”的核心流程,设计简洁、便捷的交互界面,确保用户能够快速完成操作。根据项目文档,产品原型的核心功能模块及交互流程如下:

  1. 核心功能模块:

(1)拍照/上传图片模块:用户可通过点击摄像头图标,调用手机摄像头拍照,或上传本地图片,实现图片输入;

(2)图片解析模块:调用kimi大模型接口,解析图片内容,提取最能描述图片的英文单词(A1~A2级别),生成图片描述、例句、场景化解释及回复;

(3)音频播放模块:点击播放按钮,调用TTS技术,播放单词或例句的发音,帮助用户掌握正确的发音;

(4)详情展开模块:用户可点击“Talk bout it”按钮,展开详情页面,查看图片预览、单词解释及回复内容,收起则隐藏详情,保持界面简洁。

  1. 交互流程设计:

用户打开产品→点击摄像头图标→拍照/上传图片→系统自动解析图片→显示单词、例句→点击播放按钮听发音→点击详情按钮查看完整解释→完成单词学习。

  1. 页面设计要点:

(1)主页面:简洁明了,核心突出拍照功能,避免多余的元素干扰用户操作;

(2)图片上传/拍照页面:适配手机屏幕,提供清晰的摄像头调用入口和图片上传入口;

(3)结果展示页面:单词居中显示,字体清晰,例句和发音按钮便于用户查看和操作,详情模块隐藏,避免界面杂乱;

(4)详情页面:图片预览清晰,解释内容分行显示,回复内容带有边框,便于区分,整体布局简洁、易读。

2.4 设计稿相关思考

项目文档中未提供具体的设计稿,但结合代码中的样式部分,能够看出设计稿的核心思路是“简洁、美观、贴合场景”。设计风格以暖色调为主,背景采用线性渐变(从rgb(235,189,166)到rgb(71,49,32)),营造出温馨、舒适的学习氛围;卡片式设计,搭配阴影效果,提升界面的层次感;按钮、图片等元素采用圆角设计,增强界面的柔和度;文字颜色对比清晰,确保用户能够轻松阅读。

设计过程中需要注意的细节:一是适配不同屏幕尺寸,确保在手机、平板等设备上都能正常显示;二是优化图片上传和预览的体验,确保图片显示清晰、加载快速;三是无障碍设计,确保读屏器能够正常识别界面元素,帮助特殊用户使用;四是交互反馈,如图片上传中显示加载状态、解析完成后显示提示等,提升用户体验。

三、技术架构与技术调研

3.1 技术架构整体设计

“拍照记单词”项目采用前后端分离的技术架构,前端负责用户交互、界面展示、图片上传、音频播放等功能,后端负责接口开发、大模型调用、数据处理等功能,整体架构清晰、易于维护和扩展。具体架构如下:

  1. 前端层:采用Vue3+TS+Composition API开发,结合组件化思想,将页面拆分为PictureCard组件和主页面,实现代码复用和维护;使用FileReader实现图片本地读取和base64编码,用于多模态接口调用;使用TTS技术实现文本转语音,提供音频播放功能;

  2. 接口层:前端通过调用kimi大模型接口(moonshot-v1-8k-vision-preview),实现图片解析和单词生成;后端(NestJS)负责接口转发、鉴权、数据处理等,确保接口调用的安全性和稳定性;

  3. 数据层:暂时未涉及数据库存储(可后续优化,加入用户单词本、复习记录等功能),当前主要通过前端响应式数据存储用户的图片数据、单词信息、音频地址等;

  4. 技术支撑层:包括多模态大模型(kimi-shot、moonshot-v1-8k-vision-preview)、TTS文本转语音技术、前端构建工具(Vite)等,为项目提供核心技术支持。

3.2 核心技术调研与选型

3.2.1 大模型选型与调研

项目的核心技术之一是多模态大模型的选型,多模态模型能够同时处理图片和文本信息,实现图片内容解析和单词生成。经过调研,项目选择了kimi的多模态模型,具体包括kimi-shot和moonshot-v1-8k-vision-preview,选型理由如下:

  1. 多模态能力强:能够精准解析图片内容,提取核心元素,生成贴合场景的单词和例句,符合项目的核心需求;

  2. 接口友好:提供清晰的API接口文档,支持图片base64编码传入,便于前端调用;

  3. 响应速度快:对于简单图片的解析,响应时间较短,能够提升用户体验;

  4. 支持自定义Prompt:能够通过Prompt设计,控制单词难度(A1~A2级别)、输出格式(JSON),满足项目的个性化需求。

多模态模型的调用流程:前端将图片转换为base64编码,结合自定义Prompt,通过POST请求调用kimi接口,接口返回JSON格式的响应数据,包含图片描述、代表单词、例句、解释等内容,前端解析数据后展示给用户。

3.2.2 TTS技术调研与选型

TTS(Text to Speech,文本转语音)技术用于实现单词和例句的音频播放,提升用户的学习体验。项目中采用的TTS技术,核心是将文本(单词、例句)转换为音频文件(如MP3),并通过前端播放。选型时主要考虑以下几点:

  1. 发音标准:确保英语发音准确、清晰,符合 native speaker 的发音习惯;

  2. 响应速度快:能够快速将文本转换为音频,避免用户等待;

  3. 接口便捷:支持前端直接调用,或通过后端接口转发,集成难度低;

  4. 兼容性好:支持不同浏览器、不同设备,确保音频能够正常播放。

项目中通过generateAudio函数调用TTS接口,将例句转换为音频URL,然后通过Audio对象实现播放,流程简洁、高效。

3.2.3 前端技术栈选型

前端采用Vue3+TS+Composition API的技术栈,选型理由如下:

  1. Vue3的优势:相比Vue2,Vue3具有更好的性能、更小的体积,支持Composition API,能够更灵活地组织代码,提升代码的复用性和维护性;

  2. TypeScript的优势:提供静态类型检查,能够在开发阶段发现代码中的错误,提升代码的健壮性和可维护性,尤其适合团队开发;

  3. Composition API的优势:相比Options API,Composition API能够将相关的逻辑代码聚合在一起,避免代码分散,便于逻辑的复用和维护,适合复杂组件的开发;

  4. 组件化思想:将页面拆分为PictureCard组件和主页面,实现代码复用,降低开发难度,便于后续功能扩展和维护。

此外,前端还使用了Vite作为构建工具,Vite具有快速的冷启动、热更新等优势,能够提升开发效率;使用CSS Scoped实现样式隔离,避免样式冲突,提升代码的可维护性。

3.2.4 后端技术栈选型

后端采用NestJS作为开发框架,NestJS是一款基于Node.js的后端框架,具有以下优势:

  1. 模块化设计:支持模块化开发,能够将不同的功能模块拆分,提升代码的复用性和维护性;

  2. 依赖注入:支持依赖注入,便于代码的测试和解耦;

  3. 类型安全:与TypeScript完美结合,提供静态类型检查,提升代码的健壮性;

  4. 丰富的生态:提供丰富的中间件、插件,支持多种数据库、缓存、认证等功能,便于项目的扩展。

后端的核心功能是接口转发、鉴权、数据处理等,例如将前端的图片解析请求转发给kimi大模型接口,对接口返回的数据进行处理后,返回给前端;同时,后端还可以实现用户认证、单词本存储等功能(后续优化)。

3.3 技术难点与解决方案

3.3.1 图片处理与base64编码

难点:多模态大模型接口需要传入图片的base64编码,而前端需要将用户上传的图片或拍照的图片转换为base64编码,同时要确保图片加载快速、编码正确,避免出现接口调用失败的情况。

解决方案:使用HTML5的FileReader API,实现图片的本地读取和base64编码。具体流程如下:用户上传图片或拍照后,获取文件对象,创建FileReader实例,调用readAsDataURL方法读取文件,在onload事件中获取base64编码的图片数据,然后将其传入大模型接口。同时,优化图片加载体验,在图片读取过程中显示加载状态,避免用户误以为操作失败。

3.3.2 大模型接口调用与数据解析

难点:大模型接口的调用需要注意请求格式、鉴权方式,同时接口返回的数据格式需要严格按照Prompt中定义的JSON格式,否则会导致前端解析失败;此外,接口响应时间可能受到网络影响,需要处理加载状态和异常情况。

解决方案:

  1. 严格按照接口文档要求,设置请求头(Content-Type、Authorization),请求体中传入模型名称、messages(包含图片base64和Prompt)、stream(关闭流式输出)等参数;

  2. 自定义Prompt,明确要求大模型返回JSON格式的数据,指定JSON中的字段(image_description、representative_word、example_sentence等),确保数据格式正确;

  3. 处理接口调用的异常情况,例如网络错误、接口返回错误信息等,通过try-catch捕获异常,给用户显示错误提示;

  4. 在接口调用过程中,显示“分析中...”的提示,让用户了解当前状态,提升用户体验。

3.3.3 音频播放与兼容性

难点:不同浏览器、不同设备对音频播放的支持存在差异,可能出现音频无法播放、播放卡顿等问题;同时,音频文件的加载速度也会影响用户体验。

解决方案:

  1. 使用HTML5的Audio对象实现音频播放,确保兼容性;

  2. 优化音频加载速度,通过TTS接口生成的音频URL尽量轻量化,减少加载时间;

  3. 处理音频播放的异常情况,例如音频加载失败、播放失败等,给用户显示提示信息;

  4. 提供清晰的播放按钮,用户点击后立即播放,提升交互体验。

3.3.4 无障碍访问实现

难点:实现无障碍访问,需要确保读屏器能够正常识别界面元素,尤其是图片、按钮、输入框等,同时要处理好样式控制与无障碍的兼容性(如input[type="file"]难以控制样式)。

解决方案:

  1. 使用label for + input#id的方式,将标签与输入框关联,读屏器能够通过标签识别输入框的功能;

  2. 对于难以控制样式的input[type="file"],使用display: none;隐藏,通过label标签触发输入框的点击事件,既保证样式美观,又不影响无障碍访问;

  3. 为图片、按钮等元素添加alt属性和清晰的文本提示,确保读屏器能够正确识别元素的功能和内容。

四、代码详细解析

4.1 前端代码整体结构

前端代码分为两个主要组件:主页面(App.vue)和PictureCard组件(PictureCard.vue),采用Vue3+TS+Composition API开发,代码结构清晰,逻辑连贯。具体结构如下:

  1. 主页面(App.vue):负责整体界面布局、图片解析逻辑、音频播放、详情展开/收起等功能,引入PictureCard组件,接收组件传递的图片数据,调用大模型接口,处理响应数据并展示;

  2. PictureCard组件:负责图片上传、拍照、图片预览等功能,通过props接收主页面传递的单词、音频信息,通过emit向主页面传递图片数据,实现组件间的通信。

4.2 主页面(App.vue)代码解析

4.2.1 脚本部分(script setup lang="ts")

脚本部分主要实现响应式数据定义、大模型接口调用、音频生成、数据处理等功能,具体解析如下:

  1. 引入依赖:
import PictureCard from './components/PictureCard.vue';
import { ref } from 'vue';
import { generateAudio } from './lib/audio.ts';
  • 引入PictureCard组件,用于图片上传和预览;

  • 引入ref函数,用于创建响应式数据(Vue3中,ref用于创建基本类型的响应式数据,包裹成带.value属性的响应式对象);

  • 引入generateAudio函数,用于调用TTS接口,生成音频URL。

  1. 定义响应式数据:
const imagePreview = ref(''); // 图片预览地址(base64)
const userPrompt = `...`; // 自定义Prompt,用于大模型接口调用
const word = ref('请上传文件'); // 识别出的单词
const audio = ref(''); // 音频URL
const sentence = ref(''); // 例句
const detailExpand = ref(false); // 详情展开/收起状态
const explanations = ref([]); // 单词解释(分行存储)
const expReplys = ref([]); // 解释对应的回复
  • imagePreview:存储图片的base64编码,用于图片预览和大模型接口调用;

  • userPrompt:自定义的Prompt,明确要求大模型解析图片,返回A1~A2级别单词、JSON格式数据,包含图片描述、单词、例句、解释等字段;

  • word、audio、sentence:分别存储识别出的单词、音频URL、例句,用于界面展示;

  • detailExpand:控制详情模块的展开和收起,默认收起;

  • explanations、expReplys:分别存储单词解释(分行处理后)和对应的回复,用于详情模块展示。

  1. 核心函数:update函数(图片解析核心函数)
const update = async(imageDate: string) => {
  imagePreview.value = imageDate; // 设置图片预览地址
  const endpoint = import.meta.env.VITE_KIMI_API_ENDPOINT + '/chat/completions'; // 大模型接口地址
  const headers = {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${import.meta.env.VITE_KIMI_API_KEY}`, // 鉴权信息
  }
  word.value = '分析中...'; // 显示加载提示
  try {
    const response = await fetch(endpoint,{
      method: 'POST',
      headers,
      body: JSON.stringify({
        model: 'moonshot-v1-8k-vision-preview', // 模型名称
        messages: [
          {
            role: 'user',
            content: [{
              type: 'image_url',
              image_url: {
                url: imageDate, // 图片base64编码
              }
            },{
              type: 'text',
              text: userPrompt, // 自定义Prompt
            }]
          }
        ],
        stream: false, // 关闭流式输出
      })
    });
    const data = await response.json(); // 解析接口响应数据
    const replyData = JSON.parse(data.choices[0].message.content); // 解析JSON格式的回复内容
    // 更新响应式数据
    word.value = replyData.representative_word;
    sentence.value = replyData.example_sentence;
    explanations.value = replyData.explaination.split('\n').filter((item:string) => item !== ''); // 分行处理解释内容,过滤空行
    expReplys.value = replyData.explanation_replys;
    // 生成音频URL
    const audioUrl = await generateAudio(replyData.example_sentence);
    audio.value = audioUrl;
  } catch (error) {
    word.value = '解析失败,请重新上传'; // 错误提示
    console.error('解析失败:', error);
  }
}

函数解析:

  • 接收参数imageDate(图片base64编码),设置图片预览地址;

  • 拼接大模型接口地址,设置请求头(Content-Type为application/json,Authorization为鉴权信息,通过环境变量获取,避免硬编码);

  • 设置word为“分析中...”,提示用户当前正在解析;

  • 使用fetch发起POST请求,请求体中传入模型名称、messages(包含图片和Prompt)、stream(关闭流式输出);

  • 解析接口响应数据,将大模型返回的JSON字符串解析为对象,提取单词、例句、解释等内容,更新响应式数据;

  • 调用generateAudio函数,生成例句的音频URL,更新audio响应式数据;

  • 使用try-catch捕获异常,出现错误时显示“解析失败,请重新上传”的提示,并打印错误信息。

  1. 提交函数:submit函数
const submit = (imageData: string) => {
  update(imageData);
}

函数解析:接收PictureCard组件传递的图片base64编码,调用update函数,触发图片解析流程。

4.2.2 模板部分(template)

模板部分负责界面布局和交互,结合响应式数据,实现动态展示,具体解析如下:

<!-- 引入PictureCard组件,传递单词、音频信息,监听update-image事件 -->
    <PictureCard 
    :word="word" 
    :audio="audio"
    @update-image="submit" 
    />
    <!-- 结果展示区域 -->
   {{ sentence }}<!-- 显示例句 -->
      <!-- 详情展开/收起按钮 -->
        <button @Talk bout it<!-- 收起状态 -->
        <!-- 展开状态:显示图片预览、单词解释、回复 -->
        {{ item }}{{ item }}

模板解析:

  • container:主容器,采用flex布局,垂直排列,居中对齐,设置背景渐变;

  • PictureCard组件:传递word(单词)、audio(音频URL) props,监听update-image事件,当组件传递图片数据时,调用submit函数;

  • output:结果展示区域,显示例句,设置居中对齐、加粗字体;

  • details:详情模块,固定在页面底部,居中显示;

  • 按钮:点击后切换detailExpand的状态,实现详情的展开和收起;

  • fold:详情收起状态,显示白色圆角矩形,保持界面简洁;

  • expand:详情展开状态,显示图片预览、单词解释(循环渲染explanations数组)、回复(循环渲染expReplys数组),设置白色背景、圆角,提升可读性。

4.2.3 样式部分(style scoped)

样式部分采用CSS Scoped,实现样式隔离,避免与其他组件冲突,具体解析如下:

.container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: start;
  margin: 0;
  padding: 0;
  width: 100vw;
  height: 100vh;
  font-size: .85rem;
  background: linear-gradient(180deg,rgb(235,189,166) 0%,rgb(71,49,32) 100%);
}
#selecteImage {
  display: none;
}
.input {
  width: 200px;
}
.output {
  margin-top: 20px;
  width: 80%;
  text-align: center;
  font-weight: bold;
}
.preview img {
  max-width: 100%;
}
button {
  padding: 0 10px;
  margin-left: 6px;
}
.details {
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
}
.details button {
  background-color: black;
  color: white;
  width: 160px;
  height: 32px;
  border-radius: 8px 8px 0 0;
  border: none;
  font-size: 12px;
  font-weight: bold;
  cursor: pointer;
}
.details .fold {
  width: 200px;
  height: 30px;
  background-color: white;
  border-top-left-radius: 8px;
  border-top-right-radius: 8px;
}
.details .expand {
  width: 200px;
  height: 88vh;
  background-color: white;
  border-top-left-radius: 8px;
  border-top-right-radius: 8px;
}
.expand img {
  width: 60%;
  margin-top: 20px;
  border-radius: 6px;
}
.expand .explaination {
  color: black;
  font-weight: normal;
}
.expand .explaination p {
  margin: 0 10px 10px 10px;
}
.expand .reply {
  color: black;
  font-weight: normal;
  margin-top: 20px;
}
.expand .reply p {
  padding: 4px 10px;
  margin: 0 10px 10px 10px;
  border-radius: 6px;
  border: solid 1px grey;
}

样式解析:

  • container:设置全屏显示,背景为线性渐变(暖色调),字体大小0.85rem,flex布局垂直排列,居中对齐;

  • #selecteImage:隐藏文件输入框,通过label标签触发;

  • output:设置margin-top为20px,宽度80%,居中对齐,字体加粗,突出例句显示;

  • details:固定在页面底部,居中显示(left:50% + transform: translateX(-50%));

  • 按钮样式:黑色背景、白色字体,圆角设计,鼠标悬停显示指针,提升交互体验;

  • fold和expand:均为白色背景、圆角设计,fold高度30px(收起状态),expand高度88vh(展开状态),显示图片、解释和回复;

  • 解释和回复样式:解释内容为黑色、常规字体,回复内容带有灰色边框和圆角,便于区分。

4.3 PictureCard组件代码解析

4.3.1 脚本部分(script setup lang="ts")

脚本部分主要实现图片上传、拍照、图片预览、组件通信等功能,具体解析如下:

import { ref } from 'vue';
import defaultImg from '../assets/camera.png';
import voiceIcon from '../assets/voice.png';
const imgPreview = ref(defaultImg); // 图片预览地址,默认显示相机图标
// 定义组件props,接收主页面传递的单词和音频信息
const props = defineProps({
    word: {
        type: String,
        default: ''
    },
    audio: {
        type: String,
        default: ''
    },
})
// 定义组件事件,向主页面传递图片数据
const emit = defineEmits(['updateImage'])
// 图片上传/拍照处理函数
const updateImageData = async (e:Event): Promise<any> => {
    const file = (e.target as HTMLInputElement).files?.[0]; // 获取上传的文件对象
    if (!file) return; // 若没有文件,直接返回
    return new Promise((resolve, reject) => {
        const reader = new FileReader(); // 创建FileReader实例
        reader.readAsDataURL(file); // 读取文件,转换为base64编码
        reader.onload = () => {
            const data = reader.result as string; // 获取base64编码数据
            imgPreview.value = data; // 更新图片预览地址
            emit('updateImage', data); // 向主页面传递图片数据
            resolve(data);
        }
        reader.onerror = (error) => {
            reject(error); // 处理读取错误
        }
    })
}
// 音频播放函数
const playAudio = () => {
  const audio = new Audio(props.audio); // 创建Audio对象,传入音频URL
  audio.play(); // 播放音频
}

脚本解析:

  1. 引入依赖:引入ref函数、默认相机图标(defaultImg)、音频图标(voiceIcon);

  2. 响应式数据:imgPreview用于存储图片预览地址,默认显示相机图标;

  3. defineProps:定义组件的props,接收主页面传递的word(单词)和audio(音频URL),设置默认值为空字符串;

  4. defineEmits:定义组件的事件updateImage,用于向主页面传递图片的base64编码;

  5. updateImageData函数:处理图片上传/拍照事件,获取文件对象,使用FileReader将文件转换为base64编码,更新图片预览地址,通过emit向主页面传递数据,返回Promise对象,便于处理异步操作;

  6. playAudio函数:创建Audio对象,传入props.audio(音频URL),调用play()方法播放音频。

4.3.2 模板部分(template)

<!-- 文件输入框,隐藏显示,用于接收图片上传/拍照数据 -->
    <input type="file" id="selecteImage" class="input"
    accept="image*" @ />
    <!-- 标签,触发文件输入框,显示图片预览 -->
   <!-- 显示单词 -->
        {{ props.word }}
    <!-- 音频播放按钮,只有音频存在时显示 -->
    <div class="playAudio" v-if="audio" @

模板解析:

  • card:组件容器,采用卡片式设计,设置圆角、阴影、背景色,提升界面层次感;

  • input[type="file"]:文件输入框,隐藏显示(#selecteImage设置display: none),accept="image/*"表示只接收图片文件,@change事件绑定updateImageData函数,当用户上传图片或拍照后,触发函数处理;

  • label:与文件输入框关联(for="selecteImage"),点击标签即可触发文件输入框的点击事件,显示图片预览(img标签绑定imgPreview);

  • word:显示主页面传递的单词,设置白色字体,突出显示;

  • playAudio:音频播放按钮,只有audio存在时显示(v-if="audio"),点击触发playAudio函数,显示音频图标。

4.3.3 样式部分(style scoped)

#selecteImage {
    display: none;
}
.card {
  border-radius: 8px;
  padding: 20px;
  margin-top: 40px;
  height: 280px;
  box-shadow: rgb(63,38,21) 0 3px 0px 0;
  background-color: rgb(105,78,62);
  box-sizing: border-box;
}
.upload {
  width: 160px;
  height: 160px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}
.upload img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}
.word {
  margin-top: 20px;
  font-size: 16px;
  color: rgb(255,255,255);
}
.playAudio {
  margin-top: 16px;
}
.playAudio img {
  cursor: pointer;
}

样式解析:

  • #selecteImage:隐藏文件输入框;

  • card:设置圆角8px,内边距20px,margin-top40px,高度280px,阴影效果(rgb(63,38,21)),背景色rgb(105,78,62)(深棕色),box-sizing: border-box确保内边距不影响整体尺寸;

  • upload:设置宽度和高度160px,flex布局居中对齐,确保图片预览居中显示;

  • upload img:宽度和高度100%,object-fit: contain确保图片按比例显示,不拉伸;

  • word:margin-top20px,字体大小16px,白色字体,突出显示单词;

  • playAudio:margin-top16px,音频图标鼠标悬停显示指针,提升交互体验。

4.4 核心技术点补充解析

4.4.1 Vue3 Composition API 核心用法

项目中大量使用了Vue3的Composition API,核心用法包括ref、defineProps、defineEmits等,补充解析如下:

  1. ref:用于创建基本类型的响应式数据,如string、number、boolean等,返回一个带.value属性的响应式对象。例如,const word = ref('请上传文件'),修改时需要使用word.value = '分析中...',界面会自动更新;

  2. defineProps:用于定义组件的属性,接收一个对象,指定属性的类型、默认值等,组件外部可以通过props向组件传递数据。例如,PictureCard组件通过defineProps接收主页面传递的word和audio;

  3. defineEmits:用于定义组件的事件,组件内部可以通过emit向外部传递数据。例如,PictureCard组件通过emit('updateImage', data)向主页面传递图片的base64编码;

  4. 脚本设置语法(script setup):Vue3的语法糖,无需导出组件,直接编写脚本代码,简化了组件的编写流程,提升开发效率。

4.4.2 Prompt设计技巧

Prompt设计是AIGC产品的核心,项目中的Prompt设计具有很强的参考价值,具体技巧如下:

  1. 明确指令:清晰告知大模型需要完成的任务,例如“分析图片内容,找出最能描述图片的一个英文单词,尽量选择更简单的A1~A2的词汇”;

  2. 指定输出格式:明确要求大模型返回JSON格式的数据,并指定JSON中的字段,例如image_description、representative_word、example_sentence等,确保数据格式统一,便于前端解析;

  3. 控制输出内容:对输出的内容进行约束,例如“解释的最后给一个日常生活有关的问句”“回复根据explaination给出”,确保输出内容贴合项目需求;

  4. 适配用户水平:明确要求单词级别为A1~A2,确保输出的单词和例句简单易懂,贴合目标用户的英语水平。

4.4.3 环境变量的使用

项目中使用了环境变量(import.meta.env)存储大模型接口地址和API Key,避免将敏感信息硬编码到代码中,提升代码的安全性和可维护性。具体用法如下:

  1. 在项目根目录创建.env文件,定义环境变量:VITE_KIMI_API_ENDPOINT=接口地址,VITE_KIMI_API_KEY=API Key;

  2. 在代码中通过import.meta.env.VITE_XXX获取环境变量,例如const endpoint = import.meta.env.VITE_KIMI_API_ENDPOINT + '/chat/completions';

  3. 注意:环境变量名必须以VITE_开头,否则无法在前端代码中访问。

五、总结

本次“拍照记单词”项目学习,围绕AI多模态技术与前后端开发的结合展开,全面覆盖了产品设计、技术实现、问题解决等多个核心环节,不仅掌握了具体的技术栈用法,更理解了AI时代轻量化产品的开发逻辑与核心思路,收获颇丰。

项目层面,“拍照记单词”作为一款贴合真实场景的英语学习工具,精准抓住了传统单词学习产品场景化不足、操作繁琐的痛点,以“拍照即记词”为核心,结合多模态大模型与TTS技术,实现了场景化、便捷化、个性化的学习体验,同时兼顾无障碍设计,体现了产品的包容性与人文关怀。通过对百词斩、扇贝、多邻国等竞品的分析,明确了项目的差异化优势,也为后续产品优化提供了清晰方向。

技术层面,本次学习系统掌握了Vue3+TS+Composition API的前端开发方法,理解了组件化思想在项目中的实际应用,能够熟练运用FileReader实现图片base64编码、调用多模态大模型接口、通过TTS技术实现音频播放等核心功能;同时了解了NestJS后端框架的选型逻辑与核心作用,掌握了前后端分离架构的基本设计思路。在技术实践中,针对图片处理、接口调用、音频播放、无障碍适配等难点,通过合理的技术方案解决了实际问题,提升了问题排查与解决能力,也深刻认识到细节处理(如环境变量使用、Prompt设计)在项目开发中的重要性。

整体而言,通过本次项目学习,不仅夯实了前端开发、AI接口调用等技术基础,更建立了“产品需求→技术实现→问题优化”的完整思维模式,理解了AI技术与用户需求结合的核心逻辑。后续将把本次学习收获运用到同类项目开发中,重点关注产品细节优化与用户体验提升,同时持续深耕多模态技术、前端框架等相关知识,不断提升自身的开发能力与产品思维。

LogicFlow 小地图性能优化:从「实时克隆」到「占位缩略块」!🚀

写在开头

Hi,各位朋友们好呀!😋

今是2026年03月10日,虽迟但到,时间飞快,又过去一个月了。

灵魂一问:你养虾了吗?🦞

最近 OpenClaw 很火呢,但...它真能给你带来实际作用吗?🤔
小编也养了一只,但目前好像除了提供点"情绪价值",其他场景的还没派上用场,生产力也还在观察中。

二月过了个年,这个年小编过得非常开心的,从各个方面。🥳 然后,也给辛苦一年的自己买了个小礼物:换了台电脑——MacBook Air M4 24+512。

猜猜小编花了多少钱拿下的?评论区有答案。

言归正传,今天要分享的内容依旧是关于 LogicFlow 库的,给其小地图插件增加缩略块模式,效果如下,请诸君按需食用哈。

image.png

需求背景 💡

在最近的项目里,小编基于 LogicFlow 做了流程图页面,节点类型不少,而且很多是自定义 HTML 节点,内容里有文本、图片、视频、音频等富媒体。

画布上用了官方推荐的小地图插件,功能没问题,但小地图是实时同步主画布的:主画布渲染一份节点,小地图再渲染一份,内容一样。节点一多,加上拖拽、缩放、批量操作,两边都要更新,有点一个页面干两份活的意思,几十上百个节点时性能压力就很明显。

主画布可以靠局部渲染缓解,小地图那块就没辙了,如果节点再显示图片、视频这类资源,一进页面就要全量加载,非常容易卡顿。

这次目标很明确:给小地图开发一种缩略块模式——用轻量占位块代替真实节点渲染,缓解性能问题。具体来说🤔:

  1. 保留定位导航能力
  2. 不再同步创建真实节点内容
  3. 用轻量占位块表达节点位置和大小
  4. 与现有 miniMap 配置兼容

实现过程 ⚡

以下改造思路和实现均基于 LogicFlow 官方 MiniMap 源码,插件整体代码并不算多,可以仔细瞧瞧:传送门

第1️⃣步:明确改造策略——继承官方 MiniMap

小编没有另起炉灶,从零开始,而是直接继承官方 MiniMap,只改关键实现点。

这样做的好处是:

  • 官方行为仍可复用(例如视口更新、定位等)
  • 后续升级 LogicFlow 时,迁移成本更低

🍊 为什么选择「继承 + 局部重写」❓

因为咱们真正的痛点不是功能不够,只是渲染太重。只要把渲染部分调整一下,就能快速拿到收益,不必把整个插件推倒从零开始。

import { MiniMap } from "@logicflow/extension";

/**
 * 自定义小地图:继承官方 MiniMap,通过 placeholderMode 支持「占位块」与「实时克隆」双模式
 */
class CustomMiniMap extends MiniMap {
  constructor({ lf, LogicFlow, options }) {
    const { placeholderMode = true, ...restOptions } = options || {};
    const hasRestOptions = Object.keys(restOptions).length > 0;
    // 将 placeholderMode 以外的配置透传给官方 MiniMap
    super({ lf, LogicFlow, options: hasRestOptions ? restOptions : undefined });
    this.placeholderMode = placeholderMode;  // 默认开启占位块模式
  }
}

第2️⃣步:增加 placeholderMode,支持双模式切换

这一步是整个方案的开关:

  • placeholderMode: true:占位块模式(默认)
  • placeholderMode: false:实时模式(回退到官方行为)

也就是说,咱们不是把官方逻辑「干掉」,而是给它加了个性能开关。

/**
 * 重写 setView:根据 placeholderMode 决定走官方渲染还是轻量占位块渲染
 */
setView(reRender = true) {
  if (!this.placeholderMode) {
    return MiniMap.prototype.setView.call(this, reRender);  // 回退到官方实时克隆
  }
  // placeholderMode === true 时,走轻量占位块渲染逻辑(此处省略具体实现)
}

第3️⃣步:把真实节点数据转换为占位块数据

⏰ 关键点❗❗❗

小地图不再吃原始节点类型,而是统一转换成一个占位节点类型: minimap:placeholder

转换时只保留导航必需信息:

  • id
  • x / y
  • width / height
  • 少量 properties(用于占位模型读取)
const MINIMAP_PLACEHOLDER_TYPE = "minimap:placeholder";

/**
 * 将原始节点数据转换为占位块数据,仅保留定位、尺寸等导航必需信息
 * @param {Object} data - { nodes, edges }
 * @returns {Object} 转换后的 { nodes, edges },节点类型统一为 minimap:placeholder
 */
_resetDataWithPlaceholder(data) {
  const nodes = data.nodes.map((node) => {
    // 优先从 properties 取尺寸,再 fallback 到节点顶层,默认 200
    const width = Number(node.properties?.width) || Number(node.width) || 200;
    const height = Number(node.properties?.height) || Number(node.height) || 200;
    return {
      id: node.id,
      type: MINIMAP_PLACEHOLDER_TYPE,
      x: node.x,
      y: node.y,
      width,
      height,
      properties: { width, height, _originalType: node.type },
    };
  });

  return {
    nodes,
    edges: this.showEdge ? data.edges.map((e) => ({ ...e, text: undefined })) : [],
  };
}

💡 小贴士:这里优先从 properties.width/height 取尺寸,再 fallback 到节点顶层尺寸,这个细节非常重要,能保证小地图占位块尺寸更贴近主画布真实节点。

第4️⃣步:注册轻量占位节点视图与模型

占位节点本身非常轻,只渲染一个 rect,不挂任何复杂内容。

import { h, RectNode, RectNodeModel } from "@logicflow/core";

/**
 * 轻量占位节点视图:只渲染一个圆角矩形,不挂载任何子节点或富媒体内容
 */
class MinimapPlaceholderView extends RectNode {
  getShape() {
    const { x, y, width, height } = this.props.model;
    return h("g", {}, [
      h("rect", {
        x: x - width / 2,   // LogicFlow 节点以中心点为坐标,rect 需偏移
        y: y - height / 2,
        rx: 10,
        ry: 10,
        width,
        height,
      }),
    ]);
  }
}

这样小地图的渲染成本就从创建一堆真实节点内容,降到了画几个轻量矩形块。

第5️⃣步:接入现有使用的地方

在业务页面里,小编是直接替换 MiniMap 的来源,不改既有交互入口:

// 仅改 import 来源,其余用法与官方 MiniMap 一致
import { CustomMiniMap as MiniMap } from "./plugins/CustomMiniMap";

LogicFlow.use(MiniMap);

再加上 CustomMiniMap.pluginName = "miniMap",可以继续复用原有 pluginsOptions.miniMap 配置,不需要大动干戈改业务代码,这点非常香。😁

完整源码

传送门

总结

这次改造的核心就一句话:小地图有时可能并不需要真实还原,只需要正确导航就行。

通过二次改造增加小地图新模式后,咱们拿到了几个关键收益:

  • 小地图渲染负担显著下降
  • 节点规模上来后,交互更稳
  • 依然保留官方 MiniMap 的主要能力




至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

在 Vue 项目中玩转 FullCalendar:从零搭建可交互的事件日历

在很多业务场景中,我们都需要把「时间维度上的事件」清晰地呈现在一个可交互的日历里,并和其它数据视图(图表、报表、分析面板等)联动。本文基于一个典型的「事件日历 + 外部报表联动」场景,总结一套在 Vue 项目中落地 FullCalendar 的通用实践。

概览

FullCalendar 是最受欢迎的js calendar组件

官网:fullcalendar.io/

demos: fullcalendar.io/demos

vue demo: github.com/fullcalenda…

安装

使用 NPM 或 Yarn 安装软件包core以及您计划使用的任何插件(按需安装) 插件列表:fullcalendar.io/docs/plugin…

标题 描述 示意
@fullcalendar/core 必须
@fullcalendar/vue vue2项目
@fullcalendar/vue3 vue3项目
@fullcalendar/interaction 支持点击、拖拽等事件
@fullcalendar/daygrid DayGrid 视图 image.png
@fullcalendar/timegrid timegrid视图 image.png
@fullcalendar/resource-timeline 时间轴视图 image.png
npm install \
  @fullcalendar/core \ 
  @fullcalendar/vue  \  
  @fullcalendar/daygrid \   
  @fullcalendar/timegrid \   
  @fullcalendar/interaction \  

如果是vue3项目用@fullcalendar/vue3

使用

引入组件

<template>
    <FullCalendar ref="myCalendar" :options="calendarOptions" />
</template>

<script>
  // 引入已经安装好的,页面所需要的 FullCalendar 插件
  import FullCalendar from '@fullcalendar/vue'
  import dayGridPlugin from '@fullcalendar/daygrid'
  import timeGridPlugin from '@fullcalendar/timegrid'
  import interactionPlugin from '@fullcalendar/interaction'
  // 日历参数配置
  const calendarOptions = {}

  export default {
    name: "my-calendar",
    components: {
      FullCalendar
    },
    data () {
      return {
        calendarOptions
      }
    }
  }
</script>

参数配置

dayGridMonth视图的简单配置参考:

calendarOptions = {
        locale: 'zh-cn',
        plugins: [
          dayGridPlugin,
          // interactionPlugin, // needed for dateClick
        ],
        headerToolbar: {
          left: 'prev,next today',
          center: 'title',
          right: 'dayGridMonth',
        },
        buttonText: { today: '今天', prev: '上个月', next: '下个月', dayGridMonth: '月' }, // 设置按钮文本内容
        initialView: 'dayGridMonth',
        initialEvents: [], // alternatively, use the `events` setting to fetch from a feed

        firstDay: 1,
        aspectRatio: 1.35, // 日历单元格宽高比 默认值:1.35
        eventColor: '#3a79eb', // 日历中事件的默认背景色颜色,优先级低于添加事件时设置的背景色
        dayMaxEvents: true,
        editable: false,
        selectable: false,
        selectMirror: true,
        dayMaxEvents: true,
        weekends: true,
        events: this.fetchEvents, // 获取事件
        eventClick: this.handleEventClick, // 点击事件
        eventsSet: this.handleEvents,
        // you can update a remote database when these fire:
        // eventChange: this.handleEventChange        // eventAdd:
        // eventRemove:

详细的配置可参考文章:blog.csdn.net/FlowGuanEr/…

slot模板

<template>
  <FullCalendar :options="calendarOptions">
    <template v-slot:eventContent='arg'>
      <b>{{ arg.event.title }}</b>
    </template>
  </FullCalendar>
</template>

Calendar API

let calendarApi = this.$refs.myCalendar.getApi() 
calendarApi.next()

事件

1. 事件对象

var calendar = new Calendar(calendarEl, {
  timeZone: 'UTC',
  events: [
    {
      id: 'a',
      title: 'my event',
      start: '2018-09-01',
      end: '2018-09-01'
    }
  ]
})

更多字段详解:fullcalendar.io/docs/event-…

2. 初始化事件

可以在initialEvents配置初始化事件列表

也可以用events中配置方法,调用接口去获取数据,将数据格式化成事件对象规范的格式,显示事件列表。

事件排序:接口返回的顺序可能杂乱,建议在传给 successCallback 前对列表按 start(及可选的 endtitle)排序,这样同一天内多事件在月视图中的展示顺序一致、可预期(FullCalendar 会按你传入的顺序在同一格内排列)。

fetchEvents (info, successCallback, failureCallback) {
  // info.start / info.end 是当前视图的起止时间
  requestAPI(api.fetchCalendarEvents, {
    startTime: info.start.getTime(),
    endTime: info.end.getTime()
  }).then(data => {
    if (data?.length) {
      const list = data.map((item) => ({
        id: item.eventId,
        title: item.eventTitle,
        start: moment(item.startTime).format('YYYY-MM-DD'),
        end: moment(item.endTime + MS_PER_DAY).format('YYYY-MM-DD')
      }))
      // 按开始时间排序,同一天内按结束时间、再按标题排序,保证展示顺序稳定
      list.sort((a, b) => {
        const startDiff = new Date(a.start) - new Date(b.start)
        if (startDiff !== 0) return startDiff
        const endDiff = new Date(a.end) - new Date(b.end)
        if (endDiff !== 0) return endDiff
        return (a.title || '').localeCompare(b.title || '')
      })
      successCallback(list)
    } else {
      failureCallback()
    }
  }).catch(failureCallback)
}

3. 事件回调

实战:事件日历与外部报表的联动方案

下面是一个抽象化的实战场景:在某个运营看板页面,我们用 FullCalendar 搭建了一个「事件日历」,并和右侧的数据分析报表(通过 iframe 嵌入)联动,整体思路可以概括为三步:

  1. Calendar 只负责展示事件排期
    • 通过 events: this.fetchEvents 懒加载当前视图范围内的事件,避免一次性加载整年数据。
    • 接口返回后在前端做一次格式化,转成 FullCalendar 认可的事件对象:
      • id: 使用 eventId 标识事件;
      • title: 使用 eventTitle 作为日历上展示的文案;
      • start / end: 用 moment 格式化为 YYYY-MM-DD,结束时间额外 +1 天,避免跨天活动少算一天。
    • 在调用 successCallback(list) 前对 liststartendtitle 排序,保证同一天内多事件的展示顺序稳定(见上文「事件排序」)。
fetchEvents (info, successCallback, failureCallback) {
  requestAPI(api.fetchCalendarEvents, {
    startTime: info.start.getTime(),
    endTime: info.end.getTime()
  }).then(data => {
    if (data?.length) {
      const list = data.map(item => ({
        id: item.eventId,
        title: item.eventTitle,
        start: moment(item.startTime).format('YYYY-MM-DD'),
        end: moment(item.endTime + MS_PER_DAY).format('YYYY-MM-DD')
      }))
      list.sort((a, b) => {
        const startDiff = new Date(a.start) - new Date(b.start)
        if (startDiff !== 0) return startDiff
        const endDiff = new Date(a.end) - new Date(b.end)
        if (endDiff !== 0) return endDiff
        return (a.title || '').localeCompare(b.title || '')
      })
      successCallback(list)
    } else {
      failureCallback()
    }
  }).catch(failureCallback)
}
  1. 点击日历事件,高亮并联动外部报表
    • eventClick 中拿到被点击的事件,做两件事:
      • 把上一次选中的事件颜色还原为默认蓝色;
      • 把当前事件改成高亮色,并记录 currentEventId
    • 同时,基于事件编号 eventId 拼接外部报表地址,赋值给 pageUrl,iframe 会自动切到该事件对应的数据分析页面:
handleEventClick (clickInfo) {
  if (clickInfo?.event?.id) {
    this.currentEvents.forEach(event => {
      if (event.id === this.currentEventId) {
        event.setProp('color', '#3a79eb')
      }
    })
    clickInfo.event.setProp('color', '#db3491')
    this.currentEventId = clickInfo.event.id
    this.pageUrl = `${REPORT_BASE_URL}?eventId=${clickInfo.event.id}`
  }
}
  1. 通过插槽自定义事件渲染
    • 使用 eventContent 插槽可以灵活控制日历单元里的展示结构,比如在运营看板里我们希望同时显示「活动时间 + 活动名称」:
<FullCalendar class="calendar-app-calendar" :options="calendarOptions">
  <template v-slot:eventContent="arg">
    <b>{{ arg.timeText }}</b>
    <i>{{ arg.event.title }}</i>
  </template>
</FullCalendar>

综合以上三点,一个完整的「事件日历 + 数据分析联动」就搭建好了:
使用者只需要在日历上点选某个事件,对应的外部报表就会自动切换到该事件的分析视图,从「时间排期」自然跳转到「结果分析」,大大提升日常分析效率。

《Vue 自定义指令注册技巧:从手动到自动,效率翻倍》

在 Vue 开发中,自定义指令是个非常实用的功能,比如实现输入框自动聚焦、图片懒加载、长按事件等场景都能用到。但随着项目中自定义指令数量增多,一个个手动注册会变得繁琐且容易遗漏。今天就聊聊 Vue 自定义指令的两种注册方式:手动注册(适合少量指令)和自动扫描注册(适合指令较多的场景),用最通俗的方式讲清楚怎么用、为什么这么用

自定义指令基础

在开始之前,先简单回顾下 Vue 自定义指令的核心:自定义指令本质是一个包含bindinsertedupdate等钩子函数的对象,比如我们写一个focus指令(让输入框自动聚焦):

export default { 
// 指令绑定到元素且元素插入DOM时执行 
    inserted(el) { 
        el.focus(); // 让元素获得焦点 
    } 
};

有了指令文件,接下来就是把它注册成全局指令,让整个项目都能使用。

手动全局统一注册

如果你的项目里自定义指令只有 1-2 个,手动注册是最直接的方式,逻辑简单、一目了然。

/**
 * 全局指令分发
 * 适合数量少的情况
 */
import Vue from "vue";
import focusDirective from "./focus";

//手机全局自定义指令
const OS = {
  focus: focusDirective,
};

Object.keys(OS).forEach((key) => {
  Vue.directive(key, OS[key]);
});

怎么用?

在 Vue 组件里直接用v-指令名即可:

<template> 
<!-- 使用v-focus指令,输入框渲染后自动聚焦 --> 
    <input v-focus type="text" placeholder="自动聚焦的输入框" /> 
</template>

优点&缺点

  • 优点:代码少、逻辑清晰,新手一看就懂,适合指令数量少的小项目。

  • 缺点:每新增一个指令,都要手动导入、手动加到对象里,容易忘写,维护成本随指令数量增加而上升。

自动全局统一注册

当项目里的自定义指令越来越多(比如 5 个以上),手动注册就显得很麻烦。这时可以用 Vue 生态里的require.context(Webpack 提供的 API)实现自动扫描指定目录下的指令文件,自动注册,新增指令时只需要新建文件,无需修改注册代码

import Vue from "vue";
// 【可选】手动指定一些特殊指令(比如不想被自动扫描的)
const manualDirectives = {
  focus: require("./focus").default,
};
// 核心:自动扫描当前目录下的指令文件 
// require.context(目录, 是否递归查找子目录, 匹配文件的正则) 
// 这里规则:扫描./目录、不递归、匹配除了index.js之外的所有.js文件
const autoDirectives = require.context("./", false, /^\.\/(?!index).+\.js$/);
// 合并并注册
// 合并手动指令和自动扫描的指令
const allDirectives = {
  ...manualDirectives,// 展开手动指令
  // 遍历自动扫描的文件,转换成{指令名: 指令对象}的格式
  ...autoDirectives.keys().reduce((obj, fileName) => {
      // 处理文件名:比如./longpress.js → longpress(作为指令名)
    const name = fileName.replace(/^\.\/|\.js$/g, "");
    // 获取文件导出的指令对象(取default导出)
    obj[name] = autoDirectives(fileName).default;
    return obj;
  }, {}),
};
// 统一注册所有指令(加了校验,避免空指令导致报错)
Object.keys(allDirectives).forEach((name) => {
  const directive = allDirectives[name];
  // 校验指令是否存在
  if (directive) Vue.directive(name, directive);
});

核心逻辑拆解

  • require.context:像一个 “文件扫描器”,会返回一个包含指定目录下所有匹配文件的对象,keys()方法能拿到所有文件路径(比如./focus.js./longpress.js)。

  • reduce遍历:把文件路径转换成 “指令名 - 指令对象” 的键值对,比如./focus.js{focus: 指令对象}

  • 合并指令:把手动指定的和自动扫描的指令合并,兼顾灵活性和自动化。

  • 统一注册:遍历合并后的指令对象,用Vue.directive注册全局指令。

怎么用?

新增指令时,只需要在src/directives/目录下新建.js文件即可,比如新建longpress.js

// src/directives/longpress.js 
export default { 
bind(el, binding) { 
    // 长按指令的逻辑(示例) 
    let timer = null; 
    el.addEventListener('touchstart', () => { 
        timer = setTimeout(() => { 
            binding.value(); // 执行指令绑定的方法 
            }, 1000); 
        }); 
        el.addEventListener('touchend', () => {
            clearTimeout(timer); 
        }); 
    } 
};

组件里直接用v-longpress,无需修改注册代码:

<template> 
    <button v-longpress="handleLongPress">长按1秒触发</button> 
</template> 
<script> 
export default { 
    methods: { handleLongPress() { alert('长按触发啦!'); } 
    } 
}; 
</script>

优点 & 缺点

  • 优点:新增指令只需新建文件,无需手动注册,维护成本低,适合中大型项目。
  • 缺点:比手动注册多了一点代码,新手需要理解require.contextreduce的用法,但理解后会非常香。

两种方法怎么选?

场景 推荐方式 核心原因
指令数量≤3 个 手动注册 简单直接,无需额外学习成本
指令数量≥3 个 自动扫描注册 减少重复工作,降低维护成本
新手入门 先手动后自动 循序渐进理解,避免一开始懵

总结

  1. Vue 全局注册自定义指令的核心是Vue.directive(指令名, 指令对象),两种方式最终都是调用这个方法。
  2. 手动注册适合指令少的场景,优点是简单直观;自动扫描注册基于require.context实现,适合指令多的场景,新增指令无需改注册代码。
  3. 实际开发中可以结合两种方式:特殊指令手动指定,常规指令自动扫描,兼顾灵活性和自动化。

Vue的响应式原理?Vue2和Vue3有什么区别?

一、什么是 Vue 的响应式

Vue 的核心能力就是 数据变化 → 自动更新视图

例如:

data() {
  return {
    count: 1
  }
}
<div>{{ count }}</div>

当执行:

this.count = 2

页面会自动更新。

这个过程就是 响应式系统

核心流程:

数据变化
   ↓
监听数据变化
   ↓
通知依赖更新
   ↓
重新渲染视图

Vue 内部有三个关键角色:

角色 作用
Observer 监听数据
Dep 依赖收集
Watcher 触发更新

二、Vue2 响应式原理(Object.defineProperty)

Vue2 使用:

Object.defineProperty

劫持对象属性。

示例

let obj = {}

Object.defineProperty(obj, "name", {
  get() {
    console.log("读取")
    return value
  },
  set(newVal) {
    console.log("修改")
    value = newVal
  }
})

当访问:

obj.name

会触发

get()

当修改:

obj.name = "Vue"

会触发

set()

Vue 就利用这个机制实现响应式。


Vue2 内部流程

1 数据劫持

Vue 在初始化 data 时:

遍历所有属性

给每个属性添加 getter / setter。

伪代码:

function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    get() {
      // 收集依赖
      dep.depend()
      return val
    },
    set(newVal) {
      val = newVal
      // 通知更新
      dep.notify()
    }
  })
}

2 依赖收集

当模板渲染:

{{ count }}

会生成一个 Watcher

Watcher 会读取数据:

count

触发

getter

然后:

Dep 收集 Watcher

结构:

count
  ↓
Dep
  ↓
Watcher

3 数据变化

当执行:

this.count++

触发:

setter

然后:

Dep.notify()

通知所有 Watcher 更新。

Watcher → 重新渲染

三、Vue2 的缺点

Vue2 的响应式有几个问题:

1 不能监听对象新增属性

this.obj.age = 18

不会更新。

必须:

Vue.set(this.obj, "age", 18)

2 不能监听数组下标

this.arr[1] = 10

不会更新。

必须:

splice
push
pop
shift

Vue 重写了数组方法。


3 初始化性能差

Vue2 会:

递归遍历整个 data

如果数据非常大:

初始化慢

四、Vue3 响应式原理(Proxy)

Vue3 使用:

Proxy

代替

Object.defineProperty

示例:

let obj = { name: "vue" }

let proxy = new Proxy(obj, {
  get(target, key) {
    console.log("读取")
    return target[key]
  },
  set(target, key, value) {
    console.log("修改")
    target[key] = value
    return true
  }
})

Proxy 优势

Proxy 可以拦截:

13 种操作

比如:

get
set
deleteProperty
has
ownKeys

所以:

新增属性
删除属性
数组下标

都能监听。


Vue3 响应式核心

Vue3 内部有两个核心方法:

track(收集依赖)

track(target, key)

当读取数据:

get

收集依赖。


trigger(触发更新)

trigger(target, key)

当数据变化:

set

通知更新。


核心结构:

targetMap
  ↓
WeakMapMapSet

结构图:

WeakMap
  target -> Map
              key -> Set(effect)

意思是:

对象
  ↓
属性
  ↓
依赖函数

五、Vue2 vs Vue3 区别

对比 Vue2 Vue3
响应式实现 Object.defineProperty Proxy
监听新增属性 不支持 支持
数组下标 不支持 支持
初始化性能 需要递归遍历 按需代理
API Options API Composition API
代码体积 较大 更小
TS支持 一般 非常好

六、Vue3 Composition API(核心变化)

Vue3 新增:

setup()

例如:

import { ref } from "vue"

export default {
  setup() {
    const count = ref(0)

    const add = () => {
      count.value++
    }

    return { count, add }
  }
}

优势:

逻辑复用更好
代码组织更清晰
TS友好

七、面试最佳回答(推荐说法)

面试时可以这样回答:

Vue 的响应式原理是通过数据劫持和依赖收集实现的。

在 Vue2 中,主要通过 Object.defineProperty 对 data 的属性进行 getter 和 setter 劫持,当数据被读取时进行依赖收集,当数据被修改时通知依赖更新,从而触发视图重新渲染。

Vue2 的缺点是无法监听对象新增属性和数组下标变化,因此需要使用 Vue.set 或重写数组方法。

在 Vue3 中,响应式系统改为使用 Proxy 实现。Proxy 可以拦截更多操作,例如属性新增、删除、数组索引等,因此解决了 Vue2 的很多限制。同时 Vue3 使用 tracktrigger 来进行依赖收集和触发更新,并且性能更好。

此外 Vue3 还引入了 Composition API,使得逻辑复用更加灵活,对 TypeScript 支持更好。

computed 的缓存哲学:如何避免不必要的重复计算?

前言

在 Vue 应用中,计算属性 computed 是最常用也是最重要的特性之一。它让我们能够声明式地创建基于其他响应式数据的衍生状态。但很多开发者对 computed 的理解停留在表面,不知道它背后的缓存机制,也不清楚何时该用 computed、何时该用 methods。更有甚者,在 computed 中做大量复杂计算,导致性能问题而不自知。

本文将深入探讨 computed 的缓存哲学,通过原理分析和实战案例,帮我们掌握计算属性的正确使用姿势,避免重复计算,提升应用性能。

computed 的工作原理

懒计算:只在访问时求值

computed 的第一个重要特性是懒计算(Lazy Evaluation)。这意味着计算属性不会在创建时立即执行,而是在第一次读取它的值时才会进行计算:

import { ref, computed } from 'vue'
const count = ref(1)
const double = computed(() => {
  console.log('double 被计算了')
  return count.value * 2
})

// 第一次访问 double,触发计算
console.log(double.value) // 输出: "double 被计算了", 2

// 再次访问,使用缓存,不重新计算
console.log(double.value) // 只输出 2,没有计算日志

缓存机制:依赖不变就不重新计算

computed 最核心的特性是缓存。它会记录上一次计算的结果,只有当依赖的响应式数据发生变化时,才会重新计算。如同上述例子一样,当 count 的值没有变化时,重复访问 double,读取的是缓存中的值,并不会重新走计算流程。

依赖追踪:自动收集响应式依赖

computed 本质上是一个特殊的 effect,能够精确知道自己的依赖项,在计算属性执行时,访问到的响应式数据会被自动记录为依赖:

const a = ref(1)
const b = ref(2)
const c = ref(3)
const condition = ref(true)

const result = computed(() => {
  console.log('result 重新计算')
  // 只有 condition 为 true 时才会访问 a
  // 为 false 时访问 b
  if (condition.value) {
    return a.value + c.value
  } else {
    return b.value + c.value
  }
})

console.log(result.value) // 计算一次,依赖: condition, a, c

// 修改 b - 不会触发重新计算,因为当前依赖中不包含 b
b.value = 10
console.log(result.value) // 使用缓存

// 修改 condition
condition.value = false
console.log(result.value) // 重新计算,现在依赖变为 condition, b, c

// 现在修改 b 会触发重新计算
b.value = 20
console.log(result.value) // 重新计算

computed vs methods:性能对比

多次渲染时的表现差异

在开发中,我们可以使用 computed, 也可以使用 methods 来获取衍生数据。它们在功能上没有太大的区别,但在表现上缺有着本质上的区别:

<template>
  <div>
    <!-- 三次使用 computed -->
    <p>Computed: {{ double }}</p>
    <p>Computed: {{ double }}</p>
    <p>Computed: {{ double }}</p>
    
    <!-- 三次调用 methods -->
    <p>Methods: {{ getDouble() }}</p>
    <p>Methods: {{ getDouble() }}</p>
    <p>Methods: {{ getDouble() }}</p>
    
    <button @click="count++">增加</button>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const count = ref(0)

// computed:只会计算一次,缓存三次使用
const double = computed(() => {
  console.log('computed 计算')
  return count.value * 2
})

// methods:每次调用都执行
function getDouble() {
  console.log('methods 执行')
  return count.value * 2
}
</script>

性能对比实验

我们可以写一个简单的例子,对比两者的性能:

<template>
  <div>
    <p>渲染次数: {{ renderCount }}</p>
    <p>Computed 结果: {{ expensiveComputed }}</p>
    <p>Methods 结果: {{ expensiveMethod() }}</p>
    <button @click="count++">更新 count</button>
    <button @click="forceUpdate++">强制更新</button>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const count = ref(0)
const forceUpdate = ref(0)
const renderCount = ref(0)

// 模拟耗时计算
function expensiveOperation() {
  let result = 0
  for (let i = 0; i < 1000000; i++) {
    result += i
  }
  return result + count.value
}

// computed 版本
const expensiveComputed = computed(() => {
  console.log('耗时计算开始 (computed)')
  const start = performance.now()
  const result = expensiveOperation()
  const end = performance.now()
  console.log(`耗时计算结束,用时: ${(end - start).toFixed(2)}ms`)
  return result
})

// methods 版本
function expensiveMethod() {
  console.log('耗时计算开始 (methods)')
  const start = performance.now()
  const result = expensiveOperation()
  const end = performance.now()
  console.log(`耗时计算结束,用时: ${(end - start).toFixed(2)}ms`)
  return result
}

// 模拟重新渲染
watch(forceUpdate, () => {
  renderCount.value++
})
</script>

上述代码中:

  • 点击"更新 count"(依赖变化):
    • computed:重新计算一次
    • methods:重新计算一次
    • 此时两者的耗时基本一致,没有太大的差别
  • 点击"强制更新"(依赖未变化):
    • computed:使用缓存,不计算
    • methods:不管依赖变不变,每次渲染都重新计算!
    • 这时两者的差别就体现出来了,computed 缓存的性能更好

何时用 computed,何时用 methods

基于以上对比,我们可以得出清晰的选择原则:

  • 基于现有数据衍生出新值:用 computed
  • 事件处理、非响应式计算、需要传参等:用 methods

选择决策树

选择决策树

计算属性的性能陷阱

计算量过大:在 computed 中做复杂计算

computed 虽然会缓存结果,但如果计算本身非常耗时,第一次访问时还是会造成卡顿,因此我们并不推荐在 computed 中做大量复杂的计算:

// ❌ 不好的做法:在 computed 中做大数据处理
const processedData = computed(() => {
  // 假设 data 是一个包含 10 万条记录的数组
  return data.value
    .filter(item => item.active)
    .sort((a, b) => b.value - a.value)
    .map(item => ({
      id: item.id,
      displayName: `${item.name} - ${item.category}`,
      score: item.score * item.weight
    }))
    .reduce((acc, item) => {
      // 复杂的聚合计算
      if (!acc[item.category]) {
        acc[item.category] = []
      }
      acc[item.category].push(item)
      return acc
    }, {})
})

这样当 data 变化时,computed 会重新执行整个复杂计算,可能导致界面卡顿。这种情况,我们一般推荐用多个 computed 去处理,而不是写在一个 computed 中:

const activeItems = computed(() => 
  data.value.filter(item => item.active)
)

const sortedItems = computed(() => 
  [...activeItems.value].sort((a, b) => b.value - a.value)
)

const formattedItems = computed(() => 
  sortedItems.value.map(item => ({
    id: item.id,
    displayName: `${item.name} - ${item.category}`,
    score: item.score * item.weight
  }))
)

const groupedItems = computed(() => 
  formattedItems.value.reduce((acc, item) => {
    if (!acc[item.category]) {
      acc[item.category] = []
    }
    acc[item.category].push(item)
    return acc
  }, {})
)

依赖过多:依赖太细导致频繁重新计算

computed 依赖了太多响应式数据时,任何一个小变化都会导致重新计算:

// ❌ 不好的做法:依赖太多,频繁重新计算
const userProfile = computed(() => {
  return {
    fullName: `${user.value.firstName} ${user.value.lastName}`,
    age: user.value.age,
    email: user.value.email,
    phone: user.value.phone,
    address: `${user.value.city} ${user.value.street}`,
    permissions: user.value.roles.map(r => r.permissions).flat(),
    lastLogin: formatDate(user.value.lastLogin),
    // ... 更多依赖
  }
})

如此一来,computed 几乎每次都会重新计算,丢失了缓存优势。这种情况,也是推荐用多个 computed 去处理:

const basicInfo = computed(() => ({
  fullName: `${user.value.firstName} ${user.value.lastName}`,
  age: user.value.age,
  email: user.value.email
}))

const contactInfo = computed(() => ({
  phone: user.value.phone,
  address: `${user.value.city} ${user.value.street}`
}))

const permissionInfo = computed(() => ({
  roles: user.value.roles,
  permissions: user.value.roles.map(r => r.permissions).flat()
}))

const lastLoginInfo = computed(() => ({
  lastLogin: formatDate(user.value.lastLogin)
}))

副作用问题:computed 中修改数据

computed 中,通常是禁止修改数据的,但缺经常有人这么做,这其实是一个严重的反模式:


// ❌ 绝对禁止:在 computed 中修改数据
const doubleCount = computed(() => {
  count.value++ // 副作用!修改其他响应式数据
  return count.value * 2
})

// ❌ 同样禁止:在 computed 中调用可能修改数据的函数
const userStatus = computed(() => {
  if (!user.value) {
    fetchUser() // 副作用!异步操作
    return 'loading'
  }
  return user.value.status
})

正确做法其实是使用 watch 处理副作用:

watch(user, (newUser) => {
  if (!newUser) {
    fetchUser()
  }
})

const userStatus = computed(() => {
  return user.value?.status || 'loading'
})

为什么不能在 computed 中修改数据呢?

  1. 违反单向数据流:计算属性应该是纯函数,不应该有副作用
  2. 可能导致死循环:修改依赖 -> 触发重新计算 -> 再次修改 -> 无限循环
  3. 不可预测的行为:computed 的求值时机不确定,副作用会导致难以调试的问题

优化策略

拆分计算:一个复杂的 computed 拆成多个小的

这是最常用也最有效的优化策略。通过拆分,我们可以:

  • 减少单个 computed 的计算量
  • 提高缓存命中率
  • 让代码更容易理解
  • 便于单元测试

缓存结果:对于极耗时的计算,使用 cache 模式

有些计算即使拆分后仍然很耗时,这时我们可以考虑手动缓存策略:

// 复杂的数据处理
import { shallowRef, computed } from 'vue'

// 方案1:使用 Map 缓存历史计算结果
const calculationCache = new Map()

const expensiveData = computed(() => {
  const key = JSON.stringify({
    data: rawData.value,
    config: config.value
  })
  
  if (calculationCache.has(key)) {
    console.log('使用缓存结果')
    return calculationCache.get(key)
  }
  
  console.log('执行复杂计算')
  const result = veryExpensiveCalculation(rawData.value, config.value)
  calculationCache.set(key, result)
  
  // 限制缓存大小
  if (calculationCache.size > 100) {
    const firstKey = calculationCache.keys().next().value
    calculationCache.delete(firstKey)
  }
  
  return result
})

// 方案2:使用 LRU 缓存库(如 lru-cache)
import LRU from 'lru-cache'

const cache = new LRU({
  max: 100, // 最多缓存100个结果
  maxAge: 1000 * 60 * 5 // 缓存5分钟
})

const cachedComputation = computed(() => {
  const key = generateKey(dep1.value, dep2.value)
  
  if (cache.has(key)) {
    return cache.get(key)
  }
  
  const result = expensiveComputation(dep1.value, dep2.value)
  cache.set(key, result)
  return result
})

使用 getter 和 setter:双向绑定时控制写操作

computed 默认只有 getter,但也可以提供 setter 来实现双向绑定:

const rawValue = ref(50)

const clampedValue = computed({
  get() {
    return rawValue.value
  },
  set(newValue) {
    // 确保数值在 0-100 之间
    rawValue.value = Math.max(0, Math.min(100, newValue))
  }
})

性能优化总结

  • 拆分大型 computed:将一个大计算拆分为多个小计算
  • 避免在 computed 中修改数据:保持纯函数
  • 减少依赖粒度:只依赖真正需要的数据
  • 使用缓存策略:对极耗时计算实现手动缓存
  • 考虑使用 watch:需要副作用时用 watch 替代

使用原则

应该使用 computed 的场景:

  • 从现有数据派生新数据
  • 需要在模板中多次使用同一个表达式
  • 计算逻辑较复杂,需要命名提高可读性
  • 希望利用缓存避免重复计算

不应该使用 computed 的场景:

  • 需要传参(用 methods)
  • 每次都需要新值(如随机数、时间戳)
  • 有副作用(修改其他数据)
  • 异步操作(用 watch 或 methods)

代码审查要点

  • computed 是否足够"纯"?(没有副作用)
  • 是否可以用 computed 替代 methods?(检查是否在模板中多次调用)
  • computed 的依赖是否都是响应式的?
  • 是否过度拆分?(拆分太多也会增加开销)
  • 计算逻辑是否复杂到需要拆分为多个 computed

结语

computed 的核心价值是缓存,而缓存的核心价值是避免不必要的重复计算。只有深刻理解这一点,才能真正用好 computed,写出高性能的 Vue 应用。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

watch 与 watchEffect:精准监听,避免副作用滥用

前言

在 Vue 应用中,除了计算属性这种衍生状态,我们还需要处理各种副作用:网络请求、DOM 操作、本地存储、定时器等。Vue3 提供了两个强大的 API:watchwatchEffect 来响应式地执行副作用。然而,很多开发者对它们的使用场景和区别认识不清,要么过度使用导致性能问题,要么使用不当导致内存泄漏。

本文将深入剖析 watchwatchEffect 的工作原理、使用场景和优化策略,帮助我们精准监听、高效管理副作用。

watch vs watchEffect:核心区别

watch

watch 的基本概念

watch 的设计理念是精准控制:我们需要明确告诉它需要监听什么,以及当监听的数据发生变化时又需要做什么:

import { ref, watch } from 'vue'

const count = ref(0)
const name = ref('张三')

// 基本用法:监听单个源
watch(count, (newValue, oldValue) => {
  console.log(`count 从 ${oldValue} 变为 ${newValue}`)
})

// 监听响应式对象
watch(name, (newValue, oldValue) => {
  console.log(`name 从 ${oldValue} 变为 ${newValue}`)
})

watch 的核心特点

  • 懒执行:只有在监听源发生变化时才执行,不会立即执行
  • 需要指定源:必须明确告诉它要监听什么
  • 可以访问新旧值:在回调中可以获得数据变化前后的值
  • 可以监听多个源:可以使用数组的形式监听多个源

watchEffect

watchEffect 的基本概念

watchEffect 的设计理念是自动追踪:它会立即执行一次,并且在执行过程中自动收集 所有 响应式依赖,当这些依赖发生变化时重新执行:

import { ref, watchEffect } from 'vue'

const count = ref(0)
const name = ref('张三')

watchEffect(() => {
  // 自动追踪 count 和 name
  console.log(`count: ${count.value}, name: ${name.value}`)
})
// 立即输出: count: 0, name: 张三

// 修改 count,自动重新执行
count.value++ // 输出: count: 1, name: 张三

watchEffect 的核心特点

  • 立即执行:创建时会立即执行一次
  • 自动收集依赖:不需要指定监听源,依赖是自动收集的
  • 无法获取旧值:回调中只有当前值,没有变化前的值
  • 语法更简洁:适合不需要旧值的场景

选择决策树

watch 与 watchEffect选择决策树

watch 的进阶用法

深度监听:deep

当我们需要监听一个对象时,默认情况下只有对象的引用变化才会触发,对象中的属性变化并不会触发监听:

const user = ref({
  name: '张三',
  address: {
    city: '北京'
  }
})

// ❌ 属性变化不会触发
watch(user, () => {
  console.log('user 变化')
})

user.value.name = '李四' // 不会触发

当我们使用 deep配置时,就可以触发深度监听,即:对象中的属性发生改变时也会触发监听:

// ✅ 使用 deep: true 监听所有嵌套属性变化
watch(user, () => {
  console.log('user 变化')
}, { deep: true })

user.value.name = '李四' // 触发
user.value.address.city = '上海' // 触发

deep 的性能分析

  • 深度监听 deep: true:需要递归遍历所有嵌套属性,对大型对象开销较大
  • 监听具体属性:只监听需要的属性,性能更好
  • 使用 computed:可以组合多个属性,但只在这些属性变化时触发

立即执行:immediate

默认情况下,watch 都是懒执行的,但有些场景我们需要在初始化时就执行一次监听,此时就需要用到 immediate 配置:

const userId = ref(1)
const userData = ref(null)

// 会立即执行一次
watch(userId, async (newId) => {
  userData.value = await fetchUser(newId)
}, { immediate: true })

监听多个源:使用数组

当需要监听多个数据源,并且希望在任何一个数据源变化时,都执行同一个回调:

const categoryId = ref('all')
const sortBy = ref('relevance')

// 监听多个源
watch([categoryId, sortBy], () => {
  console.log('筛选条件变化')
})

flush 时机:pre | post | sync 的区别

flush 选项可以控制回调的执行时机,这对 DOM 操作特别重要:

  • pre:默认值,在组件更新前执行,此时无法操作 DOM
  • post:在组件更新后执行,可以访问更新后的 DOM
  • sync:在响应式依赖变化时立即执行(谨慎使用)
import { ref, watch } from 'vue'

const count = ref(0)

// 默认 pre:在组件更新前执行
watch(count, () => {
  console.log('pre: DOM 还未更新')
}, { flush: 'pre' })

// post:在组件更新后执行,可以访问更新后的 DOM
watch(count, () => {
  console.log('post: DOM 已更新')
  // 可以安全地操作更新后的 DOM
}, { flush: 'post' })

// sync:在响应式依赖变化时立即执行(谨慎使用)
watch(count, () => {
  console.log('sync: 立即执行')
}, { flush: 'sync' })

副作用清理:避免内存泄漏

场景:监听路由变化,取消之前的请求

在处理异步操作时,最常见的场景就是竞态条件:当请求发起后,但还没返回结果时,参数又变化了。这时需要取消之前的请求:

import { watch, ref } from 'vue'
import { searchAPI } from './api'

const searchQuery = ref('')
const searchResults = ref([])
const loading = ref(false)

// ❌ 错误:没有处理竞态条件
watch(searchQuery, async (newQuery) => {
  loading.value = true
  const results = await searchAPI(newQuery) // 慢请求
  // 如果此时 query 已经变化,这个结果可能是过时的
  searchResults.value = results
  loading.value = false
})

// ✅ 正确:使用 onCleanup 取消之前的请求
watch(searchQuery, async (newQuery, oldQuery, onCleanup) => {
  const controller = new AbortController()
  
  // 注册清理函数
  onCleanup(() => {
    controller.abort()
    console.log('取消请求:', newQuery)
  })
  
  loading.value = true
  try {
    const results = await searchAPI(newQuery, { 
      signal: controller.signal 
    })
    // 只有请求没有被取消时才更新结果
    searchResults.value = results
  } catch (error) {
    if (error.name === 'AbortError') {
      // 请求被取消,忽略
      console.log('请求已取消')
    } else {
      // 其他错误
      console.error('搜索失败:', error)
    }
  } finally {
    loading.value = false
  }
})

onCleanup 的实现原理

onCleanupwatch 回调的第三个参数,它是一个函数,用来注册清理回调:

// 模拟 onCleanup 的工作原理
function createWatcher(source, callback) {
  let cleanup = null
  
  const registerCleanup = (fn) => {
    cleanup = fn
  }
  
  const runCallback = () => {
    // 执行之前的清理函数
    if (cleanup) {
      cleanup()
    }
    
    // 执行新的回调
    callback(source.value, null, registerCleanup)
  }
  
  // 监听变化
  onSourceChange(runCallback)
}

更多清理场景

清理定时器

const delay = ref(1000)

watch(delay, (newDelay, oldDelay, onCleanup) => {
  const timer = setInterval(() => {
    console.log('定时器执行')
  }, newDelay)
  
  onCleanup(() => {
    clearInterval(timer)
    console.log('定时器已清理')
  })
}, { immediate: true })

取消 WebSocket 连接

const roomId = ref('general')

watch(roomId, (newRoom, oldRoom, onCleanup) => {
  const socket = new WebSocket(`ws://server/${newRoom}`)
  
  socket.onmessage = (event) => {
    // 处理消息
  }
  
  onCleanup(() => {
    socket.close()
    console.log(`离开房间: ${oldRoom}`)
  })
}, { immediate: true })

移除事件监听

const element = ref(null)
const eventType = ref('click')

watch([element, eventType], ([el, type], [oldEl, oldType], onCleanup) => {
  if (!el) return
  
  const handler = (e) => {
    console.log(`事件触发: ${type}`, e)
  }
  
  el.addEventListener(type, handler)
  
  onCleanup(() => {
    el.removeEventListener(type, handler)
    console.log(`移除事件监听: ${type}`)
  })
}, { immediate: true })

性能陷阱与优化

过度监听:监听整个对象 vs 监听具体属性

const filters = ref({
  category: 'all',
  priceRange: [0, 1000],
  inStock: true,
  rating: 0,
  sortBy: 'price',
  keywords: ''
})

watch(filters, () => {
  // 任何 filter 属性变化都会触发 API 调用
  fetchProducts(filters.value)
}, { deep: true })
// 修改一个属性就调用一次 API,可能过于频繁

优化方案:监听特定属性

const fetchTrigger = computed(() => ({
  category: filters.value.category,
  priceRange: filters.value.priceRange,
  inStock: filters.value.inStock
}))

watch(fetchTrigger, () => {
  // 只有这三个相关属性变化才触发
  fetchProducts(filters.value)
})

使用 debounce 进一步优化

import { debounce } from 'lodash-es'

const debouncedFetch = debounce((filters) => {
  fetchProducts(filters)
}, 300)

watch(filters, () => {
  debouncedFetch(filters.value)
}, { deep: true })

频繁触发:使用 throttle 和 debounce

场景1:搜索输入 - 使用 debounce

const debouncedSearch = debounce((query) => {
  console.log('执行搜索:', query)
}, 300)

watch(searchInput, (newValue) => {
  debouncedSearch(newValue)
})

场景2:滚动位置 - 使用 throttle

const scrollPosition = ref(0)

const throttledSave = throttle((position) => {
  localStorage.setItem('scrollPosition', position)
}, 1000)

watch(scrollPosition, (newPos) => {
  throttledSave(newPos)
})

实战:实现一个可取消的异步请求监听器

完整实现

// composables/useCancellableWatch.js
import { watch } from 'vue'

export function useCancellableWatch(source, asyncFn, options = {}) {
  const { immediate = false, debounce: debounceMs = 0, onError } = options
  
  let controller = new AbortController()
  let timeoutId = null
  
  const wrappedAsyncFn = (value) => {
    // 取消之前的请求
    controller.abort()
    controller = new AbortController()
    
    // 执行新的异步函数
    asyncFn(value, controller.signal).catch(error => {
      if (error.name !== 'AbortError' && onError) {
        onError(error)
      }
    })
  }
  
  const handler = (value) => {
    if (timeoutId) {
      clearTimeout(timeoutId)
    }
    
    if (debounceMs > 0) {
      timeoutId = setTimeout(() => wrappedAsyncFn(value), debounceMs)
    } else {
      wrappedAsyncFn(value)
    }
  }
  
  // 创建监听
  const stop = watch(source, handler, { immediate })
  
  // 返回停止函数
  return () => {
    stop()
    controller.abort()
    if (timeoutId) {
      clearTimeout(timeoutId)
    }
  }
}

在组件中使用

<template>
  <div class="search-container">
    <input 
      v-model="query" 
      placeholder="搜索..."
      @input="handleInput"
    />
    <span class="loading" v-if="loading">搜索中...</span>
    
    <div class="results">
      <div v-for="item in results" :key="item.id">
        {{ item.title }}
      </div>
    </div>
    
    <div v-if="error" class="error">
      出错了: {{ error.message }}
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useCancellableWatch } from './composables/useCancellableWatch'

const query = ref('')
const results = ref([])
const loading = ref(false)
const error = ref(null)

// 模拟搜索 API
async function searchAPI(query, signal) {
  loading.value = true
  error.value = null
  
  try {
    // 模拟网络请求
    await new Promise(resolve => setTimeout(resolve, 1000))
    
    // 检查是否被取消
    if (signal.aborted) {
      throw new DOMException('Aborted', 'AbortError')
    }
    
    // 模拟返回结果
    const mockResults = [
      { id: 1, title: `${query} 结果1` },
      { id: 2, title: `${query} 结果2` },
      { id: 3, title: `${query} 结果3` }
    ]
    
    results.value = mockResults
  } finally {
    loading.value = false
  }
}

// 使用我们的自定义监听器
const stopWatch = useCancellableWatch(
  query,
  async (value, signal) => {
    if (value.length < 2) {
      results.value = []
      return
    }
    await searchAPI(value, signal)
  },
  {
    immediate: false,
    debounce: 300,
    onError: (err) => {
      if (err.name !== 'AbortError') {
        error.value = err
      }
    }
  }
)

// 组件卸载时自动清理
onUnmounted(() => {
  stopWatch()
})
</script>

决策指南

需求 推荐方案 原因
需要访问新旧值 watch watch 提供新旧值参数
需要立即执行一次 watch + immediate: truewatchEffect 两者皆可,看是否需要旧值
只需要知道变化了 watchEffect 语法更简洁
监听多个相关源 watch 数组形式 可以一起处理,也可以分别处理
需要操作更新后的 DOM watch + flush: post 确保 DOM 已更新
需要取消异步操作 watch + onCleanup 提供专门的清理机制
监听对象内部属性变化 watch + 函数返回具体属性 避免 deep: true 的性能开销

结语

watch 用于精确控制,watchEffect 用于自动追踪。开发中需要选择哪个,取决于我们的具体需求:需要细粒度控制就用 watch,想要简洁的自动追踪就用 watchEffect。理解它们的本质区别,就能在合适的场景做出正确的选择。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

v-model 的进阶用法:搞定复杂的父子组件数据通信

前言

在 Vue 开发中,父子组件之间的数据通信是一个核心话题。v-model 作为 Vue 的双向绑定指令,看似简单,实则蕴含着强大的表达能力。很多开发者对 v-model 的理解停留在"表单输入绑定"的层面,殊不知它早已进化为处理复杂父子组件通信的利器。

本文将深入剖析 v-model 的本质,从基础用法到进阶技巧,再到实战案例,帮助我们掌握这一强大的通信工具。

v-model 的本质

语法糖::modelValue + @update:modelValue

v-model 的本质其实是一个语法糖。在 Vue3 中,下面这两种写法是完全等价的:

<!-- 这种写法 -->
<ChildComponent v-model="parentData" />

<!-- 等价于这种写法 -->
<ChildComponent 
  :modelValue="parentData" 
  @update:modelValue="parentData = $event" 
/>

双向绑定的实现原理

v-model 实现双向绑定的核心是 Props 向下传递,Events 向上传递: 双向绑定的实现原理

双向绑定的具体流程

  1. 父组件通过 :modelValue 将数据传递给子组件
  2. 子组件通过 props.modelValue 接收数据并展示
  3. 当子组件内部需要修改数据时,通过 emit('update:modelValue', newValue) 通知父组件
  4. 父组件监听到事件后更新自己的数据
  5. 父组件数据更新后,再次通过 Props 传递给子组件,完成闭环

从 Vue2 的 v-bind.sync 到 Vu3 的 v-model

如果我们想在 Vue2 中处理多个双向绑定需要使用 .sync 修饰符:

<!-- Vue 2 中的 .sync -->
<ChildComponent 
  :name.sync="userName"
  :age.sync="userAge"
/>
<!-- 等价于 -->
<ChildComponent 
  :name="userName" 
  @update:name="userName = $event"
  :age="userAge" 
  @update:age="userAge = $event"
/>

而在Vue 3 统一为 v-model 语法,更加直观:

<!-- Vue 3 中的多 v-model -->
<ChildComponent
  v-model:name="userName"
  v-model:age="userAge"
/>

v-model 基础用法回顾

自定义组件支持 v-model

如果要让一个自定义组件支持 v-model,需要做两件事:

  1. 接收 modelValue :默认名称
  2. 当值变化时,触发 update:modelValue 事件
<!-- 自定义输入框组件 CustomInput.vue -->
<template>
  <div class="custom-input">
    <input
      :value="modelValue"
      @input="handleInput"
    />
  </div>
</template>

<script setup lang="ts">
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

function handleInput(e: Event) {
  const value = (e.target as HTMLInputElement).value
  emit('update:modelValue', value)
}
</script>

<!-- 使用方式 -->
<template>
  <CustomInput 
    v-model="searchText"
  />
</template>

默认 prop 和事件名称

v-model 的默认配置:

  • Prop 名称:modelValue
  • 事件名称:update:modelValue

当然,我们也可以通过修改 v-model 的参数来改变这些名称:

<!-- 指定参数名 -->
<ChildComponent v-model:title="pageTitle" />

<!-- 等价于 -->
<ChildComponent 
  :title="pageTitle" 
  @update:title="pageTitle = $event"
/>

多个 v-model 绑定

场景:一个组件需要双向绑定多个值

想象一下:在用户表单组件中,我们需要同时绑定姓名、年龄、邮箱等多个值:

<!-- 父组件 -->
<template>
  <UserForm
    v-model:name="userName"
    v-model:age="userAge"
    v-model:email="userEmail"
    @submit="handleSubmit"
  />
</template>

<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'

const userName = ref('张三')
const userAge = ref(25)
const userEmail = ref('zhangsan@example.com')

function handleSubmit() {
  console.log('提交表单', {
    name: userName.value,
    age: userAge.value,
    email: userEmail.value
  })
}
</script>

实现:指定不同的参数名

<!-- UserForm.vue -->
<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-group">
      <label>姓名</label>
      <input
        :value="name"
        @input="$emit('update:name', $event.target.value)"
      />
    </div>
    
    <div class="form-group">
      <label>年龄</label>
      <input
        type="number"
        :value="age"
        @input="$emit('update:age', Number($event.target.value))"
      />
    </div>
    
    <div class="form-group">
      <label>邮箱</label>
      <input
        :value="email"
        @input="$emit('update:email', $event.target.value)"
        type="email"
      />
    </div>
    
    <button type="submit">提交</button>
  </form>
</template>

<script setup lang="ts">
defineProps<{
  name: string
  age: number
  email: string
}>()

const emit = defineEmits<{
  'update:name': [value: string]
  'update:age': [value: number]
  'update:email': [value: string]
  'submit': []
}>()

function handleSubmit() {
  emit('submit')
}
</script>

复杂数据结构的双向绑定

除了简单的基础类型数据的双向绑定外,有时候我们也需要双向绑定一个复杂对象:

<template>
  <AddressEditor v-model:address="userAddress" />
</template>

<script setup>
import { ref } from 'vue'

interface Address {
  province: string
  city: string
  district: string
  detail: string
  zipCode?: string
}

const userAddress = ref<Address>({
  province: '广东省',
  city: '深圳市',
  district: '南山区',
  detail: '科技园路1号'
})
</script>

这其实相当于:

<template>
  <div class="address-editor">
    <div class="address-item">
      <label>省份</label>
      <input
        :value="address.province"
        @input="updateAddress('province', $event.target.value)"
      />
    </div>
    
    <div class="address-item">
      <label>城市</label>
      <input
        :value="address.city"
        @input="updateAddress('city', $event.target.value)"
      />
    </div>
    
    <div class="address-item">
      <label>区县</label>
      <input
        :value="address.district"
        @input="updateAddress('district', $event.target.value)"
      />
    </div>
    
    <div class="address-item">
      <label>详细地址</label>
      <input
        :value="address.detail"
        @input="updateAddress('detail', $event.target.value)"
      />
    </div>
    
    <div class="address-item">
      <label>邮编</label>
      <input
        :value="address.zipCode"
        @input="updateAddress('zipCode', $event.target.value)"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
interface Address {
  province: string
  city: string
  district: string
  detail: string
  zipCode?: string
}

const props = defineProps<{
  address: Address
}>()

const emit = defineEmits<{
  'update:address': [value: Address]
}>()

function updateAddress<K extends keyof Address>(key: K, value: Address[K]) {
  emit('update:address', {
    ...props.address,
    [key]: value
  })
}
</script>

自定义 v-model 修饰符

内置修饰符的作用

修饰符 作用 适用场景
.trim 自动过滤用户输入的首尾空白字符 用户名、留言内容等不需要首尾空格的文本输入
.number 将用户输入自动转换为数值类型 年龄、数量等数字类型的输入
.lazy 将默认的 input 事件改为 change 事件触发同步 减少频繁更新,适合评论框等场景

内置修饰符的处理

在自定义组件中需要手动处理这些修饰符:

<template>
  <CustomInput 
    v-model.trim="text"     <!-- 自动去除首尾空格 -->
    v-model.number="age"    <!-- 自动转换为数字类型 -->
    v-model.lazy="comment"  <!-- 失焦后才更新 -->
  />
</template>

在自定义组件中处理这些修饰符

<!-- CustomInput.vue -->
<template>
  <input
    :value="modelValue"
    @input="handleInput"
    @change="handleChange"
  />
</template>

<script setup>
const props = defineProps<{
  modelValue: string | number
  modelModifiers?: {
    trim?: boolean
    number?: boolean
    lazy?: boolean
  }
}>()

const emit = defineEmits(['update:modelValue'])

function handleInput(e: Event) {
  if (props.modelModifiers?.lazy) {
    // lazy 模式下,只在 change 事件触发
    return
  }
  
  let value = (e.target as HTMLInputElement).value
  
  // 处理 trim 修饰符
  if (props.modelModifiers?.trim) {
    value = value.trim()
  }
  
  // 处理 number 修饰符
  if (props.modelModifiers?.number) {
    const num = parseFloat(value)
    value = isNaN(num) ? value : num
  }
  
  emit('update:modelValue', value)
}

function handleChange(e: Event) {
  if (!props.modelModifiers?.lazy) {
    return
  }
  
  let value = (e.target as HTMLInputElement).value
  
  if (props.modelModifiers?.trim) {
    value = value.trim()
  }
  
  if (props.modelModifiers?.number) {
    const num = parseFloat(value)
    value = isNaN(num) ? value : num
  }
  
  emit('update:modelValue', value)
}
</script>

常见陷阱与解决方案

不要直接修改 props

这是新手最常见的错误:

<!-- ❌ 错误:直接修改 props -->
<template>
  <input v-model="modelValue" />
</template>

<script setup>
defineProps<{
  modelValue: string
}>()
</script>

解决方案:通过事件通知父组件

<template>
  <input 
    :value="modelValue" 
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>

处理非字符串类型的 v-model

对于数字、布尔值等类型,我们在使用时需要特别注意类型转换:

<template>
  <!-- ✅ 正确处理数字类型 -->
  <input
    type="number"
    :value="modelValue"
    @input="handleNumberInput"
  />
</template>

<script setup>
const props = defineProps<{
  modelValue: number
}>()

const emit = defineEmits(['update:modelValue'])

function handleNumberInput(e: Event) {
  const value = (e.target as HTMLInputElement).value
  // 转换为数字,处理空值
  const num = value === '' ? 0 : Number(value)
  emit('update:modelValue', num)
}
</script>

v-model 与响应式数据的配合

当使用对象作为 v-model 的值时,一定注意响应式丢失的问题:

<template>
  <!-- 这种情况没问题 -->
  <ChildComponent v-model="user" />
  
  <!-- 但这种情况会导致响应式丢失! -->
  <ChildComponent 
    v-model="user.name" 
    v-model="user.age"
  />
</template>

<script setup>
import { reactive } from 'vue'

const user = reactive({
  name: '张三',
  age: 25
})
// ❌ 这样使用 v-model 会破坏响应式
</script>

解决方案:使用 ref

<script setup>
import { ref } from 'vue'

const user = ref({
  name: '张三',
  age: 25
})
</script>

<template>
  <ChildComponent 
    v-model:name="user.value.name" 
    v-model:age="user.value.age"
  />
</template>

处理异步更新

有时需要在值变化后执行某些操作,但需要注意 Vue 的异步更新机制:

<script setup>
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits(['update:modelValue'])

function handleInput(e: Event) {
  const value = e.target.value
  emit('update:modelValue', value)
  
  // ❌ 这里的 props.modelValue 还是旧值
  console.log(props.modelValue) 
  
  // ✅ 使用 nextTick 获取更新后的值
  import { nextTick } from 'vue'
  nextTick(() => {
    console.log(props.modelValue) // 现在是最新值
  })
}
</script>

最佳实践清单

  • 优先使用多个 v-model 而不是一个包含多个字段的对象
  • 为所有 v-model 定义 TypeScript 类型,包括修饰符
  • 不要直接修改 props,始终通过事件更新
  • 处理非字符串类型时做好类型转换
  • 提供合理的默认值和空状态处理
  • 考虑使用计算属性实现复杂的转换逻辑
  • 为组件暴露 reset 等方法,方便父组件控制
  • 使用 v-model 修饰符实现可复用的输入处理逻辑

结语

好的组件设计应该是使用者友好型。当我们设计的组件让其他开发者或使用者,只需要写 v-model 就能完成复杂的双向绑定,那我们就真正掌握了 v-model 的精髓。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

TypeScript 深度加持:让你的组合式函数拥有“钢筋铁骨”

前言

在 JavaScript 的世界里,自由往往伴随着风险。当你写下一个函数,一个月后回来修改时,你还记得它接受什么参数、返回什么值吗?当团队成员接手你的代码时,他们需要花多少时间去理解函数的使用方式?

TypeScript 的出现改变了这一切。特别是当它与 Vue3 的组合式函数相结合时,TypeScript 不再是可选项,而是构建可靠、可维护应用的必备工具。本文将深入探讨如何为组合式函数添加 TypeScript 支持,让它们从“手工作坊”升级为“工业标准”。

TypeScript 为什么要深度集成?

开发时智能提示:再也不用翻文档

没有 TypeScript 的组合式函数,就像一本没有目录的书:

// 纯 JavaScript 版本
export function useUser() {
  // 这个函数返回什么?怎么用?
  // 只能去看源码或者猜
}

// 使用时
const user = useUser()
// user 里有什么?不知道

有了 TypeScript,一切变得清晰明了:

// TypeScript 版本
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user' | 'guest'
}

interface UseUserReturn {
  user: Ref<User | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
  fetchUser: (id: number) => Promise<void>
  updateUser: (data: Partial<User>) => Promise<void>
}

export function useUser(): UseUserReturn {
  // 实现...
}

// 使用时,编辑器会提供完美的智能提示
const { user, loading, fetchUser } = useUser()
// 鼠标悬停在 fetchUser 上,就能看到参数类型
fetchUser(123) // ✅ 正确
fetchUser('abc') // ❌ TypeScript 报错:类型错误

重构时的信心保证:改一处,TypeScript 帮你检查所有使用处

这是 TypeScript 最强大的特性之一。当我们需要修改一个组合式函数的返回类型时,TypeScript 会帮我们找到所有受影响的地方:

// 假设有一个 usePagination 组合式函数
function usePagination(initialPage = 1) {
  const page = ref(initialPage)
  const pageSize = ref(10)
  const total = ref(0)
  
  return { page, pageSize, total }
}

// 现在需要重构,将返回值改为响应式对象
function usePagination(initialPage = 1) {
  const state = reactive({
    page: initialPage,
    pageSize: 10,
    total: 0
  })
  
  return { state } // 返回方式改变了
}

// TypeScript 会立即标记所有使用了 page.value 的地方
const { state } = usePagination()
// ❌ 错误:page 不存在于返回值中
// 必须改为 state.page

这种“编译时检查”的特性,让我们在进行大规模重构时,不用担心遗漏任何使用之处。

运行时错误左移:在编译阶段发现潜在 bug

JavaScript 的错误往往在运行时才暴露,而 TypeScript 能在代码运行前就发现问题:

// ❌ JavaScript:运行时才报错
function processUser(user) {
  return user.name.toUpperCase() // 如果 user 是 null,这里会崩溃
}

// ✅ TypeScript:编译时就能发现问题
function processUser(user: User | null) {
  // ❌ 编译错误:对象可能为 null
  return user.name.toUpperCase() 
  
  // ✅ 正确处理
  return user?.name.toUpperCase() ?? ''
}

常见的 TypeScript 错误

错误1:拼写错误

const user = useUser()
user.nmae // ❌ 编译错误:属性 'nmae' 不存在于类型 'User'

错误2:类型不匹配

function updateProduct(id: number) { /* ... */ }
updateProduct('abc') // ❌ 编译错误:不能将 string 赋值给 number

错误3:忘记处理 undefined

const products = ref<Product[]>([])
const firstProduct = products.value[0]
console.log(firstProduct.price) // ❌ 编译错误:对象可能为 undefined

错误4:错误的参数个数

function fetchData(id: number, options?: FetchOptions) { /* ... */ }
fetchData(123, { cache: true }, 'extra') // ❌ 编译错误:参数过多

组合式函数的基础类型定义

为 ref 和 reactive 定义类型

Vue3 的响应式 API 与 TypeScript 配合得天衣无缝:

import { ref, reactive, computed } from 'vue'

// ref 的类型推导
const count = ref(0) // Ref<number>
const name = ref('') // Ref<string>
const isActive = ref(false) // Ref<boolean>

// 显式定义 ref 类型
const user = ref<User | null>(null) // Ref<User | null>

// 数组类型
const items = ref<Item[]>([]) // Ref<Item[]>

// reactive 的类型推导
const state = reactive({
  count: 0,
  name: '张三'
}) // { count: number; name: string }

// 显式定义 reactive 类型
interface FormState {
  username: string
  password: string
  remember: boolean
}

const form = reactive<FormState>({
  username: '',
  password: '',
  remember: false
})

// computed 的类型
const double = computed(() => count.value * 2) // ComputedRef<number>

注:基础数据类型,TypeScript 可以自行推导,因此不建议显示定义基础数据类型: const count = ref<number>(0) // ❌ 不建议这样写 const count = ref(0) // ✅

为函数的参数和返回值定义接口

这是组合式函数类型定义的核心,一个好的类型定义应该清晰地表达:

  • 函数接受什么参数
  • 函数返回什么
  • 各种边界情况
interface UseCounterOptions {
  initialValue?: number
  min?: number
  max?: number
  step?: number
}

interface UseCounterReturn {
  count: Ref<number>
  increment: (step?: number) => void
  decrement: (step?: number) => void
  reset: () => void
  set: (value: number) => void
}

export function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
  const { 
    initialValue = 0, 
    min = -Infinity, 
    max = Infinity, 
    step = 1 
  } = options
  
  const count = ref(clamp(initialValue, min, max))
  
  function increment(stepSize = step) {
    const newValue = count.value + stepSize
    if (newValue <= max) {
      count.value = newValue
    }
  }
  
  // 其他方法...
  
  return {
    count,
    increment,
    decrement,
    reset: () => { count.value = clamp(initialValue, min, max) },
    set: (value) => { count.value = clamp(value, min, max) }
  }
}

实战:为 useMousePosition 定义完善的类型

我们来看一个完整的实战案例,展示如何为真实的组合式函数添加类型:

// composables/useMousePosition.ts
import { ref, onMounted, onUnmounted } from 'vue'

// 1. 定义位置接口
export interface MousePosition {
  x: number
  y: number
  timestamp: number
}

// 2. 定义配置选项
export interface UseMousePositionOptions {
  /**
   * 节流时间(毫秒),默认 0 表示不节流
   */
  throttle?: number
  
  /**
   * 监听的目标元素,默认 window
   */
  target?: HTMLElement | null | (() => HTMLElement | null)
  
  /**
   * 是否立即开始监听,默认 true
   */
  immediate?: boolean
  
  /**
   * 坐标类型,默认 'client'
   */
  type?: 'client' | 'page' | 'screen'
}

// 3. 定义返回值类型
export interface UseMousePositionReturn {
  /**
   * 当前鼠标位置
   */
  position: Ref<MousePosition>
  
  /**
   * 是否正在监听
   */
  isListening: Ref<boolean>
  
  /**
   * 开始监听
   */
  start: () => void
  
  /**
   * 停止监听
   */
  stop: () => void
  
  /**
   * 重置位置为 (0, 0)
   */
  reset: () => void
}

// 4. 工具函数:获取坐标
function getMousePosition(event: MouseEvent, type: 'client' | 'page' | 'screen'): MousePosition {
  const timestamp = Date.now()
  
  switch (type) {
    case 'client':
      return { x: event.clientX, y: event.clientY, timestamp }
    case 'page':
      return { x: event.pageX, y: event.pageY, timestamp }
    case 'screen':
      return { x: event.screenX, y: event.screenY, timestamp }
  }
}

// 5. 主函数实现
export function useMousePosition(options: UseMousePositionOptions = {}): UseMousePositionReturn {
  const {
    throttle = 0,
    target = window,
    immediate = true,
    type = 'client'
  } = options

  // 创建响应式状态
  const position = ref<MousePosition>({ x: 0, y: 0, timestamp: 0 })
  const isListening = ref(false)
  
  // 获取目标元素
  const getTarget = (): EventTarget | null => {
    if (typeof target === 'function') {
      return target()
    }
    return target
  }
  
  // 节流控制
  let lastRun = 0
  let rafId: number | null = null
  
  // 鼠标移动处理函数
  const handleMouseMove = (event: MouseEvent) => {
    const now = Date.now()
    
    // 节流处理
    if (throttle > 0 && now - lastRun < throttle) {
      return
    }
    
    // 使用 requestAnimationFrame 优化性能
    if (rafId !== null) {
      cancelAnimationFrame(rafId)
    }
    
    rafId = requestAnimationFrame(() => {
      position.value = getMousePosition(event, type)
      lastRun = now
      rafId = null
    })
  }
  
  // 开始监听
  const start = () => {
    if (isListening.value) return
    
    const targetEl = getTarget()
    if (targetEl) {
      targetEl.addEventListener('mousemove', handleMouseMove)
      isListening.value = true
    }
  }
  
  // 停止监听
  const stop = () => {
    const targetEl = getTarget()
    if (targetEl) {
      targetEl.removeEventListener('mousemove', handleMouseMove)
    }
    
    if (rafId !== null) {
      cancelAnimationFrame(rafId)
      rafId = null
    }
    
    isListening.value = false
  }
  
  // 重置位置
  const reset = () => {
    position.value = { x: 0, y: 0, timestamp: 0 }
  }
  
  // 自动开始监听
  if (immediate) {
    onMounted(() => {
      start()
    })
  }
  
  // 清理
  onUnmounted(() => {
    stop()
  })
  
  return {
    position,
    isListening,
    start,
    stop,
    reset
  }
}

泛型约束:让复用更灵活

场景:实现一个通用的 useLocalStorage

没有泛型之前,我们可能会写出这样的代码:

// ❌ 不够通用,只能处理 string
function useLocalStorage(key: string, initialValue: string) {
  const value = ref(initialValue)
  
  onMounted(() => {
    const stored = localStorage.getItem(key)
    if (stored !== null) {
      value.value = stored
    }
  })
  
  watch(value, (newValue) => {
    localStorage.setItem(key, newValue)
  })
  
  return value
}

// 想存储数字?不行
const count = useLocalStorage('count', 0) // 类型错误!

解决方案:使用泛型约束

// ✅ 使用泛型,支持任意可序列化的类型
function useLocalStorage<T>(key: string, initialValue: T) {
  // 指定 ref 的类型为 T
  const value = ref<T>(initialValue) as Ref<T>
  
  onMounted(() => {
    try {
      const stored = localStorage.getItem(key)
      if (stored !== null) {
        // 反序列化,并确保类型正确
        value.value = JSON.parse(stored) as T
      }
    } catch (e) {
      console.error(`Failed to parse localStorage key "${key}":`, e)
    }
  })
  
  watch(value, (newValue) => {
    try {
      localStorage.setItem(key, JSON.stringify(newValue))
    } catch (e) {
      console.error(`Failed to stringify value for key "${key}":`, e)
    }
  }, { deep: true })
  
  return value
}

// 现在可以存储任意类型
const count = useLocalStorage('count', 0) // Ref<number>
const user = useLocalStorage('user', { name: '张三' }) // Ref<{ name: string }>
const items = useLocalStorage('items', [1, 2, 3]) // Ref<number[]>

进阶:添加类型约束和默认值处理

// 定义可序列化类型的约束
type Serializable = 
  | string 
  | number 
  | boolean 
  | null 
  | undefined
  | Serializable[]
  | { [key: string]: Serializable }

// 扩展选项
interface UseStorageOptions<T> {
  /**
   * 存储类型,默认 localStorage
   */
  storage?: 'local' | 'session'
  
  /**
   * 序列化函数
   */
  serializer?: {
    read: (raw: string) => T
    write: (value: T) => string
  }
  
  /**
   * 监听深度
   */
  deep?: boolean
  
  /**
   * 错误处理
   */
  onError?: (error: Error) => void
}

// 增强版的 useStorage
export function useStorage<T extends Serializable>(
  key: string,
  initialValue: T,
  options: UseStorageOptions<T> = {}
): Ref<T> {
  const {
    storage = 'local',
    deep = true,
    onError = (e) => console.error(`Storage error: ${e}`)
  } = options
  
  // 默认使用 JSON 序列化
  const serializer = options.serializer ?? {
    read: (raw: string) => JSON.parse(raw) as T,
    write: (value: T) => JSON.stringify(value)
  }
  
  const storageObj = storage === 'local' ? localStorage : sessionStorage
  const value = ref<T>(initialValue) as Ref<T>
  
  // 读取存储的值
  try {
    const raw = storageObj.getItem(key)
    if (raw !== null) {
      value.value = serializer.read(raw)
    } else {
      // 初始化存储
      storageObj.setItem(key, serializer.write(initialValue))
    }
  } catch (e) {
    onError(e as Error)
  }
  
  // 监听变化
  watch(value, (newValue) => {
    try {
      storageObj.setItem(key, serializer.write(newValue))
    } catch (e) {
      onError(e as Error)
    }
  }, { deep })
  
  return value
}

// 使用示例
const settings = useStorage('settings', {
  theme: 'dark',
  fontSize: 14,
  notifications: true
})

// 类型安全
settings.value.theme = 'light' // ✅
settings.value.theme = 123 // ❌ 类型错误

// 自定义序列化
const dates = useStorage('dates', [new Date()], {
  serializer: {
    read: (raw) => JSON.parse(raw).map((d: string) => new Date(d)),
    write: (value) => JSON.stringify(value.map(d => d.toISOString()))
  }
})

实战:useAsyncData 的泛型设计

// 定义异步操作的状态
interface AsyncState<T> {
  data: T | null
  loading: boolean
  error: Error | null
}

// 定义返回值类型
interface UseAsyncDataReturn<T> {
  data: Ref<T | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
  execute: (...args: any[]) => Promise<T>
  refresh: () => Promise<T>
}

// 带泛型的异步数据获取组合式函数
export function useAsyncData<T>(
  fetcher: (...args: any[]) => Promise<T>,
  options: {
    immediate?: boolean
    initialData?: T | null
    onSuccess?: (data: T) => void
    onError?: (error: Error) => void
  } = {}
): UseAsyncDataReturn<T> {
  const data = ref<T | null>(options.initialData ?? null)
  const loading = ref(false)
  const error = ref<Error | null>(null)
  
  const execute = async (...args: any[]): Promise<T> => {
    loading.value = true
    error.value = null
    
    try {
      const result = await fetcher(...args)
      data.value = result
      options.onSuccess?.(result)
      return result
    } catch (e) {
      const err = e instanceof Error ? e : new Error(String(e))
      error.value = err
      options.onError?.(err)
      throw err
    } finally {
      loading.value = false
    }
  }
  
  const refresh = () => execute()
  
  if (options.immediate !== false) {
    execute()
  }
  
  return {
    data,
    loading,
    error,
    execute,
    refresh
  }
}

// 使用示例
interface User {
  id: number
  name: string
  email: string
}

const { data, loading, error } = useAsyncData<User>(
  () => fetch('/api/user').then(r => r.json())
)

// TypeScript 知道 data 是 User | null
if (data.value) {
  console.log(data.value.name) // ✅ 类型安全
}

类型推导的艺术:何时自动推导,何时显式注解?

自动推导的场景

TypeScript 的类型推导非常智能,很多情况下不需要显式注解:

简单值可以自动推导

const count = ref(0) // Ref<number>
const name = ref('') // Ref<string>
const isActive = ref(false) // Ref<boolean>

对象字面量可以推导

const user = ref({
  name: '张三',
  age: 25
}) // Ref<{ name: string; age: number }>

函数返回值可以推导

function useCounter() {
  const count = ref(0)
  const increment = () => count.value++
  return { count, increment } // { count: Ref<number>; increment: () => void }
}

computed 可以推导

const double = computed(() => count.value * 2) // ComputedRef<number>

需要显式注解的场景

有些场景必须显式注解,否则类型会不正确:

空数组无法推导元素类型

const items = ref<Item[]>([]) 

null 初始值无法推导

const user = ref<User | null>(null)

复杂嵌套对象,类型太长

interface AppState {
  user: { name: string; age: number }
  settings: { theme: string }
}
const state = reactive<AppState>({ ... })

导出给外部使用的 API

export function useFeature(): FeatureReturn {
  // 明确告诉使用者返回什么
  return { ... }
}

类型推导原则

原则1:内部实现多用推导,外部接口显式注解

function useInternal() {
  // 内部实现,让 TypeScript 自己推导
  const count = ref(0)
  const double = computed(() => count.value * 2)
  return { count, double }
}

export function usePublic(): PublicAPI {
  // 导出的 API 显式注解
  const { count, double } = useInternal()
  return { count, double }
}

原则2:复杂类型提取为接口

interface User {
  name: string
  age: number
}

interface UpdateUserData {
  name?: string
  age?: number
}

function useUser() {
  const user = ref<User>({ name: '张三', age: 25 })
  const updateUser = (data: UpdateUserData) => {
    Object.assign(user.value, data)
  }
  return { user, updateUser }
}

原则3:使用 satisfies 确保类型正确(TS 4.9+)

const routes = {
  home: { path: '/', component: Home },
  about: { path: '/about', component: About }
} satisfies Record<string, Route>

原则4:使用 const 断言锁定字面量类型

const user = {
  name: '张三',
  role: 'admin'
} as const

高级技巧:类型守卫与类型收窄

使用自定义类型守卫处理异步数据的不同状态

在处理异步数据时,我们经常需要根据状态执行不同的逻辑:

// 定义三种状态类型
interface IdleState {
  status: 'idle'
}

interface LoadingState {
  status: 'loading'
}

interface SuccessState<T> {
  status: 'success'
  data: T
}

interface ErrorState {
  status: 'error'
  error: Error
}

// 联合类型
type AsyncState<T> = 
  | IdleState 
  | LoadingState 
  | SuccessState<T> 
  | ErrorState

// 组合式函数
function useAsyncState<T>(fetcher: () => Promise<T>) {
  const state = ref<AsyncState<T>>({ status: 'idle' })
  
  const execute = async () => {
    state.value = { status: 'loading' }
    
    try {
      const data = await fetcher()
      state.value = { status: 'success', data }
    } catch (e) {
      state.value = { 
        status: 'error', 
        error: e instanceof Error ? e : new Error(String(e))
      }
    }
  }
  
  return {
    state: readonly(state),
    execute
  }
}

// 类型守卫
function isIdle<T>(state: AsyncState<T>): state is IdleState {
  return state.status === 'idle'
}

function isLoading<T>(state: AsyncState<T>): state is LoadingState {
  return state.status === 'loading'
}

function isSuccess<T>(state: AsyncState<T>): state is SuccessState<T> {
  return state.status === 'success'
}

function isError<T>(state: AsyncState<T>): state is ErrorState {
  return state.status === 'error'
}

在组件中使用类型守卫

<template>
  <div>
    <div v-if="isLoading(state)">加载中...</div>
    
    <div v-else-if="isError(state)" class="error">
      错误: {{ state.error.message }}
      <button @click="retry">重试</button>
    </div>
    
    <div v-else-if="isSuccess(state)" class="data">
      <!-- 这里 state.data 的类型是 T -->
      <pre>{{ state.data }}</pre>
    </div>
    
    <div v-else-if="isIdle(state)">
      <button @click="execute">开始加载</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useAsyncState, isSuccess, isError, isLoading, isIdle } from './composables/useAsyncState'

interface UserData {
  id: number
  name: string
}

const { state, execute } = useAsyncState<UserData>(async () => {
  const res = await fetch('/api/user')
  return res.json()
})

// 类型守卫让 TypeScript 能够收窄类型
watch(state, (newState) => {
  if (isSuccess(newState)) {
    // 这里 TypeScript 知道 newState 是 SuccessState<UserData>
    console.log('用户数据:', newState.data.name)
  } else if (isError(newState)) {
    // 这里知道是 ErrorState
    console.error('错误:', newState.error.message)
  }
})

function retry() {
  if (isError(state.value)) {
    // 只有在错误状态下才能看到错误详情
    console.log('重试,之前的错误:', state.value.error)
    execute()
  }
}
</script>

使用判别式联合类型实现状态机

// 更复杂的异步操作状态机
interface PendingState {
  status: 'pending'
}

interface LoadingState {
  status: 'loading'
  progress?: number
}

interface SuccessState<T> {
  status: 'success'
  data: T
  timestamp: number
}

interface ErrorState {
  status: 'error'
  error: Error
  retryCount: number
}

interface CancelledState {
  status: 'cancelled'
  reason?: string
}

type RequestState<T> = 
  | PendingState
  | LoadingState
  | SuccessState<T>
  | ErrorState
  | CancelledState

// 类型守卫函数可以自动生成
const guards = {
  isPending: <T>(s: RequestState<T>): s is PendingState => s.status === 'pending',
  isLoading: <T>(s: RequestState<T>): s is LoadingState => s.status === 'loading',
  isSuccess: <T>(s: RequestState<T>): s is SuccessState<T> => s.status === 'success',
  isError: <T>(s: RequestState<T>): s is ErrorState => s.status === 'error',
  isCancelled: <T>(s: RequestState<T>): s is CancelledState => s.status === 'cancelled'
}

// 使用示例
function handleRequestState<T>(state: RequestState<T>) {
  if (guards.isSuccess(state)) {
    // 这里 state.data 可用
    console.log(`数据获取成功,时间戳: ${state.timestamp}`)
  } else if (guards.isError(state)) {
    // 这里可以访问 state.retryCount
    console.log(`错误,已重试 ${state.retryCount} 次`)
  } else if (guards.isCancelled(state)) {
    // 这里可以访问 state.reason
    console.log(`已取消: ${state.reason}`)
  }
}

TypeScript 配置的最佳实践

项目配置建议

// tsconfig.json
{
  "compilerOptions": {
    // 严格模式必须开启
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    
    // Vue 3 推荐配置
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    
    // 路径别名
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@composables/*": ["src/composables/*"]
    }
  },
  
  // 包含的文件
  "include": [
    "src/**/*.ts",
    "src/**/*.d.ts",
    "src/**/*.vue"
  ],
  
  // 排除的文件
  "exclude": [
    "node_modules",
    "dist"
  ]
}

VSCode 配置建议

// .vscode/settings.json
{
  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.preferences.autoImportFileExcludePatterns": [
    "vue-router",
    "pinia"
  ],
  "typescript.suggest.autoImports": true,
  "typescript.suggest.completeFunctionCalls": true,
  
  // 保存时自动修复
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  
  // 启用 Vue 语言服务
  "volar.autoCompleteRefs": true,
  "volar.completion.preferredTagNameCase": "kebab",
  "volar.completion.preferredAttrNameCase": "kebab"
}

组合式函数 TypeScript 最佳实践清单

  • 为所有导出函数定义接口:导出的 API 必须有清晰的类型定义
  • 使用泛型增加复用性:对于需要处理多种类型的函数,使用泛型约束
  • 提供完整的 JSDoc 注释:为参数和返回值添加说明
  • 使用 readonly 保护内部状态:对于不应该被修改的 ref,使用 readonly 包装
  • 类型守卫处理联合类型:使用自定义类型守卫收窄类型范围
  • 避免 any 类型:使用 unknown 替代 any,配合类型守卫
  • 提取共用类型:将重复使用的类型提取为接口
  • 测试类型定义:使用 tsddtslint 测试类型定义的正确性

结语

当我们的组合式函数拥有了完善的 TypeScript 支持,它们就不再是普通的函数,而是拥有“钢筋铁骨”的可靠组件。这不仅提升了开发体验,更重要的是让整个应用的质量有了根本性的保障。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

基于 ZXing 的 Vue 在线二维码扫描器实现

这篇只讲功能层 JavaScript:同一个扫描器同时支持“图片上传识别”和“摄像头实时识别”,识别到的内容进入结果列表,并提供复制能力。

在线工具网址:see-tool.com/qrcode-scan…
工具截图:
工具截图.png

识别依赖 ZXing(@zxing/library)的 BrowserMultiFormatReader,主要用到两种解码方式:

  • 图片:decodeFromImageElement(img)
  • 摄像头:decodeFromVideoDevice(deviceId, videoEl, callback)

下面按功能模块拆开讲核心实现。

1)解码器初始化:SSR 下只在客户端创建

Nuxt 有 SSR,setup 会先在服务器执行一次生成 HTML。服务器环境没有 window / navigator,也没有 navigator.mediaDevices 这类摄像头 API;而 BrowserMultiFormatReader 属于浏览器侧解码器,如果在服务端阶段创建,就可能触发 window is not defined / navigator is undefined 这类错误。

处理方式:把初始化放进 onMounted(只在浏览器端执行),并用 process.client 再兜底一次。

import { onMounted, onUnmounted } from "vue";
import { BrowserMultiFormatReader } from "@zxing/library";

let codeReader = null;

onMounted(() => {
  if (process.client) {
    codeReader = new BrowserMultiFormatReader();
  }
});

onUnmounted(() => {
  // 离开页面时释放摄像头相关资源
  if (codeReader) codeReader.reset();
});

reset() 用于停止当前扫描流程,并释放视频流相关资源(切换模式或离开页面时会用到)。

2)上传识别:File -> DataURL -> Image -> decode

上传和拖拽统一走 handleFiles(files):遍历文件,先过滤非图片,再逐个触发识别。

const handleFiles = (files) => {
  if (!files || files.length === 0) return;

  Array.from(files).forEach((file) => {
    if (!file.type.startsWith("image/")) {
      addResult(file.name, "仅支持图片文件", "error");
      return;
    }
    scanImageFile(file);
  });
};

scanImageFile 的流程是把文件读成 DataURL,加载成 Image,再交给 ZXing 解码:

const scanImageFile = (file) => {
  if (!codeReader) return;

  const reader = new FileReader();
  reader.onload = (e) => {
    const img = new Image();
    img.onload = () => {
      codeReader
        .decodeFromImageElement(img)
        .then((result) => addResult(file.name, result.text, result.format))
        .catch(() => addResult(file.name, "未识别到二维码", "error"));
    };
    img.src = e.target.result;
  };
  reader.readAsDataURL(file);
};

这里使用 Image() 的原因:先让浏览器把 DataURL 解码成像素数据,再由 ZXing 从像素中定位并识别二维码。

3)摄像头识别:decodeFromVideoDevice 持续回调

摄像头模式不自行做 getUserMedia + canvas 截帧,而是让 ZXing 直接接管:它会持续从视频帧中尝试识别。

const isCameraActive = ref(false);
const videoElement = ref(null);

const startCamera = () => {
  if (!codeReader) return;
  isCameraActive.value = true;

  codeReader
    .decodeFromVideoDevice(null, videoElement.value, (result, err) => {
      if (result) {
        addResult("摄像头扫描", result.text, result.format);
      }
      // 识别不到时 err 往往只是“没找到”,不需要每帧都弹提示
    })
    .catch(() => {
      isCameraActive.value = false;
      addResult("摄像头", "摄像头启动失败或无权限", "error");
    });
};

const stopCamera = () => {
  if (codeReader) codeReader.reset();
  isCameraActive.value = false;
};

null 表示用默认摄像头;如果你自己做了设备选择,把 deviceId 传进去就行。

4)结果结构:只存“来源 + 内容 + 格式 + 时间”

结果列表使用数组保存,元素结构如下:

// { source, content, format, isError, timestamp }
const results = ref([]);

字段都很直白:来源是“文件名/摄像头”,content 是解出来的文本,format 用来展示二维码类型,timestamp 用来做去重。

5)为什么要去重:摄像头会反复识别同一张码

摄像头模式下,二维码只要还在画面里,就可能被重复识别(可以理解为间隔很短就会再次识别)。如果每次识别成功都写入结果列表,会出现大量重复记录。

这里用“时间窗口去重”:2 秒内内容相同则跳过写入。

const addResult = (source, content, format) => {
  const isError = format === "error";
  const now = Date.now();

  const recentSame = results.value.find(
    (r) => r.content === content && now - r.timestamp < 2000,
  );

  if (recentSame && !isError) return;

  let formatName = format;
  if (!isError && typeof format === "number")
    formatName = getFormatName(format);
  else if (format && format.formatName) formatName = format.formatName;

  results.value.unshift({
    source,
    content,
    format: isError ? "" : String(formatName),
    isError,
    timestamp: now,
  });
};

效果是:镜头对准二维码时,结果只会稳定新增一次,不会被重复记录刷屏。

6)格式显示:把枚举值映射成常见名字

ZXing 的 format 有时候是枚举数字。为了让展示更直观,这里做一个映射表,把常见值转成字符串。

const getFormatName = (format) => {
  const formats = {
    11: "QR_CODE",
    5: "DATA_MATRIX",
    0: "AZTEC",
    10: "PDF_417",
  };
  return formats[format] || format;
};

没覆盖到的就原样返回,至少信息不会丢。

Vue3 + Element Plus 全局 Message、Notification 封装与规范|Vue生态精选

前端实战:Vue3 + Element Plus 全局 Message、Notification 封装教程,从概念区分、场景选择到统一错误处理、代码落地,一站式学会前端提示框封装,告别混乱代码与重复开发。

📑 文章目录


同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、我们为什么要封装?

很多同学会直接这样写:

// 散落在业务里的各种提示
this.$message.success('保存成功')
ElMessage.error('网络错误')
alert('操作失败')  // 甚至还有人用 alert

看起来能用,但会带来这些问题:

  • 提示风格不统一:有的用 Message,有的用 Notification,有的用 alert
  • 错误处理分散:每个接口各自 try-catch 各自 message
  • 难以维护:改文案、改样式、加埋点,要改很多地方
  • 用户体验差:错误提示不统一,成功/失败没规范

所以需要:把通知和消息系统统一封装,集中管理风格和错误处理

⬆ 返回目录

二、概念扫盲:Message / Notification / Toast 有啥区别?

类型 特点 典型场景
Message 轻量、短暂、通常居中或顶部,自动消失 操作结果反馈:保存成功、删除成功
Notification 带标题、正文,可带操作按钮,位置可配置 系统通知、任务完成、重要提示
Toast 和 Message 概念接近,有些库叫 Toast 同上,多用于移动端

可以简单记:Message 偏轻量,Notification 偏正式、信息更多。封装时建议:

  • 简单反馈 → Message
  • 需要标题、描述、操作 → Notification

⬆ 返回目录

三、典型使用场景

  1. 接口成功/失败:统一用 Message,成功/警告/错误三种类型
  2. 表单校验失败:一般用 Message,文案来自校验规则
  3. 全局错误:如 401、403、500 → 统一错误处理 + Message/Notification
  4. 长时间任务完成:如导出、报表生成 → 用 Notification 更合适
  5. 业务重要事件:如订单状态变更 → Notification + 操作入口

⬆ 返回目录

四、封装思路:三层结构

┌─────────────────────────────────────┐
│  业务层:直接调用 msg.success() 等  
├─────────────────────────────────────┤
│  封装层:msg / notify 统一入口      
│  - 统一风格                       
│  - 统一文案模板                   
│  - 统一埋点/日志                 
├─────────────────────────────────────┤
│  底层:Element Plus / Ant Design 等
└─────────────────────────────────────┘

业务层只调用封装好的 API,不直接接触 UI 库。

⬆ 返回目录

五、统一风格:主题、样式、交互

5.1 风格统一要管什么?

  • 类型:success / warning / error / info
  • 位置:如 Message 顶部居中,Notification 右上角
  • 持续时间:成功 2s,错误 4s 等
  • 样式:颜色、圆角、阴影等
  • 防重复:相同文案不重复弹

⬆ 返回目录

5.2 示例:统一配置

// src/utils/message.config.js

/**
 * Message 统一配置
 * 所有地方用 Message 时都走这套配置,保证风格一致
 */
export const MESSAGE_CONFIG = {
  duration: 2000,           // 默认 2 秒消失
  showClose: false,         // 不显示关闭按钮,靠自动消失
  center: true,             // 水平居中
  offset: 80,               // 距离顶部的距离
  grouping: true,           // 相同内容合并显示,避免刷屏
}

/**
 * 不同类型建议的 duration
 * 成功可以短一点,错误要留足阅读时间
 */
export const DURATION_BY_TYPE = {
  success: 2000,
  warning: 3000,
  error: 4000,
  info: 2500,
}

⬆ 返回目录

六、统一错误处理:拦截、提示、降级

6.1 核心思路

  • HTTP 拦截器:统一捕获 401、403、500 等
  • 业务错误码映射:后端错误码 → 前端文案
  • 兜底:网络异常、超时等给出通用提示

⬆ 返回目录

6.2 错误码与文案映射示例

// src/utils/errorCodeMap.js

/**
 * 后端错误码 → 前端展示文案
 * 避免把后端原始错误直接抛给用户
 */
export const ERROR_CODE_MAP = {
  401: '登录已过期,请重新登录',
  403: '没有权限执行此操作',
  404: '请求的资源不存在',
  500: '服务器异常,请稍后重试',
  10001: '参数错误',
  10002: '数据已存在',
  // ... 按你们项目补充
}

/**
 * 根据错误码获取友好提示
 */
export function getErrorMessage(code, defaultMsg = '操作失败,请稍后重试') {
  return ERROR_CODE_MAP[code] || defaultMsg
}

⬆ 返回目录

6.3 在 axios 里用

// src/api/request.js 示意

import axios from 'axios'
import { ElMessage } from 'element-plus'
import { getErrorMessage } from '@/utils/errorCodeMap'

const request = axios.create({
  baseURL: '/api',
  timeout: 10000,
})

// 响应拦截器:统一错误处理
request.interceptors.response.use(
  (response) => {
    const { code, data, message } = response.data
    // 假设业务成功是 code === 0
    if (code !== 0) {
      ElMessage.error(getErrorMessage(code, message))
      return Promise.reject(new Error(message))
    }
    return data
  },
  (error) => {
    if (error.response) {
      const { status } = error.response
      const msg = getErrorMessage(status)
      ElMessage.error(msg)
      // 401 可以在这里跳转登录
      if (status === 401) {
        // router.push('/login')
      }
    } else {
      ElMessage.error('网络异常,请检查网络后重试')
    }
    return Promise.reject(error)
  }
)

export default request

⬆ 返回目录

七、完整封装示例(Vue 3 + Element Plus)

7.1 封装文件结构

src/
├── utils/
│   ├── message.config.js    # 配置
│   ├── errorCodeMap.js      # 错误码映射
│   └── message.js           # 封装入口

⬆ 返回目录

7.2 封装实现

// src/utils/message.js

import { ElMessage, ElNotification } from 'element-plus'
import { MESSAGE_CONFIG, DURATION_BY_TYPE } from './message.config'
import { getErrorMessage } from './errorCodeMap'

/**
 * 全局 Message 封装
 * 统一风格、统一入口,方便以后替换 UI 库或加埋点
 */

function createMessage(type) {
  return (content, duration) => {
    ElMessage({
      ...MESSAGE_CONFIG,
      type,
      message: typeof content === 'string' ? content : content?.message || '操作成功',
      duration: duration ?? DURATION_BY_TYPE[type] ?? MESSAGE_CONFIG.duration,
    })
  }
}

// 对外暴露的 API
export const msg = {
  success: createMessage('success'),
  warning: createMessage('warning'),
  error: createMessage('error'),
  info: createMessage('info'),
}

/**
 * 全局 Notification 封装
 * 适合需要标题、描述、操作按钮的场景
 */
export const notify = {
  success(title, message, options = {}) {
    ElNotification({
      type: 'success',
      title: title || '成功',
      message: message || '',
      duration: 4000,
      position: 'top-right',
      ...options,
    })
  },
  error(title, message, options = {}) {
    ElNotification({
      type: 'error',
      title: title || '错误',
      message: message || '',
      duration: 5000,
      position: 'top-right',
      ...options,
    })
  },
  // warning、info 同理...
}

/**
 * 统一错误提示入口
 * 支持:错误码、Error 对象、字符串
 */
export function showError(error) {
  let message = '操作失败,请稍后重试'
  if (typeof error === 'number') {
    message = getErrorMessage(error)
  } else if (error?.message) {
    message = error.message
  } else if (typeof error === 'string') {
    message = error
  }
  msg.error(message)
}

⬆ 返回目录

7.3 业务里怎么用

// 业务组件里
import { msg, notify, showError } from '@/utils/message'

// 简单成功反馈
msg.success('保存成功')

// 接口失败时(如果拦截器没处理,可以手动调)
try {
  await saveData()
  msg.success('保存成功')
} catch (e) {
  showError(e)
}

// 重要通知
notify.success('导出完成', '您的报表已生成,请到下载中心查看')

⬆ 返回目录

7.4 全局挂载(可选)

// main.js
import { msg, notify, showError } from '@/utils/message'

app.config.globalProperties.$msg = msg
app.config.globalProperties.$notify = notify
app.config.globalProperties.$showError = showError

// 组件内:this.$msg.success('保存成功')

⬆ 返回目录

八、常见坑点与排查思路

8.1 同一个提示狂弹

  • 原因:接口失败在循环/频繁请求里被多次触发。
  • 做法:开启 grouping,或在封装层做「相同文案节流」。

⬆ 返回目录

8.2 样式跟项目不一致

  • 原因:直接用了 UI 库默认主题,或部分地方用内联样式覆盖。
  • 做法:所有 Message/Notification 都走封装层,在封装里统一传入配置,必要时用 CSS 变量或主题覆盖。

⬆ 返回目录

8.3 错误提示内容太“技术”

  • 原因:直接把后端 messageError 文本展示给用户。
  • 做法:用错误码映射表,把技术信息转成用户可读文案。

⬆ 返回目录

8.4 封装后换 UI 库很痛苦

  • 原因:业务里到处直接调用 ElMessageElNotification
  • 做法:业务只依赖 msgnotify,底层实现集中在 message.js,换库只改这一层。

⬆ 返回目录

8.5 在 setup 里没有 this

  • 做法:用 import { msg } from '@/utils/message' 直接引入,不依赖 this.$msg

⬆ 返回目录

九、实战规范总结

规范 说明
统一入口 只用 msg / notify,不直接调用 UI 库
统一风格 通过 message.config.js 统一 duration、位置、样式
统一错误处理 用错误码映射 + axios 拦截器,业务少写 try-catch
类型区分 简单反馈用 Message,复杂通知用 Notification
文案友好 错误码转成用户能看懂的话,不暴露技术细节
可扩展 封装层预留埋点、日志、国际化等扩展点

⬆ 返回目录

十、小结

封装全局 Message / Notification 的核心是:

  1. 统一入口:所有提示都从 msg / notify 走。
  2. 统一风格:配置集中管理,避免到处写死。
  3. 统一错误处理:拦截器 + 错误码映射,减少重复代码。
  4. 把用户当小白:错误文案要易懂,不吓人。

⬆ 返回目录


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

vue中怎么监测一个div的宽度变化

在 Vue 中监测一个 div 的宽度变化,可以使用以下几种方法,主要结合 ResizeObserver 或其他方式来实现动态监听。以下是具体实现方案:

方法 1:使用 ResizeObserver

ResizeObserver 是现代浏览器提供的 API,专门用于监听元素尺寸变化。它性能高效,适合动态监测 div 的宽度变化。

<template>
  <div ref="targetDiv" class="target-div">
    这是一个可调整大小的 div
  </div>
</template>

<script>
export default {
  data() {
    return {
      divWidth: 0,
    };
  },
  mounted() {
    // 创建 ResizeObserver 实例
    const observer = new ResizeObserver((entries) => {
      for (let entry of entries) {
        // 获取 div 的宽度
        this.divWidth = entry.contentRect.width;
        console.log('Div 宽度变化:', this.divWidth);
      }
    });

    // 监听目标 div
    observer.observe(this.$refs.targetDiv);
    
    // 组件销毁时清理 observer
    this.$on('hook:beforeDestroy', () => {
      observer.disconnect();
    });
  },
};
</script>

<style>
.target-div {
  width: 200px;
  height: 100px;
  background: lightblue;
  resize: horizontal; /* 允许水平拖动调整大小 */
  overflow: auto;
}
</style>

说明

  • ResizeObserver 会在 div 尺寸变化时触发回调,获取最新的宽度。
  • 使用 this.$refs.targetDiv 获取 DOM 元素。
  • 在组件销毁时调用 observer.disconnect() 清理监听,避免内存泄漏。
  • resize: horizontal 是 CSS 属性,方便测试宽度调整(需要配合 overflow: auto)。

方法 2:结合 Vue 的 watch 监听动态宽度

如果 div 的宽度是由响应式数据(如 style 或计算属性)控制的,可以通过 watch 监听相关数据的变化。

<template>
  <div :style="{ width: divWidth + 'px' }" class="target-div">
    宽度: {{ divWidth }}px
  </div>
</template>

<script>
export default {
  data() {
    return {
      divWidth: 200,
    };
  },
  watch: {
    divWidth(newWidth) {
      console.log('Div 宽度变化:', newWidth);
    },
  },
};
</script>

<style>
.target-div {
  height: 100px;
  background: lightcoral;
}
</style>

说明

  • 适用于宽度由 Vue 响应式数据驱动的场景。
  • 如果宽度变化是由外部(如用户拖动或 CSS)引起的,这种方法不适用。

方法 3:使用 window resize 事件(间接监测)

如果 div 的宽度变化与窗口大小相关(例如百分比宽度),可以监听 windowresize 事件。

<template>
  <div ref="targetDiv" class="target-div">
    这是一个宽度随窗口变化的 div
  </div>
</template>

<script>
export default {
  data() {
    return {
      divWidth: 0,
    };
  },
  methods: {
    updateWidth() {
      this.divWidth = this.$refs.targetDiv.offsetWidth;
      console.log('Div 宽度:', this.divWidth);
    },
  },
  mounted() {
    this.updateWidth(); // 初始化宽度
    window.addEventListener('resize', this.updateWidth);
    
    // 清理事件监听
    this.$on('hook:beforeDestroy', () => {
      window.removeEventListener('resize', this.updateWidth);
    });
  },
};
</script>

<style>
.target-div {
  width: 50%; /* 宽度随窗口变化 */
  height: 100px;
  background: lightgreen;
}
</style>

说明

  • 适合 div 宽度依赖窗口大小的场景(如 width: 50%)。
  • 使用 offsetWidth 获取 div 的实际宽度。
  • 注意清理事件监听以防止内存泄漏。

方法 4:使用第三方库(如 element-resize-detector)

如果需要兼容旧浏览器或更复杂的场景,可以使用第三方库如 element-resize-detector

  1. 安装库:

    npm install element-resize-detector
    
  2. 在 Vue 组件中使用:

<template>
  <div ref="targetDiv" class="target-div">
    这是一个可调整大小的 div
  </div>
</template>

<script>
import elementResizeDetectorMaker from 'element-resize-detector';

export default {
  data() {
    return {
      divWidth: 0,
    };
  },
  mounted() {
    const erd = elementResizeDetectorMaker();
    erd.listenTo(this.$refs.targetDiv, (element) => {
      this.divWidth = element.offsetWidth;
      console.log('Div 宽度变化:', this.divWidth);
    });

    // 清理监听
    this.$on('hook:beforeDestroy', () => {
      erd.removeAllListeners(this.$refs.targetDiv);
    });
  },
};
</script>

<style>
.target-div {
  width: 200px;
  height: 100px;
  background: lightyellow;
  resize: horizontal;
  overflow: auto;
}
</style>

说明

  • element-resize-detector 提供了跨浏览器兼容的尺寸变化监听。
  • 适合不支持 ResizeObserver 的旧浏览器。

推荐方案

  • 首选 ResizeObserver:现代、性能高、代码简洁,适合大多数场景。
  • 如果 div 宽度由响应式数据控制,使用 watch
  • 如果宽度与窗口大小相关,使用 window resize 事件。
  • 如果需要兼容旧浏览器,考虑 element-resize-detector

注意事项

  1. 性能:避免在大量元素上绑定监听,可能导致性能问题。
  2. 清理:总是清理 ResizeObserver、事件监听或第三方库的绑定,防止内存泄漏。
  3. 浏览器兼容性ResizeObserver 在现代浏览器(Chrome 64+、Firefox 69+ 等)支持良好,旧浏览器需 polyfill 或使用第三方库。

拒绝 Prop Drilling 与隐式耦合:Vue 组件通讯的全景指南与最佳实践

在 Vue.js 开发中,组件是构建用户界面的基本单元。一个复杂的应用通常由多个组件嵌套组成,而这些组件之间需要频繁地进行数据交换和事件通知,这就是组件通讯。掌握各种组件通讯方式,对于构建可维护、可扩展的 Vue 应用至关重要。

本文将详细介绍 Vue 2 和 Vue 3 中常用的组件通讯方式,并提供实用的代码示例。

一、父子组件通讯

1. Props(父传子)

props 是最基础的父子组件通讯方式,父组件通过属性向子组件传递数据。

Vue 3 示例:

<!-- 父组件 Parent.vue -->
<template>
  <ChildComponent :message="parentMessage" :count="42" />
</template>

<script setup>
import ChildComponent from './ChildComponent.vue'
import { ref } from 'vue'

const parentMessage = ref('Hello from Parent')
</script>
<!-- 子组件 ChildComponent.vue -->
<template>
  <div>
    <p>{{ message }}</p>
    <p>Count: {{ count }}</p>
  </div>
</template>

<script setup>
defineProps({
  message: {
    type: String,
    required: true
  },
  count: {
    type: Number,
    default: 0
  }
})
</script>

最佳实践:

  • 始终为 props 定义类型验证
  • 避免在子组件中直接修改 props(单向数据流原则)
  • 使用默认值处理可选 props

2. Emit(子传父)

子组件通过 $emit 触发事件,将数据传递给父组件。

Vue 3 示例:

<!-- 子组件 ChildComponent.vue -->
<template>
  <button @click="sendMessage">Send to Parent</button>
</template>

<script setup>
const emit = defineEmits(['custom-event', 'update:modelValue'])

const sendMessage = () => {
  emit('custom-event', { data: 'Hello from Child', timestamp: Date.now() })
}
</script>
<!-- 父组件 Parent.vue -->
<template>
  <ChildComponent @custom-event="handleChildEvent" />
</template>

<script setup>
import ChildComponent from './ChildComponent.vue'

const handleChildEvent = (payload) => {
  console.log('Received from child:', payload)
}
</script>

Vue 3.3+ 新特性:  可以使用 defineModel 简化双向绑定:

<!-- 子组件 -->
<script setup>
const modelValue = defineModel() // 自动处理 props 和 emit
</script>

<template>
  <input v-model="modelValue" />
</template>

二、兄弟组件通讯

兄弟组件之间没有直接的通讯方式,通常需要通过共同的父组件作为中介。

方案:状态提升到父组件

<!-- 父组件 -->
<template>
  <div>
    <SiblingA :shared-data="sharedData" @update-data="updateSharedData" />
    <SiblingB :shared-data="sharedData" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import SiblingA from './SiblingA.vue'
import SiblingB from './SiblingB.vue'

const sharedData = ref('Initial data')

const updateSharedData = (newData) => {
  sharedData.value = newData
}
</script>

三、跨层级组件通讯

1. Provide / Inject

适用于祖孙组件或多层嵌套场景,避免 props 逐层传递(prop drilling)。

Vue 3 示例:

<!-- 祖先组件 -->
<template>
  <div>
    <DeepChild />
  </div>
</template>

<script setup>
import { provide, ref } from 'vue'
import DeepChild from './DeepChild.vue'

const theme = ref('dark')
const user = ref({ name: 'Alice', role: 'admin' })

provide('theme', theme)
provide('user', user)
</script>
<!-- 后代组件(任意层级) -->
<template>
  <div>
    <p>Theme: {{ theme }}</p>
    <p>User: {{ user.name }}</p>
  </div>
</template>

<script setup>
import { inject } from 'vue'

const theme = inject('theme')
const user = inject('user')
</script>

注意事项:

  • provide/inject 不是响应式的,除非传递的是响应式对象(ref/reactive)
  • 过度使用会降低组件的可复用性
  • 适合全局配置、主题等场景
“不建议随意使用”或“慎用”的提示,主要是因为它破坏了组件的封装性和可维护性。以下是具体原因的深度解析:

1. 破坏了组件的显式依赖(耦合度高)

  • 问题:使用 props 和 emits 时,组件的输入和输出在代码中是显式声明的。阅读父组件代码,你一眼就能看出子组件需要什么数据、会触发什么事件。

  • 对比provide/inject 建立了一种隐式依赖

    • 祖先组件提供了数据,但不知道哪些后代组件使用了它。
    • 后代组件注入了数据,但不知道数据具体来自哪个祖先组件(只知道 key)。
  • 后果:当项目变大时,这种隐式连接会让数据流向变得难以追踪(“魔术字符串”问题)。如果你修改了 provide 中的某个值,可能会意外影响到深层嵌套中多个未知的组件,导致“牵一发而动全身”。

2. 降低了组件的可复用性

  • 问题:一个高度依赖 inject 的组件,必须要在特定的祖先组件环境下才能正常工作。

  • 后果:如果你想把这个组件复用到另一个页面或另一个项目中,如果那个环境没有提供对应的 provide,组件就会报错或行为异常。这使得组件变成了“环境依赖型”组件,而不是独立的通用组件。

    • 反例:一个按钮组件如果需要 inject('theme') 才能渲染颜色,那它在没有主题上下文的地方就很难单独使用。
    • 正解:更好的做法是通过 props 传入 color 或 theme

3. 调试困难

  • 问题:当数据出现错误时,使用 props 可以通过 Vue DevTools 清晰地看到数据在组件树中的传递路径。
  • 后果:使用 provide/inject 时,数据像是“瞬移”到子组件的。在大型应用中,很难快速定位是哪个祖先组件提供的值出了问题,或者是哪个子组件意外修改了注入的响应式对象。

4. 类型推断支持较弱(相比 Props)

  • 虽然在 Vue 3 + TypeScript 中 provide/inject 有了很好的类型支持,但相比于 defineProps 的自动类型推导,inject 往往需要手动定义类型接口或泛型,稍微繁琐一些,且在重构时(如修改 key 名称)不如 props 那样容易通过 IDE 全局搜索和替换来保证安全。

那么,什么时候应该使用 provide/inject

尽管有上述缺点,它在以下场景是最佳选择

  1. 开发组件库(UI Library)

    • 这是 provide/inject 的主战场。例如,一个 Table 组件和一个 TableCell 组件。你不可能让使用者在每个 TableCell 上都手动写一遍 :table-context="..."。此时,Table 组件 provide 上下文,TableCell inject 上下文,是极其合理且必要的。
  2. 深层嵌套的全局配置

    • 例如:应用的主题(深色/浅色)、当前语言(i18n)、权限配置等。这些数据通常在根组件或布局组件提供,深层的孙子组件需要使用。如果用 props 逐层传递(Prop Drilling),中间层的组件会被迫传递它们自己并不需要的数据,代码非常冗余。
  3. 避免 Prop Drilling

    • 当组件嵌套层级超过 3-4 层,且中间组件不需要使用这些数据,仅仅是透传时,使用 provide/inject 可以显著简化代码结构。

2. �����和attrs和 listeners(Vue 2)/ $ attrs(Vue 3)

用于透传属性和事件,常用于高阶组件或封装场景。

Vue 3 示例:

<!-- WrapperComponent.vue -->
<template>
  <BaseInput v-bind="$attrs" />
</template>

<script setup>
// 默认情况下,$attrs 包含所有未声明的 props
// 如果需要监听事件,需要在 emits 中声明或使用 v-on="$attrs"
</script>

<style>
/* 禁用继承样式 */
:root {
  inheritAttrs: false;
}
</style>

四、全局状态管理

对于大型应用,推荐使用状态管理库。

1. Pinia(Vue 3 推荐)

Pinia 是 Vue 官方推荐的状态管理库,比 Vuex 更简洁、类型友好。

npm install pinia
// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const doubleCount = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  return { count, doubleCount, increment }
})
<!-- 组件中使用 -->
<template>
  <div>
    <p>Count: {{ counterStore.count }}</p>
    <p>Double: {{ counterStore.doubleCount }}</p>
    <button @click="counterStore.increment">Increment</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()
</script>

2. Vuex(Vue 2/3 兼容)

虽然 Pinia 是未来趋势,但许多项目仍在使用 Vuex。

五、其他通讯方式

1. Event Bus(不推荐用于 Vue 3)

在 Vue 2 中常用空的 Vue 实例作为事件总线,但在 Vue 3 中由于移除了 $on$off$once,不再推荐使用。如需类似功能,可使用第三方库如 mitt

npm install mitt
// eventBus.js
import mitt from 'mitt'
export const emitter = mitt()
<!-- 发送方 -->
<script setup>
import { emitter } from '@/eventBus'

const sendData = () => {
  emitter.emit('custom-event', { message: 'Hello' })
}
</script>
<!-- 接收方 -->
<script setup>
import { onMounted, onBeforeUnmount } from 'vue'
import { emitter } from '@/eventBus'

const handleEvent = (data) => {
  console.log('Received:', data)
}

onMounted(() => {
  emitter.on('custom-event', handleEvent)
})

onBeforeUnmount(() => {
  emitter.off('custom-event', handleEvent)
})
</script>

2. 模板 refs

用于父组件直接访问子组件的实例或 DOM 元素。

<template>
  <button @click="callChildMethod">Call Child Method</button>
  <ChildComponent ref="childRef" />
</template>

<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'

const childRef = ref(null)

const callChildMethod = () => {
  if (childRef.value) {
    childRef.value.childMethod()
  }
}
</script>

六、选择指南

场景 推荐方式
父传子 Props
子传父 Emit / defineModel
兄弟组件 状态提升到共同父组件
跨多层级 Provide/Inject 或 Pinia
全局状态 Pinia(首选)或 Vuex
封装组件透传 $ attrs
直接调用子组件方法 Template Refs

七、最佳实践总结

  1. 遵循单向数据流:永远不要直接修改 props
  2. 优先使用简单方案:能用 props/emits 解决的,不要用全局状态
  3. 类型安全:在 TypeScript 项目中充分利用类型定义
  4. 避免过度耦合:组件间依赖越少越好
  5. 文档化通讯接口:明确组件的输入(props)和输出(events)
  6. 使用组合式 API:Vue 3 的 <script setup> 让组件通讯更清晰

结语

Vue 提供了丰富灵活的组件通讯机制,从简单的 props/emits 到强大的状态管理工具。选择合适的通讯方式取决于具体的应用场景。理解每种方式的优缺点,并在项目中合理运用,是构建高质量 Vue 应用的关键。

随着 Vue 生态的发展,Pinia 已成为状态管理的首选,而组合式 API 也让组件间的逻辑复用变得更加优雅。持续学习并实践这些模式,将帮助你在 Vue 开发道路上走得更远。

以界面重构文字,GenUI 正式发布!

本文由体验技术团队岑灌铭原创。

背景:传统 AI 对话的局限

随着大语言模型(LLM)的不断发展,模型选择越来越多,能力也越来越强。但传统大模型对话,主要依赖纯文本输入和输出,一旦涉及复杂交互、结构化展示或多轮协作,就会暴露出明显的体验瓶颈:

  • 可读性差、表达形式局限:纯文本呈现方式带来了较高的阅读成本,复杂的业务逻辑、多步骤流程、图表和可视化信息,用纯文字难以准确、高效地表达。例如:一张折线图能直观展示趋势,用文字描述则冗长且不直观。
  • 交互闭环断裂:传统对话模式下,用户往往需要经历「先阅读回复 → 理解内容 → 再手动输入下一步指令 → 发送内容继续对话」的流程。
  • 工具调用的体验断层:当LLM需要调用工具但缺少参数时,需要文字提示用户补充。用户需要理解每个参数的含义、类型和格式,自行组织输入,这种体验生硬且容易出错。

这些问题的症结在于纯文本形式难以跟上用户对 “高效完成复杂任务” 的核心诉求,而生成式UI正是解决这一痛点的解决方案。

1.png

生成式 UI 简介

生成式 UI(Generative UI) 是一种创新的人机交互范式:在对话过程中,能够动态生成并实时渲染 UI 界面,让 AI 不再局限于纯文字输出,而是能够"画"出表单、按钮、图表、卡片等丰富的交互组件。用户可以直接在生成的界面中操作,操作行为即时反馈回对话上下文,驱动模型进行下一轮响应,使交互与对话融为一体。

 

GenUI SDK 是 OpenTiny 团队基于生成式 UI 理念打造的解决方案,提供完整的前后端一体化集成能力。它遵循 OpenAI 接口规范,可无缝对接主流大模型服务;内置 Vue 与 Angular 双框架渲染器,支持自定义的组件库、交互行为与主题样式。无论是从零搭建一个 AI 对话应用,还是在现有业务系统中嵌入生成式界面能力,GenUI SDK 都能让开发者开箱即用、灵活扩展。

 

核心亮点

交互范式的三大突破:

1、以界面重构文字:打破文字表达壁垒,用可视化界面释放信息价值。表格、卡片、列表、图表等组件让数据与流程一目了然,用户无需再在文字中"挖矿"。

2、打破两步交互:实现从界面到对话的一站式流转。用户在生成的表单中填写、在按钮上点击,这些操作会即时反馈到对话上下文中,驱动模型的下一轮回复。无需看完再手动输入然后发送,交互与对话融为一体。

3、让 AI 更懂业务:在工具调用缺少参数时,模型可以自动生成交互式 UI 收集所需信息。用户只需在生成好的表单中填写并提交,参数即被正确传递给工具,无需理解参数格式、无需自行翻译需求。结合 MCP 等生态,GenUI 让 AI 真正具备了落地业务场景的交互能力。

SDK 工程能力:

1、现有 AI 生态兼容:遵循 OpenAI 格式,可无缝对接主流 LLM 服务;原生支持 MCP 服务接入,轻松连接丰富的工具生态。

2、定制主题:支持亮色、暗黑等主题切换,也可以完全自定义主题样式,适配不同产品的视觉风格与使用场景。

3、自定义组件:支持传入自定义组件与描述,扩展生成式 UI 的组件库,让生成的界面更贴合自身业务需求。

4、自定义交互:支持配置自定义交互行为,如跳转新页面、下载附件等,满足业务侧的各类个性化需求。

5、多技术栈支持:内置 Vue 与 Angular 渲染器,同时开放自定义渲染扩展接口,便于融入现有项目的技术栈。

6、示例与片段:支持配置自定义示例与片段,帮助模型理解业务最佳实践,进一步提升生成界面的质量。

 

GenUI SDK效果展示

以下是车票查询场景的录屏,能够让您更加深刻地了解 GenUI SDK :

2.gif

演练场体验

您还通过演练场亲自体验车票查询场景:GenUI SDK演练场

注意: 在体验前需先配置12306 MCP工具,此处可以使用 WebAgent 中 MCP 市场提供的12306工具:chat.opentiny.design/api/v1/mcp-…

3.png

快速上手:3 步集成 GenUI SDK

1. 后台服务准备

下载server包

pnpm add @opentiny/genui-sdk-server
# 或 npm install @opentiny/genui-sdk-server
# 或 yarn add @opentiny/genui-sdk-server

启动服务

使用 OpenAI 兼容的 LLM 服务,将下面的API_KEY和BASE_URL替换为您的 LLM 服务配置

export API_KEY=********* BASE_URL=https://your-llm-server.com/api && npx genui-sdk-server

若控制台出现 genui-sdk-server is running on http://localhost:3100 则说明启动成功

2.创建工程

初始化

首先,创建一个新的 Vue 项目,执行以下命令,按默认配置初始化工程:

npm create vue@latest genui-chat

安装依赖

进入项目目录并安装 GenUI SDK:

cd genui-chat
npm install @opentiny/genui-sdk-vue

删除样式

初始化引入的样式会污染组件样式,因此需要删除

修改 src/main.js 或 src/main.ts

// import './assets/main.css'; 删除 Vue 初始化工程引入的样式

import { createApp } from 'vue';
import App from './App.vue';

createApp(App).mount('#app');

3.使用并配置GenuiChat

结合配置和主题的完整示例如下:

<script setup lang="ts">
import { ref } from 'vue';
import { GenuiChat, GenuiConfigProvider } from '@opentiny/genui-sdk-vue';

const url = 'http://localhost:3100/chat/completions'; // 步骤1启动的服务
const model = ref('deepseek-v3.2'); // 对应模型服务提供商的模型ID
const temperature = ref(0.5);
const theme = ref<'dark' | 'lite' | 'light' | 'auto'>('dark');
</script>

<template>
  <GenuiConfigProvider :theme="theme">
    <GenuiChat :url="url" :model="model" :temperature="temperature">    
      <template #empty>
        <div class="empty-text">欢迎使用生成式UI</div>
      </template>
    </GenuiChat>
  </GenuiConfigProvider>
</template>

<style>
body,
html {
  padding: 0;
  margin: 0;
}
#app {
  position: fixed;
  width: 100vw;
  height: 100vh;
}
.tiny-config-provider {
  height: 100%;
}
.empty-text {
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 30px;
}
</style>

完成以上3步后,即可打开浏览器,立即体验了~

若想进一步了解GenUI SDK的用法,可以前往GenUI SDK 开发文档查看。

关于OpenTiny

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

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

如果你有任何问题,欢迎在评论区留言交流!

Vue 3 项目核心配置文件详解

你需要了解 Vue 3 项目中最常用、最关键的配置文件,我会按项目根目录配置src 内业务配置分类整理,包含完整用法和示例,直接复制就能用。

一、根目录核心配置文件(项目运行/构建依赖)

1. vite.config.js(Vite 构建工具,Vue3 官方推荐)

这是 Vue 3 + Vite 项目最重要的配置文件,配置开发服务、打包、代理、路径别名等。

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

// https://vitejs.dev/config/
export default defineConfig({
  // 1. 插件配置
  plugins: [vue()],
  
  // 2. 路径别名(简化 import 路径)
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'), // @ 代表 src 目录
      '@assets': resolve(__dirname, 'src/assets')
    }
  },

  // 3. 开发服务器配置
  server: {
    host: '0.0.0.0', // 允许局域网访问
    port: 3000,      // 端口号
    open: true,      // 自动打开浏览器
    https: false,    // 关闭 https
    // 接口代理(解决跨域)
    proxy: {
      '/api': {
        target: 'http://localhost:8080', // 后端接口地址
        changeOrigin: true,              // 允许跨域
        rewrite: (path) => path.replace(/^\/api/, '') // 重写路径
      }
    }
  },

  // 4. 打包配置
  build: {
    outDir: 'dist',      // 打包输出目录
    assetsDir: 'assets', // 静态资源目录
    minify: 'terser',    // 代码压缩
    sourcemap: false     // 关闭 sourcemap(生产环境)
  }
})

2. package.json(项目依赖/脚本配置)

管理项目依赖、运行/打包命令,Vue3 标准配置:

{
  "name": "vue3-project",
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",                // 启动开发环境
    "build": "vite build",        // 生产打包
    "preview": "vite preview"     // 预览打包结果
  },
  "dependencies": {
    "vue": "^3.4.0"               // Vue3 核心依赖
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.0.0",
    "vite": "^5.0.0"
  }
}

3. .env 环境变量配置(多环境必备)

Vite 支持三种环境文件,放在项目根目录:

  • .env:全局公共变量(所有环境生效)
  • .env.development:开发环境变量(npm run dev
  • .env.production:生产环境变量(npm run build

变量规则:必须以 VITE_ 开头

# .env.development
VITE_APP_TITLE = Vue3 开发环境
VITE_API_BASE_URL = /api
VITE_APP_DEBUG = true

使用方式

<script setup>
console.log(import.meta.env.VITE_APP_TITLE)
</script>

4. .eslintrc.cjs(代码规范检查)

统一团队代码风格,避免语法错误:

module.exports = {
  root: true,
  extends: [
    'plugin:vue/vue3-essential',
    'eslint:recommended'
  ],
  parserOptions: {
    ecmaVersion: 'latest'
  },
  rules: {
    'vue/no-unused-vars': 'warn',
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
  }
}

5. prettier.config.cjs(代码格式化)

自动格式化代码(缩进、引号、分号):

module.exports = {
  semi: false,        // 关闭分号
  singleQuote: true,  // 使用单引号
  tabWidth: 2,        // 缩进 2 格
  trailingComma: 'none'
}

二、src 目录内业务配置文件

1. src/main.js(项目入口配置)

Vue 3 入口文件,挂载全局组件、插件、样式:

import { createApp } from 'vue'
// 根组件
import App from './App.vue'
// 全局样式
import './style.css'

// 创建应用实例
const app = createApp(App)

// 全局配置(示例:全局指令/组件)
// app.directive('focus', { ... })
// app.component('GlobalButton', { ... })

// 挂载到 DOM
app.mount('#app')

2. src/router/index.js(路由配置 Vue Router)

Vue 3 路由标准配置(需先安装:npm install vue-router):

import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    component: About
  }
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})

export default router

在 main.js 中挂载

import router from './router'
app.use(router)

3. src/store/index.js(状态管理 Pinia 配置)

Vue 3 官方推荐状态库(替代 Vuex):

import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia

在 main.js 中挂载

import pinia from './store'
app.use(pinia)

三、极简配置清单(快速复制)

  1. 基础运行vite.config.js + package.json
  2. 多环境.env / .env.development / .env.production
  3. 路由src/router/index.js
  4. 状态管理src/store/index.js
  5. 代码规范.eslintrc.cjs + prettier.config.cjs

总结

  1. Vue 3 + Vite 核心配置是 vite.config.js,负责服务、代理、打包;
  2. 环境变量必须以 VITE_ 开头,用 import.meta.env 调用;
  3. 业务核心配置:main.js(入口)、router(路由)、pinia(状态)。

别再被setTimeout闭包坑了!90% 的人都写错过这个经典循环

你以为只是“延迟执行”?其实变量早就被偷换了!

在 JavaScript 中,setTimeout 是最常用的异步工具之一。但当它和 for 循环、闭包一起出现时,无数开发者都踩过同一个坑

for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 你期待输出 0,1,2?实际却是 3,3,3!
  }, 100);
}

图片

为什么?
因为 var + setTimeout + 闭包 = 变量共享陷阱

今天我们就彻底拆解这个经典问题,并告诉你如何用现代 JS 写出正确、安全、可维护的延迟逻辑。


问题根源:var 的函数作用域 + 异步执行

关键点有二:

1. var 没有块级作用域

for 循环中的 var i 实际上是在整个函数(或全局)作用域中声明一次,所有循环迭代共享同一个 i

2. setTimeout 是异步的

setTimeout 的回调真正执行时,for 循环早已结束,此时 i 的值已经是 3(循环终止条件)。

所以三个回调都引用了同一个已经变成 3 的变量i


常见错误解法(别再用了!)

解法一:用 setTimeout 第三个参数传参(可行但不推荐)

for (var i = 0; i < 3; i++) {
  setTimeout((x) => {
    console.log(x);
  }, 100, i); // 把 i 作为参数传入
}

虽然能工作,但:

  • 语义不直观;
  • 回调函数签名被污染;
  • 在复杂逻辑中难以维护。

解法二:立即执行函数(IIFE)——过时方案

for (var i = 0; i < 3; i++) {
  (function(j) {
    setTimeout(() => {
      console.log(j);
    }, 100);
  })(i);
}

这确实能创建新作用域,但:

  • 代码冗长;
  • 阅读成本高;
  • ES6 之后已有更优雅方案

正确姿势:用 let 声明循环变量

这是最简单、最现代、最推荐的方式:

for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 输出 0, 1, 2 
  }, 100);
}

图片

为什么 let 能解决?

  • let 具有块级作用域
  • 每次循环迭代都会创建一个新的绑定(binding)
  • 每个 setTimeout 回调捕获的是当前迭代的独立 i,互不干扰。

这不是“魔法”,而是 ES6 规范明确规定的语义。


更复杂的场景:循环中创建函数数组

陷阱不止出现在 setTimeout,任何异步回调或延迟执行的函数都可能中招:

const handlers = [];
for (var i = 0; i < 3; i++) {
  handlers.push(() => console.log(i));
}

handlers.forEach(fn => fn()); // 输出 3,3,3 

修复方式同样简单:

const handlers = [];
for (let i = 0; i < 3; i++) {
  handlers.push(() => console.log(i)); // 输出 0,1,2 
}

或者用 Array.map 等函数式写法,天然避免问题:

const handlers = [0, 1, 2].map(i => () => console.log(i));

特别提醒:Node.js 和浏览器都一样!

这个陷阱与运行环境无关,无论是:

  • 浏览器中的事件监听;
  • Node.js 中的定时任务;
  • React/Vue 中的副作用处理;

只要涉及 var + 异步 + 循环,就可能出错。


终极建议:彻底告别 var

在现代 JavaScript 工程中:

  • 默认使用const(不可变绑定);
  • 需要重赋值时用let
  • 永远不要用var(除非维护老代码)。

配合 ESLint 规则:

{
  "rules": {
    "no-var": "error"
  }
}

从源头杜绝此类问题。


结语

setTimeout 本身没有错,错的是我们对作用域和闭包的理解偏差。
let 的出现,正是为了终结这类“反直觉”的陷阱。

下次当你写循环+异步时,请记住:

不是代码跑错了,是你还在用十年前的变量声明方式。

升级你的语法,远离闭包陷阱!

转发给那个还在用 var 写循环的同事吧!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

vue3使用

vue是渐进式框架

  • 使用方式渐进:从CDN引入写简单交互,到CLI创建完整项目,再到Nuxt做SSR,每一步都是可选的。
  • 功能模块渐进:核心库只负责视图层,需要路由加Vue Router,需要状态管理加Pinia,不强求一次性配齐。
  • 学习曲线渐进:新手只需要会HTML/JS就能上手,随着项目复杂度提升,再逐步学习进阶特性。

Vue采用自动追踪的方式。它通过Proxy(Vue3)或Object.defineProperty(Vue2)拦截数据的读取和修改,在读取时收集依赖(当前正在运行的函数),在修改时通知所有依赖更新。这种方式的优点是精确——只有真正依赖这个数据的组件才会更新,而且开发者可以直接修改数据,不需要额外操作。

React则采用显式触发的方式。它没有自动追踪,而是通过setState手动触发更新。一旦setState调用,整个组件函数会重新执行,生成新的虚拟DOM,然后通过Diff算法找出变化的部分更新真实DOM。这种方式的优点是简单直观——数据变了就重新渲染,但缺点是需要开发者手动优化(memo/useMemo)避免不必要的渲染。

<script>
    export default {
        name: 'PP',
        // setup函数中的this是undefined,vue3中已经弱化this了,里边变量方法必须返回
        // 执行时机  早于beforeCreated()
        // setup返回对象,也可直接返回函数,页面直接渲染返回的内容
        // setup 和 data和method关系
        // setup()能和data\method同时存在
        // data和methods可以读取setup()中数据this.name,setup先执行,setup里读不到data里数据
        setup() {
            let name = ref('lili');
            let age = ref(18);
            function changeName {
                name.value = 'alice';
            }
            return {
                name,
                age,
                changeName,
            }
            // return () => 'hahhahahah' // 这个组件直接渲染hahahahah
        }
    }
</script>
// setup函数语法糖
// 设置组件名,可与setup语法糖同时存在
<script>
    export default {
        name: 'PP',
    }
</script>
// 上边不想再写个script单独设置组件名字,可以借助一个插件
// vite-plugin-vue-setup-extend  安装后在vite.config.ts中配置插件,即可name="person-123"
<script setup lang="ts" name="person-123">
    let name = 'lili';
    let age = 18;

    function fn() {}
</script>

ref和reactive

vue2中,数据写在data(){return {}}中就是响应式的,原理defineProperty劫持。
vue3响应式 数据实现响应式使
基本类型 + 对象类型 使用ref(初始值) let name = ref('ddd') name.value 需要.value取值
对象类型 let obj = reactive(初始值) 直接访问;嵌套深层的对象,建议用reactive,也可用ref
reactive定义后,不能直接再赋值整个对象。

let car = reactive({brand: 'bwp', price: 200});
// 错误
car = {brand: 'benci', price:300} // 错误,失去响应式,页面不更新
car = reactive({brand: 'aodi', price:300}) // 错误,原先的对象失去响应式,页面不更新
// 正确
Object.assign(car, {brand: 'aodi', price:300}) // 正确,页面更新,没有更新person的地址

// 如下可以,正确
const obj = ref({a: 123});
obj.value = {a: 567}; // 一个新对象赋值,obj的地址变了

toRefs和toRef

let person = reactive({name: 'll', age:18}); //将响应式对象所有属性都变成响应式
let { name, age } = toRefs(person);
console.log(name, age);
let n = toRef(person, 'name');一个一个解构成响应式

image.png

computed vue3的

计算属性有缓存

// 这么定义的计算属性不能修改
let fullName = computed(() => {
    return firstName.value + lastName.value;
})

// 这么定义的,可读可写
let fullName = computed({
    get() {
        return firstName.value + lastName.value;
    },
    // 赋值时调用
    set(newVal) {
        
    }
})

image.png

watch

监听数据变化,Vue3只能监听4种数据

  • ref定义的数据。
let sum = ref(0);
const addSum = () => {
  sum.value += 1;
};
// 解除监听
// 监听【ref】定义的【基本类型】
const stopWatch = watch(sum, (newVal, oldVal) => {
  console.log(newVal, oldVal);

  if (oldVal > 10) {
    stopWatch(); // 调用该函数解除监听
  }
});
// 监视【ref】定义的【对象类型】数据,监视的是对象的地址值,
// 若想监听对象内部属性发生的变化,需要【手动开启深度监听】
/** 监视ref定影的对象类型数据,监视的是对象的地址值,
    若想监听对象内部属性发生的变化,需要手动开启深度监听
    watch第一个参数:被监视的数据;
    第二个参数:监视的回调 
    第三个:配置的对象deep、immediate等
    */
let person = ref({ name: 'lisi', age: 18 });
watch(
  person,
  (newVal, oldVal) => {
    console.log(newVal, oldVal);
  },
  { deep: true, immediate: true }
  // deep开启,监听内部属性,
  // immediate值表示立即执行一次,数据未变化时就执行一次
);
  • reactive定义的数据
// 监视【reactive】定义的对象,默认开启深度监听,不用手动开启,不能关闭
let person = reactive({ name: 'lisi', age: 18 });
watch(
  person,
  (newVal, oldVal) => {
    console.log(newVal, oldVal);
  },
  { immediate: true }
  // 此时deep默认开启,可监听内部属性
  // immediate值表示立即执行一次,数据未变化时就执行一次
);
  • 函数返回的一个值 -》getter函数(能返回一个值的函数)。 监视ref或reactive定义的对象类型中的某个属性(属性为基本类型的或者对象类型,属性为对象的也可以直接监视这个属性,建议写成函数式
let person = reactive({
    name: 'lisi',
    car: {c1: 'yadi', c2: 'baoma'}
})
watch(() => person.name, () => {}, {})
// 下面情况能监听到car中单个属性的变化,但是car整体赋值监听不到,car = {c1; 'rr', c2: 'ee'}
watch(person.car, () => {}, {})
// 下面情况能监听到car整体赋值,不加deep参数,car的单个属性变化监听不到,所以要加deep参数
// 函数的写法,要深度监听,写deep参数。即地址上想监听内部属性变化,需加deep参数
watch(() => person.car /** 该函数返回car的地址 */, () => {}, {deep: true})
  • 上述组成的数组
let person = reactive({
    name: 'lisi',
    age: 18,
    car: {c1: 'yadi', c2: 'baoma'}
})

watch([() => person.name, () => person.car], () => {}, {deep: true})

watchEffect 副作用

watch必须明确指出监视谁。 watchEffect不用写监视谁,直接回调,回调中用哪些属性到就监视哪些

let height = ref(0);
let width = ref(0);
// 会立即调用回调函数,响应式追踪变化
watchEffect(() => {
    if (heigth.value > 10 || width.value > 5) {
        console.log('超过标准了');
    }
})

ref容器

<h2 ref='title'>nihao</h2>

let title = ref(); // title.value就是拿到h2这个Dom元素【普通标签】

<Person ref='personRef'></Person>

let personRef = ref(null);
personNull.value 就是person组件实例,可以拿到该组件defineExpose的东西【组件】

ts规范

// 接口,用于限制person对象的具体属性
// src/types/index.ts
export interface PersonInterface {
    name: string;
    age: number;
}
// 一个自定义类型
export type Persons = Array<PersonInterface>
// export type Persons = PersonInterface[] // 或者这种写法


// src/components/Person.vue
import {type PersonInterface, type Persons} from '@/types'

let person:PersonInterface = {age: 19, name: 'lisi'};
let personList2 = reactive<Persons>([]);
let personList: Persons = [];
let personList1: Array<PersonInterface> = [];

组件生命周期

v-if 创建销毁组件 v-show 隐藏使用display:none 元素还在
生命周期函数,生命周期钩子
vue2的生命周期 创建:created(创建前beforeCreate,创建完毕created)
挂载:mounted(挂载前beforeMount,挂载完毕-组件显示在页面上mounted)
更新:updated(更新前beforeUpdate,更新完毕 updated)
销毁:destroyed(销毁前beforeDestory,销毁完毕destroyed)

vue3的生命周期
创建:setup()替代了,模拟创建前和创建完
挂载:onBeforeMount(() => {}) onMounted(() => {})
更新:onBeforeUpdate(() => {}) onUpdated(() => {})
卸载:onBeforeUnmount(() => {}) onUnmounted(() => {})

父子生命周期顺序:
子挂载完--》父挂载完 父组件是最后挂载完的

hooks

本质是一个返回值的函数。 使用时引入,可解构获取hook中暴露的数据

// 将逻辑抽离出来,放到一个ts或js文件中
// 里边可以使用生命周期函数、或者computed、watch等vue中的东西
// src/hooks/sumHook.ts
import { ref } from 'vue'
export default function() {
    let sum = ref('')
    let add = () => {
        sum.value += 1;
    }
    
    return {
        sum,
        add
    }
}

// 引用处
import useSum from '@/hooks/sumHook.ts'
let { sum, add } = useSum();

路由router

import { RouterView, RouterLink} from 'vue-router'
 
<RouterView></RouterView> // 加载的路由组件显示区域占位

// 路由跳转组件
<RouterLink to='/home' active-class='actived-class'></RouterLink>
<RouterLink :to={path: '/home'} active-class='actived-class'></RouterLink>
<RouterLink :to={name: '/zhuye'} active-class='actived-class'></RouterLink>

路由组件:靠路由规则渲染出来的。一般写在pages或view文件夹下
routes: [{ path: '/home', component: Home, name='zhuye' }]
路由切换时,视觉消失的路由组件,是被卸载了
一般组件:手动写标签,一般写在components下 <person></person>

路由工作模式
history模式
优点:URL更美观,不带#,更接近传统网站的URL。 缺点:后期项目上线,需要服务端配合处理路径问题,否则刷新会有404错误,可在nginx等服务器上配置

vue2: mode: 'history'  
vue3: history: createWebHistory()  
const router = createRouter({
     history: createWebHistory(),
     routes: [],
})

hash模式
优点:兼容性更好,因为不需要服务器处理路径
缺点:url上带#不美观,且在SEO优化方面相对较差

vue2: mode: 'hash'  
vue3: history: createWebHashHistory() 
const router = createRouter({
     history: createWebHashHistory(),
     routes: [],
})

路由参数

import { useRoute, useRouter } from 'vue-router';
let route = useRoute();
// route.query
<RouterLink :to={path: '/zhuye', query: {id: xxx, title: xxx}} active-class='actived-class'></RouterLink>
<RouterLink :to=`/news/detail?id=${id}&title=${title}` active-class='actived-class'></RouterLink>
// /news/detail?id=119&title=万万没想到 // id=119&title=万万没想到 query参数

// parmas传参 to中路由必须写name,不能是path;且params中不能传对象和数组
<RouterLink :to={name: '/zhuye', params: {id: xx, title: xx}} active-class='actived-class'></RouterLink>
<RouterLink :to=`/new/detail/${id}/${title}` active-class='actived-class'></RouterLink>
// route.params    路由处占位: /news/detail/:id/:title

路由的props

routes: [{ 
    path: 'news',
    component: News,
    name='zhuye',
    children: [
        {
            name: 'xiang',
            path: 'detail/:id/:title',
            component: Detail,
            // 第一种写法:将路由收到的所有【params参数】作为props传给路由组件
            // <Detail id=xx title=xx />
            // props: true, 
            
            // 第二种写法:函数写法,可以自己决定将什么作为props传给路由组件
            //props(route){ // 参数为route路由信息
            //    return route.query
            //}
            
            // 第三种写法:对象写法,可以自己决定将什么作为props传给路由组件
            //props: { // 这种写法传固定值
            //    a: 100
            //    b: 200
            //}
        }
    ]
}] 

路由的replace属性

// replace替换,不能回退到上一个访问的路由 ;不加默认是push,可以回到上一个访问的路由
<RouterLink replace :to=`/new/detail/${id}/${title}` active-class='actived-class'></RouterLink>

编程式路由导航

import { useRouter } from 'vue-router';
const router = useRouter();

router.push('/news');
router.replace('/news');

vuex与pinia 集中式状态(数据)管理

多个组件共享数据

import { defineStore } from 'pinia';
// 选项式
export const useCountStore = defineStore('count', {
    state() {
        return {
            sum: 6,
            school: 'cc',
            address: 'ww'
        }
    },
    // actions中放置的一个一个的方法,用于响应组件中的动作
    actions: {
            increment(value) {
                console.log('ii调用了', value);
            }
    }

});

// setup写法 组合式
export const useCountStore = defineStore('count', () => {
    // state
    let sum = ref(6),
    let school = ref('cc'),
    let address = ref('ww')

    // actions
    const increment = (value) => {
       console.log('ii调用了', value);
    }
    
    return {
        sum,
        school,
        address,
        increment,
    }
});
import { useCountStore } from '@/store/count';
const countStore = useCountStore();
// 拿到store中数据
// countStore 是Proxy包裹的对象,里面的ref会自动解包,不用再.value
console.log(countStore.sum)
// 第一种修改方法
countStore.sum = 9;
// // 第一种修改方法, 批量变更 store
countStore.$patch({
    sum: 8,
    school: 'dd'
});
// 第三种修改方法,调用store的actions中定义的修改方法
countStore.increment('+++');

// import { storeToRefs } from 'pinia';
// storeToRefs 只会关注store中的数据,不会对方法进行ref包裹

const { sum, scheool } = storeToRefs(useCountStore());

组件间通信

  • props,emit 父子组件
  • mitt 引入mitt,订阅取消订阅;事件总线
  • v-model 此通信方式在UI组件库大量使用双向绑定
<input type='text' v-model="username"> 等价于下边  
<input type='text' :value="username" @input="username = (<HTMLInputElement>$event.target).value">  
<my-input v-model="username">
<my-input :modelValue="username" @update:modelValue="username = $event">
<input type='text' :value="username" @input="username = (<HTMLInputElement>$event.target).value">  

defineProps(['modelValue])
  • $attrs 用在模版中,子组件用这个获取副组件传过来的未使用props接收的其他所有属性 然后子组件可以使用v-bind=attrs将其未显示接收的参数传给他的子组件,及父传孙子组件vbind=key:value,....===>vbind=attrs将其未显示接收的参数传给他的子组件,及父传孙子组件 `v-bind={key: value, ....}` ===> `v-bind=attrs`
    用在js上时
<script setup>
import { useAttrs } from 'vue' 
const attrs = useAttrs() 
</script>
// 或
export default { 
    setup(props, ctx) { // 透传 attribute 被暴露为 ctx.attrs 
        console.log(ctx.attrs) 
    }
}
  • $ref $parents $ref 父组件获取所有的子组件;父-》子 子组件使用ref <child ref='child1Ref'/> $parents 子组件中获取到父组件 子-》父
    注意点: 一个响应式对象中的属性是ref()定义,读取时不用再.value,底层会自动获取数据
  • provide/reject 嵌套较深的组件间 祖先-子孙 project('moneyContext', {money, updateMoney}); 父 let {money, updateMoney} = reject('moneyContext', {}) // 可以给个默认值,孙子组件可以使用updateMoney通信给父组件

插槽
默认插槽
<slot>默认内容</slot> ==> <slot name='default'>默认内容</slot> 插槽没用到就显示默认内容
具名插槽

<slot name='header'></slot>

<template v-slot:header><div>menu</div></template>  
<Category v-slot:header><div>menu</div></Category>

作用域插槽 v-slot="params"
数据在子那边,但根据数据生成的结构,却由父决定,即需要用到zi的数据

// 子组件的数据可以绑定到slot上,传给父组件使用
<slot name='header' :youxi=games :a='123'></slot>
// 使用
<template v-slot:header><div>menu</div></template>  
<Category v-slot="params"><div>{{params.youxi}}</div></Category> // 默认插槽
<Category v-slot:header="{youxi}"><div>{{params.youxi}}</div></Category> // 解构 header插槽
v-slot:header="{youxi}" ===》 #header={youxi}

shallowRef与shallowReactive 用法和ref和reactive一样,只是监听的顶层属性

两者用来绕开深度响应,避免每个内部属性都做响应式带来的性能成本,使得属性访问更快,可提升性能。

  • shallowRef:浅层ref 只关注引用层的变化,不关心内部属性的变化; 只监听.value这层的改变,如果是对象,car.value.a,这个监听不到
  • shallowReactive:对象的顶层属性是响应式的,但嵌套属性不是。

readonly及shallowReadonly

readonly所有层都只读

let sum1 = ref(0);
let sum2 = readonly(sum1); // sum2关联了sum1为只读,但sum1变化时,sum2也会变化,sum1自己维护,sum2给别人使用,防止改坏了

shallowReadonly只限制第一层为只读,可以修改第二层数据

toRaw与markRaw

let person = ref({name: 'ii', age: 18});
let p2 = toRaw(person); // 变成了普通对象,无响应式了,用在作为参数传给非vue库去做处理,如lodash库的函数处理数据

let c = {a: 99, b:0};
let c1 = reactive(c); // 响应式
// markRaw 标记一个对象,使其永远不能成为响应式
let car = markRaw({b: ''qq', c: 22});

customRef

自定义ref

let initValue = '你好‘;
// track跟踪, trigger触发
let msg = customRef((track, trigger) => {
    // 读取
    get() {
        track(); // 告诉vue数据msg很重要,你要对msg进行持续关注,一旦msg变化就去更新
        reutrn initValue;
    },
    // 修改
    set(value) {
        initValue = value;
        trigger(); // 通知vue一下数据msg变化了
    }
})

Teleport 传送

将结构传送到body下,里面的元素就能插入到body元素标签下
<Teleport to='body'>
    <div>你好</div>
</Teleport>

<Teleport to='.m-box'>
    <div>你好</div>
</Teleport>
❌