普通视图

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

从0到1实现通用微任务调度器:理解前端异步调度核心

作者 颜酱
2025年12月27日 20:41

从0到1实现通用微任务调度器:理解前端异步调度核心

微任务(Microtask)是前端异步编程的核心概念,广泛应用于Vue/react的更新、Promise回调、事件循环优化等场景。手动实现一个通用的微任务调度器,不仅能加深对事件循环的理解,还能解决「批量执行异步任务、统一控制执行时机」的实际开发需求。

本文将从「最小可用版」到「生产级优化版」,一步步拆解微任务调度器的实现思路,最终给出可直接复用的完整代码。

一、核心需求与设计思路

1. 要解决的问题

在前端开发中,频繁触发的异步任务(如多次更新DOM、批量数据处理)如果直接执行,会导致多次微任务创建,增加性能开销。我们需要:

  • 批量管理任务,去重且统一在微任务队列执行;

  • 支持任务执行前/后的回调(如初始化/清理逻辑);

  • 外部可通过await等待所有任务执行完成;

  • 兼容浏览器/Node.js环境(处理queueMicrotask API兼容性)。

2. 核心设计原则

  • 去重执行:用Set存储任务,避免重复添加;

  • 防重复调度:标记是否已将执行逻辑放入微任务队列,避免多次触发;

  • 状态隔离:用私有属性封装内部状态(如isFlushing),对外暴露可控的API;

  • 兼容降级:无queueMicrotask时,用Promise.resolve().then()兜底。

二、Step 1:实现最小可用版调度器

先搭建核心骨架,完成「添加任务、批量执行」的基础能力,暂不处理回调、异常、兼容等细节。

1. 定义基础类型

先明确核心类型,让代码语义更清晰:

// 待执行的任务类型(无参无返回值函数)
export type TaskToQueue = () => void;

// 取消回调的函数类型
export type Unsubscribe = () => void;

2. 核心类实现(最小版)

/**
 * 最小可用版微任务调度器
 * 核心能力:添加任务、批量在微任务执行、防重复调度
 */
export class MicroScheduler {
  // 任务队列:Set自动去重
  private readonly taskQueue = new Set<TaskToQueue>();
  // 标记是否已调度微任务执行(防重复)
  private isFlushScheduled = false;
  // 标记是否正在执行任务(避免嵌套执行)
  private isFlushing = false;

  /**
   * 添加任务到队列,并触发调度
   */
  enqueue(task: TaskToQueue): void {
    this.taskQueue.add(task);
    this.scheduleFlush();
  }

  /**
   * 调度任务执行到微任务队列(核心:防重复调度)
   */
  private scheduleFlush(): void {
    // 已调度/正在执行,直接返回
    if (this.isFlushScheduled || this.isFlushing) return;

    this.isFlushScheduled = true;
    // 核心:将执行逻辑放入微任务队列
    queueMicrotask(() => {
      this.isFlushScheduled = false;
      this.executeTasks();
    });
  }

  /**
   * 批量执行所有任务
   */
  private executeTasks(): void {
    if (this.isFlushing) return;
    this.isFlushing = true;

    // 遍历执行所有任务,执行后从队列移除
    this.taskQueue.forEach((task) => {
      task();
      this.taskQueue.delete(task);
    });

    this.isFlushing = false;
  }
}

3. 测试最小版功能

// 实例化调度器
const scheduler = new MicroScheduler();

// 添加2个任务(故意重复添加,测试去重)
const task1 = () => console.log('执行任务1');
scheduler.enqueue(task1);
scheduler.enqueue(task1); // 重复添加,Set会自动去重
scheduler.enqueue(() => console.log('执行任务2'));

// 输出结果(微任务阶段执行):
// 执行任务1
// 执行任务2

关键逻辑说明

  • scheduleFlush:核心防重复逻辑,确保多次调用enqueue仅触发一次微任务;

  • isFlushing:避免执行任务时嵌套调用executeTasks(如任务中再次enqueue);

  • Set存储任务:天然解决重复添加问题,比数组更高效。

三、Step 2:扩展核心能力

在最小版基础上,添加「同步执行、前后回调、外部等待」能力,逐步完善功能。

1. 添加同步执行方法flushSync

支持手动同步执行任务(不等待微任务):

/**
 * 同步触发任务执行(立即执行,不等待微任务)
 */
flushSync(): void {
  this.executeTasks();
}

2. 添加执行前/后回调

支持注册/取消「执行前/后」的回调,满足初始化、清理等场景:

// 新增:执行前/后回调队列
private readonly beforeFlushCallbacks = new Set<() => void>();
private readonly afterFlushCallbacks = new Set<() => void>();

/**
 * 注册「任务执行前」的回调
 * @returns 取消回调的函数
 */
onBeforeFlush(callback: () => void): Unsubscribe {
  this.beforeFlushCallbacks.add(callback);
  return () => this.beforeFlushCallbacks.delete(callback);
}

/**
 * 注册「任务执行后」的回调
 */
onAfterFlush(callback: () => void): Unsubscribe {
  this.afterFlushCallbacks.add(callback);
  return () => this.afterFlushCallbacks.delete(callback);
}

// 改造executeTasks:执行前后回调
private executeTasks(): void {
  if (this.isFlushing) return;
  this.isFlushing = true;

  // 1. 执行前置回调
  this.beforeFlushCallbacks.forEach((cb) => cb());

  // 2. 执行任务
  this.taskQueue.forEach((task) => {
    task();
    this.taskQueue.delete(task);
  });

  // 3. 执行后置回调
  this.afterFlushCallbacks.forEach((cb) => cb());

  this.isFlushing = false;
}

3. 支持外部await等待执行完成

添加tick属性,基于固定的Promise实现外部等待(核心:用初始化的Promise做「时间锚点」,避免每次创建新Promise导致等待失效):

// 新增:固定的微任务Promise(仅初始化一次)
private readonly microtaskPromise = Promise.resolve<void>(undefined);

// 公开只读属性:外部可await
readonly tick = this.microtaskPromise;

测试扩展能力

const scheduler = new MicroScheduler();

// 注册前后回调
const unsubBefore = scheduler.onBeforeFlush(() => console.log('任务执行前'));
scheduler.onAfterFlush(() => console.log('任务执行后'));

// 添加任务
scheduler.enqueue(() => console.log('执行任务'));

// 外部等待任务执行完成
async function test() {
  await scheduler.tick;
  console.log('所有任务执行完毕');
  unsubBefore(); // 取消前置回调
}

test();

// 输出顺序:
// 任务执行前
// 执行任务
// 任务执行后
// 所有任务执行完毕

四、Step 3:兼容与容错优化(生产级)

完成核心功能后,添加「环境兼容、参数校验、异常捕获」,让代码更健壮。

1. 兼容queueMicrotask API

Node.js和部分老浏览器可能未实现queueMicrotask,需降级到Promise

// 声明全局queueMicrotask类型(兼容TS)
declare const queueMicrotask: ((callback: () => void) => void) | undefined;

// 新增:兼容后的微任务执行函数
private readonly queueMicrotaskFn: (callback: () => void) => void;

// 改造构造函数:初始化兼容函数
constructor() {
  this.queueMicrotaskFn =
    typeof queueMicrotask !== 'undefined'
      ? queueMicrotask
      : (cb) => this.microtaskPromise.then(cb);
}

// 改造scheduleFlush:使用兼容后的函数
private scheduleFlush(): void {
  if (this.isFlushScheduled || this.isFlushing) return;
  this.isFlushScheduled = true;
  this.queueMicrotaskFn(() => {
    this.isFlushScheduled = false;
    this.executeTasks();
  });
}

2. 添加参数校验

避免传入非函数类型的任务/回调:

// 改造enqueue
enqueue(task: TaskToQueue): void {
  if (typeof task !== 'function') {
    throw new TypeError('enqueue 必须传入函数类型的任务');
  }
  this.taskQueue.add(task);
  this.scheduleFlush();
}

// 封装回调注册逻辑,添加校验
private registerCallback(callbacks: Set<() => void>, callback: () => void): Unsubscribe {
  if (typeof callback !== 'function') {
    throw new TypeError('回调必须是函数类型');
  }
  callbacks.add(callback);
  return () => callbacks.delete(callback);
}

// 改造onBeforeFlush/onAfterFlush,复用校验逻辑
onBeforeFlush(callback: () => void): Unsubscribe {
  return this.registerCallback(this.beforeFlushCallbacks, callback);
}

onAfterFlush(callback: () => void): Unsubscribe {
  return this.registerCallback(this.afterFlushCallbacks, callback);
}

3. 异常捕获(容错)

单个任务/回调执行失败,不影响整体流程:

private executeTasks(): void {
  if (this.isFlushing) return;
  this.isFlushing = true;

  try {
    // 1. 执行前置回调(捕获单个回调异常)
    const beforeCallbacks = Array.from(this.beforeFlushCallbacks);
    beforeCallbacks.forEach((cb) => {
      try {
        cb();
      } catch (e) {
        console.error('执行前置回调失败:', e);
      }
    });

    // 2. 执行任务(复制队列,避免迭代时修改)
    const tasks = Array.from(this.taskQueue);
    tasks.forEach((task) => {
      try {
        task();
      } catch (e) {
        console.error('执行任务失败:', e);
      }
      this.taskQueue.delete(task);
    });
  } finally {
    // 无论是否出错,都执行后置回调+重置状态
    const afterCallbacks = Array.from(this.afterFlushCallbacks);
    afterCallbacks.forEach((cb) => {
      try {
        cb();
      } catch (e) {
        console.error('执行后置回调失败:', e);
      }
    });
    this.isFlushing = false;
  }
}

五、完整生产级代码

整合所有优化,最终的完整代码如下(可直接复制复用):

// 类型命名更精准,贴合场景
export type TaskToQueue = () => void;
// 取消回调的函数类型(语义更清晰)
export type Unsubscribe = () => void;

// 声明全局 queueMicrotask 类型(兼容 Node.js 和浏览器环境)
declare const queueMicrotask: ((callback: () => void) => void) | undefined;

/**
 * 微任务调度器类
 * 核心能力:批量管理任务,统一在微任务队列执行,支持执行前后回调
 */
export class MicroScheduler {
  // ========== 私有属性(封装,外部不可访问) ==========
  // 任务队列:Set 自动去重,避免重复执行
  private readonly taskQueue = new Set<TaskToQueue>();
  // 执行前回调队列
  private readonly beforeFlushCallbacks = new Set<() => void>();
  // 执行后回调队列
  private readonly afterFlushCallbacks = new Set<() => void>();
  // 标记是否正在执行任务
  private isFlushing = false;
  // 标记是否已将 flush 调度到微任务队列
  private isFlushScheduled = false;
  // 固定的微任务 Promise,用于外部 await 等待执行完成(仅初始化一次)
  private readonly microtaskPromise = Promise.resolve<void>(undefined);
  // 兼容后的微任务执行函数(适配无 queueMicrotask 的环境)
  private readonly queueMicrotaskFn: (callback: () => void) => void;

  // ========== 公开只读属性(外部可访问,不可修改) ==========
  /**
   * 用于等待微任务执行完成的 Promise
   * 外部可通过 await scheduler.tick 等待任务执行完毕
   */
  readonly tick = this.microtaskPromise;

  constructor() {
    // 兼容 queueMicrotask API(前端工程化必备)
    this.queueMicrotaskFn =
      typeof queueMicrotask !== 'undefined'
        ? queueMicrotask
        : (cb) => this.microtaskPromise.then(cb);
  }

  // ========== 公开方法(外部可调用) ==========
  /**
   * 添加任务到调度队列,并触发异步执行调度
   * @param task 待执行的微任务(无参无返回值函数)
   */
  enqueue(task: TaskToQueue): void {
    if (typeof task !== 'function') {
      throw new TypeError('enqueue 必须传入函数类型的任务');
    }
    this.taskQueue.add(task);
    this.scheduleFlush();
  }

  /**
   * 异步触发任务批量执行(放入微任务队列)
   * 多次调用仅会触发一次微任务,避免重复执行
   */
  flush(): void {
    this.scheduleFlush();
  }

  /**
   * 同步触发任务批量执行(立即执行,不等待微任务)
   */
  flushSync(): void {
    this.executeTasks();
  }

  /**
   * 注册「任务执行前」的回调
   * @param callback 执行前的回调函数
   * @returns 取消回调的函数(调用后不再触发该回调)
   */
  onBeforeFlush(callback: () => void): Unsubscribe {
    return this.registerCallback(this.beforeFlushCallbacks, callback);
  }

  /**
   * 注册「任务执行后」的回调
   * @param callback 执行后的回调函数
   * @returns 取消回调的函数(调用后不再触发该回调)
   */
  onAfterFlush(callback: () => void): Unsubscribe {
    return this.registerCallback(this.afterFlushCallbacks, callback);
  }

  // ========== 私有方法(内部逻辑,对外隐藏) ==========
  /**
   * 调度 flush 到微任务队列(核心:防重复调度)
   */
  private scheduleFlush(): void {
    if (this.isFlushScheduled || this.isFlushing) return;

    this.isFlushScheduled = true;
    this.queueMicrotaskFn(() => {
      this.isFlushScheduled = false;
      this.executeTasks();
    });
  }

  /**
   * 核心执行逻辑:执行前后回调 + 批量执行任务
   */
  private executeTasks(): void {
    if (this.isFlushing) return;
    this.isFlushing = true;

    try {
      // 1. 执行前置回调(复制后遍历,避免迭代时修改)
      const beforeCallbacks = Array.from(this.beforeFlushCallbacks);
      beforeCallbacks.forEach((cb) => {
        try {
          cb();
        } catch (e) {
          console.error('调度器执行前置回调失败:', e);
        }
      });

      // 2. 执行所有任务(复制后遍历,避免迭代时修改队列)
      const tasks = Array.from(this.taskQueue);
      tasks.forEach((task) => {
        try {
          task();
        } catch (e) {
          // 捕获单个任务异常,不影响其他任务执行(前端容错最佳实践)
          console.error('调度器执行任务失败:', e);
        }
        this.taskQueue.delete(task);
      });
    } finally {
      // 无论是否出错,都重置状态 + 执行后置回调
      const afterCallbacks = Array.from(this.afterFlushCallbacks);
      afterCallbacks.forEach((cb) => {
        try {
          cb();
        } catch (e) {
          console.error('调度器执行后置回调失败:', e);
        }
      });
      this.isFlushing = false;
    }
  }

  /**
   * 注册回调并返回取消函数(复用逻辑)
   * @param callbacks 回调队列
   * @param callback 要注册的回调函数
   * @returns 取消回调的函数
   */
  private registerCallback(callbacks: Set<() => void>, callback: () => void): Unsubscribe {
    if (typeof callback !== 'function') {
      throw new TypeError('回调必须是函数类型');
    }
    callbacks.add(callback);
    return () => callbacks.delete(callback);
  }
}

export default MicroScheduler;

六、使用场景与扩展方向

1. 典型使用场景

  • 批量DOM更新:多次修改DOM的任务加入调度器,避免频繁重排重绘;

  • 状态管理库:如Vue的响应式更新,批量执行依赖收集后的回调;

  • 异步数据处理:批量处理接口返回的数据,统一执行后续逻辑。

2. 扩展方向

  • 添加「任务优先级」:区分高/低优先级任务,按优先级执行;

  • 支持「任务超时」:设置任务执行超时时间,超时后抛出异常;

  • 增加「任务统计」:记录任务执行数量、耗时,便于性能监控。

七、总结

实现微任务调度器的核心是「理解事件循环+控制执行时机」:

  1. Set管理任务,解决重复执行问题;

  2. isFlushScheduled/isFlushing控制调度时机,避免重复执行;

  3. 用固定的Promise做时间锚点,支持外部可靠等待;

  4. 兼容环境+异常捕获,保证生产环境可用。

从「最小可用版」到「生产级优化版」的实现过程,不仅能掌握微任务的核心逻辑,还能学习到前端工程化的最佳实践(封装、兼容、容错)。希望本文能帮助你理解异步调度的本质,也能在实际项目中落地这个通用的微任务调度器。

Tailwind CSS:用“类名编程”重构你的前端开发体验

作者 栀秋666
2025年12月27日 19:45

一、从前端“写样式”到“拼乐高”:Tailwind 是什么?

如果你还在为 .btn-primary-large-rounded-shadow-hover 这种类名而失眠,那你可能需要认识一下这位前端界的“极简主义艺术家”——Tailwind CSS

它不让你写 CSS,而是让你“用类名造 UI”。听起来像玄学?别急,举个🌰:

<button class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition">
  我是按钮,点我不会怀孕
</button>

看懂了吗?px-4 是内边距,bg-blue-600 是背景蓝,hover: 表示“当我被悬停时”,连动画过渡 transition 都给你安排得明明白白。

这不是代码,这是UI 的说明书。Tailwind 把 CSS 拆成一个个“原子类”,你只需要像搭乐高一样组合它们,就能快速构建出漂亮、响应式的界面。


二、从零开始:3 分钟搭建一个 React + Tailwind 项目(比泡面还快)

我们来走一遍真实开发流程,保证你手不抖、心不慌。

✅ 第一步:初始化 Vite 项目(现代前端的“快捷启动键”)

npm init vite

然后按提示走:

  • 项目名:my-cool-app
  • 框架:React
  • 变体:JavaScript

接着进入项目并安装依赖:

cd my-cool-app
npm install

💡 小贴士:Vite 是新时代的打包工具,快得像开了氮气加速,热更新比你换台电视还快。


✅ 第二步:安装 Tailwind(给 React 装上“喷气背包”)

npm install -D tailwindcss postcss autoprefixer

📌 注意:-D 表示开发依赖,毕竟生产环境不需要编译器帮你“拼类名”。


✅ 第三步:生成配置文件(Tailwind 的“出生证明”)

npx tailwindcss init -p

这会生成两个关键文件:

  • tailwind.config.js —— Tailwind 的“大脑”
  • postcss.config.js —— 编译流程的“交通警察”

✅ 第四步:配置内容扫描路径(防止“内存泄漏”式打包)

编辑 tailwind.config.js

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./index.html",
    "./src/**/*.{js,jsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

⚠️ 划重点:content 字段告诉 Tailwind:“只打包我实际用到的类”,否则你会得到一个包含 10,000+ 类的 CSS 文件——那不是样式表,那是《CSS 百科全书》。


✅ 第五步:引入 Tailwind(给项目注入“超能力”)

src/index.css 中加入:

@tailwind base;
@tailwind components;
@tailwind utilities;

然后在 main.jsx 引入这个 CSS:

import './index.css'

最后,启动项目:

npm run dev

🎉 成功!你现在拥有了一个 React + Vite + Tailwind 的现代化前端开发环境,可以开始“类名编程”了!


三、Tailwind 的三大绝技:响应式、状态、原子化

🔥 绝技一:移动端优先,响应式如丝般顺滑

传统写法:

@media (min-width: 768px) {
  .layout { display: flex; }
}

Tailwind 写法:

<div className="flex flex-col md:flex-row gap-4">
  <main className="md:w-2/3">主内容</main>
  <aside className="md:w-1/3">侧边栏</aside>
</div>
  • 移动端:垂直排列,占满宽度
  • md: 断点以上:水平排列,主内容 2/3,侧边栏 1/3

无需写一行媒体查询,Tailwind 已经帮你预设好断点(sm: 640px, md: 768px, lg: 1024px...),简直是“断点自由主义者”。


🔥 绝技二:状态管理不用 JS,CSS 自己搞定

想实现“鼠标悬停变色 + 渐变动画”?

<button className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded transition">
  悬停我试试?
</button>
  • hover:bg-blue-700:悬停时背景变深
  • transition:加上平滑过渡
  • 不需要 JS 监听 onMouseEnter,也不需要写额外 CSS

Tailwind 支持 focus:active:disabled: 等伪类,真正做到了“样式即交互”。


🔥 绝技三:原子化类名,组合自由度拉满

Tailwind 的每个类只做一件事:

  • text-center → 文本居中
  • mt-4 → 上边距 1rem
  • shadow-lg → 大阴影
  • rounded-xl → 超大圆角

你可以像调鸡尾酒一样混合它们:

<div className="bg-white p-6 rounded-xl shadow-lg hover:shadow-2xl transition transform hover:scale-105">
  我是一个会“呼吸”的卡片
</div>

🤯 想象一下:以前你要写 .card-hover-effect,现在直接用类名描述行为,连文档都不用写。


四、React + Tailwind:组件化的“黄金搭档”

Tailwind 和 React 是天作之合。来看一个实战例子:

const ArticleCard = ({ title, summary }) => (
  <div className="p-5 bg-white rounded-xl shadow hover:shadow-lg transition border">
    <h2 className="text-lg font-bold text-gray-800">{title}</h2>
    <p className="text-gray-500 mt-2">{summary}</p>
  </div>
);

export default function App() {
  return (
    <>
      <ArticleCard 
        title="Tailwind 真香警告" 
        summary="用 utility class 快速构建 UI,告别 SCSS 嵌套地狱" 
      />
      <ArticleCard 
        title="React 组件化哲学" 
        summary="把 UI 拆成乐高,组合出千变万化" 
      />
    </>
  );
}

你会发现:

  • 样式全部由类名控制,组件逻辑更清晰
  • 无需维护 .scss 文件,结构和样式都在 JSX 中
  • 修改 UI?改几个类名就行,不用翻遍 CSS 文件

五、常见误解 & 正确打开方式

❌ 误解一:“类名太多,HTML 变丑了”

反驳:HTML 本来就不是给人“读”的,是给浏览器“吃”的。你见过谁吐槽“这家餐厅菜单太长”吗?关键是菜好不好吃。

而且,你可以用 @apply 提取常用组合:

.btn-primary {
  @apply px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition;
}

然后在 JSX 中:

<button className="btn-primary">提交</button>

✅ 建议:小项目直接用原子类,大项目可结合 @apply 或组件封装。


❌ 误解二:“Tailwind 学习成本高”

真相:Tailwind 的命名极其规律:

  • p-{size} → padding
  • m-{direction}-{size} → margin
  • text-{color} → 文字颜色
  • w-{fraction} → 宽度(w-1/2 就是 50%)

背 10 个类,就能写 80% 的布局。官方文档搜索功能强大,Ctrl+K 一搜就出结果,比记 CSS 属性还快。


六、总结:Tailwind 是“懒人”的胜利,也是“高效者”的武器

Tailwind 并不是要取代 CSS,而是提供了一种更高效、更一致、更可控的 UI 构建方式。

传统 CSS Tailwind
写规则 → 编译 → 调试 直接用类名 → 实时预览
容易冗余、难复用 原子化、高复用
响应式需手动写 media query 断点前缀一键切换

🎯 适合谁?

  • 快速原型开发
  • 设计系统统一的项目
  • 不想写 CSS 但又想要精致 UI 的人
  • 想摆脱“.container-wrapper-inner-content-box”这种类名噩梦的人

最后一句暴言:

“未来三年,不会用 Tailwind 的前端,就像不会用 Git 的程序员。”

别等了,现在就去 npm create vite,然后 npm install -D tailwindcss,开启你的“类名编程”之旅吧!


el-table源码解读2-2——createStore()初始化方法

作者 Joie
2025年12月27日 17:37

1. createStore()初始化方法

export function createStore<T extends DefaultRow>(
  table: Table<T>,
  props: TableProps<T>
) {
  if (!table) {
    throw new Error('Table is required.')
  }

  const store = useStore<T>()
  // fix https://github.com/ElemeFE/element/issues/14075
  // related pr https://github.com/ElemeFE/element/pull/14146

  /**
   * 原始方法:_toggleAllSelection 是执行全选/取消全选的逻辑
   * 防抖包装:用 debounce 包装,延迟 10ms 执行
   * 方法替换:将防抖后的方法赋值给 toggleAllSelection
   */
  store.toggleAllSelection = debounce(store._toggleAllSelection, 10)
  Object.keys(InitialStateMap).forEach((key) => {
    /**
     * props是Table组件的props,key是InitialStateMap的key
     * 这段代码用于初始化 store 的状态:
     * 遍历 InitialStateMap 的所有 key,从 props 中取值并同步到 store。
     */
    handleValue(getArrKeysValue(props, key), key, store)
  })
  // 监听InitialStateMap中定义的所有属性
  proxyTableProps(store, props)
  return store
}
  /**
   * 原始方法:_toggleAllSelection 是执行全选/取消全选的逻辑
   * 防抖包装:用 debounce 包装,延迟 10ms 执行
   * 方法替换:将防抖后的方法赋值给 toggleAllSelection
   */
   
  store.toggleAllSelection = debounce(store._toggleAllSelection, 10)
  
// 用户点击全选框时
store.toggleAllSelection()  // 调用防抖后的方法
  → debounce 延迟 10ms
    → _toggleAllSelection()  // 执行实际逻辑
      → 修改 selection 和 isAllSelected 状态
      
为什么需要防抖?
_toggleAllSelection方法会遍历所有行数据、更新每行的选择状态、触发事件,
如果用户快速连续点击,可能会导致状态不一致、性能问题、UI闪烁,而防抖可以避免这些问题      

2. getArrKeysValue()

/**
 * 从 props 中按路径取值,支持嵌套属性(如 'treeProps.hasChildren')
 * @param props Table组件的props
 * @param key InitialStateMap的key
 * @returns
 */
function getArrKeysValue<T extends DefaultRow>(
  props: TableProps<T>,
  key: string
) {
  if ((key as keyof typeof props).includes('.')) {
    const keyList = (key as keyof typeof props).split('.')
    let value: string | Record<string, any> = props
    keyList.forEach((k) => {
      value = (value as Record<string, any>)[k]
    })
    return value
  } else {
    return (props as any)[key] as boolean | string
  }
}

3. handleValue()

/**
 * 将props的值同步到store的状态中,并处理映射关系和默认值
 * @param value 从props中按InitialStateMap的key取到的值,支持嵌套属性(如 'treeProps.hasChildren')
 * @param propsKey InitialStateMap的key
 * @param store TableStore
 */
function handleValue<T extends DefaultRow>(
  value: string | boolean | Record<string, any>,
  propsKey: string,
  store: Store<T>
) {
  // 保存从props中按InitialStateMap的key取到的原始值
  let newVal = value
  // 从InitialStateMap获取映射配置
  // 可能是字符串(如 'rowKey')或对象(如 { key: 'lazyColumnIdentifier', default: 'hasChildren' })
  let storeKey = InitialStateMap[propsKey as keyof typeof InitialStateMap]
  if (isObject(storeKey)) {
    // 如果newVal为空,则使用默认值
    newVal = newVal || storeKey.default
    storeKey = storeKey.key
  }
  ; ((store.states as any)[storeKey] as any).value = newVal
}

4. proxyTableProps()

/**
 * 用于监听 props 的变化,当 props 中的值改变时,自动同步到 store 的状态中
 * @param store
 * @param props
 */
function proxyTableProps<T extends DefaultRow>(
  store: Store<T>,
  props: TableProps<T>
) {
  // 遍历 InitialStateMap 的所有 key,为每个 key 创建一个 watch 监听器
  Object.keys(InitialStateMap).forEach((key) => {
    watch(
      // 监听 getArrKeysValue(props, key) 的返回值
      () => getArrKeysValue(props, key),
      (value) => {
        // 当值变化时,调用 handleValue 同步到 store
        handleValue(value, key, store)
      }
    )
  })
}

核心编程思维提炼

1. 配置驱动编程(Configuration-Driven Programming)

思维:将变化的部分抽离为配置,用统一逻辑处理。

// ❌ 硬编码思维(你可能会这样写)
function syncPropsToStore(props, store) {
  store.states.rowKey.value = props.rowKey
  store.states.data.value = props.data
  store.states.defaultExpandAll.value = props.defaultExpandAll
  // ... 每个都要写一遍
}

// ✅ 配置驱动思维(Element Plus 的做法)
const config = {
  rowKey: 'rowKey',
  data: 'data',
  defaultExpandAll: 'defaultExpandAll'
}
Object.keys(config).forEach(key => {
  store.states[config[key]].value = props[key]
})

实际应用场景:

  • API 字段映射:后端字段名 → 前端字段名
  • 表单验证规则:统一配置,统一处理
  • 权限控制:路由权限配置表
// 实际工作中的应用示例
const API_FIELD_MAP = {
  'user_name': 'userName',
  'create_time': 'createTime',
  'user_info.avatar': 'avatar'
}

function transformApiData(apiData) {
  const result = {}
  Object.keys(API_FIELD_MAP).forEach(apiKey => {
    const frontendKey = API_FIELD_MAP[apiKey]
    result[frontendKey] = getNestedValue(apiData, apiKey)
  })
  return result
}

2. 映射层模式(Mapping Layer Pattern)

思维:在数据源和目标之间建立映射层,解耦命名差异。

// 映射层的作用
Props 命名(用户友好)  →  映射层  →  Store 命名(内部实现)
'treeProps.hasChildren'InitialStateMap'lazyColumnIdentifier'

实际应用场景:

  • 第三方 API 对接:外部 API 字段 → 内部数据模型
  • 多语言支持:语言 key → 翻译文本
  • 状态机转换:状态名 → 状态值
// 实际工作中的应用示例
const STATUS_MAP = {
  'pending': { label: '待处理', color: 'orange', value: 0 },
  'processing': { label: '处理中', color: 'blue', value: 1 },
  'completed': { label: '已完成', color: 'green', value: 2 }
}

function getStatusInfo(status) {
  return STATUS_MAP[status] || STATUS_MAP['pending']
}

3. 数据转换管道(Data Transformation Pipeline)

思维:将复杂的数据转换拆分为多个步骤,每个步骤职责单一。

解释 reduce 和数据管道的执行过程:

reduce 方法详解

1. reduce 的基本语法

array.reduce((accumulator, currentValue) => {
  // 处理逻辑
  return newAccumulator
}, initialValue)
  • accumulator(累加器):上一次处理的结果
  • currentValue(当前值):当前处理的元素
  • initialValue(初始值):第一次处理时的初始值

2. 数据管道的执行过程

const dataPipeline = [
  (data) => transformApiFields(data),      // 步骤1:字段转换
  (data) => validateData(data),            // 步骤2:数据验证
  (data) => formatDates(data),             // 步骤3:日期格式化
  (data) => enrichData(data),              // 步骤4:数据增强
]

function processData(rawData) {
  return dataPipeline.reduce((data, transform) => transform(data), rawData)
}

3. 逐步执行过程(拆解)

等价写法:

function processData(rawData) {
  // 初始值:rawData
  let result = rawData
  
  // 第1次循环:transform = transformApiFields
  result = transformApiFields(result)
  // 此时 result = transformApiFields(rawData)
  
  // 第2次循环:transform = validateData
  result = validateData(result)
  // 此时 result = validateData(transformApiFields(rawData))
  
  // 第3次循环:transform = formatDates
  result = formatDates(result)
  // 此时 result = formatDates(validateData(transformApiFields(rawData)))
  
  // 第4次循环:transform = enrichData
  result = enrichData(result)
  // 此时 result = enrichData(formatDates(validateData(transformApiFields(rawData))))
  
  return result
}

4. 用具体例子演示

// 假设原始数据
const rawData = {
  user_name: '张三',
  create_time: '2024-01-01',
  age: 25
}

// 定义转换函数
const transformApiFields = (data) => {
  return {
    userName: data.user_name,  // 下划线转驼峰
    createTime: data.create_time,
    age: data.age
  }
}

const validateData = (data) => {
  if (!data.userName) throw new Error('用户名不能为空')
  return data
}

const formatDates = (data) => {
  return {
    ...data,
    createTime: new Date(data.createTime).toLocaleDateString()
  }
}

const enrichData = (data) => {
  return {
    ...data,
    status: 'active',
    id: Math.random().toString(36).substr(2, 9)
  }
}

// 数据管道
const dataPipeline = [
  transformApiFields,
  validateData,
  formatDates,
  enrichData
]

// 执行过程
function processData(rawData) {
  return dataPipeline.reduce((data, transform) => transform(data), rawData)
}

// 执行结果
const result = processData(rawData)
console.log(result)
// {
//   userName: '张三',
//   createTime: '2024/1/1',
//   age: 25,
//   status: 'active',
//   id: 'abc123xyz'
// }

5. 执行流程图

原始数据: { user_name: '张三', create_time: '2024-01-01', age: 25 }
    ↓
[reduce 开始,初始值 = rawData]
    ↓
步骤1: transformApiFields(rawData)
    → { userName: '张三', createTime: '2024-01-01', age: 25 }
    ↓
步骤2: validateData(上一步结果)
    → { userName: '张三', createTime: '2024-01-01', age: 25 } (验证通过)
    ↓
步骤3: formatDates(上一步结果)
    → { userName: '张三', createTime: '2024/1/1', age: 25 }
    ↓
步骤4: enrichData(上一步结果)
    → { userName: '张三', createTime: '2024/1/1', age: 25, status: 'active', id: 'abc123xyz' }
    ↓
最终结果

6. 用 for 循环等价写法(更容易理解)

function processData(rawData) {
  let result = rawData  // 初始值
  
  // 依次执行每个转换函数
  for (let i = 0; i < dataPipeline.length; i++) {
    const transform = dataPipeline[i]
    result = transform(result)  // 将上一步的结果作为下一步的输入
  }
  
  return result
}

7. 为什么用 reduce

优势:

  1. 函数式编程:更简洁、声明式
  2. 链式处理:数据像流水线一样依次处理
  3. 易于扩展:添加新步骤只需在数组中添加函数
  4. 易于测试:每个转换函数可以独立测试

8. 实际工作中的应用场景

// 场景1:表单数据处理
const formDataPipeline = [
  (data) => trimFields(data),           // 去除空格
  (data) => validateRequired(data),     // 必填验证
  (data) => validateFormat(data),       // 格式验证
  (data) => transformToApiFormat(data)  // 转换为 API 格式
]

// 场景2:列表数据处理
const listDataPipeline = [
  (data) => transformFields(data),      // 字段转换
  (data) => filterInvalid(data),        // 过滤无效数据
  (data) => sortByDate(data),           // 按日期排序
  (data) => paginate(data)              // 分页
]

// 场景3:API 响应处理
const apiResponsePipeline = [
  (data) => extractData(data),          // 提取数据
  (data) => handleError(data),          // 错误处理
  (data) => normalizeData(data),      // 数据标准化
  (data) => cacheData(data)             // 缓存数据
]

9. 调试技巧

如果想看每一步的结果:

function processData(rawData) {
  return dataPipeline.reduce((data, transform, index) => {
    console.log(`步骤 ${index + 1}:`, data)
    const result = transform(data)
    console.log(`步骤 ${index + 1} 结果:`, result)
    return result
  }, rawData)
}

总结

  • reduce 的作用:将数组中的每个函数依次执行,前一个函数的输出作为下一个函数的输入
  • 数据管道:像工厂流水线,数据依次经过每个处理步骤
  • 优势:代码简洁、易于扩展、易于测试

这就是函数式编程中的“管道模式”(Pipeline Pattern)。

面试官: “ 说一下 JS 中什么是事件循环 ? ”

作者 千寻girling
2025年12月27日 17:36

JS 中的事件循环原理以及异步执行过程

这些知识点对新手来说可能有点难,但是是必须迈过的坎,逃避是解决不了问题的,本篇文章旨在帮你彻底搞懂它们。


1. JS 是单线程的

我们都知道 JS 是单线程执行的(原因:我们不想并行地操作 DOM,DOM 树不是线程安全的,如果多线程,那会造成冲突)。

这里小说明一下:V8 是谷歌浏览器的 JS 执行引擎,在运行 JS 代码的时候,是以函数作为一个个帧(保存当前函数的执行环境)按代码的执行顺序压入执行栈(call stack)中,栈顶的函数先执行,执行完毕后弹出再执行下一个函数。其中堆是用来存放各种 JS 对象的。

image.png

假设浏览器就是上图的这种结构的话,执行同步代码是没什么问题的,如下

function foo() {
    bar()
    console.log('foo')
}
function bar() {
    baz()
    console.log('bar')
}
function baz() {
    console.log('baz')
}

foo()

我们定义了 foobarbaz 三个函数,然后调用 foo 函数,控制台输出的结果为:

baz
bar
foo

执行过程如下:

  1. 一个全局匿名函数最先执行(JS 的全局执行入口,之后的例子将忽略),遇到 foo 函数被调用,将 foo 函数压入执行栈。
  2. 执行 foo 函数,发现 foo 函数体中调用了 bar 函数,则将 bar 函数压入执行栈。
  3. 执行 bar 函数,发现 bar 函数体中调用了 baz 函数,又将 baz 函数压入执行栈。
  4. 执行 baz 函数,函数体中只有一条语句 console.log('baz'),执行,在控制台打印:baz,然后 baz 函数执行完毕弹出执行栈。
  5. 此时的栈顶为 bar 函数,bar 函数体中的 baz() 语句已经执行完,接着执行下一条语句(console.log('bar')),在控制台打印:bar,然后 bar 函数执行完毕弹出执行栈。
  6. 此时的栈顶为 foo 函数,foo 函数体中的 bar() 语句已经执行完,接着执行下一条语句(console.log('foo')),在控制台打印:foo,然后 foo 函数执行完毕弹出执行栈。
  7. 至此,执行栈为空,这一轮执行完毕。

动图展示

还是图直观点,以上步骤对应的执行流程图如下:

fc266fc5ceece50a1622961cf201eec5.gif

非动图 image.png


2. 事件循环(event loop)

  • 事件循环:JS 处理异步任务的机制,因单线程特性,通过循环读取任务队列实现非阻塞。

  • 过程:

    1. 执行同步代码(调用栈清空)。
    2. 执行所有微任务(Promise回调等),直到微任务队列清空。
    3. 执行一个宏任务(setTimeout等),然后回到步骤 2,循环往复。

我们改变一下代码 1, 如下是代码 2:

function foo() {
    bar()
    console.log('foo')
}
function bar() {
    baz()
    console.log('bar')
}
function baz() { 
    setTimeout(() => {
        console.log('setTimeout: 2s')
    }, 2000)
    console.log('baz') 
}

foo()

根据 1 中的假设,浏览器只由一个 JS 引擎构成的话,那么所有的代码必然同步执行(因为 JS 执行是单线程的,所以当前栈顶函数不管执行时间需要多久,执行栈中该函数下面的其他函数必须等它执行完弹出后才能执行(这就是代码被阻塞的意思)),执行到 baz 函数体中的 setTimeout 时应该等 2 秒,在控制台中输出 setTimeout: 2s,然后再输出:baz。所以我们期望的输出顺序应该是:setTimeout: 2s -> baz -> bar -> foo(这是错的)。

浏览器如果真这样设计的话,肯定是有问题的!遇到 AJAX 请求、setTimeout 等比较耗时的操作时,我们页面需要长时间等待,就被阻塞住啥也干不了,出现了页面 “假死”,这样绝对不是我们想要的结果。

实际当然并非我以为的那样,这里先重点提醒一下:JS 是单线程的,这一点也没错,但是浏览器中并不仅仅只是由一个 JS 引擎构成,它还包括其他的一些线程来处理别的事情。如下图 !

image.png

浏览器除了 JS 引擎(JS 执行线程,后面我们只关注 JS 引擎中的执行栈)以外,还有 Web APIs(浏览器提供的接口,这是在 JS 引擎以外的)线程、GUI 渲染线程等。JS 引擎在执行过程中,如果遇到相关的事件(DOM 操作、鼠标点击事件、滚轮事件、AJAX 请求、setTimeout 等),并不会因此阻塞,它会将这些事件移交给 Web APIs 线程处理,而自己则接着往下执行。Web APIs 则会按照一定的规则将这些事件放入一个任务队列(callback queue,也叫 task queue)中,当 JS 执行栈中的代码执行完毕以后,它就会去任务队列中获取一个事件回调放入执行栈中执行,然后如此往复,这就是所谓的事件循环机制。

线程名 作用
JS 引擎线程 也称为 JS 内核,负责处理 JavaScript 脚本。(例如 V8 引擎)负责解析 JS 脚本,运行代码。一直等待着任务队列中的任务的到来,然后加以处理。一个 Tab 页(renderer 进程)中无论什么时候都只有一个 JS 线程运行 JS 程序。
事件触发线程 归属于渲染进程而不是 JS 引擎,用来控制事件循环。当 JS 引擎执行代码块如setTimeout时(也可来自浏览器内核的其他线程,如鼠标点击、Ajax 异步请求等),会将对应任务添加到事件线程中。当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理。 注意:由于 JS 的单线程关系,所以这些待处理队列中的事件都是排队等待 JS 引擎处理,JS 引擎空闲时才会执行。
定时触发器线程 setIntervalsetTimeout所在的线程。浏览器定时计数器并不是由 JS 引擎计数的。JS 引擎是单线程的,如果处于阻塞线程状态就会影响计时的准确,因此,通过单独的线程来计时并触发定时。计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行。注意:W3C 在 HTML 标准中规定,setTimeout中低于 4ms 的时间间隔算为 4ms。
异步 HTTP 请求线程 XMLHttpRequest在连接后通过浏览器新开一个线程请求。当检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调放入事件队列中,再由 JS 引擎执行。
GUI 渲染线程 负责渲染浏览器界面,包括:解析 HTML、CSS,构建 DOM 树和 RenderObject 树,布局和绘制等。重绘(Repaint)以及回流(Reflow)处理。

这里让我们对事件循环先来做个小总结

  1. JS线程负责处理JS代码,当遇到一些异步操作的时候,则将这些异步事件移交给Web APIs 处理,自己则继续往下执行。
  2. Web APIs线程将接收到的事件按照一定规则按顺序添加到任务队列中(应该是添加到任务集合中的各个事件队列中)。
  3. JS线程处理完当前的所有任务以后(执行栈为空),它会去查看任务队列中是否有等待被处理的事件,若有,则取出一个事件回调放入执行栈中执行。
  4. 然后不断循环第3步。

让我们来看看真正的浏览器中执行是什么个流程吧!

动图展示

第二段代码 3.gif

细心的小伙伴可能有发现Web API在计时器时间到达后将匿名回调函数添加到任务队列中了,虽然定时器时间已到,但它目前并不能执行!!!因为JS的执行栈此时并非空,必须要等到当前执行栈为空后才有机会被召回到执行栈执行。由此,我们可以得出一个结论:setTimeout设置的时间其实只是最小延迟时间,而并不是确切的等待时间。(当主线程的任务耗时比较长的时候,等待时间将会变得更长


3. 事件循环(进阶)与异步

3.1 试试 setTimeout(fn, 0)

function foo() {
    console.log('foo')
}

setTimeout(function() {
    console.log('setTimeout: 0s')
}, 0);

foo();

运行结果:

foo
setTimeout: 0s
4.gif

即使 setTimeout 的延时设置为 0(实际上最小延时 >= 4ms),JS 执行栈也将该延时事件发放给 Web API 处理,Web API 再将事件添加到任务队列中,等 JS 执行栈为空时,该延时事件再压入执行栈中执行。


3.2 事件循环中的 Promise

其实以上的浏览器模型是ES5标准的,ES6+标准中的任务队列在此基础上新增了一种,变成了如下两种:

3.2.1 宏任务 / 微任务

现在W3C重新对事件循环进行了定义,取消了宏任务,取而代之的是任务队列,微任务依旧保留,优先级为最高。

MDN 官网 : 事件循环会将作业分成两类: 任务微任务。微任务具有更高的优先级,在任务队列被拉出之前,微任务队列会先被排空

任务队列(macrotask queue)普通优先级的任务,通常包括:

  • setTimeout / setInterval / setImmediate(Node.js)
  • I/O 操作(文件读写、Ajax事件 / 网络请求等)
  • UI 渲染事件 (用户交互事件)
  • 脚本整体代码(第一次执行的同步代码)

微任务队列(microtask queue)高优先级的任务,通常包括:

  • Promise.then / Promise.catch / Promise.finally
  • async/await 中 await 后面的代码(其实是 .then 的语法糖)
  • MutationObserver(浏览器)
  • process.nextTick(Node.js,优先级比普通微任务更高)
image.png

事件循环的处理流程变成了如下:

  1. JS 线程负责处理 JS 代码,当遇到一些异步操作的时候,则将这些异步事件移交给 Web APIs 处理,自己则继续往下执行。
  2. Web APIs 线程将接收到的事件按照一定规则添加到任务队列中,宏事件添加到宏任务队列中,微事件添加到微事件队列中。
  3. JS 线程处理完当前的所有任务以后(执行栈为空),它会先去微任务队列获取事件,并将微任务队列中的所有事件一件件执行完毕,直到微任务队列为空后再去宏任务队列中取出一个事件执行。
  4. 然后不断循环第 3 步。
image.png

排一下先后顺序: 执行栈 --> 微任务 --> 渲染 --> 下一个宏任务


3.2.2 单独使用 Promise

function foo() {
    console.log('foo')
}

console.log('global start')

new Promise((resolve) => {
    console.log('promise')
    resolve()
}).then(() => {
    console.log('promise then')
})

foo()

console.log('global end')

控制台输出的结果为:

global start
promise
foo
global end
promise then

动图展示

5.gif

代码执行过程解析(文字描述)

  1. 执行同步代码
  • 执行 console.log('global start'),控制台输出:global start
  1. 执行 new Promise(...)
  • 注意:在使用 new 关键字创建 Promise 对象时,传递给 Promise 的函数称为 executor

    • 当 Promise 被创建时,executor 函数会自动同步执行
    • .then 里的回调才是异步执行的部分。
  • 执行 Promise 参数中的匿名函数(同步执行):

    • 执行 console.log('promise'),控制台输出:promise

    • 执行 resolve(),将 Promise 状态变为 resolved

  • 继续执行 .then(...)

    • 遇到 .then 会将回调提交给 Web API 处理。
    • Web API 将该回调添加到 微任务队列(此时微任务队列中有一个 Promise 事件待执行)。
  1. 继续执行同步代码
  • 执行栈在提交完 Promise 事件后,继续往下执行:

    • 执行 foo() 函数,控制台输出:foo

    • 执行 console.log('global end'),控制台输出:global end

  • 至此,本轮事件循环的同步代码执行完毕,执行栈为空。

  1. 处理微任务队列
  • 事件循环机制首先查看 微任务队列 是否为空:

    • 发现有一个 Promise 事件待执行,将其压入执行栈。

    • 执行 .then 中的回调:

      • 执行 console.log('promise then'),控制台输出:promise then
    • 至此,新的一轮事件循环(Promise 事件)执行完毕,执行栈为空。

  1. 检查任务队列
  • 执行栈再次为空,事件循环:

    1. 先查看 微任务队列,发现已空。
    2. 再查看 宏任务队列,发现也为空。
  • 执行栈进入等待事件状态。


3.2.3 Promise 结合 setTimeout

function foo() {
    console.log('foo')
}

console.log('global start')

setTimeout(() => {
    console.log('setTimeout: 0s')
}, 0)

new Promise((resolve) => {
    console.log('promise')
    resolve()
}).then(() => {
    console.log('promise then')
})

foo()

console.log('global end')

控制台输出的结果为:

global start
promise
foo
global end
promise then
setTimeout: 0s

动图展示

6.gif
  1. 执行同步代码
  • 执行 console.log('global start'),控制台输出: global start
  1. 处理 setTimeout(改变部分)
  • 继续往下执行,遇到 setTimeout

    • JS 执行栈将其移交给 Web API 处理。
    • 延迟 0 秒后,Web API 将 setTimeout 事件添加到 宏任务队列(此时宏任务队列中有一个 setTimeout 事件待处理)。
  1. 继续执行同步代码
  • JS 线程转交 setTimeout 事件后,继续往下执行:

    • 遇到 new Promise(...)

      • Promise 的 executor 函数 同步执行

        • 执行 console.log('promise'),控制台输出:promise

        • 执行 resolve(),将 Promise 状态变为 resolved

      • 执行 .then(...)

        • 遇到 .then 会将回调提交给 Web API 处理。
        • Web API 将该回调添加到 微任务队列(此时微任务队列中有一个 Promise 事件待处理)。
  1. 继续执行同步代码
  • 执行栈在提交完 Promise 事件后,继续往下执行:

    • 执行 foo() 函数,控制台输出:foo

    • 执行 console.log('global end'),控制台输出:global end

  • 至此,本轮事件循环的同步代码执行完毕,执行栈为空。

  1. 处理微任务队列
  • 事件循环机制首先查看 微任务队列 是否为空:

    • 发现有一个 Promise 事件待执行,将其压入执行栈。

    • 执行 .then 中的回调:

      • 执行 console.log('promise then'),控制台输出: promise then
    • 至此,新的一轮事件循环(Promise 事件)执行完毕,执行栈为空。

  1. 处理宏任务队列(改变部分)
  • 执行栈再次为空,事件循环:

    1. 先查看 微任务队列,发现已空。

    2. 再查看 宏任务队列,发现有一个 setTimeout 事件待处理:

    • 将 setTimeout 中的匿名函数压入执行栈执行:

      • 执行 console.log('setTimeout: 0s'),控制台输出:setTimeout: 0s
    • 至此,新的一轮事件循环(setTimeout 事件)执行完毕,执行栈为空。

7.** 检查任务队列**

  • 执行栈再次为空,事件循环:

    1. 先查看 微任务队列,发现已空。
    2. 再查看 宏任务队列,发现也为空。
  • 执行栈进入等待事件状态。


3.3 事件循环中的 async/await

这里简单介绍下async函数:

  1. 函数前面 async 关键字的作用就2点:①这个函数总是返回一个promise。②允许函数内使用await关键字。

  2. 关键字 await 使 async 函数一直等待(执行栈当然不可能停下来等待的,await将其后面的内容包装成promise交给Web APIs后,执行栈会跳出async函数继续执行),直到promise执行完并返回结果。await只在async函数函数里面奏效

  3. async函数只是一种比promise更优雅得获取promise结果(promise链式调用时)的一种语法而已。

function foo() {
    console.log('foo')
}

async function async1() {
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}

async function async2() {
    console.log('async2')
}

console.log('global start')
async1()
foo()
console.log('global end')

执行的结果如下:

global start
async1 start
async2
foo
global end
async1 end
  1. 执行同步代码
  • 执行 console.log('global start'),控制台输出:global start
  1. 调用 async1()
  • 执行 async1(),进入 async1 函数体:

    • 执行 console.log('async1 start'),控制台输出:async1 start

    • 执行 await async2()

      • await 关键字会暂停 async1 函数的执行,直到 await 后面的 Promise 返回结果。
      • await async2() 会像调用普通函数一样执行 async2()
  1. 执行 async2()
  • 进入 async2 函数体:

    • 执行 console.log('async2'),控制台输出:async2

    • async2 函数执行结束,弹出执行栈。

    • 由于 async2 没有显式返回 Promise,它会隐式返回一个已 resolved 的 Promise。

4.暂停 async1() 并继续执行同步代码

  • 因为 await 关键字之后的代码被暂停,async1 函数执行结束,弹出执行栈。

  • JS 主线程继续向下执行:

    • 执行 foo() 函数,控制台输出:foo

    • 执行 console.log('global end'),控制台输出:global end

  • 至此,本轮事件循环的同步代码执行完毕,执行栈为空。

  1. 事件循环处理微任务
  • 事件循环机制开始工作:

    1. 先查看 微任务队列

      • 发现有一个微任务事件,该事件是 async1 函数中 await async2() 之后的代码(可以理解为:用一个匿名函数包裹 await 之后的代码,作为微任务事件)。

      • 执行该微任务:

        • 执行 console.log('async1 end'),控制台输出:async1 end
    2. 执行栈再次为空,本轮事件执行结束。

  1. 检查任务队列
  • 事件循环机制再次查看:

    1. 微任务队列:已空。
    2. 宏任务队列:也为空。
  • 执行栈进入等待事件状态。


4. 大综合(自测)

4.1 简单融合

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
async function async2() {
    console.log('async2');
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0)

async1();

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});

console.log('script end');

输出结果:

script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

4.2 变形 1

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
async function async2() {
    new Promise(function(resolve) {
        console.log('promise1');
        resolve();
    }).then(function() {
        console.log('promise2');
    });
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0)

async1();

new Promise(function(resolve) {
    console.log('promise3');
    resolve();
}).then(function() {
    console.log('promise4');
});

console.log('script end');

输出的结果:

script start
async1 start
promise1
promise3
script end
promise2
async1 end
promise4
setTimeout

4.3 变形 2

async function async1() {
    console.log('async1 start');
    await async2();
    setTimeout(function() {
        console.log('setTimeout1');
    },0)
}
async function async2() {
    console.log('async2');
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout3');
}, 0)

async1();

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});

console.log('script end');

输出的结果:

script start
async1 start
async2
promise1
script end
promise2
setTimeout3
setTimeout1

4.4 变形 3

async function a1 () {
    console.log('a1 start')
    await a2()
    console.log('a1 end')
}
async function a2 () {
    console.log('a2')
}

console.log('script start')

setTimeout(() => {
    console.log('setTimeout')
}, 0)

Promise.resolve().then(() => {
    console.log('promise1')
})

a1()

let promise2 = new Promise((resolve) => {
    resolve('promise2.then')
    console.log('promise2')
})

promise2.then((res) => {
    console.log(res)
    Promise.resolve().then(() => {
        console.log('promise3')
    })
})
console.log('script end')

输出的结果:

script start
a1 start
a2
promise2
script end
promise1
a1 end
promise2.then
promise3
setTimeout

5. 结语

  • JS 是单线程执行的,同一时间只能处理一件事。
  • 浏览器是多线程的,JS 引擎通过分发这些耗时的异步事件给 Web APIs 线程处理,避免了单线程被阻塞。
  • 事件循环机制是为了协调事件、用户交互、JS 脚本、页面渲染、网络请求等事件的有序执行
  • 微任务的优先级高于宏任务

【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)
❌
❌