阅读视图

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

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>

vxe-table 如何实现分组列头折叠列功能

实现 vxe-table 分组列头折叠列功能非常简单,只需改变列的 visible 就可以实现

vxetable.cn

Video_2026-03-09_104017-ezgif.com-video-to-gif-converter

通过修改列的 visible 属性来精确控制列的显示隐藏

<template>
  <div>
    <vxe-table
      border
      height="400"
      :data="tableData">
      <vxe-column type="checkbox" width="60"></vxe-column>
      <vxe-colgroup field="g1" title="分组1">
        <template #header="{ column }">
          <vxe-button mode="text" :icon="foldMaps.g1 ? 'vxe-icon-square-minus' : 'vxe-icon-square-plus'" @click="collapsable('g1')"></vxe-button>
          <span>{{ column.title }}</span>
        </template>

        <vxe-column field="name" title="Name" width="200"></vxe-column>
        <vxe-column field="role" title="Role" :visible="foldMaps.g1" width="200"></vxe-column>
        <vxe-column field="sex" title="Sex" :visible="foldMaps.g1" width="200"></vxe-column>
      </vxe-colgroup>
      <vxe-colgroup field="g2" title="分组2">
        <template #header="{ column }">
          <vxe-button mode="text" :icon="foldMaps.g2 ? 'vxe-icon-square-minus' : 'vxe-icon-square-plus'" @click="collapsable('g2')"></vxe-button>
          <span>{{ column.title }}</span>
        </template>

        <vxe-column field="age" title="Age" width="200"></vxe-column>
        <vxe-column field="rate" title="Rate" :visible="foldMaps.g2" width="200"></vxe-column>
        <vxe-column field="address" title="Address" :visible="foldMaps.g2" width="200"></vxe-column>
      </vxe-colgroup>
    </vxe-table>
  </div>
</template>

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

const foldMaps = reactive({
  g1: true,
  g2: true
})

const tableData = ref([
  { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', age: 28, address: 'test abc' },
  { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
  { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
  { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', age: 23, address: 'test abc' },
  { id: 10005, name: 'Test5', role: 'Develop', sex: 'Women', age: 30, address: 'Shanghai' },
  { id: 10006, name: 'Test6', role: 'Designer', sex: 'Women', age: 21, address: 'test abc' },
  { id: 10007, name: 'Test7', role: 'Test', sex: 'Man', age: 29, address: 'test abc' },
  { id: 10008, name: 'Test8', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' }
])

const collapsable = (key) => {
  foldMaps[key] = !foldMaps[key]
}
</script>

gitee.com/x-extends/v…

Vue 3 核心函数全解(组合式 API + 常用工具函数)

本文按最常用优先级分类整理,包含用法、场景和示例,覆盖开发 99% 的需求。

Vue 3 核心函数分为两大类:组合式 API 核心函数(写业务必用)、工具函数(辅助开发)。


一、组合式 API 核心函数(<script setup> 中必用)

1. ref() —— 定义基础类型响应式数据

  • 作用:把字符串、数字、布尔值等基础类型变成响应式
  • 取值/赋值:必须用 .value(模板中可省略)
  • 也可用于引用 DOM、组件实例
<script setup>
// 1. 导入函数
import { ref } from 'vue'

// 2. 定义响应式数据
const count = ref(0)
const msg = ref('Hello Vue3')

// 3. 修改数据(必须加 .value)
const add = () => count.value++
</script>

<template>
  <!-- 模板中直接用,无需 .value -->
  <p>{{ msg }}</p>
  <button @click="add">{{ count }}</button>
</template>

2. reactive() —— 定义对象/数组响应式数据

  • 作用:深度响应式,适用于对象、数组、复杂数据结构
  • 取值/赋值:无需 .value,直接操作
<script setup>
import { reactive } from 'vue'

// 定义对象/数组
const user = reactive({
  name: '张三',
  age: 18,
  hobbies: ['编程', '读书']
})

// 直接修改
const updateUser = () => {
  user.age++
  user.hobbies.push('运动')
}
</script>

3. computed() —— 计算属性

  • 作用:基于响应式数据派生新数据,自带缓存
  • 用法:只读计算属性、可写计算属性
<script setup>
import { ref, computed } from 'vue'
const count = ref(1)

// 只读计算属性(最常用)
const doubleCount = computed(() => count.value * 2)

// 可写计算属性
const writableCount = computed({
  get() { return count.value },
  set(val) { count.value = val }
})
</script>

4. watch() —— 侦听器

  • 作用:监听响应式数据变化,执行异步/复杂逻辑
  • 可监听:refreactive、多个数据源
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)

// 基础监听
watch(count, (newVal, oldVal) => {
  console.log('count变化:', newVal, oldVal)
})

// 监听 reactive 对象(必须指定属性/用 getter)
const user = reactive({ age: 18 })
watch(() => user.age, (newVal) => {})

// 立即执行 + 深度监听
watch(count, () => {}, {
  immediate: true,  // 初始化立即执行一次
  deep: true        // 深度监听(对象嵌套数据)
})
</script>

5. watchEffect() —— 自动追踪依赖侦听器

  • 优势:无需指定监听目标,自动追踪内部使用的响应式数据
  • 适用:简单监听、依赖不固定的场景
<script setup>
import { ref, watchEffect } from 'vue'
const count = ref(0)

// 自动监听 count,变化立即执行
watchEffect(() => {
  console.log('最新count:', count.value)
})
</script>

6. defineProps() —— 子组件接收父组件传值

  • 专属 <script setup>无需导入
  • 用于定义组件 props(类型校验、默认值、必传)
<script setup>
// 子组件
const props = defineProps({
  title: {
    type: String,
    default: '默认标题',
    required: true
  },
  list: Array
})
// 直接使用 props.title
</script>

7. defineEmits() —— 子组件向父组件发送事件

  • 专属 <script setup>无需导入
  • 子组件触发事件,父组件监听接收数据
<script setup>
// 子组件:定义事件名
const emit = defineEmits(['update-count'])

// 触发事件
const sendToParent = () => {
  emit('update-count', 100)
}
</script>

8. defineExpose() —— 子组件暴露属性/方法给父组件

  • 作用:子组件主动暴露数据/方法,父组件通过 ref 调用
<script setup>
// 子组件
const childFn = () => console.log('子组件方法')
// 暴露出去
defineExpose({ childFn })
</script>

<!-- 父组件调用 -->
<Child ref="childRef" />
<script setup>
import { ref } from 'vue'
const childRef = ref(null)
// 调用子组件方法
childRef.value.childFn()
</script>

二、Vue 3 生命周期函数(组合式 API)

Vue 3 组合式 API 用函数形式调用,常用 4 个:

<script setup>
import { onMounted, onUpdated, onUnmounted } from 'vue'

// 1. 组件挂载完成(DOM 渲染完毕,请求数据、操作DOM)
onMounted(() => {
  console.log('组件挂载')
  // 这里发接口请求最佳
})

// 2. 组件更新完成
onUpdated(() => {})

// 3. 组件卸载(清除定时器、解绑事件)
onUnmounted(() => {
  clearInterval(timer)
})
</script>

三、工具函数(高频实用)

1. toRefs() —— 解构 reactive 不丢失响应式

  • 问题:直接解构 reactive 对象会失去响应式
  • 解决:用 toRefs 转为响应式 ref
<script setup>
import { reactive, toRefs } from 'vue'
const user = reactive({ name: '张三', age: 18 })

// 正确:解构后仍响应式
const { name, age } = toRefs(user)
</script>

2. toRef() —— 提取对象单个属性为响应式

const age = toRef(user, 'age')

3. nextTick() —— DOM 更新后执行回调

  • 适用:修改数据后,立即操作最新 DOM
import { nextTick } from 'vue'
const updateData = async () => {
  count.value++
  // 等待 DOM 更新完成
  await nextTick()
  // 操作最新 DOM
}

四、完整示例(整合核心函数)

<template>
  <div>
    <h2>{{ title }}</h2>
    <p>计数:{{ count }}</p>
    <p>双倍计数:{{ doubleCount }}</p>
    <button @click="add">+1</button>
  </div>
</template>

<script setup>
// 1. 导入核心函数
import { ref, computed, watch, onMounted } from 'vue'

// 2. Props 接收
defineProps({
  title: String
})

// 3. 响应式数据
const count = ref(0)

// 4. 计算属性
const doubleCount = computed(() => count.value * 2)

// 5. 方法
const add = () => count.value++

// 6. 侦听
watch(count, (val) => {
  console.log('计数变为:', val)
})

// 7. 生命周期
onMounted(() => {
  console.log('组件初始化完成')
})
</script>

总结

  1. 基础数据用 ref,对象/数组用 reactive
  2. 派生数据用 computed,监听变化用 watch/watchEffect
  3. 组件通信:defineProps(父→子)、defineEmits(子→父)
  4. 生命周期核心:onMounted(请求数据)、onUnmounted(清理)
  5. 解构响应式对象:必用 toRefs

这些是 Vue 3 开发最核心、最常用的函数,掌握它们就能完成绝大多数业务开发。

HTTP状态查询 在线工具核心JS实现

这篇文章只讲本项目里“HTTP状态查询”工具的功能 JavaScript 实现。它的目标很明确:用户输入一个网址后,返回当前状态码、重定向链路、响应头、页面标题、IP 和耗时等信息。

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

整个实现可以拆成 4 段:输入规范化、请求触发、服务端逐跳探测、结果整理展示。

1)输入先做规范化

这个工具不会直接拿用户原始输入去请求,而是先统一处理:

  • 去掉首尾空格和中间多余空白
  • 如果没写协议,自动补上 http://
  • URL 构造函数校验格式
  • 只允许 httphttps

这样做的好处是,像 example.comhttps://example.com 这种输入都能被转换成稳定可请求的地址,非法内容则会被提前拦下。

const normalizeUrl = (value) => {
  const rawValue = String(value || "").trim();
  if (!rawValue) return "";

  const cleaned = rawValue.replace(/\s+/g, "");
  const withProtocol = /^https?:\/\//i.test(cleaned)
    ? cleaned
    : `http://${cleaned}`;

  try {
    const target = new URL(withProtocol);
    if (!["http:", "https:"].includes(target.protocol)) return "";
    return target.toString();
  } catch {
    return "";
  }
};

2)前端状态围绕“查询过程”设计

前端没有把逻辑拆得很散,而是直接围绕一次查询需要的状态来组织:

  • urlInput:输入框内容
  • isLoading:是否正在查询
  • errorMessage:错误提示
  • resultData:接口返回的完整结果
  • pendingUrl:当前准备发送的规范化 URL

结果展示时,再通过计算属性把 resultData 拆成页面标题、结果列表和摘要文案。这样界面层只负责渲染,不需要重复处理原始数据。

3)服务端核心是“手动接管跳转链”

真正的核心不在于请求一次 URL,而在于把每一跳都查出来。实现上使用循环逐跳请求,并把 redirect 设为 manual,这样程序不会自动跟随跳转,而是自己读取 Location,再决定下一跳。

const isRedirectStatus = (statusCode) =>
  [301, 302, 303, 307, 308].includes(statusCode);

for (let i = 0; i <= MAX_REDIRECTS; i += 1) {
  if (visited.has(currentUrl)) break;
  visited.add(currentUrl);

  const { result, title: pageTitle } = await requestOnce(currentUrl, i + 1);
  results.push(result);

  if (!isRedirectStatus(result.code) || !result.location) {
    break;
  }

  currentUrl = new URL(result.location, currentUrl).toString();
}

这里有两个关键点:

  • visited 记录已经访问过的地址,避免循环跳转
  • new URL(result.location, currentUrl) 兼容相对跳转地址

所以用户最后看到的不是单个状态码,而是一整条请求链路。

4)单次请求会提取多种信息

每请求一跳,都会同时收集一组结构化结果:

  • codestatusText
  • contentType
  • cacheControl
  • responseDate
  • server
  • location
  • totalTime
  • head(原始响应头文本)

耗时的计算方式也很直接:请求前记开始时间,响应结束后减一次时间戳,最后拼成 123ms 这种格式。

5)页面标题不是直接取字符串,而是先按内容类型解码

如果响应是 HTML,工具还会继续读取正文前一部分内容,用于提取页面 <title>。这里有两个步骤:

第一步,先根据 content-type 里的 charset 选择解码方式;第二步,再从 HTML 里匹配标题标签。

const extractTitleFromHtml = (html) => {
  const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
  if (!match) return "";
  return match[1].replace(/\s+/g, " ").trim();
};

这样即使页面不是 UTF-8,只要响应头里带了字符集,标题也能尽量正确显示。

6)IP 和端口信息来自额外解析

HTTP 响应本身不会直接告诉你目标域名解析到了哪个 IP,所以这里额外做了一次域名解析。协议是 https 时默认端口记为 443,否则记为 80。这样结果里除了状态码,还能把访问目标的基础网络信息一起展示出来。

7)前端会再做一层结果归纳

查询结果返回后,前端不是机械地把数组打印出来,而是根据最后一个状态码和是否发生重定向,生成更容易理解的摘要:

  • 2xx:访问成功
  • 3xx 且有跳转:发生重定向
  • 4xx:客户端错误
  • 5xx:服务器错误

同时还会按状态码给文字加不同颜色,让用户一眼区分成功、跳转和异常结果。

8)这套实现的关键点

这个工具的功能 JS,本质上是在做一条清晰的数据链:

输入 URL -> 规范化 -> 逐跳请求 -> 提取状态与响应头 -> 解析标题/IP/耗时 -> 生成可读结果

从实现角度看,最关键的不是“发起请求”本身,而是把跳转链、响应头、标题和状态归纳成一份普通用户也能看懂的结果。这也是这个 HTTP状态查询 工具的核心实现思路。

❌