阅读视图

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

【react】 useEffect

useEffect useEffect 依赖为空时 (无依赖),无论组件首次挂载还是重渲染,useEffect 都会执行 useEffect 依赖依赖为空数组时 函数只会在组件首次挂载时执行,之后组件

Flutter 开发的极简风格音乐播放器

去年失业闲的无聊开发了一个简单的音乐播放器,过去一年,个人水平也有长进,近期花时间使用最新版Flutter SDK 重构。 其实是有设计图的进行参考的,具体的作者已经找不到了,不过贴一下展示一下。 F

前端防抖与节流一篇讲清楚

防抖(Debounce)和节流(Throttle)是两种常用的优化高频率执行代码的技术。它们主要用于限制函数执行的频率,以避免在短时间内触发大量函数调用导致性能问题。 简单来说,防抖和节流都是用来控制

连载小说大学生课设 需求&架构

需求 设计一个小说连载网站,用户分为读者和管理员,不同用户权限不同、功能不同。 完成读者功能模块:读者注册与登陆、小说章节查询、小说状态情况添加、读者留言。 完成管理员功能模块:作者注册与登陆、小说章

【react】 useReducer 集中式管理组件的状态

useReducer 集中式管理组件的状态

1️⃣ 基本概念

useReducer 是 React 提供的 管理组件状态的一种 Hook,功能上类似于 useState,但是它更适合状态逻辑复杂或依赖前一个状态的情况

它的核心思想:

  • 状态(state) 是不可变的。
  • 状态更新逻辑 写在一个函数里(Reducer)。
  • 通过 dispatch(action) 来触发状态更新,而不是直接 setState。

1.1 函数签名

const [state, dispatch] = useReducer(reducer, initialState, init?)

参数解释:

参数 说明
reducer 一个函数 (state, action) => newState,决定状态如何更新
initialState 初始状态
init 可选函数,用于惰性初始化状态(通常不需要)

返回值:

  • state:当前状态(只读)
  • dispatch:派发 action 的函数,用来触发状态更新

1.2 Reducer 函数

Reducer 是状态更新逻辑的核心:

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return { count: 0 };
    default:
      return state;
  }
}

特点:

  • 不直接修改 state,而是返回一个新的状态对象
  • 通常使用 switch(action.type) 来判断不同动作。
  • action 可以是对象,也可以是任意自定义格式(推荐对象,包含 type 字段)。

1.3 最简单的例子

import React, { useReducer } from 'react';

const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return { count: 0 };
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  );
}

export default Counter;

✅ 可以看到:

  • 所有状态更新逻辑都集中在 reducer 里。
  • 组件内部不需要关心更新细节,只需要 dispatch 对应的 action。

2️⃣ useReducer vs useState

方面 useState useReducer
适用场景 简单状态(计数器、输入框) 复杂状态逻辑、多个子值依赖前一个状态
状态更新 setState(newValue) dispatch(action)
状态逻辑 分散在多个 setState 集中在 reducer 函数
可读性 简单 状态逻辑清晰、易于维护
性能优化 不如 useReducer dispatch 不会改变引用,可避免重复渲染

总结

  • 简单场景用 useState 就够了。
  • 状态更新复杂(多分支、多子值依赖)用 useReducer 更清晰。

3️⃣ 更复杂的例子:表单管理

假设有一个复杂表单:

import React, { useReducer } from 'react';

const initialState = {
  username: '',
  email: '',
  password: '',
};

function reducer(state, action) {
  switch (action.type) {
    case 'updateField':
      return { ...state, [action.field]: action.value };
    case 'reset':
      return initialState;
    default:
      return state;
  }
}

function Form() {
  const [state, dispatch] = useReducer(reducer, initialState);

  const handleChange = (e) => {
    dispatch({ type: 'updateField', field: e.target.name, value: e.target.value });
  };

  const handleReset = () => {
    dispatch({ type: 'reset' });
  };

  return (
    <form>
      <input name="username" value={state.username} onChange={handleChange} placeholder="Username" />
      <input name="email" value={state.email} onChange={handleChange} placeholder="Email" />
      <input name="password" type="password" value={state.password} onChange={handleChange} placeholder="Password" />
      <button type="button" onClick={handleReset}>Reset</button>
    </form>
  );
}

export default Form;

✅ 优点:

  • 所有字段更新逻辑集中在 reducer 中。
  • 方便扩展,比如添加表单校验、提交状态等。

4️⃣ useReducer 的惰性初始化

有时候初始化 state 很复杂,可以用第三个参数 init

function init(initialCount) {
  return { count: initialCount };
}

const [state, dispatch] = useReducer(reducer, 0, init);

好处:

  • 性能优化:只有第一次 render 会调用 init
  • 避免每次 render 都执行复杂计算。

5️⃣ 总结

  1. useReducer 是 useState 的增强版,适合复杂状态逻辑。

  2. 核心概念

    • Reducer:决定状态如何变化
    • State:组件状态(只读)
    • Dispatch:触发状态更新
  3. 优点

    • 状态更新逻辑集中,易维护
    • 方便管理复杂或嵌套状态
    • 和 Redux 思想一致,易迁移
  4. 使用场景

    • 多个状态值相互依赖

    • 多分支的状态更新逻辑

    • 想用 dispatch(action) 统一管理

总结

useReducer 管理的 state 本身只会在 dispatch 被调用时改变。每次组件重新渲染时,整个组件函数会重新执行,但 state 不会自动变化,仍然保持最新的值

import FileUpload from './components/FileUpload'
import './App.css'
import { useReducer } from 'react'

const initData = [
  { name: '小满(只)', price: 100, count: 1, id: 1, isEdit: false, editPrice: '' },
  { name: '中满(只)', price: 200, count: 1, id: 2, isEdit: false, editPrice: '' },
  { name: '大满(只)', price: 300, count: 1, id: 3, isEdit: false, editPrice: '' }
]
const reducer = (state, action) => {
  switch (action.type) {
    case 'addCount':
      return state.map(item => item.id === action.payload.id ? {...item,count:item.count+1} : item)
    case 'subCount':
      return state.map(item => item.id === action.payload.id ? {...item,count:item.count-1} : item)
    case 'delete':
      return state.filter(item => item.id !== action.payload.id)
    case 'edit':
      return state.map(item => 
        item.id === action.payload.id 
          ? {...item, isEdit: true, editPrice: String(item.price)} 
          : item
      )
    case 'updatePrice':
      return state.map(item => 
        item.id === action.payload.id 
          ? {...item, editPrice: action.payload.price} 
          : item
      )
    case 'save':
      return state.map(item => 
        item.id === action.payload.id 
          ? {
              ...item, 
              isEdit: false, 
              price: Number(item.editPrice) || 0,
              editPrice: ''
            } 
          : item
      )
    default:
      return state
  }
}




function App() {
  const [data, dispatch] = useReducer(reducer, initData)
  return (
    <div className="app">
      <table border="1">
      <thead>
        <tr>
          <th>商品名称</th>
          <th>商品价格</th>
          <th>商品数量</th>
          <th>操作</th>
        </tr>
      </thead>
      <tbody>
        {data.map(item => (
          <tr key={item.id}>
            <td>{item.name}</td>
            <td>
              {item.isEdit ? (
                <input 
                  type="number" 
                  value={item.editPrice}  
                  onChange={(e) => dispatch({
                    type: 'updatePrice', 
                    payload: { id: item.id, price: e.target.value }
                  })}
                />
              ) : (
                item.price
              )}
            </td>
            <td>{item.count}</td>
            <td>
              <button onClick={() => {
                if (item.isEdit) {
                  dispatch({ type: 'save', payload: item })
                } else {
                  dispatch({ type: 'edit', payload: item })
                }
              }}>
                {item.isEdit ? '保存' : '编辑'}
              </button>
              <button onClick={() => dispatch({ type: 'delete', payload: item })}>删除</button>
              <button onClick={() => dispatch({ type: 'addCount', payload: item })}>数量加1</button>
              <button onClick={() => dispatch({ type: 'subCount', payload: item })}>数量减1</button>
            </td>
          </tr>
        ))}
      </tbody>
      </table>
    </div>
  )
}

export default App

【react】useState是什么,怎么用

react组件

react组件本质就是一个函数,return 返回的jsx构成视图

视图可以使用 函数内部 let const useState Props useContext 的值。

let const useState Props 哪些值的改变会让组件函数 重新执行(也就是 组件重新渲染呢?) 只有 useState Props useContext 的值 发生改变之后 组件才会重新渲染

useState的用法

关键词

setState 进入队列 异步更新


useState值的改变要 使用set函数 整体替换

React 不会因为对象内部变化而重新渲染,必须传入新对象引用 才能让 React 感知到变化。

useState值改变之后,组件会重新渲染,这个过程是怎样的!

🧩 一、React 内部的更新队列机制示意

当你调用 setState 时,React 并不会马上修改 state,而是像这样往队列里加“更新任务”:


🔴 情况一:普通写法(值更新)(三个setCount中的count都是闭包里的旧值

setCount(count + 1)
setCount(count + 1)
setCount(count + 1)

假设当前 count = 0

React 收到的其实是:

更新队列:
[ { value: 1 }, { value: 1 }, { value: 1 } ]

然后 React 最终会这样处理:

初始 state: 0
处理更新 1 → newState = 1
处理更新 2 → newState = 1(没变化)
处理更新 3 → newState = 1(没变化)

🧨 所以最后只会变成 1。
因为这三个更新都基于同一个旧值(0)计算出来。


🟢 情况二:函数式更新(函数更新)

setCount(prev => prev + 1)
setCount(prev => prev + 1)
setCount(prev => prev + 1)

React 收到的更新队列变成这样:

更新队列:
[ { updater: (prev) => prev + 1 },
  { updater: (prev) => prev + 1 },
  { updater: (prev) => prev + 1 } ]

React 在真正更新时会顺序执行每个函数:

初始 state: 0
执行第一个 updater → prev = 0 → newState = 1
执行第二个 updater → prev = 1 → newState = 2
执行第三个 updater → prev = 2 → newState = 3

✅ 最终结果:count = 3


⚙️ 三、这说明了什么?

对比点 普通更新 函数式更新
更新类型 写死的值 延迟执行的函数
React 处理顺序 只保留最后一个值 顺序执行每个函数
最终结果 只更新一次 连续叠加

💡 类比理解

可以把 setCount(xxx) 想象成往“任务列表”中丢任务。

  • 普通写法:丢进去的是「我想把值改成 1」
  • 函数式写法:丢进去的是「等会执行我,传给我最新值,我来算新值」

第二种方式才真正能保证逻辑正确。

看看下面最终的结果是啥?

function App() {
  const [user, setUser] = useState({name: '张三', age: 18})

  
  const handleClick = () => {
    setUser({...user, name: '李四'})
    setUser({...user, age: 20})
  }
  return (
    <div className="app">
      <FileUpload />
      <button onClick={handleClick}>click</button>
      <p>{user.name}</p>
      <p>{user.age}</p>
    </div>
  )
}

点击click之后的结果是张三,20.

原因:setState不是修改 闭包中的老值,而是根据这个老值生成新的state,所以老的state不会被修改

函数式更新是 把 生成的新state传递下去,让下面的set函数,根据自己新state来操作。

(不是函数式更新,只看最后一次setState即可)

setSate是异步的,只有队列执行完之后才会 重新渲染组件

1️⃣ setState 的本质

  • 普通写法(直接值)

    setCount(count + 1)
    
    • 把计算好的“值”放入更新队列。
    • 如果多次调用,每次都是基于调用时的旧值计算的。
  • 函数式写法(函数)

    setCount(prev => prev + 1)
    
    • 把函数放入更新队列,等队列执行时会拿到“最新的 state”作为参数。
    • 可以连续、正确地叠加更新。

2️⃣ 更新队列处理流程

React 内部维护一个 更新队列(update queue) ,处理步骤:

  1. 调用 setState → React 不立即修改 state,而是把更新任务加入队列。

  2. 批量处理队列

    • React 会按顺序处理队列里的任务。
    • 对于普通值更新,后面的更新可能覆盖前面,导致丢失变化。
    • 对于函数式更新,每个 updater 都能拿到最新值计算。
  3. 队列处理完毕 → React 决定是否触发重新渲染。


3️⃣ 渲染时机

  • 批量更新(Batching) :在 React 的事件回调中(例如 onClick),多次 setState 会被合并成一次渲染。

  • 队列执行完毕后

    1. React 会拿最新的 state 计算组件要渲染的虚拟 DOM。
    2. 与上一次虚拟 DOM 做 diff。
    3. 最后把差异更新到真实 DOM。
  • 所以,你可以理解为:

只有队列里的所有更新都执行完,React 才会触发组件重新渲染。

React 事件处理函数 中:

即使你连续调用多次 setState(包括函数式更新),React 只会触发一次重新渲染


🔍 一、为什么只渲染一次?

因为 React 有一个「批量更新机制(Batching) 」。

当你在事件处理函数中写:

function handleClick() {
  setCount(prev => prev + 1)
  setCount(prev => prev + 1)
  setCount(prev => prev + 1)
}

React 内部流程如下👇:

  1. 进入事件回调 → React 开启批量更新模式;
  2. 每次 setCount 不会立即触发渲染,只是往队列里加更新;
  3. 当事件函数执行完毕后,React 才合并所有更新;
  4. 执行完所有 updater(函数式更新),得到最终的 state;
  5. 最后只 重新 render 一次

🧩 二、具体过程模拟

const [count, setCount] = useState(0)

function handleClick() {
  setCount(prev => prev + 1) // 加入队列
  setCount(prev => prev + 1) // 加入队列
  setCount(prev => prev + 1) // 加入队列
  console.log('after set:', count)
}

输出过程大致如下:

点击前:count = 0
点击时:三个 setCount 都加入队列,但组件还没重新渲染
console.log 打印:0 (旧值)
React 合并更新,计算最终 state = 3
React 重新 render 一次(只一次!)

⚙️ 三、那什么时候会 render 多次?

如果你把这些更新放到不同的异步回调里,比如:

setTimeout(() => setCount(prev => prev + 1), 0)
setTimeout(() => setCount(prev => prev + 1), 0)
setTimeout(() => setCount(prev => prev + 1), 0)

此时每个 setTimeout 都是独立的事件上下文,React 不会把它们批量合并。

于是:

  • 每次 setCount 都会单独触发一次渲染;
  • 最终渲染了 3 次。

🧩 四、总结

场景 是否批量更新 渲染次数
同一个事件回调里多次 setState ✅ 是 1 次
不同异步回调(setTimeout、Promise.then) ❌ 否 多次
使用 React 18 自动批量(启用 Concurrent Mode) ✅ 是(几乎所有情况) 1 次

一句话总结

React 会在一个同步事件中收集所有 setState,等函数执行完再合并,
所以你即使执行三次更新,也只会 render 一次。

setTimeout(() => setCount(prev => prev + 1), 0) 为什么setTimeout有独立的事件上下文

🧠 一、JavaScript 事件循环与调用栈

JS 是单线程执行的。浏览器维护一个事件循环(event loop):

主线程执行:
同步代码 -> 执行完 -> 检查任务队列 -> 执行下一个宏任务 / 微任务

所以:

  • 同步任务(比如普通的点击事件函数)都在同一个调用栈中;
  • 异步任务(比如 setTimeout 回调)会被放到任务队列中,等同步任务执行完之后再执行。

⚙️ 二、React 的批量更新机制(Batching)

React 内部有一个“批量更新模式”的开关,伪代码如下:

ReactDOM.flushSync(() => {
  // 批量更新开启
  setCount(1)
  setName('foo')
  // ...
  // 批量更新关闭
})

这个“开关”只在某些 React 管控的上下文中 打开,比如:

  • 组件生命周期(render、effect、事件回调);
  • React 事件系统触发的事件(如 onClick、onChange)。

当批量更新模式开启时:

  • 所有 setState 不会立即触发渲染;
  • React 会收集所有更新,事件结束后统一执行一次。

🧩 三、为什么 setTimeout 不在 React 的上下文里?

当你这样写:

function handleClick() {
  setTimeout(() => setCount(c => c + 1), 0)
}

执行过程是:

  1. 你点击按钮 → React 调用 handleClick
  2. setTimeout 注册了一个浏览器原生异步任务
  3. handleClick 执行完 → React 的批量模式关闭;
  4. 浏览器主线程空闲时,执行 setTimeout 回调;
  5. 此时 React 不再处于“批量模式”,所以 setState 立刻触发一次渲染。

⚠️ 因此:

每个 setTimeout 的回调都在「React 批量更新范围外」,
所以每次都单独触发 render。


🧩 四、例子对比一下

// ✅ 批量更新(1次渲染)
function handleClick() {
  setCount(prev => prev + 1)
  setCount(prev => prev + 1)
  setCount(prev => prev + 1)
}

// ❌ 非批量更新(3次渲染)
function handleClick() {
  setTimeout(() => setCount(prev => prev + 1), 0)
  setTimeout(() => setCount(prev => prev + 1), 0)
  setTimeout(() => setCount(prev => prev + 1), 0)
}

第一个例子里,所有更新都在 React 控制的事件回调内 → React 自动合并。
第二个例子里,setTimeout 回调是原生异步上下文 → React 不知道你要合并。


🧩 五、React 18 之后的变化:自动批量(Automatic Batching)

在 React 18(启用 concurrent 模式)中,React 扩展了批量更新范围:

即使在 setTimeoutPromise.thenfetch 等异步回调中调用 setState
React 也会自动合并更新 🎉。

例如:

setTimeout(() => {
  setCount(c => c + 1)
  setName('Alice')
}, 0)

在 React 18 中,这两个更新会被合并成一次渲染
(以前的版本是两次)


🧭 六、总结表格

场景 React 17及以前 React 18(自动批量)
同一个事件回调中多次 setState ✅ 合并更新 ✅ 合并更新
不同 setTimeout 回调中多次 setState ❌ 各自渲染 ✅ 自动合并
Promise.then / fetch / async 回调 ❌ 各自渲染 ✅ 自动合并

🎬 一、先认识两个关键角色

1️⃣ React 的批量更新开关(Batch Mode)

React 在某些场景会打开“批量模式”:

“所有 setState 我先记着,不急着更新,等函数结束我一起更新。”

比如:

  • React 事件回调(onClick, onChange
  • 生命周期函数(useEffect, useLayoutEffect

当事件结束后,React 会自动关闭批量模式,然后统一触发一次重新渲染。


2️⃣ 浏览器的事件循环机制

浏览器的事件循环分两种任务:

  • 同步任务(主线程执行)
  • 异步任务(放入任务队列,稍后执行)

setTimeout 的回调就是被放进“任务队列”的异步任务。
它不会立刻执行,要等主线程的同步代码都跑完再轮到它。


🧩 二、我们用时间轴看整个过程

来看代码 👇

function handleClick() {
  setTimeout(() => setCount(prev => prev + 1), 0)
}

然后点击按钮。


🕒 Step 1:React 调用事件回调

React 捕获到点击事件 → 调用 handleClick()

此时 React 打开了“批量模式”:

ReactBatching = true

🕒 Step 2:执行 setTimeout 注册异步任务

执行到:

setTimeout(() => setCount(...), 0)

这里其实什么都没更新,只是:

“告诉浏览器:我有个任务,等同步任务都执行完再来执行它。”

于是浏览器把回调放到任务队列:

TaskQueue = [() => setCount(...)]

🕒 Step 3:handleClick 执行结束

handleClick() 执行完毕。

React 认为:“当前事件结束了,我可以把批量模式关掉。”

ReactBatching = false

🕒 Step 4:浏览器执行 setTimeout 回调

主线程空闲后,浏览器取出任务队列里的回调执行:

setCount(prev => prev + 1)

但此时 React 并不知道这是“同一个事件”中的更新了,
因为 React 的批量模式已经关了

于是 React 直接:

“好,我立刻更新 state 并重新渲染一次组件。”


✅ 最终结果

每次进入 setTimeout 的回调,都独立触发一次渲染。
因为这些回调是在 React 的“批量模式”之外执行的。


🧠 三、用动画类比理解

可以想象成:

阶段 React 批量模式 状态
点击按钮 → 进入 handleClick ✅ 打开批量模式 “收集更新”
handleClick 结束 ❌ 关闭批量模式 “该渲染了”
浏览器稍后执行 setTimeout 回调 🚫 React 已不知情 “立刻渲染一次”

🧩 四、如果想让它在异步回调里也批量更新怎么办?

在 React 18 之后(Concurrent Mode),React 扩展了批量机制:

即使是在 setTimeoutPromise.then 等异步中,
React 也会自动帮你批量。

所以在 React 18 中:

setTimeout(() => {
  setCount(c => c + 1)
  setName('Alice')
}, 0)

只会触发 1 次渲染 ✅。

哪些操作会触发批量更新

1️⃣ 什么是批量更新(Batching)

React 的 批量更新模式,本质上是一个开关,控制 同一次事件中多次 setState 是否合并渲染

  • 批量模式开着:多次 setState 只触发 一次 render
  • 批量模式关掉:每次 setState 都会单独触发渲染

伪代码示意:

ReactBatching = true  // 开启批量

setCount(1)
setName('foo')

// 事件结束,React 会合并更新
render() 

ReactBatching = false // 关闭批量

2️⃣ 触发批量更新的上下文

React 并不是所有 setState 都批量,而是只在 React 控制的上下文 才批量:

(1) React 事件系统触发的事件

  • onClick, onChange, onInput
  • React 内部会把事件回调包装成 SyntheticEvent
  • 批量模式在回调执行前自动打开,回调执行完毕后自动关闭
  • 示例:
<button onClick={() => {
  setCount(c => c + 1)
  setName('Alice')
}}>Click</button>

✅ 同一事件回调里的两个 setState 会被批量,最终只 render 一次。


(2) 组件生命周期

  • render 内部不会调用 setState(会报错),但 useEffect / useLayoutEffect 可以
  • React 会在这些钩子回调中 默认开启批量模式
  • 例如:
useEffect(() => {
  setCount(c => c + 1)
  setName('Bob')
}, [])

✅ 两次更新也只触发一次 render。


(3) React 18 扩展后的异步上下文

  • React 18 引入 Automatic Batching
  • 批量模式扩展到 几乎所有异步场景,包括 setTimeoutPromise.thenfetch
  • 示例:
setTimeout(() => {
  setCount(c => c + 1)
  setName('Charlie')
}, 0)

在 React 18 下,也会合并成 一次 render

注意:如果是 React 17,setTimeout 内的更新不会被合并。


3️⃣ React 内部是如何实现的

核心就是一个 批量标志 + 更新队列

let isBatching = false
let updateQueue = []

function setState(update) {
  if (isBatching) {
    updateQueue.push(update)  // 记录更新
  } else {
    // 立即更新
    state = typeof update === 'function' ? update(state) : update
    render()
  }
}

function flushUpdates() {
  for (const u of updateQueue) {
    state = typeof u === 'function' ? u(state) : u
  }
  render()
  updateQueue = []
}

React 事件回调执行时:

isBatching = true
callback()
flushUpdates()
isBatching = false
  • flushUpdates() 在事件结束时一次性把队列里的更新合并
  • 批量模式自动控制渲染次数

4️⃣ 总结理解

方面 描述
批量更新 同一事件或同一上下文内,多次 setState 只渲染一次
React 17 默认 仅事件回调 & 生命周期钩子中批量,异步回调不批量
React 18 自动批量 批量范围扩展到几乎所有异步回调(setTimeout、Promise 等)
原理 isBatching 标志 + 更新队列,事件结束 flush 一次

简单理解:

批量更新模式就是 React 的开关,控制 setState 是否“收集起来一次渲染”。
事件回调、effect、生命周期里开,其他普通异步可能不开(React 17),React 18 自动扩展。

前端合并的表格排序功能

Ⅰ- 壹 - 功能展示和使用需求

需求描述

被合并的表格支持排序,并且合并后的数据,每一列都可以进行分组的排序。

功能展示

Snipaste_2025-11-01_15-04-54.png

Ⅲ - 叁 - 设计思路

json数据格式

说明

  1. "rowSpan""colSpan" 用于控制单元格合并显示。
  2. 每个 row 对应一行数据,每行的 cells 对应列数据。例如 "rowSpan": 3 表示该单元格跨 3 行显示。
  3. 表头 headers 可以是多行表头,这里只有一行。例如 "colspan": 3 表示该单元格跨 3 列显示。
  4. value 是实际显示的内容,可为日期、区域、平台名称、数值等。
tableData.json


{
  // 表头信息
  "headers": [
    [
      {
        "title": "", // 占位列,可能用于日期或区域
        "colspan": 3, // 占 3 列
        "rowspan": 1  // 占 1 行
      },
      {
        "title": "平台DAU", // 日活跃用户数
        "colspan": 1,
        "rowspan": 1
      },
      {
        "title": "新增用户", // 新增用户数
        "colspan": 1,
        "rowspan": 1
      },
      {
        "title": "活跃率(%)", // 活跃率百分比
        "colspan": 1,
        "rowspan": 1
      },
      {
        "title": "GMV(万元)", // GMV(成交金额),单位:万元
        "colspan": 1,
        "rowspan": 1
      }
    ]
  ],

  // 表格行数据
  "rows": [
    {
      "id": "row-1-1", // 每行唯一 id
      "cells": [
        {
          "value": "20250101", // 日期
          "colSpan": 1,
          "rowSpan": 5 // 日期跨 5 行
        },
        {
          "value": "华北区", // 区域
          "colSpan": 1,
          "rowSpan": 3 // 区域跨 3 行
        },
        {
          "value": "微信小程序", // 平台名称
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 5399070, // 平台 DAU
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 123456, // 新增用户
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 68.5, // 活跃率
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 8520.3, // GMV(万元)
          "colSpan": 1,
          "rowSpan": 1
        }
      ]
    },
    {
      "id": "row-1-2",
      "cells": [
        {
          "value": "点评APP", // 平台名称
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 2537534433, // 平台 DAU
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 987654, // 新增用户
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 72.8, // 活跃率
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 125600.7, // GMV(万元)
          "colSpan": 1,
          "rowSpan": 1
        }
      ]
    },
    {
      "id": "row-1-3",
      "cells": [
        {
          "value": "美团APP",
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 39420903,
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 456789,
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 81.2,
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 98760.5,
          "colSpan": 1,
          "rowSpan": 1
        }
      ]
    },
    {
      "id": "row-1-4",
      "cells": [
        {
          "value": "华南区",
          "colSpan": 1,
          "rowSpan": 2 // 区域跨 2 行
        },
        {
          "value": "抖音小程序",
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 18765432,
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 234567,
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 55.3,
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 45320.8,
          "colSpan": 1,
          "rowSpan": 1
        }
      ]
    },
    {
      "id": "row-1-5",
      "cells": [
        {
          "value": "支付宝小程序",
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 8234567,
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 89012,
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 62.7,
          "colSpan": 1,
          "rowSpan": 1
        },
        {
          "value": 23456.2,
          "colSpan": 1,
          "rowSpan": 1
        }
      ]
    },

    // 之后的行数据和结构类似,省略重复注释
    // "row-2-1" 到 "row-5-5" 依次是不同日期和不同区域、平台的数据
  ]
}

核心逻辑

  • 分组逻辑

    • 利用 rowSpan>1 单元格生成 groupIdentifier
    • 通过 getJoinedSlice 生成 groupKey。
    • 使用 Map 保持分组顺序。
  • 排序逻辑

    • 数字与中文混合排序使用 localeCompare
    • 对第一列(分组列)排序时保持原始分组顺序。
    • 合并单元格的数据只保留在第一行。

利用ai实现各个方法,最终实现该功能

Ⅲ - 叁 - 完整代码

go2fofo/RVA-CrossSort: 被合并的表格支持排序,并且合并后的数据,每一列都可以进行分组的排序。

从天气查询到低代码 AI 应用:Coze 平台让人人都能开发智能体

在 AI 技术飞速发展的今天,“智能体开发” 似乎还是技术大佬的专属领域。但 Coze AI Agent 开发平台的出现,正在打破这一认知 —— 它以低代码、高灵活的方式,让技术与非技术人员都能投身 AI 应用开发,打造属于自己的智能体。今天我们就从一个天气查询智能体的开发实践出发,聊聊 Coze 如何重塑 AI 应用开发的体验,以及背后的提示词工程、低代码工作流等核心能力。

一、从零开始:打造 “天气查询” 智能体的全过程

1. 明确角色与技能:给 AI 一个清晰的 “身份”

在 Coze 中开发智能体,第一步是定义它的 “人设” 。以 “天气查询” 智能体为例,我们需要明确:

  • 角色定位:专业的天气查询助手,能根据用户提供的地点和时间精准查询天气,并给出穿衣建议。

  • 核心技能

    • 技能 1(查询天气):用户输入地点和时间时返回天气信息,未指定时间则默认查未来三天,信息需包含日期、星期、天气、温度、空气质量。
    • 技能 2(生成穿衣建议):结合温度和天气状况给出穿搭方案。
    • 技能 3(格式化输出):严格按照 “日期 + 星期 + 天气 + 温度 + 空气质量 + 穿衣建议” 的格式返回结果。
  • 限制条件:只回答天气相关问题,输出必须符合格式规范。

这种 “角色 + 技能 + 限制” 的设计,本质是提示词工程的 “角色赋予法” —— 让 AI 明确自己的身份和边界,从而更精准地响应需求。

2. 插件与工作流:让智能体具备 “外接能力”

天气数据从何而来?Coze 提供了插件生态,我们可以直接接入 “墨迹天气” 插件,让智能体具备查询国内天气的能力。

工作流则是智能体的 “逻辑引擎”。在 Coze 中,工作流通过拖拽节点的方式搭建:用户提问触发后,智能体先解析地点和时间,调用天气插件获取数据,再根据温度和天气生成穿衣建议,最后按格式输出结果。整个过程无需写一行代码,像搭积木一样就能完成逻辑串联。

3. 预览与调试:确保智能体 “言行如一”

开发过程中,Coze 的预览调试功能能实时验证智能体的表现。比如查询 “抚州 2025 年 11 月 2 日天气”,智能体返回 “多云,22℃/15℃,穿衣建议为长袖衬衫 + 薄外套”,完全符合我们预设的格式和逻辑。这种即时反馈让开发体验更流畅,不用反复猜测 AI 的 “脑回路”。

二、Coze 的核心能力:提示词工程与低代码开发的融合

1. 提示词工程:让 AI“听得懂、做得对”

好的提示词是智能体生效的关键。在 Coze 中,写提示词可以遵循这 5 个原则:

  • 给角色:明确 AI 是 “天气助手”“职业规划师” 还是 “英语教练”,让它有身份感。
  • 给任务:清晰说明要完成什么事,比如 “查询天气并给穿衣建议”。
  • 给方法:告诉 AI 具体怎么做,比如 “根据温度区间推荐穿搭”。
  • 给格式:指定返回结果的结构,比如 “日期 + 星期 + 天气 + 温度 +...”。
  • 给限制:说明 “不能做什么”,比如 “只回答天气问题”。

这种结构化的提示词设计,本质是将人类意图转化为 AI 可理解的 “指令语言” ,让非技术人员也能通过自然语言掌控 AI 的行为。

2. 低代码开发:拖拽式搭建 AI 应用

Coze 的低代码特性是它的另一大亮点。以 “KidsCareer” 应用为例:

  • 工作流拖拽:通过 “开始→查询数据→结束” 等节点,就能搭建起 “查询用户图片” 的逻辑,无需写 SQL 或后端代码。
  • 数据表可视化:像 “photos” 表的结构(id、用户标识、图片地址、职业等字段)可以直接在界面上配置,非技术人员也能理解数据关系。
  • 用户界面生成:除了业务逻辑,Coze 还支持拖拽生成前端页面,真正实现 “从逻辑到界面” 的全链路低代码开发。

三、Coze 的价值:让 AI 应用开发 “全民化”

1. 技术与非技术人员的协作桥梁

在传统开发中,“需求→技术实现” 的鸿沟常常导致项目延期或变形。但 Coze 的低代码环境让产品经理可以直接搭建智能体原型,技术人员则专注于插件开发、复杂逻辑优化,两者能基于同一平台高效协作。比如运营同学可以先做出 “天气查询” 的 demo,技术同学再接入企业内部的天气数据源,迭代效率大大提升。

2. AIGC 时代的用户体验创新

Coze 不仅能做 “工具型智能体”(如天气查询),还能结合 AIGC 打造更具魅力的用户体验:

  • 生成式内容:比如 “抽卡炼丹” 式的 AI 角色生成,用户输入偏好后,智能体自动生成角色立绘 + 背景故事。
  • 多模态交互:结合图标生成、文本转语音等能力,让智能体从 “文字对话” 升级为 “图文声并茂” 的交互。
  • 个性化服务:基于用户行为数据(如 “photos” 表中的职业信息),智能体可以给出定制化建议,比如 “根据你的设计师职业,推荐艺术展览相关的天气穿搭”。

3. 前端工程师的 AI 时代新机遇

在 AI 浪潮下,前端不再只是 “切图写页面”。借助 Coze,前端可以:

  • 打造智能交互界面:将天气查询、职业推荐等智能体嵌入网页,让用户体验从 “被动浏览” 变为 “主动对话”。
  • 探索 AIGC 前端应用:比如用 AI 生成图标、自动适配多端界面,甚至让智能体帮用户调试 CSS(“帮我把这个按钮改成渐变色 hover 效果”)。
  • 参与全链路开发:从前端界面到 AI 逻辑,前端工程师可以在 Coze 中完成更多环节,职业边界被进一步拓宽。

四、总结:Coze 不是 “替代开发”,而是 “重构开发”

Coze 的出现,不是让开发者 “失业”,而是重构了 AI 应用开发的范式—— 它让 “明确需求的人”(产品、运营、业务人员)能直接参与开发,让技术人员从 “重复编码” 中解放出来,聚焦更有价值的创新工作。

从一个简单的天气查询智能体,到复杂的职业规划应用,Coze 证明了:AI 应用开发可以很简单,也可以很强大。未来,或许每个人都能在 Coze 上打造属于自己的智能体,让 AI 真正服务于生活、工作的每一个角落。

如果你也想试试 “零基础开发智能体”,不妨从 Coze 的天气查询模板开始 —— 相信你会和我一样,惊叹于 “拖拽几下就能拥有一个 AI 助手” 的神奇体验。

老板:就是你小子删光了try-catch?

昨天我写了一篇《我删光了项目里的 try-catch,老板:6》 结果评论区一堆大佬冲我吼: 我一看,哎哟喂,还真有点不对劲。 于是我去查了查,发现我那套“优雅不抛异常”的写法, 确实有点“优雅过头”

Canvas 如何渲染富文本、图片、SVG 及其 Path 路径?

在前端开发中,我们习惯于使用 HTML 标签(如 <img>, <div>, <svg>)来声明我们想要显示的内容,然后由浏览器负责布局和渲染

<canvas> 元素截然不同

Canvas 提供的是一个“即时模式”的 2D (或 3D) 绘图 API。它是一块空白的位图画布,你通过 JavaScript 发出绘图指令(如“画个圈”、“填充颜色”),它就立即执行。它不会记住你画了什么对象;一旦像素被涂上,它就只是一堆像素

1. 渲染图片

drawImage() 方法可以接受多种图像源,包括 <img> 元素、另一个 <canvas> 元素、<video> 的当前帧或 ImageBitmap

渲染流程:

  1. 等待数据就绪: JavaScript 加载图片是异步的。在 img.onload 事件触发后,确保图片数据(像素)已经完全加载到内存中

  2. 执行 drawImage

    • ctx.drawImage(image, dx, dy): 将源图像的像素数据,原封不动地“复制”到 Canvas 画布的 (dx, dy) 位置。
    • ctx.drawImage(image, dx, dy, dWidth, dHeight): 在复制前,先对源图像的像素进行缩放(拉伸或压缩)到 dWidth x dHeight 大小,然后再绘制。
    • ctx.drawImage(image, sx, sy, sWidth, sHeight, ...): 这是最复杂的形式,它先从源图像上“裁剪”出一块矩形区域,然后(可选)缩放,最后“粘贴”到画布上。
// 示例:加载并绘制一张图片
const img = new Image();
img.src = 'path/to/image.png';

// 必须等待图片解码完成
img.onload = () => {
  // 将图片像素“印”在 (10, 10) 坐标
  ctx.drawImage(img, 10, 10);
};

2. 渲染富文本

Canvas 提供了两个基本方法来绘制文本:ctx.fillText()ctx.strokeText()

例如:<span>Hello <b>World</b></span> 的混合样式和自动换行

Canvas 原生 API 并不支持这些

Canvas 的 fillText 命令只是将一个纯文本字符串,根据当前的 ctx.fontctx.fillStyle 等样式,对这些形状执行一次填充操作

如何渲染真正的“富文本”?

  1. 自动换行:

    • 将长文本分割成单词
    • 逐个单词测量其宽度 (ctx.measureText(word).width)
    • 如果当前行宽度超过了设定的最大宽度,你就必须手动增加 y 坐标(换行),然后从新行开始绘制
  2. 混合样式(例如 Hello <b>World</b>):

    • ctx.font = '16px Arial';
    • ctx.fillText('Hello ', x, y);
    • 计算 "Hello " 的宽度:const w = ctx.measureText('Hello ').width;
    • 更改状态: ctx.font = 'bold 16px Arial';
    • 在后面继续绘制: ctx.fillText('World', x + w, y);

3. 渲染 SVG

SVG(可缩放矢量图形)和 Canvas 在某种理解下可以是对立的

  • SVG 是“声明式”: 用 XML 描述一个“场景”(例如,这里有个圆,那里有条线)。浏览器会记住这些对象
  • Canvas 是“命令式”: 发出“画圆”的指令,它画完就忘了

因此,Canvas 不能直接渲染 SVG 字符串,你必须在两者之间进行“翻译”

方式一:作为图片“光栅化”(最常用)

这是最简单的方法:把 SVG 当作一张普通的 <img> 图片来处理

  1. 将 SVG 转换为图像源:

    • 如果是 .svg 文件:img.src = 'icon.svg';

    • 如果是 SVG 字符串:

      const svgString = '...';

      const svgDataUri = 'data:image/svg+xml;base64,' + btoa(svgString);

      img.src = svgDataUri;

  2. 等待加载: img.onload = () => { ... }

  3. 绘制图像: ctx.drawImage(img, dx, dy, width, height);

drawImage 被调用的那一刻,SVG 的矢量特性就丢失了。它被“光栅化”成了指定 widthheight 的像素。如果你在 Canvas 上放大它,它会像普通图片一样变模糊

方式二:作为矢量“解析转译”(复杂)

这个方法会保留矢量特性

  1. 解析 SVG 的 XML 结构

  2. 遍历 SVG 节点(如 <rect>, <circle>, <path>

  3. 将每个 SVG 节点的属性翻译成等效的 Canvas API 调用

    • <rect x="10" ...> -> ctx.rect(10, ...)
    • 等等

4. 渲染 SVG 中的 Path 路径

SVG 的 <path> 元素使用 d 属性来定义极其复杂的形状,例如:

<path d="M10 10 L100 100 C150 150 200 150 250 100 Z">

M 代表 moveTo(移动到),L 代表 lineTo(画线到),C 代表贝塞尔曲线,Z 代表闭合路径

Canvas 的 ctx.lineTo 等 API 无法直接读取这个字符串,我们如何“翻译”它?

现代方案:Path2D 对象

现代浏览器提供了一个强大的 Path2D 对象,它就是为了解决这个问题而生的。Path2D 构造函数可以直接接收 SVG 的 d 属性字符串

  1. 创建 Path2D 对象:

    JavaScript

    const svgPathData = "M10 10 L100 100 C150 150 200 150 250 100 Z";
    const myPath = new Path2D(svgPathData);
    
  2. 渲染 Path2D 对象:

    一旦你有了 myPath 这个对象,Canvas 就可以直接使用它。这个对象已经“预编译”了所有路径指令

    JavaScript

    ctx.strokeStyle = 'blue';
    ctx.lineWidth = 3;
    ctx.stroke(myPath); // 描边这个路径
    
    ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
    ctx.fill(myPath); // 填充这个路径
    

Path2D 的优势:

  • 简洁: 无需手动解析字符串。
  • 高效: 浏览器底层负责解析和优化路径。
  • 可重用: 你可以创建一次 Path2D 对象,然后在不同的位置、以不同的样式反复绘制它,而无需重新定义路径

传统方案:手动解析(库的实现原理)

Path2D 出现之前,或者当需要支持旧版浏览器时,唯一的办法就是手动解析这个 d 字符串。

你需要写一个解析器,逐个读取命令 (M, L, C...) 和坐标,然后调用等效的 Canvas API:

JavaScript

function parseAndDraw(svgPathData) {
  // 这是一个极度简化的示例
  const commands = svgPathData.split(' '); // 简化的分割
  
  ctx.beginPath(); // **开始 Canvas 路径**
  
  for (let i = 0; i < commands.length; i++) {
    const cmd = commands[i];
    
    if (cmd === 'M') {
      const x = parseFloat(commands[++i]);
      const y = parseFloat(commands[++i]);
      ctx.moveTo(x, y); // 等效 M
    } else if (cmd === 'L') {
      const x = parseFloat(commands[++i]);
      const y = parseFloat(commands[++i]);
      ctx.lineTo(x, y); // 等效 L
    } else if (cmd === 'Z') {
      ctx.closePath(); // 等效 Z
    }
    // ... 还需要处理 C, S, Q, T, A 等等...
  }
  
  ctx.stroke(); // **渲染 Canvas 路径**
}

总结

Canvas 本身只是一个低级的“像素绘图仪”。它之所以能渲染高级内容,完全依赖于将这些内容“翻译”成它能理解的指令:

  • 渲染图片: 翻译成 drawImage(像素块传输)。
  • 渲染富文本: 翻译成 fillText(字形绘制),但布局和换行需要 JavaScript 手动计算
  • 渲染 SVG: 翻译成 drawImage(光栅化)或者解析其所有节点。
  • 渲染 SVG Path: 最佳翻译是使用 new Path2D(d) 将其转换成 Canvas 原生路径对象,然后再 strokefill

理解了这层“翻译”关系,就掌握了 Canvas 渲染万物的核心

❌