阅读视图

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

vxe-tree 树组件拖拽排序功能的使用教程

vxe-tree 树组件拖拽排序功能的使用教程,通过 drag 启用行拖拽排序功能,支持同层级、跨层级、拖拽到子级非常强大的拖拽功能等

官网:vxeui.com github:github.com/x-extends/v… gitee:gitee.com/x-extends/v…

同层级拖拽

通过 drag-config.isPeerDrag 启用同层级拖拽

image

<template>
  <div>
    <vxe-tree v-bind="treeOptions"></vxe-tree>
  </div>
</template>

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

const treeOptions = reactive({
  transform: true,
  drag: true,
  dragConfig: {
    isPeerDrag: true
  },
  data: [
    { title: '节点2', id: '2', parentId: null },
    { title: '节点3', id: '3', parentId: null },
    { title: '节点3-1', id: '31', parentId: '3' },
    { title: '节点3-2', id: '32', parentId: '3' },
    { title: '节点3-2-1', id: '321', parentId: '32' },
    { title: '节点3-2-2', id: '322', parentId: '32' },
    { title: '节点3-3', id: '33', parentId: '3' },
    { title: '节点3-3-1', id: '331', parentId: '33' },
    { title: '节点3-3-2', id: '332', parentId: '33' },
    { title: '节点3-3-3', id: '333', parentId: '33' },
    { title: '节点3-4', id: '34', parentId: '3' },
    { title: '节点4', id: '4', parentId: null },
    { title: '节点4-1', id: '41', parentId: '4' },
    { title: '节点4-1-1', id: '411', parentId: '42' },
    { title: '节点4-1-2', id: '412', parentId: '42' },
    { title: '节点4-2', id: '42', parentId: '4' },
    { title: '节点4-3', id: '43', parentId: '4' },
    { title: '节点4-3-1', id: '431', parentId: '43' },
    { title: '节点4-3-2', id: '432', parentId: '43' },
    { title: '节点5', id: '5', parentId: null }
  ]
})
</script>

跨层级拖拽

通过 drag-config.isCrossDrag 启用跨层级拖拽

tree_drag_coss_drag

<template>
  <div>
    <vxe-tree v-bind="treeOptions"></vxe-tree>
  </div>
</template>

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

const treeOptions = reactive({
  transform: true,
  drag: true,
  dragConfig: {
    isCrossDrag: true
  },
  data: [
    { title: '节点2', id: '2', parentId: null },
    { title: '节点3', id: '3', parentId: null },
    { title: '节点3-1', id: '31', parentId: '3' },
    { title: '节点3-2', id: '32', parentId: '3' },
    { title: '节点3-2-1', id: '321', parentId: '32' },
    { title: '节点3-2-2', id: '322', parentId: '32' },
    { title: '节点3-3', id: '33', parentId: '3' },
    { title: '节点3-3-1', id: '331', parentId: '33' },
    { title: '节点3-3-2', id: '332', parentId: '33' },
    { title: '节点3-3-3', id: '333', parentId: '33' },
    { title: '节点3-4', id: '34', parentId: '3' },
    { title: '节点4', id: '4', parentId: null },
    { title: '节点4-1', id: '41', parentId: '4' },
    { title: '节点4-1-1', id: '411', parentId: '42' },
    { title: '节点4-1-2', id: '412', parentId: '42' },
    { title: '节点4-2', id: '42', parentId: '4' },
    { title: '节点4-3', id: '43', parentId: '4' },
    { title: '节点4-3-1', id: '431', parentId: '43' },
    { title: '节点4-3-2', id: '432', parentId: '43' },
    { title: '节点5', id: '5', parentId: null }
  ]
})
</script>

gitee.com/x-extends/v…

vue里的ref和reactive的工作原理是什么?以及它们的区别是什么?为什么有人说reactive“废弃”了?

这是一个非常硬核且高质量的问题。要理解 Vue 3 的响应式系统,必须深入到源码层面。

首先,我需要纠正一个重要的误区:Vue 3 并没有“弃用” reactive 。它仍然是 Vue 3 核心 API 的一部分。但是,确实存在一种趋势(包括 Vue 官方团队的建议),推荐优先使用 ref 。

下面我将从源码级别(基于 Vue 3 packages/reactivity 模块)为你详细剖析两者的原理、区别,以及为什么现在大家更倾向于用 ref


一、 reactive 的源码工作原理

reactive 的核心是利用了 ES6 的 Proxy 对象。它拦截对象的操作(增删改查),从而实现依赖收集和触发更新。

1. 核心流程 (reactive.ts)

当你调用 reactive(obj) 时,Vue 内部实际上执行了 createReactiveObject 函数。

简化版源码逻辑:

// 存放代理对象的缓存,防止同一个对象被代理多次
const reactiveMap = new WeakMap();

function createReactiveObject(target) {
  // 1. 如果不是对象(是基础类型),直接返回,无法代理
  if (!isObject(target)) {
    return target;
  }

  // 2. 检查缓存,如果已经代理过,直接返回缓存的 Proxy
  if (reactiveMap.has(target)) {
    return reactiveMap.get(target);
  }

  // 3. 创建 Proxy
  const proxy = new Proxy(target, mutableHandlers);

  // 4. 存入缓存
  reactiveMap.set(target, proxy);
  
  return proxy;
}

2. 拦截器 (baseHandlers.ts)

Proxy 的威力在于第二个参数 mutableHandlers。它定义了 get(读取)和 set(修改)的拦截行为。

  • get (依赖收集) :当副作用函数(Effect,如 computed 或 render)读取属性时,触发 track 函数,将当前 Effect 记录下来。
  • set (派发更新) :当修改属性时,触发 trigger 函数,找到之前收集的 Effect 并执行它们。

简化版 Handler 逻辑:

const mutableHandlers = {
  get(target, key, receiver) {
    // 1. 收集依赖
    track(target, key);
    
    // 2. 获取原本的值
    const res = Reflect.get(target, key, receiver);

    // 3. 【深度响应关键点】如果获取到的 res 是对象,递归将其转为 reactive
    // 这与 Vue 2 不同,Vue 3 是懒代理(访问时才代理),性能更好
    if (isObject(res)) {
      return reactive(res); 
    }
    
    return res;
  },
  
  set(target, key, value, receiver) {
    // 1. 获取旧值
    const oldValue = target[key];
    // 2. 设置新值
    const result = Reflect.set(target, key, value, receiver);
    
    // 3. 如果值发生变化,触发更新
    if (hasChanged(value, oldValue)) {
      trigger(target, key);
    }
    
    return result;
  }
};


二、 ref 的源码工作原理

ref 的设计初衷是为了解决 基本数据类型(Primitives) 无法使用 Proxy 代理的问题(Proxy 只能代理对象)。

1. 核心流程 (ref.ts)

ref 本质上是一个 对象的包装器。它通过定义一个类 RefImpl,利用 ES6 的类属性访问器(getter/setter)来拦截 .value 的访问。

简化版源码逻辑:

function ref(value) {
  return createRef(value);
}

function createRef(rawValue) {
  if (isRef(rawValue)) return rawValue;
  return new RefImpl(rawValue);
}

class RefImpl {
  public _value; // 存储当前值
  public _rawValue; // 存储原始值(用于比较)
  public dep; // 依赖容器
  public __v_isRef = true; // 标记这是一个 Ref

  constructor(value) {
    this._rawValue = value;
    // 核心差异点:如果传入的是对象,内部会自动调用 reactive()!
    this._value = isObject(value) ? reactive(value) : value;
  }

  get value() {
    // 1. 收集依赖
    trackRefValue(this);
    return this._value;
  }

  set value(newVal) {
    // 2. 检查值是否变化
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal;
      // 如果新赋的值是对象,再次转为 reactive
      this._value = isObject(newVal) ? reactive(newVal) : newVal;
      // 3. 触发更新
      triggerRefValue(this);
    }
  }
}

重点总结

  • 如果 ref(10):利用 RefImpl 的 get value 和 set value 进行拦截。
  • 如果 ref({ count: 1 })RefImpl 会将内部的 value 变成 reactive({ count: 1 }) 的 Proxy 对象。

三、 Ref 与 Reactive 的关键区别

特性 Ref Reactive
数据类型 支持所有类型(基本类型 + 对象)。 仅支持对象(Array, Object, Map, Set)。
底层原理 RefImpl 类(getter/setter)。如果是对象,内部转调 reactive 直接使用 Proxy
访问方式 必须通过 .value 访问(模板中自动解包除外)。 直接访问属性。
重新赋值 myRef.value = {} 依然保持响应式。 let state = reactive({}); state = {} 会丢失响应性
结构解构 解构会丢失响应性(需用 toRefs)。 解构会丢失响应性(需用 toRefs)。

四、 为什么说 Vue 3 “想弃用” reactive(实际上是推荐 ref)?

这是一个由 “开发者体验(DX)” 驱动的趋势。虽然 reactive 并没有被官方删除,但社区和尤雨溪(Evan You)都倾向于 “Ref 一把梭” ,主要原因如下:

1. reactive 的局限性会导致 Bug

Vue 新手最常遇到的坑就是 reactive 丢失响应性:

  • 赋值替换问题

    let list = reactive([]);
    // 错误!这样赋值会切断 Proxy 的连接,页面不会更新
    list = [1, 2, 3]; 
    
    // 正确写法(很麻烦)
    list.push(...[1, 2, 3]);
    // 或者再包一层
    const state = reactive({ list: [] });
    state.list = [1, 2, 3];
    

    而 ref 没有这个问题:list.value = [1, 2, 3] 总是安全的。

  • 解构丢失问题

    const state = reactive({ count: 0 });
    let { count } = state; // count 变成了普通数字,不再是响应式的
    

2. 类型不一致带来的心智负担

在一个组件中,如果混用两者:

  • 有些变量直接读(reactive)。
  • 有些变量要加 .value(ref)。
  • 有些对象是通过 props 传进来的,你不知道它是普通对象还是 Proxy。

使用 ref 可以统一心智模型

  • 在 JS 代码中,凡是响应式数据,都要带 .value 。这种显式调用虽然写起来多几个字符,但让代码更清晰,更容易区分哪些是普通变量,哪些是响应式变量。

3. 官方的态度

尤雨溪曾在社区讨论中表示,如果 Vue 3 重新设计,也许会只保留 ref。虽然 reactive 为了 API 的完整性被保留了,但在实际业务开发中,全量使用 ref 是目前最推荐的最佳实践。

总结

  • Reactive: 基于 Proxy,只对对象有效,存在解构和重新赋值丢失响应性的风险。
  • Ref: 基于 class 的 getter/setter,统一了基本类型和对象的处理(对象内部调用 reactive)。
  • 结论: 建议在项目中优先使用 ref 。它更加稳健,不易出错,且能够清晰地标识出“这是一个响应式数据”。

详细说说vue2何vue3的工作原理以及区别?为什么vue3会做此升级?

这是一个非常有分量的架构级问题。要讲清楚 Vue 2 到 Vue 3 的演进,我们需要从底层的响应式原理、虚拟 DOM (Virtual DOM) 的编译策略以及运行时性能三个维度深入剖析。

以下是从源码和架构设计层面的详细解读:


一、 什么是虚拟 DOM (Virtual DOM)?

在理解 Vue 原理之前,必须先理解虚拟 DOM。

1. 定义:
虚拟 DOM 本质上是一个 JavaScript 对象(VNode) ,它是真实 DOM 的“蓝图”或“替身”。

2. 为什么需要它?

  • 操作真实 DOM 代价高昂:真实 DOM 节点非常重(包含大量属性和事件)。频繁操作 DOM 会导致浏览器频繁重排(Reflow)和重绘(Repaint),性能极差。
  • JS 计算很快:在 JS 层面通过对比两个对象(新旧 VNode)的差异(Diff),计算出最小的更变操作,然后再去更新真实 DOM,效率最高。

3. 结构示例:

// 真实 DOM: <div class="box">Hello</div>
// 虚拟 DOM (VNode):
const vnode = {
  tag: 'div',
  props: { class: 'box' },
  children: 'Hello',
  // Vue3 新增了 patchFlag 等编译优化标记
}


二、 Vue 2 的工作原理

Vue 2 的核心是 Options API 和基于 Object.defineProperty 的响应式系统。

1. 响应式原理 (Reactivity)

Vue 2 在初始化(initState)时,会递归遍历 data 中的所有属性。

  • 核心 APIObject.defineProperty

  • 源码逻辑

    • Observer(观察者) :递归把对象属性转为 getter/setter。
    • Dep(依赖容器) :每个属性闭包里都有一个 Dep 实例,用来存放到到底谁用了我。
    • Watcher(订阅者) :组件渲染函数、computed、watch 都是 Watcher。
// Vue 2 响应式简化版
Object.defineProperty(obj, key, {
  get() {
    // 1. 依赖收集:如果当前有正在计算的 Watcher,就把它收集进 Dep
    if (Dep.target) dep.depend();
    return value;
  },
  set(newVal) {
    if (newVal === value) return;
    value = newVal;
    // 2. 派发更新:通知 Dep 里所有的 Watcher 去 update
    dep.notify();
  }
});

2. Vue 2 的痛点

  1. 初始化慢:因为是递归遍历,如果 data 对象很大,启动(Init)阶段会非常耗时,且内存占用高。
  2. 动态性不足:无法监听对象属性的新增(add)和删除(delete),必须用 $set / $delete
  3. 数组限制:无法拦截数组索引修改(arr[0] = 1),Vue 2 重写了数组的 7 个变异方法(push, pop...)来实现响应式。

3. 虚拟 DOM 与 Diff (Vue 2)

Vue 2 的 Diff 算法是 全量对比
当数据变化时,Vue 2 会重新生成整个组件的 VNode 树,然后和旧的 VNode 树进行对比(双端比较算法)。即使有些节点及其子节点永远不会变(静态节点),Vue 2 依然会去比对它们。


三、 Vue 3 的工作原理

Vue 3 在响应式系统和编译优化上做了彻底的重构。

1. 响应式原理 (Reactivity)

Vue 3 使用 Proxy 替代了 defineProperty。代码位于 packages/reactivity

  • 核心 APIProxy + Reflect

  • 源码逻辑

    • 不再需要 Observer 类,直接返回一个 Proxy 代理。
    • Track(依赖收集) :当读取属性时触发 track(target, key),将副作用函数(Effect)存入全局的 WeakMap
    • Trigger(派发更新) :当修改属性时触发 trigger(target, key),从 WeakMap 取出 Effect 执行。
// Vue 3 响应式简化版
new Proxy(target, {
  get(target, key, receiver) {
    track(target, key); // 收集依赖
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    const res = Reflect.set(target, key, value, receiver);
    trigger(target, key); // 触发更新
    return res;
  }
})

  • 优势

    • 懒代理(Lazy) :只有访问到深层对象时,才会将其转为 Proxy,初始化飞快。
    • 全能拦截:支持新增、删除属性,支持数组索引修改,支持 Map/Set。

2. 编译优化 (Compiler Optimization) —— 核心升级

Vue 3 的 Diff 算法不仅仅是快,而是**“更聪明”** 。它在编译阶段(Template -> Render Function) 做了大量标记,让运行时(Runtime) 跑得更快。

  • PatchFlags (动态标记) :
    在编译时,Vue 3 会分析模板,给动态节点打上“二进制标记”。
    比如:<div :class="cls">123</div>
    Vue 3 知道只有 class 是动态的,Diff 时只对比 class ,完全忽略内容。

  • Block Tree (区块树) :
    Vue 3 将模板切分成 Block,配合 PatchFlags,Diff 时直接跳过静态节点,只遍历动态节点数组。

    • Vue 2 Diff 复杂度 = 模板总体积
    • Vue 3 Diff 复杂度 = 动态节点的数量
  • Hoist Static (静态提升) :
    静态的节点(如 <p>永远不变</p>)在内存中只创建一次,后续更新直接复用,不再重复创建 VNode。


四、 Vue 2 和 Vue 3 的比较与区别

特性 Vue 2 Vue 3
响应式底层 Object.defineProperty Proxy
检测能力 无法检测属性增删、数组索引修改 完全支持
初始化性能 递归遍历所有属性(慢、内存高) 懒代理,访问时才转换(快)
代码组织 Options API (data, methods 分离) Composition API (逻辑关注点聚合)
Diff 算法 全量双端比较,静态节点也要比 静态标记 + Block Tree,只比动态节点
TypeScript 支持较弱,类型推断困难 核心由 TS 编写,TS 支持极其友好
体积 较大,难以 Tree-shaking 模块化拆分,支持 Tree-shaking,体积更小
Fragment 组件只能有一个根节点 支持多根节点 (Fragment)

五、 为什么 Vue 3 要做这些升级?

尤雨溪和团队进行 Vue 3 重构主要为了解决 Vue 2 的三个核心瓶颈:

1. 性能瓶颈 (Performance)

Vue 2 的响应式初始化是递归的,对于大数据量的表格或列表,启动非常慢。且 Diff 算法在大型复杂组件中,无谓的静态节点对比消耗了大量 CPU。
Vue 3 通过 Proxy 和编译优化(静态标记),实现了“按需响应”和“靶向更新”,性能大幅提升。

2. 代码组织与复用瓶颈 (Scalability)

在 Vue 2 的 Options API 中,一个功能的逻辑被拆分到 datamethodswatch 里。当组件变得巨大(几千行代码)时,维护代码需要在文件里上下反复横跳(Jumping)。且 Mixin 代码复用存在命名冲突和来源不清晰的问题。
Vue 3 引入 Composition API (组合式 API) ,允许开发者按“逻辑功能”组织代码,完美解决了大型项目的维护难题,Hooks 更是取代了 Mixin。

3. TypeScript 支持 (Developer Experience)

Vue 2 的源码是 JS 写的,通过 Flow 做类型检查,对 TS 的支持是后期补丁(this 指向在 TS 中很难推断)。随着前端工程化对 TS 需求的爆发,Vue 2 显得力不从心。
Vue 3 使用 TypeScript 重写,提供了原生的、极佳的类型推断体验。

总结

  • 原理层面:Vue 2 是劫持 setter/getter,Vue 3 是代理整个对象。
  • 更新机制:Vue 2 是全量树对比,Vue 3 是基于静态标记的动态节点追踪。
  • 目的:Vue 3 的升级是为了更快(性能)、更小(体积)、更易维护(组合式 API)以及更好的 TS 支持

从原理到实现:基于 Y.js 和 Tiptap 的实时在线协同编辑器全解析

引言

在现代办公和学习场景中,多人实时协同编辑变得越来越重要。想象一下,团队成员可以同时编辑同一份文档,每个人的光标和输入都实时可见,就像坐在同一个会议室里一样。这种功能在 Google Docs、Notion 等应用中已经变得司空见惯。今天,我将带你深入剖析如何基于 Y.js、WebRTC 和 Tiptap 构建一个完整的实时协同编辑器。

技术架构概览

我们的协同编辑系统主要由三部分组成:

  1. 前端编辑器 (TiptapEditor.vue) - 基于 Vue 3 和 Tiptap 的富文本编辑器
  2. 协同框架 (Y.js) - 负责文档状态同步和冲突解决
  3. 信令服务器 (signaling-server.js) - WebRTC 连接的中介服务
用户A浏览器 ↔ WebRTC ↔ 用户B浏览器
      ↑                       ↑
     Y.js ←→ 协同状态 ←→ Y.js
      ↓                       ↓
   Tiptap编辑器           Tiptap编辑器

核心原理深度解析

1. Y.js 的 CRDT 算法

Y.js 之所以能实现无冲突的实时协同,是因为它采用了 CRDT(Conflict-Free Replicated Data Types,无冲突复制数据类型) 算法。

传统方案的问题:

  • 如果两个用户同时编辑同一位置,传统方案需要通过锁机制或最后写入者胜出的策略
  • 这些方案要么影响用户体验,要么可能导致数据丢失

CRDT 的解决方案:

  • 每个操作都有唯一的标识符(时间戳 + 客户端ID)
  • 操作是 可交换、可结合、幂等
  • 无论操作以什么顺序到达,最终状态都是一致的
// 示例:Y.js 如何解决冲突
用户A: 在位置2插入"X" → 操作ID: [时间A, 客户端A]
用户B: 在位置2插入"Y" → 操作ID: [时间B, 客户端B]

// 即使两个操作同时发生,最终文档会变成"YX"或"XY"
// 具体顺序由操作ID决定,但所有客户端都会得到相同的结果

2. WebRTC 的 P2P 通信

WebRTC(Web Real-Time Communication)允许浏览器之间直接通信,无需通过中心服务器转发数据。

关键优势:

  • 低延迟:数据直接在浏览器间传输
  • 减轻服务器压力:服务器只负责建立连接(信令)
  • 去中心化:更健壮的系统架构

建立连接的三个步骤:

  1. 信令交换:通过信令服务器交换SDP和ICE候选
  2. NAT穿透:使用STUN/TURN服务器建立直接连接
  3. 数据传输:直接传输Y.js的更新数据

3. 文档模型映射

Tiptap(基于 ProseMirror)使用树状结构表示文档,而Y.js使用线性结构。这两者之间需要建立映射关系:

ProseMirror文档树:
document
├─ paragraph
│  ├─ text "Hello"
│  └─ text(bold) "World"
└─ bullet_list
   └─ list_item
      └─ paragraph "Item 1"

Y.js XML Fragment:
<document>
  <paragraph>Hello<bold>World</bold></paragraph>
  <bullet_list>
    <list_item><paragraph>Item 1</paragraph></list_item>
  </bullet_list>
</document>

实现细节剖析

1. 协同状态管理

让我们看看如何在 Vue 组件中管理协同状态:

// 用户信息管理
const userInfo = ref({
  name: `用户${Math.floor(Math.random() * 1000)}`,
  color: getRandomColor() // 每个用户有独特的颜色
})

// 在线用户列表
const onlineUsers = ref<any[]>([])

// 更新用户列表的函数
const updateOnlineUsers = () => {
  if (!provider.value || !provider.value.awareness) return
  
  const states = Array.from(provider.value.awareness.getStates().entries())
  const users: any[] = []
  
  states.forEach(([clientId, state]) => {
    if (state && state.user) {
      users.push({
        clientId,
        ...state.user,
        isCurrentUser: clientId === provider.value.awareness.clientID
      })
    }
  })
  
  onlineUsers.value = users
}

Awareness 系统是Y.js的一个关键特性:

  • 跟踪每个用户的 状态(姓名、颜色、光标位置等)
  • 实时广播状态变化
  • 处理用户加入/离开事件

2. 编辑器的双重模式

我们的编辑器支持两种模式,需要平滑切换:

// 单机模式初始化
const reinitEditorWithoutCollaboration = () => {
  editor.value = new Editor({
    extensions: [StarterKit, Bold, Italic, Heading, ...],
    content: '<h1>欢迎使用编辑器</h1>...' // 静态内容
  })
}

// 协同模式初始化
const reinitEditorWithCollaboration = () => {
  // 关键:协同模式下不设置初始内容
  editor.value = new Editor({
    extensions: [
      Collaboration.configure({ // 协同扩展必须放在最前面
        document: ydoc.value,
        field: 'prosemirror',
      }),
      StarterKit.configure({ history: false }), // 禁用内置历史
      Bold, Italic, Heading, ...
    ],
    // 不设置 content,由Y.js提供
  })
}

关键区别:

  • 协同模式使用 Collaboration 扩展,禁用 history
  • 内容从 Y.Doc 加载,而不是静态设置
  • 所有操作通过Y.js同步

3. WebRTC 连接的生命周期

const initCollaboration = () => {
  // 1. 创建Y.js文档
  ydoc.value = new Y.Doc()
  
  // 2. 创建WebRTC提供者
  provider.value = new WebrtcProvider(roomId.value, ydoc.value, {
    signaling: ['ws://localhost:1234'], // 信令服务器地址
    password: null,
  })
  
  // 3. 设置用户awareness
  provider.value.awareness.setLocalStateField('user', userInfo.value)
  
  // 4. 监听连接状态
  provider.value.on('status', (event) => {
    isConnected.value = event.status === 'connected'
  })
  
  // 5. 监听同步完成
  provider.value.on('synced', (event) => {
    console.log('文档同步完成:', event.synced)
  })
}

4. 信令服务器的实现

信令服务器虽然简单,但至关重要:

// 房间管理
const rooms = new Map() // roomId -> Set of WebSocket connections

wss.on('connection', (ws) => {
  ws.on('message', (message) => {
    const data = JSON.parse(message.toString())
    
    if (data.type === 'subscribe') {
      // 客户端加入房间
      const topic = data.topic
      if (!rooms.has(topic)) rooms.set(topic, new Set())
      rooms.get(topic).add(ws)
    }
    else if (data.type === 'publish') {
      // 转发消息给房间内其他客户端
      const roomClients = rooms.get(data.topic)
      roomClients.forEach((client) => {
        if (client !== ws) { // 不转发给自己
          client.send(JSON.stringify(data))
        }
      })
    }
  })
})

信令服务器的作用:

  1. 房间管理:维护哪些客户端在哪个房间
  2. 消息转发:将SDP和ICE候选转发给对等方
  3. 连接建立:帮助WebRTC建立P2P连接

实时协同的工作流程

让我们通过一个具体场景来看系统如何工作:

场景:用户A和用户B协同编辑

1. 用户A打开编辑器
   ├─ 初始化Y.js文档
   ├─ 创建WebRTC提供者
   ├─ 连接信令服务器
   └─ 加入房间"room-abc123"

2. 用户B通过链接加入同一房间
   ├─ 初始化Y.js文档(相同roomId)
   ├─ WebRTC通过信令服务器发现用户A
   └─ 建立直接P2P连接

3. 用户A输入文字"Hello"
   ├─ Tiptap生成ProseMirror事务
   ├─ Collaboration扩展转换为Y.js操作
   ├─ Y.js操作通过WebRTC发送给用户B
   └─ 用户B的Y.js应用操作,更新Tiptap

4. 用户B同时输入"World"
   ├─ 同样流程反向进行
   ├─ Y.js的CRDT确保顺序一致性
   └─ 最终双方都看到"HelloWorld"

视觉反馈的实现

为了让用户感知到其他协作者的存在:

/* 其他用户的光标样式 */
.ProseMirror-y-cursor {
  border-left: 2px solid; /* 使用用户颜色 */
}

.ProseMirror-y-cursor > div {
  /* 显示用户名的标签 */
  background-color: var(--user-color);
  color: white;
  padding: 2px 6px;
  border-radius: 3px;
}
// 用户状态显示
<div v-for="user in onlineUsers" :key="user.clientId" 
     class="user-tag"
     :style="{
       backgroundColor: user.color + '20',
       borderColor: user.color,
       color: user.color
     }">
  <span class="user-avatar" :style="{ backgroundColor: user.color }"></span>
  {{ user.name }}
</div>

性能优化与注意事项

1. 延迟优化

// 批量更新,减少网络传输
provider.value.awareness.setLocalState({
  user: userInfo.value,
  cursor: editor.value.state.selection.from,
  // 其他状态...
})

// 节流频繁更新
let updateTimeout
const throttledUpdate = () => {
  clearTimeout(updateTimeout)
  updateTimeout = setTimeout(updateOnlineUsers, 100)
}

2. 错误处理与降级

try {
  // 尝试WebRTC连接
  provider.value = new WebrtcProvider(roomId.value, ydoc.value, config)
} catch (error) {
  console.error('WebRTC连接失败,降级到模拟模式:', error)
  
  // 降级策略:模拟协同,实际为单机
  isConnected.value = true
  onlineUsers.value = [{
    clientId: 1,
    ...userInfo.value,
    isCurrentUser: true
  }]
  
  // 提示用户
  showToast('协同模式不可用,已切换到单机模式')
}

3. 内存管理

// 组件卸载时清理
onBeforeUnmount(() => {
  if (editor.value) editor.value.destroy()
  if (provider.value) {
    provider.value.disconnect()
    provider.value.destroy()
  }
  if (ydoc.value) ydoc.value.destroy()
})

最终效果

在这里插入图片描述 两个用户同时编辑,各在互不影响 在这里插入图片描述

部署与扩展

1. 生产环境部署

// 生产环境信令服务器配置
const provider = new WebrtcProvider(roomId, ydoc, {
  signaling: [
    'wss://signaling1.yourdomain.com',
    'wss://signaling2.yourdomain.com' // 多节点冗余
  ],
  password: 'secure-room-password', // 房间密码保护
  maxConns: 20, // 限制最大连接数
})

2. 扩展功能

  • 离线支持:使用 IndexedDB 存储本地副本
  • 版本历史:利用 Y.js 的快照功能
  • 权限控制:不同用户的不同编辑权限
  • 插件系统:扩展编辑器功能

总结

构建实时协同编辑器是一个复杂的系统工程,涉及多个技术栈:

  1. Y.js 提供了理论基础(CRDT算法)和核心同步能力
  2. WebRTC 实现了高效的P2P数据传输
  3. Tiptap 提供了优秀的编辑器体验和扩展性
  4. Vue 3 构建了响应式的用户界面

这个项目的关键成功因素在于各个组件之间的无缝集成。Y.js处理数据一致性,WebRTC处理网络通信,Tiptap处理用户交互,而Vue将它们有机地组合在一起。

完整代码联系作者获取!

企业级RBAC 实战(八)手撸后端动态路由,拒绝前端硬编码

在企业级后台中,硬编码路由(写死在 router/index.js)是维护的噩梦。本文将深入讲解如何根据用户权限动态生成侧边栏菜单。我们将构建后端的 getRouters 递归接口,并在前端利用 Vite 的 import.meta.glob 实现组件的动态挂载,最后彻底解决路由守卫中经典的“死循环”和“刷新白屏”问题。

学习之前先浏览 前置专栏文章

一、 引言:为什么要做动态路由?

在简单的后台应用中,我们通常会在前端 router/routes.ts 中写死所有路由。但在 RBAC(基于角色的权限控制)模型下,这种做法有两个致命缺陷:

  1. 安全性低:普通用户虽然看不到菜单,但如果在浏览器地址栏手动输入 URL,依然能进入管理员页面。
  2. 维护成本高:每次新增页面都要修改前端代码并重新打包部署。

目标:前端只保留“登录”和“404”等基础页面,其他所有业务路由由后端根据当前用户的角色权限动态返回。

二、 后端实现:构建路由树 (getRouters)

后端的核心任务是:查询当前用户的菜单 -> 过滤掉隐藏的/无权限的 -> 组装成 Vue Router 需要的 JSON 树。

1. 数据结构转换

我们在上一篇设计了 sys_menus 表。Vue Router 需要的结构包含 path, component, meta 等字段。我们需要一个递归函数将扁平的数据库记录转为树形结构。

文件:routes/menu.js

// 辅助函数:将数据库扁平数据转为树形结构
function buildTree(items, parentId = 0) {
  const result = []
  for (const item of items) {
    // 兼容字符串和数字的 ID 对比
    if (item.parent_id == parentId) {
      // 组装 Vue Router 标准结构
      const route = {
        name: toCamelCase(item.path), // 自动生成驼峰 Name
        path: item.path,
        hidden: item.hidden === 1,    // 数据库 1/0 转布尔
        component: item.component,    // 此时还是字符串,如 "system/user/index"
         // 只有当 redirect 有值时才添加该字段
        ...(item.redirect && { redirect: item.redirect }),
        // 只有当 always_show 为 1 时才添加,并转为布尔
        ...(item.alwaysShow === 1 && { alwaysShow: true }),
        meta: {
          title: item.menu_name,
          icon: item.icon,
          noCache: item.no_cache === 1
        }
      }
      
      const children = buildTree(items, item.id)
      if (children.length > 0) {
        route.children = children
      }
      result.push(route)
    }
  }
  return result
}

2. 接口实现

这里有一个关键逻辑:上帝模式普通模式的区别。

  • Admin:直接查 sys_menus 全表(排除被物理删除的)。
  • 普通用户:通过 sys_users -> sys_roles -> sys_role_menus -> sys_menus 进行四表联查,只获取拥有的权限。

文件 route/menu.js

router.get('/getRouters', authMiddleware, async (req, res, next) => {
  try {
    const userId = req.user.userId
    const { isAdmin } = req.user

    let sql = ''
    let params = []

    const baseFields = `m.id, m.parent_id, m.menu_name, m.path, m.component, m.icon, m.hidden`

    if (isAdmin) {
      // 管理员:看所有非隐藏菜单
      sql = `SELECT ${baseFields} FROM sys_menus m WHERE m.hidden = 0 ORDER BY m.sort ASC`
    } else {
      // 普通用户:通过中间表关联查询
      sql = `
        SELECT ${baseFields} 
        FROM sys_menus m
        LEFT JOIN sys_role_menus rm ON m.id = rm.menu_id
        LEFT JOIN sys_users u ON u.role_id = rm.role_id
        WHERE u.id = ? AND m.hidden = 0
        ORDER BY m.sort ASC
      `
      params.push(userId)
    }

    const [rows] = await pool.query(sql, params)
    const menuTree = buildTree(rows)

    res.json({ code: 200, data: menuTree })
  } catch (err) {
    next(err)
  }
})

三、 前端实现:组件动态加载

前端拿到后端的 JSON 后,最大的难点在于:后端返回的 component 是字符串 "system/user/index",而 Vue Router 需要的是一个 Promise 组件对象 () => import(...)

在 Webpack 时代我们要用 require.context,而在 Vite 中,我们要用 import.meta.glob。

1. Store 逻辑 (store/modules/permission.ts)

import { defineStore } from 'pinia'
import { constantRoutes } from '@/router'
import { getRouters } from '@/api/menu'
import Layout from '@/layout/index.vue'

// 1. Vite 核心:一次性匹配 views 目录下所有 .vue 文件
// 结果类似: { '../../views/system/user.vue': () => import(...) }
const modules = import.meta.glob('../../views/**/*.vue')

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    routes: [],        // 完整路由(侧边栏用)
    addRoutes: [],     // 动态路由(router.addRoute用)
    sidebarRouters: [] // 侧边栏菜单
  }),
  
  actions: {
    async generateRoutes() {
      // 请求后端
      const res: any = await getRouters()
      const sdata = JSON.parse(JSON.stringify(res.data))
      
      // 转换逻辑
      const rewriteRoutes = filterAsyncRouter(sdata)
      
      this.addRoutes = rewriteRoutes
      this.sidebarRouters = constantRoutes.concat(rewriteRoutes)
      
      return rewriteRoutes
    }
  }
})

// 遍历后台传来的路由字符串,转换为组件对象
function filterAsyncRouter(asyncRouterMap: any[]) {
  return asyncRouterMap.filter(route => {
    if (route.component) {
      if (route.component === 'Layout') {
        route.component = Layout
      } else {
        // 核心:根据字符串去 modules 里找对应的 import 函数
        route.component = loadView(route.component)
      }
    }
    // ... 递归处理 children
    if (route.children && route.children.length) {
      route.children = filterAsyncRouter(route.children)
    }
    return true
  })
}

export const loadView = (view: string) => {
  let res
  for (const path in modules) {
    // 路径匹配逻辑:从 ../../views/system/user.vue 中提取 system/user
    const dir = path.split('views/')[1].split('.vue')[0]
    if (dir === view) {
      res = () => modules[path]()
    }
  }
  return res
}

四、 核心难点:路由守卫与“死循环”

在 src/permission.ts 中,我们需要拦截页面跳转,如果是第一次进入,则请求菜单并添加到路由中。

1. 经典死循环问题

很多新手会这样写判断:

// ❌ 错误写法
if (userStore.roles.length === 0) {
  // 去拉取用户信息 -> 生成路由 -> next()
}

Bug 场景:如果新建了一个没有任何角色的用户 user01,后端返回的 roles 是空数组。

  1. roles.length 为 0,进入 if。
  2. 拉取信息,发现还是空数组。
  3. next(to) 重定向,重新进入守卫。
  4. roles.length 依然为 0... 死循环,浏览器崩溃

2. 解决方案:引入 isInfoLoaded 状态位

我们在 userStore 中增加一个 isInfoLoaded 布尔值,专门标记“是否已经尝试过拉取用户信息”

文件:src/permission.ts

import router from '@/router'
import { useUserStore } from '@/store/modules/user'
import { usePermissionStore } from '@/store/modules/permission' // 引入新的 store
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

NProgress.configure({ showSpinner: false })

const whiteList = ['/login', '/404']

router.beforeEach(async (to, from, next) => {
  NProgress.start()
  const userStore = useUserStore()
  const permissionStore = usePermissionStore()

  if (userStore.token) {
    if (to.path === '/login') {
      next({ path: '/' })
      NProgress.done()
    } else {
      // 判断当前用户是否已拉取完 user_info 信息
      // 这里我们可以简单判断:如果 userStore.userInfo.roles.length==0 动态添加的菜单长度为0,说明还没请求菜单,但似乎 这么写  如果 用户没角色  会陷入死循环
      if (!userStore.isInfoLoaded) {
        try {
          // 2. 生成动态路由 (后端请求)
          const userInfo = await userStore.getInfo()
          console.log('userInfo--', userInfo)
          if (userInfo && userInfo.data.id) {
            const accessRoutes = await permissionStore.generateRoutes()
            // // 3. 后端返回的路由
            console.log('accessRoutes', accessRoutes)
            // 4. 动态添加路由
            accessRoutes.forEach((route) => {
              router.addRoute(route)
            })
          }
          // 4. 确保路由添加完成 (Hack方法)
          next({ path: to.path, query: to.query, replace: true })
        } catch (err) {
          console.log('userinfo -err', err)
          userStore.logout()
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      } else {
        next()
      }
    }
  } else {
    if (whiteList.includes(to.path)) {
      console.log('to.path', to.path)
      next()
    } else {
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    }
  }
})

router.afterEach(() => {
  NProgress.done()
})

五、 侧边栏递归渲染

最后一步是将 sidebarRouters 渲染到左侧菜单. 这里需要注意一个细节:el-tree 或 el-menu 的父节点折叠问题
如果一个目录只有一个子菜单(例如“首页”),我们通常希望直接显示子菜单,不显示父目录。

文件 layout/components/slideBar/index.vue

<template>
  <div :class="classObj">
    <Logo v-if="showLogo" :collapse="isCollapse" />
    <el-scrollbar wrap-class="scrollbar-wrapper" :class="sideTheme">
      <el-menu
        router
        :default-active="activeMenu"
        :collapse="isCollapse"
        :background-color="
          settingsStore.sideTheme === 'dark'
            ? variables.menuBackground
            : variables.menuLightBackground
        "
        :text-color="
          settingsStore.sideTheme === 'dark'
            ? variables.menuColor
            : variables.menuLightColor
        "
        :active-text-color="theme"
        :unique-opened="true"
        :collapse-transition="false"
      >
        <template v-for="item in slidebarRouters" :key="item.path">
          <!-- 如果只有一个子菜单,直接显示子菜单 -->
          <el-menu-item
            v-if="item.children && item.children.length === 1"
            :index="item.path + '/' + item.children[0].path"
          >
            <el-icon v-if="item.children[0].meta?.icon">
              <component :is="item.children[0].meta.icon" />
            </el-icon>
            <span>{{ item.children[0].meta.title }}</span>
          </el-menu-item>

          <!-- 如果有多个子菜单,显示下拉菜单 -->
          <el-sub-menu
            v-else-if="item.children && item.children.length > 1"
            :index="item.path"
          >
            <template #title>
              <el-icon v-if="item.meta?.icon">
                <component :is="item.meta.icon" />
              </el-icon>
              <span>{{ item.meta.title }}</span>
            </template>
            <el-menu-item
              v-for="subItem in item.children"
              :key="subItem.path"
              :index="item.path + '/' + subItem.path"
            >
              <el-icon v-if="subItem.meta?.icon">
                <component :is="subItem.meta.icon" />
              </el-icon>
              <span>{{ subItem.meta.title }}</span>
            </el-menu-item>
          </el-sub-menu>

          <!-- 如果没有子菜单,直接显示当前菜单 -->
          <el-menu-item v-else :index="item.path">
            <el-icon v-if="item.meta?.icon">
              <component :is="item.meta.icon" />
            </el-icon>
            <span>{{ item.meta.title }}</span>
          </el-menu-item>
        </template>
      </el-menu>
    </el-scrollbar>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import Logo from './Logo.vue'
import { useRoute } from 'vue-router'
import variables from '@/assets/styles/var.module.scss'
const route = useRoute()

import { useSettingsStore } from '@/store/modules/settings'
import { useAppStore } from '@/store/modules/app'
import { usePermissionStore } from '@/store/modules/permission'
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const permissionStore = usePermissionStore()
const sideTheme = computed(() => settingsStore.sideTheme)
const theme = computed(() => settingsStore.theme)
const showLogo = computed(() => settingsStore.showLogo)
const isCollapse = computed(() => !appStore.sidebar.opened)
const classObj = computed(() => ({
  dark: sideTheme.value === 'dark',
  light: sideTheme.value === 'light',
  'has-logo': settingsStore.showLogo,
}))

const slidebarRouters = computed(() =>
  permissionStore.sidebarRouters.filter((item) => {
    return !item.hidden
  })
)

console.log('slidebarRouters', slidebarRouters.value)

// 激活菜单
const activeMenu = computed(() => {
  const { path } = route
  return path
})
</script>

<style scoped lang="scss">
#app {
  .main-container {
    height: 100%;
    transition: margin-left 0.28s;
    margin-left: $base-sidebar-width;
    position: relative;
  }

  .sidebarHide {
    margin-left: 0 !important;
  }

  .sidebar-container {
    -webkit-transition: width 0.28s;
    transition: width 0.28s;
    width: $base-sidebar-width !important;
    background-color: $base-menu-background;
    height: 100%;
    position: fixed;
    font-size: 0px;
    top: 0;
    bottom: 0;
    left: 0;
    z-index: 1001;
    overflow: hidden;
    -webkit-box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
    box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);

    // reset element-ui css
    .horizontal-collapse-transition {
      transition:
        0s width ease-in-out,
        0s padding-left ease-in-out,
        0s padding-right ease-in-out;
    }

    .scrollbar-wrapper {
      overflow-x: hidden !important;
    }

    .el-scrollbar__bar.is-vertical {
      right: 0px;
    }

    .el-scrollbar {
      height: 100%;
    }

    &.has-logo {
      .el-scrollbar {
        height: calc(100% - 50px);
      }
    }

    .is-horizontal {
      display: none;
    }

    a {
      display: inline-block;
      width: 100%;
      overflow: hidden;
    }

    .svg-icon {
      margin-right: 16px;
    }

    .el-menu {
      border: none;
      height: 100%;
      width: 100% !important;
    }

    .el-menu-item,
    .menu-title {
      overflow: hidden !important;
      text-overflow: ellipsis !important;
      white-space: nowrap !important;
    }

    .el-menu-item .el-menu-tooltip__trigger {
      display: inline-block !important;
    }

    // menu hover
    .sub-menu-title-noDropdown,
    .el-sub-menu__title {
      &:hover {
        background-color: rgba(0, 0, 0, 0.06) !important;
      }
    }

    & .theme-dark .is-active > .el-sub-menu__title {
      color: $base-menu-color-active !important;
    }

    & .nest-menu .el-sub-menu > .el-sub-menu__title,
    & .el-sub-menu .el-menu-item {
      min-width: $base-sidebar-width !important;

      &:hover {
        background-color: rgba(0, 0, 0, 0.06) !important;
      }
    }

    & .theme-dark .nest-menu .el-sub-menu > .el-sub-menu__title,
    & .theme-dark .el-sub-menu .el-menu-item {
      background-color: $base-sub-menu-background !important;

      &:hover {
        background-color: $base-sub-menu-hover !important;
      }
    }
  }

  .hideSidebar {
    .sidebar-container {
      width: 54px !important;
    }

    .main-container {
      margin-left: 54px;
    }

    .sub-menu-title-noDropdown {
      padding: 0 !important;
      position: relative;

      .el-tooltip {
        padding: 0 !important;

        .svg-icon {
          margin-left: 20px;
        }
      }
    }

    .el-sub-menu {
      overflow: hidden;

      & > .el-sub-menu__title {
        padding: 0 !important;

        .svg-icon {
          margin-left: 20px;
        }
      }
    }

    .el-menu--collapse {
      .el-sub-menu {
        & > .el-sub-menu__title {
          & > span {
            height: 0;
            width: 0;
            overflow: hidden;
            visibility: hidden;
            display: inline-block;
          }
          & > i {
            height: 0;
            width: 0;
            overflow: hidden;
            visibility: hidden;
            display: inline-block;
          }
        }
      }
    }
  }

  .el-menu--collapse .el-menu .el-sub-menu {
    min-width: $base-sidebar-width !important;
  }

  // mobile responsive
  .mobile {
    .main-container {
      margin-left: 0px;
    }

    .sidebar-container {
      transition: transform 0.28s;
      width: $base-sidebar-width !important;
    }

    &.hideSidebar {
      .sidebar-container {
        pointer-events: none;
        transition-duration: 0.3s;
        transform: translate3d(-$base-sidebar-width, 0, 0);
      }
    }
  }

  .withoutAnimation {
    .main-container,
    .sidebar-container {
      transition: none;
    }
  }
}

// when menu collapsed
.el-menu--vertical {
  & > .el-menu {
    .svg-icon {
      margin-right: 16px;
    }
  }

  .nest-menu .el-sub-menu > .el-sub-menu__title,
  .el-menu-item {
    &:hover {
      // you can use $sub-menuHover
      background-color: rgba(0, 0, 0, 0.06) !important;
    }
  }

  // the scroll bar appears when the sub-menu is too long
  > .el-menu--popup {
    max-height: 100vh;
    overflow-y: auto;

    &::-webkit-scrollbar-track-piece {
      background: #d3dce6;
    }

    &::-webkit-scrollbar {
      width: 6px;
    }

    &::-webkit-scrollbar-thumb {
      background: #99a9bf;
      border-radius: 20px;
    }
  }
}

.dark {
  background-color: $base-menu-background !important;
}
.light {
  background-color: $base-menu-light-background !important;
}
.scrollbar-wrapper {
  overflow-x: hidden !important;
}
.has-logo {
  .el-scrollbar {
    height: calc(100% - 50px);
  }
}
</style>

六、 总结与下篇预告

通过本篇实战,我们实现了:

  1. 后端:根据角色权限过滤菜单数据。
  2. 前端:利用 Vite 的 glob 导入特性,将后端字符串动态转为前端组件。
  3. 守卫:通过 isInfoLoaded 状态位完美解决了空权限用户的死循环问题。

现在的系统已经具备了动态菜单的能力。但是,如何更方便地管理这些用户和角色呢?  如果用户很多,列表怎么分页?怎么模糊搜索?

下一篇:《企业级全栈 RBAC 实战 (九):用户管理与 SQL 复杂查询优化》,我们将深入 Element Plus 表格组件与 MySQL 分页查询的结合。

Vue3 源码学习笔记(二): 理解发布-订阅模式和实现简易响应式

当我们导入 vue 3 的特定构建版本,在浏览器打开html文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>响应式demo</title>
</head>
<body>
<script type="module">
    import {ref, effect } from '../../../node_modules/vue/dist/vue.esm-browser.prod.js'
    
    const count = ref(0)
    effect (()=> {
        console.log('count.value ==>',count.value)
    })
    setTimeout(()=>{
        count.value = 1
    }, 1000)
</script>
</body>
</html>

控制台会在1s后打印count的最新值

image.png

我们会发现,只要 count 的值变了,副作用函数就会自动重新执行。这背后的原因是什么?

我们知道 vue3 的响应式系统本质就是一个高度自动化的发布-订阅模式(Publish-Subscribe Pattern)

什么是发布-订阅模式(Publish-Subscribe Pattern)

  • 以点外卖为例
    • 发布者 = 餐厅的菜品

      • 持有状态:餐厅掌握着所有菜的状态
      • 发布通知:当菜品状态变化时,会自动发出"我变了"的信号
      • 特点:菜品不知道自己被谁关注,只负责在变化时发出信号
    • 订阅者 = 顾客的查看行为

      • 关注状态:顾客在执行点餐流程时,会查看某些菜品
      • 希望被通知:当关注的菜品变化时,希望重新执行点餐流程
      • 特点:只关心自己查看过的菜品,不关心其他菜品
    • 事件通道 = 外卖平台

      • 记录关系:当顾客查看菜品时,平台记录"顾客A关注了菜品X" (← 依赖收集
      • 桥梁作用:餐厅更新菜品状态,然后通知这些顾客重新执行点餐流程 (← 执行effect)

实现响应式

packages/reactivity/src 下新建ref.tseffect.ts 并且在 index.ts 导出。

我们要实现两个核心 API:

  • ref(value):创建一个响应式引用
  • effect(fn):创建一个副作用函数,自动追踪依赖
  1. 全局变量:记录当前正在执行的 effect
export let activeSub = undefined

export function effect(fn) {
    activeSub = fn     // 标记当前正在运行的副作用函数
    fn()               // 立即执行一次
    activeSub = undefined
}
  1. Ref 实现:发布者 + 注册表
import {activeSub} from "./effect";

enum ReactiveFlags {
    IS_REF = '__V_isRef'
}

/**
 * Ref实现类
 */
class RefImpl {
    // 保存实际的值
    _value
    // ref标记,证明是一个ref
   [ReactiveFlags.IS_REF] = true
    // 保存和 effect 之间的关联关系
    subs
    constructor(value) {
        this._value = value
    }

    /**
     * 依赖收集
     */
    get value() {
        // 如果 activeSub 有就保存起来,等更新时触发
        if(activeSub) {
            this.subs = activeSub
        }
        return this._value
    }

    /**
     * 触发更新
     * @param newVal
     */
    set value(newVal) {
        console.log('==>触发更新咯')
        this._value = newVal
        // 通知 effect 重新执行,获取最新的值
        this.subs?.()
    }
}


export function  ref(value) {
    return new RefImpl(value)
}

/**
 * 判断是不是一个 ref
 * @param value
 */
export function  isRef (value) {
    return !!(value && value[ReactiveFlags.IS_REF])
}

执行流程

步骤 1:调用 effect

effect(() => { console.log(count.value) })
  • 设置 activeSub = fn
  • 执行fn → 读取 count.value

步骤 2:读取 count.value(订阅发生)

  • 进入 RefImpl.get value
  • 发现 activeSub 存在 → 将当前函数存入 subs
  • 👉 完成订阅count 记住了“谁在用我”

步骤 3:修改 count.value = 1(发布发生)

  • 进入 RefImpl.set value
  • 更新 _value
  • 调用 this.subs?.() → 重新执行副作用函数
  • 👉 完成发布:通知订阅者“我变了!”

验证效果

将html文件的引入替换成import {ref, effect } from '../dist/reactivity.esm.js',这是本地打包的产物。

image.png 此时,实现了响应式的简易版本。

总结

  • Vue 3 的响应式系统本质是 隐式的发布-订阅模式
  • ref 是 发布者effect 是 订阅者.subs 是 注册表
  • 读取即订阅,修改即发布 —— 这就是响应式的魔法。

想了解更多 Vue 的相关知识,抖音、B站搜索远方os

Vue Router 进阶指南:打造丝滑的滚动控制与惊艳的路由动画

在现代单页应用(SPA)开发中,页面切换的流畅体验已成为衡量应用品质的重要标准。用户期望获得媲美原生应用的顺滑感受,而不仅仅是简单的页面跳转。Vue Router 作为 Vue.js 生态中的核心路由解决方案,提供了强大的滚动行为控制和路由过渡动画能力,让我们能够打造出令人印象深刻的用户体验。

一、掌握滚动行为:让页面“记住”它的位置

1.1 什么是滚动行为?

滚动行为指的是用户在路由切换时,页面滚动位置的智能管理。想象一下这样的场景:您在一个长列表页面滚动到中间位置,点击某个项目进入详情页,然后点击浏览器返回按钮——您是否希望直接回到刚才的列表位置?这正是滚动行为要解决的问题。

1.2 Vue Router 的滚动行为配置

Vue Router 提供了 scrollBehavior 选项,让我们可以定义路由切换时的滚动行为:

const router = createRouter({
  history: createWebHistory(),
  routes: [...],
  scrollBehavior(to, from, savedPosition) {
    // 返回滚动位置信息
    if (savedPosition) {
      // 有保存的位置时(如浏览器前进/后退)
      return savedPosition
    } else if (to.hash) {
      // 存在哈希锚点时
      return {
        el: to.hash,
        behavior: 'smooth' // 平滑滚动
      }
    } else {
      // 默认滚动到顶部
      return { top: 0, left: 0 }
    }
  }
})

1.3 高级滚动控制技巧

延迟滚动与异步组件结合

scrollBehavior(to, from, savedPosition) {
  // 等待页面渲染完成后再滚动
  return new Promise((resolve) => {
    setTimeout(() => {
      if (savedPosition) {
        resolve(savedPosition)
      } else if (to.hash) {
        resolve({
          el: to.hash,
          behavior: 'smooth',
          // 添加偏移量,避免被固定导航栏遮挡
          top: 80
        })
      } else {
        resolve({ top: 0, left: 0 })
      }
    }, 300) // 等待300ms,确保动态内容已加载
  })
}

页面特定滚动策略

scrollBehavior(to, from, savedPosition) {
  // 为特定路由禁用自动滚动
  if (to.meta.noScroll) {
    return false
  }
  
  // 在特定路由中保持滚动位置
  if (from.meta.keepScroll && to.meta.keepScroll) {
    return {}
  }
  
  // 默认行为
  return savedPosition || { top: 0 }
}

二、打造惊艳的路由切换动画

2.1 Vue 过渡系统与路由的完美结合

Vue 的 <Transition><TransitionGroup> 组件为路由动画提供了强大的基础。结合 Vue Router,我们可以创建各种炫酷的过渡效果。

基础路由过渡实现

<template>
  <RouterView v-slot="{ Component }">
    <Transition name="fade" mode="out-in">
      <component :is="Component" />
    </Transition>
  </RouterView>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

2.2 进阶动画:滑动过渡效果

水平滑动动画

<template>
  <RouterView v-slot="{ Component, route }">
    <Transition :name="transitionName">
      <component :is="Component" :key="route.path" />
    </Transition>
  </RouterView>
</template>

<script setup>
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'

const router = useRouter()
const transitionName = ref('slide-left')

watch(() => router.currentRoute.value, (to, from) => {
  // 根据路由深度判断滑动方向
  const toDepth = to.path.split('/').length
  const fromDepth = from.path.split('/').length
  transitionName.value = toDepth < fromDepth ? 'slide-right' : 'slide-left'
})
</script>

<style>
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
  transition: all 0.3s ease;
  position: absolute;
  width: 100%;
}

.slide-left-enter-from {
  transform: translateX(100%);
  opacity: 0;
}

.slide-left-leave-to {
  transform: translateX(-100%);
  opacity: 0;
}

.slide-right-enter-from {
  transform: translateX(-100%);
  opacity: 0;
}

.slide-right-leave-to {
  transform: translateX(100%);
  opacity: 0;
}
</style>

2.3 基于路由元信息的动态动画

为不同路由配置不同动画

// 路由配置
const routes = [
  {
    path: '/dashboard',
    component: Dashboard,
    meta: { transition: 'zoom' }
  },
  {
    path: '/settings',
    component: Settings,
    meta: { transition: 'fade' }
  }
]
<template>
  <RouterView v-slot="{ Component, route }">
    <Transition 
      :name="route.meta.transition || 'fade'"
      mode="out-in"
    >
      <component :is="Component" :key="route.path" />
    </Transition>
  </RouterView>
</template>

<style>
.zoom-enter-active,
.zoom-leave-active {
  transition: all 0.4s cubic-bezier(0.68, -0.55, 0.27, 1.55);
}

.zoom-enter-from {
  transform: scale(0.8);
  opacity: 0;
}

.zoom-leave-to {
  transform: scale(1.2);
  opacity: 0;
}
</style>

三、滚动行为与动画的协同优化

3.1 动画期间的滚动管理

在路由过渡动画期间,合理的滚动控制可以避免视觉混乱:

scrollBehavior(to, from, savedPosition) {
  // 如果启用了路由动画,延迟滚动
  if (to.meta.withAnimation) {
    return new Promise(resolve => {
      // 等待动画完成
      setTimeout(() => {
        resolve(savedPosition || { top: 0 })
      }, 500) // 与动画时长保持一致
    })
  }
  return savedPosition || { top: 0 }
}

3.2 性能优化建议

  1. 硬件加速:为动画元素添加 transform: translateZ(0)will-change: transform 启用GPU加速
  2. 动画简化:避免同时为过多属性添加动画,优先使用 transformopacity
  3. 节流处理:在快速连续导航时,取消未完成的动画

四、实战:完整的用户体验优化方案

下面是一个综合应用滚动行为和路由动画的完整示例:

<template>
  <div class="app-container">
    <AppHeader />
    <main class="main-content">
      <RouterView v-slot="{ Component, route }">
        <Transition
          :name="getTransitionName(route, $route)"
          @before-enter="onBeforeEnter"
          @after-enter="onAfterEnter"
          mode="out-in"
        >
          <KeepAlive :include="cachedRoutes">
            <component
              :is="Component"
              :key="route.fullPath"
              class="page-content"
            />
          </KeepAlive>
        </Transition>
      </RouterView>
    </main>
    <AppFooter />
  </div>
</template>

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

const router = useRouter()
const previousRoute = ref(null)

// 动态计算过渡名称
const getTransitionName = (to, from) => {
  if (!from.name) return 'fade'
  
  const toDepth = to.meta.depth || 0
  const fromDepth = from.meta.depth || 0
  
  if (toDepth < fromDepth) return 'slide-right'
  if (toDepth > fromDepth) return 'slide-left'
  
  return to.meta.transition || 'fade'
}

// 动画生命周期钩子
const onBeforeEnter = () => {
  // 动画开始前的准备工作
  document.body.style.overflow = 'hidden'
}

const onAfterEnter = () => {
  // 动画结束后的清理工作
  document.body.style.overflow = ''
}

// 需要缓存的组件
const cachedRoutes = computed(() => {
  return router.getRoutes()
    .filter(route => route.meta.keepAlive)
    .map(route => route.name)
    .filter(Boolean)
})
</script>

结语:打造极致的用户体验

通过精心设计的滚动行为和流畅的路由过渡动画,我们可以将普通的单页应用提升到新的高度。Vue Router 提供的这些功能不仅仅是技术实现,更是连接用户与内容的桥梁。记住,最好的用户体验往往是用户感受不到的——自然的滚动恢复、流畅的页面过渡,这些细节共同构成了应用的品质感。

从简单的淡入淡出到复杂的多层动画,从基本的滚动定位到智能的位置记忆,每一处优化都是对用户体验的深思熟虑。正如我们开始时提到的,现代用户期待的是丝滑流畅的交互体验。通过掌握 Vue Router 的滚动行为控制与自定义路由动画技术,我们不仅满足了这一期待,更创造了超越用户预期的愉悦体验。

在下一个 Vue 项目中,不妨尝试实现这些技巧,让您的应用在众多竞争者中脱颖而出,用细节打动每一位用户。

用一篇文章带你手写Vue中的reactive响应式

关于reactive

最近小编在刷vue面经时,有看到ref与reacive的区别,关于ref大家都耳熟能详,但是对于ractive,小编一开始只了解到与ref的区别是不需要.value访问值并且只能绑定引用数据类型,而ref既能绑定基本类型又能绑定引用数据类型,那么reactive的存在的意义在哪里?

这样的困惑驱使小编不能仅满足于表面的理解。在 Vue 庞大而精密的体系里,reactive 必然承载着特殊的使命。通过查阅资料和源码,小编这了解到reactive背后的奥秘,下面就一一道来。

github.com/vuejs/core/… 附上github上reactive的源码

reactive的设计理念

首先,为什么reactive只能接受引用数据类型?这是因为reactive是基于ES6的Proxy实现的响应式,而Proxy只能代理引用数据类型。

而ref在绑定基本数据类型时是基于Object.defineProperty通过数据劫持变化来实现数据响应式的。对于引用数据类型,defineProperty的方法存在一些弊端:比如无法监听到对象属性的新增和删除,也无法监听数组索引的直接设置和length变化。这里简单对比一下vue响应式方式。

实现方式 适用类型 核心缺陷
Object.defineProperty(ref 底层) 基本类型(包装为对象) 1. 无法监听对象新增 / 删除属性;2. 无法监听数组索引 / 长度变化;3. 只能劫持单个属性
Proxy(reactive 底层) 引用类型(对象 / 数组 / Map 等) 无上述缺陷,可代理整个对象,支持动态增删属性、数组操作

因此,ref在代理对象时也是借助到reactive

reactive基于Proxy的响应式系统能完美解决这些问题,下面我们来写一个简单的reactive响应式

reactive代理对象

要实现reactive实现数据响应式,我们需要先创建一个reactive方法,通过Proxy进行代理。 其中,proxy代理的target需要是对象并且没有被代理过。

//创建一个Map来保存代理过的reactive
const reactiveMap = new Map()

function isReactive(target){
  if(reactiveMap.has(target)){
      return true
  }
  return false
}

export function reactive(target){
  //检查是否已经被代理
  if(isReactive(target)){
    return target
  }


  return createReactiveObject(
      target,
      mutableHandlers
  )
}

export function createReactiveObject(target,mutableHandlers){
   //检查是否为对象
    if(typeof target !== 'object' || target == null){
      return target
    }
    //Proxy 接受俩个参数 代理对象和代理方法
    const newReactive =  new Proxy(target,mutableHandlers)
    reactiveMap.set(target,newReactive)
    return newReactive
}

Get & Set

我们新建一个文件导出mutableHandlers方法供proxy使用,mutableHandlers需要有一个get与set,分别在访问和修改target时触发。get需要实现依赖收集,当访问对象属性时将对应的副作用函数收集到依赖集合,set需要实现当对象属性更改时,更新依赖,通知副函数执行。

import {track,trigger} from './effect.js'

const get = (target, key) => {
  // target代理对象 key键名
  track(target, key) // 副作用收集
  // 相当于target[key]
  const value = Reflect.get(target, key)
  if (typeof value === 'object' && value !== null) {
    return reactive(value)
  }
  return value
}

const set = (target, key, value) => {
  // console.log('target被设置值', key, value)
  const oldValue = Reflect.get(target, key)
  // 比较是否更新
  if (oldValue !== value) {
    const result = Reflect.set(target, key, value)
    trigger(target, key, value, oldValue)
    return result
  }
  return true // 如果值没有变化,返回true表示设置成功
}

export const mutableHandlers = {
  get,
  set
}

副作用的收集与触发

接下来,我们要完成依赖收集函数track和副作用触发函数trigger。做之前我们要思考一下他们要做的事情: track在get时触发,主要负责将副作用函数effect载入targetMap中,tigger在set时触发 主要负责执行副作用函数。

提一嘴 *WeakMap是es6的新特性 特殊的是键必须是引用类型 *

// 存储依赖关系
const targetMap = new WeakMap() // 可以看set({}:map())
let activeEffect //当前执行的副作用函数

// 副作用的执行函数
export const effect = (callback) => {
  activeEffect = callback
  callback()
  activeEffect = null
}

//依赖收集
export const track = (target, key) => {
  // 如果该依赖没有副作用直接返回
  if (!activeEffect) return
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map())) // 第一次收集该依赖
  }

  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Set())) // 依赖的第一个副作用
  }
  dep.add(activeEffect)
}

//触发
export const trigger = (target, key) => {
  const depsMap = targetMap.get(target)
  if (!depsMap) return

  const dep = depsMap.get(key)
  if (dep) {
    dep.forEach(effect => effect())
  }
}

测试

到此为止,我们已经简单完成reactive的demo。接下来新建一个vue文件来测试一下这个简易的reactiveDemo

<template>
    <div>
        <button @click="handleClick">count++</button>
    </div>
</template>

<script setup>
import {reactive} from './utils/reactivity/reactive.js'
import {effect} from './utils/reactivity/effect.js'
const count = reactive({
  value: 0
})

effect(()=>{
  console.log('count的值是:', count.value) 
})

effect(()=>{
  console.log(count.value, '正在执行计算')
})

effect(()=>{
  console.log(count.value, '正在渲染页面')
})


const handleClick = ()=>{
  count.value++
}
</script>

当我们点击按钮触发count.value++时,到触发代理proxy的set,执行targget,从而触发此前访问过count.value相关的副作用函数,完成更新。

image.png

总结

reactive是Vue中实现响应式的Api,它通过proxy实现代理,为ref代理对象提供支持。reactive不是“多余选项”了,而是vue响应式的核心支柱。

而要实现reactive的核心在于:

  • 使用proxy代理
  • 收集与触发副作用函数

*以上是小编在学习过程中的一点小见解 如果有写得不对的 欢迎在评论区指出 *

image.png

前端基础数据中心:从混乱到统一的架构演进

本文记录了我们团队在 Vue 3 + TypeScript 项目中,如何将散乱的基础数据管理逻辑重构为统一的「基础数据中心」。如果你的项目也有类似的痛点,希望这篇文章能给你一些参考。

一、问题是怎么来的

做过 B 端系统的同学应该都有体会——基础数据无处不在。港口、船舶、航线、货币、字典……这些数据在几乎每个页面都会用到,要么是下拉选择,要么是代码翻译,要么是表格筛选。

我们项目一开始的做法很「朴素」:哪里用到就哪里请求。后来发现这样不行,同一个港口列表接口一个页面能请求三四次。于是开始加缓存,问题是加着加着,代码变成了这样:

store/basicData/cache.ts      <- Pinia 实现的缓存
composables/basicData/cache.ts  <- VueUse + localStorage 实现的缓存
store/port.ts                   <- 独立的港口缓存(历史遗留)

三套缓存系统,各自为政。更要命的是 CACHE_KEYS 这个常量在两个地方都有定义,改一处忘一处是常态。

某天排查一个 bug:用户反馈页面显示的港口名称和实际不一致。查了半天发现是两套缓存系统的数据版本不同步——A 组件用的 Pinia 缓存已经过期刷新了,B 组件用的 localStorage 缓存还是旧数据。

是时候重构了。

二、想清楚再动手

重构之前,我们先梳理了需求优先级:

需求 优先级 说明
跨组件数据共享 P0 同一份数据,全局只请求一次
缓存 + 过期机制 P0 减少请求,但数据要能自动刷新
请求去重 P1 并发请求同一接口时,只发一次
持久化 P1 关键数据存 localStorage,提升首屏速度
DevTools 调试 P2 能在 Vue DevTools 里看到缓存状态

基于这些需求,我们确定了架构原则:

Store 管状态,Composable 封业务,Component 只消费。

三、分层架构设计

最终的架构分三层:

┌─────────────────────────────────────────────────┐
│               Component Layer                    │
│              (Vue 组件/页面)                     │
│  只使用 Composables,不直接访问 Store            │
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│              Composable Layer                    │
│           (composables/basicData/)              │
│  usePorts / useVessels / useDict / ...          │
│  封装 Store,提供业务友好的 API                  │
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│                Store Layer                       │
│             (store/basicData/)                  │
│  useBasicDataStore                              │
│  统一缓存、加载状态、请求去重、持久化            │
└─────────────────────────────────────────────────┘

为什么要分这么多层?

  • Store 层:单一数据源,解决「数据从哪来」的问题
  • Composable 层:业务封装,解决「数据怎么用」的问题
  • Component 层:纯消费,只关心「界面怎么展示」

这样分层之后,职责边界就清晰了。组件开发者不用关心缓存策略,只管调 usePorts() 拿数据就行。

四、核心实现

4.1 Store 层:请求去重是关键

Store 层最核心的逻辑是 loadData 方法。这里要处理三种情况:

  1. 缓存命中 → 直接返回
  2. 有相同请求正在进行 → 复用已有 Promise
  3. 发起新请求 → 请求完成后写入缓存
// store/basicData/useBasicData.ts
export const useBasicDataStore = defineStore('basic-data', () => {
  const cacheMap = ref<Map<BasicDataType, CacheEntry>>(new Map())
  const pendingRequests = new Map<BasicDataType, Promise<unknown>>()

  async function loadData<T>(
    type: BasicDataType,
    fetcher: () => Promise<T>,
    config?: CacheConfig
  ): Promise<T | null> {
    // 1. 缓存命中
    const cached = getCache<T>(type)
    if (cached !== null) return cached

    // 2. 请求去重——这是关键
    const pending = pendingRequests.get(type)
    if (pending) return pending as Promise<T | null>

    // 3. 发起新请求
    const request = (async () => {
      try {
        const data = await fetcher()
        setCache(type, data, config)
        return data
      } finally {
        pendingRequests.delete(type)
      }
    })()

    pendingRequests.set(type, request)
    return request
  }

  return { loadData, getCache, setCache, clearCache }
})

请求去重的实现很简单:用一个 Map 存储正在进行的 Promise。当第二个请求进来时,直接返回已有的 Promise,不发新请求。

这样即使页面上 10 个组件同时调用 usePorts(),实际 API 请求也只有 1 次。

4.2 Composable 层:工厂函数批量生成

港口、船舶、航线……这些 Composable 的逻辑高度相似,用工厂函数批量生成:

// composables/basicData/hooks.ts
function createBasicDataComposable<T extends BaseDataItem>(
  type: BasicDataType,
  fetcher: () => Promise<T[]>,
  config?: CacheConfig
) {
  return () => {
    const store = useBasicDataStore()

    // 响应式数据
    const data = computed(() => store.getCache<T[]>(type) || [])
    const loading = computed(() => store.getLoadingState(type).loading)
    const isReady = computed(() => data.value.length > 0)

    // 自动加载
    store.loadData(type, fetcher, config)

    // 业务方法
    const getByCode = (code: string) => 
      data.value.find(item => item.code === code)

    const options = computed(() => 
      data.value.map(item => ({
        label: item.nameCn,
        value: item.code
      }))
    )

    return { data, loading, isReady, getByCode, options, refresh }
  }
}

// 一行代码定义一个 Composable
export const usePorts = createBasicDataComposable('ports', fetchPorts, { ttl: 15 * 60 * 1000 })
export const useVessels = createBasicDataComposable('vessels', fetchVessels, { ttl: 15 * 60 * 1000 })
export const useLanes = createBasicDataComposable('lanes', fetchLanes, { ttl: 30 * 60 * 1000 })

这样做的好处是:

  • 新增一种基础数据,只需加一行代码
  • 所有 Composable 的 API 完全一致,学习成本低
  • 类型安全,TypeScript 能正确推断返回类型

4.3 字典数据:特殊处理

字典数据稍微复杂一些,因为它是按类型分组的。我们单独封装了 useDict

export function useDict() {
  const store = useBasicDataStore()

  // 加载全量字典数据
  store.loadData('dict', fetchAllDict, { ttl: 30 * 60 * 1000 })

  const getDictItems = (dictType: string) => {
    const all = store.getCache<DictData>('dict') || {}
    return all[dictType] || []
  }

  const getDictLabel = (dictType: string, value: string) => {
    const items = getDictItems(dictType)
    return items.find(item => item.value === value)?.label || value
  }

  const getDictOptions = (dictType: string) => {
    return getDictItems(dictType).map(item => ({
      label: item.label,
      value: item.value
    }))
  }

  return { getDictItems, getDictLabel, getDictOptions }
}

使用起来非常直观:

<script setup>
const dict = useDict()
const cargoTypeLabel = dict.getDictLabel('CARGO_TYPE', 'FCL') // "整箱"
</script>

<template>
  <el-select>
    <el-option 
      v-for="opt in dict.getDictOptions('CARGO_TYPE')" 
      :key="opt.value" 
      v-bind="opt" 
    />
  </el-select>
</template>

五、实际使用场景

场景一:下拉选择器

最常见的场景。以前要自己请求数据、处理格式,现在一行搞定:

<script setup>
import { usePorts } from '@/composables/basicData'

const { options: portOptions, loading } = usePorts()
const selectedPort = ref('')
</script>

<template>
  <el-select v-model="selectedPort" :loading="loading" filterable>
    <el-option v-for="opt in portOptions" :key="opt.value" v-bind="opt" />
  </el-select>
</template>

场景二:表格中的代码翻译

订单列表里显示港口代码,用户看不懂,要翻译成中文:

<script setup>
import { usePorts } from '@/composables/basicData'

const { getByCode } = usePorts()

// 翻译函数
const translatePort = (code: string) => getByCode(code)?.nameCn || code
</script>

<template>
  <el-table :data="orderList">
    <el-table-column prop="polCode" label="起运港">
      <template #default="{ row }">
        {{ translatePort(row.polCode) }}
      </template>
    </el-table-column>
  </el-table>
</template>

场景三:字典标签渲染

状态、类型这类字段,通常要显示成带颜色的标签:

<script setup>
import { useDict } from '@/composables/basicData'

const dict = useDict()
</script>

<template>
  <el-tag :type="dict.getDictColorType('ORDER_STATUS', row.status)">
    {{ dict.getDictLabel('ORDER_STATUS', row.status) }}
  </el-tag>
</template>

场景四:数据刷新

用户修改了基础数据,需要刷新缓存:

import { usePorts, clearAllCache } from '@/composables/basicData'

const { refresh: refreshPorts } = usePorts()

// 刷新单个
await refreshPorts()

// 刷新全部
clearAllCache()

六、缓存策略

不同数据的变化频率不同,缓存策略也不一样:

数据类型 TTL 持久化 原因
国家/货币 1 小时 几乎不变
港口/码头 15-30 分钟 偶尔变化
船舶 15 分钟 数据量大(10万+),不适合 localStorage
航线/堆场 30 分钟 相对稳定
字典 30 分钟 偶尔变化

持久化用的是 localStorage,配合 TTL 一起使用。数据写入时记录时间戳,读取时检查是否过期。

船舶数据量太大,存 localStorage 会导致写入超时,所以不做持久化,每次刷新页面重新请求。

七、调试支持

用 Pinia 还有一个好处:Vue DevTools 原生支持。

打开 DevTools,切到 Pinia 面板,能看到:

  • 当前缓存了哪些数据
  • 每种数据的加载状态
  • 数据的具体内容

排查问题时非常方便。

另外我们还提供了 getCacheInfo() 方法,可以在控制台查看缓存统计:

import { getCacheInfo } from '@/composables/basicData'

console.log(getCacheInfo())
// {
//   ports: { cached: true, size: 102400, remainingTime: 600000 },
//   vessels: { cached: false, size: 0, remainingTime: 0 },
//   ...
// }

八、踩过的坑

坑 1:响应式丢失

一开始我们这样写:

// ❌ 错误写法
const { data } = usePorts()
const portList = data.value // 丢失响应式!

datacomputed,取 .value 之后就变成普通值了,后续数据更新不会触发视图刷新。

正确做法是保持响应式引用:

// ✅ 正确写法
const { data: portList } = usePorts()
// 或者
const portList = computed(() => usePorts().data.value)

坑 2:循环依赖

Store 和 Composable 互相引用导致循环依赖。解决办法是严格遵守分层原则:Composable 可以引用 Store,Store 不能引用 Composable。

坑 3:SSR 兼容

localStorage 在服务端不存在。如果你的项目需要 SSR,持久化逻辑要加判断:

const storage = typeof window !== 'undefined' ? localStorage : null

九、总结

重构前后的对比:

维度 重构前 重构后
缓存系统 3 套并存 1 套统一
代码复用 到处复制粘贴 工厂函数批量生成
请求优化 无去重,重复请求 自动去重
调试 只能打 log DevTools 原生支持
类型安全 部分 any 完整类型推断

核心收益:

  1. 开发效率提升:新增基础数据类型从半天缩短到 10 分钟
  2. Bug 减少:数据不一致问题基本消失
  3. 性能优化:重复请求减少 60%+

如果你的项目也有类似的基础数据管理问题,可以参考这个思路。关键是想清楚分层,把「状态管理」和「业务封装」分开,剩下的就是体力活了。


本文基于实际项目经验整理,代码已做脱敏处理。欢迎讨论交流。

❌