阅读视图
快速入门Vue模板里的JS表达式有啥不能碰?计算属性为啥比方法更能打?
Flutter 全局音频播放单例实现(附完整源码)——基于 just_audio 的零依赖方案
【react】 useEffect
Flutter 开发的极简风格音乐播放器
前端防抖与节流一篇讲清楚
「让规范驱动代码」——我如何用 Cursor + Spec Kit 在5小时内完成一个智能学习分析平台的
TS类型进阶:如何把对象“管”得服服帖帖
从“any”战士到类型高手:我的TypeScript进阶心得
连载小说大学生课设 需求&架构
基于Vue的数字输入框指令
vue2 模版编译原理
解决前端项目中大数据复杂列表场景的完美方案
【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️⃣ 总结
-
useReducer 是 useState 的增强版,适合复杂状态逻辑。
-
核心概念:
- Reducer:决定状态如何变化
- State:组件状态(只读)
- Dispatch:触发状态更新
-
优点:
- 状态更新逻辑集中,易维护
- 方便管理复杂或嵌套状态
- 和 Redux 思想一致,易迁移
-
使用场景:
-
多个状态值相互依赖
-
多分支的状态更新逻辑
-
想用
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
哪些值的改变会让组件函数重新执行(也就是组件重新渲染呢?)只有useStatePropsuseContext的值 发生改变之后 组件才会重新渲染
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) ,处理步骤:
-
调用 setState → React 不立即修改 state,而是把更新任务加入队列。
-
批量处理队列:
- React 会按顺序处理队列里的任务。
- 对于普通值更新,后面的更新可能覆盖前面,导致丢失变化。
- 对于函数式更新,每个 updater 都能拿到最新值计算。
-
队列处理完毕→ React 决定是否触发重新渲染。
3️⃣ 渲染时机
-
批量更新(Batching) :在 React 的事件回调中(例如
onClick),多次setState会被合并成一次渲染。 -
队列执行完毕后:
- React 会拿最新的 state 计算组件要渲染的虚拟 DOM。
- 与上一次虚拟 DOM 做 diff。
- 最后把差异更新到真实 DOM。
-
所以,你可以理解为:
只有队列里的所有更新都执行完,React 才会触发组件重新渲染。
在 React 事件处理函数 中:
即使你连续调用多次
setState(包括函数式更新),React 只会触发一次重新渲染。
🔍 一、为什么只渲染一次?
因为 React 有一个「批量更新机制(Batching) 」。
当你在事件处理函数中写:
function handleClick() {
setCount(prev => prev + 1)
setCount(prev => prev + 1)
setCount(prev => prev + 1)
}
React 内部流程如下👇:
- 进入事件回调 → React 开启批量更新模式;
- 每次
setCount不会立即触发渲染,只是往队列里加更新; - 当事件函数执行完毕后,React 才合并所有更新;
- 执行完所有 updater(函数式更新),得到最终的 state;
- 最后只 重新 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)
}
执行过程是:
- 你点击按钮 → React 调用
handleClick; -
setTimeout注册了一个浏览器原生异步任务; -
handleClick执行完 → React 的批量模式关闭; - 浏览器主线程空闲时,执行
setTimeout回调; - 此时 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 扩展了批量更新范围:
即使在
setTimeout、Promise.then、fetch等异步回调中调用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 扩展了批量机制:
即使是在
setTimeout、Promise.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
- 批量模式扩展到 几乎所有异步场景,包括
setTimeout、Promise.then、fetch - 示例:
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 自动扩展。
前端合并的表格排序功能
Ⅰ- 壹 - 功能展示和使用需求
需求描述
被合并的表格支持排序,并且合并后的数据,每一列都可以进行分组的排序。
功能展示
Ⅲ - 叁 - 设计思路
json数据格式
说明:
-
"rowSpan"和"colSpan"用于控制单元格合并显示。 - 每个
row对应一行数据,每行的cells对应列数据。例如"rowSpan": 3表示该单元格跨 3 行显示。 - 表头
headers可以是多行表头,这里只有一行。例如"colspan": 3表示该单元格跨 3 列显示。 -
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实现各个方法,最终实现该功能
Ⅲ - 叁 - 完整代码
从天气查询到低代码 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?
Canvas 如何渲染富文本、图片、SVG 及其 Path 路径?
在前端开发中,我们习惯于使用 HTML 标签(如 <img>, <div>, <svg>)来声明我们想要显示的内容,然后由浏览器负责布局和渲染
但 <canvas> 元素截然不同
Canvas 提供的是一个“即时模式”的 2D (或 3D) 绘图 API。它是一块空白的位图画布,你通过 JavaScript 发出绘图指令(如“画个圈”、“填充颜色”),它就立即执行。它不会记住你画了什么对象;一旦像素被涂上,它就只是一堆像素
1. 渲染图片
drawImage() 方法可以接受多种图像源,包括 <img> 元素、另一个 <canvas> 元素、<video> 的当前帧或 ImageBitmap。
渲染流程:
-
等待数据就绪: JavaScript 加载图片是异步的。在
img.onload事件触发后,确保图片数据(像素)已经完全加载到内存中 -
执行
drawImage:-
ctx.drawImage(image, dx, dy): 将源图像的像素数据,原封不动地“复制”到 Canvas 画布的(dx, dy)位置。 -
ctx.drawImage(image, dx, dy, dWidth, dHeight): 在复制前,先对源图像的像素进行缩放(拉伸或压缩)到dWidthxdHeight大小,然后再绘制。 -
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.font、ctx.fillStyle 等样式,对这些形状执行一次填充操作
如何渲染真正的“富文本”?
-
自动换行:
- 将长文本分割成单词
- 逐个单词测量其宽度 (
ctx.measureText(word).width) - 如果当前行宽度超过了设定的最大宽度,你就必须手动增加
y坐标(换行),然后从新行开始绘制
-
混合样式(例如
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> 图片来处理
-
将 SVG 转换为图像源:
-
如果是
.svg文件:img.src = 'icon.svg'; -
如果是 SVG 字符串:
const svgString = '...';
const svgDataUri = 'data:image/svg+xml;base64,' + btoa(svgString);
img.src = svgDataUri;
-
-
等待加载:
img.onload = () => { ... } -
绘制图像:
ctx.drawImage(img, dx, dy, width, height);
在 drawImage 被调用的那一刻,SVG 的矢量特性就丢失了。它被“光栅化”成了指定 width 和 height 的像素。如果你在 Canvas 上放大它,它会像普通图片一样变模糊
方式二:作为矢量“解析转译”(复杂)
这个方法会保留矢量特性
-
解析 SVG 的 XML 结构
-
遍历 SVG 节点(如
<rect>,<circle>,<path>) -
将每个 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 属性字符串
-
创建
Path2D对象:JavaScript
const svgPathData = "M10 10 L100 100 C150 150 200 150 250 100 Z"; const myPath = new Path2D(svgPathData); -
渲染 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 原生路径对象,然后再stroke或fill。
理解了这层“翻译”关系,就掌握了 Canvas 渲染万物的核心