普通视图

发现新文章,点击刷新页面。
今天 — 2025年10月15日技术

每日一题-检测相邻递增子数组 II🟡

2025年10月15日 00:00

给你一个由 n 个整数组成的数组 nums ,请你找出 k最大值,使得存在 两个 相邻 且长度为 k严格递增 子数组。具体来说,需要检查是否存在从下标 ab (a < b) 开始的 两个 子数组,并满足下述全部条件:

  • 这两个子数组 nums[a..a + k - 1]nums[b..b + k - 1] 都是 严格递增 的。
  • 这两个子数组必须是 相邻的,即 b = a + k

返回 k最大可能 值。

子数组 是数组中的一个连续 非空 的元素序列。

 

示例 1:

输入:nums = [2,5,7,8,9,2,3,4,3,1]

输出:3

解释:

  • 从下标 2 开始的子数组是 [7, 8, 9],它是严格递增的。
  • 从下标 5 开始的子数组是 [2, 3, 4],它也是严格递增的。
  • 这两个子数组是相邻的,因此 3 是满足题目条件的 最大 k 值。

示例 2:

输入:nums = [1,2,3,4,4,4,4,5,6,7]

输出:2

解释:

  • 从下标 0 开始的子数组是 [1, 2],它是严格递增的。
  • 从下标 2 开始的子数组是 [3, 4],它也是严格递增的。
  • 这两个子数组是相邻的,因此 2 是满足题目条件的 最大 k 值。

 

提示:

  • 2 <= nums.length <= 2 * 105
  • -109 <= nums[i] <= 109

枚举

作者 tsreaper
2024年11月10日 12:11

解法:枚举

这种一前一后两个子数组的题,一般考虑枚举分界点(比如第二个子数组的开头)。

维护 $f(i)$ 表示以第 $i$ 个元素为结尾,最长的单调递增子数组是多长;$g(i)$ 表示以第 $i$ 个元素为开头,最长的单调递增子数组是多长。那么我们枚举第二个子数组的开头 $b$,说明第一个子数组的结尾就是 $(b - 1)$,答案就是 $\max(\min(f(b - 1), g(b)))$。

复杂度 $\mathcal{O}(n)$。

参考代码(c++)

###cpp

class Solution {
public:
    int maxIncreasingSubarrays(vector<int>& nums) {
        int n = nums.size();
        int f[n + 2], g[n + 2];
        f[0] = 0; g[n + 1] = 0;
        // 计算以第 i 个元素为结尾,最长的单调递增子数组是多长
        for (int i = 1; i <= n; i++) {
            if (i > 1 && nums[i - 2] < nums[i - 1]) f[i] = f[i - 1] + 1;
            else f[i] = 1;
        }
        // 计算以第 i 个元素为开头,最长的单调递增子数组是多长
        for (int i = n; i > 0; i--) {
            if (i < n && nums[i - 1] < nums[i]) g[i] = g[i + 1] + 1;
            else g[i] = 1;
        }
        int ans = 0;
        // 枚举第二个子数组的开头
        for (int i = 1; i <= n; i++) ans = max(ans, min(f[i - 1], g[i]));
        return ans;
    }
};

典中典之前后缀分解

作者 mipha-2022
2024年11月10日 12:04

Problem: 3350. 检测相邻递增子数组 II

思路

这类拆分成 两段不相交子数组或者子序列题,套路都是一样的:

  • 前后缀分解
  • 枚举分割点

比如这题:

  • left[i]i 作为末尾的最长严格递增子数组长度
  • right[i]i 作为起始的最长严格递增子数组长度
  • 然后枚举分割点i,判断left[i-1]right[i]哪个值更小,就是对应的k[i]值,然后就能找出max(k)
        # left[i] 以 i 末尾最长严格递增子数组长度
        n = len(nums)
        left = [0] * n

        last = nums[0] - 1
        t = 0
        for i,num in enumerate(nums):
            if num > last:
                t += 1
            else:
                t = 1
            left[i] = t
            last = num

优化

通常会把求right和枚举分隔点两步合并,防止被卡常:

        # right[i] 同上,顺便枚举
        last = nums[-1] + 1
        t = 0
        res = 0
        for i in range(n-1,0,-1):
            if nums[i] < last:
                t += 1
            else:
                t = 1
            last = nums[i]
            
            res = max(res,min(left[i-1],t))

更多题目模板总结,请参考2023年度总结与题目分享

Code

###Python3

class Solution:
    def maxIncreasingSubarrays(self, nums: List[int]) -> int:
        '''
        前后缀分解dp
        '''
        # left[i] 以 i 末尾最长严格递增子数组长度
        n = len(nums)
        left = [0] * n

        last = nums[0] - 1
        t = 0
        for i,num in enumerate(nums):
            if num > last:
                t += 1
            else:
                t = 1
            left[i] = t
            last = num

        # right[i] 同上,顺便枚举
        last = nums[-1] + 1
        t = 0
        res = 0
        for i in range(n-1,0,-1):
            if nums[i] < last:
                t += 1
            else:
                t = 1
            last = nums[i]
            
            res = max(res,min(left[i-1],t))

        return res

O(n) 一次遍历,简洁写法(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2024年11月10日 12:03

遍历 $\textit{nums}$,寻找严格递增段(子数组)。

设当前严格递增段的长度为 $\textit{cnt}$,上一个严格递增段的长度为 $\textit{preCnt}$。

答案有两种情况:

  • 两个子数组属于同一个严格递增段,那么 $k$ 最大是 $\left\lfloor\dfrac{\textit{cnt}}{2}\right\rfloor$。
  • 两个子数组分别属于一对相邻的严格递增段,那么 $k$ 最大是 $\min(\textit{preCnt}, \textit{cnt})$。

本题视频讲解,欢迎点赞关注~

###py

class Solution:
    def maxIncreasingSubarrays(self, nums: List[int]) -> int:
        ans = pre_cnt = cnt = 0
        for i, x in enumerate(nums):
            cnt += 1
            if i == len(nums) - 1 or x >= nums[i + 1]:  # i 是严格递增段的末尾
                ans = max(ans, cnt // 2, min(pre_cnt, cnt))
                pre_cnt = cnt
                cnt = 0
        return ans

###java

class Solution {
    public int maxIncreasingSubarrays(List<Integer> nums) {
        int ans = 0;
        int preCnt = 0;
        int cnt = 0;
        for (int i = 0; i < nums.size(); i++) {
            cnt++;
            // i 是严格递增段的末尾
            if (i == nums.size() - 1 || nums.get(i) >= nums.get(i + 1)) {
                ans = Math.max(ans, Math.max(cnt / 2, Math.min(preCnt, cnt)));
                preCnt = cnt;
                cnt = 0;
            }
        }
        return ans;
    }
}

###java

class Solution {
    public int maxIncreasingSubarrays(List<Integer> nums) {
        Integer[] a = nums.toArray(Integer[]::new); // 转成数组处理,更快
        int ans = 0;
        int preCnt = 0;
        int cnt = 0;
        for (int i = 0; i < a.length; i++) {
            cnt++;
            // i 是严格递增段的末尾
            if (i == a.length - 1 || a[i] >= a[i + 1]) {
                ans = Math.max(ans, Math.max(cnt / 2, Math.min(preCnt, cnt)));
                preCnt = cnt;
                cnt = 0;
            }
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    int maxIncreasingSubarrays(vector<int>& nums) {
        int ans = 0, pre_cnt = 0, cnt = 0;
        for (int i = 0; i < nums.size(); i++) {
            cnt++;
            if (i == nums.size() - 1 || nums[i] >= nums[i + 1]) { // i 是严格递增段的末尾
                ans = max({ans, cnt / 2, min(pre_cnt, cnt)});
                pre_cnt = cnt;
                cnt = 0;
            }
        }
        return ans;
    }
};

###c

#define MIN(a, b) ((b) < (a) ? (b) : (a))
#define MAX(a, b) ((b) > (a) ? (b) : (a))

int maxIncreasingSubarrays(int* nums, int numsSize) {
    int ans = 0, pre_cnt = 0, cnt = 0;
    for (int i = 0; i < numsSize; i++) {
        cnt++;
        if (i == numsSize - 1 || nums[i] >= nums[i + 1]) { // i 是严格递增段的末尾
            ans = MAX(ans, MAX(cnt / 2, MIN(pre_cnt, cnt)));
            pre_cnt = cnt;
            cnt = 0;
        }
    }
    return ans;
}

###go

func maxIncreasingSubarrays(nums []int) (ans int) {
preCnt, cnt := 0, 0
for i, x := range nums {
cnt++
if i == len(nums)-1 || x >= nums[i+1] { // i 是严格递增段的末尾
ans = max(ans, cnt/2, min(preCnt, cnt))
preCnt = cnt
cnt = 0
}
}
return
}

###js

var maxIncreasingSubarrays = function(nums) {
    let ans = 0, preCnt = 0, cnt = 0;
    for (let i = 0; i < nums.length; i++) {
        cnt++;
        if (i === nums.length - 1 || nums[i] >= nums[i + 1]) { // i 是严格递增段的末尾
            ans = Math.max(ans, Math.floor(cnt / 2), Math.min(preCnt, cnt));
            preCnt = cnt;
            cnt = 0;
        }
    }
    return ans;
};

###rust

impl Solution {
    pub fn max_increasing_subarrays(nums: Vec<i32>) -> i32 {
        let mut ans = 0;
        let mut pre_cnt = 0;
        let mut cnt = 0;
        for i in 0..nums.len() {
            cnt += 1;
            if i == nums.len() - 1 || nums[i] >= nums[i + 1] { // i 是严格递增段的末尾
                ans = ans.max(cnt / 2).max(pre_cnt.min(cnt));
                pre_cnt = cnt;
                cnt = 0;
            }
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

专题训练

见下面双指针题单的「六、分组循环」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

昨天 — 2025年10月14日技术

【vue篇】Vue.js 2025:为何全球开发者都在拥抱这个前端框架?

作者 LuckySusu
2025年10月14日 23:03

在 React、Angular、Svelte 等众多前端框架中,Vue.js 凭借其独特的设计理念,持续赢得开发者青睐。

“Vue 到底强在哪?” “为什么中小企业首选 Vue?” “它的性能真的比 React 快吗?”

本文将从 轻量易学响应式生态,全面解析 Vue 的六大核心优势。


一、🔥 优势 1:极致轻量,启动飞快

Vue 3 (gzip): ~22KB
React 18 (gzip): ~40KB + react-dom

✅ 轻量带来的好处:

优势 说明
快速加载 移动端、低网速环境体验更佳
首屏更快 TTI(可交互时间)提前
Bundle 更小 减少用户流量消耗
// CDN 引入,5 秒上手
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

💡 Vue 是“渐进式框架”,你可以从 <script> 开始,逐步升级到 Vue CLI / Vite。


二、📚 优势 2:简单易学,中文友好

🌍 国人开发,文档贴心

  • 中文文档:官方文档翻译精准,无语言障碍;
  • 渐进式学习:从模板 → Options API → Composition API,平滑过渡;
  • 开发者友好:错误提示清晰,调试工具强大。

🎯 学习曲线对比

阶段 Vue React
第一天 能写 v-model 需理解 JSX、state
第一周 掌握组件通信 理解 Hooks、不可变性
第一个月 上线项目 仍在优化性能

Vue 是前端新手的“最佳第一课”


三、🔁 优势 3:双向数据绑定,开发更高效

<template>
  <!-- v-model:自动同步 -->
  <input v-model="message" />
  <p>{{ message }}</p>
</template>

<script>
export default {
  data() {
    return { message: 'Hello' }
  }
}
</script>

🆚 对比 React

// React:手动同步
function Input() {
  const [message, setMessage] = useState('');
  return (
    <input 
      value={message} 
      onChange={e => setMessage(e.target.value)} 
    />
  );
}

💥 Vue 的 v-model 让表单操作减少 50% 代码量


四、🧩 优势 4:组件化,复用无处不在

<!-- Button.vue -->
<template>
  <button :class="`btn-${type}`" @click="$emit('click')">
    <slot></slot>
  </button>
</template>
<!-- 使用 -->
<Btn type="primary" @click="save">保存</Btn>
<Btn type="danger">删除</Btn>

✅ 组件化优势:

优势 说明
UI 一致性 全站按钮风格统一
开发效率 修改一处,全局生效
团队协作 设计师 + 前端可共建组件库

📌 Vue 的单文件组件(.vue)将 模板、逻辑、样式 封装在一起,清晰易维护。


五、🧱 优势 5:关注点分离,结构清晰

视图 (template)    ←→    数据 (data)
       ↑                    ↑
   用户操作           状态管理 (Vuex/Pinia)

✅ 三大分离:

  1. 视图与数据分离

    • 修改 data,视图自动更新;
    • 无需手动操作 DOM。
  2. 结构与样式分离

    • <style scoped> 避免样式污染;
    • 支持 CSS Modules、PostCSS。
  3. 逻辑与模板分离

    • setup() / methods 集中处理业务逻辑;
    • 模板只负责展示。

💡 这种分离让维护成本大幅降低


六、⚡ 优势 6:虚拟 DOM + 响应式 = 性能王者

🎯 Vue 的性能优势在哪?

机制 说明
自动依赖追踪 渲染时自动收集依赖,只更新相关组件
细粒度更新 不像 React 默认全量 diff
编译优化 Vue 3 的 PatchFlag 标记动态节点,跳过静态节点
Tree-shaking 按需引入,减少打包体积

📊 性能对比(同场景)

操作 Vue 3 React 18
列表更新(1000项) ✅ 60fps ⚠️ 需 React.memo 优化
首次渲染 ✅ 更快 ❌ Bundle 更大
内存占用 ✅ 更低 ⚠️ 较高

💥 Vue 的响应式系统是“智能的”,它知道谁依赖谁,无需手动优化。


七、🚀 2025 Vue 生态全景

工具 说明
Vite 下一代构建工具,秒级启动
Pinia Vue 3 官方状态管理,TypeScript 友好
Vue Router 官方路由,支持懒加载
Nuxt.js SSR / SSG 框架,SEO 友好
UnoCSS 原子化 CSS,极速样式开发
# 5 秒创建项目
npm create vue@latest

💡 结语

“Vue 不是最快的框架,但可能是最平衡的。”

优势 说明
轻量 22KB,CDN 可用
易学 中文文档,渐进式学习
高效 v-model、组件化减少代码量
清晰 关注点分离,维护简单
性能 响应式 + 虚拟 DOM,自动优化
生态 Vite + Pinia + Nuxt,现代开发闭环

【vue篇】React vs Vue:2025 前端双雄终极对比

作者 LuckySusu
2025年10月14日 23:03

在选择前端框架时,你是否在 React 和 Vue 之间犹豫不决?

“React 和 Vue 到底有什么区别?” “哪个更适合我的团队?” “它们的未来趋势如何?”

本文将从 数据流模板响应式生态,全面解析 React 与 Vue 的异同。


一、相似之处:现代前端的共同基石

特性 React Vue
核心库 聚焦 UI 渲染 聚焦 UI 渲染
虚拟 DOM ✅ 支持 ✅ 支持(Vue 2+)
组件化 ✅ 鼓励 ✅ 鼓励
构建工具 Create React App Vue CLI / Vite
状态管理 Redux / MobX / Zustand Vuex / Pinia
路由 React Router Vue Router

✅ 两者都遵循 现代前端最佳实践:组件化、虚拟 DOM、单向数据流。


二、核心差异:哲学与设计

🎯 1. 数据流:双向 vs 单向

框架 数据流 示例
Vue 默认支持双向绑定v-model <input v-model="msg" />
React 严格单向数据流 <input value={msg} onChange={e => setMsg(e.target.value)} />

💡 Vue 更“贴心”,React 更“可控”。


🎯 2. 模板 vs JSX:声明式 UI 的两种范式

Vue:HTML 扩展式模板

<template>
  <div class="container">
    <h1>{{ title }}</h1>
    <ChildComponent 
      :msg="message" 
      @update="handleUpdate" 
    />
  </div>
</template>
  • ✅ 语法接近 HTML,设计师友好;
  • ✅ 指令系统(v-if, v-for)简洁;
  • ❌ 逻辑能力有限,复杂逻辑需写在 script

React:JSX(JavaScript XML)

function App() {
  const [msg, setMsg] = useState('');
  
  return (
    <div className="container">
      <h1>{title}</h1>
      <ChildComponent 
        msg={msg} 
        onUpdate={handleUpdate} 
      />
    </div>
  );
}
  • ✅ 逻辑与 UI 在同一文件,更灵活;
  • ✅ 可用完整 JavaScript 表达式;
  • ❌ 学习成本略高(JSX 语法)。

🎯 3. 响应式系统:谁更高效?

框架 实现原理 性能特点
Vue getter/setter 拦截(Vue 2)
Proxy(Vue 3)
自动依赖追踪
无需手动优化,更新粒度更细
React 手动触发更新setState
默认全量 diff
❌ 可能导致不必要的渲染
✅ 可通过 useMemo/useCallback/React.memo 优化

💥 Vue 的响应式是“自动挡”,React 是“手动挡”。


🎯 4. 组件通信与复用

React:高阶组件(HOC)与 Hooks

// HOC
const withLogger = (Component) => {
  return (props) => {
    console.log('Render:', props);
    return <Component {...props} />;
  };
};

// Hooks
function useCounter() {
  const [count, setCount] = useState(0);
  return { count, increment: () => setCount(c => c + 1) };
}
  • ✅ 函数式,组合能力强;
  • ✅ Hooks 解决了 mixin 的问题。

Vue:Mixins 与 Composition API

// Mixin(Vue 2)
const logMixin = {
  created() {
    console.log('Component created');
  }
};

// Composition API(Vue 3)
function useCounter() {
  const count = ref(0);
  const increment = () => count.value++;
  return { count, increment };
}

💡 Vue 3 的 Composition API 已向 React Hooks 靠拢。


🎯 5. 监听数据变化的实现

框架 实现方式 特点
Vue Object.defineProperty / Proxy 精确追踪
知道哪个属性变了
React 引用比较(shallowEqual) ❌ 不比较值,只比较引用
✅ 鼓励不可变数据(Immutability)

📌 Vue:可变数据 + 精确更新
📌 React:不可变数据 + 手动优化


🎯 6. 跨平台能力

平台 React Vue
Web
移动端 React Native(成熟) Weex(已停止维护)
UniApp(第三方)
桌面端 Electron + React Electron + Vue
小程序 Taro / Remax UniApp / Taro

✅ React 在跨平台(尤其是移动端)生态更强大。


🎯 7. 学习曲线

框架 学习难度 适合人群
Vue ⭐⭐☆ 初学者、HTML 开发者
React ⭐⭐⭐ 有 JavaScript 基础的开发者
  • Vue:渐进式,从模板开始;
  • React:需理解 JSX、状态、不可变性。

三、实战对比:同一个功能

需求:计数器组件

Vue 3(Composition API)

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

const count = ref(0);
const increment = () => count.value++;
</script>

<template>
  <button @click="increment">Count: {{ count }}</button>
</template>

React 18(Hooks)

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  const increment = () => setCount(c => c + 1);
  
  return (
    <button onClick={increment}>
      Count: {count}
    </button>
  );
}

💡 代码结构高度相似!Vue 3 的 Composition API 明显受 React Hooks 启发。


四、如何选择?

你的需求 推荐框架
快速上手,团队有 HTML 经验 Vue
复杂应用,需要强大状态管理 React
移动端开发(React Native) React
小程序(UniApp) Vue
喜欢函数式编程 React
喜欢模板语法 Vue

💡 结语

“React 和 Vue 不是敌人,而是共同推动前端进步的力量。”

维度 React Vue
哲学 “Just JavaScript” “渐进式框架”
模板 JSX(JavaScript) 模板(HTML 扩展)
响应式 手动触发 自动追踪
复用 Hooks / HOC Composition API / Mixins
生态 更大(尤其移动端) 更聚焦 Web
学习曲线 较陡 较平缓

🚀 2025 趋势

  • Vue 3 + Composition API:向 React Hooks 学习,提升逻辑复用;
  • React Server Components:服务端渲染新范式;
  • Vite:取代 Webpack,成为新一代构建工具(Vue 和 React 都支持)。

选择建议

  • 团队新手多?→ Vue
  • 需要跨平台?→ React
  • 追求最新技术?→ 两者都支持 Reactivity、SSR、Micro Frontends。

【vue篇】Vue 响应式核心:依赖收集机制深度解密

作者 LuckySusu
2025年10月14日 23:02

在 Vue 应用中,你是否好奇:

“当我修改 this.message 时,DOM 为何能自动更新?” “为什么只有被模板用到的数据才会触发更新?” “Vue 是如何知道哪个组件依赖哪个数据的?”

这一切的背后,是 Vue 依赖收集(Dependency Collection) 的精妙设计。

本文将从 Object.definePropertyDep-Watcher 模型,彻底解析 Vue 2 的响应式原理。


一、核心结论:依赖收集 = 数据 ↔ 视图 的双向绑定

数据变化 → 通知视图更新
     ↑          ↓
   收集      触发 getter
  • 谁收集? Dep(依赖中心)
  • 被谁收集? Watcher(观察者)
  • 何时收集? 组件渲染时读取数据(触发 getter)

二、三大核心角色

🎯 1. defineReactive:让数据“响应式”

function defineReactive(obj, key, val) {
  // 每个属性都有一个独立的依赖中心
  const dep = new Dep();
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      // ✅ 依赖收集:谁在读我?
      if (Dep.target) {
        dep.depend(); // 通知 dep:当前 watcher 依赖我
      }
      return val;
    },
    set(newVal) {
      if (newVal === val) return;
      val = newVal;
      // ✅ 派发更新:通知所有依赖者
      dep.notify();
    }
  });
}

💥 dep 是每个属性的“私人秘书”,记录谁依赖它。


🎯 2. Dep:依赖管理中心

class Dep {
  static target = null; // 🌟 全局唯一,指向当前正在计算的 Watcher
  subs = []; // 存储所有依赖此数据的 Watcher

  // 被收集:当前 Watcher 依赖我
  depend() {
    if (Dep.target) {
      Dep.target.addDep(this); // 告诉 Watcher:你依赖我
    }
  }

  // 添加订阅者
  addSub(watcher) {
    this.subs.push(watcher);
  }

  // 派发更新:数据变了!
  notify() {
    // 避免在 notify 时修改数组
    const subs = this.subs.slice();
    for (let i = 0; i < subs.length; i++) {
      subs[i].update(); // 通知每个 Watcher 更新
    }
  }
}

🔑 Dep.target 是关键:它确保同一时间只有一个 Watcher 在收集依赖。


🎯 3. Watcher:观察者(组件/计算属性)

class Watcher {
  constructor(vm, expOrFn, cb) {
    this.vm = vm;
    this.getter = expOrFn; // 如 vm._update(vm._render())
    this.cb = cb;
    this.deps = [];      // 记录依赖了哪些 dep
    this.depIds = new Set(); // 去重
    this.value = this.get(); // 🚀 首次执行,触发依赖收集
  }

  // 读取数据,触发 getter
  get() {
    pushTarget(this); // 设置当前 Watcher
    const value = this.getter.call(this.vm, this.vm);
    popTarget(); // 清除
    return value;
  }

  // 被 dep 收集
  addDep(dep) {
    const id = dep.id;
    if (!this.depIds.has(id)) {
      this.depIds.add(id);
      this.deps.push(dep);
      dep.addSub(this); // dep 记录我
    }
  }

  // 更新:数据变化后调用
  update() {
    queueWatcher(this); // 异步更新
  }

  run() {
    const value = this.get(); // 重新计算
    this.cb(value, this.value); // 执行回调(如更新 DOM)
    this.value = value;
  }
}

💡 Watcher 是“消费者”,它知道自己依赖哪些数据。


三、依赖收集全过程(图文解析)

🔄 阶段 1:初始化响应式数据

// data: { message: 'Hello' }
defineReactive(data, 'message', 'Hello');
// → 为 message 创建 dep 实例
data.message
     ↓
   dep (subs: [])

🔄 阶段 2:组件挂载,创建 Watcher

new Watcher(vm, () => {
  vm._update(vm._render());
});
  • Watcher.get() 被调用;
  • pushTarget(this)Dep.target = watcher
Dep.target → Watcher实例

🔄 阶段 3:渲染触发 getter,完成收集

vm._render(); // 生成 VNode
// 模板中:{{ message }}
// → 读取 this.message → 触发 getter
// getter 执行
get() {
  if (Dep.target) {
    dep.depend(); // dep.depend()
  }
  return val;
}
// dep.depend()
depend() {
  Dep.target.addDep(this); // watcher.addDep(dep)
}
// watcher.addDep(dep)
addDep(dep) {
  this.deps.push(dep);
  dep.addSub(this); // dep.subs.push(watcher)
}

收集完成!

data.message
     ↓
   dep (subs: [watcher])
     ↑
Watcher (deps: [dep])

🔄 阶段 4:数据变化,派发更新

this.message = 'World'; // 触发 setter
// setter 执行
set(newVal) {
  val = newVal;
  dep.notify(); // 通知所有 subs
}
// dep.notify()
notify() {
  this.subs.forEach(watcher => watcher.update());
}

queueWatcher(watcher) → 异步更新 DOM。


四、实战演示:一个简单的响应式系统

// 1. 数据
const data = { count: 0 };

// 2. 响应式化
defineReactive(data, 'count', 0);

// 3. 创建 Watcher(模拟组件)
new Watcher(null, () => {
  console.log('Render:', data.count);
}, null);
// → 触发 getter → 依赖收集完成

// 4. 修改数据
data.count = 1;
// → 触发 setter → dep.notify() → Watcher.update()
// → 输出:Render: 1

五、Vue 3 的改进:Proxy + effect

// Vue 3 使用 Proxy
const reactiveData = reactive({ count: 0 });

effect(() => {
  console.log(reactiveData.count); // 收集依赖
});

reactiveData.count++; // 触发更新
  • 优势
    • 支持动态新增属性;
    • 性能更好(无需递归 defineProperty);
    • 代码更简洁。

💡 结语

“依赖收集是 Vue 响应式的灵魂。”

角色 职责
defineReactive 拦截 getter/setter
Dep 管理订阅者(Watcher)
Watcher 观察数据变化,执行更新
过程 关键操作
初始化 defineReactive
收集 Dep.target = watcher + dep.depend()
更新 dep.notify()watcher.update()

掌握依赖收集机制,你就能:

✅ 理解 Vue 响应式原理;
✅ 调试响应式问题;
✅ 设计自己的响应式系统;
✅ 顺利过渡到 Vue 3 的 reactiveeffect

【vue篇】Vue 单向数据流铁律:子组件为何不能直接修改父组件数据?

作者 LuckySusu
2025年10月14日 23:02

在 Vue 开发中,你是否写过这样的代码:

<!-- ChildComponent.vue -->
<template>
  <button @click="changeParentData">修改父数据</button>
</template>

<script>
export default {
  methods: {
    changeParentData() {
      // ❌ 危险操作!
      this.$parent.formData.name = 'Hacker';
    }
  }
}
</script>

“为什么 Vue 要禁止子组件修改父数据?” “直接改不是更方便吗?” “如果必须改,该怎么办?”

本文将从 设计哲学实战模式,彻底解析 Vue 的单向数据流原则。


一、核心结论:绝对禁止!

子组件绝不能直接修改父组件的数据。

<!-- Parent.vue -->
<template>
  <Child :user="user" />
</template>

<script>
export default {
  data() {
    return {
      user: { name: 'Alice', age: 20 }
    }
  }
}
</script>
<!-- Child.vue -->
<script>
export default {
  props: ['user'],
  methods: {
    // ❌ 错误:直接修改 prop
    badWay() {
      this.user.name = 'Bob'; // ⚠️ Vue 会警告!
    },
    
    // ✅ 正确:通过事件通知父组件
    goodWay() {
      this.$emit('update:user', { ...this.user, name: 'Bob' });
    }
  }
}
</script>

二、为什么禁止?三大核心原因

🚫 1. 破坏单向数据流

父组件 → (props) → 子组件
   ↑
   └── (events) ← 子组件
  • 单向:数据流动清晰可预测;
  • 双向:数据可能从任意子组件修改,形成“意大利面条式”数据流。

💥 复杂应用中,你将无法追踪数据变化来源。


🚫 2. 导致难以调试

// 10 个子组件都可能修改 user.name
// 问题:name 何时、何地、被谁修改?
  • 控制台警告:

    [Vue warn]: Avoid mutating a prop directly...
    
  • 调试时需检查 所有子组件$emit$parent 调用。


🚫 3. 组件复用性降低

<!-- 假设 Child 可以直接修改 user -->
<Child :user="user1" />
<Child :user="user2" />

<!-- 如果 Child 修改了 user1,user2 也会被意外修改(引用传递) -->

✅ 组件应是“纯”的:相同输入 → 相同输出。


三、正确修改父数据的 4 种方式

✅ 1. v-model / .sync 修饰符(Vue 2)

方式一:v-model(默认 value / input

<!-- Parent -->
<Child v-model="userName" />

<!-- Child -->
<input 
  :value="value" 
  @input="$emit('input', $event.target.value)" 
/>

方式二:.sync 修饰符

<!-- Parent -->
<Child :user.sync="user" />

<!-- Child -->
<button @click="$emit('update:user', { ...user, name: 'New' })">
  更新
</button>

💡 .sync 本质是 :user + @update:user 的语法糖。


✅ 2. 自定义事件($emit

<!-- Parent -->
<Child 
  :config="config" 
  @change-config="updateConfig" 
/>

<!-- Child -->
<button @click="$emit('change-config', newConfig)">
  修改配置
</button>
// Parent method
updateConfig(newConfig) {
  this.config = newConfig;
}

✅ 3. 作用域插槽(传递方法)

<!-- Parent -->
<Child>
  <template #default="{ updateUser }">
    <button @click="updateUser({ name: 'New' })">
      通过插槽修改
    </button>
  </template>
</Child>
<!-- Child -->
<template>
  <div>
    <slot :updateUser="updateUser" />
  </div>
</template>

<script>
export default {
  methods: {
    updateUser(newData) {
      this.$emit('update:user', newData);
    }
  }
}
</script>

✅ 4. 状态管理(Vuex / Pinia)

// store.js
const userStore = defineStore('user', {
  state: () => ({ user: { name: 'Alice' } }),
  actions: {
    updateUser(payload) {
      this.user = { ...this.user, ...payload };
    }
  }
});

// Child.vue
import { useUserStore } from '@/stores/user';

export default {
  setup() {
    const userStore = useUserStore();
    return {
      updateUser: () => userStore.updateUser({ name: 'Bob' })
    }
  }
}

✅ 适合跨层级、复杂状态


四、特殊情况:如何“安全”地修改?

⚠️ 仅当 prop 是“配置对象”时

<!-- Parent -->
<Child :options="chartOptions" />

<!-- Child -->
<script>
export default {
  props: ['options'],
  mounted() {
    // ✅ 安全:只读取,不修改
    const chart = new Chart(this.$el, this.options);
  }
}
</script>

❌ 即使是配置对象,也不应修改其属性。


五、Vue 3 中的 definePropsdefineEmits

<script setup>
const props = defineProps(['user']);
const emit = defineEmits(['update:user']);

function changeName() {
  emit('update:user', { ...props.user, name: 'Charlie' });
}
</script>

definePropsdefineEmits 是 Vue 3 <script setup> 的推荐方式。


💡 结语

“单向数据流不是限制,而是自由的保障。”

方式 适用场景
$emit 简单父子通信
.sync / v-model 双向绑定场景
作用域插槽 需要传递方法
Vuex/Pinia 复杂全局状态
反模式 正确做法
this.$parent.xxx = value $emit('update:xxx', value)
直接修改 prop 对象属性 通过事件通知父组件

记住:

“子组件只应通过事件告诉父组件‘我想改变’,而非直接动手。”

掌握这一原则,你就能:

✅ 构建可维护的大型应用;
✅ 快速定位数据变更问题;
✅ 提升组件复用性;
✅ 为迁移到 Pinia 打下基础。

【vue篇】Vue 自定义指令完全指南:从入门到高级实战

作者 LuckySusu
2025年10月14日 23:02

在 Vue 开发中,你是否遇到过:

“如何让输入框自动聚焦?” “如何实现图片懒加载?” “如何集成 Chart.js 到 Vue 组件?”

数据驱动 无法满足需求时,自定义指令(Custom Directives)就是你的终极武器。

本文将从 基础语法高级实战,全面解析 Vue 自定义指令的用法与原理。


一、为什么需要自定义指令?

✅ Vue 的哲学

数据驱动视图” —— 大部分情况下,你只需修改数据,Vue 自动更新 DOM。

❌ 但有些场景例外

场景 数据驱动不足
输入框聚焦 无数据变化
图片懒加载 需监听 scroll 事件
集成第三方库(如 DatePicker 需直接操作 DOM
按钮权限控制(v-permission) 需动态显示/隐藏

💥 这些场景需要直接操作 DOM,此时自定义指令是最佳选择。


二、基础语法:钩子函数详解

📌 钩子函数执行时机

bind → inserted → update → componentUpdated → unbind
钩子 触发时机 典型用途
bind 指令第一次绑定到元素 初始化设置(如添加事件监听)
inserted 元素插入父节点 访问 DOM 尺寸、位置
update 组件 VNode 更新时 值变化时更新 DOM
componentUpdated 组件及其子组件更新后 执行依赖完整 DOM 的操作
unbind 指令解绑时 清理事件、定时器

🎯 钩子函数参数

function myDirective(el, binding, vnode, prevVnode) {
  // el: 绑定的 DOM 元素
  // binding: 指令对象
  // vnode: 虚拟节点
  // prevVnode: 上一个 VNode(仅 update/componentUpdated)
}

binding 对象详解

属性 示例 说明
value v-my-dir="msg"msg 的值 指令绑定的值
oldValue 更新前的值 仅在 update/componentUpdated 中可用
arg v-my-dir:arg'arg' 传入的参数
modifiers v-my-dir.mod1.mod2{ mod1: true, mod2: true } 修饰符对象
expression v-my-dir="a + b"'a + b' 绑定的表达式字符串

三、定义方式

✅ 1. 全局指令

Vue.directive('focus', {
  inserted(el) {
    el.focus();
  }
});

✅ 2. 局部指令

<template>
  <input v-focus />
</template>

<script>
export default {
  directives: {
    focus: {
      inserted(el) {
        el.focus();
      }
    }
  }
}
</script>

四、初级应用:5 个经典案例

🎯 1. 自动聚焦(v-focus

Vue.directive('focus', {
  inserted(el) {
    el.focus();
  }
});
<input v-focus />

🎯 2. 点击外部关闭(v-click-outside

Vue.directive('click-outside', {
  bind(el, binding) {
    const handler = (e) => {
      if (!el.contains(e.target)) {
        binding.value(e); // 执行传入的函数
      }
    };
    document.addEventListener('click', handler);
    el._clickOutside = handler;
  },
  unbind(el) {
    document.removeEventListener('click', el._clickOutside);
  }
});
<div v-click-outside="closeMenu">菜单</div>

🎯 3. 相对时间(v-timeago

Vue.directive('timeago', {
  bind(el, binding) {
    const date = new Date(binding.value);
    el.textContent = `${Math.floor((Date.now() - date) / 60000)}分钟前`;
  },
  update(el, binding) {
    // 值变化时更新
    if (binding.value !== binding.oldValue) {
      const date = new Date(binding.value);
      el.textContent = `${Math.floor((Date.now() - date) / 60000)}分钟前`;
    }
  }
});
<span v-timeago="post.createdAt"></span>

🎯 4. 按钮权限(v-permission

Vue.directive('permission', {
  bind(el, binding) {
    const userRoles = this.$store.getters.roles;
    if (!userRoles.includes(binding.value)) {
      el.parentNode.removeChild(el); // 移除无权限的按钮
    }
  }
});
<button v-permission="'admin'">删除</button>

🎯 5. 滚动动画(v-scroll

Vue.directive('scroll', {
  inserted(el, binding) {
    const onScroll = () => {
      if (window.scrollY > 100) {
        el.classList.add('scrolled');
      } else {
        el.classList.remove('scrolled');
      }
    };
    window.addEventListener('scroll', onScroll);
    el._scrollHandler = onScroll;
  },
  unbind(el) {
    window.removeEventListener('scroll', el._scrollHandler);
  }
});
<header v-scroll></header>

五、高级应用:2 个深度实战

🚀 1. 图片懒加载(v-lazy

const imageObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      img.classList.remove('lazy');
      imageObserver.unobserve(img);
    }
  });
});

Vue.directive('lazy', {
  bind(el, binding) {
    el.dataset.src = binding.value;
    el.classList.add('lazy');
    imageObserver.observe(el);
  },
  update(el, binding) {
    if (binding.value !== binding.oldValue) {
      el.dataset.src = binding.value;
      // 如果已进入视口,立即加载
      if (el.getBoundingClientRect().top < window.innerHeight * 1.5) {
        el.src = binding.value;
      }
    }
  },
  unbind(el) {
    imageObserver.unobserve(el);
  }
});
<img v-lazy="imageUrl" />

🚀 2. 集成 ECharts(v-chart

Vue.directive('chart', {
  bind(el) {
    el._chart = echarts.init(el);
  },
  update(el, binding) {
    const chart = el._chart;
    if (binding.value) {
      chart.setOption(binding.value, true);
    }
  },
  unbind(el) {
    el._chart.dispose();
  }
});
<div v-chart="chartOption" style="width: 400px; height: 300px;"></div>

六、重要注意事项

⚠️ 1. 不要修改 v-model 绑定的值

<input v-model="msg" v-my-directive />
  • ❌ 在指令中直接 el.value = 'new'msg 不会更新;
  • ✅ 正确做法:触发 inputchange 事件。
el.value = 'new';
el.dispatchEvent(new Event('input'));

⚠️ 2. 清理副作用

  • unbind 中移除事件监听;
  • 清除定时器;
  • 销毁第三方实例(如 ECharts)。

⚠️ 3. 性能优化

  • 避免在 update 中做昂贵操作;
  • 使用 binding.valuebinding.oldValue 判断是否需要更新。

💡 结语

“自定义指令是 Vue 的‘最后一公里’解决方案。”

场景 推荐方案
简单 DOM 操作 自定义指令
复杂逻辑复用 Mixin / Composition API
UI 组件 普通组件
钩子 使用场景
bind 初始化
inserted 访问布局
update 值变化
unbind 清理资源

掌握自定义指令,你就能:

✅ 实现原生 DOM 操作;
✅ 集成第三方库;
✅ 创建可复用的 DOM 行为;
✅ 补充数据驱动的不足。

仅用几行 CSS,实现优雅的渐变边框效果

作者 序猿杂谈
2025年10月14日 22:30

概述

在网页设计中,渐变边框(Gradient Border) 是一种常见的视觉效果,能让元素的边框呈现出丰富的色彩过渡,常用于按钮、卡片或装饰性容器中,增强界面的层次感与视觉吸引力。

gradient_border_button.jpg

background: 
linear-gradient(90deg, #d38312, #a83279) padding-box, 
linear-gradient(90deg, #ffcc70, #c850c0) border-box;
background-clip: padding-box, border-box;

许多人在实现渐变边框时,第一反应是使用 border-image、伪元素(:before / :after),或套两层容器:外层模拟边框,内层放内容。例如:

<div class="outer">
  <div class="inner"></div>
</div>
.outer {
  width: 100px;
  height: 100px;
  padding: 10px;
  border-radius: 12px;
  background: linear-gradient(90deg, #d38312, #a83279);
}
.inner {
  width: 100%;
  height: 100%;
  border-radius: inherit;
  background: linear-gradient(90deg, #ffcc70, #c850c0);
}

但事实上,只需几行简洁的 CSS,无需多层结构,就能实现优雅可控的渐变边框。

原理

在理解实现方式之前,我们先回顾一下 CSS 背景的层叠机制。

在 CSS 中,一个元素的背景(background ↪)可以由多层组成,每一层都可以单独指定作用范围,例如:

  • padding-box:作用于内容 + 内边距区域。
  • border-box:作用于包括边框在内的整个盒子。

当我们设置了透明边框(border: 10px solid transparent)后,border-box 层的背景就能透过边框区域被看到。

利用这一特性,我们可以通过两层线性渐变来制造边框的渐变效果:

background:
  linear-gradient(90deg, #d38312, #a83279) padding-box,  /* 内层渐变 */
  linear-gradient(90deg, #ffcc70, #c850c0) border-box;   /* 外层渐变 */
background-clip: padding-box, border-box;

其中:

  • 第一层控制内容区渐变;
  • 第二层控制边框区域渐变;
  • background-clip 用来精确限定各层渐变的绘制范围。

代码实现

以下是一个完整可运行的示例👇

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>渐变边框按钮</title>
    <style>
      body {
        height: 100vh;
        display: flex;
        justify-content: center;
        align-items: center;
      }

      .box {
        padding: 15px 45px;
        border-radius: 10px;

        /** ⭐️ 关键属性:设置边框粗细,并用透明填充,让背景的 border-box 渐变可见 */
        border: 2px solid transparent;

        /** ⭐️ 关键属性:多层背景实现渐变边框 */
        background: 
          linear-gradient(90deg, #d38312, #a83279) padding-box, 
          linear-gradient(90deg, #ffcc70, #c850c0) border-box;
        background-clip: padding-box, border-box;

        display: flex;
        justify-content: center;
        align-items: center;
        cursor: pointer;

        font-size: 18px;
        font-weight: bold;
        color: white;

        font-family: Georgia, "Times New Roman", Times, serif;
      }
    </style>
  </head>
  <body>
    <div class="box">Details</div>
  </body>
</html>

动态扩展

在此基础上,我们还可以轻松添加交互动画。例如,为按钮添加渐变流动的 hover 效果:

gradient_border_button_hover.gif

实现代码如下:

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>渐变边框按钮</title>
    <style>
      body {
        height: 100vh;
        display: flex;
        justify-content: center;
        align-items: center;
      }

      .box {
        border-radius: 10px;
        box-shadow: 0 0 20px #eee;
        padding: 15px 45px;
        transition: 0.5s;

        background-size: 200% auto;
        background-image: linear-gradient(to right, #d38312 0%, #a83279 51%, #d38312 100%);

        cursor: pointer;
        color: white;
        text-transform: uppercase;
      }
      .box:hover {
        background-position: right center; /* change the direction of the change here */
      }
    </style>
  </head>
  <body>
    <div class="box">Hover me</div>
  </body>
</html>

总结

通过本文,我们掌握了使用 多层 background + background-clip + 透明边框 实现渐变边框的核心技巧。

要点回顾:

  1. 使用透明边框:border: 10px solid transparent;
  2. 通过多层背景实现不同区域渐变;
  3. 用 background-clip 精确控制绘制范围。

React 状态管理中的循环更新陷阱与解决方案

2025年10月14日 21:17

React 状态管理中的循环更新陷阱与解决方案

问题抽象

在前端开发中,我们经常遇到这样的场景:需要根据初始数据自动回显UI状态,但数据需要分批异步加载。这时容易陷入一个经典的状态同步循环陷阱

核心矛盾

初始数据: [A, B, C]  (需要回显3项)
         ↓
第一批加载: 找到 A → 回显 A
         ↓
状态同步: 将当前状态 [A] 写回初始数据
         ↓
初始数据: [A]  (丢失了 B, C)
         ↓
第二批加载: 只知道需要回显 A,无法回显 B, C

问题本质

两个 useEffect 形成了不对等的双向绑定

// Effect 1: 数据 → 视图(分批加载)
useEffect(() => {
  const itemsToShow = source.filter(item => targetIds.includes(item.id))
  setSelected(itemsToShow)  // 部分数据
}, [availableData])

// Effect 2: 视图 → 数据(立即同步)
useEffect(() => {
  targetIds = selected.map(item => item.id)  // 覆盖原始数据
}, [selected])

关键问题:Effect 2 不知道 Effect 1 还没完成,就把部分结果当作最终结果同步回去了。

核心解决思想

思想1:识别"中间状态" vs "最终状态"

通过数据对比判断当前是否处于回显的中间过程:

useEffect(() => {
  const newIds = selected.map(item => item.id)
  const originalIds = initialData.split(',')
  
  // 关键判断:新数据是原始数据的真子集 → 中间状态
  const isPartialState = 
    newIds.every(id => originalIds.includes(id)) &&  // 是子集
    newIds.length < originalIds.length                // 且不完整
  
  // 只在非中间状态时同步
  if (!isPartialState) {
    syncBackToSource(newIds)
  }
}, [selected])

核心逻辑

  • 子集 + 不完整 = 中间状态 → 不同步
  • 完整包含新增 = 最终状态 → 同步

思想2:保护"原始意图"

维护一个不可变的原始数据引用:

const originalIntent = useRef(null)

// 首次接收时保存原始意图
useEffect(() => {
  if (!originalIntent.current) {
    originalIntent.current = initialData
  }
}, [initialData])

// 始终基于原始意图进行回显
useEffect(() => {
  const targetIds = originalIntent.current.split(',')
  const found = availableData.filter(item => targetIds.includes(item.id))
  
  // 增量更新,不覆盖
  setSelected(prev => {
    const merged = [...prev]
    found.forEach(item => {
      if (!merged.some(m => m.id === item.id)) {
        merged.push(item)
      }
    })
    return merged
  })
}, [availableData])

思想3:单向数据流 + 完成标志

明确区分"回显阶段"和"编辑阶段":

const [phase, setPhase] = useState('loading')  // loading | editing

// 回显阶段:数据 → 视图
useEffect(() => {
  if (phase === 'loading') {
    const found = availableData.filter(item => targetIds.includes(item.id))
    setSelected(found)
    
    // 判断是否完成
    if (found.length === targetIds.length) {
      setPhase('editing')  // 切换阶段
    }
  }
}, [availableData, phase])

// 编辑阶段:视图 → 数据
useEffect(() => {
  if (phase === 'editing') {
    syncBackToSource(selected)
  }
}, [selected, phase])

通用模式总结

模式1:子集检测模式

适用场景:数据逐步加载,需要保护完整性

function shouldSync(current, original) {
  const isSubset = current.every(item => original.includes(item))
  const isIncomplete = current.length < original.length
  return !(isSubset && isIncomplete)  // 非中间状态才同步
}

模式2:原始意图保护模式

适用场景:需要确保回显完整性,防止数据丢失

const intent = useRef(initialData)
// 所有操作基于 intent.current 而非可能被修改的 initialData

模式3:阶段切换模式

适用场景:明确的流程阶段,需要清晰的状态机

const phases = {
  INITIALIZING: 'init',
  LOADING: 'loading',
  READY: 'ready',
  EDITING: 'editing'
}

设计原则

  1. 单一职责:一个 Effect 只负责一个方向的数据流
  2. 意图保护:保护用户/系统的原始意图不被中间状态破坏
  3. 状态识别:能够识别出"过渡状态"和"稳定状态"
  4. 延迟同步:在不确定的情况下,宁可延迟同步也不要过早同步
  5. 幂等性:同步操作应该是幂等的,多次执行结果一致

反模式警示

❌ 反模式1:盲目双向绑定

// 错误:不加判断的双向绑定
useEffect(() => setA(b), [b])
useEffect(() => setB(a), [a])  // 容易形成循环

❌ 反模式2:忽略异步本质

// 错误:假设数据一次性就绪
useEffect(() => {
  const items = findItems(ids)
  setSelected(items)
  updateSource(items)  // 可能还没加载完
}, [availableData])

❌ 反模式3:过度依赖副作用

// 错误:在副作用中修改依赖的数据源
useEffect(() => {
  const result = process(source)
  source = result  // 修改了依赖,可能导致循环
}, [source])

实战技巧

  1. 添加调试标记:在开发时输出状态转换日志
  2. 使用 TypeScript:类型系统帮助发现潜在的状态不一致
  3. 编写测试:针对边界情况编写单元测试
  4. 代码审查:重点关注 useEffect 的依赖关系图

总结

状态同步的循环更新问题本质是时序控制状态识别的问题:

  • 时序控制:何时应该同步,何时应该等待
  • 状态识别:当前是中间状态还是最终状态

解决方案的核心是:在不确定的情况下,保护原始数据的完整性,等待明确的信号再进行同步

记住:数据流应该像河流一样单向流动,而不是像池塘一样相互影响

React 架构重生记:从递归地狱到时间切片

作者 DoraBigHead
2025年10月14日 19:55

本文参考卡颂老师的《React 技术揭秘》,并结合小dora个人理解与源码阅读编写的一篇博客。
目标是让你看懂:React 为什么要重写架构、Fiber 到底解决了什么问题。


一、React15:一个“全力以赴但不会刹车”的系统

React15 的架构只有两层:

  • 🧩 Reconciler(协调器) :负责计算哪些组件要更新;
  • 🖼️ Renderer(渲染器) :把更新同步到对应平台(浏览器、原生、测试环境等)。

听起来没问题,但问题出在它的更新策略——
React15 在更新时使用的是递归调用

每次调用 setState() 时,React 会自上而下递归遍历整棵组件树。

我们可以用伪代码看看它的本质:

function updateComponent(component) {
  component.render(); // 渲染当前组件
  component.children.forEach(updateComponent); // 递归子组件
}

简单粗暴,效率直接。
但问题是——一旦递归开始,就停不下来


🧠 举个例子:

假设你有一棵很深的组件树,当用户点击按钮触发更新时,
React 就会一路递归更新下去:

App
 ├─ Header
 ├─ Main
 │   ├─ List
 │   │   ├─ Item #1
 │   │   ├─ Item #2
 │   │   └─ Item #3
 │   └─ Sidebar
 └─ Footer

当层级很深、每个组件都要执行 render() 时,
整个递归过程会持续超过 16ms(一帧的理想渲染时间)。

这意味着在更新的过程中,浏览器完全没有机会响应用户操作

想点击?等我更新完再说。
想输入?我还在 render 呢。

这,就是 React15 最大的痛点——同步更新不可中断


二、如果在中途强行“打断”会发生什么?

假设我们有个 Demo:

function List({ items }) {
  return (
    <ul>
      {items.map((num) => (
        <li key={num}>{num * 2}</li>
      ))}
    </ul>
  );
}

用户希望看到 [1, 2, 3] → [2, 4, 6]

如果中途在更新到第二个 <li> 时被中断,就可能出现半成品页面:

<li>2</li>
<li>2</li>
<li>3</li>

React15 没法处理这种情况。因为它没有保存中间状态,也没有“恢复机制”。
它只能一口气跑完。

这时候 React 团队意识到:

我们需要一个可以「暂停、恢复、甚至丢弃」任务的架构。


三、React16:Fiber——让 React 学会「调度」

于是,在 React16 中,React 团队重写了整个协调层,设计了新的架构:

+------------------+
|   Scheduler      | 调度器:分配优先级,安排执行顺序
+------------------+
|   Reconciler     | 协调器:找出变化的组件(Fiber)
+------------------+
|   Renderer       | 渲染器:将变化反映到宿主环境
+------------------+

新增的那一层 Scheduler(调度器) 就是关键!


🧬 Fiber 是什么?

简单来说,Fiber 是对「组件更新单元」的抽象
每个组件都会对应一个 Fiber 对象,它保存:

{
  type: Component,
  pendingProps: newProps,
  child: firstChildFiber,
  sibling: nextFiber,
  return: parentFiber
}

它就像是一个链表节点,连接整棵组件树。
通过 Fiber,React 可以记录任务执行的进度


🔁 可中断的循环

React16 的更新逻辑不再是递归,而是循环:

function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
  }
}

每次只处理一个 Fiber 单元,然后问一句:

if (shouldYield()) pause();

shouldYield() 就是核心判断:
👉 当前帧的时间是否用完?
👉 有没有更高优任务进来?

如果答案是“是”,就中断执行,把控制权交还给浏览器。

React 会在下一帧或空闲时间里继续从中断点恢复


四、Scheduler:React 的「时间管理大师」

Fiber 可以被打断,但谁来决定打断时机

这就轮到 Scheduler 登场了。

浏览器有个原生 API requestIdleCallback()
可以在浏览器空闲时执行任务,但它兼容性和触发频率都不稳定。

于是 React 自己实现了一个更强的版本:

📦 scheduler
它模拟浏览器空闲回调,并为任务赋予多种优先级。

每个任务都带有权重,比如:

优先级 说明 示例
Immediate 立即执行 错误边界恢复
UserBlocking 用户输入 输入框响应
Normal 常规更新 列表渲染
Low 低优任务 动画或日志
Idle 空闲任务 后台预加载

通过这种优先级机制,React 终于可以像操作系统一样分配 CPU 时间。


五、渲染:内存标记 + 批量提交

Fiber 负责协调,Renderer 才是执行者。
在 React16 中,Reconciler 不再边遍历边渲染,而是先打标记、后统一提交

比如:

export const Placement = 0b0000000000010;
export const Update = 0b0000000000100;
export const Deletion = 0b0000000001000;

每个 Fiber 节点在内存中被打上这些标签。
等所有标记完成后,Renderer 一次性提交所有 DOM 变更。

这就保证了即使中途被中断,DOM 始终保持一致性


六、可视化理解:React15 vs React16

对比项 React15 React16 (Fiber)
架构层次 Reconciler + Renderer Scheduler + Reconciler + Renderer
更新机制 递归 循环
可中断性 ❌ 不可中断 ✅ 可中断
DOM 一致性 更新中可能闪烁 内存标记后统一提交
优先级调度 有(Scheduler)
源码模块 ReactDOM react-reconciler + scheduler

📊 可以把这两者比喻成:

  • React15:单线程跑完一场马拉松,中途谁也拦不住;
  • React16:多任务分片执行,随时暂停、恢复、插队。

七、总结:从渲染引擎到时间调度系统

React16 的架构重写并非简单的性能优化,
而是一种“调度哲学的引入”。

React 不再只是「渲染 DOM 的库」,
而是一个「管理任务优先级的调度系统」。

Fiber 让任务可中断;
Scheduler 让任务有先后;
Renderer 让任务有结果。

React 的底层逻辑已经从:

同步执行异步调度

演化成一套“以用户体验为核心的调度架构”。


📘 参考资料

  • 卡颂,《React 技术揭秘》
  • React 官方源码(react-reconciler / scheduler)
  • React 团队公开设计文档

WebSocket服务封装实践:从连接管理到业务功能集成

作者 小鹿小陆
2025年10月14日 18:33

现代Web应用中的实时通信需求

最近项目中需要将先科的广播系统管理平台移植到系统中,经过不断反复的推翻修改,终于有了这篇文章。主要分享一下在设计websocket过程中的一些小技巧与实践方法。

image.png

前言: 在当今的Web应用开发中,实时通信功能已成为许多系统的核心需求。无论是即时聊天、实时数据监控还是广播通知系统,WebSocket技术都扮演着至关重要的角色。然而,直接使用原生的WebSocket API往往会导致代码重复、状态管理混乱和错误处理困难等问题。本文将介绍如何封装一个健壮的WebSocket服务,展示从基础连接管理到高级业务功能集成的最佳实践。

123.png

1. 连接管理:建立可靠的双向通信通道

WebSocket服务封装了完整的连接生命周期管理:

// --- 全局变量声明(WebSocket连接状态管理) ---
let connectPromise = null        // 核心:保存连接的Promise,用于共享连接状态
let socket = null                // 当前的 WebSocket 实例
let connectionStatus = 'disconnected'    // 连接状态:'idle'|'connecting'|'connected'|'error'|'disconnected'
let shouldReconnect = true       // 控制是否允许自动重连
let reconnectAttempts = 0        // 当前重连尝试次数

// 可配置的重连策略
const MAX_RECONNECT_ATTEMPTS = 5 // 最大重连次数
const RECONNECT_INTERVAL = 3000  // 重连间隔时间(毫秒)
let timer = null                 // 心跳定时器句柄

连接初始化的关键特性

  • 单例模式实现:避免重复创建连接
  • Promise封装:提供异步操作接口
  • 自动重连机制:在连接断开时自动尝试恢复
  • 状态跟踪:实时监控连接状态
/**
 * 初始化 WebSocket 连接(Promise 版本)
 * 
 * 该函数用于建立与 WebSocket 服务器的连接,并在连接成功后自动执行用户登录和心跳机制。
 * 使用 Promise 封装连接过程,避免重复创建连接,支持自动重连机制。
 * 
 * @param {string} wsUrl - WebSocket 服务器地址(如:ws://localhost:8080)
 * @param {function} onMessage - 可选的消息回调函数(此处未使用,但可扩展)
 * @param {number} timeout - 可选:连接超时时间(毫秒),默认 10 秒(当前未实现超时控制)
 * @returns {Promise<WebSocket>} - 成功时 resolve,返回 WebSocket 实例(实际 resolve 无参数)
 *                                  失败时 reject,携带错误信息
 */

export const initWebSocket = (wsUrl = 'ws://') => {
    // 如果已经有连接或正在连接,则直接返回同一个 Promise
    // 避免多次调用 initWebSocket 时创建多个连接
    if (connectPromise) {
        return connectPromise;
    }

    // 创建一个新的 Promise 来管理 WebSocket 的连接过程
    connectPromise = new Promise((resolve, reject) => {
        // 情况 1:如果 WebSocket 已经打开,直接 resolve,无需重复连接
        if (socket && socket.readyState === WebSocket.OPEN) {
            console.log('WebSocket 已连接,跳过初始化');
            return resolve(socket); // 可选择返回 socket 实例
        }

        // 情况 2:如果 WebSocket 正在连接中,不重复创建,但此处未 reject 或 resolve
        // 注意:这里没有处理正在连接的情况,可能导致 Promise 悬挂(潜在问题)
        // 建议:可以 reject 或 resolve 等待现有连接完成
        if (socket && socket.readyState === WebSocket.CONNECTING) {
            console.log('WebSocket 正在连接中...');
            // 当前未处理,connectPromise 会一直等待 onopen 或 onclose
            // 可优化:监听现有 socket 的 onopen 并 resolve
            return; // 不执行后续连接逻辑
        }
        
        shouldReconnect = true; // 设置重连标志为 true,表示允许自动重连
        reconnectAttempts = 0; // 重置重连尝试次数
        socket = new WebSocket(wsUrl); // 创建新的 WebSocket 实例
        connectionStatus = 'connecting'; // 更新连接状态

        /**
         * WebSocket 连接成功打开时触发
         */
        socket.onopen = () => {
            console.log('WebSocket 连接已建立');
            connectionStatus = 'connected';

            // 连接成功后尝试用户登录(根据实际业务自行封装)
            userLogin('xxx', 'xxx')
                .then(() => {
                    console.log('用户登录成功,开始心跳');
                    startHeartbeat(); // 登录成功后启动心跳机制,维持连接
                    resolve(socket);  // 登录成功才认为初始化完成,resolve Promise
                })
                .catch((err) => {
                    console.error('用户登录失败:', err);
                    reject(new Error('登录失败')); // 登录失败则 reject
                });
        };

        /**
         * 接收到服务器消息时触发
         * 假设消息为 JSON 格式
         */
        socket.onmessage = (event) => {
            let data;
            try {
                data = JSON.parse(event.data);
                
                // 特殊处理:如果收到心跳响应且 result 不为 0,表示心跳失败,关闭连接
                if (data.command === 'heartbeat' && data.result !== 0) {
                    console.warn('心跳响应失败,关闭连接');
                    closeWebSocket(); // 调用关闭函数,可能触发重连
                }
            } catch (e) {
                console.error('无法解析消息为 JSON:', event.data);
                return; // 解析失败,忽略该消息
            }

            // 将正常消息通过事件机制广播给其他模块处理
            notifyMessage(data);
        };

        /**
         * WebSocket 发生错误时触发
         * 注意:error 事件并不一定会导致连接关闭,但应记录日志
         */
        socket.onerror = (error) => {
            console.error('WebSocket 错误:', error);
            connectionStatus = 'error';
            // 注意:此处不 reject,因为连接可能仍会通过 onclose 触发重连
        };

        /**
         * WebSocket 连接关闭时触发
         * 可能是网络断开、服务端关闭、手动关闭等
         */
        socket.onclose = () => {
            console.log('WebSocket 连接已关闭');
            connectionStatus = 'disconnected';
            clearInterval(timer); // 清除心跳定时器

            // 判断是否需要自动重连
            if (shouldReconnect && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
                reconnectAttempts++;
                console.log(`尝试重连... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);

                // 延迟一段时间后尝试重新连接
                setTimeout(() => {
                    connectPromise = null; // 清除旧的 Promise,允许重新调用 initWebSocket
                    // 递归调用自身进行重连
                    initWebSocket(wsUrl).catch((err) => {
                        console.error('重连失败:', err);
                    });
                }, RECONNECT_INTERVAL);
            } else {
                // 超过最大重连次数或不允许重连
                console.warn('达到最大重连次数或已禁止重连,停止重连');
            }
        };
    });

    // 返回连接 Promise,调用者可通过 .then().catch() 处理结果
    return connectPromise;
};

2. 消息处理:事件总线与命令分发

高效的消息处理是WebSocket服务的核心能力:

import { notifyMessage } from '@/utils/eventBus'; // 引入消息总线

/**
* 接收到服务器消息时触发
* 假设消息为 JSON 格式
*/
socket.onmessage = (event) => {
    let data;
    try {
        data = JSON.parse(event.data);
        
        // 特殊处理:如果收到心跳响应且 result 不为 0,表示心跳失败,关闭连接
        if (data.command === 'heartbeat' && data.result !== 0) {
            console.warn('心跳响应失败,关闭连接');
            closeWebSocket(); // 调用关闭函数,可能触发重连
        }
    } catch (e) {
        console.error('无法解析消息为 JSON:', event.data);
        return; // 解析失败,忽略该消息
    }

    // 将正常消息通过事件机制广播给其他模块处理
    notifyMessage(data);
};

利用eventBus事件总线通知全局订阅者,接收消息

// eventBus.js
import mitt from 'mitt'
const subscribers = []

// 订阅消息
export const subscribe = (callback) => {
    if (typeof callback === 'function') {
        subscribers.push(callback)
    }
    // 返回取消订阅函数
    return () => {
        const index = subscribers.indexOf(callback)
        if (index > -1) {
            subscribers.splice(index, 1)
        }
    }
}

// 通知所有订阅者
export const notifyMessage = (data) => {
    subscribers.forEach((callback) => {
        try {
            callback(data)
        } catch (error) {
            console.error('消息回调执行出错:', error)
        }
    })
}

export default mitt()

消息处理策略

  • JSON数据解析与错误处理
  • 特殊解析与错误处理
  • 特殊命令的优先处理(如心跳、登录响应)
  • 通用消息通过事件总线广播
  • 命令路由机制(根据command字段分发处理)

3. Promise封装:管理异步操作

利用Promise管理异步操作使代码更清晰:

// 发送消息的Promise封装
/**
 * 发送消息到 WebSocket 服务器(异步安全版本)
 *
 * 该函数用于向 WebSocket 服务端发送消息。在发送前会确保连接已建立(自动初始化连接),
 * 并对消息格式、连接状态进行检查,确保消息可靠发送。
 *
 * @param {Object|string} message - 要发送的消息内容,通常为对象(将被 JSON.stringify)
 * @returns {Promise<boolean>} - 发送成功返回 true,失败返回 false
 *
 * @example
 * const success = await sendMessage({ command: 'chat', data: 'Hello' });
 * if (success) {
 *   console.log('消息发送成功');
 * } else {
 *   console.log('消息发送失败');
 * }
 */
export const sendMessage = async (message) => {
    // 参数校验:禁止发送空消息
    if (!message) {
        console.warn('无法发送空消息:message 为 null、undefined 或空值');
        return false;
    }

    try {
        // 确保 WebSocket 连接已建立
        // 如果尚未连接,initWebSocket 会尝试建立连接并完成登录流程
        // 如果连接失败或登录失败,initWebSocket 会 reject,此处捕获并返回 false
        await initWebSocket();
    } catch (error) {
        // initWebSocket 失败(如连接超时、网络问题、登录失败等)
        console.warn('WebSocket 初始化失败,无法发送消息:', error.message);
        return false;
    }

    // 再次检查 WebSocket 的当前状态是否为 OPEN(已打开)
    // 即使 initWebSocket 成功,网络可能在发送前断开,因此需要二次确认
    if (socket && socket.readyState === WebSocket.OPEN) {
        try {
            // 将消息序列化为 JSON 字符串并发送
            socket.send(JSON.stringify(message));
            console.log('消息已发送:', message);
            return true; // 发送成功
        } catch (error) {
            // send() 方法在某些异常情况下可能抛出异常(如序列化失败、底层错误)
            console.error('WebSocket send() 方法调用失败:', error);
            return false;
        }
    } else {
        // WebSocket 未连接或处于 CONNECTING/CLOSING/CLOSED 状态
        console.warn('WebSocket 未处于 OPEN 状态,无法发送消息');
        return false;
    }
};

Promise使用场景

  • 连接初始化:确保连接就绪
  • 用户登录:处理认证流程
  • 业务操作:如广播寻呼、获取设备信息等
  • 错误处理:统一捕获和报告异常

4. 通用方法封装示例

/**
 * 获取设备信息
 *
 * 该函数用于向指定设备发送指令,以获取与指定账户关联的区域(zone)信息。
 * 它通过调用 sendMessage 函数发送一个包含设备唯一标识和目标账户的命令。
 * @param {string} device_type - 0:分区设备 1:寻呼台设备 
 *
 * @description
 * 发送的消息格式如下:
 * {
 *   command: "get_user_zone",  获取用户的分区:get_user_zone
 *   dest_account: "目标账户" // 目标账户名称(自己或子用户)
 * }
 */
export const getDeviceInfo = (type) => {
    return new Promise(async (resolve, reject) => {
        // 如果 WebSocket 未连接,直接拒绝
        if (!socket || socket.readyState !== WebSocket.OPEN) {
            await initWebSocket()
            console.warn('WebSocket 未连')
            return reject(new Error('WebSocket 未连接'))
        }
        const Message = {
            uuid: '登录返回的uuid',
            command: 'get_device_info',
            device_type: type, // 0:分区设备 1:寻呼台设备 
            all_zone: true, // 是否请求全部分区
            page: 1
        }
        try {
            socket.send(JSON.stringify(Message))
            resolve()
        } catch (error) {
            return reject(new Error('发送消息失败: ' + error.message))
        }
    })
}

5. 请求示例

<script setup>
import { onMounted, onBeforeUnmount } from 'vue'
import { initWebSocket, closeWebSocket, getDeviceInfo } from '@/utils/WebSocket'
import { subscribe } from '@/utils/eventBus'

// 订阅消息
subscribe((ev) => {
    if (ev.command == 'get_device_info') {
        // 这里处理订阅的消息
    }
})

onMounted(async () => {
    await initWebSocket() // 初始化websocket
    await getDeviceInfo('3') // 获取设备信息
})
onBeforeUnmount(() => {
    closeWebSocket() // 关闭websocket
})
</script>

<template>
    <div></div>
</template>

<style lang="scss" scoped></style>

6. 完整代码

// websocketService.js
// websoket链接(用于IP广播)
import { notifyMessage } from '@/utils/eventBus' // 引入消息总线
let socket = null
let connectionStatus = 'disconnected'
let connectPromise = null // 核心:保存连接的 Promise,用于共享连接状态

// 可配置的最大重试次数和重连间隔
const MAX_RECONNECT_ATTEMPTS = 5
const RECONNECT_INTERVAL = 3000 // 3秒

let reconnectAttempts = 0
let shouldReconnect = false
let onMessageCallback = null
let timer = null

/**
 * 初始化 WebSocket 连接(Promise 版本)
 *
 * @param {string} wsUrl - WebSocket 服务器地址
 * @param {function} onMessage - 可选的消息回调函数
 * @param {number} timeout - 可选:连接超时时间(毫秒),默认 10 秒
 * @returns {Promise<WebSocket>} - 成功时返回 socket 实例
 */
export const initWebSocket = (wsUrl = 'ws://') => {
    // 如果已经有连接或正在连接,直接返回同一个 Promise
    if (connectPromise) {
        return connectPromise
    }

    // 创建新的连接 Promise
    connectPromise = new Promise((resolve, reject) => {
        // 如果已经连接,直接 resolve
        if (socket && socket.readyState === WebSocket.OPEN) {
            console.log('WebSocket 已连接,跳过初始化')
            return resolve()
        }

        // 正在连接或手动关闭后不再自动重连,则拒绝
        if (socket && socket.readyState === WebSocket.CONNECTING) {
            console.log('WebSocket 正在连接中...')
            return
        }

        shouldReconnect = true
        reconnectAttempts = 0

        socket = new WebSocket(wsUrl)
        connectionStatus = 'connecting'

        socket.onopen = () => {
            console.log('WebSocket 连接成功')
            connectionStatus = 'connected'
            sessionStorage.removeItem('storage-token')
            // 连接成功后尝试登录
            userLogin('admin', 'admin')
                .then(() => {
                    console.log('自动登录成功')
                    startHeartbeat() // 登录成功后开始心跳
                    resolve() // 登录成功才认为初始化完成
                })
                .catch((err) => {
                    console.error('自动登录失败:', err)
                    reject(new Error('登录失败'))
                })
        }

        socket.onmessage = (event) => {
            let data
            try {
                data = JSON.parse(event.data)
                if (data.command == 'heartbeat' && data.result != 0) closeWebSocket()
            } catch (e) {
                console.error('无法解析消息:', event.data)
                return
            }

            // 处理登录响应
            if (data.command === 'user_login') {
                handleLoginResponse(data, resolve, reject)
                return
            }

            // 广播其他消息
            notifyMessage(data)
        }

        socket.onerror = (error) => {
            console.error('WebSocket 错误:', error)
            connectionStatus = 'error'
        }

        socket.onclose = () => {
            console.log('WebSocket 连接关闭')
            connectionStatus = 'disconnected'
            clearInterval(timer)

            if (shouldReconnect && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
                reconnectAttempts++
                console.log(`尝试重连... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`)
                setTimeout(() => {
                    connectPromise = null // 允许重新连接
                    initWebSocket(wsUrl).catch(() => {})
                }, RECONNECT_INTERVAL)
            } else {
                console.warn('停止重连')
            }
        }
    })

    return connectPromise
}

// 2. 发送消息函数
export const sendMessage = async (message) => {
    if (!message) {
        console.warn('无法发送空消息')
        return false
    }

    try {
        // 确保连接已建立
        await initWebSocket()
    } catch (error) {
        console.warn('连接失败,无法发送消息:', error.message)
        return false
    }

    if (socket.readyState === WebSocket.OPEN) {
        try {
            socket.send(JSON.stringify(message))
            return true
        } catch (error) {
            console.error('发送消息失败:', error)
            return false
        }
    } else {
        console.warn('WebSocket 未处于 OPEN 状态,无法发送')
        return false
    }
}

// 3. 关闭连接函数
export const closeWebSocket = () => {
    shouldReconnect = false
    if (socket) {
        socket.close()
    }
    if (timer) clearInterval(timer)
    connectPromise = null // 允许下次重新连接
    sessionStorage.removeItem('storage-token')
}

// 登录响应处理
let loginResolve = null
let loginReject = null

function handleLoginResponse(data, resolve, reject) {
    if (data.result === 0) {
        try {
            sessionStorage.setItem('storage-name', data.user_name || '')
            sessionStorage.setItem('storage-password', data.password || '')
            sessionStorage.setItem('storage-token', data.uuid || '')
            sessionStorage.setItem('storage-userType', data.user_type || '')
        } catch (err) {
            console.error('存储登录信息失败:', err)
        }
        if (loginResolve) loginResolve(data)
        if (resolve) resolve()
    } else {
        const error = new Error(data.msg || '登录失败')
        if (loginReject) loginReject(error)
        if (reject) reject(error)
    }
    loginResolve = null
    loginReject = null
}
/**
 * 用户登录函数
 *
 * 该函数用于处理用户登录请求
 * @param {string} account - 用户的登录账户名(可以是用户名、邮箱或手机号等)。
 * @param {string} password - 用户的登录密码。建议在调用此函数前对密码进行加密处理,避免明文传输。
 * @description
 * 发送的消息格式如下:
 * {
 *   command: "user_login ",     // 指定操作为用户登录(注意:末尾有多余空格)
 *   account: "用户账户",
 *   password: "用户密码"
 * }
 */
export const userLogin = (account, password) => {
    return new Promise((resolve, reject) => {
        if (!socket || socket.readyState !== WebSocket.OPEN) {
            return reject(new Error('WebSocket 未连接'))
        }

        const token = sessionStorage.getItem('storage-token')
        if (token) {
            return resolve({ status: 'success', msg: 'already logged in' })
        }

        loginResolve = resolve
        loginReject = reject

        const loginMessage = {
            command: 'user_login',
            account: '',
            password: ''
        }

        try {
            socket.send(JSON.stringify(loginMessage))
        } catch (error) {
            reject(new Error('发送登录消息失败: ' + error.message))
        }
    })
}

/**
 * 心跳检测
 */
const startHeartbeat = () => {
    clearInterval(timer)
    timer = setInterval(() => {
        if (socket.readyState === WebSocket.OPEN) {
            const heartbeatMsg = {
                uuid: sessionStorage.getItem('storage-token'),
                command: 'heartbeat'
            }
            try {
                socket.send(JSON.stringify(heartbeatMsg))
            } catch (e) {
                console.error('心跳发送失败')
            }
        }
    }, 60000)
}
/**
 * 4.2 获取设备信息
 *
 * 该函数用于向指定设备发送指令,以获取与指定账户关联的区域(zone)信息。
 * 它通过调用 sendMessage 函数发送一个包含设备唯一标识和目标账户的命令。
 * @param {string} device_type - 0:分区设备 1:寻呼台设备
 *
 * @description
 * 发送的消息格式如下:
 * {
 *   command: "get_user_zone",  获取用户的分区:get_user_zone
 *   dest_account: "目标账户" // 目标账户名称(自己或子用户)
 * }
 */
export const getDeviceInfo = (type) => {
    return new Promise(async (resolve, reject) => {
        // 如果 WebSocket 未连接,直接拒绝
        if (!socket || socket.readyState !== WebSocket.OPEN) {
            await initWebSocket()
            console.warn('WebSocket 未连')
            return reject(new Error('WebSocket 未连接'))
        }
        // 发送登录消息(注意:原代码 command 末尾有空格,按原逻辑保留)
        const Message = {
            uuid: sessionStorage.getItem('storage-token'),
            command: 'get_device_info',
            device_type: type, // 0:分区设备 1:寻呼台设备 
            all_zone: true, // 是否请求全部分区
            page: 1
            // zone_mac:'' //  指定分区的mac地址:all_zone=0时此字段有效
        }
        try {
            socket.send(JSON.stringify(Message))
            resolve()
        } catch (error) {
            return reject(new Error('发送消息失败: ' + error.message))
        }
    })
}

深入解析 Cursor 规则:为团队打造统一的 AI 编程规范

2025年10月14日 18:28

掌握 Cursor 规则功能,让 AI 编程助手真正理解你的项目需求

在 AI 编程时代,我们经常面临一个挑战:如何让 AI 生成的代码符合团队的技术栈和编码规范?Cursor 的规则功能正是为了解决这一痛点而设计。本文将基于官方文档,为你全面解析 Cursor 规则的使用方法和最佳实践。

规则的核心价值:持久化的上下文指导

大型语言模型在多次补全之间不会保留记忆,而规则正是在提示层面提供持久且可复用的上下文。当规则启用时,其内容会被置于模型上下文的开头,为 AI 在生成代码、解释编辑或协助工作流时提供一致的指导。

Cursor规则主要作用于Agent(聊天)和Inline Edit(Cmd+K)功能。这意味着当你使用Chat对话或行内编辑时,规则会自动生效,确保AI生成的代码符合预定规范。

四种规则类型详解

Cursor 支持四种不同类型的规则,每种都有其特定的适用场景:

1. 项目规则(Project Rules)

项目规则位于 .cursor/rules 目录中,每条规则都是一个独立的文件,并纳入版本控制。这是团队协作中最常用的规则类型。

核心特性:

  • 通过路径模式限定作用范围
  • 支持手动执行或按相关性自动包含
  • 子目录下可以有各自的 .cursor/rules,仅作用于该文件夹

使用场景:

  • 固化与代码库相关的领域知识
  • 自动化项目特定的流程或模板
  • 规范化风格或架构决策

2. 团队规则(Team Rules)

Team 和 Enterprise 计划可以通过 Cursor 控制台在整个组织范围内创建并强制执行规则。

管理特性:

  • 管理员可以配置每条规则对团队成员是否为必选
  • 支持“强制执行”模式,防止用户关闭重要规则
  • 优先级最高:Team Rules → Project Rules → User Rules

适用场景:

  • 跨项目的统一编码标准
  • 组织级的安全和合规要求
  • 确保所有项目遵循相同的最佳实践

3. 用户规则(User Rules)

用户规则是在 Cursor Settings → Rules 中定义的全局偏好,适用于所有项目。它们为纯文本格式,适合设置沟通风格或个人编码偏好。

例如所有问题使用中文回答, 可以这样设置。

Always respond in Chinese-simplified

4. AGENTS.md

AGENTS.md 是一个用于定义代理指令的简单 Markdown 文件,将其放在项目根目录,可作为 .cursor/rules 的替代方案,适用于简单、易读指令且不想引入结构化规则开销的场景。

Cursor 支持在项目根目录和子目录中使用 AGENTS.md。

# 项目说明

## 代码风格

- 所有新文件使用 TypeScript
- React 中优先使用函数组件
- 数据库列使用 snake_case 命名

## 架构

- 遵循仓储模式
- 将业务逻辑保持在服务层中

规则文件结构与编写规范

规则文件格式

每个规则文件使用 MDC(.mdc) 格式编写,这是一种同时支持元数据与内容的格式。通过规则类型下拉菜单控制规则的应用方式:

下面是一个 typescript 的规则文件示例

---
description: TypeScript Patterns
globs: *.ts,*.tsx
---
# TypeScript Patterns

## Type Definitions

### API Response Types
Use consistent API response wrapper types:
```typescript
// For array responses
type TArrayResult<T = unknown> = {
  code: number;
  result: T[];
  message?: string;
  msg?: string;
};

// For single item responses  
type TResult<T = unknown> = {
  code: number;
  result: T;
  message?: string;
  msg?: string;
};

规则类型配置

规则类型在 cursor 中通过下拉框选择, 目前支持四种类型:

类型 描述 适用场景
Always Apply 始终包含在模型上下文中 核心技术栈声明、全局编码规范
Apply Intelligently 根据文件类型和内容智能判断是否包含 根据文件内容智能判断是否包含
Apply to Specific Files 仅在文件被 globs 匹配时应用 根据文件名、路径、内容等智能判断是否包含
Apply Manually 仅在使用 @ruleName 明确提及时才包含 需要特殊处理的场景

嵌套规则机制

Cursor 支持在项目中的各级目录下设置规则,实现精细化的控制:

project/
  .cursor/rules/        # 项目级规则
  backend/
    server/
      .cursor/rules/    # 后端专用规则
  frontend/
    .cursor/rules/      # 前端专用规则

当引用某个目录中的文件时,该目录下的嵌套规则会自动生效,为不同模块提供针对性的指导。

团队协作中的规则管理策略

1. 版本控制集成

.cursor/rules 目录纳入 Git 仓库是确保团队一致性的基础。这样可以:

  • 保证所有成员使用相同的规则配置
  • 方便追踪规则的变更历史
  • 支持代码审查流程应用于规则修改

2. 分层规则设计

针对大型项目,建议采用分层规则结构:

基础层规则(项目根目录):

  • 技术栈和框架约束
  • 全局编码规范
  • 项目架构约定

模块层规则(子目录中):

  • 特定模块的专用规则
  • 业务领域的特殊约定
  • 模块间的接口规范

3. 团队规则强制执行

对于关键的组织标准,使用团队规则的“强制执行”功能:

  • 安全规范:SQL 注入防护、认证授权要求
  • 合规要求:数据隐私、行业规范
  • 质量门禁:代码审查标准、测试覆盖要求

规则创建与优化实践

创建规则的方法

  1. 命令创建:执行 New Cursor Rule 命令或在 Cursor Settings > Rules 中创建

  2. AI 生成:在对话中使用 /Generate Cursor Rules 命令直接生成规则。

  3. 手动编写:基于项目需求手动创建和优化规则文件

Generate Cursor Rules 不仅可以为已存在的项目升成完整的规则文件, 也可以通过添加描述对规则进行优化。

社区有大量成熟的规则模板可供参考,能帮你快速起步:

  • 官方规则库cursor.directory):提供Python、FastAPI、Django、Next.js、TypeScript等多种主流语言或框架的预设规则。
  • Awesome CursorRules:GitHub上的高星开源项目,收集了针对不同场景的大量规则模板。

使用社区规则时,复制内容后根据项目实际情况进行调整是关键,包括修改技术栈版本、更新项目结构描述等。

规则优化最佳实践

根据实战经验,以下是让规则更高效的关键技巧:

精简内容,避免重复

  • 合并重复的技术栈描述,删除冗余信息
  • 避免在规则中写入大量示例代码,除非特别重要

精确控制生效范围

  • 不要所有规则都设为Always,这会浪费token并引入噪声
  • 使用Specific Files按文件类型匹配,或Manual模式按需调用

避免“假大空”的要求

  • 规则应具体可行,如“使用TypeScript接口定义props”
  • 删除像“提高性能”等模糊表述,代之以具体实践

实战技巧:让规则真正生效

增加过程决策机制

在user rule中要求AI在遇到不确定时主动暂停并寻求确认,而不是自行决策。这能避免AI基于错误理解继续生成代码。

采用渐进式开发

将大需求拆解为多个小步骤,逐步完成并验证。任务粒度越小,AI完成度越高,也便于及时发现问题。

明确修改范围

要求AI遵守最小范围修改原则,指哪打哪,避免“画蛇添足”修改无关代码。

.cursorrules

项目根目录中的 .cursorrules(旧版)文件仍受支持,但建议迁移到 Project Rules 或 AGENTS.md。

总结

Cursor 规则功能为团队提供了一种强大的方式来统一 AI 编程助手的行为。通过合理配置项目规则、团队规则和用户规则,团队可以确保 AI 生成的代码符合组织的技术标准和质量要求。

关键要点总结:

  1. 规则提供持久化的上下文,弥补了 AI 模型在多次交互间的记忆空白
  2. 四种规则类型各司其职,满足从个人偏好到组织标准的各种需求
  3. 嵌套规则机制支持精细化的模块级控制
  4. 版本控制集成是团队协作的基础保障
  5. 渐进式优化让规则随着团队成长而不断完善

通过系统性地应用 Cursor 规则,你的团队将能够充分发挥 AI 编程的潜力,同时保持代码质量和风格的一致性。现在就开始为你的项目配置规则,体验智能化协作开发的新高度吧!

公众号会持续输出更多技术文章,欢迎关注。

vue2中$set的原理

作者 九十一
2025年10月14日 18:21

image.png

在 Vue2 中,$set 是一个核心 API,用于解决对象或数组新增属性时无法触发响应式更新的问题。要理解其原理,需要先了解 Vue2 响应式系统的底层实现。

1. Vue2 响应式的核心:Object.defineProperty

Vue2 的响应式系统基于 Object.defineProperty 实现,其核心逻辑是:

  • 对数据对象的已有属性进行劫持(拦截 get 和 set 操作)
  • 当属性被访问时(get),收集依赖(Watcher)
  • 当属性被修改时(set),触发依赖更新(通知视图重新渲染)

但这种方式存在天然限制:只能劫持对象已存在的属性。对于新增属性或删除属性,默认无法触发响应式更新。

2. $set 的作用

$set 的设计目的就是解决上述限制,它能让新增的属性也具备响应式能力,同时触发视图更新。

// 响应式对象
const vm = new Vue({
  data() {
    return {
      obj: { name: 'foo' }, // 已有属性 name 是响应式的
      arr: ['a', 'b']
    }
  }
})

// 直接新增属性,不会触发更新
vm.obj.age = 20; // 非响应式

// 使用 $set 新增属性,会触发更新
this.$set(vm.obj, 'age', 20); // 响应式

// 数组新增元素(Vue 对数组方法做了特殊处理,但直接通过索引修改仍需 $set)
this.$set(vm.arr, 2, 'c'); // 响应式

3. $set 的实现原理

$set 源码(简化版)的核心逻辑如下:

function set(target, key, value) {
  // 1. 处理数组:利用重写的 splice 方法触发更新
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key);
    target.splice(key, 1, value);    // splice 已被 Vue 重写,会触发更新
    return value;
  }

  // 2.处理对象:如果属性已存在,直接赋值(会触发 set 拦截)
  if (key in target && !(key in Object.prototype)) {
    target[key] = value;
    return value;
  }

  // 获取响应式对象的 Observer 实例
  const ob = target.__ob__;

  // 3. 非响应式对象(如普通对象),直接赋值
  if (!ob) {
    target[key] = value;
    return value;
  }

  // 4.为新属性添加响应式劫持(调用 defineReactive)
  defineReactive(ob.value, key, value);
  
  // 触发依赖更新(通知视图渲染)
  ob.dep.notify();
  
  return value;
}

defineReactive 是 Vue2 响应式系统的核心函数,它的主要作用是将对象的属性转换为响应式属性,实现对属性读写的拦截,从而在数据变化时自动更新视图

  • 递归响应式:如果属性值是对象(或数组),会通过 observe 函数递归调用 defineReactive,实现深层响应式。
  • 依赖收集:借助 Dep 类管理依赖,Dep.target 指向当前正在渲染的组件对应的 Watcher,读取属性时会将 Watcher 加入依赖列表。
  • 触发更新:当属性被修改时,set 函数会调用 dep.notify(),遍历依赖列表并触发所有 Watcher 的更新逻辑(最终调用组件的 render 方法重新渲染)。

核心步骤拆解:

  1. 处理数组:由于 Vue 对数组的 splicepush 等方法进行了重写(拦截),调用这些方法会自动触发更新。因此对于数组,$set 本质是通过 splice(key, 1, value) 实现的。

  2. 处理对象

    • 若属性已存在,直接赋值(会触发该属性原有的 set 拦截器)。
    • 若属性不存在,通过 defineReactive 为新属性添加响应式劫持(即重新调用 Object.defineProperty 拦截 get 和 set)。
    • 手动触发依赖更新(ob.dep.notify()),确保视图同步刷新。

 $set 本质是 “手动为新属性添加响应式劫持,并强制触发更新” 的封装。

总结

$set 是 Vue2 为弥补 Object.defineProperty 缺陷而设计的 API,其核心原理是:

  • 对数组:通过重写的 splice 方法触发响应式更新。
  • 对对象:为新属性手动添加响应式劫持(defineReactive),并触发依赖更新。

在 Vue3 中,由于响应式系统改用 Proxy 实现(支持监听新增 / 删除属性),因此不再需要 $set 这个 API。

vue3事件总线与emit

作者 九十一
2025年10月14日 17:55

ccd29c6c-26b6-4e79-8b92-45e14ce4a24a.png

1.vue3为什么去掉了$on$off?

1.设计理念的调整

Vue 3 更加注重组件间通信的明确性和可维护性。$on 这类事件 API 本质上是一种 "发布 - 订阅" 模式,容易导致组件间关系模糊(多个组件可能监听同一个事件,难以追踪事件来源和流向)。Vue 3 推荐使用更明确的通信方式,如: - 父子组件通过 props 和 emit 通信 - 跨组件通信使用 provide/inject 或 Pinia/Vuex 等状态管理库 - 复杂场景可使用专门的事件总线库(如 mitt

2.与 Composition API 的适配

Vue 3 主推的 Composition API 强调逻辑的封装和复用,而 $on 基于选项式 API 的实例方法,与 Composition API 的函数式思维不太契合。移除后,开发者可以更自然地使用响应式变量或第三方事件库来实现类似功能。

3.减少潜在问题

  • $on 容易导致内存泄漏(忘记解绑事件)
  • 事件名称可能冲突(全局事件总线尤其明显)
  • 不利于 TypeScript 类型推断,难以实现类型安全

vue3中如何使用事件总线实现跨级组件之间的通信?

1.可以通过第三方库(如 mitt 或 tiny-emitter)替代,示例如下:

// 安装 mitt:npm install mitt
import mitt from 'mitt'

// 创建事件总线实例
const emitter = mitt()

// 监听事件
emitter.on('event-name', (data) => {
  console.log('收到事件:', data)
})

// 触发事件
emitter.emit('event-name', { message: 'hello' })

// 移除事件监听
emitter.off('event-name', handler)

2.使用 Vue3 提供的 provide/inject

// 父组件提供事件总线
import { provide, ref } from 'vue'

export default {
  setup() {
    const events = ref({})
    
    const on = (name, callback) => {
      events.value[name] = callback
    }
    
    const emit = (name, data) => {
      if (events.value[name]) {
        events.value[name](data)
      }
    }
    
    provide('eventBus', { on, emit })
  }
}

// 子组件使用
import { inject } from 'vue'

export default {
  setup() {
    const eventBus = inject('eventBus')
    
    // 监听事件
    eventBus.on('event-name', (data) => {
      // 处理逻辑
    })
    
    // 发送事件
    eventBus.emit('event-name', data)
  }
}

3.利用 Vue 实例的自定义事件 虽然 Vue3 移除了 $on$off 等方法,但可以创建一个空的 Vue 实例作为事件总线,利用其自定义事件 API:

// eventBus.js
import { createApp } from 'vue'
const app = createApp({})
export default app



// 发送事件
import bus from './eventBus'
bus.config.globalProperties.$emit('event-name', data)

// 监听事件(在组件中)
import { getCurrentInstance } from 'vue'
export default {
  mounted() {
    const instance = getCurrentInstance()
    instance.appContext.config.globalProperties.$on('event-name', (data) => {
      // 处理逻辑
    })
  }
}

4.使用 reactive 创建事件总线

// 组件A中发送事件
import eventBus from './eventBus'
eventBus.emit('user-updated', { name: '张三' })

// 组件B中监听事件
import eventBus from './eventBus'
export default {
  mounted() {
    this.handleUserUpdate = (user) => {
      console.log('用户更新了', user)
    }
    eventBus.on('user-updated', this.handleUserUpdate)
  },
  beforeUnmount() {
    // 组件卸载时移除监听,避免内存泄漏
  1. 使用 Pinia/Vuex 状态管理

对于复杂应用,更推荐使用状态管理库来处理组件间通信,通过修改共享状态来实现组件间的数据传递。

总结

  • 在 Vue3 中实现事件总线,最推荐的方式是使用 mitt 库,它轻量高效且 API 简洁,能够很好地替代 Vue2 中的事件总线功能。对于简单场景也可以使用 provide/inject 方案,但对于大型应用,状态管理库会是更优选.择。
  • 手动实现的事件总线需要注意在组件卸载时移除事件监听,避免内存泄漏; 注意考虑 “同一事件绑定多个回调” 的去重逻辑;避免没有事件触发时的异常捕获,单个回调报错可能阻断整个事件流程。

2.vue3中的defineEmits $emit又是什么关系?

Vue3 并没有完全移除 $emit(它仍然用于子组件向父组件传递事件)。

defineEmits是 Vue3 提供的编译时宏命令(Compiler Macro),用于在 <script setup> 语法中声明组件可以触发的事件,主要作用是:

  1. 明确组件对外暴露的事件,提升代码可读性和可维护性
  2. 提供 TypeScript 类型校验(如果使用 TS)
  3. 在开发环境下对未声明的事件触发给出警告

使用方式(在 <script setup> 中)

<template>
  <button @click="handleClick">点击触发事件</button>
</template>

<script setup>
// 声明组件可以触发的事件
const emit = defineEmits(['change', 'update'])

const handleClick = () => {
  // 触发事件并传递数据
  emit('change', 'hello')
  emit('update', { id: 1, name: 'test' })
}
</script>

带类型校验的用法(TypeScript)

<script setup lang="ts">
// 用类型标注事件名称和参数类型
const emit = defineEmits<{
  (e: 'change', value: string): void
  (e: 'update', data: { id: number; name: string }): void
}>()

// 错误示例:参数类型不匹配会报错
emit('change', 123) // TS 类型错误
</script>

注意点

  1. 仅在 <script setup> 中可用defineEmits 是编译时宏,不需要导入,直接使用(Vue 编译器会处理)

  2. 替代 Vue2 的 emits 选项:在非 <script setup> 语法中,仍可以用 emits 选项声明事件:

    export default {
      emits: ['change', 'update'], // 等价于 defineEmits
      setup(props, { emit }) {
        // ...
      }
    }
    
  3. 与 $emit 的关系defineEmits 返回的 emit 函数与 this.$emit 功能一致,但在 <script setup> 中推荐使用前者(更符合组合式 API 风格)

👋 一起写一个基于虚拟模块的密钥管理 Rollup 插件吧(四)

作者 xiaohe0601
2025年10月14日 17:52

上一章 我们成功将插件迁移到 Unplugin 插件系统,使其同时支持 Vite、Rollup、Webpack、Esbuild 等多种构建工具,让更多用户都能轻松体验到我们基于虚拟模块的密钥管理方案。

然而,尽管我们的插件功能已经完整实现,但是在未来的迭代过程中仍然存在潜在风险。插件可能因为版本更新、构建工具差异或者代码修改而出现功能回归、虚拟模块解析异常或类型声明生成不正确等问题。

为了确保插件在各种环境下始终稳定可靠,本章我们将会为插件编写单元测试,及时发现和防止潜在问题,从而为插件的持续维护和升级提供安全保障!

框架选型

我们的插件设计之初便考虑为 Vite 提供优先支持,所以对于单元测试框架自然第一时间想到的就是 Vitest,那么 Vitest 有哪些优势呢?

  • 与 Vite 通用的配置、转换器、解析器和插件。
  • 智能文件监听模式,就像是测试的 HMR!
  • 支持对 Vue、React、Svelte、Lit 等框架进行组件测试。
  • 开箱即用的 TypeScript / JSX 支持。
  • 支持套件和测试的过滤、超时、并发配置。
  • ...

Jest

Jest 在测试框架领域占据了主导地位,因为它为大多数 JavaScript 项目提供开箱即用的支持,具备舒适的 API(it 和 expect),且覆盖了大多数测试的需求(例如快照、模拟和覆盖率)。

在 Vite 项目中使用 Jest 是可能的,但是在 Vite 已为最常见的 Web 工具提供了支持的情况下,引入 Jest 会增添不必要的复杂性。如果你的应用由 Vite 驱动,那么配置和维护两个不同的管道是不合理的。如果使用 Vitest,你可以在同一个管道中进行开发、构建和测试环境的配置。

Cypress

Cypress 是基于浏览器的测试工具,这对 Vitest 形成了补充。如果你想使用 Cypress,建议将 Vitest 用于测试项目中不依赖于浏览器的部分,而将 Cypress 用于测试依赖浏览器的部分。

Cypress 的测试更加专注于确定元素是否可见、是否可以访问和交互,而 Vitest 专注于为非浏览器逻辑提供最佳的、快速的开发体验。

单元测试

在编写插件或工具库时,单元测试主要用于验证每个独立功能模块的行为是否正确,它通常具有以下特点:

  1. 细粒度:测试目标是最小的可测试单元(函数、方法、类);
  2. 隔离性:各测试相互独立,不依赖执行顺序或外部环境;
  3. 可重复:相同的输入应产生相同的输出,便于回归测试;
  4. 快速执行:测试运行速度快,适合频繁执行;
  5. 自动化:通常集成到构建或持续集成(CI)流程中。

快速上手

首先使用 npm 将 Vitest 安装到项目:

# pnpm
pnpm add -D vitest

# yarn
yarn add -D vitest

# npm
npm install -D vitest

然后可以编写一个简单的测试来验证将两个数字相加的函数的输出:

// sum.ts

export function sum(a: number, b: number) {
  return a + b;
}
// sum.test.ts

import { expect, it } from "vitest";
import { sum } from "./sum";

it("adds 1 + 2 to equal 3", () => {
  expect(sum(1, 2)).toBe(3);
});

一般情况下,执行测试的文件名中必须包含 .test..spec.

接下来,为了执行测试,将以下部分添加到 package.json 文件中:

// package.json

{
  "scripts": {
    "test": "vitest"
  }
}

最后,运行 npm run testyarn testpnpm test,具体取决于你的包管理器,Vitest 将打印此消息:

✓ sum.test.ts (1)
  ✓ adds 1 + 2 to equal 3

Test Files  1 passed (1)
     Tests  1 passed (1)
  Start at  02:15:44
  Duration  311ms

我们轻松入门了使用 Vitest 编写单元测试!

开始编码

接下来我们为插件的各个模块编写单元测试,测试文件放在 test 目录中,使用 .test.ts 后缀命名。

crypto-splitter

// crypto-splitter.test.ts

import { describe, expect, it } from "vitest";
import { combine, split } from "../packages/crypto-splitter/src";

describe("crypto-splitter", () => {
  it("returns empty array for empty string", () => {
    expect(split("")).toEqual([]);
  });

  it("returns empty string for empty chunks", () => {
    expect(combine([])).toBe("");
  });

  it("splits into default 4 segments and combines correctly", () => {
    const key = "iamxiaohe";

    const chunks = split(key);
    expect(chunks).toHaveLength(4);

    expect(combine(chunks)).toBe(key);
  });

  it("splits into custom number of segments and combines correctly", () => {
    const key = "iamxiaohe";

    const chunks = split(key, { segments: 6 });
    expect(chunks).toHaveLength(6);

    expect(combine(chunks)).toBe(key);
  });

  it("different splits produce different chunks but combine correctly", () => {
    const key = "iamxiaohe";

    const chunks1 = split(key);
    const chunks2 = split(key);

    expect(chunks1).not.toEqual(chunks2);

    expect(combine(chunks1)).toBe(key);
    expect(combine(chunks2)).toBe(key);
  });
});
  1. 空字符串 → 应返回空数组;
  2. 空数组 → 应还原为空字符串;
  3. 默认会拆成 4 段,并能正确合并;
  4. 可自定义段数(比如 6 段),也能正确合并;
  5. 同一个字符串多次拆分结果不同(说明有随机性),但都能还原原文。

getCode

// code.test.ts

import { unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { getCode } from "../packages/shared/src";

describe("getCode", () => {
  it("should generate code that exports correct key values", async () => {
    const keys = {
      key1: "iamxiaohe",
      key2: "ilovexiaohe"
    };

    const temp = join(__dirname, "virtual-code.js");

    await writeFile(temp, getCode(keys));

    const { key1, key2 } = await import(temp);

    expect(key1).toBe(keys.key1);
    expect(key2).toBe(keys.key2);

    await unlink(temp);
  });
});

先准备一个包含若干键值的对象 keys,调用 getCode(keys) 得到生成的代码字符串,然后将其写入临时文件 virtual-code.js。通过动态 import 方式加载这个文件,检查其中导出的变量 key1key2 是否与原始对象中的值完全一致,最后删除临时文件。

writeDeclaration

// declaration.test.ts

import { ensureFile, outputFile } from "fs-extra";
import { describe, expect, it, vi } from "vitest";
import { writeDeclaration } from "../packages/shared/src";

vi.mock("fs-extra", () => ({
  ensureFile: vi.fn(),
  outputFile: vi.fn()
}));

describe("writeDeclaration", () => {
  it("should create a declaration file with default name when dts is true", async () => {
    await writeDeclaration(
      {
        key1: "iamxiaohe",
        key2: "ilovexiaohe"
      },
      {
        moduleId: "virtual:crypto-key",
        dts: true
      }
    );

    expect(ensureFile).toHaveBeenCalledWith("crypto-key.d.ts");
    expect(outputFile).toHaveBeenCalledWith(
      "crypto-key.d.ts",
      `declare module "virtual:crypto-key" {
  export const key1: string;
  export const key2: string;
}`
    );
  });

  it("should create a declaration file with custom path when dts is a string", async () => {
    await writeDeclaration(
      {
        key1: "iamxiaohe"
      },
      {
        moduleId: "virtual:crypto-key",
        dts: "types/crypto-key.d.ts"
      }
    );

    expect(ensureFile).toHaveBeenCalledWith("types/crypto-key.d.ts");
    expect(outputFile).toHaveBeenCalledWith(
      "types/crypto-key.d.ts",
      `declare module "virtual:crypto-key" {
  export const key1: string;
}`
    );
  });
});
  1. 模拟文件操作:通过 vi.mock("fs-extra") 模拟 ensureFileoutputFile,避免实际读写磁盘。
  2. 测试默认路径:当 dts: true 时,writeDeclaration() 应生成默认文件名 crypto-key.d.ts,并写入对应的模块声明和键值类型。
  3. 测试自定义路径:当 dts 是字符串(自定义路径)时,应生成指定路径的声明文件,并写入正确内容。
  4. 验证调用:通过 expect(...).toHaveBeenCalledWith(...) 检查 ensureFileoutputFile 是否被正确调用,确保文件路径和内容符合预期。

运行测试与结果

Vitest 通过 v8 支持原生代码覆盖率,通过 istanbul 支持检测代码覆盖率。

这里我们选择 Vitest 默认的 v8 作为覆盖工具,在 vitest.config.ts 中配置 providerv8 并指定 include 配置覆盖率报告中需要统计的文件范围:

// vitest.config.ts

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    coverage: {
      provider: "v8",
      include: [
        "packages/*/src/**/*.ts"
      ]
    }
  }
});

然后在 package.json 中添加 coverage 配置:

// package.json

{
  "scripts": {
    "test": "vitest",
    "test:coverage": "vitest run --coverage"
  }
}

现在执行 test:coverage 就可以运行测试并且输出单元测试覆盖率啦!

Coverage enabled with v8

 ✓ test/crypto-splitter.test.ts (5 tests) 2ms
 ✓ test/declaration.test.ts (2 tests) 2ms
 ✓ test/code.test.ts (1 test) 5ms

 Test Files  3 passed (3)
      Tests  8 passed (8)
   Start at  13:54:48
   Duration  279ms (transform 61ms, setup 0ms, collect 96ms, tests 9ms, environment 0ms, prepare 176ms)

 % Coverage report from v8

---------------------|---------|----------|---------|---------
File                 | % Stmts | % Branch | % Funcs | % Lines 
---------------------|---------|----------|---------|---------
All files            |     100 |      100 |     100 |     100 
 crypto-splitter/src |     100 |      100 |     100 |     100 
  combine.ts         |     100 |      100 |     100 |     100 
  split.ts           |     100 |      100 |     100 |     100 
 shared/src          |     100 |      100 |     100 |     100 
  code.ts            |     100 |      100 |     100 |     100 
  declaration.ts     |     100 |      100 |     100 |     100 
---------------------|---------|----------|---------|---------

🎉 所有测试用例全部通过,并且测试覆盖率达到 100%!

这意味着插件的核心逻辑已全部经过验证,不仅功能正确,而且具备极高的稳定性与可维护性。

源码

插件的完整代码可以在 virtual-crypto-key 仓库中查看。赠人玫瑰,手留余香,如果对你有帮助可以给我一个 ⭐️ 鼓励,这将是我继续前进的动力,谢谢大家 🙏!

总结与回顾

至此,我们已经为插件建立了完善的单元测试体系,使用 Vitest 对各个核心模块进行了自动化验证,确保:

  • 🔐 密钥拆分与还原逻辑正确无误
  • 🧩 生成虚拟模块代码行为符合预期
  • 🧾 类型声明文件生成逻辑正确
  • ✅ 整体代码质量和覆盖率达标

回顾整个系列,我们从需求分析、插件设计、虚拟模块实现,到 TypeScript 支持、多构建工具迁移,再到如今的测试验证,完整经历了一个现代化插件从无到有的开发全流程。

如果你一路读到了这里,那说明你已经具备独立开发一个可发布插件的能力,不仅了解了 Rollup / Vite 插件机制的底层逻辑,也掌握了 Unplugin 的跨构建工具开发模式和 Vitest 的测试方法。

未来,你完全可以基于本系列的思路继续扩展更多特性,比如:

  • 支持更复杂的密钥混淆算法
  • 添加 CI 流程自动化测试
  • 发布到 npm 供更多开发者使用

祝贺你完成了这场关于插件设计、类型系统与测试驱动开发的完整旅程!

本系列到此完结,感谢你的阅读与坚持,我是 xiaohe0601,我们下一个项目再见!👋

基于UniApp实现DeepSeek AI对话:流式数据传输与实时交互技术解析

作者 BumBle
2025年10月14日 17:49

在移动应用开发中,集成AI对话功能已成为提升用户体验的重要手段。本文将详细介绍如何在UniApp中实现类似DeepSeek的AI对话界面,重点讲解流式数据传输、实时交互等核心技术。

【最后附上完整源代码】

实现效果

image.png

核心技术实现

1. 流式数据传输核心

流式数据传输是实现实时AI对话的关键,我们使用微信小程序的enableChunked配置来启用分块传输:

sendChats(params, isFirstTime) {
  const requestTask = wx.request({
    url: `${empInterfaceUrl}/gateway/basics/aiDialog/sendMsg`,
    timeout: 60000,
    responseType: 'text', // 必须设置为text才能处理流式数据
    method: 'POST',
    enableChunked: true, // 关键配置:启用分块传输
    header: {
      Accept: 'text/event-stream', // 接受服务器推送事件
      'Content-Type': 'application/json',
    },
    data: params,
  })
}

2. 流式数据实时处理

通过onChunkReceived监听器实时处理服务器推送的数据块:

this.chunkListener = (res) => {
  // 将二进制数据转换为文本
  const uint8Array = new Uint8Array(res.data)
  let text = String.fromCharCode.apply(null, uint8Array)
  text = decodeURIComponent(escape(text))
  
  // 解析SSE格式数据
  const messages = text.split('data:')
  
  messages.forEach(message => {
    if (!message.trim()) return
    
    const data = JSON.parse(message)
    
    // 处理AI回复数据
    if (data.data && data.data.answer) {
      const lastChat = this.chatArr[this.chatArr.length - 1]
      
      // 分离思考过程和实际回复
      const cleanedAnswer = data.data.answer.replace(/<think>[\s\S]*?<\/think>/g, '')
      const thinkContent = data.data.answer.match(/<think>([\s\S]*?)<\/think>/g)
        ?.map(tag => tag.replace(/<\/?think>/g, ''))
        ?.join(' ')
      
      // 实时更新UI
      if (lastChat && lastChat.type === 'robot' && cleanedAnswer) {
        lastChat.content = cleanedAnswer
        this.scrollToLower() // 自动滚动到底部
      }
    }
  })
}

// 注册监听器
requestTask.onChunkReceived(this.chunkListener)

3. 双模式参数构建

支持普通对话和产品话术两种模式:

getParams(item, content) {
  let data = {
    rootShopId: this.empShopInfo.rootShop,
    shopId: this.empShopInfo.shopId
  }
  
  if (this.sessionId) data.sessionId = this.sessionId
  
  if (this.type === 'product') {
    // 产品模式参数
    data = {
      ...data,
      msgType: 'prod',
      prodMsgType: this.sessionId ? item.value : '1',
      msg: this.productInfo.itemTitle,
      prodId: this.productInfo.itemId,
    }
  } else {
    // 普通对话模式参数
    data = {
      ...data,
      msgType: 'ai',
      msg: content || this.content
    }
  }
  return data
}

4. 消息生成控制

防止重复请求和实现重新生成功能:

generate(item, index) {
  // 防止重复请求
  if (this.isListening) {
    let msg = this.sessionId ? '当前会话未结束' : '服务器繁忙,请稍后再试'
    this.$alert(msg)
    return
  }
  
  let content
  // 重新生成时从历史消息获取原始提问
  if (index !== undefined) {
    for (let i = index - 1; i >= 0; i--) {
      if (this.chatArr[i].type === 'self') {
        content = this.chatArr[i].content
        break
      }
    }
  }
  
  // 添加用户消息到对话列表
  this.chatArr.push({
    type: 'self',
    content
  })
}

5. 自动滚动机制

确保新消息始终可见:

scrollToLower() {
  this.scrollIntoView = ''
  // 异步确保滚动生效
  setTimeout(() => {
    this.scrollIntoView = 'lower'
  }, 250)
}

完整源代码

以下是完整的组件代码,包含详细注释:

<template>
  <view class="ai">
    <scroll-view class="ai-scroll"  :scroll-into-view="scrollIntoView" scroll-y scroll-with-animation>
      <view class="ai-tips flex-c-c">
        <view class="ai-tips-content">{{ type === 'product' ? '请在下面点击选择您想生成的内容' : '请在下面输入框输入您想生成的内容' }}</view>
      </view>
      <view style="padding: 0 20rpx ">
        <view class="ai-product" v-if="type === 'product'">
          <image :src="productInfo.miniMainImage || productInfo.mainImage" class="ai-product-img" mode="aspectFill" />
          <view class="ai-product-info">
            <view>{{ productInfo.itemTitle }}</view>
            <view class="ai-product-info-price">¥{{ productInfo.spePrice }}</view>
          </view>
        </view>
      </view>
      <view class="ai-chat" v-for="(item, index) in chatArr" :key="index">
        <view class="ai-chat-item self" v-if="item.type === 'self'">
          <view class="ai-chat-content">{{ item.content}}</view>
          <image class="ai-chat-avatar" :src="empUserInfo.avatarUrl || DEFAULT_AVATAR_URL"></image>
        </view>
        <view class="ai-chat-item robot" v-if="item.type === 'robot'">
          <image class="ai-chat-avatar" :src="`${mdFileBaseUrl}/stand/emp/AI/icon_avatar.png`"></image>
          <view class="ai-chat-content">
            <view class="ai-chat-content-box flex-c content-think" @click="switchExpand(item)">
              {{ item.isListening ? '正在思考中...' : '已推理' }}
              <MDIcon :name="item.expand ? 'arrowUp' : 'arrowDown'" color="#919099" left="8" />
            </view>
            <text class="ai-chat-content-box  content-think" v-if="item.expand">{{ item.think }}</text>
            <text class="ai-chat-content-box">{{ item.content }}</text>
            <view class="ai-chat-opt flex-c">
              <template v-if="item.isListening">
                <view class="ai-chat-opt-btn pause-btn flex-c-c" hover-class="h-c" @click="pauseAnswer(index)">
                  <image class="ai-chat-opt-icon" :src="`${mdFileBaseUrl}/stand/emp/AI/icon_pause.png`"></image>
                  暂停回答
                </view>
              </template>
              <template v-else>
                <view class="ai-chat-opt-btn flex-c-c" hover-class="h-c" @click="generate(item, index)">
                  <image class="ai-chat-opt-icon" :src="`${mdFileBaseUrl}/stand/emp/AI/icon_reset.png`"></image>
                  重新生成
                </view>
                <view class="ai-chat-opt-btn flex-c-c" hover-class="h-c" @click="copyAnswer(item.content)">
                  <image class="ai-chat-opt-icon" :src="`${mdFileBaseUrl}/stand/emp/AI/icon_copy.png`"></image>
                  复制回答
                </view>
              </template>
            </view>
          </view>
        </view>
      </view>
      <view id="lower" class="lower"></view>
    </scroll-view>
    <view class="ai-footer">
      <view class="ai-footer-buttons flex-c" v-if="type === 'product'">
        <view class="ai-footer-buttons-btn flex-c-c" v-for="x in footerBtnList" :key="x.value" hover-class="h-c" @click="generate(x)">
          {{ x.label }}
        </view>
      </view>
      <template v-else>
        <view class="ai-keyboard">
          <textarea class="ai-keyboard-inp" v-model="content" cursor-spacing="30" maxlength="-1" placeholder="请输入相关产品信息" @confirm="generate()"></textarea>
        </view>
        <view class="ai-send flex-c-c" hover-class="h-c" @click="generate()">
          <image class="ai-send-icon" :src="`${mdFileBaseUrl}/stand/emp/AI/icon_send.png`"></image>
          开始生成
        </view>
      </template>
    </view>
  </view>
</template>

<script>
import { empInterfaceUrl } from '@/config'

export default {
  data() {
    return {
      content: '', // 内容
      type: 'normal', // 类型:normal-普通,product-产品
      productInfo: {},
      footerBtnList: [
        { label: '首次分享话术', value: '1' },
        { label: '破冰话术', value: '2' },
        { label: '产品介绍', value: '3' },
        { label: '产品优点', value: '4' }
      ],
      requestTask: null,
      sessionId: '',
      isListening: false, // 添加状态变量
      chatArr: [],
      scrollIntoView: 'lower',
      chunkListener: null
    }
  },
  methods: {
    scrollToLower() {
      this.scrollIntoView = ''
      setTimeout(() => {
        this.scrollIntoView = 'lower'
      }, 250)
    },
    switchExpand(item) {
      item.expand = !item.expand
      this.$forceUpdate()
    },
    copyAnswer(content) {
      uni.setClipboardData({
        data: content,
        success: () => {
          uni.showToast({ title: '复制成功', icon: 'none' })
        }
      })
    },
    getParams(item, content) {
      let data = {
        rootShopId: this.empShopInfo.rootShop,
        shopId: this.empShopInfo.shopId
      }
      if (this.sessionId) data.sessionId = this.sessionId
      if (this.type === 'product') {
        data = {
          ...data,
          msgType: 'prod',
          prodMsgType: this.sessionId ? item.value : '1',
          msg: this.productInfo.itemTitle,
          prodId: this.productInfo.itemId,
        }
        // 如果是重新生成,获取上一个的提问内容的value
        if (content) {
          const footerValue = this.footerBtnList.find(x => x.label === content).value
          data.prodMsgType = footerValue
        }
      } else {
        data = {
          ...data,
          msgType: 'ai',
          msg: content || this.content // 第一次:'' , ai模式:1.this.content 2.重新生成content
        }
      }
      return data
    },
    // 开始生成
    // 第一个参数为按钮信息(product模式),第二个参数为重新生成需要的index
    generate(item, index) {
      if (this.isListening) {
        let msg = this.sessionId ? '当前会话未结束' : '服务器繁忙,请稍后再试'
        this.$alert(msg)
        return
      }
      if (this.type === 'normal' && !this.content.trim() && !index) {
        return uni.showToast({ title: '请输入相关产品信息', icon: 'none' })
      }
      let content
      // 如果是重新生成,获取上一个的提问内容
      if (index !== undefined) {
        for (let i = index - 1; i >= 0; i--) {
          if (this.chatArr[i].type === 'self') {
            content = this.chatArr[i].content
            break
          }
        }
      } else {
        content = this.type === 'product' ? item.label : this.content
      }
      this.chatArr.push({
        type: 'self',
        content
      })
      this.scrollToLower()
      const params = this.getParams(item, content)
      this.content = ''
      this.isListening = true
      this.sendChats(params)
    },

    sendChats(params, isFirstTime) {
      let chatIndex // 获取新添加的robot消息的索引
      // 取消之前的请求
      if (this.requestTask) {
        this.requestTask.abort()
        this.requestTask = null
      }
      if (!isFirstTime) {
        this.chatArr.push({
          type: 'robot',
          think:'',
          expand: false,
          content: '',
          isListening: true
        })
        chatIndex = this.chatArr.length - 1
      }
      this.scrollToLower()
      const requestTask = wx.request({
        url: `${empInterfaceUrl}/gateway/basics/aiDialog/sendMsg`,
        timeout: 60000,
        responseType: 'text',
        method: 'POST',
        enableChunked: true,
        header: {
          Accept: 'text/event-stream',
          'Content-Type': 'application/json',
          'root-shop-id': this.empShopInfo.rootShop,
          Authorization: this.$store.getters.empBaseInfo.token
        },
        data: params,
        fail: () => {
          this.isListening = false
          if (chatIndex !== undefined) {
            this.chatArr[chatIndex].isListening = false
          }
        }
      })
      // 移除之前的监听器
      if (this.chunkListener && this.requestTask) {
        this.requestTask.offChunkReceived(this.chunkListener)
      }
      // 添加新的监听器
      this.chunkListener = (res) => {
        if (!this.isListening) {
          requestTask.abort()
          return
        }
        const uint8Array = new Uint8Array(res.data)
        let text = String.fromCharCode.apply(null, uint8Array)
        text = decodeURIComponent(escape(text))
        const messages = text.split('data:')
        messages.forEach(message => {
          if (!message.trim()) {
            return
          }
          const data = JSON.parse(message)
          if (data.data === true) {
            this.pauseAnswer(chatIndex, isFirstTime)
            return
          }
          if (data.data && data.data.session_id && isFirstTime) {
            this.sessionId = data.data.session_id
            this.isListening = false
            return
          }
          if (data.data && data.data.answer) {
            const lastChat = this.chatArr[this.chatArr.length - 1]
            const cleanedAnswer = data.data.answer.replace(/<think>[\s\S]*?<\/think>/g, '')
            const thinkContent = data.data.answer.match(/<think>([\s\S]*?)<\/think>/g)?.map(tag => tag.replace(/<\/?think>/g, ''))?.join(' ')
            if (lastChat && lastChat.type === 'robot' && cleanedAnswer) {
              lastChat.content = cleanedAnswer
              this.scrollToLower()
            }
            if (thinkContent) {
              lastChat.think = thinkContent
              this.scrollToLower()
            }
          }
        })
      }
      requestTask.onChunkReceived(this.chunkListener)
      this.requestTask = requestTask
    },
    pauseAnswer(index, isFirstTime) {
      if (this.requestTask) {
        this.requestTask.abort()
        this.requestTask.offChunkReceived(this.chunkListener)
        this.requestTask = null
      }
      this.isListening = false
      if (!isFirstTime) {
        this.chatArr[index].isListening = false
      }
    },
    getAiSessionId() {
      const params = this.getParams()
      this.isListening = true
      this.sendChats(params, true)
    }
  },
  onLoad(options) {
    this.type = options.type || 'normal'
    this.$store.dispatch('checkLoginHandle').then(() => {
      if (options.type === 'product') {
        this.productInfo = uni.getStorageSync('productInfo')
        uni.removeStorageSync('subShopInfo')
      }
      this.getAiSessionId()
    })
  },
  beforeDestroy() {
    // 移除之前的监听器
    if (this.requestTask) {
      this.requestTask.abort()
      if (this.chunkListener) {
        this.requestTask.offChunkReceived(this.chunkListener)
      }
      this.requestTask = null
    }
  }
}
</script>

<style lang="scss">
page {
  background: #f5f5f5;
}
.ai {
  padding-top: 20rpx;
  &-scroll {
    height: calc(100vh - 120rpx);
    overflow: auto;
  }
  &-tips {
    &-content {
      padding: 0 8rpx;
      height: 36rpx;
      background: #eeeeee;
      font-size: 24rpx;
      color: #999999;
    }
  }
  &-product {
    padding: 20rpx;
    background: #fff;
    border-radius: 8rpx;
    margin: 24rpx 0;
    display: flex;
    &-img {
      flex-shrink: 0;
      width: 120rpx;
      height: 120rpx;
      background: #EEEEEE;
      border-radius: 4rpx 4rpx 4rpx 4rpx;
      margin-right: 16rpx;
    }
    &-info {
      display: flex;
      flex-direction: column;
      justify-content: space-between;
      &-price {
        font-weight: 700;
        color: #FF451C;
      }
    }
  }
  &-chat {
    padding: 0 20rpx;
    &-item {
      margin-top: 40rpx;
      display: flex;
      &.self {
        .ai-chat-content {
          background: $uni-base-color;
          color: #ffffff;
          margin-right: 10rpx;
          margin-left: 0rpx;
        }
      }
    }
    &-content {
      background: #fff;
      border-radius: 14rpx;
      padding:27rpx 20rpx;
      font-size: 28rpx;
      color: #333;
      line-height: 33rpx;
      word-break: break-all;
      flex: 1;
      margin-left: 10rpx;
      .content-think {
        color: #919099;
        margin-bottom: 8rpx;
      }
    }
    &-avatar {
      width: 88rpx;
      height: 88rpx;
      border-radius: 14rpx;
    }
    &-opt {
      justify-content: flex-end;
      margin-top: 40rpx;
      border-top: 1px solid #eeeeee;
      padding-top: 20rpx;
      &-btn {
        padding: 0 16rpx;
        height: 64rpx;
        border-radius: 8rpx;
        border: 1px solid $uni-base-color;
        font-size: 24rpx;
        color: $uni-base-color;
        &:last-child {
          background: $uni-base-color;
          margin-left: 20rpx;
          color: #fff;
        }
        &.pause-btn {
          border: 2rpx solid $uni-base-color;
          color: $uni-base-color;
          background: none;
        }
      }
      &-icon {
        width: 32rpx;
        height: 32rpx;
        margin-right: 8rpx;
      }
    }
  }
  &-footer {
    min-height: 120rpx;
    position: fixed;
    bottom: 0;
    background: #fff;
    left: 0;
    right: 0;
    z-index: 1;
    padding: 20rpx;
    &-buttons {
      &-btn {
        width: 163rpx;
        height: 64rpx;
        font-size: 24rpx;
        color: #FFFFFF;
        line-height: 28rpx;
        background: $uni-base-color;
        border-radius: 8rpx 8rpx 8rpx 8rpx;
        &:not(:last-child) {
          margin-right: 20rpx;
        }
      }
    }
  }
  &-keyboard {
    background: #f5f5f5;
    border-radius: 8rpx;
    padding: 20rpx;
    &-inp {
      font-size: 28rpx;
      height: 146rpx;
      box-sizing: border-box;
      display: block;
      width: 100%;
    }
  }
  &-send {
    height: 72rpx;
    background: $uni-base-color;
    border-radius: 8rpx;
    margin-top: 18rpx;
    color: #ffffff;
    &-icon {
      width: 36rpx;
      height: 36rpx;
      margin-right: 8px;
    }
  }
  .lower {
    height: 350rpx;
    width: 750rpx;
  }
}
</style>

技术要点总结

  1. 流式传输:通过enableChunked: trueonChunkReceived实现实时数据传输
  2. SSE协议:使用Server-Sent Events协议处理服务器推送
  3. 二进制处理:正确处理Uint8Array数据流转换
  4. 状态管理:完善的请求状态控制防止重复提交
  5. 用户体验:自动滚动、思考过程展示等细节优化

这种实现方式能够提供流畅的AI对话体验,适用于各种需要实时交互的AI应用场景。

Canvas 入门及常见功能实现

作者 Mh
2025年10月14日 17:29

Canvas 绘制基础图形详解

Canvas 是 HTML5 核心绘图 API,支持在网页中动态绘制矢量图形。本文将系统讲解 Canvas 基础图形(线条、三角形、矩形、圆形)及组合图形(笑脸)的绘制方法,并附带完整代码与关键说明。

一、基础环境搭建(HTML + CSS + 初始化)

首先创建 Canvas 容器与绘图上下文,设置基础样式确保绘图区域清晰可见。

<style>
  /* 容器样式:优化布局与视觉效果 */
  .canvas-container {
    background-color: #f8fafc; /* 浅灰背景,区分页面其他区域 */
    padding: 20px;
    max-width: 600px;
    margin: 20px auto; /* 水平居中 */
    border-radius: 8px; /* 圆角优化 */
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 轻微阴影增强层次感 */
  }
  /* Canvas 样式:明确绘图边界 */
  #basic-canvas {
    border: 4px dashed #cbd5e1; /* 虚线边框,区分画布区域 */
    background-color: #ffffff; /* 白色画布,便于观察图形 */
    border-radius: 4px;
  }
</style>

<!-- 画布容器 -->
<div class="canvas-container">
  <!-- Canvas 核心元素:width/height 需直接设置(非CSS),确保图形不失真 -->
  <canvas id="basic-canvas" width="500" height="200"></canvas>
</div>

<script>
  // 1. 获取 Canvas 元素与 2D 绘图上下文(核心对象)
  const canvas = document.getElementById('basic-canvas')
  const ctx = canvas.getContext('2d') // 所有绘图操作都通过 ctx 实现

  // 2. 设置公共样式(避免重复代码)
  ctx.lineWidth = 2 // 线条宽度(所有图形通用)
  ctx.strokeStyle = '#2d3748' // 线条颜色(深灰,比黑色更柔和)

  // 3. 页面加载完成后执行绘图(确保 Canvas 已渲染)
  window.addEventListener('load', () => {
    drawLine() // 绘制线条
    drawTriangle() // 绘制三角形
    drawRectangle() // 绘制矩形(原 Square 更准确的命名)
    drawCircle() // 绘制圆形
    drawSmilingFace() // 绘制笑脸(组合图形)
  })
</script>

二、Canvas 路径绘制核心 API

在绘制路径之前先介绍几个常用的canvas的api。

  1. beginPath() 新建一条路径,生成之后,图形绘制命令被指向到路径上生成路径。
  2. closePath() 闭合路径之后图形绘制命令又重新指向到上下文中。
  3. stroke() 通过线条来绘制图形轮廓。
  4. fill() 通过填充路径的内容区域生成实心的图形。
  5. moveTo(x, y) 将笔触移动到指定的坐标 x 以及 y 上。
  6. lineTo(x, y) 绘制一条从当前位置到指定 x 以及 y 位置的直线。

三、具体图形绘制实现

1. 绘制直线(基础入门)

通过 moveTo() 定位起点,lineTo() 绘制线段,最后用 stroke() 渲染轮廓。

function drawLine() {
  ctx.beginPath() // 开启新路径(避免与其他图形混淆)
  ctx.moveTo(25, 25) // 起点:(25,25)(Canvas 左上角为原点 (0,0))
  ctx.lineTo(105, 25) // 终点:(105,25)(水平向右绘制)
  ctx.stroke() // 渲染直线轮廓
}

2. 绘制三角形(空心 + 实心)

三角形由三条线段组成,空心需手动闭合路径,实心可直接填充(自动闭合)。

function drawTriangle() {
  // 1. 绘制空心三角形
  ctx.beginPath()
  ctx.moveTo(150, 25) // 顶点1
  ctx.lineTo(200, 25) // 顶点2(水平向右)
  ctx.lineTo(150, 75) // 顶点3(向左下方)
  ctx.closePath() // 闭合路径(连接顶点3与顶点1)
  ctx.stroke() // 渲染空心轮廓

  // 2. 绘制实心三角形(位置偏移,避免与空心重叠)
  ctx.beginPath()
  ctx.moveTo(155, 30) // 顶点1(右移5px,下移5px)
  ctx.lineTo(185, 30) // 顶点2(缩短宽度,更美观)
  ctx.lineTo(155, 60) // 顶点3(上移15px,避免超出范围)
  ctx.fillStyle = '#4299e1' // 单独设置填充色(蓝色)
  ctx.fill() // 填充实心(无需 closePath(),自动闭合)
}

3. 绘制矩形(专用 API,更高效)

Canvas 为矩形提供了专用方法,无需手动写路径,直接指定位置与尺寸即可。

function drawRectangle() {
  // 1. 空心矩形:strokeRect(x, y, 宽度, 高度)
  ctx.strokeRect(10, 100, 50, 50) // 位置(10,100),尺寸50x50

  // 2. 实心矩形:fillRect(x, y, 宽度, 高度)(偏移避免重叠)
  ctx.fillStyle = '#48bb78' // 填充色(绿色)
  ctx.fillRect(15, 105, 40, 40) // 位置(15,105),尺寸40x40

  // 3. 清除矩形区域:clearRect(x, y, 宽度, 高度)(生成“镂空”效果)
  ctx.clearRect(25, 115, 20, 20) // 清除中间20x20区域,变为透明
}

4. 绘制圆形(arc () 方法详解)

圆形通过 arc() 方法绘制,核心是理解「弧度制」与「绘制方向」。

arc () 方法语法: arc(x, y, radius, startAngle, endAngle, anticlockwise)

  • x, y:圆心坐标
  • radius:圆的半径
  • startAngle/endAngle:起始 / 结束角度(必须用弧度制,公式:弧度 = (Math.PI / 180) * 角度)
  • anticlockwise:是否逆时针绘制(布尔值,默认 false 顺时针)
function drawCircle() {
  // 1. 绘制完整圆形(360° = 2π 弧度)
  ctx.beginPath()
  ctx.arc(100, 125, 25, 0, Math.PI * 2, false) // 圆心(100,125),半径25
  ctx.stroke()

  // 2. 绘制上半圆(逆时针,180° = π 弧度)
  ctx.beginPath()
  ctx.arc(100, 125, 15, 0, Math.PI, true) // 半径15,逆时针绘制上半圆
  ctx.stroke()

  // 3. 绘制实心下半圆(顺时针)
  ctx.beginPath()
  ctx.arc(100, 130, 10, 0, Math.PI, false) // 圆心下移5px,半径10
  ctx.fillStyle = '#f6ad55' // 填充色(橙色)
  ctx.fill()
}

注意事项:为了保证新的圆弧不会追加到上一次的路径中,在每一次绘制圆弧的过程中都需要使用beginPath()方法。

5. 绘制组合图形(笑脸)

通过组合「圆形(脸)+ 小圆(眼睛)+ 半圆(嘴巴)」,实现复杂图形。

function drawSmilingFace() {
  // 1. 绘制脸部轮廓(圆形)
  ctx.beginPath()
  ctx.arc(170, 125, 25, 0, Math.PI * 2, false) // 圆心(170,125),半径25
  ctx.stroke()

  // 2. 绘制左眼(小圆)
  ctx.beginPath()
  ctx.arc(163, 120, 3, 0, Math.PI * 2, false) // 左眼位置:左移7px,上移5px
  ctx.fillStyle = '#2d3748' // 眼睛颜色(深灰)
  ctx.fill() // 实心眼睛,无需 stroke()

  // 3. 绘制右眼(小圆,与左眼对称)
  ctx.beginPath()
  ctx.arc(178, 120, 3, 0, Math.PI * 2, false) // 右眼位置:右移8px,上移5px
  ctx.fill()

  // 4. 绘制微笑嘴巴(下半圆,顺时针)
  ctx.beginPath()
  ctx.arc(170, 123, 18, 0, Math.PI, false) // 圆心(170,123),半径18,180°
  ctx.stroke()
}

完整效果展示:

四、常见问题与注意事项

  1. Canvas 尺寸设置: width 和 height 必须直接在 Canvas 标签上设置,若用 CSS 设置会导致图形拉伸失真。
  2. 路径隔离: 每次绘制新图形前,务必调用 beginPath(),否则新图形会与上一次路径叠加。
  3. 弧度与角度转换: arc() 方法仅支持弧度制,需用 (Math.PI / 180) * 角度 转换(如 90° = Math.PI/ 2)。
  4. 样式优先级: 若单个图形需要特殊样式(如不同颜色),需在 stroke()/fill() 前单独设置(如 ctx.fillStyle),否则会继承公共样式。

Canvas 实现电子签名功能

电子签名功能在现代 Web 应用中非常常见,从在线合同签署到表单确认都有广泛应用。本文将带你从零开始,使用 Canvas API 实现一个功能完备的电子签名组件。

一、实现思路与核心技术点

实现电子签名的核心思路是追踪用户的鼠标或触摸轨迹,并在 Canvas 上将这些轨迹绘制出来。

核心技术点:

  • Canvas API:用于在网页上动态绘制图形
  • 事件监听:监听鼠标 / 触摸的按下、移动和松开事件
  • 坐标转换:将鼠标 / 触摸事件的坐标转换为 Canvas 元素内的相对坐标
  • 线条优化:通过设置线条属性实现平滑的签名效果

二、HTML 结构设计

这是一份简单到爆的html结构,没错,就是这样简单...

<div class="container">
  <p>电子签名</p>
  <canvas id="signatureCanvas" class="signature-border"></canvas>
</div>

三、CSS 样式设置

为 Canvas 添加一些基础样式,使其看起来像一个签名板。

.container {
  background-color: #fff;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.signature-border {
  width: 98%;
  height: 300px;
  border: 4px dashed #cbd5e1;
  border-radius: 10px;
  cursor: crosshair;
}

四、JavaScript 核心实现

这是实现签名功能的关键部分,主要包含以下几个步骤:

  1. 获取 Canvas 元素和上下文
  2. 设置 Canvas 的实际绘制尺寸
  3. 定义变量存储签名状态和坐标
  4. 实现坐标转换函数
  5. 编写事件处理函数
  6. 绑定事件监听器
// 获取Canvas元素和上下文
const canvas = document.getElementById('signatureCanvas')
const ctx = canvas.getContext('2d', { willReadFrequently: true })

// 签名状态变量
let isDrawing = false
let lastX = 0
let lastY = 0
let lineColor = '#000000'
let lineWidth = 2

// 初始化Canvas
function initCanvas() {
  // 设置Canvas样式
  ctx.strokeStyle = lineColor
  ctx.lineWidth = lineWidth
  ctx.lineJoin = 'round'
  ctx.lineCap = 'round'

  resizeCanvas()
  window.addEventListener('resize', resizeCanvas)
}

// 响应窗口大小变化
function resizeCanvas() {
  const rect = canvas.getBoundingClientRect()
  const { width, height } = rect
  // 保存当前画布内容
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
  // 调整Canvas尺寸
  canvas.width = width
  canvas.height = height
  // 恢复画布内容
  ctx.putImageData(imageData, 0, 0)
  // 重新设置绘图样式
  ctx.strokeStyle = lineColor
  ctx.lineWidth = lineWidth
  ctx.lineJoin = 'round'
  ctx.lineCap = 'round'
}

// 获取坐标(适配鼠标和触摸事件)
function getCoordinates(e) {
  const rect = canvas.getBoundingClientRect()
  if (e.type.includes('mouse')) {
    return [e.clientX - rect.left, e.clientY - rect.top]
  } else if (e.type.includes('touch')) {
    return [e.touches[0].clientX - rect.left, e.touches[0].clientY - rect.top]
  }
}

// 开始绘制
function startDrawing(e) {
  isDrawing = true
  lastX = getCoordinates(e)[0]
  lastY = getCoordinates(e)[1]
}

// 绘制中
function draw(e) {
  if (!isDrawing) return
  const [currentX, currentY] = getCoordinates(e)
  ctx.beginPath()
  ctx.moveTo(lastX, lastY)
  ctx.lineTo(currentX, currentY)
  ctx.stroke()
  // 解释: 这里是将当前移动的坐标赋值给下一次绘制的起点,实现线条的流畅。
  ;[lastX, lastY] = [currentX, currentY]
}

// 结束绘制
function stopDrawing() {
  isDrawing = false
}

// 绑定事件监听
function bindEvents() {
  canvas.addEventListener('mousedown', startDrawing)
  canvas.addEventListener('mousemove', draw)
  canvas.addEventListener('mouseup', stopDrawing)
  canvas.addEventListener('mouseout', stopDrawing)
  // 触摸事件(移动设备)
  canvas.addEventListener('touchstart', e => {
    e.preventDefault() // 防止触摸事件被浏览器默认处理
    startDrawing(e)
  })
  canvas.addEventListener('touchmove', e => {
    e.preventDefault()
    draw(e)
  })
  canvas.addEventListener('touchend', e => {
    e.preventDefault()
    stopDrawing()
  })
}

// 初始化
window.addEventListener('load', () => {
  initCanvas()
  bindEvents()
})

五、功能亮点与设计思路

  1. 流畅的绘制体验:通过设置lineCap: 'round'lineJoin: 'round'让线条更加平滑自然。
  2. 响应式设计:监听窗口resize事件,动态调整 Canvas 尺寸,确保在不同设备和屏幕尺寸下都能正常工作。
  3. 跨设备支持:同时支持鼠标和触摸事件,兼容桌面和移动设备。

六、完整的代码

七、下一步可以探索的方向

  1. 颜色和粗细选择:增加 UI 控件让用户自定义签名的颜色和笔触粗细。
  2. 清空签名和保存签名:增加 UI 控件让用户清空当前的签名,同时支持保存和下载签名。

canvas 实现滚动序列帧动画

前言

在现代网页设计中,滚动触发的动画能极大增强用户体验,其中 Apple 官网的 AirPods Pro 产品页动画堪称经典 —— 通过滚动进度控制序列帧播放,营造出流畅的产品展示效果。本文将简单的实现一下这个动画效果。

一、动画核心逻辑

  1. 页面分为 3 个楼层:楼层 1(灰色背景)、楼层 2(黑色背景,核心动画区)、楼层 3(灰色背景)
  2. 楼层 2 高度为200vh(2 倍视口高度),内部有一个sticky定位的容器,包含文字和 Canvas
  3. 当用户滚动页面时,仅在楼层 2 进入并完全离开视口的过程中,Canvas 会根据滚动进度播放 147 帧 AirPods 序列图
  4. 窗口尺寸变化时,Canvas 会自动适配,保证动画显示比例正确

二、核心技术栈及原理拆解

要实现滚动序列帧动画,需要解决 3 个核心问题:序列帧加载与管理、滚动进度计算、Canvas 渲染与适配。

  1. HTML 部分的核心是三层 section 结构和Canvas 动画容器,结构清晰且语义化:
<!-- 楼层1:引导区 -->
<section class="floor1-container floor-container">
  <p>楼层一</p>
</section>
<!-- 楼层2:核心动画区(目标楼层) -->
<section class="floor2-container floor-container" id="targetFloor">
  <!-- sticky容器:滚动时"粘住"视口 -->
  <div class="sticky">
    <p>楼层二</p>
    <!-- Canvas:用于渲染序列帧 -->
    <canvas class="canvas" id="hero-lightpass"></canvas>
  </div>
</section>
<!-- 楼层3:结束区 -->
<section class="floor3-container floor-container">
  <p>楼层三</p>
</section>
  1. CSS 的核心作用是控制三层布局、实现 sticky 定位、保证 Canvas 适配,代码注释已标注关键逻辑:
/* 重置默认margin,避免布局偏移 */
body,
p {
  margin: 0;
}

/* 楼层1和楼层3样式:灰色背景+居中文字 */
.floor1-container,
.floor3-container {
  background-color: #474646; /* 深灰色背景 */
  height: 500px; /* 固定高度,模拟常规内容区 */
  display: flex; /* Flex布局:实现文字水平+垂直居中 */
  justify-content: center; /* 水平居中 */
  align-items: center; /* 垂直居中 */
}

/* 楼层1/3文字样式:响应式字体 */
.floor3-container p,
.floor1-container p {
  font-size: 5vw; /* 5vw:相对于视口宽度的5%,实现响应式字体 */
  color: #fff; /* 白色文字,与深色背景对比 */
}

/* 楼层2样式:黑色背景+高高度(动画触发区) */
.floor2-container {
  height: 200vh; /* 200vh:2倍视口高度,保证有足够滚动空间触发动画 */
  background-color: black; /* 黑色背景,突出产品图片 */
  color: #fff; /* 白色文字 */
}

/* 楼层2文字:水平居中 */
.floor2-container p {
  text-align: center;
}

/* 核心:sticky定位容器 */
.sticky {
  position: sticky; /* 粘性定位:滚动到top:0时固定 */
  top: 0; /* 固定在视口顶部 */
  height: 500px; /* 与楼层1/3高度一致,保证视觉连贯 */
  width: 100%; /* 占满视口宽度 */
}

/* Canvas样式:宽度自适应 */
.canvas {
  width: 100%; /* 宽度占满容器 */
  height: auto; /* 高度自动,保持图片比例 */
}
  1. JS 部分是整个动画的核心,负责预加载序列帧、计算滚动进度、控制 Canvas 渲染和窗口适配,我们分模块解析:

模块 1:初始化变量与 DOM 元素

首先定义动画所需的核心变量,包括序列帧数量、图片数组、Canvas 上下文等:

// 1. 动画核心配置
const frameCount = 147 // 序列帧总数(根据实际图片数量调整)
const images = [] // 存储所有预加载的序列帧图片
const canvas = document.getElementById('hero-lightpass') // 获取Canvas元素
const context = canvas.getContext('2d') // 获取Canvas 2D渲染上下文
const airpods = { frame: 0 } // 存储当前播放的帧序号(用对象便于修改)

// 2. 获取目标楼层(楼层2)的DOM元素,用于后续计算滚动位置
const targetFloor = document.getElementById('targetFloor')

// 3. 序列帧图片地址模板(Apple官网的AirPods序列帧地址)
// 作用:通过索引生成每帧图片的URL(如0001.jpg、0002.jpg...)
const currentFrame = index =>
  `https://www.apple.com/105/media/us/airpods-pro/2019/1299e2f5_9206_4470_b28e_08307a42f19b/anim/sequence/large/01-hero-lightpass/${(index + 1).toString().padStart(4, '0')}.jpg`

模块 2:预加载所有序列帧图片

序列帧动画需要所有图片加载完成后才能流畅播放,因此必须先预加载图片:

// 循环生成147帧图片,存入images数组
for (let i = 0; i < frameCount; i++) {
  const img = new Image() // 创建Image对象
  img.src = currentFrame(i) // 给图片设置URL(通过模板生成)
  images.push(img) // 将图片存入数组
}

// 当第一张图片加载完成后,执行首次渲染(避免页面空白)
images[0].onload = render

为什么要预加载:

  1. 如果不预加载,用户滚动时图片可能还在加载,导致动画卡顿或跳帧
  2. 监听第一张图片的onload事件:保证页面初始化时至少有一张图显示,提升首屏体验

模块 3:Canvas 渲染函数

定义render()函数,负责将当前帧图片绘制到 Canvas 上:

function render() {
  // 1. 清除Canvas画布(避免上一帧残留)
  context.clearRect(0, 0, canvas.width, canvas.height)

  // 2. 绘制当前帧图片
  // 参数:图片对象、绘制起点X、Y、绘制宽度、绘制高度
  context.drawImage(images[airpods.frame], 0, 0, canvas.width, canvas.height)
}

模块 4:Canvas 窗口适配函数

当窗口尺寸变化时,需要重新调整 Canvas 的宽高,避免图片拉伸或变形:

function resizeCanvas() {
  // 1. 获取Canvas元素的实际位置和尺寸(包含CSS样式的影响)
  const rect = canvas.getBoundingClientRect()

  // 2. 设置Canvas的实际宽高(Canvas的width/height是像素尺寸,而非CSS样式)
  canvas.width = rect.width
  canvas.height = rect.height

  // 3. 重新渲染当前帧(避免尺寸变化后画布空白)
  render()
}

易错点提醒:

  1. Canvas 有两个 "尺寸":一个是 HTML 属性width/height(实际像素尺寸),另一个是 CSS 样式width/height(显示尺寸)
  2. 如果只改 CSS 样式而不改canvas.width/height,图片会拉伸变形;因此必须通过getBoundingClientRect()获取实际显示尺寸,同步设置 Canvas >的像素尺寸

模块 5:滚动进度计算与帧控制(核心中的核心)

这是整个动画的逻辑核心 —— 根据用户的滚动位置,计算当前应播放的帧序号,实现 "滚动控制动画":

function handleScroll() {
  // 1. 获取关键尺寸数据
  const viewportHeight = window.innerHeight // 视口高度(浏览器可见区域高度)
  const floorTop = targetFloor.offsetTop // 目标楼层(楼层2)距离页面顶部的距离
  const floorHeight = targetFloor.offsetHeight // 目标楼层自身的高度(200vh)
  const currentScrollY = window.scrollY // 当前滚动位置(页面顶部到视口顶部的距离)

  // 2. 计算"滚动结束点":当目标楼层底部进入视口时,动画应播放到最后一帧
  const scrollEnd = floorTop + floorHeight - viewportHeight

  // 3. 计算滚动进度(0~1):0=未进入楼层2,1=完全离开楼层2
  let scrollProgress = 0
  if (currentScrollY < floorTop) {
    // 情况1:滚动位置在楼层2上方→进度0(显示第一帧)
    scrollProgress = 0
  } else if (currentScrollY > scrollEnd) {
    // 情况2:滚动位置在楼层2下方→进度1(显示最后一帧)
    scrollProgress = 1
  } else {
    // 情况3:滚动位置在楼层2内部→计算相对进度
    const scrollDistanceInFloor = currentScrollY - floorTop // 进入楼层2后滚动的距离
    const totalScrollNeeded = scrollEnd - floorTop // 楼层2内需要滚动的总距离(触发完整动画的距离)
    scrollProgress = scrollDistanceInFloor / totalScrollNeeded // 进度=已滚动距离/总距离
  }

  // 4. 根据进度计算当前应显示的帧序号
  // 公式:目标帧 = 进度 × (总帧数-1) → 保证进度1时显示最后一帧(避免数组越界)
  const targetFrame = Math.floor(scrollProgress * (frameCount - 1))

  // 5. 优化性能:仅当帧序号变化时才重新渲染
  if (targetFrame !== airpods.frame) {
    airpods.frame = targetFrame
    render() // 重新绘制当前帧
  }
}

模块 6:事件监听与初始化

最后,通过事件监听触发上述逻辑,完成动画初始化:

window.addEventListener('load', () => {
  // 1. 监听滚动事件:用户滚动时触发进度计算
  window.addEventListener('scroll', handleScroll)

  // 2. 监听窗口 resize 事件:窗口尺寸变化时适配Canvas
  window.addEventListener('resize', resizeCanvas)

  // 3. 初始化Canvas尺寸(页面加载完成后首次适配)
  resizeCanvas()
})

三、完成代码展示

更多canvas功能敬请期待...

❌
❌