普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月27日掘金 前端

【Vue3 高级技巧】函数重载+Watch:打造类型安全的通用事件监听 Hook

2025年12月27日 16:05

【Vue3 高级技巧】函数重载+Watch:打造类型安全的通用事件监听 Hook

📖 引言

在 Vue3 项目开发中,事件监听是一项非常基础但频繁使用的功能。我们经常需要为 DOM 元素或 window 对象绑定各类事件,如点击、滚动、键盘输入等。虽然原生 API 使用起来并不复杂,但在组件化开发中,手动管理事件的绑定与解绑不仅繁琐,还容易导致内存泄漏。

今天,我们将探索如何利用 Vue3 的watchAPI 和 TypeScript 的函数重载特性,打造一个类型安全、自动清理、使用便捷的通用事件监听 Hook,彻底解决事件管理的痛点。

🎯 问题剖析:原生事件绑定的痛点

先来看一段我们在 Vue 组件中经常写的事件绑定代码:

<template>
  <div ref="divRef"></div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const divRef = ref();
onMounted(() => {
  divRef.value.addEventListener("click", (e) => {
    console.log(e);
  });
});
onUnmounted(() => {
  divRef.value.removeEventListener("click");
});
</script>

这段代码看似简单,但存在以下几个问题:

  1. 代码重复:每个需要事件绑定的组件都要写类似的onMountedonUnmounted逻辑
  2. 手动管理:必须手动调用removeEventListener,容易遗漏导致内存泄漏
  3. 缺乏灵活性:无法很好地处理动态渲染的 DOM 元素(如 v-if 控制的元素)
  4. 类型不安全:事件处理函数中的事件对象缺乏类型提示

💡 解决方案:封装通用事件监听 Hook

针对上述问题,我们可以封装一个通用的事件监听 Hook——useEventListener,利用 Vue3 的watchAPI 来自动管理事件的生命周期。

核心实现思路

  1. 自动清理机制:利用watchonClear回调实现事件的自动解绑
  2. 动态目标支持:同时支持 window 对象和 DOM 元素作为事件目标
  3. 响应式处理:通过watch监听目标元素的变化,支持动态 DOM
  4. 类型安全:使用 TypeScript 的函数重载提供完整的类型提示

基础版本实现

import { watch, unref } from "vue";

export function useEventListener(...args) {
  // 判断目标:如果第一个参数是字符串,则目标为window;否则为传入的DOM元素
  const target = typeof args[0] === "string" ? window : args.shift();

  // 使用watch监听目标元素的变化
  return watch(
    () => unref(target),
    (element, _, onClear) => {
      // 处理DOM不存在的情况(如v-if初始为false)
      if (!element) return;

      // 绑定事件
      element.addEventListener(...args);

      // 清理函数:在组件卸载或watch停止时执行
      onClear(() => {
        element.removeEventListener(...args);
      });
    },
    {
      immediate: true, // 立即执行
    }
  );
}

用法示例

封装完成后,我们可以通过两种方式使用这个 Hook:

// 1. 给window绑定事件
useEventListener("click", () => console.log("Window clicked!"), options);

// 2. 给指定DOM元素绑定事件
useEventListener(domRef, "click", () => console.log("DOM clicked!"), options);

如果需要手动结束事件监听,可以调用返回的stop方法:

const handle = useEventListener(domRef, "click", () => {});
// 手动终止监听
handle.stop();

🚀 进阶优化:函数重载实现类型安全

基础版本虽然功能完整,但在 TypeScript 环境下使用时缺乏类型提示,这会影响开发体验。为了解决这个问题,我们可以利用 TypeScript 的函数重载特性。

函数重载的定义

函数重载允许我们为同一个函数提供多个类型定义,TypeScript 会根据传入的参数类型自动选择匹配的重载版本。

类型安全版本实现

import { watch, unref, Ref } from "vue";

// 重载1:给window绑定事件
export function useEventListener<K extends keyof WindowEventMap>(
  type: K,
  handle: (event: WindowEventMap[K]) => void,
  options?: boolean | AddEventListenerOptions
);

// 重载2:给指定DOM元素绑定事件
export function useEventListener<K extends keyof HTMLElementEventMap>(
  target: Ref<HTMLElement | null>,
  type: K,
  handle: (event: HTMLElementEventMap[K]) => void,
  options?: boolean | AddEventListenerOptions
);

// 通用实现
export function useEventListener(...args: any[]) {
  // 判断目标:如果第一个参数是字符串,则目标为window;否则为传入的DOM元素
  const target = typeof args[0] === "string" ? window : args.shift();

  // 使用watch监听目标元素的变化
  return watch(
    () => unref(target),
    (element, _, onClear) => {
      // 处理DOM不存在的情况(如v-if初始为false)
      if (!element) return;

      // 绑定事件
      element.addEventListener(...args);

      // 清理函数:在组件卸载或watch停止时执行
      onClear(() => {
        element.removeEventListener(...args);
      });
    },
    {
      immediate: true, // 立即执行
    }
  );
}

类型重载的优势

  1. 智能提示:IDE 会根据传入的参数类型提供对应的事件名称和事件对象类型提示
  2. 类型检查:TypeScript 会检查事件处理函数的参数类型是否正确
  3. 错误预防:避免传入不存在的事件类型或错误的事件处理函数签名

🎯 技术深度解析

1. Watch API 的高级用法

在这个 Hook 中,我们充分利用了 Vue3 watch API 的高级特性:

  • 响应式监听:通过unref(target)确保可以同时处理 ref 和普通值
  • immediate 选项:确保组件挂载后立即绑定事件
  • onClear 回调:提供了可靠的清理机制,避免内存泄漏

2. TypeScript 类型系统的强大

  • 事件映射类型WindowEventMapHTMLElementEventMap提供了浏览器原生事件的完整类型定义
  • 泛型约束:使用K extends keyof EventMap确保事件类型的正确性
  • 函数重载:为不同的使用场景提供精确的类型定义

3. 自动清理机制的原理

当以下情况发生时,onClear回调会被自动调用:

  • 组件卸载时
  • 调用返回的stop方法时
  • 监听的目标元素发生变化时

这种机制确保了事件监听始终与组件生命周期同步,彻底避免了内存泄漏。

📝 最佳实践与注意事项

1. 事件处理函数的注意事项

  • 避免箭头函数陷阱:如果需要在事件处理函数中访问this,应使用普通函数
  • 事件对象的正确使用:利用 TypeScript 的类型系统确保事件对象的属性访问安全

2. 性能优化建议

  • 事件委托:对于大量相似元素,优先考虑事件委托而不是为每个元素单独绑定事件
  • 合理使用事件选项:根据需要设置passivecapture等选项优化性能

3. 扩展使用场景

  • 自定义事件:可以扩展支持自定义事件的类型定义
  • 组件事件:结合 Vue 的组件事件系统使用
  • 第三方库集成:与 Chart.js、Mapbox 等第三方库的事件系统集成

🔧 实战案例:实时键盘监听

让我们通过一个实际案例来展示useEventListener的强大功能:

<template>
  <div>
    <h2>键盘监听演示</h2>
    <p>当前按下的键:{{ pressedKey }}</p>
    <p>按下次数:{{ pressCount }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import { useEventListener } from "./useEventListener";

const pressedKey = ref("");
const pressCount = ref(0);

// 使用通用事件监听Hook
useEventListener(
  "keydown",
  (event: KeyboardEvent) => {
    pressedKey.value = event.key;
    pressCount.value++;
  },
  { passive: true }
);
</script>

这个示例展示了如何轻松实现一个实时键盘监听功能,无需手动管理事件的绑定与解绑。

📚 扩展阅读

  1. Vue3 Composition API - Watch
  2. TypeScript 函数重载
  3. DOM 事件 API
  4. 前端内存泄漏排查与解决

💭 思考题

  1. 如何扩展这个 Hook 以支持自定义事件类型?
  2. 如果需要同时监听多个事件,应该如何优化实现?
  3. 如何将这个 Hook 与 Vue 的响应式系统更好地结合?

🎉 总结

通过本文的介绍,我们学习了如何利用 Vue3 的watchAPI 和 TypeScript 的函数重载特性,打造一个类型安全、自动清理的通用事件监听 Hook。这个 Hook 不仅解决了原生事件绑定的痛点,还提供了良好的开发体验和类型支持。

核心技术点回顾:

  • 函数重载:提供精确的类型定义和智能提示
  • Watch API:实现响应式监听和自动清理
  • 自动管理:事件生命周期与组件同步,避免内存泄漏
  • 灵活使用:支持 window 和 DOM 元素,适应各种场景

这个简单而强大的 Hook 展示了 Vue3 Composition API 的灵活性和 TypeScript 类型系统的强大,是我们在日常开发中值得掌握的高级技巧。

取件伙伴性能提升——长列表

作者 云_杰
2025年12月27日 15:53

取件伙伴性能提升——长列表

在移动应用开发中,List是最常见也是最容易出现性能瓶颈的场景之一。在 取件伙伴 项目中,取件列表页面需要展示可能多达数百条的包裹信息。如果不进行优化,随着数据量的增长,应用会出现滑动掉帧、内存占用过高甚至崩溃的问题,特别是最近我增加了在深色模式下的雪花效果,列表更是卡的不行!

本文将详细介绍我们如何利用 性能优化 "三剑客" —— LazyForEach@ReusablecachedCount,将列表渲染性能提升至极致。


核心问题分析

在早期的开发中,如果直接使用 ForEach 渲染列表:

// ❌ 性能较差的写法
List() {
  ForEach(this.packages, (item) => {
    PackageCard({ packageInfo: item })
  })
}

这种方式存在两个主要缺陷:

  1. 全量加载:无论列表有多长,ForEach 都会一次性创建所有的数据对象和组件节点。如果有 1000 个包裹,就会瞬间创建 1000 个 PackageCard,导致内存激增。
  2. 频繁销毁与创建:当用户滑动列表时,移出屏幕的组件会被销毁,新进入屏幕的组件需要重新创建、布局和渲染。对于包含图片和复杂布局的卡片,这种开销是巨大的,直接导致滑动卡顿。

解决方案:性能优化 "三剑客"

1. LazyForEach:按需加载

LazyForEach 是专门为长列表设计的渲染控制语法。与 ForEach 不同,它只渲染屏幕可见区域的组件,并配合数据源(IDataSource)实现按需加载。

实现步骤:

首先,我们需要实现一个 IDataSource 接口的数据源类:

entry/src/main/ets/utils/BasicDataSource.ets

// 通用数据源基类,实现了 IDataSource 接口
export class BasicDataSource<T> implements IDataSource {
  private listeners: DataChangeListener[] = [];
  private originDataArray: T[] = [];

  // 获取数据的总条数
  public totalCount(): number {
    return this.originDataArray.length;
  }

  // 获取指定索引的数据
  public getData(index: number): T {
    return this.originDataArray[index];
  }

  // 注册/注销监听器(框架调用)
  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) {
      this.listeners.splice(pos, 1);
    }
  }

  // === 通知 LazyForEach 刷新 ===
  notifyDataReload(): void {
    this.listeners.forEach(listener => listener.onDataReloaded());
  }
  
  public setData(data: T[]) {
    this.originDataArray = data;
    this.notifyDataReload();
  }
}

2. @Reusable:组件复用

这是解决“滑动卡顿”的关键。通过 @Reusable 装饰器,我们可以让组件具备“复用”能力。当一个列表项滑出屏幕时,它的组件实例不会被销毁,而是被放入缓存池;当新数据滑入屏幕时,直接从缓存池取出实例并更新数据,跳过了昂贵的组件创建和布局计算过程

entry/src/main/ets/components/PackageCard.ets

@Component
@Reusable // <--- 1. 标记为可复用组件
export struct PackageCard {
  @State packageInfo: PackageInfo | undefined = undefined;
  
  /**
   * 2. 复用生命周期回调
   * 当组件被复用时触发。在此处更新状态变量,驱动 UI 刷新。
   * 
   * @param params 上层传入的新参数
   */
  aboutToReuse(params: Record<string, Object>) {
    // 快速更新数据
    this.packageInfo = params.packageInfo as PackageInfo;
    
    // 更新其他状态
    if (params.compactModeEnabled !== undefined) {
      this.compactModeEnabled = params.compactModeEnabled as boolean;
    }
    // ...
  }

  build() {
    // 构建复杂的卡片布局...
    // 复用时,这里的节点结构保持不变,仅数据发生变化
  }
}

3. cachedCount:预加载

LazyForEach 默认只加载屏幕可见的项。为了让滑动更流畅,我们可以利用 cachedCount 属性,让列表在屏幕上下方预先加载几个项目。

entry/src/main/ets/pages/PackagesPage.ets

List({ space: 12 }) {
  // 使用 LazyForEach + 自定义数据源
  LazyForEach(this.packagesDataSource, (packageInfo: PackageInfo, index: number) => {
    ListItem() {
      // 使用可复用组件
      PackageCard({
        packageInfo: packageInfo,
        // ...
      })
    }
  }, (item: PackageInfo) => `${item.id}_${item.updateTime}`) // 键值生成器
}
.width('100%')
.cachedCount(5) // <--- 设置缓存数量为 5
  • 原理cachedCount(5) 表示在屏幕视口之外,预先渲染并缓存 5 个列表项。
  • 收益:当用户快速滑动时,即将进入屏幕的卡片已经渲染好了,消除了白屏和闪烁,极大提升了跟手性。

优化效果对比

指标 优化前 (ForEach) 优化后 (LazyForEach + @Reusable) 提升原理
首屏加载时间 慢(加载所有数据) (仅加载首屏可见项) 按需渲染
内存占用 高(随数据量线性增长) 低且稳定(仅维持可见项+缓存项) 对象复用
滑动帧率 掉帧明显 满帧运行 (60/90/120Hz) 避免频繁创建销毁节点
CPU 占用 高(频繁 GC 和布局计算) 复用现有节点结构

总结

在开发复杂列表界面时,"LazyForEach + @Reusable + cachedCount" 是标准的高性能解决方案。

  1. LazyForEach 替代 ForEach,解决内存和首屏问题。
  2. @Reusable 改造子组件,解决滑动掉帧问题。
  3. cachedCount 调节预加载,进一步提升流畅度。

这套方案在 PickupPartner 项目中经受住了大量数据的考验,为用户提供了丝滑的操作体验。

webpack的生命周期与Loader/Plugin

作者 MQliferecord
2025年12月27日 14:35

核心对象:

  • compiler: 代表Webpack构建的全局上下文,钩子是全局生命周期钩子
  • compilation: 代表一次构建的编译过程,钩子是编译阶段钩子

生命周期:

整个过程分为【初始化->编译->输出->结束】,一共四个阶段,以下列举一下各个阶段的核心钩子:

  1. 初始化:
  • entryOption 动态新增入口
  1. 编译:
  • compile:等同于vite的config阶段,能够获得最终生成的config文件,如果需要添加一些环境值的占位符,可以在这个阶段介入
compiler.hooks.compiler.tap('插件名',()=>{
    compiler.options.plugins.push(
        new webpack.DefinePlugin({
          'process.env.APP_VERSION': JSON.stringify(PLACEHOLDER),
        })
    );
    console.log('✅ 已注入APP_VERSION占位符:', PLACEHOLDER);
})
  • compilation: 内部包含整个打包构建流程,像是buildModule和optimize等等,实际打包实例已经创建完成,一般这个阶段可以拿到文件最终的contenthash值
compiler.hooks.compilation.tap('ContentHashReplacePlugin', () => {
  this.contentHash = this.generateContentHash();
  console.log('✅ 生成真实contentHash:', this.contentHash);
});
  1. 输出:
  • emit: 异步钩子!必须和callback和tapAsync结合使用!准备将数据写入磁盘,这个阶段可以把环境值的占位符替换成真正的数据
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
  // 遍历所有输出的资源
  for (const [filename, source] of Object.entries(compilation.assets)) {
    if (filename.endsWith('.js')) {
      // 读取原内容,添加版权注释
      const content = source.source();
      const newContent = `/* 版权所有 © 2025 MyProject */\n${content}`;
      // 替换资源内容
      compilation.assets[filename] = {
        source: () => newContent,
        size: () => newContent.length
      };
    }
  }
  callback(); // 异步钩子必须调用 callback 结束
});
  1. 结束:
  • done: 构建完成之后输出一些日志信息,生成报告

tap和tapAsync的区别:

同步钩子和异步钩子调用时候的函数。

tapAsync必须和callback结合使用,用于通知webpack异步流程已经结束,可以继续接下来的流程,不然会卡住。

Plugin: 用于webpack打包生命周期中执行的一些函数,比如css和图片压缩的plugin,无视打包模块的IgnorePlugin

Loader: 用于代码转换,因为浏览器只能解析html,css和js,所以会有各种loader将浏览器没法解析的东西转换成能解析的语言,同时webpack本身无法识别一些文件,也需要Loader做转换,比如css-loader,sass-loader,ts-loader,style-loader,postcss-loader

(1.1) css-loader:因为webpack没办法识别css文件,webpack其实只能理解js和json,所以才会使用需要css-loader去处理css文件引用,将其转为模板字符串

(1.2) style-loader: 将css-loader生成的样式字符串注入到style标签中

(1.3) postcss-loader: 对css做兼容处理,自动增加前缀

执行顺序是postcss-loader->css-loader->style-loader,而配置的时候需要从右向左配置,顺序错误,会导致报错

Loader整体执行阶段类似于洋葱模型,从左到右依次遍历对应的loader,再从右到左执行对应的loader

module.exports = {
  module: {
    rules: [
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader',  'postcss-loader']
      }
    ]
  }
};

vite自身没有loader,可以通过装插件来配置loader,但是没有原生好用,而且vite很多loader功能都原生内嵌了,而且因为工具特性,vite依赖es模块,而浏览器是完全支持es模块的,无需loader做模块转换,整体上都是依靠plugin进行处理

Vue.js 源码揭秘(一):Vue3 架构总览

作者 借个火er
2025年12月27日 13:51

Vue.js 源码揭秘(一):Vue3 架构总览

本文从全局视角解析 Vue3 的核心架构,建立源码阅读的整体认知。

一、整体架构

┌─────────────────────────────────────────────────────────────┐
│                      Vue Application                        │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                    Compiler (编译时)                         │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐         │
│  │   Parse     │─►│  Transform  │─►│   Codegen   │         │
│  │  (解析)     │  │   (转换)    │  │  (代码生成)  │         │
│  └─────────────┘  └─────────────┘  └─────────────┘         │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼ render function
┌─────────────────────────────────────────────────────────────┐
│                    Runtime (运行时)                          │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐         │
│  │ Reactivity  │  │   Renderer  │  │  Scheduler  │         │
│  │  (响应式)   │  │   (渲染器)   │  │   (调度器)   │         │
│  └─────────────┘  └─────────────┘  └─────────────┘         │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                      DOM / Platform                         │
└─────────────────────────────────────────────────────────────┘

二、编译时 vs 运行时

2.1 编译时(Compile Time)

// 模板
<template>
  <div>{{ msg }}</div>
</template>

// 编译后的 render 函数
function render(_ctx) {
  return _createElementVNode("div", null, _toDisplayString(_ctx.msg))
}

2.2 运行时(Runtime)

// 运行时执行 render 函数
const vnode = render(ctx)

// patch 到 DOM
patch(null, vnode, container)

三、响应式系统

3.1 核心 API

// reactive - 对象响应式
const state = reactive({ count: 0 })

// ref - 基本类型响应式
const count = ref(0)

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

// effect - 副作用
effect(() => {
  console.log(count.value)
})

3.2 依赖收集与触发

┌─────────────────────────────────────────────────────────────┐
│                    响应式流程                                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   ┌─────────┐    get     ┌─────────┐                       │
│   │  Proxy  │ ─────────► │  track  │ ──► 收集当前 effect    │
│   └─────────┘            └─────────┘                       │
│        │                                                    │
│        │ set                                                │
│        ▼                                                    │
│   ┌─────────┐            ┌─────────┐                       │
│   │ trigger │ ─────────► │ effects │ ──► 执行所有 effect    │
│   └─────────┘            └─────────┘                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘

3.3 Dep 与 Effect

// Dep - 依赖容器
class Dep {
  subs: Set<Subscriber>  // 订阅者集合
  
  track() {
    if (activeSub) {
      this.subs.add(activeSub)
    }
  }
  
  trigger() {
    this.subs.forEach(sub => sub.notify())
  }
}

// ReactiveEffect - 副作用
class ReactiveEffect {
  deps: Link[]  // 依赖链表
  
  run() {
    activeSub = this
    return this.fn()
  }
  
  notify() {
    this.scheduler ? this.scheduler() : this.run()
  }
}

四、虚拟 DOM

4.1 VNode 结构

interface VNode {
  type: string | Component    // 节点类型
  props: object | null        // 属性
  children: VNode[] | string  // 子节点
  el: Element | null          // 真实 DOM
  key: string | number        // diff key
  shapeFlag: number           // 节点类型标记
  patchFlag: number           // 优化标记
}

4.2 ShapeFlags

enum ShapeFlags {
  ELEMENT = 1,                    // 普通元素
  FUNCTIONAL_COMPONENT = 1 << 1,  // 函数组件
  STATEFUL_COMPONENT = 1 << 2,    // 有状态组件
  TEXT_CHILDREN = 1 << 3,         // 文本子节点
  ARRAY_CHILDREN = 1 << 4,        // 数组子节点
  SLOTS_CHILDREN = 1 << 5,        // 插槽子节点
  TELEPORT = 1 << 6,              // Teleport
  SUSPENSE = 1 << 7,              // Suspense
  COMPONENT = STATEFUL_COMPONENT | FUNCTIONAL_COMPONENT
}

五、渲染器

5.1 patch 函数

const patch = (n1, n2, container) => {
  if (n1 === n2) return
  
  // 类型不同,卸载旧节点
  if (n1 && !isSameVNodeType(n1, n2)) {
    unmount(n1)
    n1 = null
  }
  
  const { type, shapeFlag } = n2
  
  switch (type) {
    case Text:
      processText(n1, n2, container)
      break
    case Fragment:
      processFragment(n1, n2, container)
      break
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(n1, n2, container)
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        processComponent(n1, n2, container)
      }
  }
}

5.2 组件挂载

const mountComponent = (vnode, container) => {
  // 1. 创建组件实例
  const instance = createComponentInstance(vnode)
  
  // 2. 设置组件(执行 setup)
  setupComponent(instance)
  
  // 3. 设置渲染副作用
  setupRenderEffect(instance, vnode, container)
}

const setupRenderEffect = (instance, vnode, container) => {
  const effect = new ReactiveEffect(() => {
    if (!instance.isMounted) {
      // 首次挂载
      const subTree = instance.render()
      patch(null, subTree, container)
      instance.subTree = subTree
      instance.isMounted = true
    } else {
      // 更新
      const nextTree = instance.render()
      patch(instance.subTree, nextTree, container)
      instance.subTree = nextTree
    }
  })
  
  effect.run()
}

六、调度器

6.1 任务队列

const queue: SchedulerJob[] = []
let isFlushing = false

function queueJob(job) {
  if (!queue.includes(job)) {
    queue.push(job)
    queueFlush()
  }
}

function queueFlush() {
  if (!isFlushing) {
    isFlushing = true
    Promise.resolve().then(flushJobs)
  }
}

function flushJobs() {
  queue.sort((a, b) => getId(a) - getId(b))
  
  for (const job of queue) {
    job()
  }
  
  queue.length = 0
  isFlushing = false
}

6.2 nextTick

const resolvedPromise = Promise.resolve()

function nextTick(fn?) {
  return fn 
    ? resolvedPromise.then(fn) 
    : resolvedPromise
}

七、组件系统

7.1 组件实例

interface ComponentInternalInstance {
  uid: number                    // 唯一 ID
  type: Component                // 组件定义
  parent: ComponentInternalInstance | null
  
  // 状态
  data: object                   // data()
  props: object                  // props
  setupState: object             // setup() 返回值
  ctx: object                    // 渲染上下文
  
  // 渲染
  render: Function               // render 函数
  subTree: VNode                 // 渲染的 VNode 树
  effect: ReactiveEffect         // 渲染副作用
  
  // 生命周期
  isMounted: boolean
  isUnmounted: boolean
  
  // 生命周期钩子
  bc: Function[] | null          // beforeCreate
  c: Function[] | null           // created
  bm: Function[] | null          // beforeMount
  m: Function[] | null           // mounted
  bu: Function[] | null          // beforeUpdate
  u: Function[] | null           // updated
  bum: Function[] | null         // beforeUnmount
  um: Function[] | null          // unmounted
}

7.2 setup 执行

function setupComponent(instance) {
  const { props, children } = instance.vnode
  
  // 初始化 props
  initProps(instance, props)
  
  // 初始化 slots
  initSlots(instance, children)
  
  // 执行 setup
  const { setup } = instance.type
  if (setup) {
    const setupResult = setup(instance.props, {
      attrs: instance.attrs,
      slots: instance.slots,
      emit: instance.emit,
      expose: instance.expose
    })
    
    handleSetupResult(instance, setupResult)
  }
}

八、编译优化

8.1 PatchFlags

enum PatchFlags {
  TEXT = 1,              // 动态文本
  CLASS = 1 << 1,        // 动态 class
  STYLE = 1 << 2,        // 动态 style
  PROPS = 1 << 3,        // 动态 props
  FULL_PROPS = 1 << 4,   // 有动态 key
  NEED_HYDRATION = 1 << 5,
  STABLE_FRAGMENT = 1 << 6,
  KEYED_FRAGMENT = 1 << 7,
  UNKEYED_FRAGMENT = 1 << 8,
  NEED_PATCH = 1 << 9,
  DYNAMIC_SLOTS = 1 << 10,
  HOISTED = -1,          // 静态提升
  BAIL = -2              // 退出优化
}

8.2 Block Tree

// 编译优化:只追踪动态节点
const _hoisted_1 = createVNode("div", null, "static")

function render() {
  return (openBlock(), createBlock("div", null, [
    _hoisted_1,  // 静态提升
    createVNode("span", null, ctx.msg, PatchFlags.TEXT)  // 动态节点
  ]))
}

九、完整渲染流程

┌─────────────────────────────────────────────────────────────┐
│                    Vue 渲染流程                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. createApp(App).mount('#app')                            │
│        │                                                    │
│        ▼                                                    │
│  2. 创建 VNode                                              │
│        │                                                    │
│        ▼                                                    │
│  3. render(vnode, container)                                │
│        │                                                    │
│        ▼                                                    │
│  4. patch(null, vnode, container)                           │
│        │                                                    │
│        ▼                                                    │
│  5. processComponent → mountComponent                       │
│        │                                                    │
│        ├── createComponentInstance                          │
│        ├── setupComponent (执行 setup)                      │
│        └── setupRenderEffect                                │
│              │                                              │
│              ▼                                              │
│  6. ReactiveEffect.run()                                    │
│        │                                                    │
│        ▼                                                    │
│  7. instance.render() → subTree VNode                       │
│        │                                                    │
│        ▼                                                    │
│  8. patch(null, subTree, container)                         │
│        │                                                    │
│        ▼                                                    │
│  9. 递归处理子节点 → 挂载到 DOM                              │
│                                                             │
└─────────────────────────────────────────────────────────────┘

十、小结

Vue3 架构的核心:

  1. 响应式系统:基于 Proxy,依赖收集 + 触发更新
  2. 虚拟 DOM:VNode 描述 UI,patch 算法高效更新
  3. 编译优化:PatchFlags、Block Tree、静态提升
  4. 调度器:批量更新,nextTick 微任务队列
  5. 组件系统:setup + Composition API

📦 源码地址:github.com/vuejs/core

下一篇:响应式系统详解

如果觉得有帮助,欢迎点赞收藏 👍

面试官: “ 请你说一下什么是 ajax ? ”

作者 千寻girling
2025年12月27日 13:33

一、AJAX 核心定义

AJAX 是 Asynchronous JavaScript and XML 的缩写,翻译为 “异步的 JavaScript 和 XML”。

  • 本质:它不是一种新的编程语言,而是一套使用现有技术组合实现的编程方案
  • 核心作用:让 JavaScript 在不刷新整个网页的情况下,异步地与服务器交换数据,实现网页局部更新。
  • 关键特点:异步(请求发送后,页面不用等待服务器响应,仍可正常交互)、局部更新(只更新需要变化的部分,提升用户体验)。

补充:虽然名字里有 XML,但现在实际开发中几乎都用JSON(更轻量、易解析)来传输数据,AJAX 只是沿用了历史名称。

二、AJAX 工作原理

AJAX 的核心是浏览器提供的 XMLHttpRequest 对象(简称 XHR),现代浏览器也提供了更易用的 fetch API。其基本工作流程如下:

  1. 创建 AJAX 请求对象(XHR/fetch);
  2. 配置请求参数(请求方式、URL、是否异步等);
  3. 发送请求到服务器;
  4. 监听服务器响应状态;
  5. 接收服务器返回的数据;
  6. 用 JavaScript 更新网页局部内容。

三、AJAX 代码示例

1. 传统 XHR 方式(兼容所有浏览器)

// 1. 创建XHR对象
const xhr = new XMLHttpRequest();

// 2. 配置请求:请求方式(GET)、URL、是否异步(true)
xhr.open('GET', 'https://jsonplaceholder.typicode.com/todos/1', true);

// 3. 监听请求状态变化(核心)
xhr.onreadystatechange = function() {
    // readyState=4 表示请求完成;status=200 表示响应成功
    if (xhr.readyState === 4 && xhr.status === 200) {
        // 4. 解析服务器返回的JSON数据
        const data = JSON.parse(xhr.responseText);
        // 5. 局部更新页面(比如把数据显示到id为result的元素里)
        document.getElementById('result').innerHTML = `
            <h3>任务标题:${data.title}</h3>
            <p>是否完成:${data.completed ? '是' : '否'}</p>
        `;
    }
};

// 处理请求失败的情况
xhr.onerror = function() {
    document.getElementById('result').innerHTML = '请求失败!';
};

// 4. 发送请求
xhr.send();

2. 现代 fetch 方式(ES6+,更简洁)

fetch 是 AJAX 的现代替代方案,基于 Promise,语法更优雅:

// 1. 发送GET请求
fetch('https://jsonplaceholder.typicode.com/todos/1')
    // 2. 处理响应:先判断是否成功,再解析为JSON
    .then(response => {
        if (!response.ok) {
            throw new Error('请求失败,状态码:' + response.status);
        }
        return response.json();
    })
    // 3. 使用数据更新页面
    .then(data => {
        document.getElementById('result').innerHTML = `
            <h3>任务标题:${data.title}</h3>
            <p>是否完成:${data.completed ? '是' : '否'}</p>
        `;
    })
    // 4. 捕获异常
    .catch(error => {
        document.getElementById('result').innerHTML = error.message;
    });

四、AJAX 的典型应用场景

  • 表单提交(比如登录验证,不用刷新页面就能提示 “用户名密码错误”);
  • 数据分页加载(比如滚动到底部自动加载下一页内容);
  • 搜索框联想(输入关键词实时显示匹配结果);
  • 局部数据刷新(比如网页的点赞、评论功能,点击后直接更新数字)。

五、关键注意点

  1. 同源策略:浏览器默认限制 AJAX 请求只能访问同域名、同端口、同协议的服务器(比如http://localhost:8080不能请求http://baidu.com),跨域需要服务器配置 CORS 或使用代理。
  2. 异步特性:AJAX 请求是异步的,不能在请求发送后立即获取结果,必须在回调函数(onreadystatechange)或 Promise 的then里处理返回数据。

总结

  1. AJAX 是一套实现 “网页异步请求数据、局部更新” 的技术方案,核心是XMLHttpRequest对象(或现代的fetch)。
  2. 核心优势:无需刷新整个页面,提升用户体验,实现网页与服务器的异步数据交互。
  3. 现代开发中,fetch(结合 Promise/async-await)已逐步替代传统 XHR,是 AJAX 的主流实现方式。

shadcn-ui 的 Radix Dialog 这两个警告到底在说什么?为什么会报?怎么修?

2025年12月26日 16:44

问题

最近在项目里遇到两个来自 Radix Dialog 的控制台提示:

image.png

它们不是 “功能错误”,但属于 无障碍(a11y)级别的警告:Radix 在开发环境主动提醒你对话框缺少 “可被屏幕阅读器正确理解” 的关键语义。

本文基于 coco-app(React 18 + TS + Tailwind + shadcn-ui)里真实踩坑的修复过程总结。


1. 背景:Radix Dialog 需要什么语义?

一个可访问的 Dialog 至少需要:

  • 可访问名称(accessible name):告诉读屏软件 “这个弹窗叫啥”
    • 对应:<DialogTitle />(内部映射到 aria-labelledby
  • 可访问描述(accessible description):告诉读屏软件 “这个弹窗在说啥/要用户做啥”
    • 对应:<DialogDescription />(内部映射到 aria-describedby

Radix 的示例结构也是这个顺序(Title + Description + 内容 + Close 等)。


2. 报错 1:为什么必须要 DialogTitle

2.1 报错含义

DialogContent requires a DialogTitle...

意思是:你的 <DialogContent /> 里没有提供标题,导致 Dialog 没有可访问名称。读屏用户打开弹窗时,不知道这是 “更新提示” 还是 “删除确认”。

2.2 coco-app 的修复方式

我们在 UpdateApp 弹窗中增加了一个 “对视觉隐藏、对读屏可见” 的标题:

  • 文件:src/components/UpdateApp/index.tsx:164
  • 代码形态:
<DialogTitle className="sr-only">{t("update.title")}</DialogTitle>

为什么用 sr-only

  • Tailwind 的 sr-only 能达到UI 不变,读屏可读的效果(有些 shadcn 模板会有现成的 VisuallyHidden 组件)。

3. 报错 2:为什么必须要 DialogDescription / aria-describedby

3.1 报错含义

Warning: Missing Description or aria-describedby={undefined} for {DialogContent}.

意思是:Dialog 没有可访问描述,或者你显式把 aria-describedby 置为 undefined 但又没有描述节点关联上。

Radix 的逻辑大致是:

  • 你提供 <DialogDescription />:Radix 自动把它的 id 绑定到 aria-describedby
  • 你不提供 <DialogDescription />:Radix 会提醒你 “缺描述”,避免读屏用户只听到标题但不知道要做什么

3.2 coco-app 的修复方式

我们把原先展示更新说明的 div 替换为 DialogDescription(UI class 不变,只换组件语义):

  • 文件:src/components/UpdateApp/index.tsx:179-193
  • 代码形态:
<DialogDescription className="text-sm leading-5 py-2 text-foreground text-center">
  {updateInfo ? ... : t("update.date")}
</DialogDescription>

这样 Radix 就能自动生成正确的 aria-describedby,warning 消失。


4. “洁癖”:它不是 bug,但是就是在控制台报红了...

shadcn-ui 的 Dialog 本质是对 Radix Dialog 的一层轻封装(项目里对应 src/components/ui/dialog.tsx),它不会强制你必须写 Title/Description。

4.1 为什么更容易踩坑?

因为 UI 上你可能觉得:

  • 我已经有图标(logo)
  • 我已经有一段说明文字(div/p)
  • 我不想显示标题

视觉上满足 ≠ 语义上满足。读屏依赖的是 aria-labelledby/aria-describedby 的关联,而不是你页面里有没有一个看起来像标题的 div


5. 最推荐的写法、更标准的写法

5.1 标题不想显示:用 sr-only

<DialogTitle className="sr-only">{t("xxx.title")}</DialogTitle>

5.2 描述存在:用 DialogDescription

<DialogDescription className="sr-only">
  {t("xxx.description")}
</DialogDescription>

是否一定要隐藏 Description?

  • 不一定。像更新弹窗这种 “正文就是描述”,直接用 DialogDescription 包住正文最自然。

6. 也可以手动 aria-describedby 吗?可以,但更容易出错

你当然可以自己写:

<DialogContent aria-describedby="my-desc">
  <div id="my-desc">...</div>
</DialogContent>

但坑在于:

  • id 可能忘了写 / 重复
  • 条件渲染导致节点不在 DOM(aria 指向不存在的 id)
  • 重构时删掉了 id 没发现
  • 多弹窗复用组件时 id 冲突

所以在 shadcn/Radix 体系里,优先使用 DialogTitle / DialogDescription 让 Radix 负责关联更稳。


7. 真正的“坑点清单”(建议以后 review 的时候对照)

  • 只写了 <DialogContent />,把标题/正文都塞进普通 div
  • 标题用视觉元素表达(比如 logo 或大号文本),但没用 DialogTitle
  • 描述是条件渲染的,导致有时没有 DialogDescription
  • 想隐藏标题却直接不写(应该隐藏而不是删除)

8. coco-app 里的落地实践(最终结论)

在 coco-app 里,我们最终遵循了一个简单规则:

  • 每个 DialogContent 内部都应该有且只有一个语义标题:DialogTitle
  • 只要弹窗有 “说明性文本”,优先用 DialogDescription 承载
  • 如果 UI 不需要展示标题/描述:用 sr-only 隐藏(而不是不写)

案例+图解带你一文读懂Svg、Canvas、Css、Js动画🔥🔥(4k+字)

作者 Lsx_
2025年12月26日 15:43

引言

动画在前端开发中扮演着重要的角色。它不仅可以提升用户体验,还可以使界面更加生动和有趣。在这篇文章中,我们将深入探讨前端动画的各种实现方式,包括 CSS 动画、JavaScript 动画、SVG 动画等。我们还将讨论一些触发动画的方式和动画在用户体验中的最佳实践。

前端动画分类

  • CSS 动画

    • CSS Transition
      CSS 过渡,属于补间动画,即设置关键帧的初始状态,然后在另一个关键帧改变这个状态,比如大小、颜色、透明度等,浏览器将自动根据二者之间帧的值创建的动画。

    • CSS Animation
      CSS 动画,可以理解是 CSS Transition 的加强版,它既可以实现 补间动画 的动画效果,也可以使其以 逐帧动画 的方式进行绘制。

  • SVG 动画

    • SVG 动画用于矢量图形,提供了高质量的动画效果,常用于图标和图形动画。可以使用 SMIL 在SVG中定义动画。同样的也可以使用css或者js来控制svg动画。
  • Canvas 动画

    • 通过结合使用 requestAnimationFrame、路径和变换等技术对画布的元素进行擦除和重新绘制,可以实现复杂的动画效果。另外Canvas还可以用于绘制复杂的背景或静态内容,从而减少每帧的绘制工作量。

    • 可以参考我的一篇关于canvas制作动画的文章:用Canvas绘制一个高可配置的圆形进度条

  • JS 动画

    • setTimeout / setInterval / requestAnimationFrame
      setTimeoutsetInterval 这两个 API 设定的时间会因为浏览器当前工作负载而有所偏差,而且无法与浏览器的绘制帧保持同步。所以才有了 与浏览器的绘制帧同步 的原生 API requestAnimationFrame,以取代 setTimeoutsetInterval 实现动画。

    • Web Animations API
      浏览器动画 API,通过 JavaScript 操作。这些 API 被设计成 CSS TransitionCSS Animation 的接口,很容易通过 JS 的方式实现 CSS 动画,它是对动画化的支持最有效的方式之一。

css 动画

css过渡动画 transition

注意

由于浏览器是根据样式差异化的两帧自动计算并过渡,所以 transition 只支持可识别中间值的属性 (如大小、颜色、位置、透明度等),而如 display 属性则不支持。

语法定义

  • transition-property: 指定哪个或哪些 CSS 属性用于过渡。只有指定的属性才会在过渡中发生动画,其他属性仍如通常那样瞬间变化。

  • transition-duration: 指定过渡的时长。你可以为所有属性指定一个值,或者指定多个值,或者为每个属性指定不同的时长。

  • transition-timing-function: 指定一个缓动函数,定义属性值怎么变化。常见的缓动函数是一个三次贝塞尔曲线 ( cubic-bezier(<x1>, <y1>, <x2>, <y2>) )。当然也可以选择关键字

    • linearcubic-bezier(0.0, 0.0, 1.0, 1.0)
    • easecubic-bezier(0.25, 0.1, 0.25, 1.0)
    • ease-incubic-bezier(0.42, 0.0, 1.0, 1.0)
    • ease-outcubic-bezier(0.0, 0.0, 0.58, 1.0)
    • ease-in-outcubic-bezier(0.42, 0.0, 0.58, 1.0)
  • transition-delay: 指定延迟,即属性开始变化时与过渡开始发生时之间的时长。

代码示例

  /* 单条 简写形式 */
  transition: 
    <property> <duration> <timing-function> <delay>;
  
  
  /* 多条 简写形式 */
  transition: 
    <property> <duration> <timing-function> <delay>,
    <property> <duration> <timing-function> <delay>,
    ...;


  /* 单条 子属性形式 */
  transition-property: <property-name>;
  transition-duration: <duration-time>;
  transition-timing-function: <timing-function>;
  transition-delay: <duration-time>;
  
  
  /* 多条 子属性形式 */
  transition-property: <property-name> [, <property-name>, ...];
  transition-duration: <duration-time> [, <duration-time>, ...];
  transition-timing-function: [, <cubic-bezier>, ...];
  transition-delay: [, <duration-time>, ...];
  
  
  // 如果任意属性值列表的长度比其他属性值列表要短,则其中的值会重复使用以便匹配
  
  // 如果某个属性的值列表长于 `transition-property` 的属性,则将被截短
  

css过渡动画 触发方式

1. 伪类触发(:hover、:focus、:active等)
.button {
  background-color: blue;
  transition: background-color 0.3s ease;
}

.button:hover {
  background-color: red;
}

2. 类名切换(通过JS动态切换类名来触发过渡效果)
<button id="toggleButton">Toggle</button>
<div id="box" class="box"></div>

<style>
  .box {
    width: 100px;
    height: 100px;
    background-color: blue;
    transition: background-color 0.3s ease;
  }

  .box.active {
    background-color: red;
  }
</style>

<script>
  document.getElementById('toggleButton').addEventListener('click', function() {
    document.getElementById('box').classList.toggle('active');
  });
</script>

3. 属性变化
<button id="toggleButton">Toggle</button>
<div id="box" class="box"></div>

<style>
  .box {
    width: 100px;
    height: 100px;
    background-color: blue;
    transition: background-color 0.3s ease;
  }
</style>

<script>
  document.getElementById('toggleButton').addEventListener('click', function() {
    const box = document.getElementById('box');
    box.style.backgroundColor = box.style.backgroundColor === 'red' ? 'blue' : 'red';
  });
</script>

4. 伪元素触发(通过伪元素如::before::after的状态变化来触发过渡效果。)
<div class="box"></div>

<style>
  .box {
    width: 100px;
    height: 100px;
    position: relative;
  }

  .box::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: blue;
    transition: background-color 0.3s ease;
  }

  .box:hover::before {
    background-color: red;
  }
</style>

css动画 animation

注意

CSS Animation 具备了对 关键帧和循环次数 的自定义能力。CSS Animation 在实现像 CSS Transition 补间动画 效果时,还可以在起始帧和结束帧之间自定义中间帧,使得动画更加平滑过渡的同时,对动画有了更好的控制和自定义能力。

语法定义

先创建一个带名称的 @keyframes 规则,以便后续使用 animation-name 属性将动画同其关键帧声明进行匹配。每个规则包含多个关键帧,也就是一段样式块语句,每个关键帧有一个百分比值作为名称,代表在动画进行中,在哪个阶段触发这个帧所包含的样式。

  • animation-name:指定一个或多个 @keyframes 的名称,描述了要应用于元素的动画。多个 @keyframes 以逗号分隔。

  • animation-duration:设置动画完成一个动画周期所需的时间,需要指定单位,如 1s500ms

  • animation-delay:指定执行动画之前的等待时间。动画可以稍后开始、立即从开头开始、立即在动画中途播放 (如 -1s) 。其中 -1s 意思是动画立即从 1s 处开始。

  • animation-iteration-count:设置动画序列在停止前应播放的次数,有效值 0、正整数、正小数、无限循环 infinite

  • animation-direction:设置动画是正向播放 normal、反向播放 reverse、正向交替播放 alternate、反向交替播放 alternate-reverse

  • animation-play-state:设置动画是运行还是暂停,有效值 runningpaused

  • animation-fill-mode:设置 CSS 动画在执行之前和之后如何将样式应用于其目标,有效值如下:

    • none:当动画未执行时,动画将不会将任何样式应用于目标,而是已经赋予给该元素的 CSS 规则来显示该元素。这是默认值
    • forwards:目标将保留由执行期间遇到的最后一个关键帧计算值。
    • backwards:动画将在应用于目标时立即应用第一个关键帧中定义的值。 animation-timing-function:设置动画在每个周期的持续时间内如何进行,主要是如下两种函数:
  • cubic-bezier 三次贝塞尔曲线 ( cubic-bezier(<x1>, <y1>, <x2>, <y2>) ),以实现 补间动画 效果。

  • steps 是一个分段的阶跃函数,,以实现 逐帧动画。n 相当于单次动画的帧数,每帧动画的时间是均等的 (steps(n, <jumpterm>)),其中 jumpterm (默认值 end) 含义如下:

    1. jump-start:在起始位置阶跃,n=2 ⇒ 50% 100%; (100 / 2)
    2. jump-end:在结束位置阶跃, n=4 ⇒ 0% 25% 50% 75%; (100 / 4)
    3. jump-none:起止位置均无跳跃,n=5 ⇒ 0% 25% 50% 75% 100%; (100 / 4)
    4. jump-both:起止位置均有跳跃 n=3 ⇒ 25% 50% 75%; (100 / 4)
    5. start:等同 jump-start
    6. end:等同 jump-end
    7. step-start:等同 steps(1, jump-start)
    8. step-end:等同 steps(1, jump-end)
  /* animation 声明样式顺序 */ 
  /* animation-duration */
  /* animation-easing-function */
  /* animation-delay */ 
  /* animation-iteration-count */
  /* animation-direction */
  /* animation-fill-mode */
  /* animation-play-state */
  /* animation-name */
  animation: 3s ease-in 1s 2 reverse both paused slidein; 

  
  /* animation - duration | easing-function | delay | name */
  animation: 3s linear 1s slidein;
  
  
  /* more animations - duration | easing-function | delay | name */
  animation: 3s linear slidein, 3s ease-out 5s slideout;

 
  /* animation-name */
  animation-name: none;
  animation-name: animate1;
  animation-name: animate1, animate2;
  
  
  /* animation-timing-function */
  animation-timing-function: ease;
  animation-timing-function: step-start;
  animation-timing-function: cubic-bezier(0.1, 0.7, 1, 0.1);
  animation-timing-function: ease, step-start, cubic-bezier(0.1, 0.7, 1, 0.1);
  

css animation 动画触发方式

和css transition 触发动画方式相似

此外还可以增加一个图层,专门用于制作动画效果。

例如:鼠标在点击按钮时,会有涟漪动画。


// 涟漪动画定义
@keyframes ripple {
  0% {
    transform: scale(0);
    opacity: 1;
  }

  to {
    transform: scale(4);
    opacity: 0;
  }
}

// 图层动画 css
.ripple {
  position: absolute;
  border-radius: 50%;
  background: rgba(8, 7, 7, 0.2);
  pointer-events: none;

  animation: ripple 0.6s linear;
}

// 制作动画  这样每次点击按钮 就会生成动画,动画结束便销毁动画元素
const makeAnimate = (e: React.MouseEvent) => {
  const dom = e.currentTarget;
  const rect = dom.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;
  const size = 100;

  const ripple = document.createElement('span');
  ripple.classList.add('ripple');
  ripple.style.width = `${size}px`;
  ripple.style.height = `${size}px`;
  ripple.style.left = `${x - size / 2}px`;
  ripple.style.top = `${y - size / 2}px`;
  dom.appendChild(ripple);

  ripple.addEventListener('animationend', () => {
    ripple.remove();
  });
};


svg 动画

常用的 SMIL 动画元素

  • <animate>:用于动画化单个属性。
  • <animateTransform>:用于动画化变换属性,如旋转、缩放、平移等。
  • <animateMotion>:用于沿着路径动画化元素。(路径动画)
  • <set>:用于在指定时间点设置属性值。
<svg width="100" height="100">
  <circle cx="50" cy="50" r="40" fill="red">
    <animate attributeName="cx" from="50" to="150" dur="2s" repeatCount="indefinite" />
  </circle>
</svg>

svg 描边动画

SVG动画的路径实现主要依赖属性:stroke(描边)和 fill(填充)。

  • stroke:定义svg的轮廓线。常用css属性有: stroke-dasharray(描边的样式),stroke-dashoffset(起始位置),stroke-color(描边的颜色),stroke-opacity(描边的透明度),stroke-linecap(描边端点形状)等。
  • fill:定义svg内部颜色或图案 ,常用css属性有fill-opacity(定义填充的透明度), fill-rule(定义填充规则)等。

stroke-dasharray (定义虚线的长度和间隔)

提供一个奇数或偶数数列,其中数与数之间用逗号或空格隔开,用来指定短划线和缺口的长度,并重复。 如果是偶数数列,则一个表示短线长度,一个表示缺口长度。 如果是奇数数列,将奇数数列复制一个变成偶数数列,然后按照短线,缺口的顺序绘制。

(偶数数列) stroke-dasharray="5, 5" x1="10" y1="10" x2="190" y2="10"表示从坐标(10,10)到(200,10)这条水平线上,短划线和缺口都为5个px

image.png

(奇数数列) stroke-dasharray="20 10 5" x1="10" y1="10" x2="190" y2="10"表示从坐标(10,10)到(200,10)这条水平线上,短划线和缺口按照20 10 5 20 10 5的顺序排列。

image.png

stroke-dashoffset (定义虚线的起始位置)

stroke-dashoffset 属性用于指定路径开始的距离(正值向左偏移,负值向右偏移)

image.png

描边动画示例:

code.juejin.cn/pen/7391734…

js 动画

setTimeout / setInterval API

设定定时器,通过周期性的触发重复执行绘制动画的函数,来实现 “逐帧动画” 的效果。

  • 优势

    1. 具有很好的浏览器兼容性
  • 劣势

    1. 只能接近设备屏幕刷新率,无法做到和浏览器同步,所以可能会存在卡顿、丢帧、抖动的现象
    2. 由于浏览器单线程机制,存在队列中回调函数被阻塞的可能,所以无法保证每一次调用的时间间隔都相同,某次回调可能会被跳过,导致跳帧。

requestAnimationFrame API

为了弥补 setTimeout / setInterval 在动画方面的不足,浏览器提供了为动画而生的 API,它可以让 DOM 动画、Canvas 动画、 SVG 动画等有一个统一的刷新机制,随着浏览器的屏幕刷新,统一绘制动画帧。

  let id = null
  
  // 动画函数
  const draw = () => {
    /* 动画绘制... */
  }
  
  const start = () => {
    draw()
    cancelAnimationFrame(id)
    id = requestAnimationFrame(start)
  }
  const stop = () => { cancelAnimationFrame(id) }
  
  • 优势

    1. 由系统来决定回调函数的执行时机, 它能保证回调函数在屏幕每一次的刷新间隔中只被执行一次, 这样就不会引起丢帧现象, 也不会导致动画出现卡顿的问题。
    2. 在运行时浏览器会自动优化方法的调用,并且如果页面不是激活状态下的话,动画会自动暂停,有效节省了CPU的开销。
  • 不足

    1. 同 setTimeout/setInterval 一样,它是以逐帧动画的方式进行绘制,无法做到像 CSS 动画,让游览器自动根据两帧之间的差异创建插值,以实现补间动画的过渡效果。

Web Animations API

  1. requestAnimationFramesetTimeout/setInterval 都是以逐帧绘制的方式实现动画, 而 Animations API 不仅可以 “逐帧动画”,还可以实现 “补间动画” 的效果。
  2. CSS 动画有一定的局限性,需要事先预设动画样式,而且无法与 JS 进行交互。相比之下,Animations API 可以随时定义并使用动画,自然是更加灵活方便。

参考文档: developer.mozilla.org/zh-CN/docs/…

语法示例:

  const element = document.getElementById("container");

  const animation = element.animate(
    [
      { transform: "translateY(0%)" },
      { transform: "translateY(100%)" },
    ],
    { duration: 3000, fill: "forwards" }
  );
  

代码示例:

code.juejin.cn/pen/7391799…

关于Flip动画

浏览器计算位置很快,绘制可能很慢。利用浏览器强大的计算能力,获取动画的起止状态,接着单独开启一个线程做动画。这样触发布局更新的操作,只会发生在一帧时间内,剩下的动画跑在单独的线程上,会更流畅。

介绍下FLIP 。

  1. F 代表 First,也就是动画的开始状态。
  2. L 代表 Last,代表动画结束状态。
  3. I 代表 Invert,也就是状态反转,使用 transform 等属性,创建单独的图层,并将元素状态反转回去。
  4. P 代表 Play,播放动画。

示例代码:

其中,在初始帧中,应用逆变换(translate 和 scale),将元素从其最终状态逆变换到初始状态。

最后一帧 transform: "none" 的作用是将元素的变换属性重置为其最终状态。具体来说,transform: "none" 表示不应用任何变换,这意味着元素将恢复到由 CSS 设置的最终位置和大小。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>FLIP Animation Example</title>
  <style>
    #box {
      width: 100px;
      height: 100px;
      background-color: #4caf50;
      position: absolute;
    }
  </style>
</head>
<body>
  <div id="box"></div>
  <button id="animateButton" style="margin-top: 300px;">Animate</button>

  <script>
    const box = document.getElementById('box');
    const button = document.getElementById('animateButton');

    button.addEventListener('click', () => {
      // First: 记录初始状态
      const first = box.getBoundingClientRect();

      // 修改元素的位置
      box.style.top = `${300}px`;
      box.style.left = `${300}px`;

      // Last: 记录最终状态
      const last = box.getBoundingClientRect();

      // Invert: 计算初始状态和最终状态之间的变换
      const deltaX = first.left - last.left;
      const deltaY = first.top - last.top;
      const deltaW = first.width / last.width;
      const deltaH = first.height / last.height;


      // 应用 FLIP 动画
      box.animate(
        [
          {
            transformOrigin: "top left",
            transform: `
            translate(${deltaX}px, ${deltaY}px)
            scale(${deltaW}, ${deltaH})`,
          },
          {
            transformOrigin: "top left",
            transform: "none",
          },
        ],
        {
          duration: 300,
          easing: "ease-in-out",
          fill: "both",
        }
      );
    });
  </script>
</body>
</html>

React下拉框接口请求hook封装

作者 Jolyne_
2025年12月26日 15:35

前言

记录一下公司 下拉框封装的 接口hook。主要是支持

  • 初始化或者指定状态下请求接口
  • 防抖搜索
  • 支持不同类型的接口(get、post)

代码

主体 hook

import { IObj, IOption, TRecord } from "@/utils/interface";
import { to } from "@/utils/tools";
import { useMount } from "@quarkunlimit/react-hooks";
import { debounce } from "lodash";
import { useEffect, useRef, useState } from "react";
import {
  IUseMountFetchDataNewProps,
  IUseMountFetchDataResult,
} from "./interface";

/**
 * 初始化请求下拉框接口
 * @param props
 * @returns
 */
export const useSearchSelectFetchNew = (
  props: IUseMountFetchDataNewProps
): IUseMountFetchDataResult => {
  const {
    fetchDataApi,
    request,
    searchParamKey,
    transformOptions,
    refreshFetch,
    initFetch = true,
    needSetExtarData = false,
  } = props;

  const [data, setData] = useState<IOption[]>([]);
  const isMount = useRef<boolean>(false);
  const originData = useRef<IOption[]>([]);

  const fetchData = async (otherRequest?: TRecord) => {
    let newRequst: IObj = {}
    newRequst = {
        page: 1,
        size: 100,
        ...request,
        ...otherRequest
    }
    const [err, res] = await to(
      (() => {
        return fetchDataApi(newRequst);
      })()
    );
    if (!(err || !res)) {
      const data = transformOptions(res);
      setData(data);
      if (!isMount.current) {
        originData.current = data;
        isMount.current = true;
      }
    }
  };

  const onSearch = debounce((value: string) => {
    if (value.trim()) {
      fetchData({
        [searchParamKey]: value,
      });
    } else {
      setData(originData.current);
    }
  }, 500);

  useEffect(() => {
    if (refreshFetch) {
      fetchData();
    }
  }, [refreshFetch]);

  useMount(() => {
    if (initFetch) {
      fetchData();
    }
  });

  const setExtarData = (list: IOption[]) => {
    const newData: IOption[] = [];
    const idSet = new Set<string>();
    for (let item of data) {
      newData.push(item);
      idSet.add(item.value);
    }

    for (let item of list) {
      if (typeof item !== "object") {
        continue;
      }
      if (idSet.has(item?.value)) {
        continue;
      }
      idSet.add(item.value);
      newData.push(item);
    }
    setData(newData);
  };

  return {
    options: data,
    onSearch,
    onFocus: () => fetchData(),
    ...(needSetExtarData ? { setExtarData } : {}),
  };
};

类型定义

import { SelectProps } from "antd";

export interface IOption {
  label: string;
  value: string;
  [key: string]: any;
}

export interface IUseMountFetchDataNewProps {
  /**@param 接口Api */
  fetchDataApi: (...arg: any) => Promise<IApiData>;
  /**@param 初始化时接口额外参数 */
  request?: TRecord & { enableFlag?: boolean; dataScopeEnableFlag?: boolean };
  /**@param 搜索时的key */
  searchParamKey: string;
  /**@function 转换数据源为options */
  transformOptions: (res: IApiData) => IOption[];
  /**@param 满足某种条件时加载数据,使用时请将 initFetch 设置为 false  */
  refreshFetch?: boolean;
  /**@param 是否挂载时默认加载数据 */
  initFetch?: boolean;
  /** @param 是否需要setExtarData */
  needSetExtarData?: boolean;
}

export interface IUseMountFetchDataResult extends SelectProps {
  /** @param 下拉框选项 */
  options: IOption[];
  /** @function 下拉框搜索 */
  onSearch: (value: string) => void;
  /** @function 手动添加额外的数据源 */
  setExtarData?: (list: IOption[]) => void;
}

使用


/** @function 获取字典值集合 */
export const sys_dict_value = (params?: IReqSysDictValueDictValue) => {
  return Service.get("/api/business/v1/sys-dict-value/dict-value", {
    params,
  }) as Promise<IResDetail<IResSysDictValueDictValue[]>>;
};

export interface IReqSysDictValueDictValue {
  /** @param 字典编码 */
  dictCode?: string;
  /** @param 字典编码集合 */
  dictCodeList?: string;
  /** @param 字典名称 */
  dictName?: string;
  /** @param 字典值 */
  dictValue?: string;
  /** @param 备注 */
  memo?: string;
}

export interface IResSysDictValueDictValue {
  dictCode: string;
  dictName: string;
  dictValue: string;
  enableFlag: boolean;
  id: string;
  memo: string;
  color: string;
  sortNum: number;
}

const TXDiplomaRadio = function TXDiplomaRadio_({
  initFetch = true,
  refreshFetch = false,
  extraReq = {},
  ...rest
}: ITXDiplomaRadioProps) {
  const { options } = useSearchSelectFetchNew({
    fetchDataApi: sys_dict_value(你的接口,返回promise),
    request: {
      dictCode: "diploma",
      ...extraReq,
    },
    initFetch,
    refreshFetch,
    searchParamKey: "dictName",
    transformOptions: (res) => {
      return res?.data?.map((x: IResSysDictValueDictValue) => ({
        label: x.dictName,
        value: x.dictValue,
      }));
    },
  });

  return <Radio.Group options={options} {...rest} />;
};

export default TXDiplomaRadio;

【AI 编程实战】第 5 篇:Pinia 状态管理 - 从混乱代码到优雅架构

作者 HashTang
2025年12月26日 15:12

状态管理是前端应用的"心脏",但很多人一提到 Pinia/Vuex 就头疼:Store 该怎么设计?持久化怎么做?登录态怎么维护?这篇文章以心动恋聊小程序为例,通过和 AI 的真实对话,展示如何从零搭建一个完整的用户状态管理系统。

系列专栏【AI 编程实战:TRAE SOLO 全栈开发指南】

本篇主题:Pinia 状态管理 - 从混乱代码到优雅架构

实战项目:心动恋聊 - AI 恋爱聊天助手

一、开篇:状态管理的痛点

1.1 没有状态管理时的混乱

在没有集中式状态管理之前,我的代码是这样的:

// 页面 A:登录后保存用户信息
uni.setStorageSync('userInfo', userData);
uni.setStorageSync('token', token);
uni.setStorageSync('isLoggedIn', true);

// 页面 B:读取用户信息
const userInfo = uni.getStorageSync('userInfo');
const isLoggedIn = uni.getStorageSync('isLoggedIn');

// 页面 C:更新用户信息
const userInfo = uni.getStorageSync('userInfo');
userInfo.avatar = newAvatar;
uni.setStorageSync('userInfo', userInfo);

// 页面 D:判断登录状态
if (uni.getStorageSync('token')) {
  // 已登录逻辑
}

问题显而易见

  • 每个页面都在重复读写 Storage
  • 状态分散在各处,难以追踪
  • 修改一处,容易漏改其他地方
  • 没有响应式,页面不会自动更新

1.2 我需要什么

我需要一个集中式的用户状态管理

  • 统一管理用户信息、登录态
  • 支持持久化(刷新不丢失)
  • 响应式更新(数据变化,页面自动刷新)
  • 多种登录方式支持(微信、手机号)

但我对 Pinia 不熟悉,于是开始和 AI 对话。

二、基础 Store 搭建

2.1 我的第一句话

我:项目需要用户状态管理,用 Pinia 实现。
    要管理用户信息、登录状态,支持登录、退出功能。

2.2 AI 先问问题

AI:好的,我来帮你搭建用户状态管理。先确认几个问题:

🤔 问题:
1. 项目技术栈是什么?(Vue3/UniApp/其他)
2. 用户信息需要哪些字段?
3. 登录方式有哪些?(账号密码/微信/手机号)
4. 需要持久化吗?(刷新页面后保持登录)

请告诉我这些信息,我来设计 Store 结构。

2.3 我补充信息

我:UniApp + Vue3 项目,跑微信小程序。
    用户信息:user_id、username、avatar、gender、phone_number、vip_status 这些。
    登录方式:主要是微信登录,后面可能加手机号登录。
    需要持久化,用户登录后刷新不能退出。

2.4 AI 给出执行计划

AI:明白了,我来设计 Store 结构。

📋 执行计划:

1. 创建 src/store/index.ts - Pinia 入口配置
2. 创建 src/store/user.ts - 用户状态管理
3. 在 main.ts 中注册 Pinia

📐 Store 结构设计:

src/store/
├── index.ts      # Pinia 实例 + 插件配置
└── user.ts       # 用户状态管理
    ├── state     # userInfo, isLoggedIn
    ├── getters   # genderDisplay, isVip
    └── actions   # login, logout, updateUserInfo

🤔 关于语法风格,有两种选择:
1. Options API 风格:state/getters/actions 分开写
2. Setup 风格:用 ref/computed/function,更灵活

你想用哪种风格?
我:用 Setup 风格吧,看起来更简洁

2.5 AI 生成基础代码

AI:好的,开始生成代码...

✅ 创建 src/store/index.ts
✅ 创建 src/store/user.ts
✅ 修改 main.ts

基础结构已完成,这是核心代码:
// src/store/index.ts
import { createPinia } from 'pinia';

const store = createPinia();

export default store;
export * from './user';
// src/store/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

// 用户信息类型
interface UserInfo {
  user_id: string;
  username: string;
  avatar: string;
  gender?: number; // 1: 男, 2: 女
  phone_number?: string;
  vip_status: 'none' | 'vip' | 'expired';
}

// 初始状态
const initialUserInfo: UserInfo = {
  user_id: '',
  username: '',
  avatar: '/static/images/default-avatar.png',
  gender: undefined,
  phone_number: '',
  vip_status: 'none',
};

export const useUserStore = defineStore('user', () => {
  // ===== 状态 =====
  const userInfo = ref<UserInfo>({ ...initialUserInfo });
  const isLoggedIn = ref(false);

  // ===== 计算属性 =====
  const isVip = computed(() => userInfo.value.vip_status === 'vip');

  // ===== 方法 =====

  /** 设置用户信息 */
  const setUserInfo = (info: UserInfo) => {
    userInfo.value = { ...initialUserInfo, ...info };
    isLoggedIn.value = !!info.user_id;
  };

  /** 清除用户信息 */
  const clearUserInfo = () => {
    userInfo.value = { ...initialUserInfo };
    isLoggedIn.value = false;
  };

  /** 退出登录 */
  const logout = () => {
    clearUserInfo();
  };

  return {
    userInfo,
    isLoggedIn,
    isVip,
    setUserInfo,
    clearUserInfo,
    logout,
  };
});
AI:基础 Store 已创建。使用方式:

// 在组件中
import { useUserStore } from '@/store'

const userStore = useUserStore()

// 读取状态
console.log(userStore.userInfo.username)
console.log(userStore.isLoggedIn)

// 调用方法
userStore.setUserInfo({ user_id: '123', username: '小明', ... })
userStore.logout()

现在可以测试一下基础功能。

三、持久化配置

3.1 我发现刷新后状态丢失

测试后,我发现一个问题:

我:登录后刷新页面,状态丢失了,又变成未登录状态。
    你不是说要支持持久化吗?

3.2 AI 解释并修复

AI:抱歉,我忘了加持久化配置。

🔍 原因:
Pinia 默认不持久化,状态只存在内存中,刷新就没了。

🔧 解决方案:
使用 pinia-plugin-persistedstate 插件,自动将状态同步到 Storage。

⚠️ 小程序注意:
小程序没有 localStorage,需要用 uni.getStorageSync/setStorageSync。

我来修改配置...
// src/store/index.ts(修改后)
import { createPinia } from 'pinia';
import { createPersistedState } from 'pinia-plugin-persistedstate';

const store = createPinia();

// 配置持久化插件,适配小程序存储 API
store.use(
  createPersistedState({
    storage: {
      getItem: uni.getStorageSync,
      setItem: uni.setStorageSync,
    },
  }),
);

export default store;
export * from './user';
// src/store/user.ts(添加持久化配置)
export const useUserStore = defineStore(
  'user',
  () => {
    // ... 原有代码不变
  },
  {
    persist: true, // 开启持久化
  },
);
AI:
✅ 已安装 pinia-plugin-persistedstate
✅ 已修改 src/store/index.ts
✅ 已修改 src/store/user.ts

现在刷新页面,登录状态会保持。

⚠️ 注意:
persist: true 会持久化整个 state。
如果某些状态不想持久化,可以用 paths 指定:

{
  persist: {
    paths: ['userInfo', 'isLoggedIn'],  // 只持久化这些
  },
}

3.3 我验证修复成功

我:好了,刷新后状态保持了。但我还有个问题:
    token 现在是存在 Store 里还是 Storage 里?

3.4 AI 解释 Token 存储策略

AI:好问题!目前 token 没有放在 Store 里。

🤔 关于 Token 存储,有两种策略:

【策略一】Token 放 Store(持久化到 Storage)
优点:统一管理,响应式
缺点:Store 初始化前无法使用

【策略二】Token 单独存 Storage(推荐)
优点:HTTP 拦截器可以直接读取,不依赖 Store 初始化
缺点:需要单独管理

📌 推荐策略二,原因:
HTTP 请求拦截器需要在应用启动最早期就能获取 token,
此时 Store 可能还没初始化完成。

代码示例:
// 登录成功时
uni.setStorageSync('token', response.token)
setUserInfo(response.user)

// HTTP 拦截器中
const token = uni.getStorageSync('token')

你想用哪种策略?
我:用策略二,Token 单独存

四、完善登录流程

4.1 我要添加微信登录

我:现在要加微信登录功能。
    流程是:调用 uni.login 获取 code → 发给后端换 token 和用户信息。

4.2 AI 设计登录方法

AI:明白,我来添加微信登录方法。

📋 执行计划:
1. 添加 wechatLogin 方法到 userStore
2. 处理登录成功:保存 token + 设置用户信息
3. 处理登录失败:提示错误

🤔 问题:
1. 登录 API 接口路径是什么?
2. 登录成功后要不要自动跳转?
3. 需要 loading 和 toast 提示吗?
我:API 是 /api/auth/wechat-login,参数是 { code }。
    不需要自动跳转,由调用方决定。
    需要 toast 提示成功/失败。
AI:好的,添加登录方法:
// src/store/user.ts(添加登录方法)
import { toast } from '@/utils/toast';
import { loginByWechat } from '@/api/auth';

export const useUserStore = defineStore(
  'user',
  () => {
    // ... 原有状态和方法

    /**
     * 微信登录
     */
    const wechatLogin = async (code: string) => {
      try {
        const response = await loginByWechat({ code });

        if (response.code !== 200) {
          toast.error(response.msg || '登录失败');
          throw new Error(response.msg || '登录失败');
        }

        const { token, user } = response.data;

        // 保存 token(单独存 Storage)
        uni.setStorageSync('token', token);

        // 设置用户信息
        setUserInfo(user);

        toast.success('登录成功');
        return response.data;
      } catch (error) {
        console.error('微信登录失败:', error);
        toast.error('登录失败,请稍后再试');
        throw error;
      }
    };

    /**
     * 退出登录(更新)
     */
    const logout = () => {
      clearUserInfo();
      uni.removeStorageSync('token'); // 清除 token
      toast.success('已退出登录');
    };

    return {
      // ... 原有导出
      wechatLogin,
      logout,
    };
  },
  { persist: true },
);
AI:使用方式:

// 页面中调用
const handleWechatLogin = async () => {
  try {
    // 1. 获取微信 code
    const { code } = await new Promise((resolve, reject) => {
      uni.login({
        provider: 'weixin',
        success: resolve,
        fail: reject,
      })
    })

    // 2. 调用 Store 登录方法
    await userStore.wechatLogin(code)

    // 3. 登录成功后的操作(由页面决定)
    uni.navigateBack()
  } catch (error) {
    console.error('登录失败:', error)
  }
}

4.3 我追问:登录态初始化

我:还有个问题,应用启动时怎么恢复登录态?
    比如用户之前登录过,打开应用应该自动是登录状态。

4.4 AI 添加初始化逻辑

AI:需要添加一个初始化方法,在应用启动时调用。

🔍 初始化逻辑:
1. 检查本地是否有 token 和 userInfo
2. 如果有,恢复登录状态
3. 异步请求最新用户信息(token 可能过期)
4. 如果 token 过期,清除登录态
// src/store/user.ts(添加初始化方法)
export const useUserStore = defineStore(
  'user',
  () => {
    // ... 原有代码

    /**
     * 初始化用户状态(应用启动时调用)
     */
    const initializeUserState = () => {
      const token = uni.getStorageSync('token');
      const savedUserInfo = uni.getStorageSync('userInfo');
      const savedLoginStatus = uni.getStorageSync('isLoggedIn');

      // 检查是否有完整的登录信息
      if (token && savedUserInfo?.user_id && savedLoginStatus) {
        // 恢复登录态
        userInfo.value = savedUserInfo;
        isLoggedIn.value = true;

        // 异步获取最新用户信息(校验 token 有效性)
        loadUserInfo();
      } else {
        // 清除可能存在的无效数据
        clearUserInfo();
        uni.removeStorageSync('token');
      }
    };

    /**
     * 从服务器加载最新用户信息
     */
    const loadUserInfo = async () => {
      try {
        const response = await apiGetUserInfo();
        if (response.code === 200) {
          setUserInfo(response.data);
        } else {
          // token 可能已过期
          clearUserInfo();
          uni.removeStorageSync('token');
        }
      } catch (error) {
        console.error('获取用户信息失败:', error);
        clearUserInfo();
        uni.removeStorageSync('token');
      }
    };

    return {
      // ... 原有导出
      initializeUserState,
      loadUserInfo,
    };
  },
  { persist: true },
);
AI:在 App.vue 的 onLaunch 中调用初始化:

// App.vue
<script setup lang="ts">
import { useUserStore } from '@/store'

const userStore = useUserStore()

onLaunch(() => {
  userStore.initializeUserState()
})
</script>

这样应用启动时会自动恢复登录态。

五、计算属性与统一更新

5.1 我需要显示性别文本

我:用户信息里 gender 是数字(1=男,2=女),
    但页面上要显示"男生"、"女生",每次都要写判断很麻烦。

5.2 AI 添加计算属性

AI:用计算属性(computed)解决,在 Store 里统一处理。

这样所有页面都可以直接用 userStore.genderDisplay,不用重复判断。
// src/store/user.ts(添加计算属性)
import { GenderEnum } from 'shared-types';

export const useUserStore = defineStore(
  'user',
  () => {
    // ... 原有状态

    // ===== 计算属性 =====

    /** 性别显示文本 */
    const genderDisplay = computed(() => {
      switch (userInfo.value.gender) {
        case GenderEnum.MALE: // 1
          return '男生';
        case GenderEnum.FEMALE: // 2
          return '女生';
        default:
          return '未设置';
      }
    });

    /** 是否是 VIP */
    const isVip = computed(() => userInfo.value.vip_status === 'vip');

    /** 根据性别获取默认头像 */
    const defaultAvatarByGender = computed(() => {
      switch (userInfo.value.gender) {
        case GenderEnum.MALE:
          return '/static/images/avatar-male.png';
        case GenderEnum.FEMALE:
          return '/static/images/avatar-female.png';
        default:
          return '/static/images/avatar-default.png';
      }
    });

    return {
      // 状态
      userInfo,
      isLoggedIn,
      // 计算属性
      genderDisplay,
      isVip,
      defaultAvatarByGender,
      // 方法
      // ...
    };
  },
  { persist: true },
);
AI:使用方式:

<template>
  <view>
    <text>性别:{{ userStore.genderDisplay }}</text>
    <image :src="userStore.userInfo.avatar || userStore.defaultAvatarByGender" />
    <text v-if="userStore.isVip">VIP 用户</text>
  </view>
</template>

📌 计算属性的好处:
1. 逻辑集中,不用每个页面重复写
2. 响应式,userInfo 变化自动更新
3. 有缓存,多次访问只计算一次

5.3 我追问:更新用户信息

我:用户在"我的"页面可以修改性别、昵称,
    修改后要同步更新 Store 和服务器,怎么做比较好?

5.4 AI 添加统一更新方法

AI:添加一个统一的更新方法,封装 API 调用 + Store 更新 + 提示。

这样所有修改用户信息的地方都调用同一个方法,保证一致性。
// src/store/user.ts(添加更新方法)
import { updateUserInfo as apiUpdateUserInfo } from '@/api/auth';

/**
 * 统一更新用户信息
 * @param updateData - 要更新的字段
 * @param successMessage - 成功提示,默认"修改成功"
 */
const updateUserInfo = async (
  updateData: Partial<Pick<UserInfo, 'gender' | 'username' | 'avatar'>>,
  successMessage = '修改成功',
) => {
  try {
    // 1. 调用 API 更新服务器
    const response = await apiUpdateUserInfo(updateData);

    if (response.code === 200 && response.data) {
      // 2. 更新 Store(用服务器返回的最新数据)
      setUserInfo(response.data);
      // 3. 提示成功
      toast.success(successMessage);
    } else {
      toast.error(response.msg || '修改失败');
    }
  } catch (error) {
    console.error('更新用户信息失败:', error);
    toast.error('修改失败,请稍后再试');
  }
};
AI:使用方式:

// 修改性别
await userStore.updateUserInfo({ gender: 1 })

// 修改昵称,自定义提示
await userStore.updateUserInfo(
  { username: '新昵称' },
  '昵称修改成功'
)

// 同时修改多个
await userStore.updateUserInfo({
  username: '新昵称',
  avatar: 'https://xxx.com/avatar.png',
})

六、跨 Store 通信

6.1 我遇到跨 Store 问题

我:用户退出登录时,除了清除用户信息,还要清除聊天记录。
    聊天记录在另一个 chatSessionStore 里,怎么在 userStore 里调用它?

6.2 AI 解释跨 Store 通信

AI:Pinia 支持在一个 Store 里调用另一个 Store。

⚠️ 注意事项:
1. 必须在 action(方法)内部调用,不能在 Store 定义的顶层调用
2. 这是因为顶层调用时,Pinia 可能还没初始化完成

❌ 错误写法:
const useChatStore = useChatSessionStore()  // 顶层调用,可能报错

✅ 正确写法:
const logout = () => {
  const chatStore = useChatSessionStore()  // 方法内部调用
  chatStore.reset()
}
// src/store/user.ts(跨 Store 调用)
import { useChatSessionStore } from '@/store/chatSession';

export const useUserStore = defineStore(
  'user',
  () => {
    // ...

    /**
     * 清除用户信息
     */
    const clearUserInfo = () => {
      userInfo.value = { ...initialUserInfo };
      isLoggedIn.value = false;

      // 清除本地存储
      uni.removeStorageSync('userInfo');
      uni.removeStorageSync('token');
      uni.removeStorageSync('isLoggedIn');

      // 【跨 Store】清除聊天记录
      try {
        const chatStore = useChatSessionStore();
        chatStore.reset();
      } catch (error) {
        console.warn('清理聊天记录失败:', error);
      }
    };

    // ...
  },
  { persist: true },
);
AI:现在退出登录时会自动清除聊天记录。

📌 跨 Store 通信场景:
1. 退出登录 → 清除所有业务数据
2. 登录成功 → 初始化其他 Store
3. VIP 状态变化 → 通知其他模块

如果跨 Store 调用很多,也可以考虑用事件总线解耦。

七、核心经验:状态管理的最佳实践

7.1 Store 设计原则

原则 说明 示例
单一职责 一个 Store 管理一个领域 userStore、chatStore、uiStore
状态只读 用 readonly 包装,防止外部直接修改 readonly(userInfo)
方法封装 修改状态统一通过方法,不要直接赋值 setUserInfo() 而不是 userInfo.value = xxx
计算属性 派生状态用 computed,避免重复计算 genderDisplayisVip

7.2 持久化注意事项

// ✅ 推荐:持久化配置适配小程序
store.use(
  createPersistedState({
    storage: {
      getItem: uni.getStorageSync,
      setItem: uni.setStorageSync,
    },
  }),
)

// ⚠️ 注意:Token 单独存储
uni.setStorageSync('token', token)  // 不放 Store

// ⚠️ 注意:敏感信息不要持久化
{
  persist: {
    paths: ['userInfo', 'isLoggedIn'],  // 明确指定
  },
}

7.3 跨 Store 通信规则

// ❌ 错误:在 Store 顶层调用其他 Store
const chatStore = useChatSessionStore(); // 可能报错

// ✅ 正确:在方法内部调用
const logout = () => {
  const chatStore = useChatSessionStore();
  chatStore.reset();
};

7.4 初始化时机

// App.vue - 应用启动时初始化
onLaunch(() => {
  const userStore = useUserStore();
  userStore.initializeUserState();
});

八、总结:对话中学会状态管理

8.1 迭代过程回顾

阶段 需求 成果
基础搭建 创建 Store 状态定义、基础方法
数据持久化 刷新保持登录 pinia-plugin-persistedstate 配置
登录流程 微信登录 + 初始化 wechatLogin、initializeUserState
体验优化 派生状态 + 统一更新 genderDisplay、updateUserInfo
架构完善 跨 Store 通信 clearUserInfo 中调用 chatStore

8.2 关键收获

  1. 不要一次想清楚所有细节,先搭基础框架,遇到问题再补充
  2. 让 AI 解释原理,比如"为什么 Token 不放 Store",理解后才能举一反三
  3. 注意平台差异,小程序没有 localStorage,需要用 uni.getStorageSync
  4. 状态管理不只是存数据,计算属性、方法封装、跨 Store 通信都是关键

8.3 下一篇预告

《【AI 编程实战】第 6 篇:告别复制粘贴 - 设计一个优雅的 HTTP 模块》

下一篇继续对话式协作,教你:

  • 如何设计 HTTP 请求/响应拦截器
  • Token 自动携带和刷新
  • 统一错误处理和 Loading 管理

状态管理的核心不是"用什么库",而是如何组织数据和逻辑。 通过和 AI 对话,你可以快速理清思路,少走弯路。

这是《AI 编程实战:TRAE SOLO 全栈开发指南》专栏的第五篇文章

如果这篇文章对你有帮助,请点赞、收藏、转发!

async/await 到底要不要加 try-catch?异步错误处理最佳实践

作者 刘大华
2025年12月26日 09:28

上周五下午,我正准备下班,产品经理突然跑过来:“用户反馈说提交订单后没反应,是不是又出 bug 了?”

我一查日志,发现接口报了 500 错误,但页面上什么提示都没有。

原来是我写异步请求时忘了加try-catch。用户点完提交就以为成功了,结果订单根本没生成。

那一刻的我才意识到:async/await 错误处理真的不能省。


先来理解 async/await 是什么

简单来说,async/await是处理异步操作的语法糖,让异步代码看起来像同步代码一样直观。

没有 async/await 的时代:

// 回调地狱
fetchData(function(result1) {
  fetchMoreData(result1, function(result2) {
    fetchEvenMoreData(result2, function(result3) {
      // 更多嵌套...
    })
  })
})

有了 async/await 之后:

// 同步般的写法
async function getData() {
  const result1 = await fetchData()
  const result2 = await fetchMoreData(result1)
  const result3 = await fetchEvenMoreData(result2)
  return result3
}

是不是清爽多了?但是,如果 await 后面的 Promise 发生错误(比如网络请求失败),这个错误会直接抛出,如果不捕获,就会导致程序崩溃。


什么时候必须加 try-catch?

1. 需要给用户明确反馈的场景

举个例子:用户点击提交按钮,如果失败了却没有任何提示,用户会以为提交成功,这体验多差啊!

<template>
  <div>
    <button @click="submitOrder" :disabled="loading">
      {{ loading ? '提交中...' : '提交订单' }}
    </button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      loading: false,
      successMessage: '',
      errorMessage: ''
    }
  },
  methods: {
    async submitOrder() {
      // 开始加载
      this.loading = true
      this.errorMessage = ''
      
      try {
        // 尝试提交订单
        const result = await this.$http.post('/api/orders', this.orderData)
        
        // 提交成功
        this.successMessage = '订单提交成功!'
        this.$router.push('/success') // 跳转到成功页面
        
      } catch (error) {
        // 根据不同错误类型给用户不同的提示
        if (error.response?.status === 401) {
          this.errorMessage = '请先登录后再提交订单'
        } else if (error.response?.status === 400) {
          this.errorMessage = '订单数据有误,请检查后重试'
        } else if (error.response?.status === 500) {
          this.errorMessage = '服务器繁忙,请稍后重试'
        } else {
          this.errorMessage = '网络错误,请检查网络连接'
        }
      } finally {
        // 无论成功失败,都要取消加载状态
        this.loading = false
      }
    }
  }
}
</script>

关键点:

  • 用户操作必须有反馈
  • 不同错误给出不同提示
  • 使用 finally 确保加载状态正确重置

2. 需要继续执行后续逻辑的场景

有时候,即使某个请求失败了,我们仍然希望继续执行其他操作。

async function initializePage() {
  // 获取用户基本信息(重要)
  try {
    this.userInfo = await this.$http.get('/api/user/info')
  } catch (error) {
    console.error('获取用户信息失败,但页面仍可正常使用')
    // 即使失败,也继续执行下面的逻辑
  }
  
  // 获取用户设置(重要)
  try {
    this.userSettings = await this.$http.get('/api/user/settings')
  } catch (error) {
    console.error('获取用户设置失败')
    // 使用默认设置继续
    this.userSettings = this.defaultSettings
  }
  
  // 获取推荐内容(非关键,失败也没关系)
  try {
    this.recommendations = await this.$http.get('/api/recommendations')
  } catch (error) {
    // 静默失败,不影响主要功能
    console.warn('推荐内容加载失败')
  }
}

什么时候可以不加 try-catch?

1. 有全局错误拦截器的情况

如果你的项目配置了全局的 HTTP 拦截器,那么很多错误已经被统一处理了。

// http.js - 全局拦截器
this.$http.interceptors.response.use(
  response => response,
  error => {
    // 全局统一处理错误
    if (error.response?.status === 401) {
      router.push('/login')
    } else if (error.response?.status >= 500) {
      Message.error('服务器错误,请稍后重试')
    }
    return Promise.reject(error)
  }
)

// 组件中 - 不需要重复处理
async fetchData() {
  // 错误已经被全局拦截器处理了
  const data = await this.$http.get('/api/data')
  this.list = data
}

2. 错误需要向上抛出的情况

在编写可复用的函数时,通常不应该在函数内部处理错误,而是让调用方来决定如何处理。

// api/user.js - 用户相关的 API 函数
export const userApi = {
  // 不处理错误,让调用方决定如何处理
  async getUserProfile(userId) {
    const response = await this.$http.get(`/api/users/${userId}`)
    return response.data
  },
  
  async updateUserProfile(userId, profile) {
    const response = await this.$http.put(`/api/users/${userId}`, profile)
    return response.data
  }
}

// 组件中 - 调用方处理错误
export default {
  methods: {
    async loadUserProfile() {
      try {
        this.profile = await userApi.getUserProfile(this.userId)
      } catch (error) {
        this.$message.error('加载用户信息失败')
      }
    }
  }
}

让代码更简洁

每次都写 try-catch 确实有点啰嗦。我们可以封装一个工具函数:

// utils/safeAsync.js
export function safeAsync(promise) {
  return promise
    .then(data => [null, data])      // 成功:[null, 数据]
    .catch(err => [err, null])       // 失败:[错误, null]
}

使用方式:

import { safeAsync } from '@/utils/safeAsync'

async function loadUser() {
  const [err, data] = await safeAsync(getUserInfo())
  
  if (err) {
    ElMessage.error('加载失败')
    return
  }
  
  user.value = data
}

这看起来更舒服了吧?这种写法来自 Go 语言的错误优先风格,在 JS 社区也很流行。


多个请求怎么办?先别用 Promise.all

很多朋友喜欢这样写:

// 危险!一个失败,全部失败
const [user, orders] = await Promise.all([
  getUser(),
  getOrders()
])

但如果 getOrders() 挂了,getUser() 的结果也会丢掉!

正确做法:用 Promise.allSettled

const results = await Promise.allSettled([getUser(), getOrders()])

const user = results[0].status === 'fulfilled' ? results[0].value : null
const orders = results[1].status === 'fulfilled' ? results[1].value : []

// 即使订单加载失败,用户信息还能显示!

或者用我们上面的 safeAsync

const [userErr, user] = await safeAsync(getUser())
const [orderErr, orders] = await safeAsync(getOrders())

if (userErr) ElMessage.warning('用户信息加载失败')
if (orderErr) ElMessage.warning('订单加载失败')

全局错误兜底

即使你写了 try-catch,也可能漏掉。所以可以做一些兜底的操作。

比如在全局拦截器处理,或者也可以在 main.js 这样写加个安全网:

// main.js
const app = createApp(App)

// 全局 Vue 错误处理器
app.config.errorHandler = (err, instance, info) => {
  console.error('Vue 组件错误:', err, info)
  ElNotification.error({
    title: '系统异常',
    message: '页面出现错误,请刷新重试'
  })
}

app.mount('#app')

这样,就算你忘了加 try-catch,也不会让用户看到白屏!


总结

  1. 用户需要反馈时必须加 try-catch
  2. 关键业务流程必须加 try-catch
  3. 有全局处理时可以不加,避免重复
  4. 编写可复用函数时通常不加,让调用方处理
  5. 非关键操作可以不加,或简单处理

错误处理不是一刀切的事情,需要根据具体业务场景来决定。好的错误处理能让你的应用更加健壮,用户体验更好。

感谢观看,希望这篇文章能帮你理清思路!

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《代码里全是 new 对象,真的很 Low 吗?我认真想了一晚》

《Java 开发必看:什么时候用 for,什么时候用 Stream?》

《这 5 个冷门 HTML 标签,让我直接删了100 行 JS 代码》

《Vue 组件通信的 8 种最佳实践,你知道几种?》

📦 Uni ECharts 是如何使用定制 echarts 的?一篇文章轻松掌握!

作者 xiaohe0601
2025年12月22日 07:51

Uni ECharts 是适用于 uni-app 的 Apache ECharts 组件,无需繁琐的步骤即可轻松在 uni-app 平台上使用 echarts。

官网 & 文档:uni-echarts.xiaohe.ink

Github:github.com/xiaohe0601/…

🤓 前言

朋友们好啊,我是 Uni ECharts 掌门人小何。

刚才有个朋友问我:“何老师,发生甚么事了?” 我说怎么回事?给我发了两张截图。

我一看!噢,原来是昨天,有两个小程序,页面很多,一个 400 多页,一个 500 多页。

塔们说,哎…有一个说是主包实在是放不下 echarts 了,何老师你能不能教教我优化功法?帮助我改善一下我的小程序体积。

我说可以,我说你直接 npm 安装 echarts 按需引用,不好用,他不服气。

我说小朋友,你一个组件同时兼容所有端,不需要条件编译,他说你这也没用。

我说我这个有用,这是抹平差异,传统开发是讲一次编译、多端覆盖,二百多行代码的条件编译都抵不过我这个小组件。

他说要和我试试,我说可以。我一说,他啪一下就把条件编译给写出来了,很快啊,然后上来就是一个 require,吭!一个 ifdef,吭!一个 ifndef!

我全部防出去了,防出去以后自然是传统开发宜点到为止,Uni ECharts 藏在 Github 没给他看。我笑一下,准备上班,因为这时间按传统开发的点到为止他已经输了,如果 Uni ECharts 发力,一下就把他条件编译整破防了,放在 Github 没给他看。

他也承认,说条件编译写起来繁琐。啊,我收手的时间不聊了,他突然袭击说 npm 装的 echarts 不能放到分包,啊,我大意了啊,没有考虑到。

哎,他的条件编译给我脸打了一下,但是没关系啊!他也说了,他截图也说了,两分多钟以后,当时流眼泪了,捂着眼我就说停…停,然后两分多钟以后就好了。

我说小伙子你不讲武德,你不懂,他忙说何老师对不…对不起,我不懂规矩。啊,他说他是乱打的,他可不是乱打啊,ifdef、ifndef 训练有素,后来他说他练过 两年半 开源,看来是有备而来。

这两个年轻人,不讲武德。来,骗!来,偷袭!我 22 岁的老同志。这好吗?这不好。我说小朋友你不懂,开发要以和为贵,不是好勇斗狠,要讲武德。

我劝!这位年轻人,耗子尾汁,好好反思。年轻人要脚踏实地,不要急功近利,以后不要再犯这样的聪明,小聪明啊!更不要搞窝里斗!谢谢朋友们!

灵感来源 @德莱厄斯

🪄 定制 ECharts

👉 前往 Uni ECharts 官网 定制 ECharts 查看完整内容

通常情况,使用 按需导入 就能有效减小打包体积,但是在某些场景如果需要使用定制的 ECharts,在 Uni ECharts 中可以配合 provideEcharts 实现,具体参考以下步骤:

  1. 使用 ECharts 官网的 在线定制 功能根据需求选择需要使用的模块构建并下载 echarts.min.js 到本地;

  2. 由于 Vite 默认仅支持 ESM 模块,但是 ECharts 官网的在线定制功能并不支持下载 ESM 格式的产物,所以 Uni ECharts 提供了一个 CLI 工具可以轻松将其转换为 ESM 格式,使用示例如下:

    # pnpm
    pnpm dlx @uni-echarts/c2e@latest
    
    # npm
    npx @uni-echarts/c2e@latest
    
    ┌  Uni ECharts Transform CLI
    │
    ●  Transform input echarts.min.js to ESM
    │
    ◇  Input file
    │  ./echarts.min.js
    │
    ◇  Output file
    │  ./echarts.esm.js
    │
    ◇  Transform completed!
    │
    └  Output: /path/to/echarts.esm.js
    

    受限于 echarts.min.js 的内容,目前转换后的 ESM 产物不支持 Tree-Shaking,无法剔除未使用的代码,并且需要使用默认导入,示例如下:

    import echarts from "/path/to/echarts.esm.js";
    
  3. 将转换后的 echarts.esm.js 放入项目中,注意不要放到 static 目录(因为小程序仅支持 ES5,无法识别 export 语法)。

  4. 调用 provideEchartsecharts 提供给组件,根据 Uni ECharts 的引入方式参考下述指引:

    • NPM 方式

      2.0.0 开始,npm 方式可以通过修改 Vite 插件配置轻松使用!

      // vite.config.js[ts]
      import { UniEcharts } from "uni-echarts/vite";
      import { defineConfig } from "vite";
      
      export default defineConfig({
        // ...
        plugins: [
          UniEcharts({
            echarts: {
              // 传实际的 echarts 文件路径,例如:"@/plugins/echarts.esm.js"
              provide: "/path/to/echarts.esm.js",
              importType: "default"
            }
          })
        ]
      });
      

      当然,也可以手动调用,示例如下:

      import { provideEcharts } from "uni-echarts/shared";
      import echarts from "/path/to/echarts.esm.js";
      
      provideEcharts(echarts);
      
    • Uni Modules 方式

      使用 uni-modules 方式需要手动调用,示例如下:

      import { provideEcharts } from "@/uni_modules/xiaohe-echarts";
      import echarts from "/path/to/echarts.esm.js";
      
      provideEcharts(echarts);
      

因为目前转换后的 ESM 产物不支持 Tree-Shaking,所以使用定制 echarts 时不再需要调用 echarts.use 按需注册组件。

💻 使用组件

<template>
  <uni-echarts custom-class="chart" :option="option"></uni-echarts>
</template>
import { ref } from "vue";
import echarts from "/path/to/echarts.esm.js";

const option = ref({
  legend: {
    top: 10,
    left: "center"
  },
  tooltip: {
    trigger: "item",
    textStyle: {
      // #ifdef MP-WEIXIN
      // 临时解决微信小程序 tooltip 文字阴影问题
      textShadowBlur: 1
      // #endif
    }
  },
  series: [
    {
      type: "pie",
      radius: ["30%", "52%"],
      label: {
        show: false,
        position: "center"
      },
      itemStyle: {
        borderWidth: 2,
        borderColor: "#ffffff",
        borderRadius: 10
      },
      emphasis: {
        label: {
          show: true,
          fontSize: 20
        }
      }
    }
  ],
  dataset: {
    dimensions: ["来源", "数量"],
    source: [
      ["Search Engine", 1048],
      ["Direct", 735],
      ["Email", 580],
      ["Union Ads", 484],
      ["Video Ads", 300]
    ]
  }
});
.chart {
  height: 300px;
}

💡 前往 Uni ECharts 官网 快速开始 查看完整内容

❤️ 支持 & 鼓励

如果 Uni ECharts 对你有帮助,可以通过以下渠道对我们表示鼓励:

无论 ⭐️ 还是 💰 支持,我们铭记于心,这将是我们继续前进的动力,感谢您的支持!

🍵 写在最后

我是 xiaohe0601,热爱代码,目前专注于 Web 前端领域。

欢迎关注我的微信公众号「小何不会写代码」,我会不定期分享一些开发心得、最佳实践以及技术探索等内容,希望能够帮到你!

📚 推荐阅读

AI 四格笑话爆火,我做了什么?

2025年12月27日 12:15

0. 前言

在 2025年的尾巴上,发生了一件非常有趣的事,我在微信公众号上的 AI 四格漫画 意外爆火。之前公众号上发的技术文章,基本上阅读量不过 300,每天广告收益也就几毛钱。目前最火的美杜莎,浏览量已经达到惊人的 5W。这样让我不禁感叹:

十年技术无人问,一曲漫笑广人闻。


火爆之后,带来的最直接价值就是迎来了泼天富贵。从未想过有一天,我的日广告收益能达到 250+ ,目前已经连续三天高位。除了金钱,自己的作品受到欢迎,以及大家在评论区的吐槽、讨论,也为我带来了很大的情绪价值。

- -

1. 缘起

先简单介绍一下:我是一个籍籍无名的编程技术小博主,全网统一名号 张风捷特烈编程之王 是我维护的公众号,一直是输出编程技术的文章,主要以 Flutter 技术为主。
但技术文章更新的不是非常频繁,而公众号每天有一篇发文的机会。本着 不想浪费 的优良传统,在 AI 重塑一切的浪潮中,我想用 AI 画些四格漫画的笑话试试。于是开启了 慧心一笑 专栏, 《小火柴的倒霉日常》 就是第一篇,现在还没火。大家也可以点开看看,内容非常精简,就是一幅图+提示词。

这个系列整体是诙谐幽默的,下面是第一篇的内容:

一开始我是用自然语言的提示词,感觉效果并不是太好,四格漫画有着连续的信息和一致性的人物、场景等。由于编程出身,在 结构一致性 方面有着天然的敏锐嗅觉。于是基于 yaml 文件来定义统一的场景、角色、样式、色调等信息:

comic_info:
  type: "四格漫画"
  style: "手绘简笔画、柔软线条、轻松冷幽默、统一角色"
  color_scheme: "暖黄主色调,红橙色点缀,柔和明暗层次"
  character:
    name: "小火柴"
    appearance: "细长圆柱身体、红色火柴头、两根短竖眉毛、圆点眼睛、呆萌可爱"
    personality: "迷糊、天真、略倒霉"
  background_style: "白色简约背景,搭配少量手绘街景或物件增强生活感"

面板列表放在 panels 节点下,每个宫格由 panel[x] 固定场景内容。包括描述、场景、动作、表情、细节、文本等:

panels:
  panel1:
    description: "第一格:日常铺垫"
    scene: "温暖的手绘街道:地面为淡黄色纹理,简单的路灯、几株小草、远处一座小房子,空气里飘着幾颗小亮点"
    action: "小火柴双手背在身后,踩着轻快的小步子前进"
    expression: "轻松微笑,眼睛微弯"
    details: "路灯用细线勾勒,小草三两稀疏点缀,天空加几朵柔软的白云"
    text: "今天天气真好呀~"

定义完结构,一个 yaml 文件就对应了一个四格故事,把这个内容丢给 AI 生图的工具,就能得到对应的图片。


2. 关于 AI 生图工具与质量

我的理念是: 文本是一种序列的约定:

它可以视为一个四格漫画的 基因,而 AI 工具会将基因 实例化 为个体。

所以,生成图的好坏取决于两个因素:基因序列成长环境。也就是提示词好不好,以及 AI 工具厉不厉害。 AI 生图的工具有很多,单目前大多数,对于标准的四格漫画都无法准确输出,下面列举几个:

  • 即梦 AI

  • 豆包

  • Nano Banana

目前来看,国产的 AI 仍有很大的进步空间,Nano Banana 能符合我对图片产品的预期。但是 AI 正在蓬勃发展中, AI 生图也是最近一两年才逐渐可用的,我对他们的未来持有乐观的态度,包括我们国产的大模型。所以如果 成长环境 将会越来越好,那么 基因序列 本身将会成为非常重要的因素。
目前我只是简单设计了一下 yaml,按照版本控制,称为 v0.0.1 吧,后续随着创作需求的升级,我也会逐步迭代整体结构,设计更合理的 DNA 结构 😁


3. 选定方向? Flow Heart

有人问我,你是怎么想到这些稀奇古怪的方向的,而且你是怎么坚持下来的。

对于一个创作者来说,拓宽自己的边界是一个很必要的事。特别是对一个编程创作者,广泛涉猎是家常便饭。使用一切手段,解决自己遇到的问题;没有问题时就去发展自己,在新的领域中寻找问题。至于坚持嘛,遵循内心的指引,做自己喜欢的事,是不需要坚持的,就像你每天都要喝水一样自然。

可能有人会问,如果 AI 的笑话漫画没有火,你还会坚持下去吗?刚做前两个漫画文章时,还没有火,一天收入 1 块钱,我已经觉得很美滋滋了。投入的产出符合我的预期,毕竟只需要准备个笑话雏形,其他都交给 AI 写就行了。我还和女朋友炫耀:

- -

最后还是想强调一点:如果一件事,对社会、对他人没有危害,自己做着觉得开心,起来没有负担和压力,就会大胆去做。反之,可以在其他方面继续延伸,找到自己喜欢的那个领域。AI 工具的加持,让个体拥有了前所未有的能力,个人的边界可以极度拓宽。


4. 为什么会火?

第一次感觉会火,是因为擎天柱 这篇,浏览量异常上升:

从数据统计来看,发布第一天只有 102 个浏览量,和往常没什么区别。持续一周,没有任何波澜,突然在 12-20 号,增加了近 5000 的浏览量,第二天持续上涨过万,然后逐渐平息:


在第一篇爆火的后一天,慧心一笑#03 | 爸爸去钓鱼~ 数据开始上升,感觉像是连带效应:


为了验证一下是不是偶然火爆,我在 20号和 21 号又发表了两篇小笑话。结果不温不火,似乎感觉也不是必然的。 在 23 号,我发布了 慧心一笑#06 | 被美杜莎石化...,这篇在当晚直接火爆,

从数据来看,第二天浏览量直接过 2.6W,后面还有持续几天的流量:

至于为什么火爆,从阅读渠道构成来看 98.7% 的阅读量来自于公众号推荐。只能说是老天喂饭吃 ~


5. 小结一下

接下来几天的 慧心一笑#07 | 爸爸回来了...慧心一笑#09 | 农夫与蛇 也阅读过 3万。目前慧心一笑系列发布了 9 篇,阅读量超过 2.5W 的爆款有 5 篇,比例算是很高了。

感觉微信公众号的推荐阅读机制应该有所变化。另外也不是每篇都会火爆,应该和作品本身质量、流传度也有关系。这个有趣的现象让我非常欣喜,后续我还会继续创作更有意思的四格漫画,来继续验证数据。大家也可以关注 《编程之王》 公众号和我一起见证。等到第 30 篇后,我会再写一个复盘报告,和大家分享。

另外可能会有人问,你发这个就不怕别人也抄你的模式,跟你竞争吗。我只想说:


更多文章和视频知识资讯,大家可以关注我的公众号、掘金和 B 站 。让我们一起成长,变得更强。我们下次再见~

Vue3与iframe通信方案详解:本地与跨域场景

作者 小杨梅君
2025年12月27日 11:42

ps:本项目使用的vue3技术栈

Vue3与iframe通信方案详解:本地与跨域场景

本文详细介绍了在Vue3项目中,与内嵌iframe(包括本地HTML文件和服务端跨域HTML)进行双向通信的完整解决方案。核心通信方式为postMessage API,并针对不同场景提供了安全可靠的代码示例。

1. iframe加载本地HTML文件

1.1 Vue端通信代码

<template>
...
    <iframe
        ref="iframe"
        name="iframe-html"
        src="./index.html"
        width="100%"
        height="100%"
        frameborder="0"
    ></iframe>
...
</template

如何在vue端跟iframe端加载的.html文件进行通讯呢,看下面的代码

// vue端
...
const sendMsg2iframe = (msg) => {
    window["iframe-html"].sendMsg2iframe(msg);
}
...
// index.html
...
window.sendMsg2iframe = function (msg) {
    // 接收到vue端发来的消息
}
...

1.2 iframe端(index.html)通信代码

// index.html
function sendMessageToVue(messageData) {
    // 发送消息到父窗口
    window.parent.postMessage(messageData, window.location.origin);
}

// vue端
// 组件挂载时开始监听消息
onMounted(() => {
  window.addEventListener('message', handleReceiveMessage);
});

// 组件卸载时移除监听,防止内存泄漏
onUnmounted(() => {
  window.removeEventListener('message', handleReceiveMessage);
});

// 接收来自iframe消息的处理函数
const handleReceiveMessage = (event) => {
  // 重要:在实际应用中,应验证event.origin以确保安全
  // if (event.origin !== '期望的源') return;
  
  console.log('Vue组件收到来自iframe的消息:', event.data);
  // 在这里处理接收到的数据
};

2. iframe加载服务器HTML(跨域场景)

其实还是通过window的postMessage进行通讯,只不过是涉及到了跨域问题,下面是具体的代码,关键在于postMessage的第二个参数上

2.1 html端通信代码

// .html
...
// 获取url并解析出父窗口的origin
const urlParams = new URLSearchParams(window.location.search);
const parentOrigin = urlParams.get('parentOrigin') || window.location.origin;
// 监听来自父窗口的消息
window.addEventListener('message', function (event) {
    if (event.origin === parentOrigin) {
        console.log('收到来自父窗口的消息:', event.data);
        if(event.data.type === 'sendJSON2Unity'){
            window.SendJSON2Unity(event.data.data);
        }
    }
});
function sendMessageToVue(messageData) {
    // 发送消息到父窗口
    window.parent.postMessage(messageData, parentOrigin);
}
...

2.2 Vue端通信代码

// .vue
...
<iframe
    ref="iframeRef"
    name="unity-home"
    :src="violationDocumentURL"
    width="100%"
    height="100%"
    frameborder="0"
    @load="onIframeLoad">
</iframe>
...
// 这里把自己的origin通过URL参数传给iframe
const violationDocumentURL = import.meta.env.VITE_U3D_SERVICE + "具体路径" + "?parentOrigin=" + encodeURIComponent(window.location.origin);

const iframeRef = ref(null);
const iframeOrigin = ref(import.meta.env.VITE_U3D_SERVICE.replace(/\/$/, ""));  // iframe加载的资源的origin
const sendToUnity = (data) => {
    iframeRef.value.contentWindow.postMessage(
        data,
        iframeOrigin.value
    );
};

// 组件挂载时开始监听消息
onMounted(() => {
  window.addEventListener('message', handleReceiveMessage);
});

// 组件卸载时移除监听,防止内存泄漏
onUnmounted(() => {
  window.removeEventListener('message', handleReceiveMessage);
});
// 接收来自iframe的消息
const handleMessageFromIframe = (event) => {
    // 确保消息来自可信的来源
    if (event.origin === iframeOrigin.value) {
        if (event.data) {
            // do something
        }
    }
};

ok基本就是这样的

3 服务器HTML端(Unity WebGL示例)

因为我们是加载的unity的webgl包,所以最后附赠一下打出的webgl包的index.html的代码(ps:是不压缩版的)

<!DOCTYPE html>
<html lang="en-us" style="width: 100%; height: 100%">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Unity WebGL Player | NanDingGDS</title>
</head>
<body id="unity3d-body" style="text-align: center; padding: 0; border: 0; margin: 0; width: 100%; height: 100%; overflow: hidden">
<canvas id="unity-canvas" style="background: #231f20"></canvas>
<script>
/** unity的web包加载逻辑开始 */
const canvas = document.getElementById("unity-canvas");
const body = document.getElementById("unity3d-body");
const { clientHeight, clientWidth } = body;

if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) {
var meta = document.createElement("meta");
meta.name = "viewport";
meta.content = "width=device-width, height=device-height, initial-scale=1.0, user-scalable=no, shrink-to-fit=yes";
document.getElementsByTagName("head")[0].appendChild(meta);
container.className = "unity-mobile";
canvas.className = "unity-mobile";
} else {
canvas.width = clientWidth;
canvas.height = clientHeight;
}

const baseUrl = "Build/webgl";
var loaderUrl = baseUrl + ".loader.js";
var myGameInstance = null;
var script = document.createElement("script");
script.src = loaderUrl;
var config = {
dataUrl: baseUrl + ".data",
frameworkUrl: baseUrl + ".framework.js",
codeUrl: baseUrl + ".wasm",
streamingAssetsUrl: "StreamingAssets",
companyName: "DefaultCompany",
productName: "FanWeiZhang",
productVersion: "0.1.0",
};
script.onload = () => {
createUnityInstance(canvas, config, (progress) => {}).then((unityInstance) => {
myGameInstance = unityInstance;
sendMessageToVue({
type: "unityLoaded",
message: "Unity3D加载完成",
});
});
};
document.body.appendChild(script);
/** unity的web包加载逻辑结束 */

// 获取url并解析出父窗口的origin
const urlParams = new URLSearchParams(window.location.search);
const parentOrigin = urlParams.get("parentOrigin") || window.location.origin;
// 监听来自父窗口的消息
window.addEventListener("message", function (event) {
if (event.origin === parentOrigin) {
console.log("收到来自父窗口的消息:", event.data);
if (event.data.type === "sendJSON2Unity") {
window.SendJSON2Unity(event.data.data);
}
}
});
function sendMessageToVue(messageData) {
// 发送消息到父窗口
window.parent.postMessage(messageData, parentOrigin);
}

window.SendJSON2Unity = function (str) {
console.log("发送到Unity的JSON字符串:", str);
myGameInstance.SendMessage("WebController", "receiveJSONByWeb", str);
};

window.QuiteUnity = function () {
console.log("退出Unity3D");
sendMessageToVue({
type: "quitUnity",
message: "退出Unity3D",
});
};
// window.js2Unity = function (str) {
// // 第一个参数是unity中物体的名称,第二是要调用的方法名称,第三个参数是unity中接收到的参数
// // myGameInstance.SendMessage('Main Camera', 'TestRotation', '')
//     console.log(str);
// }
</script>
</body>
</html>


css和图片主题色“提取”

作者 hello_Code
2025年12月27日 11:33

这个想法是来源于「性能优化」中的骨架屏: 在图片居多的站点中,这将是非常nice的体验 —— 图片加载通常是比较让人难受的,好的骨架中一般占位图就是低像素的图片,即大体配色和变化是和实际内容一致的。 有时候比如图片不固定的,那可以使用算法获取图片的主体颜色(至少得是同色系的吧),使用纯色块占位。

再进一步想到,在一些“轻松”的场景下,我们可以让背景色/页面主题色跟随轮播图改变。至于效果嘛......你们可以想一下网易云音乐滑动切歌时的背景效果。

因为是不固定图片,所以我想到了四种方法:

  • tensorflow.js 图像色彩分析
  • canvas对图片主基调进行分析,取大概值
  • css高斯模糊
  • 上传图片时后端对图片分析处理,返回时直接返回一张低像素图片

第一种方式目前还在我的实践中,以后会单独出一篇文章;最后一种方式个人不太建议首选:首先后端处理也需要时间,另一方面毕竟也是以图片进行传输的...yee~(而且后端可能也不太建议你首选🤣)

想看实际效果的推荐自己动手试下,因为我发现本文中用QQ截屏截取的图片怎么都这么暗啊,实际展示的还是挺漂亮的。

第三种方式看起来是纯css实现的,怎么获取呢?这就要说到css中的filter: blur(); 简单来说,利用模糊滤镜及进一步拉伸,可以近似地拿到一张图片的主题色:

<div></div>
div {
background: url(图片地址);
background-size: cover;
filter: blur(50px);
}

你看,通过比较大的一个模糊滤镜,将图片高斯模糊50px,模糊后的图片是不是有点内味了, ruawaba

不过还不行,存在一些模糊边缘,我们可以利用overflow进行剪裁。

接下来,我们需要去掉模糊的边角,以及通过transform: scale()放大效果,将颜色进一步聚焦: 这里就很推荐使用伪元素进行操作了

div {
position: relative;
width: xx;
height: xx;
overflow: hidden;
}
div::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url(图片地址);
background-size: cover;
filter: blur(50px);
transform: scale(2); //自行更改
transform-origin: center center;
}

ruawaba2

这样就拿到图片的主色调了。当然是要进行其他处理的。

再来说说第二种方法 —— canvas。其实也不建议,因为本身就是JS操作,而在图片又不固定又有些多的情况下单线程的js处理这种“一级事件”造成的性能和体验感的损失是不可想象的。但本文笔者还是要分享一下,因为这是我当初研究的第一个被应用的成果(有情怀了嘿嘿)

首先,canvas中的getImageData()方法可以获取图片的像素集合:

function getImagePixel(canvas, img) {
const context = canvas.getContext("2d");
context.drawImage(img, 0, 0);
return context.getImageData(0, 0, canvas.width, canvas.height).data;
}

这里对使用canvas不熟悉的同学提个醒:img是异步加载的,所有对图片的操作都要放在 img 的 onload 中进行 —— 你可以考虑用 promise 做这件事。

rgba

调用这个函数会拿到一个数组 —— 它是rgba值,也就是说,处理时四个数据为“一组”,更通俗地说,for循环中i+=4!来处理一下数据:

function getCountArr(pData) {
let colorList = [], rgba = [], rgbaStr = '';
for(let i=0; i<pData.length; i+=4) {
rgba[0] = pData[i];
rgba[1] = pData[i+1];
rgba[2] = pData[i+2];
rgba[3] = pData[i+3];
if(rgba.indexOf(undefined)!==-1 || pData[i+3] === 0) {
continue;
}
rgbaStr = rgba.join(',');
if(rgbaStr in colorList) {
++colorList[rgbaStr];
}else {
colorList[rgbaStr] = 1;
}
}
return colorList;
}

这个时候,得到的就是每组数据(色值)出现的次数了。 然后改写刚刚的getImagePixel函数:

return getCountArr(pixelData);

至此,我们将其排序并取出第一个值/或者取出某些标志项的平均值,基本上就可以将其作为 background 值了!


峰回路转!

你难道真觉得canvas的这种方法只是鸡肋?那试想这样一种场景:在弱网情况下,图片必定贼慢才能加载出来。这时候我们通过js拿到图片的主色调并填充到图片的位置中。这是不是一个“模糊渐变加载”的绝佳场景! 而且,笔者曾经遇到这样一个场景:往图片上添加文字。这时候你就需要注意一个问题,图片主色调。用canvas分析图片的主要颜色或平均色可以在深色调时添加白色文字在浅色调时添加黑色文字!

笔者前段时间弄了一个微信公众号:前端Code新谈。里面暂时有webrtc、前端面试和用户体验系列文章,欢迎关注!希望能够帮到大家,也希望能互相交流!共同进步

Echarts常用配置

作者 小白x
2025年12月27日 11:25
title设置字体

textStyle

option = {
  title: {
    text: "Main Title",
    subtext: "Sub Title",
    left: "center",
    top: "center",
    textStyle: {
      fontSize: 30,
      fontWeight:'bolder'
    },
    subtextStyle: {
      fontSize: 20
    }
  }
}
控制图表边距

grid: { top: '20%',botton:'20%',left:'10%',right:'10%' },

X轴坐标系标签,旋转角度
       xAxis: [
          {
            type: 'category',
            data: data,
            axisPointer: {
              type: 'shadow'
            },
            axisLabel: { // 坐标轴刻度标签的相关设置。
              rotate: '20' // x轴数据标签旋转角度
            }
          }
        ],
限制柱状图最大宽度
    series: [
          {
            name: '数量',
            type: 'bar',
            barMaxWidth: 50, // 最大宽度
            data: data
          }]
柱状图渐变色
series里面
    itemStyle: {
              color: new echarts.graphic.LinearGradient(
                0, 0, 0, 1, // 渐变方向从左上角到右下角
                [
                  { offset: 0, color: 'rgb(128,100,162)' }, // 0% 处的颜色
                  { offset: 1, color: '#fff' } // 100% 处的颜色
                ]
              )
            },
柱状图文字显示

直接在取消柱子上方显示具体数据信息,以及自定义信息,比如100%,数字后面加一个百分号 1)show,显示节点上的文本信息 2)position,文本位置,可以根据需要调整为 ‘top’, ‘bottom’, ‘inside’, ‘insideTop’, 等 top,表示在节点上方

series: [
    {
      data: [150, 230, 224, 218, 135, 147, 260],
      type: 'bar',
      label:{
        show:true,
        position:'top',
        formatter:function(data){
          return data.value+'件'
        }
      }
    }
  ]
折线图变平滑

series属性中使用smooth: true语句让折线图变成平滑折线图

echart柱状图最小间隔

var option = {
    // ... 其他配置项
    yAxis: {
        type: 'value',
        // 设置Y轴的最小间隔
        minInterval: 1 // 示例值,根据实际需求进行调整
    },
    // ... 其他配置项
};

立体柱状图

var xData2 = ['容城谷庄']
var data1 = [50]
option = {
    backgroundColor: 'rgba(0,0,0,0)',
    grid: {
        left: 0,
        bottom: 15,
        top: 15,
        right: 80
    },
    xAxis: {
        data: xData2,
        axisTick: {
            show: false
        },
        axisLine: {
            show: false
        },
        axisLabel: {
            show: false
        }
    },
    yAxis: {
        splitLine: {
            show: false
        },
        axisTick: {
            show: false
        },
        axisLine: {
            show: false
        },
        axisLabel: {
            // textStyle: {
            //     color: '#fff',
            //     fontSize: 20,
            // },
            // 不显示Y轴数值
            formatter: function () {
                return ''
            }
        }
    },
    series: [
        // 数据低下的圆片
        {
            name: '',
            type: 'pictorialBar',
            symbolSize: [41, 15],
            symbolOffset: [0, 8],
            z: 12,
            symbol: 'circle', // 修改为圆形
            itemStyle: {
                opacity: 1,
                color: function (params) {
                    return new echarts.graphic.LinearGradient(
                        1,
                        // 深色#2BA9ED 浅色 #34EDF2
                        0,
                        0,
                        0,
                        [
                            {
                                offset: 0,
                                color: '#E1DC53' // 0% 处的颜色
                            },
                            {
                                offset: 1,
                                color: '#E1DC53' // 100% 处的颜色
                            }
                        ],
                        false
                    )
                }
                // color: 'transparent'
            },
            data: [1]
        },
        // 数据的柱状图
        {
            name: '',
            type: 'bar',
            barWidth: 41,
            itemStyle: {
                // lenged文本
                opacity: 1, // 这个是 透明度
                color: function (params) {
                    return new echarts.graphic.LinearGradient(
                        0,
                        1,
                        0,
                        0,
                        [
                            {
                                offset: 0,
                                color: '#E1DC53' // 0% 处的颜色
                            },
                            {
                                offset: 1,
                                color: '#E8AE62' // 100% 处的颜色
                            }
                        ],
                        false
                    )
                }
            },

            data: data1
        },
        // 替代柱状图 默认不显示颜色,是最下方柱图(邮件营销)的value值 - 20
        {
            type: 'bar',
            symbol: 'circle', // 修改为圆形
            barWidth: 43,
            itemStyle: {
                color: 'transparent'
            },
            data: data1
        },
        // 数据顶部的样式
        {
            name: '',
            type: 'pictorialBar',
            symbol: 'circle', // 修改为圆形
            symbolSize: [41, 15],
            symbolOffset: [0, -8],
            z: 12,
            itemStyle: {
                normal: {
                    opacity: 1,
                    color: function (params) {
                        return new echarts.graphic.LinearGradient(
                            0,
                            0,
                            1,
                            0,
                            [
                                {
                                    offset: 0,
                                    color: '#E1DC53' // 0% 处的颜色
                                },
                                {
                                    offset: 1,
                                    color: '#E8AE62' // 100% 处的颜色
                                }
                            ],
                            false
                        )
                    },
                    label: {
                        show: true, // 开启显示
                        position: 'top', // 在上方显示
                        textStyle: {
                            // 数值样式
                            color: '#FFFFFF',
                            fontSize: 20,
                            top: 50
                        },
                        formatter: function (param) {
                            return param.data + '%'
                        }
                    }
                }
            },
            symbolPosition: 'end',
            data: data1
        },

        // 阴影的顶部
        {
            name: '', // 头部
            type: 'pictorialBar',
            symbol: 'circle', // 修改为圆形
            symbolSize: [41, 15],
            symbolOffset: [0, -8],
            z: 17,
            symbolPosition: 'end',
            itemStyle: {
                color: 'rgba(24,78,134,0.3)',
                opacity: 0.3,
                borderWidth: 1,
                borderColor: '#526558'
            },
            data: [100]
        },
        // 后面的背景
        {
            name: '2019',
            type: 'bar',
            barWidth: 41,
            barGap: '-100%',
            z: 0,
            itemStyle: {
                color: 'rgba(24,78,134,0.1)'
            },
            data: [100]
        }
    ]
}
Echarts给柱状图上面增加小横杠
 option = {
      title: {
        text: '世界人口统计',
        left: 'center',
        textStyle: {
          fontSize: 24,
          fontWeight: 'bold',
          color: '#333'
        }
      },

      xAxis: {
        type: 'value',
        boundaryGap: [0, 0.01],
        name: '人口 (万)',
        nameLocation: 'middle',
        nameGap: 30,
        axisLabel: {
          formatter: function(value) {
            if (value >= 10000) {
              return (value / 10000) + '亿';
            }
            return value;
          }
        }
      },
      yAxis: {
        type: 'category',
        data: ['巴西',  '中国', '世界'],
        axisLabel: {
          color: '#666',
          fontSize: 14
        }
      },
      series: [
        {
          name: '2011',
          type: 'bar',
          data: [10,20],
          itemStyle: {
            borderRadius: [0, 4, 4, 0],
            color: '#36A2EB'
          },
          label: {
            show: true,
            position: 'right',
            formatter: function(params) {
              return params.value.toLocaleString();
            }
          },
          markPoint: {
             symbol: 'rect',
             symbolSize: [4, 20],
            data: [
              {
                // 标记巴西 (2011)
                name: '',
                coord: [10, '巴西'],
                itemStyle: { color: '#FF6384' }
              },
              {
                // 标记中国 (2011)
                name: '',
                coord: [20, '中国'],
                itemStyle: { color: '#FF6384' }
              }
            ]
          }
        }
      ]
    };


javascript

markPoint: { symbol: 'rect', // 标记点形状为矩形 symbolSize: [4, 20], // 标记点大小 data: [ { name: '', coord: [2, 36], value: 36 } // 关键配置 ] }


  


假设这是一个**折线图或柱状图**(直角坐标系):

  


-   **`coord: [2, 36]`**  的含义:

    -   **`2`**:在 X 轴上,对应**第 3 个类目**(索引从 0 开始,例如 `['一月', '二月', '三月', ...]` 中的 `'三月'`)。
    -   **`36`**:在 Y 轴上的数值位置(例如 Y 轴范围是 0~100,标记点位于 Y=36 的高度)。

-   **效果**:在 X 轴第 3 个类目(`'三月'`)与 Y 轴数值 36 的交叉点处,绘制一个 4×20 大小的矩形标记
柱状图文字太多不显示

优化 X 轴标签显示

若标签文字过长或过多,即使调整柱子间距仍可能显示不全,需进一步配置 axisLabel

1. 强制显示所有标签(避免省略)

xAxis: {
  axisLabel: {
    interval: 0, // 强制显示所有标签(默认自动隐藏部分标签)
    // 或使用 formatter 换行(适用于长标签)
    formatter: function (value) {
      return value.split('').join('\n'); // 按字符换行(示例)
      // 或根据字数换行:return value.substr(0, 4) + '\n' + value.substr(4);
    }
  }
}

2. 旋转标签文字

通过 rotate 调整文字角度,避免重叠:

xAxis: {
  axisLabel: {
    rotate: 45, // 旋转角度(建议 30°~60°,避免垂直显示)
    margin: 10 // 标签与轴的间距,防止被柱子遮挡
  }
}

3. 自适应隐藏部分标签

若必须显示部分标签,可通过 interval 控制显示间隔(如每隔 N 个显示 1 个):

xAxis: {
  axisLabel: {
    interval: 1 // 0=全部显示,1=隔 1 个显示 1 个,2=隔 2 个显示 1 个,依此类推
  }
}
取消Y轴分割线
yAxis: {
          type: 'value',
          splitLine: {
            show: false
          }
        },
y轴上方标题
option = {
  yAxis: {
    name: '数量\n(个)',  // 名称和单位分行
    nameLocation: 'end',
    nameGap: 5,
    nameTextStyle: {
      color: '#333',       // 深色文本
      align: 'left',       // 左对齐
      lineHeight: 16,      // 行高控制间距
      padding: [0, 0, 0, -8]  // 往左偏移更多
    },
    // 其他配置...
  },
  // 其他配置...
};
设置柱状图间隔(象形图)

image.png

echart分组柱状图没数组不展示
  // 原始数据源(模拟有缺失数据的场景)
      let tufang = [100, 200, 150, 80, 70, 110, 10];
      let qiaoliang = [100, 80, 90, 0, 60, 0, 150];
      let suidao = [0, 90, 150, 80, 70, 0, 10];
      let lumian = [0, 0, 10, 80, 70, 0, 0];
      let jidian = [90, 190, 150, 0, 70, 0, 10];
      const option = {
        tooltip: {},
        title: {
          show: true,
          text: '不符合常理的柱状图表实现',
          textStyle: {
            fontSize: 14,
            lineHeight: 18,
            width: 10
          }
        },
        xAxis: [
          {
            type: 'category',
            axisLabel: {
              align: 'center',
              hideOverlap: true
            },
            data: this.specificKeys
          }
        ],
        yAxis: [
          {
            type: 'value'
          }
        ],
        series: [
          {
            type: 'custom',
            renderItem: function (params, api) {
              return getRect(params, api);
            },
            data: tufang
          },
          {
            type: 'custom',
            renderItem: function (params, api) {
              return getRect(params, api);
            },
            data: qiaoliang
          },
          {
            type: 'custom',
            renderItem: function (params, api) {
              return getRect(params, api);
            },
            data: suidao
          },
          {
            type: 'custom',
            renderItem: function (params, api) {
              return getRect(params, api);
            },
            data: lumian
          },
          {
            type: 'custom',
            renderItem: function (params, api) {
              return getRect(params, api);
            },
            data: jidian
          }
        ]
      }

      function getRect (params, api) {
        let dataSeries = [
          tufang,
          qiaoliang,
          suidao,
          lumian,
          jidian
        ]; // 确保这里有5个数据系列
        const { seriesIndex } = params;
        let categoryIndex = api.value(0); // x轴序列
        let vald = api.value(1); // 数据值
        // 如果数据为0,则不渲染柱子
        if (vald === 0) {
          return;
        }
        let start = api.coord([categoryIndex, vald]);
        let height = api.size([0, vald]);
        // 柱子宽度和间距
        let barWidth = 30; // 单个柱子的固定宽度
        let barGap = 3; // 柱子之间的间距
        // 计算当前系列的偏移量
        let xOffset = dataSeries.slice(0, seriesIndex).reduce((sum, currentSeries, index) => {
          return sum + (currentSeries[categoryIndex] !== undefined && currentSeries[categoryIndex] !== 0 ? barWidth + barGap : 0);
        }, 0);
        // 计算当前系列的x位置
        let x = start[0] - barWidth / 2 + xOffset - 10; // 柱子的中心位置 再减20是因为让其起点靠中间左边点
        return {
          type: 'rect',
          shape: {
            x: x, // 当前柱子的x位置
            y: start[1],
            width: barWidth,
            height: height[1]
          },
          style: api.style()
        };
      }
      option && this[myChart].setOption(option, true)

React 19 源码揭秘(二):useState 的实现原理

作者 借个火er
2025年12月27日 10:42

React 19 源码揭秘(二):useState 的实现原理

本文深入 React 源码,带你彻底搞懂 useState 从调用到更新的完整流程。

前言

useState 可能是你用得最多的 Hook,但你知道它背后是怎么工作的吗?

const [count, setCount] = useState(0);
setCount(count + 1);  // 这行代码背后发生了什么?

本文将从源码角度,完整解析 useState 的实现原理。

一、Hook 的数据结构

首先,我们需要了解 Hook 在 React 内部是如何存储的。

Hook 节点

每个 Hook 调用都会创建一个 Hook 对象:

type Hook = {
  memoizedState: any,    // 存储的状态值
  baseState: any,        // 基础状态(用于更新计算)
  baseQueue: Update | null,  // 基础更新队列
  queue: UpdateQueue | null, // 更新队列
  next: Hook | null,     // 指向下一个 Hook
};

Hook 链表

多个 Hook 以链表形式存储在 Fiber 节点的 memoizedState 上:

Fiber.memoizedState
        │
        ▼
    ┌───────┐     ┌───────┐     ┌───────┐
    │ Hook1 │ ──► │ Hook2 │ ──► │ Hook3 │ ──► null
    │useState│     │useEffect│    │useMemo│
    └───────┘     └───────┘     └───────┘

这就是为什么 Hook 不能在条件语句中调用——React 依赖调用顺序来匹配 Hook。

二、首次渲染:mountState

当组件首次渲染时,useState 会调用 mountState

// 源码位置:react-reconciler/src/ReactFiberHooks.js

function mountState(initialState) {
  // 1. 创建 Hook 节点,加入链表
  const hook = mountWorkInProgressHook();
  
  // 2. 处理初始值(支持函数式初始化)
  if (typeof initialState === 'function') {
    initialState = initialState();
  }
  
  // 3. 保存初始状态
  hook.memoizedState = hook.baseState = initialState;
  
  // 4. 创建更新队列
  const queue = {
    pending: null,           // 待处理的更新
    lanes: NoLanes,
    dispatch: null,          // setState 函数
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: initialState,
  };
  hook.queue = queue;
  
  // 5. 绑定 dispatch 函数(就是 setState)
  const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
  queue.dispatch = dispatch;
  
  // 6. 返回 [state, setState]
  return [hook.memoizedState, dispatch];
}

mountWorkInProgressHook

这个函数负责创建 Hook 节点并维护链表:

function mountWorkInProgressHook() {
  const hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  };

  if (workInProgressHook === null) {
    // 第一个 Hook,挂载到 Fiber
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 追加到链表末尾
    workInProgressHook = workInProgressHook.next = hook;
  }
  
  return workInProgressHook;
}

三、触发更新:dispatchSetState

当你调用 setCount(1) 时,实际执行的是 dispatchSetState

function dispatchSetState(fiber, queue, action) {
  // 1. 获取更新优先级
  const lane = requestUpdateLane(fiber);
  
  // 2. 创建更新对象
  const update = {
    lane,
    action,              // 新值或更新函数
    hasEagerState: false,
    eagerState: null,
    next: null,
  };
  
  // 3. 性能优化:Eager State(提前计算)
  if (fiber.lanes === NoLanes) {
    const currentState = queue.lastRenderedState;
    const eagerState = basicStateReducer(currentState, action);
    update.hasEagerState = true;
    update.eagerState = eagerState;
    
    // 如果新旧状态相同,跳过更新!
    if (Object.is(eagerState, currentState)) {
      return;  // Bailout!
    }
  }
  
  // 4. 将更新加入队列
  enqueueConcurrentHookUpdate(fiber, queue, update, lane);
  
  // 5. 调度更新
  scheduleUpdateOnFiber(root, fiber, lane);
}

Eager State 优化

这是一个重要的性能优化:

const [count, setCount] = useState(0);

// 点击按钮
setCount(0);  // 状态没变,React 会跳过这次更新!

React 会在调度之前就计算新状态,如果和旧状态相同(通过 Object.is 比较),直接跳过整个更新流程。

四、更新渲染:updateState

当组件重新渲染时,useState 会调用 updateState

function updateState(initialState) {
  // useState 本质上是预设了 reducer 的 useReducer
  return updateReducer(basicStateReducer, initialState);
}

// 基础 reducer:支持值或函数
function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action;
}

updateReducer

这是处理更新的核心逻辑:

function updateReducer(reducer, initialArg) {
  // 1. 获取当前 Hook
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;
  
  // 2. 获取待处理的更新
  const pending = queue.pending;
  
  // 3. 计算新状态
  let newState = hook.baseState;
  if (pending !== null) {
    let update = pending.first;
    do {
      const action = update.action;
      newState = reducer(newState, action);
      update = update.next;
    } while (update !== null);
  }
  
  // 4. 保存新状态
  hook.memoizedState = newState;
  
  // 5. 返回新状态和 dispatch
  return [hook.memoizedState, queue.dispatch];
}

updateWorkInProgressHook

更新时,需要从 current 树复制 Hook:

function updateWorkInProgressHook() {
  // 从 current Fiber 获取对应的 Hook
  let nextCurrentHook;
  if (currentHook === null) {
    const current = currentlyRenderingFiber.alternate;
    nextCurrentHook = current.memoizedState;
  } else {
    nextCurrentHook = currentHook.next;
  }
  
  currentHook = nextCurrentHook;
  
  // 复制 Hook 到 workInProgress
  const newHook = {
    memoizedState: currentHook.memoizedState,
    baseState: currentHook.baseState,
    baseQueue: currentHook.baseQueue,
    queue: currentHook.queue,
    next: null,
  };
  
  // 加入链表...
  return newHook;
}

五、完整流程图

┌─────────────────────────────────────────────────────────┐
│                    首次渲染 (Mount)                      │
├─────────────────────────────────────────────────────────┤
│  useState(0)                                            │
│      │                                                  │
│      ▼                                                  │
│  mountState(0)                                          │
│      │                                                  │
│      ├──► 创建 Hook 节点                                │
│      ├──► 初始化 memoizedState = 0                      │
│      ├──► 创建 UpdateQueue                              │
│      ├──► 绑定 dispatch = dispatchSetState              │
│      │                                                  │
│      ▼                                                  │
│  返回 [0, setCount]                                     │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                    触发更新                              │
├─────────────────────────────────────────────────────────┤
│  setCount(1)                                            │
│      │                                                  │
│      ▼                                                  │
│  dispatchSetState(fiber, queue, 1)                      │
│      │                                                  │
│      ├──► 获取优先级 lane                               │
│      ├──► 创建 Update 对象                              │
│      ├──► Eager State: 计算新状态                       │
│      ├──► 比较新旧状态,相同则 Bailout                   │
│      ├──► 入队更新                                      │
│      │                                                  │
│      ▼                                                  │
│  scheduleUpdateOnFiber() ──► 调度重新渲染               │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                    重新渲染 (Update)                     │
├─────────────────────────────────────────────────────────┤
│  useState(0)  // 初始值被忽略                           │
│      │                                                  │
│      ▼                                                  │
│  updateState(0)                                         │
│      │                                                  │
│      ▼                                                  │
│  updateReducer(basicStateReducer, 0)                    │
│      │                                                  │
│      ├──► 获取对应的 Hook                               │
│      ├──► 处理 UpdateQueue 中的更新                     │
│      ├──► 计算新状态 = 1                                │
│      │                                                  │
│      ▼                                                  │
│  返回 [1, setCount]                                     │
└─────────────────────────────────────────────────────────┘

六、Dispatcher 切换

React 如何区分 mount 和 update?答案是 Dispatcher 切换

// renderWithHooks 中
ReactSharedInternals.H = 
  current === null || current.memoizedState === null
    ? HooksDispatcherOnMount   // 首次渲染
    : HooksDispatcherOnUpdate; // 更新渲染

// 两个 Dispatcher 的 useState 指向不同函数
const HooksDispatcherOnMount = {
  useState: mountState,
  useEffect: mountEffect,
  // ...
};

const HooksDispatcherOnUpdate = {
  useState: updateState,
  useEffect: updateEffect,
  // ...
};

渲染完成后,切换到 ContextOnlyDispatcher,禁止在组件外调用 Hook:

// 渲染完成后
ReactSharedInternals.H = ContextOnlyDispatcher;

const ContextOnlyDispatcher = {
  useState: throwInvalidHookError,  // 抛出错误
  // ...
};

七、为什么 Hook 不能条件调用?

现在你应该明白了:

// ❌ 错误
if (condition) {
  const [a, setA] = useState(0);  // Hook 1
}
const [b, setB] = useState(0);    // Hook 2 或 Hook 1?

// ✅ 正确
const [a, setA] = useState(0);    // 始终是 Hook 1
const [b, setB] = useState(0);    // 始终是 Hook 2

React 通过遍历链表来匹配 Hook,如果顺序变了,状态就乱了。

八、调试技巧

想要亲自验证?在这些位置打断点:

// 首次渲染
mountState          // react-reconciler/src/ReactFiberHooks.js

// 触发更新
dispatchSetState    // react-reconciler/src/ReactFiberHooks.js

// 重新渲染
updateReducer       // react-reconciler/src/ReactFiberHooks.js

用 Counter 组件测试:

const Counter = () => {
  const [count, setCount] = useState(0);  // 断点这里
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
};

小结

本文深入分析了 useState 的实现原理:

  1. 数据结构:Hook 以链表形式存储在 Fiber.memoizedState
  2. 首次渲染:mountState 创建 Hook 和 UpdateQueue
  3. 触发更新:dispatchSetState 创建 Update,调度渲染
  4. Eager State:提前计算,相同状态跳过更新
  5. 重新渲染:updateReducer 处理更新队列,计算新状态
  6. Dispatcher:通过切换实现 mount/update 的区分

下一篇我们将分析 useEffect 的实现原理,看看副作用是如何被调度和执行的。


📦 配套源码:github.com/220529/reac…

上一篇:React 19 源码全景图

下一篇:useEffect 的实现原理

如果觉得有帮助,欢迎点赞收藏 👍

React 19 源码全景图:从宏观到微观

作者 借个火er
2025年12月27日 10:41

React 19 源码全景图:从宏观到微观

本文是 React 源码系列的总览篇,帮你建立完整的知识框架,后续文章将逐一深入。

一、React 是什么?

一句话:React 是一个将状态映射为 UI 的函数

UI = f(state)

当状态变化时,React 会:

  1. 计算新的 UI(Reconciler)
  2. 调度更新任务(Scheduler)
  3. 将变化应用到 DOM(Renderer)

二、三大核心模块

┌─────────────────────────────────────────────────────────┐
│                        你的代码                          │
│            <App /> → useState → setState                │
└─────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────┐
│                   Scheduler 调度器                       │
│                                                         │
│  • 优先级管理(用户交互 > 动画 > 数据请求)               │
│  • 时间切片(5ms 一片,避免卡顿)                        │
│  • 任务队列(最小堆实现)                                │
└─────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────┐
│                  Reconciler 协调器                       │
│                                                         │
│  • Fiber 架构(可中断的链表结构)                        │
│  • Diff 算法(最小化 DOM 操作)                          │
│  • Hooks 系统(状态和副作用管理)                        │
└─────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────┐
│                   Renderer 渲染器                        │
│                                                         │
│  • ReactDOM(Web)                                      │
│  • React Native(移动端)                               │
│  • React Three Fiber3D)                              │
└─────────────────────────────────────────────────────────┘

三、核心概念速览

1. Fiber

Fiber 是 React 的核心数据结构,每个组件对应一个 Fiber 节点:

FiberNode {
  // 类型信息
  tag,              // 组件类型(函数组件=0,类组件=1,DOM=5)
  type,             // 组件函数或 DOM 标签
  
  // 树结构
  return,           // 父节点
  child,            // 第一个子节点
  sibling,          // 兄弟节点
  
  // 状态
  memoizedState,    // Hooks 链表
  memoizedProps,    // 上次的 props
  
  // 副作用
  flags,            // 标记(插入、更新、删除)
  
  // 双缓冲
  alternate,        // 另一棵树的对应节点
}

2. Lane(优先级)

React 19 使用 31 位二进制数表示优先级:

SyncLane           = 0b0000000000000000000000000000010  // 同步(最高)
InputContinuousLane = 0b0000000000000000000000000001000  // 连续输入
DefaultLane        = 0b0000000000000000000000000100000  // 默认
TransitionLane     = 0b0000000000000000000000010000000  // 过渡
IdleLane           = 0b0010000000000000000000000000000  // 空闲(最低)

3. 双缓冲

React 维护两棵 Fiber 树:

  • current:当前屏幕显示的
  • workInProgress:正在构建的

更新完成后一行代码切换:root.current = workInProgress

四、渲染流程

完整流程图

setState() 
    │
    ▼
scheduleUpdateOnFiber()     ← 标记更新
    │
    ▼
ensureRootIsScheduled()     ← 确保调度
    │
    ▼
scheduleCallback()          ← Scheduler 调度
    │
    ▼
performConcurrentWorkOnRoot() ← 开始渲染
    │
    ├─────────────────────────────────────┐
    │         Render 阶段(可中断)         │
    │                                     │
    │  workLoopConcurrent()               │
    │      │                              │
    │      ▼                              │
    │  performUnitOfWork() ←──┐           │
    │      │                  │           │
    │      ▼                  │           │
    │  beginWork()            │ 循环      │
    │      │                  │           │
    │      ▼                  │           │
    │  completeWork() ────────┘           │
    │                                     │
    └─────────────────────────────────────┘
    │
    ▼
    ├─────────────────────────────────────┐
    │        Commit 阶段(不可中断)        │
    │                                     │
    │  commitBeforeMutationEffects()      │
    │      │                              │
    │      ▼                              │
    │  commitMutationEffects()  ← DOM操作 │
    │      │                              │
    │      ▼                              │
    │  root.current = finishedWork        │
    │      │                              │
    │      ▼                              │
    │  commitLayoutEffects()              │
    │                                     │
    └─────────────────────────────────────┘
    │
    ▼
flushPassiveEffects()       ← useEffect(异步)

Render 阶段

beginWork(向下递归):

  • 根据组件类型处理(函数组件、类组件、DOM 元素)
  • 调用组件函数,执行 Hooks
  • Diff 子节点,创建/复用 Fiber

completeWork(向上回溯):

  • 创建 DOM 节点
  • 收集副作用标记
  • 冒泡 subtreeFlags

Commit 阶段

三个子阶段:

阶段 时机 主要工作
Before Mutation DOM 操作前 getSnapshotBeforeUpdate
Mutation DOM 操作 增删改 DOM
Layout DOM 操作后 useLayoutEffect、componentDidMount

五、Hooks 原理

Hooks 以链表形式存储在 Fiber 的 memoizedState 上:

Fiber.memoizedState → useState → useEffect → useMemo → null

useState 流程

Mount:  mountState() → 创建 Hook → 初始化状态 → 返回 [state, setState]
Update: updateState() → 获取 Hook → 处理更新队列 → 返回 [newState, setState]

useEffect 流程

Mount:  mountEffect() → 创建 Effect → 标记 Passive
Commit: flushPassiveEffects() → 执行销毁函数 → 执行创建函数

六、Diff 算法

React Diff 的三个策略:

  1. 同层比较:不跨层级移动节点
  2. 类型判断:类型不同直接替换
  3. key 标识:通过 key 识别节点

单节点 Diff

key 相同 && type 相同 → 复用
key 相同 && type 不同 → 删除重建
key 不同 → 删除,继续找

多节点 Diff(三轮遍历)

第一轮:从左到右,处理更新
第二轮:处理新增或删除
第三轮:处理移动(Map 查找)

七、Scheduler 调度

优先级

ImmediatePriority   // -1ms,立即执行
UserBlockingPriority // 250ms,用户交互
NormalPriority      // 5000ms,普通更新
LowPriority         // 10000ms,低优先级
IdlePriority        // 永不过期,空闲执行

时间切片

function workLoop() {
  while (task && !shouldYield()) {  // 5ms 检查一次
    task = performTask(task);
  }
  if (task) {
    scheduleCallback(task);  // 还有任务,继续调度
  }
}

八、源码目录

packages/
├── react/                    # React API
│   └── src/ReactHooks.js     # Hooks 入口
│
├── react-reconciler/         # 协调器(核心)
│   └── src/
│       ├── ReactFiber.js           # Fiber 定义
│       ├── ReactFiberWorkLoop.js   # 工作循环
│       ├── ReactFiberBeginWork.js  # 递阶段
│       ├── ReactFiberCompleteWork.js # 归阶段
│       ├── ReactFiberHooks.js      # Hooks 实现
│       ├── ReactFiberCommitWork.js # Commit
│       ├── ReactFiberLane.js       # 优先级
│       └── ReactChildFiber.js      # Diff 算法
│
├── react-dom/                # DOM 渲染器
│
└── scheduler/                # 调度器
    └── src/
        ├── Scheduler.js          # 调度逻辑
        └── SchedulerMinHeap.js   # 最小堆

九、系列文章导航

序号 主题 核心内容
00 调试环境搭建 项目介绍、快速开始
01 架构总览(本文) 三大模块、核心概念
02 useState 原理 Hook 链表、更新队列
03 useEffect 原理 Effect 链表、执行时机
04 Fiber 工作循环 beginWork、completeWork
05 Diff 算法 单节点、多节点 Diff
06 Scheduler 调度器 优先级、时间切片
07 Commit 阶段 三个子阶段、DOM 操作

十、学习建议

  1. 先跑起来:clone react-debug,打断点调试
  2. 从 useState 开始:最简单也最核心
  3. 画流程图:边看边画,加深理解
  4. 写测试组件:验证你的理解

📦 配套源码:github.com/220529/reac…

上一篇:React 源码调试环境搭建

下一篇:useState 的实现原理

Cursor Visual Editor:前端样式调试的新利器

作者 bytemanx
2025年12月27日 03:05

作为前端开发者,你一定经历过这样的场景:为了调整一个渐变的角度、修改一个元素的行高,反复在代码和浏览器之间切换,改一行代码、保存、刷新、看效果、再改……

这种"盲调"的方式效率低下,尤其是在调试 CSS 动画这类需要精细控制的效果时,更是让人抓狂。

好消息是,Cursor 2.2 带来了一个令人兴奋的新功能——Visual Editor。它将你的 Web 应用、代码库和可视化编辑工具整合到同一个窗口中,让界面调试变得前所未有的直观。

今天,我们就通过两个炫酷的 CSS 动画案例,来体验一下这个可视化编辑器的强大之处。

认识 Visual Editor

首先,在 Cursor 中选择 Open Browser 即可打开内置浏览器小窗口:

20251227024633_rec_-convert.gif

根据 Cursor 官方博客 的介绍,Visual Editor 提供了四大核心能力:

1. 拖拽重排(Drag-and-drop)

直接在渲染好的页面上拖动元素,调整布局结构。你可以交换按钮顺序、旋转区块位置、测试不同的网格配置——所有操作都不需要切换上下文。当视觉设计符合预期后,让 Agent 帮你更新底层代码。

2. 组件状态测试(Test component states)

对于 React 应用,Visual Editor 可以在侧边栏直接显示组件的 props,让你方便地切换不同的组件状态变体。

3. 属性可视化调整(Visual controls)

这是最实用的功能之一。侧边栏提供了滑块、颜色选择器等可视化控件,支持实时预览。你可以精确调整颜色、布局、字体等属性,所有改动即时生效。

4. 点击 + 提示(Point and prompt)

选中界面上的任意元素,用自然语言描述你想要的修改。比如点击一个元素说"把这个变大",点击另一个说"改成红色"——多个 Agent 会并行执行,几秒钟内完成修改。

实战案例一:渐变流动文字

让我们用一个渐变流动文字效果来体验 Visual Editor 的威力。

效果展示

先来看看最终效果:

可视化调试体验

在 Visual Editor 中打开这个页面后,点击文字元素即可选中它:

image.png

选中后,侧边栏会显示该元素的所有可调整属性。比如我们想调整渐变的角度,只需要拖动滑块即可实时预览效果:

20251226200440_rec_.gif

想象一下,如果用传统方式调试这个角度参数:修改代码 → 保存 → 等待热更新 → 查看效果 → 不满意再改……而现在只需要拖动滑块,所见即所得!

核心原理

这个渐变流动效果的实现原理其实很简单,核心代码如下:

.text {
    /* 多色线性渐变 */
    background: linear-gradient(
        90deg,
        rgba(48, 207, 208, 1) 0%,
        rgba(102, 166, 255, 1) 22%,
        rgba(136, 136, 136, 1) 40%,
        rgba(255, 154, 139, 1) 60%,
        rgba(51, 8, 103, 1) 81%,
        rgba(48, 207, 208, 1) 100%
    );
    /* 背景宽度设为元素的 2 倍 */
    background-size: 200% auto;
    /* 将背景裁剪到文字形状 */
    -webkit-background-clip: text;
    background-clip: text;
    /* 文字颜色透明,露出背景 */
    color: transparent;
    /* 应用流动动画 */
    animation: gradient-flow 3s linear infinite;
}

@keyframes gradient-flow {
    0% {
        background-position: 0% center;
    }
    100% {
        background-position: 200% center;
    }
}

原理解析:

  1. linear-gradient:创建一个多色渐变背景,首尾颜色相同以实现无缝循环
  2. background-size: 200%:让背景宽度是元素的两倍,为动画提供移动空间
  3. background-clip: text:将背景裁剪到文字轮廓内
  4. animation:通过改变 background-position 从 0% 到 200%,让渐变"流动"起来

实战案例二:立体透视文字

接下来看一个更有意思的效果——立体透视文字。

效果展示

可视化调试体验

这个效果的视觉呈现高度依赖于 line-heightclip-height 等参数的精确配合。使用 Visual Editor,我们可以直观地调整这些数值:

20251227021832_rec_-convert.gif

通过可视化调整,你可以直观地看到参数变化对立体效果的影响,快速找到最佳的视觉平衡点。

核心原理

这个立体透视效果的核心在于 CSS 变换的巧妙组合:

:root {
    --clip-height: 90px;
    --line-height: 85px;
    --left-offset: 50px;
}

.Words-line {
    height: var(--clip-height);
    overflow: hidden;
    position: relative;
}

/* 奇数行:倾斜 + 压缩 */
.Words-line:nth-child(odd) {
    transform: skew(60deg, -30deg) scaleY(0.66667);
}

/* 偶数行:倾斜 + 拉伸 */
.Words-line:nth-child(even) {
    transform: skew(0deg, -30deg) scaleY(1.33333);
}

/* 每行递增的左偏移,形成阶梯效果 */
.Words-line:nth-child(1) { left: calc(var(--left-offset) * 1); }
.Words-line:nth-child(2) { left: calc(var(--left-offset) * 2); }
.Words-line:nth-child(3) { left: calc(var(--left-offset) * 3); }
/* ... */

原理解析:

  1. skew() 倾斜变换:通过不同的倾斜角度,让奇偶行形成视觉上的"折叠"效果
  2. scaleY() 垂直缩放:奇数行压缩(0.66667),偶数行拉伸(1.33333),配合倾斜创造 3D 透视错觉
  3. 递增的 left 偏移:每行向右偏移,形成阶梯状的立体层次
  4. overflow: hidden:裁剪超出的内容,确保每行只显示固定高度

hover 时的文字切换动画则通过 translate3d 实现:

p {
    transition: all 0.4s ease-in-out;
}

.Words:hover p {
    transform: translate3d(0, calc(var(--clip-height) * -1), 0);
}

总结

Cursor Visual Editor 的出现,真正实现了"设计即代码"的理念:

  • 所见即所得:告别反复保存刷新的低效循环,样式调整即时生效
  • 降低心智负担:不再需要脑补参数变化的效果,可视化控件让调试更直观
  • 设计与代码统一:在同一窗口完成视觉调整和代码修改,无缝衔接

这个功能特别适合以下场景:

  1. 样式微调:颜色、间距、字体大小等参数的精细调整
  2. 布局实验:快速测试不同的布局方案
  3. 动画调试:实时预览动画参数的变化效果

正如 Cursor 官方所说,他们看到了一个未来:Agent 与 Web 应用开发深度融合,人们通过更直观的界面将想法转化为代码。Visual Editor 正是朝着这个方向迈出的重要一步。

如果你还没有尝试过这个功能,强烈建议打开 Cursor,用你自己的项目体验一下——相信你会爱上这种"所见即所得"的开发方式!

Tailwind CSS:原子化 CSS 的现代开发实践

作者 Tzarevich
2025年12月27日 00:26

Tailwind CSS:原子化 CSS 的现代开发实践

在当今快速迭代的前端开发环境中,如何高效、一致且可维护地构建用户界面,成为每个团队必须面对的核心问题。传统 CSS 的命名困境、样式冗余和复用难题,催生了一种新的解决方案——原子化 CSS(Atomic CSS)。而 Tailwind CSS,正是这一理念最成功的实践者。本文将结合实际代码与开发场景,深入解析 Tailwind CSS 的核心思想、优势及最佳实践。


一、什么是原子化 CSS?

传统 CSS 倾向于“语义化命名”:我们为组件起一个名字(如 .card-title),然后为其编写样式。这种方式常被称为“面向对象 CSS”(OOCSS),它试图通过封装基类、组合多态来提升复用性。例如:

.btn { padding: 8px 16px; border-radius: 6px; }
.btn-primary { background: skyblue; color: white; }

但实践中,样式往往带有太多业务属性,导致在一个或少数类名下,样式几乎无法跨项目复用,最终演变为“一次性 CSS”。

原子化 CSS 则反其道而行之:它将样式拆解为最小、单一职责的“原子类”,每个类只控制一个具体的样式属性。例如:

  • p-4padding: 1rem
  • bg-blue-500background-color: #3b82f6
  • text-centertext-align: center

这些类名直接描述样式本身,而非内容语义。通过组合这些原子类,我们可以在 HTML 中直接构建 UI,无需离开模板文件。

将我们的 CSS 规则拆分成原子 CSS,会有大量的基类,好复用、好维护,不会重复。


二、Tailwind CSS:原子化理念的集大成者

Tailwind CSS 是一个功能优先(Utility-First)的 CSS 框架,它不提供预设组件(如 Bootstrap 的 .btn),而是提供一套完整的工具类系统。

示例:构建一个按钮

<button className="px-4 py-2 bg-[skyblue] text-white rounded-lg hover:bg-blue-200">
  提交
</button>
  • px-4 py-2:设置内边距,表示水平方向内边距1rem,垂直方向内边距0.5rem;
  • bg-[skyblue]:背景色;
  • rounded-lg:圆角,lg为large大号圆角(0.5rem = 8px);
  • hover:bg-blue-200:悬停效果,鼠标悬停时背景色变为蓝色系200深度颜色,hover为悬停伪类前缀。

所有样式一目了然,无需查阅 CSS 文件。

🤖 LLM 时代的理想搭档

随着大语言模型(LLM)的普及,用自然语言生成 UI 代码成为可能。而 Tailwind 的类名具有高度语义化、结构化、可预测的特点:

  • 开发者只需描述:“一个带圆角、蓝色背景、白色文字的按钮”
  • LLM 即可输出:<button class="px-4 py-2 bg-blue-500 text-white rounded">

相比之下,传统 CSS 需要模型同时生成 HTML 和 CSS,并保证类名匹配,难度更高。Tailwind 让“Prompt → UI” 的路径更短、更可靠


三、快速上手:基于 Vite 的项目配置

要在项目中使用 Tailwind,只需几步:

  1. 创建 Vite 项目:

    npm init vite
    
  2. 安装 Tailwind 及官方 Vite 插件:

    npm install -D tailwindcss @tailwindcss/vite
    npx tailwindcss init
    
  3. 配置 vite.config.js

    import { defineConfig } from 'vite'
    import tailwindcss from '@tailwindcss/vite' // tailwind插件
    import react from '@vitejs/plugin-react' // react插件
    
    // https://vite.dev/config/
    export default defineConfig({
    plugins: [react(), tailwindcss()],
    })
    
  4. 在入口 CSS 文件(如 index.css)中引入:

    @import "tailwindcss";
    

至此,所有原子类即可在 JSX/HTML 中直接使用,几乎无需再手写 CSS

四、性能与工程化:DocumentFragment 与 React Fragment

高效的 UI 不仅关乎视觉,也涉及性能。在动态渲染大量 DOM 节点时,减少重排/重绘至关重要。

原生优化:DocumentFragment

const fragment = document.createDocumentFragment();
fragment.appendChild(p1);
fragment.appendChild(p2);
container.appendChild(fragment); // 仅触发一次 DOM 更新

通过 DocumentFragment,我们将多个节点在内存中组装后一次性插入,显著提升性能。

React 场景:Fragment 解决单根限制

React 要求组件返回单一根节点。若需返回多个同级元素,传统做法是包裹一个无意义的 <div>,但这会污染 DOM 结构。

Tailwind + React 的最佳实践是使用 Fragment

export default function App() {
  return (
    <>
      <h1>111</h1>
      <h2>222</h2>
      <ArticleCard /> {/* 自定义卡片组件 */}
    </>
  )
}

<>...</>(即 <React.Fragment>)允许我们返回多个元素,不产生额外 DOM 节点,保持结构纯净,同时也便于一次性插入整个 UI 片段,提升渲染性能。

五、响应式设计:Mobile First 的优雅实现

Tailwind 内置响应式前缀,完美支持“移动端优先”开发策略。

基础布局(移动端垂直堆叠):

<div className="flex flex-col gap-4">
  <main className="bg-blue-100 p-4">主内容</main>
  <aside className="bg-green-100 p-4">侧边栏</aside>
</div>

增强至桌面端(水平排列):

<div className="flex flex-col md:flex-row gap-4">
  <main className="bg-blue-100 p-4 md:w-2/3">主内容</main>
  <aside className="bg-green-100 p-4 md:w-1/3">侧边栏</aside>
</div>
  • 小屏:flex-col(垂直)
  • 中屏及以上:md:flex-row(水平)

这种“渐进增强”的方式,确保了在所有设备上都有良好体验。

六、为什么选择 Tailwind?

  1. 开发效率高:样式即代码,无需上下文切换;
  2. 设计一致性:基于预设的设计系统(间距、颜色、字体等);
  3. 高度可定制:通过 tailwind.config.js 扩展主题、断点、插件;
  4. 极致性能:JIT 模式仅生成用到的 CSS,体积极小;
  5. 未来友好:与 React、Vue、Svelte 等现代框架无缝集成;
  6. AI 友好:类名结构清晰,易于 LLM 理解与生成。

七、结语

Tailwind CSS 不仅仅是一个 CSS 框架,更是一种UI 开发哲学。它通过原子化、功能优先的设计,将 CSS 从“命名的艺术”转变为“组合的科学”。正如我们在文章中所见,无论是简单的按钮、复杂的卡片,还是响应式布局,Tailwind 都能以简洁、直观的方式实现。

更重要的是,在 AI 编程时代,Tailwind 的结构化、语义化类名使其成为自然语言生成 UI 的理想载体。对于追求效率、一致性和可维护性的现代前端团队而言,Tailwind CSS 无疑是值得拥抱的利器。

“不用离开 HTML 写 CSS 了,所有的样式都在类名中。”
—— 这或许是对 Tailwind 最精炼的赞美。

参考资料

❌
❌