从 localStorage 到 Chrome 商店:一个 HTML 文件的 7 次重构

本文记录了使用 WorkBuddy 从零构建一个健康打卡 + 记账工具的完整过程。起点是一个单文件 HTML,终点是打包提交 Chrome Web Store。中间被 7 个坑逼着重构了 7 次------每个坑的根因都不一样,但有一个共同点:踩之前看不出来,踩完之后都觉得"这我怎么不知道"。


起因:半小时能做完的东西

早上 10 点,想做个健康打卡加记账的工作台。需求很简单:记录每天的体重、运动、饮食打卡,加上收支流水,能看到图表趋势。

技术选型也简单到不像话:单文件 HTML,localStorage 存数据,内联 SVG 画图表,不依赖任何框架。半小时写完,双击打开就能用。

到下午 4 点,这个文件已经变成了一个 Chrome 扩展,打包成 zip 准备上架。中间 7 次重构,每次都是被一个具体的坑逼的。

下面按时间线还原每次重构的触发条件和根因。


坑 1:在线 page ≠ 云端同步

触发条件:想把工具发布成在线链接,手机能打开。

用 WorkBuddy 的「在线 page」能力,import HTML + publish,30 秒拿到公开链接。手机浏览器打开,页面正常显示,图表能画,表单能填。

但数据存在哪?还是 localStorage。电脑上记的账,手机打开看不到。在线 page 只是把 HTML 部署到了 CDN,localStorage 是浏览器本地存储,跨设备不共享,甚至同一个电脑换浏览器都看不到。

根因:在线 page 是静态托管,不是应用后端。你部署了一个前端,但没有后端。前端能跑,数据没地方放。

这次重构的方向:找一个真正的后端。


坑 2:资料库数据表 + SDK 的 lint 陷阱

触发条件:用 WorkBuddy 资料库的数据表做后端,页面通过 SDK 做增删改查。

WorkBuddy 的资料库提供了数据表能力,页面通过 window.__SMART_PAGE__.database SDK 对数据表做增删改查。思路没问题------建表、写页面、联调,一气呵成。

但 SDK 有两条 lint 规则,踩了才知道。

规则一db.querydatabaseId 必须内联写,不能塞进变量。

javascript 复制代码
// ❌ 报错 DSDK002:databaseId 不能通过变量引用
const DB_ID = 'xlc1OtqfN2bb2UlnlhctoO';
db.query({ databaseId: DB_ID, ... });

// ✅ 正确:databaseId 必须内联
db.query({ databaseId: 'xlc1OtqfN2bb2UlnlhctoO', ... });

这条规则的设计意图是让 SDK 的静态分析能追踪到数据表依赖关系,做权限校验和缓存控制。但对开发者来说,这意味着不能把表 ID 抽成常量复用。

规则二 :含 addRecord 的页面必须接入表单输入的 localStorage 缓存,成功时 removeItem,否则报 DSDK008。

javascript 复制代码
// ❌ 报错 DSDK008:表单输入未做本地缓存
db.addRecord({ databaseId: '...', record: formData });

// ✅ 正确:提交前缓存,成功后清除
localStorage.setItem('draft_form', JSON.stringify(formData));
db.addRecord({ databaseId: '...', record: formData }).then(() => {
  localStorage.removeItem('draft_form');
});

这条规则防止用户填了半天表单,网络一断全白填。强制你做本地草稿,提交成功才清掉。

这两条都是 SDK 的静态分析在拦,不是运行时错误------代码逻辑没问题,但 lint 不过就发布不了。修完之后数据终于写进云端表,查表确认 15 条记录,读写双向皆通。

但新的问题来了。


坑 3:多用户共用一张表

触发条件:上线之后发现,公开链接谁都能打开,所有人共用同一张数据表。

我的记账金额、健康数据,任何人拿到链接都能看到。这不是一个 bug,这是一个设计层面的问题:资料库的 page 是公网托管的,数据表没有用户隔离机制。SDK 查询是全表读取,不做按用户过滤。

根因:个人工具和多人工具是两种东西。

维度 个人工具 多人工具
数据存储 localStorage 够用 必须有后端
用户隔离 不需要 必须有账号体系
部署形态 本地文件 / 在线 page 需要认证服务

我一开始把"个人工具"当成"多人工具"来做,跳过了认证和数据隔离这一层。现在得补回来。

这次重构的方向:换一个带用户认证和数据隔离的后端。


坑 4:Supabase RLS 实测

触发条件:选了 Supabase 做后端,需要验证数据隔离是否真的生效。

Supabase 基于 PostgreSQL,提供 Row Level Security(RLS)做行级权限控制。建了 3 张表(打卡 health_checkins、收支 transactions、预算 budgets),每张加 user_id 列,开 RLS:

sql 复制代码
-- 启用行级安全
alter table health_checkins enable row level security;

-- 创建策略:用户只能读写自己的数据
create policy "用户只能读写自己的数据"
  on health_checkins for all
  using (auth.uid() = user_id)      -- 读取时过滤:只能看到自己的行
  with check (auth.uid() = user_id); -- 写入时校验:不能伪造别人的 user_id

两个子句的区别很关键:

  • using 控制读取 :查询时自动加上 WHERE auth.uid() = user_id,看不到别人的数据
  • with check 控制写入 :插入/更新时校验 auth.uid() = user_id,不能伪造 user_id 写别人的数据

用 Python 跑了端到端测试,验证四个场景:

测试场景 预期结果 实际结果
匿名读取 0 行 ✅ 0 行,RLS 生效
账号 A 写打卡 + 读 成功 ✅ 成功
账号 B 读 看不到 A 的数据 ✅ 0 行
账号 A 写 user_id = B 拒绝 ✅ 403 Forbidden

第 4 条是关键。没有 with check 子句,攻击者可以用合法登录态伪造 user_id,把数据写到别人的名下。前端过滤只是查询参数,不是权限边界;RLS 是数据库层面的硬隔离,绕不过去。

后端搞定了,接下来把网页版改成 Chrome 扩展。


坑 5:MV3 的 CSP 杀了内联 script

触发条件:把网页版改成 Chrome 扩展,manifest V3,popup 弹窗 360px。

第一版 popup.html 里直接内联 <script>,逻辑全写在 HTML 里。加载扩展,点登录按钮,没反应。打开 DevTools 看:控制台不报错,网络面板没请求,按钮像死的。

根因 :MV3 的默认 CSP 是 script-src 'self'禁止内联 script

html 复制代码
<!-- ❌ MV3 下这段代码不会执行,静默失败,不报错 -->
<script>
  console.log('hello'); // 你看不到这行输出
  document.getElementById('login').addEventListener('click', login);
</script>
html 复制代码
<!-- ✅ 正确:提取到外部文件 -->
<script src="popup.js"></script>

这个问题隐蔽到什么程度------代码逻辑完全正确,fetch 调用没问题,Supabase 配置没问题,但脚本压根没跑。你能调出的所有错误都是"没反应"三个字。没有报错信息,没有异常堆栈,没有网络请求,因为脚本连第一行都没执行到。

为什么 MV3 要禁止内联 script? 这是 Chrome 的安全策略:防止 XSS 攻击者通过注入内联脚本劫持扩展的权限。扩展有比普通网页更高的权限(跨域访问、读写标签页),一旦被 XSS,后果比普通网页严重得多。所以 MV3 从 CSP 层面直接掐断了内联脚本的可能。

修复方法不复杂:把所有内联 JS 提取到外部 popup.js,HTML 里只留 <script src="popup.js"></script>。但排查过程花了不少时间,因为"静默失败"是调试中最恶心的模式。

扩展能正常跑了,接下来要做自动化测试。


坑 6:Chrome 扩展 ID 算法,Windows 上是 UTF-16LE

触发条件:为了做 CDP(Chrome DevTools Protocol)自动化测试,需要提前算出扩展 ID。

Chrome 扩展的 ID 是由扩展安装路径经过 SHA256 哈希后映射生成的,算法公开。网上能搜到的 Python 实现:

python 复制代码
import hashlib

p = r'C:\path\to\extension'
h = hashlib.sha256(p.encode('utf-8')).hexdigest()[:32]
eid = ''.join(chr(int(c, 16) + ord('a')) for c in h)

算出来跟 Chrome 实际加载的对不上。

查 Chromium 源码 extensions/common/id_util.cc,找到 GenerateIdForPath 函数。关键在编码方式:Windows 上 base::FilePath::StringTypestd::wstring(宽字符),SHA256 的输入是 UTF-16LE 编码的字节,不是 UTF-8。

python 复制代码
# ❌ Windows 上算出来的 ID 和 Chrome 实际不一致
h = hashlib.sha256(p.encode('utf-8')).hexdigest()[:32]

# ✅ Windows 正确写法:用 UTF-16LE 编码
h = hashlib.sha256(p.encode('utf-16-le')).hexdigest()[:32]

另外还有一个容易踩的小坑:映射规则是对 hex 字符串的每个字符+ord('a')(即 0-9a-fa-p),不是对字节拆 nibble。这两个错误叠加,算出来的 ID 差了十万八千里。

正确且跨平台的实现

python 复制代码
import hashlib
import sys

def get_extension_id(path: str) -> str:
    # Windows 用 UTF-16LE,其他平台用 UTF-8
    encoding = 'utf-16-le' if sys.platform == 'win32' else 'utf-8'
    h = hashlib.sha256(path.encode(encoding)).hexdigest()[:32]
    return ''.join(chr(int(c, 16) + ord('a')) for c in h)

# 用法
eid = get_extension_id(r'C:\path\to\extension')
print(eid)  # 输出 32 位字母 ID,和 chrome://extensions 显示的一致

ID 算对了,CDP 测试环境搭起来了......但下一个坑在等着。


坑 7:headless 模式不支持加载扩展

触发条件 :用 Chrome --headless=new + --load-extension 起 CDP 测试环境,自动化验证扩展弹窗。

扩展列表里只有系统自带的 Google Hangouts 的 service worker,我的扩展根本没加载。--load-extension 参数被静默忽略了。

根因:Chrome headless 模式不支持加载扩展。这是设计如此,不是 bug。Chrome 团队认为 headless 模式用于自动化测试和 CI/CD,加载扩展会引入不确定行为,所以从架构层面禁掉了。

这坑的代价是浪费了一小时做 CDP 自动化测试,最后发现被测对象根本没启动。最终方案:放弃自动化,人工到 chrome://extensions 开 Developer mode → Load unpacked → 手动点。

教训:在投入时间做自动化之前,先确认被测对象在目标环境下能正常加载。一步验证省一小时。


上架:manifest 加 key + 4 张截图

扩展能正常用了,开始准备上架 Chrome Web Store。

1. manifest 加 key 字段

生成一个 RSA 2048 公钥,DER 编码后 base64,写进 manifest 的 key 字段。作用是锁定扩展 ID------本机加载和商店审核时 ID 一致,用户更新不断链。

python 复制代码
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import base64, hashlib

# 生成 RSA 2048 密钥对
priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)

# 提取公钥的 DER 编码(SPKI 格式)
spki = priv.public_key().public_bytes(
    encoding=serialization.Encoding.DER,
    format=serialization.PublicFormat.SubjectPublicKeyInfo
)

# 用公钥的 SHA256 生成扩展 ID(验证用)
eid = ''.join(chr(int(c, 16) + ord('a')) for c in hashlib.sha256(spki).hexdigest()[:32])

# base64 编码后的公钥,贴进 manifest.json 的 "key" 字段
pub_b64 = base64.b64encode(spki).decode()
print(f"Extension ID: {eid}")
print(f"Key (paste into manifest.json):\n{pub_b64}")
json 复制代码
{
  "manifest_version": 3,
  "key": "<上面输出的 pub_b64>"
}

不加 key 也行,但每次换路径 ID 都会变,用户更新会断。加了 key,ID 永远固定。私钥自己存好,丢了就再也换不了 key 了。

2. 打包 zip

只含必要文件:

文件 说明
manifest.json 扩展清单
popup.html 弹窗界面
popup.js 弹窗逻辑
icon-16.png 16×16 图标
icon-32.png 32×32 图标
icon-48.png 48×48 图标
icon-128.png 128×128 图标

不能含 README.mdPRIVACY.md 这些辅助文件------商店解析 zip 时会按 manifest 声明的文件列表加载,多余文件会导致解析失败。

3. 商店截图

4 张 1280×800 的操作截图 + 1 张 440×280 的 promo tile。用 Python PIL 画的,模拟扩展弹窗的实际界面。商店要求至少 1 张截图,建议 3-5 张覆盖核心功能场景。

4. 隐私声明

Chrome Web Store 要求披露数据收集情况。这个扩展只收集:

  • 账号信息(邮箱 + 加密密码,通过 Supabase Auth)
  • 用户主动输入的打卡/记账数据

不读浏览历史,不跨站追踪,不上传第三方。填完隐私问卷,提交审核。


总结:7 个坑的根因分布

回头看,这 7 个坑的根因分布在完全不同的层面:

# 根因层面 关键认知
1 在线 page 不同步 架构 静态托管 ≠ 应用后端
2 SDK lint 陷阱 工具链 静态分析规则 ≠ 运行时逻辑
3 多用户共用表 设计 个人工具 ≠ 多人工具
4 Supabase RLS 安全 前端过滤 ≠ 权限边界
5 MV3 CSP 平台限制 安全策略导致静默失败
6 扩展 ID 编码 平台差异 Windows 路径编码是 UTF-16LE
7 headless 不加载扩展 平台限制 被测对象在目标环境不可用

没有一个坑是"代码写错了"------每个坑都是认知盲区。你以为的运行环境和你实际面对的运行环境之间有 gap,这个 gap 不踩看不出来,踩完之后才有资格说"我知道了"。

材料打包好了,zip 14KB。

Chrome Web Store 审核 1-3 个工作日,一次性注册费 $5。Edge 商店对 MV3 兼容,同一个 zip 直接传,免注册费。

同一个 HTML 文件,从 localStorage 走到 Chrome 商店,中间被 7 个坑逼着重构了 7 次。但每个坑都让架构往前走了一步:从无后端到有后端,从无隔离到有隔离,从网页到扩展,从临时 ID 到固定 ID。

等审核结果。

相关推荐
月読h2 小时前
# Agent 的自主执行与工程决策记录:读 Anthropic 和 AWS ADR
agent·测试
特立独行的猫a2 小时前
仓颉语言开发踩坑记录--基于仓颉语言实现的 Harness实战
ai·agent·仓颉·cangjie·harness
武子康2 小时前
9/10 和 900/1000 都是 90%:机器人成功率计算器必须阻止的误判
人工智能·llm·agent
武子康2 小时前
第一次用 SGLang:把本地模型接进聊天应用
人工智能·llm·agent
星期一研究室3 小时前
我们开发了一个「杂志封面.skill」
agent·ai编程·设计
极小狐3 小时前
用 Flow 编排 Agent 智能体:极狐GitLab Duo 自定义工作流实战
ai·gitlab·agent·devops·flow·duo
程序员柒叔3 小时前
Dify -- 定时任务系统
人工智能·github·agent·dify·rag
MicrosoftReactor3 小时前
技术速递|解码 AI 新术语:Loops、Harnesses、Squads、Hill Climbing……到底都是什么?
人工智能·agent·harness
寻道码路4 小时前
大模型工程化实战(八):OTel + LangFuse 全链路 Trace——把网关、护栏、SchemaGate 全部打点串起来,出问题一眼定位到环节
大模型·agent·mlops·rag·ai工程化·langfuse·llm可观测