Stepwise · 网站操作指南生成器
一个完全离线的浏览器扩展:在网页上拖框选区域,自动截图、标注、打码,生成带图解的 Word 操作指南。零账号、零服务器、零遥测。
项目概览
一句话理解它:你在一堆网页上点一点、框一框,它把操作过程变成一份带截图和标注的 Word 说明文档。
核心产品理念:把「记什么、记哪里」的控制权交还给用户——用最熟悉的「拖框」方式,而不是让机器自动猜。
01需求设计
做什么、给谁用、为什么做成这样。
一、背景与问题
痛点一句话:把一个网页操作过程,变成一份别人能照着做的说明文档,这件事又费时又容易出错。
传统做法是:一边操作一边手动截图,再贴到 Word 里,一张张标箭头、写字。做一份 20 步的操作指南,可能要花一两个小时,而且截图经常截到无关内容、重点标不清楚。
市面上有 Tango 这类工具能自动做这件事,但它们往往是在线服务:要注册账号、数据上传云端、依赖服务器。对于内网系统、隐私敏感的教程、或者就是想离线用的人来说,这些都不合适。
所以本项目的定位很明确:做一个完全离线的浏览器扩展,把「录操作 → 生成带标注的 Word 指南」这件事做成零门槛、零配置。
通俗类比:它不是「云端剪辑软件」,而是「装在浏览器里的拍立得」——拍下来、当场就能看到、数据全留在你电脑里。
二、目标用户
| 用户 | 场景 | 诉求 |
|---|---|---|
| 客服 / 培训师 | 给客户写「怎么用某个系统」的教程 | 快、截图干净、能标注重点 |
| 技术支持 | 写复现步骤、给用户发操作指引 | 步骤清晰、能导出成正式文档 |
| 产品 / 项目经理 | 记录演示流程、沉淀操作手册 | 离线、可编辑、可反复修改 |
| 个人用户 | 给爸妈写「怎么用手机银行网页版」 | 零学习成本、无需账号 |
共同点:要的是「能交付的正式文档」,不是「录屏视频」或「一堆散截图」。
三、核心需求
按重要性排序:
- 手动框选截图:由用户自己拖框决定「截哪里」,而不是机器自动猜。这是本产品的第一性需求(详见 04-重难点 · 为什么从自动走向手动)。
- 三种标注合一:一次框选同时表达三种诉求——紫框 = 截图区域、红框 = 标记重点、马赛克框 = 打码敏感。
- 步骤可管理:每一步都能补文字、删、改、拖动排序。
- 导出 Word(.docx):生成 A4 纵向、截图直接嵌入的正式文档。
- 完全离线:不联网、无账号、无服务器、无遥测,数据存在浏览器本地。
四、功能清单
录制端(在网页上)
- 点工具栏图标弹出控制面板,一键「开始录制」
- 页面右下角出现状态栏,点「选择截图区域」进入截图模式
- 截图模式覆盖一层蒙版,禁止误点网页
- 拖拽画紫框(截图区域)
- 紫框之上可选画红框(标记)、马赛克框(打码)
- 点「完成」对该区域截图,自动合成红框 + 马赛克
- 右下角滑出步骤确认条:「丢弃 / 确认保留(3 秒倒计时自动保留)」
编辑端(本地文档库)
- 左侧文档列表,切换 / 选择文档
- 修改文档标题、每步文案
- 拖动步骤排序
- 删除单步 / 插入纯文字步骤
- 替换或上传步骤截图
- 一键「导出文档」生成 .docx
明确的「不做」
- 不做自动录制(点击/输入/滚动自动记)——机器猜不准重点
- 不做在线同步、云存储、协作——违背离线定位
- 不做视频导出——需求是「文档」不是「录像」
五、交互方案
为什么是「拖框」,而不是「点一下自动截」:早期方案试过「自动录制」和「hover 高亮 + 快捷键」,都因「截图不干净」「找不准重点」「有学习成本」被放弃。最终收敛为截图软件式的拖框——拖框选区域人人都会、零学习成本;框选是像素级的、不依赖网页 DOM 结构;三种颜色各司其职。
状态机(录制流程):
idle(未录制)
└─ 开始录制 ──> record(选择截图区域阶段)
└─ 点「选择截图区域」──> edit(画框阶段)
├─ 画完紫框 ──> 显示红框/马赛克工具 + 「完成」
└─ 点「完成」──> 截图 → 回到 record(可继续下一步)
反馈闭环:每完成一步,右下角滑出确认条,给用户「反悔」的机会,但用 3 秒倒计时自动保留兜底——既不强制多点一下,又给足了确认空间。
六、设计原则
- 离线优先:零账号、零服务器、零遥测,数据只在本机
chrome.storage.local。 - 开箱即用:
npm install && npm run build后加载dist/目录即可用。 - 所见即所得:画框时看到的(实线紫框、黑色马赛克底、红色标记框)就是导出文档里的样子。
- 克制与减法:没被要求的东西一律不要。
- 兼容老环境:目标 Edge 89,不使用超出该版本能力的新特性。
02开发过程
22 个工作日、30 次提交,一条「先跑通、再打磨、后沉淀」的曲线。
一、总体节奏
- 时间范围:2026-07-20(周一)~ 2026-08-18(周二)
- 工作日:22 个(除开周末)
- 提交次数:30 次(每次提交都有明确的单一意图)
- 开发方式:迭代开发——先跑通最小闭环,再逐项打磨,最后沉淀文档。
整个过程的节奏是一条很自然的曲线:前期密集搭骨架(每天 1~2 次提交),中期逐项打磨(版本号从 0.11.1 一路涨到 0.11.18),后期收敛到写文档。
通俗类比:像盖房子——先打地基立框架(骨架),再砌墙装窗(功能),最后粉刷收边(打磨),交付前整理验收资料(文档)。
二、五个阶段
阶段一:项目奠基(07-20 ~ 07-24,提交 1~10)
从零到「能跑通」:搭好 Vue 3 + Vite 的构建骨架,再按「存储 → 后台 → 内容脚本 → 弹窗 → 编辑器 → 导出」的顺序,一块块把功能立起来。
| # | 日期 | 提交 | 说明 |
|---|---|---|---|
| 1 | 07-20 | chore: 初始化 Vite + Vue 3 项目骨架 | 选型:Vue 3 + Vite,锁定 Vue 3.4 |
| 2 | 07-20 | feat: 添加 Manifest V3 扩展清单 | 声明权限、入口、content_scripts |
| 3 | 07-21 | feat: 本地文档存储层 | chrome.storage.local 封装 |
| 4 | 07-21 | feat: background 录制会话管理 + 消息路由 | 后台管家 |
| 5 | 07-22 | feat: content 脚本入口(Shadow DOM + store) | 注入页面、样式隔离 |
| 6 | 07-22 | feat: 状态栏 + 蒙版 + 三种框 | 紫框/红框/马赛克框 |
| 7 | 07-23 | feat: 截图后期处理(马赛克/红框/裁剪) | 内存画布合成 |
| 8 | 07-23 | feat: 工具栏弹窗 | 开始/完成/放弃录制 |
| 9 | 07-24 | feat: 本地文档库编辑器 | 步骤编辑 |
| 10 | 07-24 | feat: 手写 DOCX + ZIP 生成,导出 Word | 打通「录 → 导」完整链路 |
本阶段踩的坑:captureVisibleTab 第一个参数要传 windowId 而非 tabId,一开始传错了导致截图静默失败,调试了一下午。
阶段二:精简打磨(07-28 ~ 08-11,提交 11~28)
完整链路跑通后,进入「减法」和「对齐」阶段:版本号从 0.11.1 一路涨到 0.11.18,几乎每一版都在删东西、对齐视觉、精修文案,而不是加功能。
| # | 日期 | 提交 | 类别 |
|---|---|---|---|
| 11 | 07-28 | 截图区域按钮直接触发画紫框,框仅边缘可拖动 | 交互收敛 |
| 12 | 07-28 | 删除紫框回到初始状态,恢复步骤缩略图删除 | 交互收敛 |
| 13 | 07-29 | 完整恢复步骤确认条(滑入动画 + 3 秒倒计时) | 反馈闭环 |
| 14 | 07-29 | 截图模式加蒙版禁止网页交互 + 未画紫框提示语 | 防误操作 |
| 15 | 07-30 | 去掉「处理中…」文案与框阴影/外发光 | 视觉减法 |
| 16 | 07-30 | popup 副标题改「网站操作指引制作工具」 | 文案精修 |
| 17 | 07-31 | 红框创建预览改为实线(与完成态一致) | 所见即所得 |
| 18 | 07-31 | 去掉 popup 版本标签 | 视觉减法 |
| 19 | 08-03 | popup 副标题改「网站操作指南制作工具」 | 文案精修 |
| 20 | 08-03 | popup 状态文案精简为「准备就绪。」 | 文案精修 |
| 21 | 08-04 | 未填写说明不再写占位 + 插件名改「网站操作指南生成器」 | 文案精修 |
| 22 | 08-04 | 插件名调整为「Stepwise - 网站操作指南生成器」 | 文案精修 |
| 23 | 08-05 | 红框/马赛克工具画完保持激活,可连续创建 | 提效 |
| 24 | 08-05 | 紫框创建态改实线、马赛克创建态改黑底(所见即所得) | 所见即所得 |
| 25 | 08-06 | popup 简化(去暂停按钮)+ editor 排版精修 | 减法 |
| 26 | 08-07 | 去掉录制临时提示组件 flashHint | 减法 |
| 27 | 08-10 | 去掉 editor notice 组件 + 移除暂停功能残留 | 减法 |
| 28 | 08-11 | 去掉 editor 空状态 + 隐藏清空按钮 + 按钮禁用文本选中 | 减法 |
本阶段的规律:大量提交是「文案精修」和「减法」,印证了「克制与减法」——做出来的东西,删掉不必要的一直是重头戏。
阶段三:文档沉淀(08-14 ~ 08-18,提交 29~30)
| # | 日期 | 提交 | 说明 |
|---|---|---|---|
| 29 | 08-14 | docs: 技术实现概览 + 关键技术细节 | 技术文档(面向小白 + 技术深入) |
| 30 | 08-18 | docs: 项目全周期文档 + HTML 汇总展示页 | 需求/过程/细节/重难点/Vue + 汇总页 |
三、关键里程碑
| 时间 | 里程碑 | 意义 |
|---|---|---|
| 07-24 | 导出 Word 链路打通(0.11.0) | 「录 → 导」完整可用,MVP 成立 |
| 07-29 | 步骤确认条完整恢复 | 反馈闭环定型 |
| 08-05 | 创建态 = 完成态(所见即所得) | 视觉一致性收官 |
| 08-10 | 暂停功能彻底移除 | 减法到位的标志 |
| 08-18 | 全周期文档 + 汇总页 | 可交付、可维护 |
四、过程反思
- 先跑通,再打磨:阶段一 10 个提交就把完整链路立起来,哪怕粗糙;阶段二才慢慢收敛细节。
- 减法是高频动作:18 个打磨提交里,超过一半是「删、去、精简」。
- 文案即产品:副标题、状态文案、插件名反复改,一字之差都值得单独一次提交。
- 版本号跟着每次改动走:每次改动都同步 bump
manifest.json的 version。
03技术细节
架构、数据流、关键实现决策。
一、总体架构:四个角色
浏览器扩展不是一段代码,而是几个各自独立、分工不同的模块,运行在不同地方,靠消息通信。
| 角色 | 代码位置 | 运行位置 | 职责 |
|---|---|---|---|
| popup(弹窗) | src/popup/ | 点工具栏图标弹出的窗口 | 开始 / 完成 / 放弃录制 |
| content(内容脚本) | src/content/ | 注入到网页内部 | 画蒙版、三种框、状态栏、确认条 |
| background(后台) | src/background/ | 浏览器后台 | 截图调度、步骤存取、会话管理 |
| editor(编辑器) | src/editor/ | 独立的本地页面 | 步骤编辑、排序、导出 Word |
通俗类比:content 是「派驻网页里的助手」,background 是「总部管家」,popup 和 editor 是「两个操作台」。助手不自己存数据,干完活给管家发消息。
二、数据流:一次录制的完整旅程
以「录制一步截图」为例,完整走一遍消息流:
[popup] ──recording:start──▶ [background] ──recorder:setMode──▶ [content]
│ 创建草稿文档,记录会话
│
[content] 用户拖紫框 + 画红框/马赛克(纯内部,无消息)
│
[content] ──capture:step──▶ [background]
│ 三段式截图:
│ ① snapshot:prepare(content 藏 UI,回报框坐标)
│ ② captureVisibleTab(background 截整屏)
│ ③ snapshot:redact(content 打码/画红框/裁剪)
│
▼
[background] 追加步骤到文档,存 chrome.storage.local
│
[content] ◀── 缩略图 ── 滑出「丢弃/确认保留(3 秒倒计时)」
关键认知:真正的截图能力(captureVisibleTab)只有 background 有;而框的坐标在 content 里。所以必须来回传消息,这就是「三段式截图」的由来。
三、数据模型
所有数据存在 chrome.storage.local(本机,无服务器),用三组 key 区分(src/lib/storage.js):
| key | 存什么 |
|---|---|
stepwise.guideIndex | 所有文档的摘要数组(id/title/time/stepCount,不含正文) |
stepwise.guide.<id> | 每篇文档完整内容(含 steps,每步含截图 dataUrl) |
stepwise.recording | 当前录制会话(在录哪篇、哪个标签页、状态) |
为什么分 index 和正文:列表页只读摘要,不必把每篇的截图全读出来(截图很占内存)。
四、关键技术决策与取舍
1. 前端选型:Vue 3 + Vite,锁定 Vue 3.4
- 用 Vue 3(而非原生 JS 拼 DOM):界面 = 数据(
store)的投影,改数据界面自动变。 - 用 Vite 5:多入口构建——popup/editor 走 ESM,content 走 IIFE(MV3 content_scripts 不支持 ESM)。
- 锁定 Vue 3.4(而非 3.5):Vue 3.5 的响应式内部用到
Array.prototype.findLast,Edge 89 没有,会崩溃。
2. 样式隔离:Shadow DOM
content 脚本直接往网页里塞元素,会被网页的 CSS 污染。用 Shadow DOM 套一层「隔音罩」:里面自成小世界,外面影响不到里面,里面也不泄漏出去。
3. 框的交互:事件穿透 + 边缘拖动
- 框体中间一片
pointer-events: none(穿透)——所以能在紫框内画红框,不被紫框抢占。 - 只有边缘 5px 细条和八个缩放手柄是
pointer-events: auto(可拖拽/缩放)。 - 定位用
translate3d+ 真实宽高:走 GPU 合成层,拖动流畅、不模糊。
4. 截图:背景截屏 + 内容加工
background 只能整屏截,content 负责在内存画布(canvas)上做三件事:打马赛克 → 画红框 → 裁剪到紫框。坐标换算:框坐标是「视口坐标」,截图是「像素坐标」,需要乘 devicePixelRatio 缩放系数对齐。
5. 导出:手写 DOCX + 手写 ZIP,零依赖
.docx 本质是 ZIP 压缩包,里面装 XML。所以「生成 Word」= 拼 XML 字符串 + 打包成 zip,纯手写,不依赖任何 npm 库。图片以 wp:inline + a:blip 嵌入,实现图文混排。
6. 数据:index + 正文分离
列表页读轻量索引,点开才读全文(含截图),避免一次性把所有截图读进内存。
04重难点
五个硬骨头,以及为什么没有更简单的路。
一、截图如何做到「干净」(无 UI 入镜)
难点:截图那一刻,扩展自己的状态栏、框、确认条还显示在页面上,会被一起截进去。产物是要给别人看的正式文档,任何 UI 残留都是硬伤。
为什么难:content(负责藏 UI)和 background(负责截屏)是两个不同的进程。content 藏好 UI 后,浏览器不一定立刻把「UI 已消失」这一帧画到屏幕上,background 就截早了——这是「竞态」。
解决:三个手段叠加,保证「藏完再截」:
- 藏 UI:截屏前设
store.uiHidden = true,Vue 用v-if把所有 UI 移除出 DOM。 - 等两帧(双 requestAnimationFrame):等浏览器把「移除 UI」这一帧真正合成到屏幕。
- 再缓冲 80ms:content 和 background 跨进程,还要等合成的传播,80ms 是经验值。
二、Edge 89 兼容
难点:目标浏览器是 Edge 89(Chromium 89,2021 年),很多新 API 用不了。硬套新写法会静默崩溃(尤其 Service Worker 崩了很难排查)。
| 新写法 | Edge 89 不支持 | 本项目做法 |
|---|---|---|
crypto.randomUUID() | Chrome 92+ | Date.now() + Math.random() 兜底,带 typeof 守卫 |
chrome.storage Promise 写法 | 仅回调 | 手动包一层 Promise |
chrome.runtime.sendMessage Promise | Chrome 99+ | 包装成回调式 Promise |
Array.prototype.findLast | Chrome 97+ | 锁定 Vue 3.4(3.5 会用 findLast,崩溃) |
chrome.action | Chrome 88 才新增 | chrome.action 优先,browserAction 兜底(try-catch) |
| content 用 ESM | MV3 content_scripts 不支持 | content 用 IIFE 打包 |
importScripts 模块 | SW 受限 | background 里把 storage 逻辑内联 |
一句话:绝不依赖 2021 年之后的新特性。
三、手写 DOCX / ZIP
难点:要导出 .docx,但扩展内不能用 docx 这类 npm 库(体积大、且要能在老环境跑)。.docx 是 OOXML 格式——一个 ZIP 包里装一堆 XML。
解决:认清楚「.docx = ZIP + XML」这一本质,分两层手写:
- 拼 XML(
src/lib/docx.js):标题、步骤小标题、说明文字、图片,都是手写字符串。 - 打包 ZIP(
src/lib/zip.js):只用到「存储、不压缩」,手写 local file header + central directory + CRC32 + DOS 时间戳。
收获:零依赖、体积小、完全可控。代价是写的时候要对着 ZIP/OOXML 规范逐字段核对。
四、框的交互
难点:三种框叠在一起时,怎么让用户既能「在紫框内画红框」,又能「拖动紫框的边缘」?
矛盾:如果框整体都响应鼠标,在紫框内画红框会被紫框抢走;如果整体都不响应,又没法拖动紫框。
解决:pointer-events 分层设计——
- 框体中间一大片 →
pointer-events: none(穿透,能在紫框内画红框) - 四条「边缘条」(贴边框内外各 5px)→
pointer-events: auto(拖拽) - 八个缩放手柄 →
pointer-events: auto(缩放)
额外收获:定位用 translate3d + 真实宽高,走 GPU 合成层,拖动流畅、放大不模糊。
五、手动与自动的取舍
这是贯穿全程的第一性难点,也是本项目最值得记的一课。
难点:到底由「机器自动判断记什么」还是「用户手动指定」?
为什么难:自动看起来很省事,但撞上三堵墙——误录、截图不干净、找不准重点。
解决:三次转变,一路从自动走向手动:
- 自动录制 → 被「截图不干净/误录/找不准重点」逼退
- hover 高亮 + F9 快捷键 → 快捷键有学习成本、DOM 元素定位不稳
- 纯手动拖框(紫/红/马赛克) → 截图软件心智模型,像素级精准,零学习成本
结论:自动遇到难以逾越的障碍、成本过高或不如手动精准时,及时止损转向手动——把控制权交还用户,并用用户早已熟悉的方式(拖框)呈现。
总结:五个难点一句话
| 难点 | 本质 | 解法 |
|---|---|---|
| 截图干净 | 跨进程竞态 | 藏 UI → 双 rAF → 80ms 缓冲 |
| Edge 89 兼容 | 老环境缺新 API | 老写法 + 兜底,锁 Vue 3.4 |
| 手写 DOCX/ZIP | 二进制协议 | 认清「docx = zip + xml」 |
| 框交互 | 穿透 vs 拖拽的矛盾 | pointer-events 分层 |
| 手动 vs 自动 | 精准 vs 省事的取舍 | 自动受阻时转手动 |
05Vue 实现(零基础版)
从「数据是什么」讲起,用大白话 + 生活类比,讲清 Vue 怎么把这个工具做出来。下面每个词都会先讲明白再往下走。
预备1:数据、变量、对象(三个最底层的词)
数据 = 记下来的信息;变量 = 一个贴了标签的盒子,盒子里放一个值;对象 = 一个大收纳柜,里面分很多小格,每格贴一个标签。
let mode = 'idle' // 变量:贴着 "mode" 标签的盒子,盒子里放 "idle"(空闲)
let store = { // 对象:大收纳柜,名叫 store
mode: 'idle', // 第一格叫 mode
crop: null, // 第二格叫 crop,装 null(空)
marks: [], // 第三格叫 marks,装空列表 []
}
预备2:store:一本「总记事本」
store 是一个专门记「当前界面整体状态」的对象。界面上所有会变化的东西(蒙版、几个框、状态栏……)都记在这一本里。
类比:厨房里挂一块小黑板,写清「当前阶段 / 切了几样 / 要不要打码」。任何人看一眼黑板就知道现在什么情况。
export const store = reactive({
mode: 'idle', // 当前模式(空闲 / 录制中)
crop: null, // 紫框(截图区域)
marks: [], // 红框(标记区域)
masks: [], // 马赛克框
uiHidden: false, // 截图时是否隐藏界面
})
预备3:reactive():「自动更新」魔法
reactive(对象) 会给对象施一个魔法:你只要改对象里的数据,屏幕上显示的内容就自动跟着变,不用你手动去改屏幕。
没有魔法时:
let count = 1
count = 2 // 盒子里的数字改成 2
// ❌ 但屏幕上的数字还是 1!你得再手动去改屏幕
有魔法之后:
import { reactive } from 'vue'
let store = reactive({ count: 1 })
store.count = 2 // ✅ 屏幕上的数字自动变成 2
类比:没有魔法像写纸质通知(改了还要自己去张贴);有魔法像发群聊消息(改一下,所有人自动收到)。这就是 Vue 最核心的东西:你只管改数据,界面自动更新。
预备4:Vue 实例:「数据 → 界面」翻译机
Vue 实例是一台翻译机,唯一的活儿就是:盯着数据,把数据自动翻译成界面。createApp(App) 造机器,.mount(容器) 通电开机。
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount(container) // 造机器 + 装到 container 上,开机
预备5:组件:「乐高积木」
组件是一块可以反复用的「界面积木」,自己带两部分:长什么样(template)+ 怎么动(script)。一个 .vue 文件就是一块积木。
<script setup>
// 积木的「逻辑」:数据、函数写这里
</script>
<template>
<!-- 积木的「外观」:长什么样写这里 -->
</template>
这个项目里最主要的两块积木:src/content/App.vue(整面墙:蒙版、状态栏、三种框、确认条)、src/content/components/Box.vue(一个框,被 App 反复拼上去)。
预备6:props 和 emit:父子之间怎么传话
积木是「大套小」的(App 里套 Box)。它们之间传话,两个方向:props(下传)父把数据递给子,子只能读;emit(上传)子有事发生,喊一声,父听到再决定怎么办。
类比:props 像「父母递东西给娃」,emit 像「娃喊爸妈」。
// 子(Box.vue)
const props = defineProps({ box: { type: Object, required: true } })
const emit = defineEmits(['dragstart', 'delete'])
function onDragStart() {
emit('dragstart', { boxId: props.box.id }) // 喊一声「我开始拖了」
}
<!-- 父(App.vue) -->
<Box :box="box" @dragstart="onBoxDragStart" @delete="onDeleteBox" />
<!-- ↑递数据 ↑听到"拖"就执行 ↑听到"删"就执行 -->
预备7:chrome 消息通信:不同房间「发邮件」
这个扩展有 4 个独立的部分,住在不同的「房间」里,互相不能直接碰对方的东西,只能靠「发消息」沟通。
chrome.runtime.sendMessage({ type: 'capture:step' }) // 发一封「请截一张图」的邮件
chrome.runtime.onMessage.addListener((msg) => { ... }) // 守着邮箱,收到就处理
类比:像公司 4 个部门不在一个办公室,不能直接改别人的电脑,只能发邮件。content 和 background 是两个独立进程,只能「寄信」协作。
预备8:顺带认识 ref / computed / v-if / v-model
| 词 | 一句话 | 类比 |
|---|---|---|
ref(初值) | 装单个值的盒子,也带自动更新魔法 | 贴标签的小盒子,改了自动刷新 |
computed(() => ...) | 自动算出来的格子,原料变了自动重算 | Excel 公式:B1 改了,C1 的 =B1*2 自动变 |
v-if | 条件开关:满足才显示 | 「如果下雨就带伞」 |
v-for | 循环:列表每项渲染一遍 | 复印机:一份名单逐个打印 |
v-model | 双向绑定:输入框和数据同步 | 镜子:你动它也动,两边一样 |
@click 等 | 绑事件:点一下执行某函数 | 门铃:一按就响 |
一句话记忆:ref/computed 管数据,v-if/v-for 管显示,v-model 管输入,@事件 管交互。
实现点 1:画一个框,其实只是「改一次数据」
store.crop = { id: 'abc', type: 'crop', x: 100, y: 80, width: 300, height: 200 }
因为 store 是 reactive() 的,这一句赋值后,屏幕上的紫框自动出现。不用写任何「去画一个框」的代码。你在界面上看到的所有框、蒙版、按钮,背后都是 store 里的一份数据。界面只是数据的投影。
实现点 2:三种框,用一块积木复用
const boxes = computed(() => [
...(store.crop ? [store.crop] : []),
...store.marks,
...store.masks,
])
<Box v-for="box in boxes" :box="box" @dragstart="..." @delete="..." />
props.box.type 决定画成紫色(crop)、红色(mark)还是黑色马赛克(mask)——一套积木,画三种框。
实现点 3:草稿态(ref)与正式态(store)分开
const draftRect = ref(null) // 草稿:拖拽中的虚线框,临时
function onMouseMove(e) {
draftRect.value = { x, y, width, height } // 实时更新草稿
}
function commitBox(rect) {
store.crop = { id: uid(), type: 'crop', ...rect } // 松手才写进 store
}
规律:临时 UI 状态用局部 ref,业务数据用全局 store。
实现点 4:子组件传话(props 下传 + emit 上传)
Box 被拖拽 / 被删除时,它自己不改 store,而是 emit('dragstart' / 'delete') 喊给父组件 App,由 App 来改 store。数据改动集中由父组件负责,子组件只负责喊。
实现点 5:用 v-if 切换界面「阶段」
<template v-if="store.phase === 'record'">
<button @click="onSelectCropArea">选择截图区域</button>
</template>
<template v-else>
<button v-for="tool in TOOLS" ...>...</button>
<button @click="onFinish">完成</button>
</template>
切换不是手动改 DOM,而是改 store.phase,v-if 自动决定显示哪块。
实现点 6:确认条的滑入动画
const previewVisible = ref(false) // 是否渲染出来
const previewSlidIn = ref(false) // 是否滑到目标位置
const style = computed(() => ({
transform: previewSlidIn.value ? 'translateX(0)' : 'translateX(120%)',
}))
实现点 7:编辑器拖拽排序
function onDrop(event, step) {
const [moved] = guide.value.steps.splice(from, 1) // 取出来
guide.value.steps.splice(to, 0, moved) // 插到新位置
}
数组顺序一变,v-for 渲染的卡片顺序自动跟着变。
实现点 8:v-model 双向绑定 + 自动保存
<input v-model="guide.title" @input="scheduleSave">
<textarea v-model="step.description" @input="scheduleSave"></textarea>
实现点 9:popup 用 v-if 切「解锁 / 应用」两个面板
<main v-if="!unlocked"> <!-- 未解锁:密码输入 -->
<input v-model="unlockInput" ...>
</main>
<main v-else> <!-- 已解锁:录制控制 -->
<div>{{ statusText }}</div>
</main>
实现点 10:三台独立的 Vue 翻译机
| 翻译机 | 挂在哪 | 管什么界面 |
|---|---|---|
| content | 网页内部(Shadow DOM 里) | 框、蒙版、状态栏、确认条 |
| popup | 工具栏弹窗 | 开始 / 完成 / 放弃 |
| editor | 本地文档页 | 列表、排序、导出 Word |
三台机器互不直接调用,靠 chrome 消息协作,各自 createApp(App).mount(...) 开机。
一张图串起来
数据(store / ref / computed)
│ reactive 魔法:改数据,界面自动跟着变
▼
模板(v-if / v-for / v-model / @事件 / :style)
│ Vite 编译 + Vue 翻译机渲染
▼
界面(框、蒙版、按钮、卡片)
│ 用户操作触发 @事件 / emit 上传
▼
函数(改数据 / 发 chrome 消息给别的房间)
│
└──── 循环:改数据 → 界面自动更新 → 用户操作 → 再改数据
全文最该记住的一句话:界面 = 数据(响应式)的投影。你只管改数据,界面自动变;用户操作通过事件反过来改数据。其余所有概念——store、reactive、组件、props/emit、消息通信——都是在为这一句服务。
附技术实现概览
读完能看懂整体结构、理解每个模块在干什么,并能用自己的话转述出来。用类比和故事讲,不要求懂底层细节。
一、这个扩展到底是干什么的
一句话:你在一堆网页上点一点、框一框,它帮你把操作过程变成一份带截图和标注的 Word 说明文档。
整个过程拆成两段:
- 录制段:你在网页上拖框选区域(紫框 = 截图、红框 = 标重点、马赛克 = 打码),每选好一个点「完成」,它就截一张图存起来。
- 整理段:录完打开本地编辑页,给每步补文字、调顺序,最后点「导出」得到
.docx文件。
二、先认识四个「角色」
| 角色 | 代码在哪 | 运行在哪儿 | 干什么 |
|---|---|---|---|
| popup | src/popup/ | 点工具栏图标弹出的小窗口 | 「开始录制 / 完成 / 打开文档库」的按钮 |
| content | src/content/ | 注入到网页内部 | 画蒙版、紫框、红框、马赛克、状态栏——页面上所有框都是它画的 |
| background | src/background/ | 浏览器后台(不露面的管家) | 协调全局:真正执行截图、把步骤存本地、维护录制状态 |
| editor | src/editor/ | 独立的本地页面 | 编辑步骤文字、拖拽排序、导出 Word |
它们之间靠发消息协作(就像互相发微信),而不是直接调用对方的函数。这是浏览器扩展的规则:这几个模块被浏览器隔离开,只能通过消息通信。
三、一次完整操作,数据是怎么流动的(故事线)
- 点 popup 的「开始录制」 → popup 给 background 发
recording:start→ background 创建空文档草稿,给 content 发recorder:setMode→ content 在网页右下角显示状态栏。 - 点「选择截图区域」,拖出紫框 → 这一步完全在 content 内部完成:拖拽时 content 实时画紫色矩形(数据存在
store.crop),网页被蒙版盖住。 - 点「完成」 → content 发
capture:step→ background 三段式流程拿到「裁好 + 打码 + 画框」的截图 → 追加到文档,返回缩略图 → content 滑出「丢弃 / 确认保留(3 秒倒计时)」。 - 重复 2、3 步,录完所有步骤。
- 点 popup 的「完成录制」 → background 打开 editor 页面。
- 在 editor 里补文字、调顺序,点「导出 Word」 → 组装成
.docx,浏览器自动下载。
四、三个最重要的技术概念(用类比讲)
1. Shadow DOM —— 「隔音罩」
网页有自己的 CSS,扩展直接往页面塞元素可能被污染(比如页面的 div { border: 0 } 把扩展的框弄乱)。Shadow DOM 给扩展界面套一层隔音罩:里面自成小世界,外面影响不到里面。
2. Vue 响应式 —— 「数据一变,界面自动跟着变」
传统写法要手动改界面;Vue 只管改数据,界面自动刷新。比如紫框,代码里只有一份数据 store.crop = { x, y, width, height },拖动时改的是数据,紫色矩形自动跟着动。
3. chrome.storage.local —— 「浏览器的本地硬盘」
扩展把数据存在浏览器自带的存储区里。存文档、存截图、存录制状态都放这里。注意:Edge 89 的 storage API 只支持回调,代码里用一层包装把它变成 Promise(见 关键技术细节 §7)。
五、每个文件是干嘛的(速查表)
| 文件 | 作用 |
|---|---|
src/manifest.json | 扩展的「身份证」:名字、版本、权限、声明脚本 |
src/popup/App.vue | 工具栏弹窗界面 |
src/content/main.js | content 入口:挂 Shadow DOM、监听消息、桥接 background |
src/content/App.vue | content 主界面:蒙版、状态栏、画框逻辑、确认条 |
src/content/store.js | 全局响应式数据 + 发消息的工具函数 |
src/content/components/Box.vue | 单个框(紫/红/马赛克)的绘制、拖拽、缩放、删除 |
src/content/snapshot.js | 截图后期处理:打马赛克、画红框、裁剪 |
src/background/background.js | 后台管家:录制会话、截图调度、步骤存取 |
src/editor/App.vue | 文档编辑器:编辑步骤、排序、导出 Word |
src/lib/storage.js | 本地存储的读写封装 |
src/lib/docx.js | 把文档数据拼成 Word 的 XML 结构 |
src/lib/zip.js | 手写的 ZIP 打包器(.docx 本质是 zip) |
vite.config.js / vite.content.config.js | 构建配置(把源码打包成 dist/) |
六、转述时可以抓住的核心骨架
- 四个角色各司其职:弹窗、内容脚本、后台、编辑器,靠消息通信。
- 界面即数据:所有框都是响应式数据(
store)的投影,改数据界面自动变。 - 截图是「截图 → 后期加工 → 裁剪」三段式:先整屏截,再打码、画红框,最后裁到紫框。
- 数据都存在浏览器本地(
chrome.storage.local),不联网、无服务器。 - Word 文档是手工拼出来的:自己拼 XML + 自己打包 zip。
附关键技术细节
逐个讲透核心功能的实现。建议先读「技术实现概览」建立整体框架,再看本文。文中代码均为精简摘录。
1. 蒙版:覆盖网页 + 禁止交互
目标:点「选择截图区域」后,整个网页盖上半透明遮罩,用户点不到网页上的任何东西,只能拖框。
const maskStyle = {
position: 'fixed', inset: '0', // 铺满整个视口
zIndex: '2147483645', // 层级:低于框(…646)、状态栏(…647)
background: 'rgba(16,24,40,.28)', // 半透明深色
pointerEvents: 'auto', // 关键:拦截鼠标事件
cursor: 'crosshair', // 十字光标
}
<div v-if="store.mode === 'recording' && store.phase === 'edit' && !store.uiHidden" :style="maskStyle"></div>
pointer-events: auto是「禁止交互」的核心:这个 div 覆盖最上层,鼠标事件全被它接住,传不到下面的网页元素。z-index分层是刻意设计的:蒙版(645) < 框(646) < 状态栏/确认条(647)。
2. 选定区域截图:三段式消息流
为什么这么绕:真正的截图能力(captureVisibleTab)只有 background 有;而紫框/红框/马赛克的坐标数据在 content 里。所以必须两边来回传消息。
// ① 让 content 隐藏 UI,并回报框的坐标
const prepared = await notifyContent(tabId, { type: "snapshot:prepare" });
// 等浏览器把"UI 已隐藏"重绘完成(双 rAF + 80ms 缓冲)
await new Promise(r => setTimeout(r, 80));
// ② background 直接截整个可见视口
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
format: "jpeg", quality: 88
});
// ③ 让 content 做后期加工(打码/画红框/裁剪),返回成品
const redacted = await notifyContent(tabId, {
type: "snapshot:redact",
payload: { dataUrl, viewport, crop, marks, masks }
});
三步:snapshot:prepare(藏 UI + 回报坐标)→ captureVisibleTab(截整屏,注意传 tab.windowId 而非 tabId)→ snapshot:redact(canvas 上打码、画红框、裁剪)。
export async function renderSnapshot(payload) {
const source = await loadImage(payload.dataUrl)
const canvas = document.createElement('canvas')
canvas.width = source.naturalWidth
canvas.height = source.naturalHeight
const ctx = canvas.getContext('2d')
ctx.drawImage(source, 0, 0)
const scaleX = canvas.width / payload.viewport.width // 视口坐标 → 像素坐标
const scaleY = canvas.height / payload.viewport.height
for (const rect of payload.masks ?? []) mosaicRect(ctx, rect, scaleX, scaleY)
for (const rect of payload.marks ?? []) drawMarker(ctx, rect, scaleX, scaleY)
if (payload.crop) { /* 新建画布,把紫框那块 drawImage 过来 */ }
return cropped.toDataURL('image/jpeg', 0.88)
}
为什么要 scaleX/scaleY 换算:框坐标是「视口坐标」,截图可能是 2 倍像素(devicePixelRatio),所有框坐标要先乘缩放系数。
3. 马赛克:像素级打码
核心思路:把区域划分成若干小方块,每个方块用块内所有像素的平均色填充。方块够大,细节就没了,但整体色调还在。
const block = Math.max(6, Math.min(24, Math.round(Math.min(w, h) / 8)))
const imageData = ctx.getImageData(x, y, w, h)
const data = imageData.data
for (let by = 0; by < h; by += block) {
for (let bx = 0; bx < w; bx += block) {
let r = 0, g = 0, b = 0, count = 0
for (let yy = by; yy < by + block; yy++)
for (let xx = bx; xx < bx + block; xx++) {
const idx = (yy * w + xx) * 4
r += data[idx]; g += data[idx+1]; b += data[idx+2]; count++
}
const rr = Math.round(r/count), gg = Math.round(g/count), bb = Math.round(b/count)
// 用平均值回填整个 block
}
}
ctx.putImageData(imageData, x, y)
getImageData/putImageData是 canvas 的「逐像素读写」接口。- 一个像素占 4 字节
[R, G, B, A],下标要* 4。 - 只打码不「黑块盖住」,观感更自然(保留原区域色彩基调)。
4. 红框标注
红框在两个地方出现:
A. 截图时(drawMarker):
function drawMarker(ctx, rect, scaleX, scaleY) {
const x = rect.x * scaleX, y = rect.y * scaleY
const w = rect.width * scaleX, h = rect.height * scaleY
const lw = Math.max(3, 3 * Math.max(scaleX, scaleY))
ctx.strokeStyle = '#d92d20'
ctx.lineWidth = lw
ctx.strokeRect(x, y, w, h) // 只画 3px 实线描边
}
B. 编辑器里:针对旧版本录的数据(截图里没 baked 红框),导出前补画一次。两处都遵循同一原则:红框是纯 3px 实线,创建时和完成时一致(所见即所得)。
5. 框的拖拽 / 缩放 / 边缘识别
目标:框的内部要「穿透」(在紫框内画红框时不能误拖到紫框),只有边缘才能拖动。
const boxStyle = { ..., pointerEvents: 'none' } // 容器:完全穿透
const bodyStyle = { ..., pointerEvents: 'none' } // 框体视觉:纯展示
const EDGE = 5
const EDGE_STRIPS = [
{ dir: 'n', pos: { top: '-5px', height: '10px', ... } }, // 上边
{ dir: 's', pos: { bottom: '-5px', height: '10px', ... } }, // 下边
{ dir: 'w', pos: { left: '-5px', width: '10px', ... } }, // 左边
{ dir: 'e', pos: { right: '-5px', width: '10px', ... } }, // 右边
]
分层逻辑:框体中间一片 pointer-events: none(穿透)→ 四条边缘细条 pointer-events: auto(拖拽)→ 八个缩放手柄 pointer-events: auto(缩放)。定位用 translate3d + 真实宽高走 GPU 合成层。
6. 步骤自动生成 Word 文档
关键认知:.docx 本质是 ZIP 压缩包,里面装 XML。所以生成 Word = 拼 XML 字符串 + 打包 zip,纯手写,不依赖任何库。
const body = [
paragraph(textRun(guide.title), { style: "Title" }) // 标题
]
for (const step of guide.steps) {
body.push(paragraph(textRun(`步骤 ${n}`), { style: "Heading2", ... }))
if (step.description?.trim()) {
body.push(paragraph(textRun(step.description), { ... }))
}
if (image) body.push(imageParagraph(image, relationshipId, imageIndex))
}
function textRun(value) {
return `<w:r><w:t xml:space="preserve">${xml(value)}</w:t></w:r>`
}
function paragraph(content, opts) {
return `<w:p>...<w:pPr>...</w:pPr>${content}</w:p>`
}
图片用 wp:inline + a:blip r:embed="rIdX" 引用关系表里的图片,实现图文混排。ZIP 打包(zip.js)手写 local file header + central directory + CRC32 + DOS 时间戳。
7. 本地文档如何保存
位置:chrome.storage.local(本机,无服务器)。三组 key(见 03-技术细节 · 数据模型)。为什么分 index 和正文:列表页只读摘要,不必把每篇截图全读出来。
Edge 89 的坑:chrome.storage.local.get/set 在老版只支持回调,不支持 Promise。所以 storage.js 手动包一层:
function storageGet(keys) {
return new Promise(resolve => {
chrome.storage.local.get(keys, result => resolve(result || {}))
})
}
8. 截图如何做到「干净」(无 UI 入镜)
问题:截图那一刻,扩展自己的状态栏、框还显示在页面上,就会被截进去。解法:截图前先把 UI 全藏起来,等浏览器重绘完成再截,截完再恢复。三个手段叠加:
- 藏 UI:
snapshot:prepare时设store.uiHidden = true,Vue 用v-if="!store.uiHidden"全部移除。 - 等两帧(双 rAF):等浏览器把「移除 UI」这一帧真正画到屏幕。
- 再缓冲 80ms:跨进程传播,80ms 是经验值。
早期踩了很多坑(baseline 修补、预采、冷却期等 workaround),最终收敛到「藏 UI → 双 rAF → 80ms 缓冲」。
9. Edge 89 兼容性清单
| 新写法 | Edge 89 不支持 | 本项目做法 |
|---|---|---|
crypto.randomUUID() | Chrome 92+ | Date.now() + Math.random() 兜底 |
chrome.storage Promise 写法 | 仅回调 | 手动包 Promise |
chrome.runtime.sendMessage Promise | Chrome 99+ | 包装成回调式 Promise |
Array.prototype.findLast | Chrome 97+ | 锁定 Vue 3.4 |
chrome.action | Chrome 88 才新增 | action 优先,browserAction 兜底 |
| content script 用 ESM | MV3 不支持 | content 用 IIFE 打包 |
importScripts 模块 | SW 受限 | background 里内联 storage 逻辑 |
一句话:能用老写法的都用老写法,实在没有就用兜底逻辑,绝不依赖 2021 年之后的新特性。
总结:一张图串起所有技术点
[popup] ──消息──▶ [background] ──tabs.sendMessage──▶ [content]
│ │
真正截图(captureVisibleTab) 画蒙版/框/状态栏
存本地(storage.local) (Vue + Shadow DOM)
│ │
└────snapshot:redact──────▶ 打码/画框/裁剪(canvas)
│
[editor] ──docx.js+zip.js──▶ .docx 下载
- 界面 = Vue 响应式 + Shadow DOM 隔离(content 侧)
- 截图 = background 截屏 + content 加工(三段式消息流)
- 存储 =
chrome.storage.local(index + 正文 + 会话) - 导出 = 手拼 XML + 手写 ZIP =
.docx - 兼容 = 全程绕开 Edge 89 缺失的新 API