阅读视图
印尼考虑对16岁以下人群实施“网购禁令”
Vue创建一个简单的Agent聊天
创建项目
用的为vue3.6测试版本
npm create vue@latest
什么是Agent?
就是大语言模型+记忆+工具调用
-
大语言模型:大脑,会思考、理解、规划、推理
-
记忆:短期对话记忆 + 长期知识库,知道上下文、历史人设、过往任务
-
工具调用:会联网搜索、查文件、写代码、调接口、操作插件
安装依赖
npm install @langchain/core @langchain/langgraph @langchain/openai
@langchain/core
LangChain 核心底座
- 提供统一接口:LLM、Prompt、Runnable、回调、工具
- 所有 LangChain 生态都依赖它
- 相当于底层操作系统
@langchain/openai
对接 OpenAI 大模型
- 让你能调用
gpt-4o,gpt-3.5-turbo - 支持结构化输出、工具调用(Tool Calling)
- 就是 Agent 的大脑
@langchain/langgraph
做 Agent / 智能体的框架
- 管理:记忆、状态、工具调用、多步骤流程
- 支持:循环思考、决策、多 Agent 协作
- 你说的「LLM + 记忆 + 工具调用」= 全靠它实现
env全局变量
TITLE=聊天机器人
# 模型配置
VITE_API_KEY=你的ApiKey
VITE_AI_MODEL=模型
VITE_AI_BASE_URL=https://api.deepseek.com/v1 # api地址,此处为deepseek
# api配置
# 高德地图配置 https://console.amap.com/dev/key/app # 高德地图api地址
VITE_AMAP_KEY=你的高德地图APIKEy(用于工具:ip地址查询,天气查询)
ts类型
type ROLE = 'user' | 'assistant' | 'system';
interface IChat {
id: string,
name: string,
messages: string,
role: ROLE,
created_at: Date
}
export type Chat = IChat;
创建agent
import type { Chat } from "@/types/chat";
import { AIMessage, HumanMessage } from "@langchain/core/messages";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { RunnableSequence } from "@langchain/core/runnables";
import { ChatOpenAI } from "@langchain/openai";
function createLLM() {
// 创建LLM
const llm = new ChatOpenAI({
model: import.meta.env.VITE_AI_MODEL,
apiKey: import.meta.env.VITE_API_KEY,
configuration: {
baseURL: import.meta.env.VITE_AI_BASE_URL,
},
temperature: 0.7, // ai生成的内容丰富度
})
return llm
}
function createPrompt() {
// 创建一个提示模板
return ChatPromptTemplate.fromMessages([
["system", "你是善解人意的,且热爱着某人的智能女友"],// 创建个对象(提示词,prompt)
["placeholder", "{chat_history}"], // 聊天记录(记忆,memory)
["human", "{input}"], // 用户输入的问题
])
}
function createChain() {
const llm = createLLM()
const prompt = createPrompt()
// 创建一个链,将prompt和llm联系起来
return RunnableSequence.from([prompt, llm])
}
export default async function chat(
input: string,
onChat: (text: string) => void,
messages: Chat[] = []) {
// 历史记录
const allMessages: Array<HumanMessage | AIMessage> = []
// 遍历历史聊天记录
for (const msg of messages) {
if (msg.role === "user") {
allMessages.push(new HumanMessage(msg.messages))
} else if (msg.role === "assistant") {
allMessages.push(new AIMessage(msg.messages))
}
}
// 添加当前用户信息
allMessages.push(new HumanMessage(input))
const chain = createChain()
const res = await chain.invoke({
chat_history: allMessages,
input: input,
})
onChat(String(res.content) || '')
}
页面组件
<template>
<div class="p-4 flex justify-center items-center">
<div class="flex gap-2 p-4 rounded-2xl shadow-md w-full max-w-200">
<input v-model="value" class=" w-full outline-0" type="text" placeholder="发送消息吧( •̀ ω •́ )✧">
<ElButton :disabled="isDisabled" type="primary" @click="onChatHandler">{{ btnText }}</ElButton>
</div>
</div>
</template>
<script setup lang="ts">
import chat from '@/agent';
import type { Chat } from '@/types/chat';
import { ElButton, ElMessage } from 'element-plus';
import { computed, reactive, ref } from 'vue';
const value = defineModel<string>(''); // vue3.6提供的新语法糖,不用再props和emit一个modelValue了
const loading = ref(false);
const history = reactive<Chat[]>([]);
const isDisabled = computed(() => !value.value && !loading.value); // 禁用按钮
const btnText = computed(() => loading.value ? '正在思考...' : '发送');
// 生成一个随机字符串
function randomId() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
const onChatHandler = async () => {
if (!value.value) return ElMessage.error('请输入内容');
loading.value = true;
console.log("用户:" + value.value);
console.log("====================================");
history.push({
id: randomId(),
name: 'user',
messages: value.value,
role: "user",
created_at: new Date(),
});
try {
await chat(value.value, (text) => {
console.log("ai:" + text);
console.log("====================================");
history.push({
id: randomId(),
name: 'assistant',
messages: text,
role: "assistant",
created_at: new Date(),
});
}, history);
} catch (error) {
if (error instanceof Error)
ElMessage.error('请求错误' + error.message);
} finally {
value.value = '';
loading.value = false;
}
};
</script>
<style lang="css" scoped></style>
经测试,控制台内容如下:
注意:
- 诺未生效,可能是没有充值,我deepseek充值了10块钱都用了两个月了
- apiKey一定要真实的,去模型提供商网站申请
- 本示例中还使用了element plus和tailwind css
无问芯穹再获超7亿融资
一行命令把 PSD 还原成 HTML / React / Vue:psd2code 实战干货
设计稿来了,运营要求"明天上线"。 你打开 PSD,开始切图、量像素、写 CSS、对位置——半天过去了,还在调那个差 2px 的按钮。
这篇文章介绍我们自研的
psd2code工具:一行命令把 PSD 转成可运行的前端项目,像素级还原 + 智能布局优化 + 多框架产物(HTML / React / Vue)。
一、为什么不用现成的 PSD 转 HTML 工具?
社区里其实已经有不少 PSD 转代码方案,但落到运营活动 / H5 / 长图详情页这类「像素级还原」需求上,普遍有三个痛点:
| 痛点 | 现象 |
|---|---|
| ① 文字字号不准 | PSD 里的 FontSize=17.5,浏览器渲染出来又小又模糊——因为忽略了图层 transform.scale
|
② 全是 position: absolute
|
几百个图层全部绝对定位,CSS 体积爆炸,后期完全不可维护 |
| ③ 组级效果丢失 | 圆角矩形 8px 外描边、文字描边+投影叠加,要么裁切要么糊掉 |
我们做了一组对比统计(实际 PSD:南瓜大作战 H5、总决赛-折叠 H5、兑奖 H5):
传统切图工作流: 设计稿到可运行 HTML 平均 4~6 小时
psd2code 自动转换: 设计稿到可运行 HTML 平均 20 秒
由此得出 psd2code 的四大核心方向,也正是本文后续四大章节:
-
PSD 解析:借助
psd-tools,但对它的缺陷做深度修补; - 资源提取与优化:像素去重 + 智能命名 + 合成背景图;
- 布局优化:聚类算法识别行列/网格、智能重写成 Flex;
- 多 Target 可插拔:同一份 IR,一键产出 HTML / React / Vue 三种工程。
二、整体架构:编译器式分层
psd2code 借鉴编译器的「前端解析 + IR + 后端代码生成」三段式:
flowchart LR
PSD[".psd<br/>设计稿"] --> Core["core/<br/>PSD 解析 + 图层渲染"]
Core --> IR[("IR<br/>pydantic 校验<br/>的中间表示")]
IR --> HTML["targets/html<br/>HTML + CSS"]
IR --> React["targets/react<br/>Vite + React 18"]
IR --> Vue["targets/vue<br/>Vite + Vue 3"]
IR -.预留.-> MP["targets/mini-program"]
style PSD fill:#f9e79f,stroke:#b9770e
style IR fill:#aed6f1,stroke:#1f618d
style HTML fill:#d5f5e3,stroke:#196f3d
style React fill:#d5f5e3,stroke:#196f3d
style Vue fill:#d5f5e3,stroke:#196f3d
style MP fill:#f5f5f5,stroke:#999,stroke-dasharray: 5 5
核心抽象:
-
IR (Intermediate Representation):pydantic BaseModel 严格定义、自带校验。是
core与targets之间的契约——任何 target 都从 IR 出发,不直接读 PSD。 - PipelineContext:贯穿所有 Stage 的全局上下文,承载 PSD、IR、配置、产物路径、target 中间产物等。
- Stage:单一职责的处理步骤,输入/输出都是 PipelineContext。
-
Target:一个产物对应一个 Target 子类,通过
@register("html")注册到全局 registry。
这个分层带来一个直接好处:HTML target 每次能力升级,自动惠及 React / Vue target——因为后两者只是在 HTML 产物之上做二次加工。
三、Skill 使用方式
psd2code 同时也是一个 CodeBuddy Skill,对话里直接说"帮我把这个 PSD 转成 HTML"就会自动触发;也可以脱离 CodeBuddy 单独跑命令行。
3.1 在 CodeBuddy 中调用(推荐)
只要项目里有 .codebuddy/skills/psd2code/ 目录,触发词就能让 CodeBuddy 自动加载该 skill:
"帮我把
设计稿/南瓜大作战.psd转成 HTML" "把这个 psd 转成 React 项目" "psd 转 vue" "设计稿转代码"
CodeBuddy 会自动选择合适的 target、定位 PSD 文件、执行 skill、把产物路径回报给你。
3.2 命令行直接运行
# 默认 target = html(同时产出 absolute 原版 + Flex 优化版)
python3 .codebuddy/skills/psd2code/psd_to_code.py /path/to/file.psd
# 显式指定 target
python3 .codebuddy/skills/psd2code/psd_to_code.py /path/to/file.psd --target html
python3 .codebuddy/skills/psd2code/psd_to_code.py /path/to/file.psd --target react
python3 .codebuddy/skills/psd2code/psd_to_code.py /path/to/file.psd --target vue
3.3 常用参数
| 参数 | 默认 | 说明 |
|---|---|---|
--target {html,react,vue} |
html |
选择产物形态 |
--css-style {compact,expanded} |
compact |
优化版 CSS 输出风格:compact 接近手写、expanded 全展开 + PSD 坐标溯源注释 |
--no-css-pretty |
关闭 | 关闭 CSS 美化,回到字母序机械渲染(CI 基线对比常用) |
举例:
# 想要排查某个元素位置不对,开 expanded 模式看坐标溯源注释
python3 .codebuddy/skills/psd2code/psd_to_code.py 南瓜大作战.psd --css-style expanded
# 跑 React 产物 + 启动 dev server
python3 .codebuddy/skills/psd2code/psd_to_code.py 南瓜大作战.psd --target react
cd output/南瓜大作战/react
npm install && npm run dev # http://localhost:5173
# 跑 Vue 产物
python3 .codebuddy/skills/psd2code/psd_to_code.py 南瓜大作战.psd --target vue
cd output/南瓜大作战/vue
npm install && npm run dev # http://localhost:5173
3.4 产物目录速查
output/<psd_stem>/
├── html/ # 任何 target 都会先产出
│ ├── index.html # 原始 absolute 版(保留 dev metadata,方便诊断)
│ ├── index_optimized.html # Flex 优化版(已剥离 dev metadata,最终交付物)
│ ├── style.css / style_optimized.css
│ ├── main.js # 国际化等运行时逻辑
│ ├── metadata.json # 图层树元数据
│ ├── layer_map.json # 反查表:CSS 类名 → PSD 原图层名
│ ├── _naming_report.md # 语义命名报告(每个 token 的来源)
│ └── images/ # 切图 / 合成图 / 背景图
├── react/ # --target react 时产出
└── vue/ # --target vue 时产出
3.5 排查与定位三件套
跑完后如果发现某处不对,优先看这三个文件:
-
_naming_report.md:CSS 类名为什么是这个?哪一层(layer1 词典 / layer2 角色推断 / fallback 拼音)给的? -
layer_map.json:.bg-main-4e8c1d是 PSD 里的哪个图层?图层类型? -
对比
index.html和index_optimized.html:absolute 原版作为"地面真相",优化版有偏移基本就是 LayoutOptimizer 哪一步过激了。
3.6 系统依赖
Python 3.10+
psd-tools >= 1.14
Pillow >= 10
numpy
beautifulsoup4
pydantic >= 2.0
pypinyin
四、PSD 解析:踩过 psd-tools 的那些坑
psd-tools 是 Python 生态里最成熟的 PSD 解析库,但它只实现了最常用的渲染器,对一些常见场景(shape 填充 + 图层样式、超大发光溢出、引擎字典 transform 等)要么出错、要么丢失。我们的做法是:能修的源码级修,不能修的绕开走手动栅格化。
3.1 文字 transform.scale 修正
PSD 文本图层的 engine_dict.StyleRun...FontSize 是原始字号,但 PS 实际渲染时会用 layer.transform = (a, b, c, d, e, f) 矩阵缩放——其中 a 是 X 缩放、d 是 Y 缩放。
真实例子:
PSD 图层:"消耗99兑换币"
raw FontSize = 17.5
transform.scale = 1.6
实际渲染字号 = 17.5 × 1.6 ≈ 28px
如果忽略 transform.scale,浏览器会用 17.5px 渲染,文字直接缩成花生米。另一个易踩的兄弟坑:ParagraphSheet 的路径不在 StyleRun 下,而是 engine.ParagraphRun.RunArray[0].ParagraphSheet.Properties 里的 Justification,老代码写错路径会导致所有文字永远左对齐。这两处我们在 core/psd/text_extractor.py 里重新解析。
3.2 浏览器字宽差异:纵向 + 横向双兜底
PSD 设计师常用思源黑体 / 造字工房,浏览器渲染时却用 PingFang / Arial——相同字号下,浏览器中文会比 PSD 更宽,导致:
- 单行文本被挤成两行("预测四"+"强")
- 按钮内文字撑出按钮边界
我们设计了双兜底:
# 纵向兜底:字号不超过 bbox 高度的 0.85
if font_size >= height * 0.85:
font_size = height * 0.85
# 横向兜底(仅纯中文短标题):按字数 + 宽度倒推上限
if pure_cjk and char_count <= 12:
max_font_by_width = width / cjk_count * 0.95
font_size = min(font_size, max_font_by_width)
效果验证(总决赛-折叠 PSD):
| 文本 | 修正前 | 修正后 | 效果 |
|---|---|---|---|
| 主赛区(68px 宽) | 25.5px | 20.9px | ✓ 单行 |
| 预测四强(164px 宽) | 46.75px | 38.5px | ✓ 不再折行 |
3.3 Shape + 图层样式描边:psd-tools 的致命 bug
一个看似普通的 PSD shape 图层(#feffd7 浅黄填充 + 2px 内描边 #5f8618 绿色),用 psd-tools 直接 layer.composite() 会得到整片纯绿色——填充色完全丢失。
排查后发现两个叠加的根因:
-
layer.topil()对 shape 返回None,老代码降级到layer.composite()取基础图;但composite()把整块 shape 区域错误地涂成描边色。 -
draw_stroke_effect用skimage.filters.scharr检测 alpha 边缘做描边,当 alpha 紧贴画布无 padding 时,scharr 检测不到边缘,归一化后 mask 整片=1,描边色铺满整张图。
绕开方案:
- 新增
_render_shape_base_from_fill(layer):跳过composite(),直接读SoCo填充色 +origination几何(Rectangle / RoundedRectangle / Ellipse)用 PILImageDraw合成基础图。 - 调用
draw_stroke_effect前给 alpha 加 padding(pad = max(stroke_size+2, 4)),渲染完再裁回。
# stroke_renderer.py
padded_alpha = np.pad(alpha, ((pad,pad),(pad,pad),(0,0)), mode='constant')
stroke_color, stroke_mask = draw_stroke_effect(bbox, padded_alpha, ...)
out[:,:,:3] = stroke_color[pad:pad+h, pad:pad+w, :]
out[:,:,3:4] = stroke_mask[pad:pad+h, pad:pad+w, :] * opacity
效果:兑奖活动卡片的浅黄背景 + 2px 绿描边正确还原,无需人工补图。
3.4 图层样式的两层 enabled 开关
PS 图层样式面板有两个开关:
-
整体开关:
layer.effects.enabled(fx 行最左边那个勾) -
单项开关:
effect.enabled(外发光/描边/投影各自的勾)
PS 的规则:整体开关关闭 → 所有效果都不渲染,哪怕单项 enabled=True。
psd-tools 不替你 AND 这两个标志,psd2code 早期代码多处只看 effect.enabled,导致"PS 中没效果的文本"被当成"有外发光"处理,错误地栅格化成图片。修复后统一用一个 helper:
def is_effect_active(effect, layer):
if not layer.effects or not layer.effects.enabled:
return False
return bool(effect.enabled)
全工程 8 处调用点全部切换。这解决了带 fx 残留的昵称文本全部被错误降级为图片的问题。
3.5 组级效果溢出:手动渲染 + composite 混合
PS 图层效果(描边、阴影、发光,共 8 种)在组(Group)级别有个隐蔽特性:效果会沿组的整体边界裁切。
psd-tools 的 composite() 能在组内正确复现这一行为——但只在组的 bbox 内有效。一个圆角矩形有 8px 外描边时,描边会溢出组的 bbox 被 composite() 裁掉。实测过各种"绕过"方法——父级 composite、根级 composite、给超大 viewport——都是徒劳:
psd-tools 在任何层级 composite,都按目标节点(及其所有祖先组)的 bbox 做硬裁切,不存在绕过方案。
我们的解法是「手动栅格化 + composite 混合」:
1. 先用扩展画布 + 手动逐层渲染 → 拿到完整溢出像素(外部区域)
2. 再用 group.composite(viewport=bbox) → 拿到组 bbox 内的 PS 原生高质量
3. 把 composite 结果覆盖到手动渲染结果的内部区域
最终:外部保留溢出效果,内部达到像素级匹配(实测 Alpha 差异 max=0、mean=0.00)。
硬约束:子组(嵌套组)必须用
sub_grp.composite(viewport=grp_bbox)渲染,不能退回"递归调用_render_group_expanded+ 裁切"——历史回归实测会在圆角轮廓位置多出 ~75px/行的错误描边。
3.6 总结:PSD 解析的修复清单
| 问题 | 表现 | 我们的做法 |
|---|---|---|
| transform.scale 未应用 | 文本被缩成花生米 | 读 layer.transform[0],FontSize 乘以它 |
| ParagraphSheet 路径错误 | 所有文字都是左对齐 | 走 ParagraphRun.RunArray[0] 路径重新解析 |
| shape + 图层样式整片涂描边色 | 兑奖卡片全变绿 | 手动栅格化填充 + 给 alpha 加 padding 再描边 |
| 两层 enabled 未 AND | 无效果文本被错误降级为图片 | 统一 is_effect_active(effect, layer)
|
| 组级效果溢出被裁 | 外描边断掉 | 混合渲染:手动扩展 + composite 覆盖 |
五、资源提取与优化
psd2code 对每个图层做一次决策:切图 / 文字保留 / shape 用 CSS 还原 / 吸收为父容器背景 / 合并成单图,最终写到 output/<psd>/html/images/ 目录。
5.1 智能去重:基于内容哈希而非图层名
活动页里"星星""糖果""装饰点"这类小图会被设计师复用几十次,每次都单独切图是极大浪费。
def _save_image_dedup(self, img, name, depth) -> str:
data = serialize(img) # 按 Config.IMAGE_FORMAT 序列化
md5_hash = hashlib.md5(data).hexdigest()
if md5_hash in self._image_hash_map: # 命中去重
return self._image_hash_map[md5_hash]
path = make_image_filename(name, content_hash=md5_hash, ltype=ltype)
self._image_hash_map[md5_hash] = path
write(path, data)
return path
南瓜大作战 H5 实测:239 个 image 图层 → 87 张 PNG(去重率 63.6%)。
5.2 语义化文件名:tag + 内容哈希
旧方案拼音 + 自增序号(yuanjiaojuxing_3_7.png)有两个问题:HTML 里不可读;每次运行序号都在跳,git diff 噪声极大。
新方案:
images/<semantic-tag>-<md5前6位>.png
例:rounded-a3f012.png
btn-receive-279914.png
bg-main-4e8c1d.png
candy-big-7b0a12.png
-
semantic-tag由semantic/子包从图层名推断得出(支持 3 层置信度:Layer2 角色推断 → Layer1 清洗词典 → fallback 关键词 + 拼音),PS 默认名走ltype兜底(img/shape) -
md5前6位= 图片内容哈希——PSD 没改,文件名就不变,git diff 和 CDN 缓存两全其美 - 同名撞车自动追加
-2/-3
5.3 形状层保留矢量:不切图就是最好的优化
圆角矩形、椭圆、纯色矩形这类简单 shape,不切图而是直接翻译成 CSS 几何属性:
| PSD shape | 输出 CSS |
|---|---|
| Rectangle + SoCo 填充 | background: <color>; width/height |
| RoundedRectangle | border-radius: <r>px |
| Ellipse | border-radius: 50% |
| shape + 图层样式描边 | border: <w>px solid <color> |
效果:CSS 体积下降的同时,文件也能 retina 无损缩放。
5.4 多张全屏背景的合成
活动页常见模式:组里有 2~3 张全屏背景叠加(渐变底 + 花纹 + 噪点)。如果每张都单独切图,HTML 里会写多 url 背景:
.bg-section {
background-image: url(bg-gradient.png), url(bg-pattern.png), url(bg-noise.png);
background-position: 0 0, 0 0, 0 0;
background-repeat: no-repeat, no-repeat, no-repeat;
}
问题:多张 PNG 多次请求,而且浏览器要多次合成。psd2code 的做法是在布局优化阶段检测这种多 url 模式,用 PIL alpha_composite 合成单张 PNG 写回磁盘:
输入:bg-gradient.png(284 KB) + bg-pattern.png(412 KB) + bg-noise.png(67 KB)
输出:flat-af0dce35.png (153 KB) # 1/5 体积、1 次请求
南瓜大作战 H5 实测:47 组合并、节省 45.6 KB。
一个与 CSS 规范相反的坑:background-image: url(a), url(b) 中第一个 url 在视觉最上层,而 PIL alpha_composite 期望"底层在前"。调用方必须 reverse 列表——早期代码漏掉 reverse 导致所有合成图的颜色上下层叠错,颜色"对调"。
5.5 图层扁平化:子图合并 + 父容器吸收
更激进的优化:当一个容器里只有纯 image 子图层(无文本、无按钮),把容器自身的 background + 所有 image 子按 z 序合成单张 PNG、删掉所有子 div 及其 CSS 规则、只留容器自己的 background-image。
这个 ImageLayerFlatten transformer 采用后序遍历 + 多轮扫描:最深层先合并、外层再发现"我的子变成单 div 了"继续合并。护栏非常严格:
- 子元素必须全部
data-type="image"且无孙子 -
opacity≈1/mix-blend-mode: normal - 容器本身不能有
border-radius / box-shadow / clip-path / filter / transform等"不能烧进 PNG 的装饰字段"(一旦合并后再叠这些,会双重作用) - 总层数 ≥ 2,几何包围盒 ≤ 画布 50%(否则合并一张巨图反而得不偿失)
南瓜大作战 H5 实测:这一步搞定了 47 个容器的视觉简化,DOM 节点从 ~500 降到 ~280。
六、布局优化(本工具最核心的功能)
直接用 absolute 还原 PSD 没问题,但 200+ 图层全部 position: absolute 是工程灾难。psd2code 的 LayoutOptimizer 把 absolute 智能重写成 Flex,同时保证视觉零偏移。
6.1 七步流水线全景
flowchart TD
A["原始 absolute HTML"] --> B["Step 1:DOM 重构<br/>(聚类 / 背景剥离 / 容器吸收)"]
B --> C["Step 1.2:图层扁平化<br/>(多 image 子 → 单张合成 PNG)"]
C --> D["Step 1.5:同质兄弟分组<br/>(识别 v-list,支持 v-for)"]
D --> E["Step 2:Flex 推断<br/>(analyzer V10 + 三道闸门)"]
E --> F["Step 2.5:单子 wrapper 折叠<br/>(消除中间层)"]
F --> G["Step 3:CSS 去冗余<br/>(z-index 精简 + 等价规则合并)"]
G --> G2["Step 3.5:重复元素抽取<br/>(3+ hash 类 → 单 base 类)"]
G2 --> H["Step 4:CSS 美化<br/>(DOM 序 + 属性分段 + 多行)"]
H --> I["✅ 优化后 HTML / CSS"]
style A fill:#fadbd8,stroke:#c0392b
style I fill:#d5f5e3,stroke:#196f3d
style B fill:#fcf3cf,stroke:#b7950b
style E fill:#fcf3cf,stroke:#b7950b
style G fill:#fcf3cf,stroke:#b7950b
6.2 聚类算法:怎么"看懂"一堆 absolute 框
这是整个 LayoutOptimizer 的灵魂。对任意一个容器,我们有 N 个子图层的 bbox(left/top/width/height),目标是自动把它们组织成**行(row)/ 列(col)/ 叠图组(stack)**的树状结构。
第一步:切行(_split_by_rows)
从左到右、从上到下遍历子元素,维护一个"当前行"的 envelope(bbox 包络)。新来一个元素 e,判断它和 envelope 的纵向重叠率:
overlap_y = min(e.bottom, env.bottom) - max(e.top, env.top)
ratio = overlap_y / min(e.height, env.height)
if ratio >= 0.5: # 同行判据
env 吸收 e,继续
else:
新开一行
第二步:行内切列
对每一行内部,把切行逻辑换成纵/横轴就是切列。递归后我们得到一棵"行包列 / 列包行"的嵌套树。
第三步:背景层剥离
一个组里常有"全屏卡片底框 + 多个浮层元素"的设计模式。直接聚类会把底框当成一个"占 100% 空间的大元素",严重干扰行/列判断。我们在聚类前先剥离满足以下三种规则之一的"背景层":
- 完全包含型:bbox 完全包住其他所有元素
- 主轴覆盖型:在主轴(宽或高)上覆盖 ≥ 90%
- 双轴主导覆盖型:宽、高都覆盖 envelope ≥ 80%(识别"略带 padding 的卡片底图")
剥离后的背景层被吸收进父容器的 background-image 列表。
第四步:伪多行装饰堆叠回退
切出多行后,若所有行都只有一个元素、且相邻行横向覆盖率 ≥ 80%,回退为 stack(堆叠)——这是"图标 + 标题上下贴边"这种"本质上堆叠装饰"的场景。
第五步:二维网格识别
当多行 × 多列的元素满足"列对齐 + 跨行对齐"时,单纯用"列 包 行"嵌套表达不够干净,改成显式的 v-grid-row + flex column 结构:
rows = _split_into_rows(...)
if len(rows) >= 2 and all_rows_have_aligned_cols(rows):
layout_type = 'grid'
flex_applier 包装为:
父: display:flex; flex-direction:column
每行: <div class="grid-row-N v-grid-row">
南瓜大作战 H5 的"用户信息区"9 个子图层(剥掉背景卡 + 头像装饰后剩 7 个文本),被正确识别为 2 行 × [4, 3] 列 grid。
6.3 三道安全闸门:什么时候不该用 Flex
不是所有看起来"整齐"的容器都该用 Flex。我们踩过太多坑后总结出三道闸门(全在 layout_analyzer.py):
互相重叠的装饰簇
n 个图层互相重叠(每个与多个邻居都重叠),且 trend_ratio < 0.6。典型场景:多层装饰贴纸、若干徽章叠在一起。判定为堆叠装饰,保持 absolute。
支配背景层
存在某个子元素 X 满足 X.area / envelope.area >= 0.8,且其余子元素中 ≥ 60% 显著落在 X 内(重叠/自身面积 ≥ 0.6)。判定为"大底图 + 多个浮层"的卡片,整组保持 absolute。
装饰剥离
先把子节点分类为 bg / decor / content 三类,只在 content 子集上做趋势检测。这让"内容整齐成行 + 角落有装饰"的容器不再因为装饰打乱排版被误判。
6.4 Flex 应用:非趋势子元素保留 absolute
识别为 vertical / horizontal 后,我们把趋势元素写成 flex 子项(用 margin 表达间距),非趋势元素(角标、装饰)保留其 position: absolute 坐标:
/* 趋势元素:flex 流 */
.prop-card-1 { margin-top: 20px; margin-left: 0; }
.prop-card-2 { margin-top: 18px; }
/* 非趋势元素:保留 absolute */
.badge { position: absolute; right: -6px; top: -6px; }
这里有个极易反复重犯的 bug:容器重构后,子元素的 top/left 是"相对父容器"的坐标(由 extract 阶段产出),不需要再减父 top。
还有一条来自 v-stack 的保护:flex_applier 默认会 del child_css['position'] 把子元素的定位去掉;但如果子本身是 v-stack wrapper(内含 absolute 子节点),删除 position 会让其孩子跳到外层定位,直接飘到屏幕角落。修复:遇到 'v-stack' in child.classes 就改成 position: relative,而不是删除。
6.5 同质兄弟簇检测:识别"同类卡片"
PSD 设计师经常把 N 个商品卡 / 道具卡 / 礼包卡平铺在 #canvas 直接子,没有用父组包起来。传统聚类只在已有 group 内部做,这种列表会全部走 absolute 路径,开发拿到的 HTML 完全看不出"它是一个数据列表",没法直接写 v-for。
SiblingGroupDetector 的 5 条 AND 规则:
- ≥ 3 个连续兄弟
-
class 词根相同(去掉
__\d+后缀和-\d+序号)——prop__30 / prop-2__38 / prop-10__101词根都是prop,这是最强的设计师意图信号 - bbox 尺寸近似(误差 ≤ 5%)
- 满足网格规则:
M 列 × K 行满格排布,同列 left 一致、同行 top 一致(误差 ≤ 2px) - 父容器本身不是 flex
识别成功后包成虚拟容器:
<div class="prop-list v-list" data-virtual="list">
<div class="prop__30 layer-group">...</div>
<div class="prop-2__38 layer-group">...</div>
<div class="prop-3__45 layer-group">...</div>
</div>
CSS 用 display: flex; flex-wrap: wrap; column-gap / row-gap,下游开发可直接写 v-for。
一个设计决定:我们不做子结构同构判定。实际 PSD 里同类卡几乎总是有差异(首张卡设计完复制改文案,结构漂移:少一行文字、按钮换成图片、装饰数量不一致)。强求子结构一致会绝大多数现实场景识别失败——class 词根 + bbox 尺寸两条已经够强。
6.6 CSS 去冗余:z-index 精简 + 等价规则合并
core/extract/layer_exporter.py 给每个图层无脑塞 z-index = 全局 layer_id——这是合理的像素还原默认值,但优化版完全不需要。CssDedup 分两个 Pass:
Pass 1 — z-index 精简
遍历每个父容器,收集子元素的 (selector, z) 序列:
| 形态 | 动作 |
|---|---|
| 长度 0 | 跳过 |
| 长度 1(独 z,其他全 None) | 删该 z-index |
| 长度 ≥ 2 严格递增 | 全删(DOM 顺序 = z 序) |
| 长度 ≥ 2 出现倒挂 | 全保留 |
逻辑:position:absolute 子元素的叠序只在"兄弟 bbox 重叠"时依赖 z-index;绝大多数父容器下"DOM 顺序 = z 序升序"(这是 LayerRenderer 的天然产出),浏览器默认行为就能正确实现叠序。
Pass 2 — 等价规则合并
属性 dict 完全相等的多个选择器合并为 .a, .b, .c { ... } 单条规则。南瓜大作战 H5 实测合并 209 条。
Pass 3.5 — 重复元素抽取
Pass 2 合并了 CSS,但 HTML 里依然写了 N 个不同的 hash 类(.prop__68 / .prop__105 / ...)。RepeatClassUnifier 进一步:≥ 3 个 .<base>__<digits> 形式的等价类 → 合并为单一 base 类(.prop),HTML 同步改写。
最终 HTML 里你看到的就是:
<div class="prop-list v-list">
<div class="prop layer-group">...</div>
<div class="prop layer-group">...</div>
<div class="prop layer-group">...</div>
</div>
直接就是这种干净的语义化结构。
6.7 实战效果(南瓜大作战 H5)
| 指标 | V2 优化器 | 当前版本 |
|---|---|---|
| 元素位置偏移 PSD 原位置 | 94 个元素偏离 5~13px | 0 个元素偏离 |
| CSS 行数 | 4805 | 1499 |
| CSS 块数 | 457 | ~270 |
| z-index 字段 | 432 | 97 |
| 6×4 任务网格识别 | 每个 cell 独立 absolute | 自动识别 v-col + v-row 嵌套 |
下面这张是真实产物里"任务格子"那段——20 多个图层、4 行多列、每个 cell 带描边小图标,全部由算法自动识别:
6.8 算法的天花板与人工边界
再好的算法也有上限——下面这些场景 psd2code 会"尽力而为,但结果不一定最优":
① Flex 布局化不充分:设计师图层组织混乱
典型问题:活动页版块 2 的按钮、图标、装饰全部散乱摆在同一个 PSD 根组,没有任何分组——聚类算法能看到的只是 bbox 位置,看不到"设计意图"。
👉 解决方案:整理 PSD 图层结构。按视觉版块分组(版块1-签到 / 版块2-道具 / 版块3-任务),每个版块内部再按"标题 / 卡片列表 / 底部按钮"分组。psd2code 会优先在已有组内部做聚类,组边界 = 聚类边界。分好组之后,95% 的场景都能自动重构为干净的 Flex。
② CSS 不够语义化:图层名用了默认命名
典型问题:PSD 图层名是 矢量智能对象、图层 12 拷贝 3、形状 47——psd2code 只能给你 .img-a3f012 这种内容哈希名,无从推断语义。
👉 解决方案:整理图层命名。重要的结构性图层给中文或 kebab-case 命名(bg-main / btn-领取 / 用户信息背景 / 任务卡片)。psd2code 的 semantic/ 子包能识别:
- 按钮语义:
btn/按钮/领取/确定→.btn-receive/.btn-ok - 背景语义:
bg/背景/底框→.bg-main - 卡片容器:
prop/card/道具→.prop-card - 中文关键词:通过
common/cn_dict.json词典映射到 kebab-token
命名整洁之后,HTML 就会是 .prop-card / .btn-receive / .user-info-bg 这种一眼看懂的语义类,而不是 hash 串。
③ 人工干预:特殊场景需人工调整
psd2code 只实现常用渲染器所以部分图层导出效果不好(全实现产出比太低),需要人工干预。
👉 解决方案:手动栅格化或导出图片。
七、实战演练:把"南瓜大作战 H5"PSD 跑一遍
以 南瓜大作战 H5(750 × 6778 长图活动页)为例,一行命令 20 秒拿到完整可运行 HTML:
$ python3 psd_to_code.py "南瓜大作战 H5.psd" --target html
🎨 合并背景图层: ['背景', '矩形 1', '形状 839 拷贝 2']
🖼️ background [合并3层 750x6778] → images/background-f07984.png
📁 solgan (组)
✨ 形状 16 (含效果渲染)
🌟 检测到效果溢出 6px,使用混合渲染策略
📁 版块1 (组) ...
🎨 开始布局优化...
✅ 优化完成!
- DOM 重构: 60 个
- v-list 创建: 3 个 (包裹 24 个节点)
- 应用 flex: 28 个
- z-index 精简: 304 处
- CSS 等价规则合并: 节省 128 条
- 重复元素抽取: 25 组 → 删除 49 个 hash 类、复用到 61 个元素
- 图层扁平化: 47 个容器 (共合并 105 层, 节省 45.6 KB)
✅ 产物:output/南瓜大作战 H5/html/
浏览器打开 index.html 第一屏——和 PSD 设计稿完全像素对齐,包括 solgan 上的描边发光、用户信息区的圆角、糖果图标的渐变叠加:
absolute 原版 vs Flex 优化版对比:
| 文件 | HTML 大小 | CSS 大小 | 定位方式 | 可维护性 |
|---|---|---|---|---|
index.html |
71 KB | 113 KB | 全部 position: absolute
|
⭐⭐ |
index_optimized.html |
52 KB | 38 KB | Flex 嵌套 + 局部 absolute | ⭐⭐⭐⭐ |
不要小看这 75 KB 的 CSS 压缩——它代表着 60 个容器被语义化、25 组 hash 类被复用,后期改样式不再需要逐个调
top/left。
整个活动页 6778px 长,包含 9 大版块(用户信息 / 任务区 / 道具 / 助力 / 排行 等):
转换日志里有几个有意思的点:
- 组级效果溢出自动触发 3 次:solgan 日期组(6px)、副标题组(10px)、糖果数目组(4px)——全部走"手动栅格化 + composite 混合"。
- 47 个容器被图层扁平化:原本 105 张 image 合并成 47 张 PNG,节省 45.6 KB。
-
3 组同质兄弟列表识别:道具卡 × 6、任务卡 × 12、排行榜条目 × 6,被包成
v-list——可直接写v-for。 - 叠图组识别:邀请助力 / 核销助力码 / 版块3(7 个图层)等被 V8/V9 闸门正确识别为"装饰堆叠",保持 absolute。
八、多 Target 可插拔架构
8.1 Target Registry:装饰器注册
targets/registry.py 非常简单:
_REGISTRY: dict[str, Type[Target]] = {}
def register(name: str):
def _wrap(cls: Type[Target]) -> Type[Target]:
key = name.strip().lower()
if key in _REGISTRY and _REGISTRY[key] is not cls:
raise ValueError(f"Target '{key}' already registered")
cls.name = key
_REGISTRY[key] = cls
return cls
return _wrap
每个 target 是 Target 子类,实现 build_pipeline(ctx) -> Pipeline:
# targets/html/target.py
@register("html")
class HtmlTarget(Target):
def build_pipeline(self, ctx):
return Pipeline([
LoadPsdStage(),
ParseStage(),
ExtractAssetsStage(),
CodegenStage(),
LayoutOptimizeStage(),
EmitStage(),
])
# targets/react/target.py
@register("react")
class ReactTarget(Target):
def build_pipeline(self, ctx):
return Pipeline([
HtmlTarget().build_pipeline(ctx), # 先产出 HTML(含优化版)
Html2ReactStage(), # 再转 JSX + Vite 脚手架
])
# targets/vue/target.py 同理
CLI --target vue → get("vue") → 实例化 → target.run(ctx)。新增 target(比如小程序)只需:
@register("mini-program")
class MiniProgramTarget(Target):
def build_pipeline(self, ctx):
return Pipeline([
HtmlTarget().build_pipeline(ctx),
Html2WxmlStage(), # 转 WXML
Html2WxssStage(), # 转 WXSS
])
无需改动核心代码。
8.2 为什么 React / Vue 都在 HTML 基础上二次加工
业界也有"直接从 IR 生成 JSX"的设计,但 psd2code 选择"先走一遍 HTML target,再转框架":
- HTML target 的优化(布局、CSS 去冗余、语义化命名)免费继承给 React/Vue——任何一次 LayoutOptimizer 升级自动惠及三端。
- 开发者本地 review 时可以直接对比
html/index_optimized.html和react/src/App.jsx的视觉一致性,容易定位转换问题。 - React/Vue 的转换就是 DOM 遍历 + class/style 重映射 + 模板语法替换,逻辑简单、可测试性强。
8.3 产物结构一览
output/<psd_stem>/
├── html/ # 任何 target 都会先产出
│ ├── index.html # absolute 版(与 PSD 像素级对齐)
│ ├── index_optimized.html # Flex 优化版
│ ├── style.css / style_optimized.css
│ ├── main.js # 国际化等运行时逻辑
│ ├── metadata.json # 图层树元数据
│ ├── class_alias_map.json # 老 hash 类 → 新语义类的映射
│ └── images/ # 切图 / 合成图 / 背景图
├── react/ # --target react 产出
│ ├── package.json / vite.config.js
│ └── src/App.jsx, App.css, main.jsx, assets/images/
└── vue/ # --target vue 产出
├── package.json / vite.config.js
└── src/App.vue, main.js, assets/images/
React / Vue 产物开箱即用:
cd output/<psd_stem>/react # 或 vue
npm install && npm run dev # http://localhost:5173
九、其他你可能在意的细节
- 图片去重:按内容 MD5,同一张装饰图只导出一次。
-
语义化类名:图层名
预测四强→.yucesi__152(拼音兜底),或通过cn_dict.json词典映射为.predict-top4。 -
国际化预留:所有文本节点自动带
data-i18n-key,可通过 JS 动态替换。 - 旋转/倾斜文本:自动降级为图片,保证视觉一致。
-
剪贴蒙版:按
layer.clip标志识别,合并成父图基底 + 描边/发光效果。
十、踩过的坑(写给后来者)
如果你打算自己实现 PSD → 代码工具,以下几个坑可以省你几天:
-
transform.scale不能忘——所有 FontSize 都要乘以transform.a / transform.d。 - shape + 图层样式描边 psd-tools 会整片涂描边色——必须手动用 SoCo + origination 合成基础图、给 alpha 加 padding 再描边。
-
两层 enabled 开关必须 AND——
layer.effects.enabled(整体)和effect.enabled(单项)都要为 True 才算生效。 -
composite()的 viewport 限制——任何层级的 composite 都按"目标节点 + 所有祖先"的 bbox 硬裁切,不存在绕过方案,组级溢出效果必须手动扩展画布。 - 子组必须用 composite 渲染——不要退回手动递归,会在圆角处多出 ~75px/行的错误描边。
-
tree.children顺序 ≠ z 序——背景剥离后再合并 background-image 时,必须按原 DOM sibling index 重排。 -
多 url 背景合成时 reverse 列表——CSS 第一个 url 是视觉最上层,但 PIL
alpha_composite期望底层在前,反了会颜色对调。 -
CSS parser 别用贪婪正则——
@media (...) { #canvas { ... } }嵌套时,简单正则会把内层#canvas误当顶层规则,整个 canvas 塌成 0 高。 -
flex 容器 envelope 越界:
envelope.left/top可能为负(图层超出组 bbox),写 padding 时要max(0, ...),否则 cross_offset 算多了。 -
v-stack wrapper 的 position 必须保留——flex_applier 默认
del child_css['position'],遇到 v-stack 要改写为relative,否则内部 absolute 子元素会跳到外层定位。 -
background-repeat: no-repeat不是默认值——background-repeat的 CSS 默认值是repeat,CssDedup 删默认值时不能把它加进去,否则大背景图会被平铺。
十一、写在最后
psd2code 不是一个"AI 读图猜布局"的玩具——它是一个严格基于 PSD 结构信息的编译器。每一步决策都可解释、可调参、可单测,算法失败点(比如 V8/V9/V10 闸门)都有明确的 fallback 路径。
再强调一次:算法做的再多效果也是有限的。想要 psd2code 产出高质量代码,有两件事你得做:
- 整理 PSD 图层结构(按视觉版块分组)
- 整理 PSD 图层命名(关键图层给语义名)
做到这两点,运营活动页从设计稿到可上线代码的时间可以从 4~6 小时降到 20 秒。
未来计划:
- ✅ HTML / React / Vue 已上线
- 🚧 小程序 target(架构已预留扩展点)
- 🚧 Tailwind CSS 输出
- 🚧 Figma 文件支持(共享 IR,新增 figma loader)
如果你也在做活动页 / 长图详情页 / 运营 H5,欢迎试用并提反馈。
Thanks
以上就是本篇文章的全部内容,如有问题欢迎指出,我们一起进步。
如果觉得本篇文章对您有帮助的话请点个赞让更多人看到吧,您的鼓励是我前进的动力。
谢谢~~
源代码地址
月之暗面申请注册KimiClaw商标
抖音火山引擎在苏州成立新科技公司,注册资本1000万
纳芯微发布ASIL D隔离驱动芯片
WebView 性能优化实战:从首屏1.5秒到300毫秒
WebView 性能优化实战:从首屏1.5秒到300毫秒
做移动端H5开发,性能是永恒的话题。本文分享我在实战中总结的WebView优化方案
前言
"页面加载太慢了,用户都流失了"
这是很多移动端H5开发者面临的痛点。WebView的性能直接影响用户体验,但优化起来往往无从下手。
本文从实战角度出发,分享我从首屏1.5秒优化到300毫秒的经验。
一、性能指标定义
1.1 核心指标
| 指标 | 说明 | 目标值 |
|---|---|---|
| 白屏时间 | 从发起请求到首屏可见 | < 500ms |
| 首屏时间 | 从发起请求到首屏内容渲染完成 | < 1000ms |
| 可交互时间 | 页面可以响应用户操作 | < 1500ms |
| 完全加载时间 | 所有资源加载完成 | < 3000ms |
1.2 测量方法
使用 Performance API
// 白屏时间
const whiteScreenTime = performance.timing.domLoading - performance.timing.navigationStart;
// 首屏时间
const firstPaintTime = performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart;
// 完全加载时间
const loadTime = performance.timing.loadEventEnd - performance.timing.navigationStart;
使用 PerformanceObserver(推荐)
// 观察首屏渲染
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('FP:', entry.startTime); // First Paint
}
});
observer.observe({ type: 'paint', buffered: true });
// 观察 LCP(Largest Contentful Paint)
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP:', lastEntry.startTime);
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
二、WebView 初始化优化
2.1 WebView 预加载
问题:WebView 初始化需要时间,首次打开页面会有延迟。
方案:在 Application 启动时预热 WebView。
Android 实现
public class MyApp extends Application {
@Override
public void onCreate() {
super.onCreate();
// 在主线程预热 WebView
WebView webView = new WebView(this);
webView.destroy();
}
}
iOS 实现
// 在 AppDelegate 中预热 WKWebView
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 预热 WKWebView
let config = WKWebViewConfiguration()
let webView = WKWebView(frame: .zero, configuration: config)
return true
}
优化效果:首次打开 WebView 时间减少 200-300ms。
2.2 WebView 复用池
public class WebViewPool {
private static final int MAX_POOL_SIZE = 2;
private static final Queue<WebView> pool = new LinkedList<>();
public static WebView obtain(Context context) {
WebView webView = pool.poll();
if (webView == null) {
webView = new WebView(context);
}
return webView;
}
public static void recycle(WebView webView) {
if (pool.size() < MAX_POOL_SIZE) {
webView.loadUrl("about:blank");
webView.clearCache(true);
pool.offer(webView);
} else {
webView.destroy();
}
}
}
三、网络请求优化
3.1 DNS 预解析
<head>
<!-- DNS 预解析 -->
<link rel="dns-prefetch" href="//cdn.example.com">
<link rel="dns-prefetch" href="//api.example.com">
</head>
3.2 预连接
<head>
<!-- 预建立连接 -->
<link rel="preconnect" href="https://cdn.example.com">
<link rel="preconnect" href="https://api.example.com" crossorigin>
</head>
3.3 资源预加载
<head>
<!-- 预加载关键资源 -->
<link rel="preload" href="/css/main.css" as="style">
<link rel="preload" href="/js/main.js" as="script">
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
</head>
3.4 HTTP 缓存策略
静态资源:强缓存 + 版本号
# nginx 配置
location ~* \.(js|css|png|jpg|gif|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
HTML 页面:协商缓存
location ~* \.html$ {
etag on;
if_modified_since exact;
add_header Cache-Control "no-cache";
}
四、WebView 缓存优化
4.1 离线缓存方案
方案一:Application Cache(已废弃)
不推荐,现代浏览器已移除支持。
方案二:Service Worker(推荐)
// 注册 Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => console.log('SW registered'))
.catch(error => console.log('SW registration failed'));
}
// sw.js
const CACHE_NAME = 'v1';
const CACHE_URLS = [
'/',
'/css/main.css',
'/js/main.js'
];
// 安装时缓存
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(CACHE_URLS))
);
});
// 拦截请求
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);
})
);
});
4.2 Android WebView 缓存配置
WebSettings settings = webView.getSettings();
// 开启 DOM Storage
settings.setDomStorageEnabled(true);
// 开启数据库缓存
settings.setDatabaseEnabled(true);
// 设置缓存模式
settings.setCacheMode(WebSettings.LOAD_DEFAULT);
// 设置缓存路径
String cachePath = getCacheDir().getAbsolutePath();
settings.setAppCachePath(cachePath);
settings.setAppCacheEnabled(true);
4.3 iOS WKWebView 缓存配置
let config = WKWebViewConfiguration()
// 配置缓存
let websiteDataStore = WKWebsiteDataStore.nonPersistent()
config.websiteDataStore = websiteDataStore
// 或者使用默认缓存
config.websiteDataStore = .default()
五、渲染优化
5.1 阻塞渲染的资源处理
CSS 放头部,JS 放底部
<head>
<link rel="stylesheet" href="/css/main.css">
</head>
<body>
<!-- 内容 -->
<script src="/js/main.js"></script>
</body>
JS 异步加载
<!-- 异步加载,不阻塞解析 -->
<script async src="/js/analytics.js"></script>
<!-- 延迟加载,解析完成后执行 -->
<script defer src="/js/main.js"></script>
5.2 关键渲染路径优化
内联关键 CSS
<head>
<style>
/* 内联首屏关键 CSS */
body { margin: 0; font-family: sans-serif; }
.header { height: 50px; background: #fff; }
.content { padding: 20px; }
</style>
<link rel="preload" href="/css/main.css" as="style" onload="this.rel='stylesheet'">
</head>
5.3 图片优化
懒加载
<img src="placeholder.jpg" data-src="real-image.jpg" loading="lazy">
// Intersection Observer 懒加载
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
});
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));
响应式图片
<picture>
<source srcset="image-small.webp" media="(max-width: 600px)" type="image/webp">
<source srcset="image-large.webp" media="(min-width: 601px)" type="image/webp">
<img src="image.jpg" alt="fallback">
</picture>
六、JavaScript 优化
6.1 代码分割
// 动态导入
const module = await import('./heavy-module.js');
// React 懒加载
const LazyComponent = React.lazy(() => import('./HeavyComponent'));
6.2 长任务优化
// 使用 requestIdleCallback
requestIdleCallback(() => {
// 执行低优先级任务
});
// 使用 Web Worker
const worker = new Worker('worker.js');
worker.postMessage({ type: 'heavy-computation', data: largeData });
worker.onmessage = (e) => {
console.log('Result:', e.data);
};
6.3 事件处理优化
// 节流
function throttle(fn, delay) {
let last = 0;
return function(...args) {
const now = Date.now();
if (now - last >= delay) {
fn.apply(this, args);
last = now;
}
};
}
// 防抖
function debounce(fn, delay) {
let timer = null;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 应用
window.addEventListener('scroll', throttle(handleScroll, 100));
input.addEventListener('input', debounce(handleInput, 300));
七、WebView 与原生交互优化
7.1 减少通信次数
问题:每次 JSBridge 调用都有开销。
方案:批量传输数据。
// 差:多次调用
const userInfo = await bridge.call('getUserInfo');
const deviceId = await bridge.call('getDeviceId');
const appVersion = await bridge.call('getAppVersion');
// 好:一次调用
const data = await bridge.call('getInitData');
// data = { userInfo, deviceId, appVersion }
7.2 使用消息队列
// 消息队列
const messageQueue = [];
function flushQueue() {
if (messageQueue.length === 0) return;
const messages = [...messageQueue];
messageQueue.length = 0;
bridge.call('batchMessages', messages);
}
function enqueueMessage(msg) {
messageQueue.push(msg);
requestAnimationFrame(flushQueue);
}
八、内存优化
8.1 Android WebView 内存泄漏
问题:WebView 持有 Activity 引用导致内存泄漏。
方案:使用独立进程 + 动态销毁。
// AndroidManifest.xml
<activity
android:name=".WebViewActivity"
android:process=":webview" />
// Activity 销毁时
@Override
protected void onDestroy() {
if (webView != null) {
webView.loadDataWithBaseURL(null, "", "text/html", "utf-8", null);
webView.clearHistory();
webView.destroy();
webView = null;
}
super.onDestroy();
}
8.2 iOS WKWebView 内存管理
deinit {
webView.stopLoading()
webView.navigationDelegate = nil
webView.uiDelegate = nil
}
8.3 JS 内存管理
// 及时清理定时器
const timer = setInterval(() => {}, 1000);
clearInterval(timer);
// 及时清理事件监听
const handler = () => {};
element.addEventListener('click', handler);
element.removeEventListener('click', handler);
// 避免闭包内存泄漏
function createHandler() {
const largeData = new Array(10000);
return () => {
// 使用 largeData
};
}
// 用完后置空
let handler = createHandler();
handler();
handler = null;
九、实战案例
9.1 优化前
| 指标 | 数值 |
|---|---|
| 白屏时间 | 800ms |
| 首屏时间 | 1500ms |
| 完全加载 | 3500ms |
9.2 优化措施
| 措施 | 效果 |
|---|---|
| WebView 预加载 | -200ms |
| DNS 预解析 + 预连接 | -150ms |
| 资源预加载 | -200ms |
| 关键 CSS 内联 | -100ms |
| 图片懒加载 | -300ms |
| JS 代码分割 | -150ms |
| 缓存优化 | -100ms |
9.3 优化后
| 指标 | 数值 | 提升 |
|---|---|---|
| 白屏时间 | 300ms | -62.5% |
| 首屏时间 | 700ms | -53.3% |
| 完全加载 | 1800ms | -48.6% |
十、总结
WebView 性能优化是一个系统工程,需要从多个维度入手:
- 初始化优化:WebView 预加载、复用池
- 网络优化:DNS 预解析、预连接、预加载、缓存策略
- 渲染优化:阻塞资源处理、关键路径优化、图片懒加载
- JS 优化:代码分割、长任务处理、事件优化
- 交互优化:减少通信次数、消息队列
- 内存优化:防止内存泄漏、及时清理资源
记住:性能优化没有银弹,要根据实际场景选择合适的方案。先用工具测量,找到瓶颈,再对症下药。
#WebView性能优化 #移动端H5 #前端优化 #性能调优
沪深两市成交额突破1.5万亿元
vxe-table 实现撤销与重做:单元格编辑后支持 Ctrl+Z 回退
vxe-table 如何实现撤销与重做,修改单元格编辑后支持回退 当公司业务需求需要实现编辑表格支持撤销功能,这时就可以通过设置 keyboard-config.isUndoRedo 启用。当编辑的单元格之后,按 Ctrl + Z | Command + Z 键 ,可以撤销会编辑之前的状态
需求场景
在表格编辑场景中,用户经常需要撤销误操作或重做已撤销的内容。vxe-table 提供了内置的撤销与重做功能,只需启用 keyboardConfig.isUndoRedo 即可获得类似 Excel 的编辑历史记录能力。
核心配置
在 vxe-grid 或 vxe-table 组件中,通过 keyboardConfig 开启撤销与重做:
keyboardConfig: {
isUndoRedo: true, // 启用撤销与重做功能(Ctrl+Z / Ctrl+Y)
// ... 其他键盘配置
}
示例代码
<template>
<div>
<vxe-grid v-bind="gridOptions"></vxe-grid>
</div>
</template>
<script setup>
import { reactive } from 'vue'
const sexEditRender = reactive({
name: 'VxeSelect',
options: []
})
const gridOptions = reactive({
border: true,
height: 500,
showOverflow: true,
keepSource: true,
columnConfig: {
resizable: true
},
mouseConfig: {
area: true // 是否开启区域选取
},
areaConfig: {
multiple: true // 是否启用多区域选取功能
},
editConfig: {
mode: 'cell', // 单元格编辑模式
trigger: 'dblclick', // 双击单元格激活编辑状态
showStatus: true // 显示编辑状态
},
keyboardConfig: {
arrowCursorLock: true, // 方向键光标锁,开启后处于非聚焦式编辑状态,将支持在编辑状态中通过方向键切换单元格。(切换为聚焦编辑状态,可以按 F2 键或者鼠标左键点击输入框,就可以用方向键左右移动输入框的光标)
isClip: true, // 是否开启复制粘贴
isArrow: true, // 是否开启方向键功能
isShift: true, // 是否开启同时按住方向键以活动区域为起始,向指定方向扩展单元格区域
isTab: true, // 是否开启 Tab 键功能
isEnter: true, // 是否开启回车键功能
isEdit: true, // 是否开启任意键进入编辑(功能键除外)
isBack: true, // 是否开启 Backspace 键功能
isDel: true, // 是否开启删除键功能
isEsc: true, // 是否开启Esc键关闭编辑功能
isFNR: true, // 是否开启查找与替换
isMerge: true, // 是否开启单元格合并功能
isChecked: true, // 是否启用复选框/单选框按空格键选中/取消功能
isUndoRedo: true // 是否启用撤销与重做功能
},
columns: [
{ type: 'seq', fixed: 'left', width: 60 },
{ type: 'checkbox', fixed: 'left', width: 60 },
{ field: 'name', title: 'name', width: 200, editRender: { name: 'VxeInput' } },
{ field: 'role', title: 'Role', width: 200, editRender: { name: 'VxeInput' } },
{ field: 'sex', title: 'sex', width: 250, editRender: sexEditRender },
{ field: 'num1', title: 'Num1', width: 200, editRender: { name: 'VxeInput' } },
{ field: 'num2', title: 'Num2', width: 250, editRender: { name: 'VxeInput' } },
{ field: 'num3', title: 'Num3', width: 300, editRender: { name: 'VxeInput' } },
{ field: 'age', title: 'age', width: 100, editRender: { name: 'VxeInput' } },
{ field: 'address', title: 'Address', fixed: 'right', width: 300, editRender: { name: 'VxeInput' } }
],
data: [
{ id: 10001, name: 'Test1', role: 'Develop', sex: '1', num1: 12, num2: 21, num3: 78, age: 28, address: 'Shengzhen' },
{ id: 10002, name: 'Test2', role: 'Test', sex: '0', num1: 23, num2: 45, num3: 23, age: 22, address: 'Guangzhou' },
{ id: 10003, name: 'Test3', role: 'PM', sex: '1', num1: 23, num2: 12, num3: 68, age: 32, address: 'Shanghai' },
{ id: 10004, name: 'Test4', role: 'Designer', sex: '0', num1: 23, num2: 23, num3: 47, age: 24, address: 'Shanghai' },
{ id: 10005, name: 'Test5', role: 'Designer', sex: '0', num1: 32, num2: 77, num3: 65, age: 42, address: 'Guangzhou' },
{ id: 10006, name: 'Test6', role: 'Designer', sex: '1', num1: 39, num2: 66, num3: 87, age: 38, address: 'Shengzhen' },
{ id: 10007, name: 'Test7', role: 'Test', sex: '0', num1: 23, num2: 44, num3: 23, age: 24, address: 'Shengzhen' },
{ id: 10008, name: 'Test8', role: 'PM', sex: '1', num1: 23, num2: 23, num3: 87, age: 34, address: 'Shanghai' },
{ id: 10009, name: 'Test9', role: 'Designer', sex: '1', num1: 91, num2: 23, num3: 24, age: 52, address: 'Shanghai' },
{ id: 10010, name: 'Test10', role: 'Test', sex: '0', num1: 20, num2: 72, num3: 54, age: 44, address: 'Guangzhou' },
{ id: 10011, name: 'Test11', role: 'Designer', sex: '1', num1: 56, num2: 32, num3: 63, age: 52, address: 'Shanghai' },
{ id: 10012, name: 'Test12', role: 'Test', sex: '0', num1: 34, num2: 65, num3: 54, age: 47, address: 'Guangzhou' },
{ id: 10013, name: 'Test13', role: 'Test', sex: '0', num1: 39, num2: 65, num3: 435, age: 47, address: 'Guangzhou' },
{ id: 10014, name: 'Test14', role: 'Test', sex: '0', num1: 44, num2: 65, num3: 324, age: 45, address: 'Guangzhou' },
{ id: 10015, name: 'Test15', role: 'Test', sex: '0', num1: 45, num2: 56, num3: 54, age: 39, address: 'Guangzhou' },
{ id: 10016, name: 'Test16', role: 'Test', sex: '0', num1: 34, num2: 65, num3: 344, age: 44, address: 'Shanghai' },
{ id: 10017, name: 'Test17', role: 'Test', sex: '0', num1: 78, num2: 77, num3: 78, age: 48, address: 'Guangzhou' },
{ id: 10018, name: 'Test18', role: 'Test', sex: '0', num1: 32, num2: 12, num3: 45, age: 89, address: 'Shanghai' },
{ id: 10019, name: 'Test19', role: 'Test', sex: '0', num1: 42, num2: 65, num3: 8, age: 52, address: 'Guangzhou' },
{ id: 10020, name: 'Test20', role: 'Test', sex: '0', num1: 56, num2: 45, num3: 4, age: 41, address: 'Shanghai' },
{ id: 10021, name: 'Test21', role: 'Test', sex: '1', num1: 48, num2: 65, num3: 54, age: 49, address: 'Guangzhou' },
{ id: 10022, name: 'Test22', role: 'Test', sex: '0', num1: 41, num2: 65, num3: 12, age: 50, address: 'Shanghai' }
]
})
// 异步加载下拉选项
setTimeout(() => {
sexEditRender.options = [
{ label: 'Man', value: '1' },
{ label: 'Women', value: '0' }
]
}, 300)
</script>
关键配置说明
- 必须启用的基础配置
- keepSource: true:保留原始数据副本,撤销/重做依赖于此。
- editConfig.mode: 'cell':单元格编辑模式(也支持行编辑模式,但撤销/重做对单元格编辑体验更好)。
- 当 isUndoRedo: true 时,vxe-table 内部会维护一个编辑历史栈: 每次单元格编辑完成后(失焦或按回车),当前数据快照会被推入历史栈。 按 Ctrl+Z 时,从历史栈中弹出上一个状态并恢复。 按 Ctrl+Y 时,执行重做操作(需先有撤销操作)。
获取历史记录 API
// 获取表格实例
const $grid = gridRef.value
// 手动撤销
$grid.undo()
// 手动重做
$grid.redo()
vxe-table 的撤销/重做功能开箱即用,无需额外存储逻辑,极大降低了开发成本。如果你的业务需要严谨的编辑历史管理,这是一个非常实用的特性。
韩国称将确保每年从加拿大进口至多3300万桶原油
汇丰上调香港2026年经济增长预期至3.8%
vue甘特图vxe-gantt如何实现拖拽任务条时如有已关联依赖线,同时更新依赖任务的日期的方式
vue甘特图vxe-gantt如何实现拖拽任务条时如有已关联依赖线,同时更新依赖任务的日期的方式
当任务关联前置任务或后置任务依赖线时,拖拽该任务时同步更新对应的起始日期和结束日期,可以通过 task-bar-drag-config.moveSetMethod 来自定义业务逻辑
基础代码
简单实现同步移动任务
<template>
<div>
<vxe-gantt v-bind="ganttOptions"></vxe-gantt>
</div>
</template>
<script setup>
import { reactive } from 'vue'
import { VxeGanttDependencyType } from 'vxe-gantt'
import XEUtils from 'xe-utils'
const ganttOptions = reactive({
border: true,
height: 600,
rowConfig: {
keyField: 'id' // 行主键
},
taskBarConfig: {
showProgress: true, // 是否显示进度条
showContent: true, // 是否在任务条显示内容
moveable: true, // 是否允许拖拽任务移动日期
resizable: true, // 是否允许拖拽任务调整日期
linkCreatable: true, // 是否允许自定义创建依赖线
barStyle: {
round: true, // 圆角
bgColor: '#fca60b', // 任务条的背景颜色
completedBgColor: '#65c16f' // 已完成部分任务条的背景颜色
}
},
taskLinkConfig: {
isHover: true, // 当鼠标移到依赖线时,是否要高亮当前依赖线
isCurrent: true, // 当鼠标点击依赖线时,是否要高亮当前依赖线
isDblclickToRemove: true // 是否允许双击依赖线删除
},
taskViewConfig: {
tableStyle: {
width: 480 // 表格宽度
}
},
taskBarMoveConfig: {
// 自定义拖拽结束时任务日期被赋值的方法
async moveSetMethod ({ row, startValue, endValue, offsetSize, linkInfo }) {
const { toRows, fromRows } = linkInfo
row.start = startValue
row.end = endValue
// 实现拖拽任务后,关联任务自动同步更新日期
fromRows.forEach(row => {
row.start = XEUtils.toDateString(XEUtils.getWhatDay(row.start, offsetSize), 'yyyy-MM-dd')
row.end = XEUtils.toDateString(XEUtils.getWhatDay(row.end, offsetSize), 'yyyy-MM-dd')
})
toRows.forEach(row => {
row.start = XEUtils.toDateString(XEUtils.getWhatDay(row.start, offsetSize), 'yyyy-MM-dd')
row.end = XEUtils.toDateString(XEUtils.getWhatDay(row.end, offsetSize), 'yyyy-MM-dd')
})
}
},
links: [
{ from: 10001, to: 10002, type: VxeGanttDependencyType.FinishToFinish },
{ from: 10004, to: 10005, type: VxeGanttDependencyType.StartToStart },
{ from: 10005, to: 10006, type: VxeGanttDependencyType.FinishToStart },
{ from: 10013, to: 10012, type: VxeGanttDependencyType.StartToFinish }
],
columns: [
{ type: 'seq', width: 70 },
{ field: 'title', title: '任务名称' },
{ field: 'start', title: '开始时间', width: 100 },
{ field: 'end', title: '结束时间', width: 100 },
{ field: 'progress', title: '进度(%)', width: 80 }
],
data: [
{ id: 10001, title: '任务1', start: '2024-03-01', end: '2024-03-04', progress: 3 },
{ id: 10002, title: '任务2', start: '2024-03-03', end: '2024-03-08', progress: 10 },
{ id: 10003, title: '任务3', start: '2024-03-03', end: '2024-03-11', progress: 90 },
{ id: 10004, title: '任务4', start: '2024-03-05', end: '2024-03-11', progress: 15 },
{ id: 10005, title: '任务5', start: '2024-03-08', end: '2024-03-15', progress: 100 },
{ id: 10006, title: '任务6', start: '2024-03-10', end: '2024-03-21', progress: 5 },
{ id: 10007, title: '任务7', start: '2024-03-15', end: '2024-03-24', progress: 70 },
{ id: 10008, title: '任务8', start: '2024-03-05', end: '2024-03-15', progress: 50 },
{ id: 10009, title: '任务9', start: '2024-03-19', end: '2024-03-20', progress: 5 },
{ id: 10010, title: '任务10', start: '2024-03-12', end: '2024-03-20', progress: 10 },
{ id: 10011, title: '任务11', start: '2024-03-01', end: '2024-03-08', progress: 90 },
{ id: 10012, title: '任务12', start: '2024-03-03', end: '2024-03-06', progress: 60 },
{ id: 10013, title: '任务13', start: '2024-03-02', end: '2024-03-05', progress: 50 },
{ id: 10014, title: '任务14', start: '2024-03-04', end: '2024-03-15', progress: 0 },
{ id: 10015, title: '任务15', start: '2024-03-01', end: '2024-03-05', progress: 30 }
]
})
</script>
还可以实现更复杂的逻辑
<template>
<div>
<vxe-gantt ref="ganttRef" v-bind="ganttOptions" @task-bar-drag-end="onTaskDragEnd"></vxe-gantt>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { VxeGanttDependencyType } from 'vxe-gantt'
import XEUtils from 'xe-utils'
const ganttRef = ref(null)
const ganttOptions = reactive({
// ...(保持原有配置)
taskBarMoveConfig: {
// 自定义拖拽结束时任务日期被赋值的方法
async moveSetMethod({ row, startValue, endValue, offsetSize, linkInfo }) {
const { toRows, fromRows, toLinks, fromLinks } = linkInfo
// 1. 获取变更前的日期(用于错误恢复)
// const oldStart = row.start
// const oldEnd = row.end
// 2. 更新当前拖拽任务的日期
row.start = startValue
row.end = endValue
// 4. 统一更新依赖任务的方法
// 根据依赖类型计算出新的日期,而不是简单粗暴地整体偏移
const updateDependentTasks = (tasks, currentRow, linkType, offsetDays) => {
tasks.forEach(task => {
const currentStart = new Date(currentRow.start)
const currentEnd = new Date(currentRow.end)
let newStart, newEnd
// 根据不同的依赖类型,更新关联任务的日期
switch (linkType) {
case VxeGanttDependencyType.FinishToStart:
// 前置任务结束后,后置任务才能开始
newStart = XEUtils.toDateString(
XEUtils.getWhatDay(currentRow.end, offsetDays),
'yyyy-MM-dd'
)
newEnd = XEUtils.toDateString(
XEUtils.getWhatDay(task.end, offsetDays),
'yyyy-MM-dd'
)
break
case VxeGanttDependencyType.StartToStart:
// 两个任务同时开始
newStart = XEUtils.toDateString(
XEUtils.getWhatDay(currentRow.start, offsetDays),
'yyyy-MM-dd'
)
newEnd = XEUtils.toDateString(
XEUtils.getWhatDay(task.end, offsetDays),
'yyyy-MM-dd'
)
break
case VxeGanttDependencyType.FinishToFinish:
// 两个任务同时完成
newStart = XEUtils.toDateString(
XEUtils.getWhatDay(task.start, offsetDays),
'yyyy-MM-dd'
)
newEnd = XEUtils.toDateString(
XEUtils.getWhatDay(currentRow.end, offsetDays),
'yyyy-MM-dd'
)
break
case VxeGanttDependencyType.StartToFinish:
// 当前任务完成才能开始
newStart = XEUtils.toDateString(
XEUtils.getWhatDay(currentRow.start, offsetDays),
'yyyy-MM-dd'
)
newEnd = XEUtils.toDateString(
XEUtils.getWhatDay(task.end, offsetDays),
'yyyy-MM-dd'
)
break
default:
// 默认整体偏移
newStart = XEUtils.toDateString(
XEUtils.getWhatDay(task.start, offsetDays),
'yyyy-MM-dd'
)
newEnd = XEUtils.toDateString(
XEUtils.getWhatDay(task.end, offsetDays),
'yyyy-MM-dd'
)
}
// 更新任务数据
task.start = newStart || task.start
task.end = newEnd || task.end
})
}
// 6. 更新依赖任务(前置和后置)
// 使用依赖关系信息来精确更新
if (fromRows.length && fromLinks.length) {
updateDependentTasks([fromRows[0]], row, fromLinks[0], offsetSize)
}
if (toRows.length && toLinks.length) {
updateDependentTasks([toRows[0]], row, toLinks[0], offsetSize)
}
}
},
// ...(保持原有数据配置)
})
// 监听拖拽事件,执行其他业务逻辑(如保存到后端)
const onTaskDragEnd = ({ row, startValue, endValue }) => {
// 可以在这里保存更新后的任务数据,或者执行其他业务逻辑
console.log('任务拖拽完成', { row, startValue, endValue })
// 可选:触发数据更新到后端
// updateTaskToBackend(row)
}
</script>
依赖类型说明
| 依赖类型 | 原逻辑缺陷 | 优化后逻辑 |
|---|---|---|
| FinishToStart | 前置任务结束日期推迟,后置任务应该整体推迟?❌ 应该是后置任务的开始日期=前置任务的结束日期 | 仅更新后置任务的开始日期,保持持续时间不变 |
| StartToStart | 同时开始的任务,拖拽一个不应该导致另一个结束日期整体偏移 | 保持两个任务的结束日期相对不变,仅调整开始日期 |
| FinishToFinish | 两个任务同时结束,拖拽一个不应该影响另一个的开始日期 | 保持两个任务的开始日期相对不变,仅调整结束日期 |
| StartToFinish | 前置任务开始日期影响后置任务完成日期 | 根据依赖关系的具体约束来精确更新 |
腾讯Hy3 preview上线两周Token调用增10倍
React 中的语音与摄像头输入:语音识别、媒体设备与权限
语音和摄像头是把一个静态 Web 应用变得鲜活的两种感官。一个能对它说话的搜索栏。一个实时把你说的话转成文字的笔记应用。一个让你选择用哪个摄像头的会议工具。一个按住按键就能说话的对讲机。这些早已不再罕见——浏览器有这些 API 已经好多年了——但每一个都被一连串权限弹窗、厂商前缀和生命周期的怪癖挡在前面,让人很难干净地把它们集成进 React 组件。
本文将带你走过四种用于语音和摄像头输入的浏览器能力:带中间结果的实时语音识别、枚举用户的摄像头和麦克风、在权限被撤销时仍能存活的权限查询,以及把 Shift 键当作按住说话修饰符使用。和往常一样,我们会先用手动实现来开局,让你看清底层的管道,然后再换成 ReactUse 里专门的 Hook。最后,我们会把四个 Hook 组合成一个完整的语音搜索组件,包含设备选择器、权限闸门,以及按住说话的录音交互。
1. 实时语音识别
手动实现
Web Speech API 是一个比较老的浏览器 API,但从未真正被标准化——Chrome 把它实现成 webkitSpeechRecognition,而无前缀的 SpeechRecognition 在大多数引擎里仍然缺失。最小可用的 React 包装看起来像这样:
function ManualSpeechRecognition() {
const [transcript, setTranscript] = useState("");
const [listening, setListening] = useState(false);
const recognitionRef = useRef<any>(null);
useEffect(() => {
const SR =
(window as any).SpeechRecognition ||
(window as any).webkitSpeechRecognition;
if (!SR) return;
const recognition = new SR();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = "zh-CN";
recognition.onresult = (event: any) => {
const result = event.results[event.resultIndex];
setTranscript(result[0].transcript);
};
recognition.onend = () => setListening(false);
recognitionRef.current = recognition;
return () => recognition.abort();
}, []);
const start = () => {
recognitionRef.current?.start();
setListening(true);
};
const stop = () => {
recognitionRef.current?.stop();
setListening(false);
};
return (
<div>
<button onClick={listening ? stop : start}>
{listening ? "停止" : "开始"}识别
</button>
<p>{transcript}</p>
</div>
);
}
这个能跑,但忽略了那些粗糙的边角。它没有区分 isFinal,所以 UI 无法判断用户什么时候停顿了("中间结果"和"最终结果"的区别正是让语音 UI 显得有响应的关键)。它没有错误处理——如果用户拒绝了麦克风权限或网络断了,转录就会默默地永远不更新。它没有语言协商。而且 SR 的类型很糟糕,因为 TypeScript 没有为 webkitSpeechRecognition 提供类型。
ReactUse 的方式:useSpeechRecognition
useSpeechRecognition 返回一个干净的对象,提供恰当的原语:
import { useSpeechRecognition } from "@reactuses/core";
function VoiceNote() {
const { isSupported, isListening, isFinal, result, error, start, stop } =
useSpeechRecognition({
lang: "zh-CN",
interimResults: true,
continuous: true,
});
if (!isSupported) {
return <p>当前浏览器不支持语音识别。</p>;
}
return (
<div>
<button onClick={isListening ? stop : start}>
{isListening ? "停止" : "开始"}口述
</button>
<p
style={{
fontStyle: isFinal ? "normal" : "italic",
color: isFinal ? "#0f172a" : "#64748b",
}}
>
{result || "说点什么..."}
</p>
{error && <p style={{ color: "#ef4444" }}>错误:{error.error}</p>}
</div>
);
}
你不用写就能拿到的好处:
-
isFinal—— Hook 会跟踪当前result是语音引擎的临时猜测(在示例里是斜体)还是已经锁定的转录。这是相比朴素版本最大的 UX 提升。 -
error对象 —— 当权限被拒、网络断开或引擎失败时,你能拿到一个带类型的错误对象,可以展示给用户而不是默默地卡住。 -
热配置。
start({ lang: "fr-FR" })让你能在会话中途切换语言,无需重建识别器。 -
卸载时清理。Hook 会自动调用
abort(),所以离开页面永远不会让麦克风一直开着。
最有威力的模式是把识别结果绑到一个搜索输入框上,让用户在说话时实时输入查询。因为 Hook 会在每个中间结果到来时重渲,你可以直接用语音输入来驱动一个实时搜索查询,让用户在说话时就能看到结果。
2. 枚举摄像头和麦克风
手动实现
列出用户的音频和视频设备需要 navigator.mediaDevices.enumerateDevices()。有个陷阱:在用户对某个设备授予权限之前,返回的标签是空的——你只能拿到一组 deviceId,但拿不到像 "FaceTime HD Camera" 这样的 label。要拿到标签,你必须先调用 getUserMedia 触发权限弹窗,然后再枚举一次。
function ManualDeviceList() {
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
useEffect(() => {
let mounted = true;
const refresh = async () => {
try {
// 触发权限以填充标签
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
});
stream.getTracks().forEach((t) => t.stop());
const list = await navigator.mediaDevices.enumerateDevices();
if (mounted) setDevices(list);
} catch (e) {
console.error(e);
}
};
refresh();
navigator.mediaDevices.addEventListener("devicechange", refresh);
return () => {
mounted = false;
navigator.mediaDevices.removeEventListener("devicechange", refresh);
};
}, []);
return (
<ul>
{devices.map((d) => (
<li key={d.deviceId}>
{d.kind}: {d.label || "(标签隐藏)"}
</li>
))}
</ul>
);
}
形状是对的,但你每次都要写权限触发的舞蹈、临时流的清理,以及 device-change 监听器。
ReactUse 的方式:useMediaDevices
useMediaDevices 把整套流程打包了起来:
import { useMediaDevices } from "@reactuses/core";
function CameraPicker({
selected,
onSelect,
}: {
selected: string;
onSelect: (id: string) => void;
}) {
const [{ devices }, ensurePermissions] = useMediaDevices({
requestPermissions: true,
constraints: { video: true, audio: false },
});
const cameras = devices.filter((d) => d.kind === "videoinput");
return (
<div>
<button onClick={() => ensurePermissions()}>刷新设备</button>
<select
value={selected}
onChange={(e) => onSelect(e.target.value)}
style={{ marginLeft: 8 }}
>
{cameras.map((cam) => (
<option key={cam.deviceId} value={cam.deviceId}>
{cam.label || `摄像头 ${cam.deviceId.slice(0, 6)}`}
</option>
))}
</select>
</div>
);
}
Hook 处理了三件你本来要自己写的事:
-
权限协商。传
requestPermissions: true,Hook 会在挂载时根据你指定的 constraints 触发getUserMedia,然后立即停止临时音视轨道,让摄像头指示灯熄灭。 -
实时设备列表。Hook 监听
devicechange并自动重新枚举——如果用户插入新麦克风或拔掉耳机,列表会自动更新,不需要额外代码。 -
手动刷新。返回的
ensurePermissions让你随时能再触发一次提示,对于"用户拒绝了一次后想再试一次"的按钮非常有用。
constraints 参数会直接转发给 getUserMedia,所以你只需要视频时(跳过那种"想要麦克风权限吗"的别扭弹窗)就只请求视频。
3. 正确地查询权限
手动实现
要在不触发弹窗的情况下检查用户是否已经授予(或拒绝)麦克风或摄像头权限,需要 Permissions API。它支持得很好但很啰嗦:
function ManualMicPermission() {
const [state, setState] = useState<PermissionState | "unknown">("unknown");
useEffect(() => {
let mounted = true;
let status: PermissionStatus | null = null;
(async () => {
try {
status = await navigator.permissions.query({
name: "microphone" as PermissionName,
});
if (mounted) setState(status.state);
status.onchange = () => mounted && status && setState(status.state);
} catch {
// 此名称的 Permissions API 不可用
}
})();
return () => {
mounted = false;
if (status) status.onchange = null;
};
}, []);
return <p>麦克风权限:{state}</p>;
}
三件值得注意的事。第一,API 通过 onchange 提供回调,对 React 不友好。第二,你必须同时特性检测 Permissions API 本身和具体的 name(某些浏览器不支持 "microphone")。第三,change 监听器必须显式清理,而不能通过 effect 返回值。
ReactUse 的方式:usePermission
usePermission 把整段舞蹈减到一次调用:
import { usePermission } from "@reactuses/core";
function MicStatusBadge() {
const state = usePermission("microphone");
const color =
state === "granted"
? "#10b981"
: state === "denied"
? "#ef4444"
: "#f59e0b";
return (
<span style={{ color, fontWeight: 600 }}>
麦克风:{state || "未知"}
</span>
);
}
state 是一个 React 原生字符串,每当底层权限状态变化时就会更新——包括外部变化,比如用户进入浏览器设置撤销了权限,你的组件 state 就会翻转到 "denied",不需要你做任何操作。
你可以传一个像 "microphone" 或 "camera" 这样的字符串,也可以传一个完整的 PermissionDescriptor 对象,用于像 "push" 这样需要额外字段的权限。形状和 navigator.permissions.query 完全一致,只是变成了一个 Hook。
4. 用 useKeyModifier 实现按住说话
手动实现
按住说话按钮比看起来要难。你想检测用户是否在按住某个键(比如 Space 或 Shift),按住时开始录音,松开时立即停止。你还得处理这种情况:用户按住按键、把焦点切到另一个窗口、在你的页面隐藏时松开按键、然后再回来——否则录音器会一直卡在录制状态。
function ManualPushToTalk() {
const [pressed, setPressed] = useState(false);
useEffect(() => {
const onDown = (e: KeyboardEvent) => {
if (e.code === "Space") setPressed(true);
};
const onUp = (e: KeyboardEvent) => {
if (e.code === "Space") setPressed(false);
};
const onBlur = () => setPressed(false);
window.addEventListener("keydown", onDown);
window.addEventListener("keyup", onUp);
window.addEventListener("blur", onBlur);
return () => {
window.removeEventListener("keydown", onDown);
window.removeEventListener("keyup", onUp);
window.removeEventListener("blur", onBlur);
};
}, []);
return <p>{pressed ? "正在录制..." : "按住空格说话"}</p>;
}
这个差不多能跑。bug 是:如果 Space 键在按住时自动重复(大多数操作系统都会这样),你会先收到一个 keydown,然后又一个 keydown,最后才是 keyup。这个你处理了。但如果用户按的是 Shift 并把它当成与其他键的组合修饰符使用,你的手动跟踪就不知道了。
ReactUse 的方式:useKeyModifier
useKeyModifier 把 OS 级别的修饰键状态(和你从 event.getModifierState 拿到的值一样)暴露为 React state:
import { useKeyModifier } from "@reactuses/core";
function ShiftToRecord({ onTalkStart, onTalkEnd }: {
onTalkStart: () => void;
onTalkEnd: () => void;
}) {
const shift = useKeyModifier("Shift");
useEffect(() => {
if (shift) onTalkStart();
else onTalkEnd();
}, [shift, onTalkStart, onTalkEnd]);
return (
<div
style={{
padding: 16,
background: shift ? "#fef3c7" : "#f1f5f9",
borderRadius: 8,
textAlign: "center",
}}
>
{shift ? "正在录制(松开 Shift 停止)" : "按住 Shift 说话"}
</div>
);
}
相比 keydown/keyup 版本的好处:
-
OS 感知。Hook 读取
getModifierState,从 OS 查询实际的修饰键状态。它能正确应对自动重复、焦点丢失和奇怪的组合键。 -
支持任何修饰键。传
"Control"、"Alt"、"Meta"、"CapsLock"、"NumLock"——浏览器追踪的任何修饰键都行。 -
初始值。如果你想让 React state 初始为
true,就配置initial: true(不常见,但调试时有用)。
全部组合:带设备选择器的语音搜索
我们把四个 Hook 组合成一个语音驱动的搜索组件。用户可以选择用哪个麦克风、看到一个权限徽章、按住 Shift 开始口述、并在说话时实时看到转录更新。当他们松开 Shift 时,最终转录就成了搜索查询。
import { useEffect, useState } from "react";
import {
useSpeechRecognition,
useMediaDevices,
usePermission,
useKeyModifier,
} from "@reactuses/core";
function VoiceSearch() {
const [selectedMic, setSelectedMic] = useState<string>("");
const [query, setQuery] = useState("");
const micPermission = usePermission("microphone");
const [{ devices }, requestDevices] = useMediaDevices({
requestPermissions: false,
constraints: { audio: true, video: false },
});
const microphones = devices.filter((d) => d.kind === "audioinput");
const {
isSupported,
isListening,
isFinal,
result,
error,
start,
stop,
} = useSpeechRecognition({
lang: "zh-CN",
interimResults: true,
continuous: false,
});
const shiftDown = useKeyModifier("Shift");
// 按住说话:按下 Shift 时开始,松开时停止
useEffect(() => {
if (!isSupported || micPermission !== "granted") return;
if (shiftDown) {
start();
} else if (isListening) {
stop();
}
}, [shiftDown, isSupported, micPermission, start, stop, isListening]);
// 当识别最终化时,把结果提交到查询
useEffect(() => {
if (isFinal && result) {
setQuery(result);
}
}, [isFinal, result]);
const permissionColor =
micPermission === "granted"
? "#10b981"
: micPermission === "denied"
? "#ef4444"
: "#f59e0b";
return (
<div
style={{
maxWidth: 640,
padding: 24,
background: "#ffffff",
borderRadius: 16,
boxShadow: "0 4px 24px rgba(15, 23, 42, 0.06)",
fontFamily: "system-ui, sans-serif",
}}
>
<header
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
}}
>
<h2 style={{ margin: 0, fontSize: 18 }}>语音搜索</h2>
<span style={{ color: permissionColor, fontSize: 13, fontWeight: 600 }}>
● 麦克风:{micPermission || "未知"}
</span>
</header>
{!isSupported && (
<p style={{ color: "#64748b" }}>
当前浏览器不支持语音识别。请试试 Chrome。
</p>
)}
{isSupported && micPermission !== "granted" && (
<button
onClick={requestDevices}
style={{
width: "100%",
padding: 12,
background: "#3b82f6",
color: "white",
border: "none",
borderRadius: 8,
cursor: "pointer",
}}
>
授权麦克风访问
</button>
)}
{isSupported && micPermission === "granted" && (
<>
<div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
<select
value={selectedMic}
onChange={(e) => setSelectedMic(e.target.value)}
style={{
flex: 1,
padding: 8,
borderRadius: 6,
border: "1px solid #cbd5e1",
}}
>
<option value="">默认麦克风</option>
{microphones.map((mic) => (
<option key={mic.deviceId} value={mic.deviceId}>
{mic.label || `麦克风 ${mic.deviceId.slice(0, 6)}`}
</option>
))}
</select>
</div>
<div
style={{
padding: 16,
background: shiftDown ? "#dcfce7" : "#f8fafc",
borderRadius: 8,
border: shiftDown
? "2px solid #10b981"
: "2px dashed #cbd5e1",
textAlign: "center",
transition: "all 120ms ease",
}}
>
<p style={{ margin: 0, fontWeight: 600, fontSize: 13 }}>
{shiftDown ? "正在监听..." : "按住 Shift 进行口述"}
</p>
{result && (
<p
style={{
margin: "8px 0 0",
fontStyle: isFinal ? "normal" : "italic",
color: isFinal ? "#0f172a" : "#64748b",
}}
>
{result}
</p>
)}
</div>
{error && (
<p style={{ color: "#ef4444", fontSize: 13, marginTop: 8 }}>
识别错误:{error.error}
</p>
)}
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索查询..."
style={{
width: "100%",
marginTop: 12,
padding: 10,
borderRadius: 6,
border: "1px solid #cbd5e1",
fontSize: 16,
}}
/>
</>
)}
</div>
);
}
四个 Hook,四个相互正交的关注点:
-
usePermission驱动 header 中的徽章,并把 UI 的其余部分挡在用户实际决策之后。因为它是响应式的,如果用户在浏览器设置里撤销了麦克风权限,徽章会自动更新,输入框会自动消失。 -
useMediaDevices填充麦克风选择器,除非用户点击"授权",否则不会强制弹出权限对话框。 -
useSpeechRecognition完成实际的转录,区分中间结果和最终结果,并以带类型的方式暴露引擎错误。 -
useKeyModifier把 Shift 键变成按住说话的触发器,能正确应对焦点丢失、OS 自动重复和奇怪的组合键。
整个组件大概 130 行,绝大多数都是标签。浏览器 API 那些历来最难做对的部分,每个关注点只占一行 import。
关于测试的一点说明
语音和摄像头功能出了名地难测试,因为它们依赖的浏览器 API 需要真实的人手势和物理硬件。这些 Hook 都暴露了 isSupported 标志,所以你的测试环境(jsdom、Vitest、用 mock navigator 的 Storybook)可以在底层 API 缺失时干净地分支并渲染 fallback 状态。如果你在做严肃的语音 UI,请专门划出一小层在 headless Chrome 里用假媒体流跑的集成测试——那才是抓真正 bug 的唯一方式。
安装
npm i @reactuses/core
相关 Hook
-
useSpeechRecognition—— 实时语音转文字,跟踪中间和最终结果 -
useMediaDevices—— 枚举摄像头和麦克风,处理权限 -
usePermission—— 响应式地查询任意权限的 Permissions API -
useKeyModifier—— 跟踪 OS 级别的修饰键状态(Shift、Control 等) -
useSupported—— 响应式地检查浏览器 API 是否可用 -
useEventListener—— 声明式地附加事件监听器,可用于自定义语音流程 -
useObjectUrl—— 为录制的音频 blob 创建临时 URL 以预览
ReactUse 提供了 100+ 个 React Hook。全部探索 →
千问电脑版上线语音输入法:打工人终于可以用嘴干活了

Vibe Coding 火了之后,越来越多的人选择对着屏幕口述,而不是敲键盘,不少网友甚至为此整出 AI 语音键盘。
今年 3 月,Anthropic 也给 Claude Code 加了语音模式,在终端输入 /voice,按住空格说话,松手执行。很难想象,连「写代码」这种最依赖键盘输入的场景,都开始支持语音了。
既然写代码的人都开始用嘴干活了,那我一个每天写文章、写方案、做 PPT、整表格的打工人,还在一个字一个字敲键盘,显然也不够高效,尤其是查数据要切三个页面,做个汇报 PPT 要从找模板开始花三小时,整理会议纪要边听边记还漏一半。
究其原因,不是每个人的口头表达都那么好。哪怕也有一些 AI 输入法能解决,比如我们之前介绍过的 Typeless,每年光订阅费每年就得花 1000 块。
在真正干活的电脑端,至今没有和深度 AI 办公能力打通的语音入口。刚好,千问电脑版/网页版最近也上线了千问语音输入法,据说奔着「用嘴干活」而来。而且千问电脑版还是全免费——不只是语音免费,它内置的所有 AI 办公能力,全部敞开用。

别被输入法这个名字骗了
一开始,看到千问语音输入法这个名字,我下意识以为这就是一个识别准确率更好的 AI 输入法,结果我发现完全不是一回事。
千问语音输入法上手几乎没有门槛。两个快捷键搞定一切,按住是语音输入,双击是让 AI 干活。Win 是右 Alt,Mac 是右 Command,你可以根据使用习惯来设置唤起的快捷键。

你在 Word 里写文档也好,浏览器里看资料也好,钉钉里回消息也好,快捷键一按,语音入口浮出来。不用切到千问客户端,不用打开额外窗口。想问就问,想说就说。
千问语音输入法主要就两种使用姿势:按住开始语音输入,想到什么直接说就行,千问帮你自动去口水话、纠正口误、生成结构化表达。双击唤起语音指令,这时候你是在给 AI 派任务,比如查个东西、帮你回消息、生成文档。
打从一开始,它就不只是打算只做一个「帮你打字更快」的输入法。你的嘴负责下达指令,它是一个中枢接口,负责听懂、翻译、调度,让 AI 把活儿干完交给你。
说话就是比打字好使
2026 年,我对一款语音输入法的要求,已经远远不止是识别准确率。「听得清」的逐字听写都是基操,更重要的是理解我想表达什么,再帮我组织好。
比如口述一段想法,它能保留我的意图,帮同事把废话全部过滤掉,口误也顺手修正,吐出来出来的是干净、精炼、可以直接发出去的文字。
比如碰到方案延期这种事,也可以交给千问整理成一段清晰的书面表达,而我只需要直接按住快捷键,随口反馈给千问:
关于这个项目的延期,我… 啊不对,我想说的是关于这个方案的调整,其实原定计划是本周五交付,但是… 呃… 因为客户那边临时加了三个需求点,我们评估了一下大概需要多两天,所以… 不对,我的最终意思是:方案交付时间从本周五调整至下周三,原因是客户新增三个功能点,需要补充技术评估,我们承诺下周三前一定提交初版方案。

松手后结果就出来了,可以看到它自动删除所有「啊不对」「呃」「但是」等语气词,把我表达的核心清晰整理了出来;对比常规的语音输入,只能逐字记录,还要自己手动编辑,千问语音输入法基本无需手动调整,就能直接发出去。
在一些更专业和复杂的项目沟通中,千问语音输入法就更加实用了。
比如下面这个沟通需求,注意看,我长按说了一大堆话后,最后还补了一句:将关于数据部分提前。
这次产品改版的核心目标是提升新用户的留存率。我们在 onboarding 流程里增加了三个引导步骤,把原来的五步走改成了三步走,还在每个节点加了进度提示。另外,我们发现很多用户在第二步就流失了,所以把第二步的表单从 8 个字段缩减到 3 个必填字段。数据方面,改版后一周的留存率从 35% 提升到了 48%,次日留存提升了 12 个百分点。不过也有一个风险,就是表单精简后收集的用户信息变少了,可能会影响后续精准推荐的效果,这个需要持续观察。最后是团队层面的配合,设计部在两周内出了两版方案,开发部用了三天完成上线,整体节奏还是很快的。嗯把数据那段放到最前面,然后分段给我
这里结果对比就更明显了,只有千问听懂了「把数据那段放前面「」的指令,自动重排段落,我用嘴就完成了原本需要鼠标+键盘的操作。

▲ 常规语音输入结果

▲ 千问语音输入法结果
体验过程我还发现了一个让我惊喜的细节,千问语音输入法对于中英文夹杂的口述内容,识别特别到位。
这个函数的主要作用是处理用户登录时的 token 验证,首先会调用 validateToken 方法检查 token 是否过期,如果 expired 的话就返回 401,然后如果是 valid 的话,再调用 getUserInfo 接口去拉取用户信息,最后把 userId 和 role 写进 session 里面。注意一下,这里有一个 edge case,就是当 token 是 refresh token 的时候,要走另外一条逻辑分支。
千问不只把所有的英文术语都识别对了,而且还自动根据我的话分点输出,一目了然。

▲ 千问语音输入法结果
我还想分享一个对于内容创作者特别有帮助的用法,APPSO 每天早上都要开选题会,大家会有很多碎片想法,一个热点现象、一个行业观察、一个趋势判断……
之前有些想法是散装的,不成体系,现在我可以直接在会上按住唤出千问语音输入法,让它将这些想法整理成大纲。比如这一大段我在会上对编辑选题的反馈意见,如下图所示:

▲ 千问语音输入法结果
松开手后,一段详细的选题大纲就出来了,编辑能稳稳接住我的反馈,稍微扩充就能写出一篇深度分析稿件。最后的成文也附上给大家看看:苹果悄悄砍掉丐版 Mac mini,人人都要交「AI 税」的时代来了 。
单就语音输入这个维度,千问给我最大感受是,真就说多快多乱都没关系,反正输出的质量 AI 会兜底。
万物皆可 Vibe,一句话的事
语音输入只是第一步,千问语音输入法更大的价值是还能帮你干活。
上面提到了整理选题大纲,然后我就需要沉浸式写作,但每次要查个数据和报告,都得切到其他网页和应用。这里千问语音输入法就很自然地出现了——它支持在任意软件、桌面全局唤起,不用切换窗口,动动嘴就能直接查。
比如我在写一篇关于 OpenAI 的文章时,刚好有一段要引用最近的融资金额和投资方。我双击唤起语音指令,说一句:「帮我找一下 OpenAI 最新融资背景。」

思考一两秒,千问小窗就直接弹出把详细结果发我了,我看着引用继续写,心流就不会被打断。
假期刚回来,一大堆工作等着推进,我需要整理一个清晰的周报,但又没时间慢慢敲字,于是双击并随口将把需求说了出来,里面夹杂着带着大量口头表达:
诶那个,我汇报下这周进度哈……A 项目目前跟进到第三阶段了,中间遇到了供应商交付延迟,大概迟了三天,后来通过加班把进度赶回来了……B 项目还在需求评审,产品那边原型图有点模糊,约了下周一早上十点对齐……下周还要申请两台测试服务器……你帮我整理为周报 word 文档,语气专业一点,条理清楚。

此外,千问语音输入法还有一个更有意思的功能——帮你回消息。
我每天往往需要在微信、钉钉、飞书等各种项目群里穿梭,回复各种消息。非常消耗精力,这时候我就能让千问让我的「嘴替」了。
比如假期还没过完就被同事催交文章,我就双击让它帮我来一段高情商回复。

▲ 我无需给它介绍背景,它就能根据屏幕内容补充上下文,给我一个「聪明」的回复
而在一些面对客户或者更正式的场合里,我也可以双击让它给我拟一个得体的回复。

这是因为千问语音输入法支持了「场景感知」。它自动识别你当前在什么应用里,看到你屏幕上的内容,据此调整输出的风格。你不用告诉它更多背景,它自己就能看懂。
下周要出差,我直接双击,在微信让它帮我根据聊天信息,整理成一个出行指南便签。

最后给我的这份出行指南,除了航班信息,还贴心地给我整理了待办事项,并根据当地天气和交通情况给了我一些具体建议,这对于常常出差的媒体人来说十分友好。
开周会的时候我还发现了一个实用的小技巧,会议开始,我双击两下唤起千问语音输入法,结束后一句:「帮我把刚才的内容整理成会议纪要。」它就自动帮我整理好了。
这很适合一些快速拉通的临时会议,不用再单独打开会议记录类的应用,随手双击马上记。

对着电脑说话,活儿 AI 自己就干完了
别误会,对着电脑喊「帮我查资料」「写个邮件」,现在只能算 AI 的基本操作。
千问 电脑端这次真正亮出的底牌,是把语音输入和 PPT 创作、AI 表格、文档处理等功能组合起来,这也是真正能帮打工人实现「每天早下班一小时」的实用功能。
拿最折磨人的 PPT 来说,千问不是去素材库里给你拼凑烂大街的野生模板,而是直接用大模型的代码能力动态生成复杂排版。如果你觉得哪里不够完美?直接多轮对话让它接着改,改到你满意为止。
为了探探底,我先让它帮我做个视频号运营课程 PPT,几乎在语音落地的瞬间,AI 就进入了光速消化模式:填充血肉、匹配逻辑一气呵成。

最令人惊喜的是,千问对「图文穿插」的理解并非生搬硬套,而是根据内容深度匹配了差异化的版式,整份 PPT 拿出来,几乎就是可以直接交付的成稿。
这还没完,你还能一次性给千问喂最多 39 种不同格式的参考文件,让它自动梳理逻辑、提炼重点,帮你省去了来回翻资料的麻烦。至于配图,它也能根据上下文自动匹配,找不着合适的甚至能当场给你生图,全程都不需要你切出界面去求助搜索引擎、或者下载下来用 office 处理。
表格处理方面,千问的 Excel Agent 主打一个高水准。
不管是格式随意的聊天截图、手写笔记,还是大段的纯文本,丢给它就能快速生成标准的 Excel 表格。如果后续还要算算增长率、画个趋势图,也不用再去头疼怎么写函数公式了,直接用自然语言吩咐它就能搞定。
我试了一个稍微有点复杂的需求:让它根据 2026 广州最新版初中英语教材,把各句型的语法结构、时态变化和参考例句整理成 Excel 表格,格式要适合一页纸打印,方便拿来背诵。

换以前,这种事得自己一条条查资料、手动录入、再调格式,至少要折腾半小时。现在说一句话,它直接把表格生成好,列名、行距、例句填充,基本不需要再动手改。
文档处理这边,Word/PDF Agent 支持图文数据混合上传,能自动排版并输出直接可交付的文件。
更有意思的是,传完长文档你不需要自己去翻阅找重点,直接张嘴问,它就能快速定位给出答案;想修改哪里也是一句话的事儿,省去了自己去对照原文件一点点改的麻烦。
我试着传了一份繁杂的合同 PDF,直接问它:「独家授权内容是哪些?」它并没有傻傻地把全文复述一遍,而是精准定位到了授权条款,把独家范围、授权期限和限制事项一条条列得清清楚楚。

目前,这个语音指令甚至还能和 AI 写代码、手搓网页等任务助理功能组合使用,照这个架势下去,未来的办公形态,大概真的就是「动动嘴皮子就把活儿干了」。
和 AI 说话的人,会比键盘打字的人更早下班
用了一段时间千问语音输入法,我想到一件事。
过去几年「AI 提升办公效率」喊得震天响,但大部分人的体验是:我跟 AI 说了半天,它给我的东西根本不能用。然后就觉得 AI 也不过如此。
问题出在哪?出在沟通方式上。你用键盘跟 AI 对话,40% 的精力花在组织文字上,只剩 60% 在想你到底要什么。给出去的指令信息密度低、上下文薄,AI 当然输出垃圾。这不是 AI 不行,是你喂给它的东西不行。
语音把这个死结解开了。说话时你不会给自己设字数限制,细节会自然地冒出来,上下文会自动变厚。它能把嘴里说出的自然语言需求梳理得井井有条,让 AI 精确执行。

纽约销售平台 Clay 的教育负责人 Yash Tekriwal 提到,他用语音输入的速度是每分钟 205 个词,打字只有 110 到 120 个。但速度还不是最关键的,他发现口述的 prompt 质量更高。
AI 圈最近有个词特别火,叫 harness。它的意思大概是:你有一匹马(AI 的能力),但你得有一套缰绳才能驾驭它,让它往你要的方向跑。没有 harness,马再强壮也只是在原地打转。
千问电脑版的语音输入法就是这套 harness。
它连接的一端是你的嘴,另一端是 AI 的全套办公能力:PPT、表格、文档、搜索、分析、格式转换。你说一句话,它把你的意图翻译成 AI 能执行的指令,然后调度对应的 Agent 去跑腿。它不是输入法,是缰绳。是你驾驭 AI 办公能力的那套 harness。
而别的「带 AI 功能的输入法」解决的是什么?是入口问题,帮你找到 AI 在哪里。千问解决的是驾驭问题,帮你把 AI 的能力精确地用起来。一个是给你指路,一个是帮你套好缰绳直接上路。差距就在这。
在 Agent 时代,语音本来是驱动 AI 工作的最自然和高效的方式。千问语音输入法,就是率先在桌面入口端出了这套 harness 的产品 ,这也是为什么我期待,未来在更多终端上,能看到这种真正能驾驭 AI 的语音入口。
去年这个时候,如果你在办公室突然对着电脑说话,一次两次会被当成在打电话,三番五次就不禁让人怀疑,工作压力是不是太大了,精神状态还好吗?
今年开始,那些对着电脑自言自语的,可能就是全公司最早下班 (摸鱼) 的人。
附客户端下载地址:
https://www.qianwen.com/download?ch=tongyi_redirect
网页版体验地址:
https://www.qianwen.com/
#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。