普通视图

发现新文章,点击刷新页面。
昨天 — 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功能敬请期待...

JS核心知识-Ajax

作者 云枫晖
2025年10月14日 16:59

在现代Web开发中,用户体验已经成为衡量应用成功与否的关键指标。回想早期的互联网,每次与服务器交互都需要刷新整个页面,这种"白屏-等待-刷新"的体验显然无法满足当今用户对流畅操作的需求。

在这样的背景下,Ajax技术应运而生。它如同为网页装上了隐形翅膀,让数据交互在后台静默进行,用户无需等待页面刷新即可获取最新内容。从Gmail的无刷新操作到Google Maps的流畅拖动,从社交媒体的实时更新到电商网站的动态加载,Ajax已成为现代Web应用的基石技术。

本文将深入探索Ajax的核心原理,从概念到底层机制,从简单使用到企业级封装,逐步揭开这项改变Web开发格局的技术面纱。

什么是Ajax

Ajax(Asynchronous JavaScript and XML)是一种创建交互式网页应用的开发技术。它允许网页在不重新加载整个页面的情况下,与服务器交互数据并更新部分页面内容。

Ajax这个术语最早在2005年由Jesse James Garrett提出,但相关技术在此之前已经存在。它的出现标志着Web 2.0时代的到来,让网页应用具备了与桌面应用相媲美的交互体验。

核心特点:

  • 异步通信:浏览器可以在不阻碍用户操作的情况下与服务器通信
  • 局部更新:只更新页面中需要变化的部分,而不是整个页面
  • 更好的用户体验:用户操作几乎无感知,页面响应更加流畅

Ajax的底层机制

Ajax的核心在于XMLHttpRequest对象,它充当了浏览器与服务器之间的中间人角色。让我们深入了解其底层运作机制:

整体架构

image.png

XMLHttpRequest与网络栈中的各个模块协同配合完成与服务器的交互,主要包含以下模块:

  • HTTP处理器:处理HTTP协议相关的所有逻辑
  • DNS解析器:将域名转换为IP地址
  • 安全引擎:处理HTTPS加密通信
  • 套接字管理器:管理TCP连接和网络I/O
  • 缓存管理器:管理HTTP缓存,提高性能
  • Cookie管理器:管理HTTP Cookie的存储和发送

请求发送流程

image.png

响应处理流程

image.png

Ajax使用详解

创建XMLHttpRequest对象

var xhr = new XMLHttpRequest();

配置请求

xhr.open('GET', 'https://api.example.com/data', true);

通过XMLHttpRequest对象的open方法配置请求,接收三个参数:

  • 请求方法:GET、POST、PUT、DELETE等
  • 请求地址:获取服务器数据的地址
  • 是否异步:true为异步,false为同步请求(现代开发基本都使用异步请求)

设置请求头(可选)

// 设置需要的请求类型
xhr.setRequestHeader('Content-Type', 'application/json');

处理响应

xhr.onreadystatechange = function() {
// 判断请求/响应处理完成阶段
  if (xhr.readyState === 4) {
    // 判断响应HTTP状态  304 Not Modified 也表示成功(缓存有效)
    if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
        // 处理响应成功
        console.log('请求成功:', xhr.responseText);
    } else {
        // 处理响应失败
        console.error('请求失败:', xhr.status, xhr.statusText);
    }
  }
};

通过XMLHttpRequest对象的onreadystatechange函数监听请求/响应阶段,然后判断HTTP状态来处理业务逻辑。readyState的可能值:

  • 0:未初始化。尚未调用open方法
  • 1:已打开。已调用open方法,尚未调用send方法
  • 2:已发送。已调用send方法,尚未收到响应
  • 3:接收中。已收到部分响应
  • 4:完成。已收到所有响应,可以使用了

在XMLHttpRequest Level 2中,可以使用onload事件替代onreadystatechange,无需判断readyState属性:

xhr.onload = function() {
  // 判断响应HTTP状态 304 Not Modified 也表示成功(缓存有效)
  if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
    // 处理响应成功
  } else {
    // 处理响应失败
  }
};

发送请求

xhr.send(null); // GET请求
// 如果是POST请求:xhr.send(data),data是服务器需要的参数

其他事件

XMLHttpRequest对象还提供其他实用事件:

  • ontimeout:处理请求超时
xhr.timeout = 5000;
xhr.ontimeout = function() {
    // 处理超时情况
};
  • onerror:处理请求错误
  • abort() :取消请求
  • onprogress:监听请求进度
xhr.onprogress = function(event) {
  // event中包含三个重要属性:
  // lengthComputable - 布尔值,表示进度信息是否可用
  // loaded - 已接收字节数
  // total - 响应的Content-Length头部定义的总字节数
  if (event.lengthComputable) {
    const percentComplete = (event.loaded / event.total) * 100;
    console.log(`进度: ${percentComplete.toFixed(2)}%`);
  }
};

注意:为确保正确执行,必须在调用open之前添加onprogress事件。

Ajax的企业级封装

原生Ajax使用较为繁琐,封装势在必行。下面逐步封装一个功能完整的Ajax库。

基础结构搭建

/**
 * Ajax请求类 - 企业级封装
 * 提供完整的HTTP请求功能,支持并发控制、重试机制、错误处理等
 */
class AjaxRequest {
    /**
     * 构造函数
     * @param {Object} baseConfig 基础配置
     */
    constructor(baseConfig = {}) {
        // 默认配置,用户配置会覆盖这些默认值
        this.defaultConfig = {
            baseURL: '',                    // 基础URL路径
            timeout: 10000,                 // 请求超时时间(毫秒)
            headers: {                      // 默认请求头
                'Content-Type': 'application/json'
            },
            responseType: 'text',           // 响应类型:text, json, blob, arraybuffer
            withCredentials: false,         // 是否携带跨域cookie
            retry: 0,                       // 重试次数
            retryDelay: 1000,               // 重试延迟时间(毫秒)
            maxPendingRequests: 50,         // 最大并发请求数
            requestTimeout: 30000,          // 请求超时自动清理时间(毫秒)
            validateStatus: (status) => status >= 200 && status < 300, // 状态码验证函数
            shouldRetry: (error) => {       // 重试条件判断函数
                // 只在网络错误或5xx服务器错误时重试
                return error.type === 'NETWORK_ERROR' || 
                       (error.status >= 500 && error.status < 600);
            },
            xsrfCookieName: 'XSRF-TOKEN',   // CSRF token的cookie名称
            xsrfHeaderName: 'X-XSRF-TOKEN', // CSRF token的请求头名称
            ...baseConfig
        };

        // 存储进行中的请求,用于并发控制和请求取消
        this.pendingRequests = new Map();

        // 请求ID计数器,用于生成唯一请求标识
        this.requestIdCounter = 0;
    }

    /**
     * 创建新的AjaxRequest实例
     * @param {Object} config 实例配置
     * @returns {AjaxRequest} 新的实例
     */
    create(config = {}) {
        return new AjaxRequest({ ...this.defaultConfig, ...config });
    }

    /**
     * 设置默认配置
     * @param {Object} config 配置对象
     * @returns {AjaxRequest} 当前实例(支持链式调用)
     */
    setConfig(config) {
        this.defaultConfig = { ...this.defaultConfig, ...config };
        return this;
    }
}

核心请求方法实现

class AjaxRequest {
    // ... 之前的代码 ...

    /**
     * 核心请求方法
     * @param {Object} config 请求配置
     * @returns {Promise} 请求Promise对象
     */
    async request(config) {
        // 1. 验证配置合法性
        this.validateConfig(config);

        // 2. 合并配置(默认配置 + 用户配置)
        const mergedConfig = { ...this.defaultConfig, ...config };

        // 3. 生成请求唯一标识
        const requestKey = this.generateRequestKey(mergedConfig);

        // 4. 清理过期的请求,防止内存泄漏
        this.cleanupExpiredRequests();

        // 5. 检查并发数限制
        if (this.pendingRequests.size >= mergedConfig.maxPendingRequests) {
            throw this.createError('同时发起的请求过多,请稍后重试', 'TOO_MANY_REQUESTS');
        }

        // 6. 防重复请求检查(相同URL、参数、方法的请求)
        if (this.pendingRequests.has(requestKey)) {
            console.warn('重复请求已被阻止:', requestKey);
            return this.pendingRequests.get(requestKey).promise;
        }

        let lastError; // 记录最后一次错误

        // 7. 重试机制:尝试请求(初始请求 + 重试次数)
        for (let attempt = 0; attempt <= mergedConfig.retry; attempt++) {
            try {
                // 7.1 非首次请求时添加延迟(指数退避)
                if (attempt > 0) {
                    console.log(`第${attempt}次重试请求: ${mergedConfig.url}`);
                    await this.delay(mergedConfig.retryDelay * attempt);
                }

                // 7.2 发送单次请求
                const requestPromise = this.sendSingleRequest(mergedConfig, requestKey);

                // 7.3 只在第一次尝试时存储到pendingRequests(避免重复存储)
                const requestInfo = {
                  promise: requestPromise,
                  timestamp: Date.now(),
                  timeout: mergedConfig.requestTimeout,
                  config: mergedConfig,
                  xhr: xhr  // 存储xhr实例用于取消操作
                };
                this.pendingRequests.set(requestKey, requestInfo);

                // 7.4 等待请求结果
                const result = await requestPromise;
                return result;

            } catch (error) {
                lastError = error; // 记录错误

                // 7.5 检查是否应该继续重试
                if (attempt < mergedConfig.retry && mergedConfig.shouldRetry(error)) {
                    console.log(`请求失败,进行第${attempt + 1}次重试:`, error.message);
                    continue; // 继续重试
                }
                break; // 不再重试,退出循环
            }
        }

        // 8. 所有重试都失败,抛出最后一次错误
        throw lastError;
    }

    /**
     * 发送单次请求(不包含重试逻辑)
     * @param {Object} config 请求配置
     * @param {string} requestKey 请求唯一标识
     * @returns {Promise} 请求Promise
     */
    sendSingleRequest(config, requestKey) {
        return new Promise((resolve, reject) => {
            // 1. 创建新的XMLHttpRequest实例(每次请求都是独立的)
            const xhr = new XMLHttpRequest();

            const { 
                method = 'GET', 
                url, 
                data = null, 
                headers = {}, 
                timeout,
                responseType,
                withCredentials
            } = config;

            // 2. 构建完整URL(处理baseURL)
            const fullUrl = config.baseURL ? `${config.baseURL}${url}` : url;

            // 3. 初始化请求
            xhr.open(method.toUpperCase(), fullUrl, true);

            // 4. 配置XHR对象
            if (responseType) xhr.responseType = responseType;
            if (withCredentials) xhr.withCredentials = true;

            // 5. 设置请求头(包含CSRF保护)
            this.setHeaders(xhr, headers, config);

            // 6. 设置超时时间
            xhr.timeout = timeout;

            // 7. 注册事件监听器

            // 7.1 请求成功完成
            xhr.onload = () => {
                // 从pendingRequests中移除已完成的请求
                this.pendingRequests.delete(requestKey);

                // 验证状态码
                if (config.validateStatus(xhr.status)) {
                    resolve(this.handleResponse(xhr, config));
                } else {
                    reject(this.handleError(xhr, config));
                }
            };

            // 7.2 网络错误
            xhr.onerror = () => {
                this.pendingRequests.delete(requestKey);
                reject(this.handleError(xhr, config));
            };

            // 7.3 请求超时
            xhr.ontimeout = () => {
                this.pendingRequests.delete(requestKey);
                reject(this.createError(`请求超时: ${timeout}ms`, 'TIMEOUT_ERROR', xhr));
            };

            // 7.4 请求被取消
            xhr.onabort = () => {
                this.pendingRequests.delete(requestKey);
                reject(this.createError('请求已被取消', 'ABORT_ERROR', xhr));
            };

            // 8. 进度事件监听(可选)
            if (config.onUploadProgress) {
                xhr.upload.onprogress = config.onUploadProgress;
            }

            if (config.onDownloadProgress) {
                xhr.onprogress = config.onDownloadProgress;
            }

            // 9. 发送请求数据
            try {
                xhr.send(this.processData(data, headers));
            } catch (sendError) {
                this.pendingRequests.delete(requestKey);
                reject(this.createError(`请求发送失败: ${sendError.message}`, 'SEND_ERROR', xhr));
            }
        });
    }

    /**
     * 生成请求唯一标识
     * @param {Object} config 请求配置
     * @returns {string} 请求唯一标识
     */
    generateRequestKey(config) {
        const { method = 'GET', url, data } = config;
        // 使用请求方法、URL、数据生成唯一key
        const dataStr = data ? JSON.stringify(data) : '';
        return `${method}:${url}:${dataStr}`;
    }

    /**
     * 清理过期的请求
     */
    cleanupExpiredRequests() {
        const now = Date.now();
        for (const [key, request] of this.pendingRequests) {
            // 检查请求是否超时
            if (now - request.timestamp > request.timeout) {
                this.pendingRequests.delete(key);
                console.warn(`请求超时自动清理: ${key}`);
            }
        }
    }

    /**
     * 延迟函数
     * @param {number} ms 延迟时间(毫秒)
     * @returns {Promise} 延迟Promise
     */
    delay(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
}

数据处理和错误处理

class AjaxRequest {
        // ... 之前的代码 ...
        
        /**
         * 设置请求头
         * @param {XMLHttpRequest} xhr XHR对象
         * @param {Object} headers 请求头对象
         * @param {Object} config 请求配置
         */
        setHeaders(xhr, headers, config) {
            // 1. 添加CSRF保护(如果启用)
            if (config.withCredentials) {
                const xsrfValue = this.getCookie(config.xsrfCookieName);
                if (xsrfValue && config.xsrfHeaderName) {
                    xhr.setRequestHeader(config.xsrfHeaderName, xsrfValue);
                }
            }
            
            // 2. 设置其他请求头
            Object.keys(headers).forEach(key => {
                if (headers[key] !== undefined && headers[key] !== null) {
                    // 检查是否为危险头信息(浏览器禁止设置的请求头)
                    if (!this.isDangerousHeader(key)) {
                        xhr.setRequestHeader(key, headers[key]);
                    } else {
                        console.warn(`跳过设置危险请求头: ${key}`);
                    }
                }
            });
        }
        
        /**
         * 处理响应数据
         * @param {XMLHttpRequest} xhr XHR对象
         * @param {Object} config 请求配置
         * @returns {Object} 响应对象
         */
        handleResponse(xhr, config) {
            let data;
            
            // 根据responseType获取数据
            switch (xhr.responseType) {
                case 'json':
                    data = xhr.response; // 浏览器自动解析JSON
                    break;
                case 'blob':
                    data = xhr.response; // Blob对象
                    break;
                case 'arraybuffer':
                    data = xhr.response; // ArrayBuffer对象
                    break;
                case 'document':
                    data = xhr.response; // Document对象
                    break;
                default:
                    // 默认text类型,需要手动处理JSON
                    data = xhr.responseText;
                    // 自动JSON解析(如果内容是JSON格式)
                    const contentType = xhr.getResponseHeader('content-type') || '';
                    if (contentType.includes('application/json') && data) {
                        try {
                            data = JSON.parse(data);
                        } catch (e) {
                            console.warn('JSON解析失败,返回原始数据:', e.message);
                            // 解析失败时保持原始数据
                        }
                    }
            }
            
            // 构建标准化的响应对象
            return {
                data,                    // 响应数据
                status: xhr.status,      // 状态码
                statusText: xhr.statusText, // 状态文本
                headers: this.parseHeaders(xhr.getAllResponseHeaders()), // 响应头
                config,                  // 请求配置
                xhr,                     // 原始XHR对象(用于高级操作)
                requestId: this.generateRequestId() // 请求ID(用于追踪)
            };
        }
        
        /**
         * 处理请求错误
         * @param {XMLHttpRequest} xhr XHR对象
         * @param {Object} config 请求配置
         * @returns {Error} 错误对象
         */
        handleError(xhr, config) {
            const error = new Error(this.getErrorMessage(xhr.status));
            error.name = 'AjaxError';
            error.status = xhr.status;
            error.statusText = xhr.statusText;
            error.config = config;
            error.xhr = xhr;
            error.timestamp = new Date().toISOString();
            error.requestId = this.generateRequestId();
            
            // 分类错误类型
            if (xhr.status === 0) {
                error.type = 'NETWORK_ERROR'; // 网络错误
            } else if (xhr.status >= 400 && xhr.status < 500) {
                error.type = 'CLIENT_ERROR'; // 客户端错误
            } else if (xhr.status >= 500) {
                error.type = 'SERVER_ERROR'; // 服务器错误
            } else {
                error.type = 'UNKNOWN_ERROR'; // 未知错误
            }
            
            return error;
        }
        
        /**
         * 创建错误对象
         * @param {string} message 错误消息
         * @param {string} type 错误类型
         * @param {XMLHttpRequest} xhr XHR对象
         * @returns {Error} 错误对象
         */
        createError(message, type, xhr = null) {
            const error = new Error(message);
            error.name = 'AjaxError';
            error.type = type;
            error.timestamp = new Date().toISOString();
            error.requestId = this.generateRequestId();
            
            if (xhr) {
                error.xhr = xhr;
                error.status = xhr.status;
                error.statusText = xhr.statusText;
            }
            
            return error;
        }
        
        /**
         * 根据状态码获取错误消息
         * @param {number} status HTTP状态码
         * @returns {string} 错误消息
         */
        getErrorMessage(status) {
            const messages = {
                0: '网络连接失败,请检查网络设置',
                400: '请求参数错误,请检查输入',
                401: '未授权访问,请先登录',
                403: '访问被禁止,没有权限',
                404: '请求的资源不存在',
                408: '请求超时,请稍后重试',
                500: '服务器内部错误',
                502: '网关错误',
                503: '服务不可用,请稍后重试',
                504: '网关超时'
            };
            return messages[status] || `请求失败 (${status})`;
        }
        
        /**
         * 处理请求数据
         * @param {any} data 请求数据
         * @param {Object} headers 请求头
         * @returns {any} 处理后的数据
         */
        processData(data, headers) {
            if (!data) return null;
            
            const contentType = headers['Content-Type'] || '';
            
            // JSON数据序列化
            if (contentType.includes('application/json') && typeof data === 'object') {
                return JSON.stringify(data);
            }
            
            // URL编码表单数据
            if (contentType.includes('application/x-www-form-urlencoded') && typeof data === 'object') {
                const params = new URLSearchParams();
                Object.keys(data).forEach(key => {
                    params.append(key, data[key]);
                });
                return params.toString();
            }
            
            // FormData、Blob、ArrayBuffer等特殊对象直接返回
            if (data instanceof FormData || data instanceof Blob || data instanceof ArrayBuffer) {
                return data;
            }
            
            // 其他类型数据直接返回
            return data;
        }
        
        /**
         * 解析响应头字符串为对象
         * @param {string} headersString 响应头字符串
         * @returns {Object} 响应头对象
         */
        parseHeaders(headersString) {
            const headers = {};
            if (headersString) {
                headersString.split('\r\n').forEach(line => {
                    const [key, ...valueParts] = line.split(': ');
                    const value = valueParts.join(': ');
                    if (key && value) {
                        headers[key] = value;
                    }
                });
            }
            return headers;
        }
        
        /**
         * 获取Cookie值
         * @param {string} name Cookie名称
         * @returns {string|null} Cookie值
         */
        getCookie(name) {
            const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
            return match ? decodeURIComponent(match[2]) : null;
        }
        
        /**
         * 检查是否为危险请求头
         * @param {string} name 请求头名称
         * @returns {boolean} 是否为危险头
         */
        isDangerousHeader(name) {
            // 浏览器禁止设置的请求头列表
            const dangerousHeaders = [
                'accept-charset', 'accept-encoding', 'access-control-request-headers',
                'access-control-request-method', 'connection', 'content-length',
                'cookie', 'cookie2', 'date', 'dnt', 'expect', 'host', 'keep-alive',
                'origin', 'referer', 'te', 'trailer', 'transfer-encoding', 'upgrade',
                'via'
            ];
            return dangerousHeaders.includes(name.toLowerCase());
        }
        
        /**
         * 生成请求ID
         * @returns {string} 唯一请求ID
         */
        generateRequestId() {
            return `req_${Date.now()}_${++this.requestIdCounter}`;
        }
    }

便捷API和请求管理


    class AjaxRequest {
        // ... 之前的代码 ...
        
        // ========== 便捷HTTP方法 ==========
        
        /**
         * GET请求
         * @param {string} url 请求URL
         * @param {Object} config 请求配置
         * @returns {Promise} 请求Promise
         */
        get(url, config = {}) {
            return this.request({ ...config, method: 'GET', url });
        }
        
        /**
         * POST请求
         * @param {string} url 请求URL
         * @param {any} data 请求数据
         * @param {Object} config 请求配置
         * @returns {Promise} 请求Promise
         */
        post(url, data = null, config = {}) {
            return this.request({ ...config, method: 'POST', url, data });
        }
        
        /**
         * PUT请求
         * @param {string} url 请求URL
         * @param {any} data 请求数据
         * @param {Object} config 请求配置
         * @returns {Promise} 请求Promise
         */
        put(url, data = null, config = {}) {
            return this.request({ ...config, method: 'PUT', url, data });
        }
        
        /**
         * DELETE请求
         * @param {string} url 请求URL
         * @param {Object} config 请求配置
         * @returns {Promise} 请求Promise
         */
        delete(url, config = {}) {
            return this.request({ ...config, method: 'DELETE', url });
        }
        
        /**
         * PATCH请求
         * @param {string} url 请求URL
         * @param {any} data 请求数据
         * @param {Object} config 请求配置
         * @returns {Promise} 请求Promise
         */
        patch(url, data = null, config = {}) {
            return this.request({ ...config, method: 'PATCH', url, data });
        }
        
        /**
         * 文件上传专用方法
         * @param {string} url 上传URL
         * @param {FormData} formData 表单数据
         * @param {Object} config 请求配置
         * @returns {Promise} 请求Promise
         */
        upload(url, formData, config = {}) {
            return this.request({
                ...config,
                method: 'POST',
                url,
                data: formData,
                // FormData会自动设置Content-Type为multipart/form-data
                headers: {
                    ...config.headers
                }
            });
        }
        
        // ========== 请求管理方法 ==========
        
        /**
         * 取消特定请求
         * @param {string} requestKey 请求唯一标识
         * @param {string} reason 取消原因
         * @returns {boolean} 是否取消成功
         */
        cancelRequest(requestKey, reason = '手动取消') {
            const requestInfo = this.pendingRequests.get(requestKey);
            if (requestInfo) {
                // 如果有XHR实例,调用abort方法真正取消请求
                if (requestInfo.xhr) {
                    requestInfo.xhr.abort();
                }
                this.pendingRequests.delete(requestKey);
                console.log(`请求已取消: ${requestKey}`, reason);
                return true;
            }
            return false;
        }
        
        /**
         * 取消所有进行中的请求
         * @param {string} reason 取消原因
         * @returns {number} 取消的请求数量
         */
        cancelAllRequests(reason = '批量取消') {
            const cancelledCount = this.pendingRequests.size;
            for (const [key, requestInfo] of this.pendingRequests) {
                if (requestInfo.xhr) {
                    requestInfo.xhr.abort();
                }
            }
            this.pendingRequests.clear();
            console.log(`已取消所有请求 (${cancelledCount}个)`, reason);
            return cancelledCount;
        }
        
        /**
         * 按条件取消请求
         * @param {Function} conditionFn 条件函数
         * @param {string} reason 取消原因
         * @returns {number} 取消的请求数量
         */
        cancelRequestsByCondition(conditionFn, reason = '条件取消') {
            let cancelledCount = 0;
            for (const [key, requestInfo] of this.pendingRequests) {
                if (conditionFn(requestInfo)) {
                    if (this.cancelRequest(key, reason)) {
                        cancelledCount++;
                    }
                }
            }
            return cancelledCount;
        }
        
        /**
         * 获取进行中的请求数量
         * @returns {number} 请求数量
         */
        getPendingRequestCount() {
            return this.pendingRequests.size;
        }
        
        /**
         * 获取所有进行中的请求信息
         * @returns {Array} 请求信息数组
         */
        getPendingRequests() {
            return Array.from(this.pendingRequests.entries()).map(([key, info]) => ({
                key,
                timestamp: info.timestamp,
                config: info.config,
                age: Date.now() - info.timestamp
            }));
        }
        
        // ========== 配置验证 ==========
        
        /**
         * 验证配置合法性
         * @param {Object} config 请求配置
         * @throws {Error} 配置验证失败时抛出错误
         */
        validateConfig(config) {
            const errors = [];
            
            // 验证URL
            if (!config.url || typeof config.url !== 'string') {
                errors.push('url必须是非空字符串');
            }
            
            // 验证HTTP方法
            const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
            if (config.method && !validMethods.includes(config.method.toUpperCase())) {
                errors.push(`method必须是以下值之一: ${validMethods.join(', ')}`);
            }
            
            // 验证超时时间
            if (config.timeout && (typeof config.timeout !== 'number' || config.timeout < 0)) {
                errors.push('timeout必须是大于等于0的数字');
            }
            
            // 验证重试次数
            if (config.retry && (typeof config.retry !== 'number' || config.retry < 0 || !Number.isInteger(config.retry))) {
                errors.push('retry必须是大于等于0的整数');
            }
            
            // 验证请求头
            if (config.headers && (typeof config.headers !== 'object' || Array.isArray(config.headers))) {
                errors.push('headers必须是对象');
            }
            
            // 如果有错误,抛出异常
            if (errors.length > 0) {
                throw this.createError(`配置验证失败: ${errors.join(', ')}`, 'CONFIG_ERROR');
            }
        }
    }

导出和使用

/**
 * 创建默认的AjaxRequest实例
 * 这是主要的导出对象,应用程序通常使用这个实例
 */
const ajax = new AjaxRequest({
    // 全局默认配置
    baseURL: process.env.API_BASE_URL || '', // 可从环境变量读取
    timeout: 15000,
    retry: 2,
    retryDelay: 1000
});

// 导出默认实例和类
export default ajax;
export { AjaxRequest };

// 如果是在浏览器环境,挂载到window对象(可选)
if (typeof window !== 'undefined') {
    window.AjaxRequest = AjaxRequest;
    window.ajax = ajax;
}

技术总结

通过本文的深入学习,我们不仅理解了Ajax的核心原理,还亲手打造了一个功能完整、健壮可靠的企业级Ajax封装库。

🎯 核心价值

  • 生产就绪:具备企业级应用所需的所有功能
  • 开发者友好:直观的API设计和详细的错误信息
  • 安全可靠:多重安全防护和健壮的错误处理

🛠 技术特色

  • 现代事件模型:使用onload等现代事件,代码更简洁
  • 完整生命周期:从请求创建到清理的全流程管理
  • 智能重试机制:可配置的重试策略,提高请求成功率
  • 强大请求管理:支持请求取消、并发控制等高级功能

📈 最佳实践

  1. 错误处理:分类处理不同错误类型,提供友好提示
  2. 性能优化:并发控制、内存管理、防重复请求
  3. 安全防护:CSRF保护、输入验证、危险头过滤
  4. 可维护性:清晰的代码结构、详细的注释、标准化响应

🔄 演进建议

虽然这个封装已经相当完善,但在实际项目中还可以:

  • 添加TypeScript类型定义
  • 集成请求缓存机制
  • 添加请求/响应转换器
  • 支持请求优先级调度
  • 添加性能监控和统计

这个Ajax封装库不仅是一个可用的工具,更是一个学习现代前端架构的优秀范例。理解其设计思想和实现细节,将为你构建更复杂的前端应用打下坚实基础。

重要提示:虽然我们实现了功能完整的Ajax封装,但在生产环境中,根据具体需求选择成熟的库(如axios)仍然是更稳妥的选择。这个练习的价值在于深入理解底层原理和封装思想!

RN 的初版架构——UI 布局与绘制

作者 Joyee691
2025年10月14日 16:21

我们知道 RN 之所以受欢迎的其中一个原因就是把之前只有在 React 中有的 jsx 带进了 Native 开发的世界

在这一个篇章中,我们会深入了解 RN 是如何将 <View><Text> 标签转换成 UIView(IOS)、ViewGroup(Android)

当然,还有 yoga 究竟在其中做了什么?以及为什么要有 yoga


但是,在深入之前,我们要先聊聊一个方法:runApplication

runApplication 顾名思义,这是一个启动应用的方法,但这里启动的不是原生应用,而是在 JS bundle 在加载完之后,由 RN 在原生应用中启动 React 应用,它的调用过程涵盖了三个线程,其调用流程如下:

可以看到,我们的 RN 程序启动以后,会在客户端的 RootView 中调用 runApplication 方法,这个方法的调用会通过我们在通信机制中讲到的 Instance -> Bridge -> JSCExecutor 这条通道一路走进 JS 程序

当 JS 接收到 AppRegistry.runApplication 的调用后,它会去找到我们 RN 项目根目录的 index.js 注册的组件(默认在 App.js),最后调用 ReactNative.jsrender 方法

ReactNative.js 中包含着 RN 在 JS 侧的核心代码,他的主要任务是将 React diff 完的 fiber 转换成为一系列的 UIManager.xxxxx 调用

这些调用最后会触发 Native 中的 UIManager(UIManager 也是一个 Native module) 的逻辑生成原生元素(UIView,ViewGroup 等等) ,最后在 yoga 这个布局引擎的帮助下完成原生页面的渲染

createView, setChildren 与 yoga 布局

接下来我们以一个简单的例子来聊聊我们写的 RN jsx 是如何最后转变为原生元素并显示在屏幕中的

<View>
<Text>Hello world!</Text>
</View>

当我们这个组件被 ReactNative.jsrender 执行后,会有以下方法被调用:

题外话,在具体的场景中,上述例子可能不止有下述方法被调用了,被调用的方法也可能会有区别,但是他们的目的与功能是类似的,本文为了方便理解做了部份简化

  1. UIManager.createView(tagV, 'RCTView', rootTag, {})
  2. UIManager.createView(tagT, 'RCTText', rootTag, { text: 'Hello world!' })
  3. UIManager.setChildren(tagV, [tagT])

createView 接受 4 个参数:

  • 第一个参数是一个自增的数字,会唯一标识一个创建的元素
  • 第二个参数是需要创建的元素类型,因为我们需要的是 View 元素,其对应的是 RCTView (在原生平台中,它是一个继承自各自平台 View 元素的类,其中定义了一些 RN 需要的方法)
  • 第三个参数是根容器(root container)的唯一标识符,根容器在 native 侧创建,是 RN 创建的元素的根结点。由于一个 APP 中可以创建多个根容器,createView 需要确保当前创建的元素被归类到正确的容器中
  • 最后一个参数代表元素的属性

setChildren 接受 2 个参数:

  • 第一个参数与 createView 一致,唯一标识着一个父元素
  • 第二个参数是一个数组,其中包含子元素的标签

当这两个方法通过 bridge 最后进入 Native 侧的 UIManager 时,会根据 IOS 与 Android 的平台特性区分为两套实现,分别是:

  • RCTUIManager.m:IOS 中 UIManager 的实现
  • UIManagerModule.java:Android 中 UIManager 的实现

下面我们分别聊聊这两者都做了些什么

UIManager in IOS

在 IOS 的 createView 实现中,主要做了 3 件事:

  1. 根据 RCTView 这个类型分别创建了一个 shadowView 以及一个离屏的 UIKit UIViewRCTText 类也同理,后不赘述)
  2. 根据 RCTView 这个类型的规则,从元素的属性中筛选了部份 shadowView 需要的属性赋值给 shadowViewprops
  3. 将当前的 shadowView 放进 _shadowViewsWithUpdatedProps 中等待后续消费

其中,shadowView 是 RN 为了方便 yoga 计算布局而设计的类型,而 UIView 是 IOS 正儿八经在屏幕上渲染的元素

两者的区别在于 shadowView 负责接受元素布局相关的属性(如 width, height, border, margin 等),然后交给 yoga 计算布局;UIView 只需要处理布局之外的 backgroundColor, opacity, shadow 等等属性就好

属性的分类依据每个类型不同而不同,比如 RCTView 的定义在 RCTViewManager.m 中

这样做的好处在于可以将计算量较大的布局工作交给另外一个线程防止 IOS 的主线程阻塞


在 IOS 的 setChildren 实现中,主要做了 3 件事:

  1. 将子元素的 shadowView 插入成为父元素 shadowViewsubView
  2. 将子元素插入成为父元素的 subView
  3. 将当前的 shadowView 放进 _shadowViewsWithUpdatedChildren 中等待后续消费

最后,我们在之前讲 runApplication 的调用流程的时候留了一个伏笔:在 JSCExecutor.cpp 中调用 JS 的方法用的是 callFunctionReturnFlushedQueue ,以下是它的实现:

callFunctionReturnFlushedQueue(module: string, method: string, args: any[]) {
    this.__guard(() => {
      // 调用对应的 js 方法
      this.__callFunction(module, method, args);
    });

  // 返回到目前为止积压在 queue 中的 native module 调用请求
    return this.flushedQueue();
  }

可以看到在执行完 js 侧的 runApplication 后,该方法会将执行过程中累积的 native module 调用一下子清空,明确告知 native 侧:我这个方法调用过程中发生的请求已经全部给你了

当 native 侧接受到这个信息之后,它会去轮询所有注册过 batchDidComplete 方法的 native module(UIManager 也是其中一员)并执行他们的 batchDidComplete 方法

UIManagerbatchDidComplete 调用了最重要的一个方法:_layoutAndMount

我们来看看实现:

// in RCTUIManager.m

- (void)_layoutAndMount
{
  // 消费上述 _shadowViewsWithUpdatedProps:把有变化的 props 经过转换后赋值给 yogaNode(后续 yoga 会根据这些节点的属性来计算布局
  [self _dispatchPropsDidChangeEvents];
  // 消费上述 _shadowViewsWithUpdatedChildren:根据不同的 view 类型做不同处理(shadowView 场景的话什么都不做)
  [self _dispatchChildrenDidChangeEvents];

  // 遍历所有的 root container(reactTag)
  for (NSNumber *reactTag in _rootViewTags) {
    // 找到每一个 root container 的 shadowView(也就是 rootShadowView),由于 view 跟 shadowView 是一一对应的关系,所以 rootShadowView 也有可能有多个)
    RCTRootShadowView *rootView = (RCTRootShadowView *)_shadowViewRegistry[reactTag];
    // 触发 yoga 的布局计算,并且把布局结果包装到一个代码片段中返回,返回的代码片段会被加到一个等待队列中等待被主线程执行(因为在 ios 中只有主线程能操纵 UIKit)
    [self addUIBlock:[self uiBlockWithLayoutUpdateForRootView:rootView]];
  }

  // 执行上述的代码片段,将计算好的布局应用给元素
  [self flushUIBlocksWithCompletion:^{}];
}

补充一点,我们说到 uiBlockWithLayoutUpdateForRootView 方法除了计算新的元素布局之外,还会返回一个代码片段,这个代码片段除了在普通情况下将计算好的布局应用给元素之外,还负责判断该元素是否需要动画,如果需要的话,还会将对应的动画效果应用给对应元素

至此,我们完成了对 IOS 中 UIManager 的部份方法与核心机制讲解

UIManager in Android

UIManager 在 Android 中的目标跟在 IOS 中是一致的,主要区别在于加入了一个 NativeViewHierarchyOptimizer 的优化机制

至于加入的原因我们会在后文描述,现在我们先来看看 Android 是如何实现 createView, setChildren, batchDidComplete

在 Android createView 的实现中,RN 也做了三件事:

  1. 根据 RCTView 这个类型创建了一个 shadowView ,并将其保存至 mShadowNodeRegistry(一个用来保存所有 shadowView 的类)
  2. 将元素属性中 shadowView 需要的属性赋值给新创建的 shadowView
  3. 将创建原生 View 元素的任务交给 NativeViewHierarchyOptimizer,它会在符合条件的情况下创建原生元素

NativeViewHierarchyOptimizer 就是 Android 与 IOS 在 UIManager 中最大的区别,它的工作主要就是将元素用是否为布局专用元素进行区分:如果是布局专用元素它将不会创建真正的原生元素;反之则会跟 IOS 一样创建原生元素


setChildren 中,则是:

  1. 将子元素的 shadowView 插入成为父元素 shadowViewmChildren(对应 IOS 中的 subView
  2. 将插入原生子元素的任务交给 NativeViewHierarchyOptimizer,在其中会判断父元素是否为布局专用元素,如果是,则会将子元素插入到最近的不是布局专用元素的父元素上

最后,在 JS 侧所有请求结束后,Android 会执行 dispatchViewUpdates 方法(对应 IOS 中的 _layoutAndMount

// in UIImplementation.java

public void dispatchViewUpdates(int batchId) {
    try {
      // 1. 调用 yoga 计算布局
      // 2. 将布局结果转换成一些对元素的操作并将这些操作入栈等待执行
      // 3. 执行 JS 侧的 onLayout 回调
      updateViewHierarchy();
      // 清理布局过程中使用到的一些标识
      mNativeViewHierarchyOptimizer.onBatchComplete();
      // 将操作一一出栈并应用布局(调用元素的 measure 以及 layout 方法)
      mOperationsQueue.dispatchViewUpdates(batchId, commitStartTime, mLastCalculateLayoutTime);
    }
  }

Android vs IOS

在上文中,我们说到 Android 会比 IOS 多一个 NativeViewHierarchyOptimizer 用来防止为一些布局专用元素创建真正的元素,为什么呢?

首先,什么是布局专用元素?布局专用元素需要同时满足个条件:

  1. 该元素是 RCTView
  2. 该元素的 collapsable 元素是 true(也就是默认值)
  3. 该元素所有属性都是布局专用属性(LAYOUT_ONLY_PROPS),包含:
// in ViewProps.java

private static final HashSet<String> LAYOUT_ONLY_PROPS =
      new HashSet<>(
          Arrays.asList(
              ALIGN_SELF,
              ALIGN_ITEMS,
              COLLAPSABLE,
              FLEX,
              FLEX_BASIS,
              FLEX_DIRECTION,
              FLEX_GROW,
              FLEX_SHRINK,
              FLEX_WRAP,
              JUSTIFY_CONTENT,
              ALIGN_CONTENT,
              DISPLAY,

              /* position */
              POSITION,
              RIGHT,
              TOP,
              BOTTOM,
              LEFT,
              START,
              END,

              /* dimensions */
              WIDTH,
              HEIGHT,
              MIN_WIDTH,
              MAX_WIDTH,
              MIN_HEIGHT,
              MAX_HEIGHT,

              /* margins */
              MARGIN,
              MARGIN_VERTICAL,
              MARGIN_HORIZONTAL,
              MARGIN_LEFT,
              MARGIN_RIGHT,
              MARGIN_TOP,
              MARGIN_BOTTOM,
              MARGIN_START,
              MARGIN_END,

              /* paddings */
              PADDING,
              PADDING_VERTICAL,
              PADDING_HORIZONTAL,
              PADDING_LEFT,
              PADDING_RIGHT,
              PADDING_TOP,
              PADDING_BOTTOM,
              PADDING_START,
              PADDING_END));

在这种情况下,NativeViewHierarchyOptimizer 将不会创建真正的原生元素

为什么要在 Android 中应用这个优化呢?这个我们要从 Android 的 Choreographer 开始说起:

对于非 RN 的 Android app来说,当 app 接受到硬件传来的 vsync 信号之后,他会启动 choreographer 程序:

ChoreographerViewRootImpl.performTraversals()// 开始从程序根节点向下遍历所有元素performMeasure()   // 执行元素 measure 方法performLayout()    // 计算元素布局performDraw()      // 绘制元素

其中 measure 以及 layout 这两步只有当元素本身判断需要(元素调用了 requestLayout )之后才会启动,由于 RN 引入了 yoga 引擎来计算布局(取代了 performMeasure 与 performLayout 的功能),所以 RN 的目标就是让 Android 本身的 performMeasure 以及 performLayout 尽可能少的被启动

所以在 Android 中才需要 NativeViewHierarchyOptimizer 来尽可能减少多余的节点被挂在渲染树上


那么为什么 IOS 不需要呢?

因为 IOS 用的是完全不同的机制,IOS 提供了两种渲染机制:Frame-Based Layout 和 Constraint-Based Layout

RN 选用了 Frame-Based Layout,它的好处就是:系统会直接根据我们计算好的结果来渲染下一帧,不会有多余的操作

一个前端工程师的年度作品:从零开发媲美商业级应用的后台管理系统!

2025年10月14日 16:04

@2x封面.png

过去一年,我花了无数个夜晚,在一次次打磨与推翻中,完成了自己最满意的作品 —— Art Design Pro。

这不是一个普通的后台模板。
它是一场关于 「设计美学」与「工程化开发」 的融合实验——
希望让后台系统不再冰冷枯燥,而是像一件作品:优雅、流畅、有温度。

为什么要做这个项目?

在日常开发中,我几乎体验过所有主流后台模板。
它们的功能确实完整,但更多时候给人的感觉是——「工具」而非「产品」。

我希望做一个后台系统,
让人打开的第一眼就觉得舒服,
用起来像是在和它对话。

许多后台系统普遍存在这些问题 👇

视觉疲劳:灰白配色、生硬布局,难以长时间使用;

体验割裂:逻辑不统一、入口分散,操作效率低;

复用困难:组件风格不一致,二次开发成本高。

于是我决定从零开始,花一年的时间去打造一个真正属于自己的系统——
👉 一款 “既好看、又好用,还能直接用于商业项目” 的后台管理模板。

项目简介:Art Design Pro

Art Design Pro 是一款基于 Vue 3 + TypeScript + Vite + Element Plus 打造的现代化后台管理系统模板。

它的核心理念是:

让后台系统兼具设计美学与开发效率。

这个项目有什么特别的呢?

界面设计:现代化 UI 设计,流畅交互,以用户体验与视觉设计为核心

极速上手:简洁架构 + 完整文档,后端开发者也能轻松使用

丰富组件:内置数据展示、表单等多种高质量组件,满足不同业务场景的需求

丝滑交互:按钮点击、主题切换、页面过渡、图表动画,体验媲美商业产品

高效开发:内置 useTable、ArtForm 等实用 API,显著提升开发效率

精简脚本:内置一键清理脚本,可快速清理演示数据,立即得到可开发的基础项目

技术栈

开发框架:Vue3、TypeScript、Vite、Element-Plus

代码规范:Eslint、Prettier、Stylelint、Husky、Lint-staged、cz-git

预览

主页仪表盘

电子商务仪表盘

卡片

横幅

图表

系统图标库

富文本编辑器

礼花效果

全局搜索

系统设置

表格

暗黑模式


快速访问

GitHub: github.com/Daymychen/a…

演示地址: www.artd.pro

官方文档: www.artd.pro/docs

高效开发

在后台管理系统开发中,表格页面占据了 80% 的工作量。每次都要写分页、搜索、刷新、列配置...这些重复的代码让人头疼。今天分享一套我们团队正在使用的表格开发方案,让你的开发效率提升 10 倍!

痛点分析

在开发后台管理系统时,你是否遇到过这些问题:

  • 每个表格页面都要写一遍分页逻辑
  • 搜索、重置、刷新等功能重复实现
  • 表格列配置、显示隐藏、拖拽排序需要手动处理
  • 数据请求、loading 状态、错误处理代码冗余
  • 缓存策略难以统一管理
  • 移动端适配需要额外处理

如果你的答案是"是",那这篇文章就是为你准备的。

解决方案概览

我们的方案包含以下核心部分:

  • useTable - 强大的表格数据管理 Hook
  • ArtTable - 增强的表格组件
  • ArtTableHeader - 表格工具栏组件
  • ArtSearchBar - 智能搜索栏组件
  • ArtForm - 通用表单组件

一、useTable:表格数据管理的核心

1.1 基础用法

先看一个最简单的例子,只需要几行代码就能实现一个完整的表格:

const {
  data,
  columns,
  columnChecks,
  loading,
  pagination,
  refreshData,
  handleSizeChange,
  handleCurrentChange
} = useTable({
  core: {
    apiFn: fetchGetUserList,
    apiParams: {
      current: 1,
      size: 20
    },
    columnsFactory: () => [
      { prop: 'id', label: 'ID' },
      { prop: 'userName', label: '用户名' },
      { prop: 'userPhone', label: '手机号' }
    ]
  }
})

就这么简单!你已经拥有了:

  • ✅ 自动的数据请求
  • ✅ 分页功能
  • ✅ Loading 状态
  • ✅ 列配置管理

1.2 核心特性

🚀 智能缓存机制

useTable({
  core: {
    /* ... */
  },
  performance: {
    enableCache: true, // 启用缓存
    cacheTime: 5 * 60 * 1000, // 缓存 5 分钟
    debounceTime: 300, // 防抖 300ms
    maxCacheSize: 50 // 最多缓存 50 条
  }
})

缓存带来的好处:

  • 相同参数的请求直接从缓存读取,秒开
  • 减少服务器压力
  • 提升用户体验

🎯 多种刷新策略

不同的业务场景需要不同的刷新策略:

// 新增数据后:回到第一页,清空分页缓存
await refreshCreate()

// 编辑数据后:保持当前页,只清空当前搜索缓存
await refreshUpdate()

// 删除数据后:智能处理页码,避免空页面
await refreshRemove()

// 手动刷新:清空所有缓存
await refreshData()

// 定时刷新:轻量刷新,保持分页状态
await refreshSoft()

这些方法让你的代码更语义化,不用再纠结什么时候该清缓存。

🔄 数据转换器

有时候接口返回的数据需要处理一下才能用:

useTable({
  core: {
    /* ... */
  },
  transform: {
    dataTransformer: (records) => {
      return records.map((item, index) => ({
        ...item,
        // 替换头像
        avatar: localAvatars[index % localAvatars.length].avatar,
        // 格式化日期
        createTime: dayjs(item.createTime).format('YYYY-MM-DD')
      }))
    }
  }
})

📊 生命周期钩子

useTable({
  core: {
    /* ... */
  },
  hooks: {
    onSuccess: (data, response) => {
      console.log('数据加载成功', data)
    },
    onError: (error) => {
      ElMessage.error('加载失败:' + error.message)
    },
    onCacheHit: (data) => {
      console.log('从缓存读取', data)
    }
  }
})

1.3 搜索功能

搜索是表格的核心功能,useTable 提供了完善的搜索支持:

// 定义搜索参数
const searchParams = reactive({
  userName: '',
  userPhone: '',
  status: '1'
})

const { getData, resetSearchParams } = useTable({
  core: {
    apiFn: fetchGetUserList,
    apiParams: searchParams
  }
})

// 搜索
const handleSearch = (params) => {
  Object.assign(searchParams, params)
  getData() // 自动回到第一页
}

// 重置
const handleReset = () => {
  resetSearchParams() // 清空搜索条件并重新加载
}

二、ArtTable:增强的表格组件

2.1 核心特性

ArtTable 基于 Element Plus 的 ElTable 封装,完全兼容原有 API,同时提供了更多增强功能:

<ArtTable
  :loading="loading"
  :data="data"
  :columns="columns"
  :pagination="pagination"
  @selection-change="handleSelectionChange"
  @pagination:size-change="handleSizeChange"
  @pagination:current-change="handleCurrentChange"
/>

✨ 自动高度计算

不用再手动计算表格高度了!ArtTable 会自动计算剩余空间:

<div class="art-full-height">
  <UserSearch />
  <ElCard class="art-table-card">
    <ArtTableHeader />
    <ArtTable /> <!-- 自动占满剩余高度 -->
  </ElCard>

</div>

🎨 列配置灵活

支持多种列类型和自定义渲染:

columnsFactory: () => [
  { type: 'selection' }, // 勾选列
  { type: 'index', width: 60, label: '序号' }, // 序号列
  { type: 'globalIndex' }, // 全局序号(跨页)

  // 自定义渲染
  {
    prop: 'avatar',
    label: '用户',
    formatter: (row) => {
      return h('div', { class: 'user-info' }, [
        h(ElImage, { src: row.avatar }),
        h('span', row.userName)
      ])
    }
  },

  // 使用插槽
  {
    prop: 'status',
    label: '状态',
    useSlot: true // 在模板中使用 #status 插槽
  },

  // 操作列
  {
    prop: 'operation',
    label: '操作',
    fixed: 'right',
    formatter: (row) =>
      h('div', [
        h(ArtButtonTable, {
          type: 'edit',
          onClick: () => handleEdit(row)
        }),
        h(ArtButtonTable, {
          type: 'delete',
          onClick: () => handleDelete(row)
        })
      ])
  }
]

📱 响应式分页

自动适配移动端、平板、桌面端:

// 移动端:prev, pager, next, sizes, jumper, total
// 平板:prev, pager, next, jumper, total
// 桌面端:total, prev, pager, next, sizes, jumper

三、ArtTableHeader:强大的工具栏

3.1 开箱即用的功能

<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
  <template #left>
    <ElButton @click="handleAdd">新增用户</ElButton>

  </template>

</ArtTableHeader>

一行代码,你就拥有了:

  • 🔍 搜索栏切换
  • 🔄 刷新按钮(带加载动画)
  • 📏 表格尺寸切换(大、中、小)
  • 🖥️ 全屏模式
  • 📋 列显示/隐藏配置
  • 🎨 斑马纹、边框、表头背景切换

3.2 列配置功能

用户可以:

  • ✅ 勾选显示/隐藏列
  • ✅ 拖拽调整列顺序
  • ✅ 固定列不可拖动

这些配置会自动同步到表格显示。

3.3 自定义布局

<ArtTableHeader layout="refresh,size,fullscreen,columns" :show-zebra="false" :show-border="false" />

通过 layout 属性控制显示哪些功能按钮。

四、ArtSearchBar:智能搜索栏

4.1 配置化搜索表单

不用再手写一堆 ElFormItem 了,用配置就能搞定:

<template>
  <ArtSearchBar
    ref="searchBarRef"
    v-model="formData"
    :items="formItems"
    :rules="rules"
    @reset="handleReset"
    @search="handleSearch"
  />
</template>

<script setup lang="ts">
  const formData = ref({
    userName: undefined,
    userPhone: undefined,
    status: '1'
  })

  const formItems = computed(() => [
    {
      label: '用户名',
      key: 'userName',
      type: 'input',
      placeholder: '请输入用户名',
      clearable: true
    },
    {
      label: '手机号',
      key: 'userPhone',
      type: 'input',
      props: { placeholder: '请输入手机号', maxlength: '11' }
    },
    {
      label: '状态',
      key: 'status',
      type: 'select',
      props: {
        placeholder: '请选择状态',
        options: [
          { label: '在线', value: '1' },
          { label: '离线', value: '2' }
        ]
      }
    },
    {
      label: '性别',
      key: 'userGender',
      type: 'radiogroup',
      props: {
        options: [
          { label: '男', value: '1' },
          { label: '女', value: '2' }
        ]
      }
    }
  ])

  const handleSearch = async () => {
    await searchBarRef.value.validate()
    emit('search', formData.value)
  }

  const handleReset = () => {
    emit('reset')
  }
</script>

4.2 核心特性

🎯 支持多种表单组件

开箱即用的组件类型:

  • input - 输入框
  • select - 下拉选择
  • date / datetime / daterange - 日期选择
  • radiogroup / checkboxgroup - 单选/多选
  • cascader - 级联选择
  • treeselect - 树选择
  • 自定义组件 - 支持任意 Vue 组件

📦 自动展开/收起

当搜索项过多时,自动显示展开/收起按钮:

<ArtSearchBar :items="formItems" :show-expand="true" :default-expanded="false" :span="6" />
  • 默认只显示一行
  • 超出部分自动隐藏
  • 点击"展开"查看全部搜索项

🔄 动态选项加载

支持异步加载选项数据:

const statusOptions = ref([])

onMounted(async () => {
  // 模拟接口请求
  statusOptions.value = await fetchStatusOptions()
})

const formItems = computed(() => [
  {
    label: '状态',
    key: 'status',
    type: 'select',
    props: {
      options: statusOptions.value // 动态选项
    }
  }
])

🎨 响应式布局

自动适配不同屏幕尺寸:

// 通过 span 控制每行显示的表单项数量
// span=6: 一行显示 4 个(24/6=4)
// span=8: 一行显示 3 个(24/8=3)
// span=12: 一行显示 2 个(24/12=2)

移动端自动调整为单列布局。

4.3 表单校验

支持完整的表单校验:

const rules = {
  userName: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  userPhone: [
    { required: true, message: '请输入手机号', trigger: 'blur' },
    { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
  ]
}

const handleSearch = async () => {
  // 校验通过才执行搜索
  await searchBarRef.value.validate()
  emit('search', formData.value)
}

五、ArtForm:通用表单组件

5.1 配置化表单

ArtForm 和 ArtSearchBar 使用相同的配置方式,但更适合弹窗、详情页等场景:

<template>
  <ArtForm
    ref="formRef"
    v-model="formData"
    :items="formItems"
    :rules="formRules"
    :label-width="100"
    :span="12"
    @reset="handleReset"
    @submit="handleSubmit"
  />
</template>

<script setup lang="ts">
  const formData = ref({
    userName: '',
    userPhone: '',
    userEmail: '',
    userGender: '1',
    status: true
  })

  const formItems = [
    {
      label: '用户名',
      key: 'userName',
      type: 'input',
      placeholder: '请输入用户名'
    },
    {
      label: '手机号',
      key: 'userPhone',
      type: 'input',
      props: { maxlength: 11 }
    },
    {
      label: '邮箱',
      key: 'userEmail',
      type: 'input',
      placeholder: '请输入邮箱'
    },
    {
      label: '性别',
      key: 'userGender',
      type: 'radiogroup',
      props: {
        options: [
          { label: '男', value: '1' },
          { label: '女', value: '2' }
        ]
      }
    },
    {
      label: '是否启用',
      key: 'status',
      type: 'switch'
    },
    {
      label: '备注',
      key: 'remark',
      type: 'input',
      span: 24, // 占满整行
      props: {
        type: 'textarea',
        rows: 4
      }
    }
  ]

  const formRules = {
    userName: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
    userEmail: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }]
  }

  const handleSubmit = async () => {
    await formRef.value.validate()
    // 提交表单
    console.log('表单数据:', formData.value)
  }
</script>

5.2 高级特性

🎨 自定义组件渲染

支持使用 h 函数渲染任意组件:

import ArtIconSelector from '@/components/core/base/art-icon-selector/index.vue'

const formItems = [
  {
    label: '图标选择',
    key: 'icon',
    type: () =>
      h(ArtIconSelector, {
        iconType: IconTypeEnum.UNICODE,
        width: '100%'
      })
  },
  {
    label: '文件上传',
    key: 'files',
    type: () =>
      h(
        ElUpload,
        {
          action: '#',
          multiple: true,
          limit: 5,
          onChange: (file, fileList) => {
            formData.value.files = fileList
          }
        },
        {
          default: () => h(ElButton, { type: 'primary' }, () => '点击上传')
        }
      )
  }
]

🔌 插槽支持

支持为表单项添加插槽:

<ArtForm v-model="formData" :items="formItems">
  <template #customField>
    <div class="custom-content">
      <!-- 自定义内容 -->
    </div>

  </template>

</ArtForm>

或者在配置中使用插槽:

const formItems = [
  {
    label: '网站',
    key: 'website',
    type: 'input',
    slots: {
      prepend: () => h('span', 'https://'),
      append: () => h('span', '.com')
    }
  }
]

🎯 条件显示

根据条件动态显示/隐藏表单项:

const formItems = computed(() => [
  {
    label: '用户类型',
    key: 'userType',
    type: 'select',
    props: {
      options: [
        { label: '个人', value: 'personal' },
        { label: '企业', value: 'enterprise' }
      ]
    }
  },
  {
    label: '企业名称',
    key: 'companyName',
    type: 'input',
    // 只有选择企业类型时才显示
    hidden: formData.value.userType !== 'enterprise'
  }
])

📏 灵活布局

通过 span 控制表单项宽度:

const formItems = [
  {
    label: '用户名',
    key: 'userName',
    type: 'input',
    span: 12 // 占半行
  },
  {
    label: '手机号',
    key: 'userPhone',
    type: 'input',
    span: 12 // 占半行
  },
  {
    label: '地址',
    key: 'address',
    type: 'input',
    span: 24 // 占整行
  }
]

5.3 与 ArtSearchBar 的区别

特性 ArtSearchBar ArtForm
使用场景 表格搜索 表单提交、弹窗
展开/收起 ✅ 支持 ❌ 不支持
按钮文案 搜索/重置 提交/重置
样式 卡片样式 无背景
默认布局 横向排列 横向排列

六、完整示例

让我们看一个完整的用户管理页面:

<template>
  <div class="user-page art-full-height">
    <!-- 搜索栏 -->
    <ArtSearchBar
      v-model="searchForm"
      :items="searchItems"
      @search="handleSearch"
      @reset="resetSearchParams"
    />

    <ElCard class="art-table-card" shadow="never">
      <!-- 工具栏 -->
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
        <template #left>
          <ElButton @click="handleAdd">新增用户</ElButton>

        </template>

      </ArtTableHeader>

      <!-- 表格 -->
      <ArtTable
        :loading="loading"
        :data="data"
        :columns="columns"
        :pagination="pagination"
        @selection-change="handleSelectionChange"
        @pagination:size-change="handleSizeChange"
        @pagination:current-change="handleCurrentChange"
      />
    </ElCard>

    <!-- 弹窗 -->
    <UserDialog
      v-model:visible="dialogVisible"
      :type="dialogType"
      :user-data="currentUserData"
      @submit="handleDialogSubmit"
    />
  </div>

</template>

<script setup lang="ts">
  import { useTable } from '@/composables/useTable'
  import { fetchGetUserList } from '@/api/system-manage'
  import UserDialog from './modules/user-dialog.vue'

  // 搜索表单
  const searchForm = ref({
    userName: undefined,
    userPhone: undefined,
    status: '1'
  })

  // 搜索项配置
  const searchItems = [
    {
      label: '用户名',
      key: 'userName',
      type: 'input',
      placeholder: '请输入用户名',
      clearable: true
    },
    {
      label: '手机号',
      key: 'userPhone',
      type: 'input',
      props: { placeholder: '请输入手机号', maxlength: '11' }
    },
    {
      label: '状态',
      key: 'status',
      type: 'select',
      props: {
        placeholder: '请选择状态',
        options: [
          { label: '在线', value: '1' },
          { label: '离线', value: '2' },
          { label: '异常', value: '3' }
        ]
      }
    }
  ]

  // 表格配置
  const {
    data,
    columns,
    columnChecks,
    loading,
    pagination,
    getData,
    searchParams,
    resetSearchParams,
    handleSizeChange,
    handleCurrentChange,
    refreshData
  } = useTable({
    core: {
      apiFn: fetchGetUserList,
      apiParams: {
        current: 1,
        size: 20,
        ...searchForm.value
      },
      columnsFactory: () => [
        { type: 'selection' },
        { type: 'index', width: 60, label: '序号' },
        {
          prop: 'avatar',
          label: '用户名',
          width: 280,
          formatter: (row) => {
            return h('div', { class: 'user-info' }, [
              h(ElImage, { src: row.avatar }),
              h('div', [h('p', row.userName), h('p', { class: 'email' }, row.userEmail)])
            ])
          }
        },
        { prop: 'userGender', label: '性别' },
        { prop: 'userPhone', label: '手机号' },
        {
          prop: 'status',
          label: '状态',
          formatter: (row) => {
            const config = getStatusConfig(row.status)
            return h(ElTag, { type: config.type }, () => config.text)
          }
        },
        {
          prop: 'operation',
          label: '操作',
          width: 120,
          fixed: 'right',
          formatter: (row) =>
            h('div', [
              h(ArtButtonTable, {
                type: 'edit',
                onClick: () => handleEdit(row)
              }),
              h(ArtButtonTable, {
                type: 'delete',
                onClick: () => handleDelete(row)
              })
            ])
        }
      ]
    }
  })

  // 搜索处理
  const handleSearch = (params) => {
    Object.assign(searchParams, params)
    getData()
  }

  // 新增
  const handleAdd = () => {
    dialogType.value = 'add'
    dialogVisible.value = true
  }

  // 编辑
  const handleEdit = (row) => {
    dialogType.value = 'edit'
    currentUserData.value = row
    dialogVisible.value = true
  }

  // 删除
  const handleDelete = (row) => {
    ElMessageBox.confirm('确定要删除该用户吗?', '提示', {
      type: 'warning'
    }).then(() => {
      // 调用删除接口
      // await deleteUser(row.id)
      ElMessage.success('删除成功')
      refreshData()
    })
  }
</script>

七、性能优化

7.1 智能防抖

搜索时自动防抖,避免频繁请求:

const { getDataDebounced } = useTable({
  performance: {
    debounceTime: 300 // 300ms 防抖
  }
})

// 使用防抖搜索
const handleSearch = () => {
  getDataDebounced()
}

7.2 请求取消

切换页面或快速切换搜索条件时,自动取消上一个请求:

// useTable 内部实现
let abortController = new AbortController()

const fetchData = async () => {
  // 取消上一个请求
  if (abortController) {
    abortController.abort()
  }

  // 创建新的控制器
  abortController = new AbortController()

  // 发起请求
  await apiFn(params)
}

7.3 缓存统计

实时查看缓存状态:

const { cacheInfo } = useTable({
  performance: { enableCache: true }
})

console.log(cacheInfo.value)
// { total: 10, size: '45KB', hitRate: '8 avg hits' }

八、最佳实践

8.1 目录结构

views/
  system/
    user/
      index.vue           # 主页面
      modules/
        user-search.vue   # 搜索组件
        user-dialog.vue   # 弹窗组件

8.2 搜索组件封装

使用 ArtSearchBar 封装搜索组件:

<!-- user-search.vue -->
<template>
  <ArtSearchBar
    ref="searchBarRef"
    v-model="formData"
    :items="formItems"
    @reset="handleReset"
    @search="handleSearch"
  />
</template>

<script setup lang="ts">
  const formData = defineModel({ required: true })
  const emit = defineEmits(['search', 'reset'])

  const formItems = [
    {
      label: '用户名',
      key: 'userName',
      type: 'input',
      placeholder: '请输入用户名',
      clearable: true
    },
    {
      label: '手机号',
      key: 'userPhone',
      type: 'input',
      props: { placeholder: '请输入手机号', maxlength: '11' }
    }
  ]

  const handleSearch = () => {
    emit('search', formData.value)
  }

  const handleReset = () => {
    emit('reset')
  }
</script>

8.3 类型安全

充分利用 TypeScript 的类型推导:

// API 类型定义
declare namespace Api.SystemManage {
  interface UserListItem {
    id: number
    userName: string
    userPhone: string
    userEmail: string
    status: string
    avatar: string
  }

  interface UserSearchParams {
    userName?: string
    userPhone?: string
    status?: string
  }
}

// useTable 会自动推导类型
const { data } = useTable({
  core: {
    apiFn: fetchGetUserList // 返回 Promise<UserListItem[]>
    // data 的类型自动推导为 UserListItem[]
  }
})

九、对比传统方案

传统方案(约 200 行代码)

// 需要手动管理的状态
const loading = ref(false)
const data = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const searchForm = ref({})

// 需要手动实现的方法
const fetchData = async () => {
  loading.value = true
  try {
    const res = await api.getUserList({
      current: currentPage.value,
      size: pageSize.value,
      ...searchForm.value
    })
    data.value = res.records
    total.value = res.total
  } catch (error) {
    console.error(error)
  } finally {
    loading.value = false
  }
}

const handleSizeChange = (val) => {
  pageSize.value = val
  currentPage.value = 1
  fetchData()
}

const handleCurrentChange = (val) => {
  currentPage.value = val
  fetchData()
}

const handleSearch = () => {
  currentPage.value = 1
  fetchData()
}

const handleReset = () => {
  searchForm.value = {}
  currentPage.value = 1
  fetchData()
}

// ... 还有更多代码

使用 useTable(约 30 行代码)

const {
  data,
  columns,
  loading,
  pagination,
  searchParams,
  getData,
  resetSearchParams,
  handleSizeChange,
  handleCurrentChange
} = useTable({
  core: {
    apiFn: fetchGetUserList,
    apiParams: { current: 1, size: 20 },
    columnsFactory: () => [
      /* 列配置 */
    ]
  }
})

const handleSearch = (params) => {
  Object.assign(searchParams, params)
  getData()
}

代码量减少 85%,功能更强大!

十、总结

这套表格开发方案的核心优势:

  1. 开发效率提升 10 倍 - 从 200 行代码减少到 30 行
  2. 功能更强大 - 缓存、防抖、多种刷新策略、列配置等
  3. 类型安全 - 完整的 TypeScript 支持
  4. 易于维护 - 统一的代码风格和最佳实践
  5. 用户体验好 - 响应式设计、智能缓存、流畅交互

如果你也在开发后台管理系统,强烈建议尝试这套方案。它不仅能让你的代码更简洁,还能让你有更多时间专注于业务逻辑,而不是重复造轮子。

如果这篇文章对你有帮助,欢迎点赞收藏!有任何问题也欢迎在评论区讨论 💬

FlowGram 官网建设

作者 FlowGramAI
2025年10月14日 14:42

FlowGram 官网文档建设详细代码见:GitHub.com/bytedance/f…

1. 背景

随着使用的用户量增多,FlowGram 需要有一个面向使用者的更详细的教程指引,辅助入门,降低 SDK oncall 成本。 因此在开源的初期即计划基于 SSG 做一套全新的官网体系。

术语 解释
SSG SSG 是 Static Site Generation(静态站点生成)的简称,是一种在构建网站时的预渲染技术。SSG 通过在构建时将网站内容生成静态 HTML 文件,而不是动态地通过服务器请求实时生成内容。这种方式通常用于静态网站、博客、文档站点和营销页面等。SSG 优点:无运行时代码:因为没有动态后端逻辑,减少了常见的服务器攻击面(如 SQL 注入、服务器端脚本漏洞等)。可使用 CDN 部署:静态文件托管于 CDN 中,进一步提升安全性并减少复杂配置。静态文件可托管于任何支持 HTTP 的平台(如 GitHub Pages、Vercel、Netlify 等),部署过程简单、快速。良好的 SEO:因为 HTML 是在构建时生成的,搜索引擎能够直接抓取完整的页面内容,而无需额外的客户端渲染或动态生成。 对于经常变更的动态数据和超大型站点,SSG 静态生成模型不够高效。

2. 需求目标

2.1. 需求目标

  1. 【业务目标】减少用户使用成本和团队的沟通成本,有一个美观的官方站点也有利于开源项目的宣传。
  2. 【技术目标】搭建 Demo Playground 演示,用户可以直接在官网上配置入参调试。
优先级 需求点 详细描述 备注
P0 Demo 功能拆分 水平布局 / 垂直布局画布引擎 / 节点引擎 / 变量引擎ShortcutsHistoryreduxDevTool第三方插件 plugins生命周期其他细节的 props 解释。
P0 Changelog、BreakingChange 展示
P0 Playground 代码实时编辑渲染 类似 semi 官网,做一个编辑代码实时更新渲染的功能,方便用户直接在网站上编辑 props,更直观教导用户。
P0 API 自动生成 基于已有代码的 TS 定义,自动生成文档内容 一方面节约官网内容维护人力成本另一方面可以通过这种方式规范代码的类型定义。(ts 即 doc)
P1 国际化 官网支持中英文 官网针对组件的使用描述,方便后续文案维护。

3. 准备工作

考虑到官网需要有一个专属的图标,这里通过 coze 搭建了一个 imageflow 来生成图标:

prompt:

2D 图,工作流,固定布局,自由布局,简洁,flowgramai,不要展示任何文字

将生成的图标挑选,进行背景色抠图处理后生成了我们现在的官网图标。

4. 技术选型

4.1. 【P0】官网建设框架

初步是打算使用一个 SSG 框架,这样 SEO 和

Storybook Docz Nextra Gatsby Docusaurus Rspress
性能 ⭐️ ⭐️⭐️ ⭐️ ⭐️⭐️ ⭐️⭐️⭐️ ⭐️⭐️⭐️⭐️⭐️
上手成本 ⭐️⭐️ ⭐️⭐️ ⭐️⭐️ ⭐️⭐️⭐️ ⭐️⭐️⭐️ ⭐️
社区完备度 ⭐️⭐️⭐️⭐️⭐️ ⭐️⭐️⭐️ ⭐️⭐️⭐️⭐️⭐️ ⭐️⭐️⭐️⭐️⭐️ ⭐️⭐️⭐️⭐️⭐️ ⭐️⭐️⭐️

结论:使用 Rspress 框架 原因:高性能、开箱即用、低上手成本、整体默认 UI 风格更字节 like

下面是几个比较典型的官网建设的框架

Storybook

storybook.js.org/

添加图片注释,不超过 140 字(可选)

最常用的前端组件库框架,storybook 自带静态站点生成的能力:build-storybook 优点是社区建设比较完整;缺点是不适合作为官网的首页、本地运行的时候即便组件数量比较少的场景也会卡顿。

Docz

www.docz.site/

Demo: docz-theme-ztopia.surge.sh/docs/design Docz 交互体验风格感觉略老,类 storybook 的交互。

Nextra

nextra.site/docs 作为一个静态站点,启用 SSR 有一些服务资源浪费。

同时,nextra 的 SSG 构建速度比较缓慢。

Gatsby

GitHub.com/gatsbyjs/ga… SemiDesign 官网使用的就是 Gatsby

使用 Gatsby 体验下来,开箱即用的功能很好用: npm init gatsby 一条命令即可执行。

但是与此同时,gatsby 的插件化配置会比较多。 默认只有内置一个 mdx 的解析

mdx 内容预览

Gatsby 刚配置好,站点内容是这样的: header、doc 悬浮窗等功能都需要额外的插件配置,具有一定的上手成本。

Docusaurus

Facebook 公司的 SSG 框架 GitHub.com/facebook/do…

Docusaurus 类似 Gatsby,也支持 cli 一键安装生成: npm init docusaurus@latest my-website classic 配置好后直接有官网首页、有 doc tutorial 页面,也兼备样式

Rspress

rspress.dev/zh/guide/ba…

只支持 markdown 和组件的绑定,不支持实时热更新。 但是支持配置 mdxRs 。其能力底层基于 Rspress 自研的 @rspress/mdx-rs 库来实现,性能比 JS 版本的 MDX 编译器提升 10 倍以上,但不支持 JS 的插件。 Rspress 也是一键生成:npm create rspress@latest 跑起来后,ui 比较简单:

构建产物也是纯静态 HTML。 Rspress 相比较于另外两个热门的框架 Nextra、Docusaurus,性能有比较大的飞跃

Rspress vs Docusaurus

最终在 Rspress 和 Docusaurus 两个技术栈中进行 pk

Rspress Docusaurus
搜索 Rspress 中提供开箱即用的全文搜索能力,你无需任何配置即可接入,底层基于开源的 FlexSearch 引擎实现。 p.s. 由于是构建时生成搜索索引,本地文件变更后需要重启项目才能检索到。 Docusaurus 需要手动添加 algolia 自动搜索配置,github.com/facebook/do…
MDX 支持 支持
本地编译、热更新速度(使用脚手架初始化的简单 demo) 启动 200ms+热更新 50ms Rspress 性能更佳 启动 1s +热更新 200ms +
插件系统 基于 Vite 的生态插件生态仍在发展中适合功能较简单的文档站点 提供详细生命周期 hook,灵活度高社区活跃,插件开发文档完善。适合大型文档站点
国际化 支持 支持
Documate 主题支持 支持 支持

最终决定使用性能体验更佳、上手成本更低的 Rspress。

4.2. 代码预览

Rspress playground-plugin

Rspress 天然支持 playground 预览:@rspress/plugin-playground 支持代码实时变更。

优势

  • 官方插件,和 rspress 配合天然好。
  • 支持 md 文件内直接写代码块,自动生成可运行预览。
  • 配置简单,开箱即用。

劣势

  • 功能较基础,灵活性不如 Sandpack 或 react-live。
  • 只适合简单示例,不适合大型 demo 或复杂交互。

Playground 插件有如下变量:

interface PlaygroundOptions {
    render: string;  // 文件路径字符串,playground 会使用该路径下的组件代码渲染,因此 playground 插件可以集成其他代码预览 sdk
    include: Array<string | [string, string]>; // jsx 内包含的组件 import 需要
    defaultDirection: 'horizontal' | 'vertical';
    editorPosition: 'left' | 'right';
    babelUrl: string;
    monacoLoader: Parameters<typeof loader.config>[0];
    monacoOptions: EditorProps['options'];
    /**
     * determine how to handle a internal code block without meta
     * @default 'playground'
     */
    defaultRenderMode?: 'pure' | 'playground';

也可以支持 monaco editor 自定义。但是不支持多代码文件的回显,因此仅作为备用回显手段注册。

@codesandbox/sandpack-react

优势

  • 非常强大,支持完整的 React/JS 运行环境。
  • 可以在线编辑、实时运行,甚至模拟多文件结构。
  • 与 CodeSandbox 官方生态接轨,功能最完整。

劣势

  • 体积大,引入 bundle 会增多。
  • 配置比 @rspress/plugin-playground 稍复杂。

相比较之下适合作为官网的预览插件。

react-live

优势

  • 轻量,支持在网页中直接写 JSX 并实时渲染。
  • 对简单代码 demo 很方便,灵活度高。

劣势

  • 只能处理单文件 JSX,不能像 Sandpack 那样处理多文件项目。

  • 对复杂依赖不太友好,需要自己提供 scope(React、组件等依赖)。

因此最终我们选择了 @codesandbox/sandpack-react + @rspress/plugin-playground 的组合构建官网代码预览部分。

5. API 生成

Rspress 社区插件总览:Rspress 代码自动生成这块目前暂时未做技术比对,目前社区使用 TypeDoc 是最方便的解决方案

5.1. 根据 TypeScript 自动生成 API Doc

rspress.dev/zh/plugin/o…

Rspress 集成 TypeDoc 插件,可以自动生成 TS 模块的 API 文档:

import { defineConfig } from 'rspress/config';
import { pluginTypeDoc } from '@rspress/plugin-typedoc';
import path from 'path';

export default defineConfig({
  plugins: [
    pluginTypeDoc({
      entryPoints: [
        path.join(__dirname, 'src', 'foo.ts'),
        path.join(__dirname, 'src', 'bar.ts'),
      ],
    }),
  ],
});

当启动项目的时候,插件会在文档根目录下自动生成 API 目录,结构如下:

api
├── README.md
├── documentation.json
├── functions
│   ├── bar.multi.md
│   └── foo.add.md
├── interfaces
│   ├── foo.RunTestsOptions.md
│   └── foo.TestMessage.md
└── modules
    ├── bar.md
    └── foo.md

但是默认的 Rspress 的 Typedoc 插件默认是根据模块、方法等汇总划分的,对用户来说可读性不高。因此这里我们自行实现了一套逻辑,从包维度来划分 API 展示。

具体源码:github.com/bytedance/f…

6. 站点部署

这里前后经历了几次方案的变更。

  • 最开始的时候我们使用了 GitHub Pages 的功能来实现官网部署

docs.GitHub.com/en/pages/se…

使用 actions/upload-pages-artifact@v3 + actions/deploy-pages@v2 可以很方便的实现静态产物上传 + 站点部署的功能。但是在此期间我们发现:GitHub Pages 官方的 Fastly CDN 比较基础,如果是国内用户直接访问站点,速度会比较慢。因此后来我们将站点部署到了 Vercel 上。Vercel 的全球边缘 CDN 更加优秀,国内用户在移动端用流量也能有比较好的访问体验。

但是在升级完 rspress 版本,并且开启了 rpsress llmstxt 插件的时候,我们发现构建经常会超时失败。这是由于我们解析了整个站点的源码,并且使用了 typedoc 生成了 API 文档,编译内存要求较高,而 Vercel 上支持的编译内存最大的内存只有 16G,所以会偶发报错。

vercel 编译报错超时

因此这里做了一个优化,在 GitHub 上进行编译,然后将编译产物上传到指定分支,最后通过指定分支 gh-pages 的静态资源部署触发官网的更新:

GitHub.com/bytedance/f…

之所以没有使用 Vercel CLI,是因为 CLI 有上传文件数量限制(15000),而我们的站点在 TypeDoc 生成 API 后,文件数量达到 28000+

❌
❌