零依赖手写前端:caveman书签小工具项目复盘 最近手头有个小活儿项目代号就叫 caveman。收到这个名字的时候我心里大概就有数了——这不会是什么现代化全家桶工程发起人的意思很直白像原始人拿石头砸钉子一样怎么直接怎么来。放到开发里就是把不必要的复杂度全部砍掉先让东西能用再考虑好看不好看、优雅不优雅。说实话caveman 作为网络热词本来就有“用最笨办法解决问题”的味道。这个项目要做的东西也不复杂一个团队内部用的书签收藏工具把大家平时散落在各个地方的工具网站、文档链接、参考资料统一收进来支持关键词搜索和快速打开。需求听起来简单但真做起来几个容易想当然的地方把人坑得够呛。这篇就当作一次完整的项目复盘把我怎么定方案、怎么写、踩了哪些坑全倒出来希望可以给同样不打算上框架、纯手写前端的同学做份参考。1. caveman 作为一个代号我接到的需求长什么样1.1 “像穴居人一样简单”不是贬义项目要求其实只有三条任何人都能通过浏览器访问不需要登录打开就是可用状态。支持新增、删除、搜索、复制链接这四个核心动作。数据要能长期保存不能刷新页面就丢。当时同事给出的“友好建议”不少有人建议直接开个带数据库的后台管理系统有人建议用一套现成的静态网站生成器还有人建议干脆做进公司现有的知识库插件里。这些方案都不错但问题是它们的维护成本全都压在我们这个小团队身上。如果需求本身只需要一个“能用的列表”那我为什么非要给它套上数据库、构建工具、权限系统这一整套装备caveman 的约束实际上是很好的过滤器它逼着我重新想一遍什么功能是必需的什么是我为了“显得专业”而硬加进去的。真正的核心需求只有一个——增删查加一个可持续存储。后面所有技术选型都是围绕这个核心展开的。1.2 先列需求再谈技术别让框架决定方案做这个项目时我养成了一个习惯第一件事不是去 GitHub 搜项目模板而是拿着纸笔把使用场景完整写一遍。一边写一边记了几个具体问题使用的人是谁团队内部大概 20 个人技术背景参差不齐有人根本不知道浏览器开发者工具怎么开。使用频率多高不是每天重度使用更像是一个“偶尔来翻一下”的工具书。有多少条数据初期最多几百条撑死也不到一万条根本不需要数据库去处理复杂关联。访问设备有哪些绝大部分是公司配的笔记本电脑和手机上。把这些答案摆到桌面上“零依赖、单文件、纯前端”这个方向就很清晰了。不是说上框架就丢人而是这个问题根本不需要框架。大部分所谓的技术选型困境本质上都是需求没有想清楚。需求里没有提的就是不需要的这是我后来处理类似项目一直沿用的一条原则。2. 零依赖技术栈选型前我做了四件不讨好但有用的事2.1 兼容性边界直接定成“现代浏览器的 ES5 以上”很多项目死就死在兼容性上。一开始为了迁就某些老同事我差点把代码写成 2009 年的风格连Array.find都不敢用。后来仔细确认了一下公司内部浏览器最低版本是 Chrome 90 和 Safari 14。这就好办了ES 语法随便用localStorage、URL、FormData、navigator.clipboard这些 API 全都有原生支持。没有构建工具帮忙降级所以这个兼容性目标一定要在开始在写代码之前写进 README 的第一行。不然写着写着突然想用某个新 API回头再改兼容性边界恶心的是自己。2.2 存储方案的取舍localStorage 比后端数据库更适合这里项目要不要上后端我纠结了一阵。后来被一个问题点醒如果第二天服务器崩溃了这个工具还能不能正常工作对内部小工具来说数据存在哪里、谁负责备份其实比功能本身更影响长期维护成本。最后用的localStorage理由很实际数据量小几百条书签撑死了几十 KB。不需要用户登录也没法做账号体系放服务器上反而要解决鉴权问题。团队用的是一个固定的内网环境不存在多浏览器同步的需求。这里面有个常见的错误认知觉得localStorage是“临时性数据”刷新就没了。其实不是只要不主动清除它跟 Cookie 一样是持久存在的。真正要补的是异常处理比如 Safari 的隐私模式或者用户手动清空缓存。我在代码里把读取、写入都包了 try-catch出错了至少不会让整个页面白屏。2.3 性能预算让首屏渲染尽量接近“秒开”没有框架不代表可以不考虑性能。这个项目最核心的交互就是搜索而搜索是实时触发再渲染的如果每次输一个字符都让页面卡一下用起来会非常难受。所以我在需求阶段就写了一个硬指标页面加载后 300 毫秒内完成首次渲染输入关键字后列表刷新不超过 100 毫秒。这个预算很容易达到但有一个步骤是刻意避开的——没有直接渲染全部数据因为书签多了以后每次都生成几百个 DOM 节点再快也会产生肉眼可见的顿挫。我做了两个处理搜索时先通过filter把数据层过滤完再一次性渲染输入框做 150 毫秒防抖避免每次按键都触发渲染。这种性能预算是写给自己的“防御底线”不然写代码的时候容易胡来。比如有人可能会顺手把整个列表重新插入页面做得多了页面卡得让人怀疑人生。2.4 退出机制要是上框架能省多少事选型最后一步我做了一个“反向评估”假设非要用前端框架能省多少工作量结论很有意思框架主要帮我解决的是状态同步和 DOM 更新但这个项目的数据结构太简单了——一个数组、一个关键字、一个增删操作手写状态管理也就几十行。如果用框架为了跑起来还得引入构建工具、安装依赖、处理版本升级。算下来上框架的时间成本可能比手写还高。当然这不等于退回了“辣眼睛时代”。我用的是最基础的数据驱动渲染思路状态变化后把列表整体重新渲染。这是最贴近古典 DOM 操作的写法又比命令式地一节一节去操作节点清爽得多。后来验证下来写起来确实丝滑。3. 手写核心逻辑筛选、收藏、持久化3.1 数据结构设计用版本号保护未来零依赖项目的第一道坎是数据结构没人替你兜底。要是存进去的数据格式写错了后面再加功能就要开始写数据迁移脚本。我这个项目虽然简单也照样留了心眼localStorage 的键名直接带上了版本号。const STORAGE_KEY caveman:bookmark:v1; let state { keyword: , items: [] };为什么这么设计举个例子第一版我可能只存title和url第二版突然想加一个desc字段。如果键名始终是同一个那么旧数据会被直接解析成一个没有desc字段的对象渲染时访问undefined还没事一旦要做排序、去重就出大问题。带版本号之后每次结构变化就换一个新键名新版读不到旧数据也没关系顶多让用户体验一次“重新添加”总比后台静默出错好。实际初始化数据的函数长这样专门处理“首次访问”和“数据损坏”两个场景function loadState() { try { const raw localStorage.getItem(STORAGE_KEY); if (!raw) { return { items: [] }; } const data JSON.parse(raw); if (!Array.isArray(data.items)) { return { items: [] }; } return data; } catch (e) { // 数据一旦解析失败宁可清空也不要让页面崩溃 return { items: [] }; } }这个方法看起来啰嗦但它是整个工具稳定性的地基。浏览器里的存储数据是不可信的用户可能删了中间的某个引号也可能导入了别处的数据一切都要做最坏打算。3.2 渲染与事件委托增删查三板斧这个工具没有用任何前端框架所有页面渲染都是我手动拼字符串实现。最开始我当然也试过用createElement逐个建节点但那样子代码会像老太太的裹脚布又臭又长。后来我选择了一种折中方案用innerHTML整体渲染但所有动态内容都经过转义函数防止用户把恶意脚本直接塞进页面上。function escapeHTML(str) { return String(str).replace(/[]/g, function (char) { return { : amp;, : lt;, : gt;, : quot;, : #39; }[char]; }); }然后是一个简洁的渲染函数根据关键字对数组做过滤function render() { const list document.querySelector(#bookmark-list); const keyword state.keyword.trim().toLowerCase(); const visible state.items.filter(function (item) { return item.title.toLowerCase().indexOf(keyword) ! -1 || item.url.toLowerCase().indexOf(keyword) ! -1; }); if (visible.length 0) { list.innerHTML li classempty没有找到匹配的书签/li; return; } list.innerHTML visible.map(function (item, index) { return [ li, a href escapeHTML(item.url) target_blank relnoopener escapeHTML(item.title) /a, span classdesc escapeHTML(item.desc || ) /span, button typebutton>document.querySelector(#bookmark-list).addEventListener(click, function (event) { const button event.target.closest(button[data-action]); if (!button) return; if (button.dataset.action remove) { const index Number(button.dataset.index); state.items.splice(index, 1); saveState(); render(); } });新增和保存的逻辑也不复杂但有一个细节值得单独拎出来说输入框里的网址有可能是用户随手打的不一定带协议头。所以我加了一个normalizeUrl缺省情况直接补全https://省去用户少打几个字造成打不开的尴尬。function normalizeUrl(value) { let trimmed value.trim(); if (!trimmed) return ; if (!/^https?:\/\//i.test(trimmed)) { trimmed https:// trimmed; } try { return new URL(trimmed).href; } catch (e) { return ; } }关于new URL这个 API我多说一句它本身就会对.com、.cn这种没有协议的输入做非常严格的解析把它当成网址规范化的最后一道防线可以筛掉绝大多数“假网址”。之前我见过有人用正则去匹配 URL写了一大坨最后还是漏了很多边缘情况不如系统自带的解析器可靠。3.3 搜索交互的防抖细节搜索框我单独用了 150 毫秒的防抖避免每次按键都立即触发render那样会在快速打字时造成布局抖动。这里顺手给一个标准防抖实现以后任何输入搜索都能用let searchTimer null; searchInput.addEventListener(input, function (event) { clearTimeout(searchTimer); searchTimer setTimeout(function () { state.keyword event.target.value; render(); }, 150); });用防抖的代价是反馈延迟了 150 毫秒这个体感几乎察觉不到但换来的是渲染次数大幅减少。尤其是以后数据量涨到几千条时这个设计就非常关键了。3.4 数据安全把状态保存到一个入口整个项目里改数据的地方只有三处新增、删除、迁移。我特意没有在每个函数里直接调localStorage.setItem而是封装了一个统一的saveState这样以后如果想加“导入导出”“云端同步”只需要在这个函数内部扩展就行。function saveState() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) { console.error(书签保存失败, e); alert(保存失败请检查浏览器是否允许本地存储。); } }这里特别处理了QuotaExceededError。在隐身模式、浏览器存储被禁用、或者存储空间不足的情况下setItem会直接抛异常而不给任何警告如果不处理用户会以为保存成功刷新后数据却没了。做本地存储应用这个坑几乎天天有人在踩。4. 没有构建工具我的日常调试节奏4.1 文件组织让浏览器真的能直接打开项目最终是一个文件夹里面就四个东西caveman/ index.html style.css app.js README.mdHTML 文件里通过link和script引入另外两个文件没有任何压缩、混淆、打包的过程。因为代码量不大文件少在浏览器里打开index.html就能跑。我趁这个机会把以前公司里从不让人看的各种精美网页还原了一遍。不过“双击打开”这种模式有一个小坑就是localStorage是绑定协议和域名的。直接在本地双击 HTML 文件时浏览器可能会用file://协议打开存储行为不稳定。为了贴近真实使用场景我去项目目录下起了一个静态文件服务器python3 -m http.server 8080这种方式零额外依赖任何装了 Python 的电脑都能跑用来调试刚合适。4.2 调试技巧把自己需要看的状态暴露到 window 上没有构建工具意味着没有热更新也没有强大的调试中间件。我的处理方式是“手动把它们全补回来”。首先项目开头的开发环境里我把内部状态直接挂到了window对象上取了个一目了然的名字window.cavemanDebug。window.cavemanDebug { state: state, save: saveState, render: render, load: loadState };这样在浏览器控制台里就能随时执行cavemanDebug.state.items.push({ title: 测试, url: https://example.com, desc: }); cavemanDebug.save(); cavemanDebug.render();这个习惯帮过大忙。有一次某个数据字段的渲染一直不出结果我直接在控制台里打印 state发现是desc字段拼错了名称而不是渲染逻辑的锅。在没有 Source Map、没有中间层的环境里把状态暴露出来是最快的路径。其次我还习惯在render函数里加一个非常不起眼的日志开关默认关掉需要排查时手动在控制台打开if (window.cavemanDebug.log true) { console.log([caveman] render with keyword:, state.keyword, visibleCount:, visible.length); }等一切都稳了之后这些代码并不会影响性能因为加了判断不打开日志时只是多了一次布尔判断对浏览器来说可以忽略不计。4.3 数据校验创建临时书签而不是直接在代码里写死开发初期我给列表手动加过两条“假数据”来测试渲染。后来我发现直接在代码里写死测试数据容易忘记清理而且一改就影响真实数据。最终解决方案是写了一个“开发专用填充函数”但它只会在 local storage 为空时执行function seedIfEmpty() { const saved localStorage.getItem(STORAGE_KEY); if (saved) return; state.items [ { title: 示例掘金, url: https://example.com, desc: 相对引用地址示例 }, { title: 示例Stack Overflow, url: https://example.com/questions, desc: 开发者常用问答站 } ]; saveState(); }这段代码我把它包在开发注释区里上线前会手动从文件里移除保证用户拿到的版本是干净的首屏。5. 踩坑记录你以为的“零依赖”其实到处都是雷5.1 localStorage 的配额、隐身与跨域localStorage 的坑是我整整花了一个下午才踩完的。最典型的是 iPhone 上的 Safari 隐私模式localStorage.getItem可以调用也不会报错但一旦调用localStorage.setItem就会抛出 QuotaExceededException。如果你不做任何防御页面会非常安静地失败用户完全无感知直到关闭浏览器才发现所有刚存的网址都没了。解决方式是写入时捕获异常并且提示用户。同时我还在saveState里做了一个自检写入后立刻读回来校验一遍。虽然会多读一次存储但对这种极低频率的写入操作来说完全无所谓。function saveState() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); const back JSON.parse(localStorage.getItem(STORAGE_KEY)); if (!back || !Array.isArray(back.items)) { throw new Error(verify failed); } } catch (e) { alert(本地存储不可用换用普通浏览器模式再来。); } }另外localStorage 是有跨域隔离的。如果以后想把这个工具部署到https://bookmark.example.com那用户之前在https://example.com下的数据不会跟着过去。真要跨域名迁移就得做导出导入功能。这一点项目开始部署前要约定好否则多半必踩。5.2 复制链接功能剪贴板 API 需要 HTTPS需求里有“一键复制链接”。我第一版直接用了navigator.clipboard.writeText在本地调试一切正常。结果部署到公司内网测试环境同事反馈点击复制没有反应。一查才知道剪贴板 API 只有在安全上下文HTTPS 或 localhost下才能用而内网 IP 地址恰好既不是 HTTPS 也不被当成 localhost。这里没有退路只能加一层兜底。我采用的方法是用隐藏的textarea加上document.execCommand(copy)。这个 API 早就被标记为废弃但它仍然在绝大多数浏览器里能正常工作尤其是这种内网场景属于“老而弥坚”的方案。async function copyTextToClipboard(text) { if (navigator.clipboard window.isSecureContext) { try { await navigator.clipboard.writeText(text); return; } catch (e) { // 继续走下面的绕行方案 } } const textarea document.createElement(textarea); textarea.value text; textarea.style.position fixed; textarea.style.opacity 0; document.body.appendChild(textarea); textarea.select(); try { document.execCommand(copy); } finally { document.body.removeChild(textarea); } }这个方案也顺便解决了一个更隐蔽的问题有些浏览器在非用户手势环境下会拒绝剪贴板写入而textarea.select加execCommand的老路往往能绕过这个限制。做“复制到剪贴板”这种功能强烈建议直接把这个双保险写上别偷懒。5.3 删除错位、旧数据格式与 URL 解析的连锁问题删除按钮的>