1. 用一个周末把博客站做出来原生 Web 三件套的取舍与边界做个人博客系统页面搭建这件事最容易被带偏的地方不是写不出来而是还没开始就先把脚手架装上。我见过太多人在第一步就卡住为了一个只有十几篇文章的个人站装 Node、配构建、调依赖、处理版本冲突两天过去首页还是白屏。等你终于跑起来写文章的热情已经消耗掉一半。所以这次分享的方案很直接HTML 负责结构、CSS 负责视觉、JavaScript 负责交互也就是常说的 Web 三件套不引入任何框架和构建工具双击index.html就能看效果扔进任意静态托管空间就能上线。这不是什么情怀而是对个人博客这个具体场景最合理的技术选择。这篇文章要讲的是一个完整的、能跑起来的个人博客系统页面包含首页文章列表、文章详情页、标签筛选、关键词搜索、深浅色主题切换、文章目录自动高亮、代码块一键复制、移动端响应式适配这几个模块。文中的源码可以直接拿走改也可以当模板往上叠功能。适合的人群有三类正在做课设或毕业设计、需要一个能看懂全流程的博客前端的同学想给自己的笔记找一个轻量展示出口、又不想维护复杂工程的开发者以及已经用过框架但想搞清楚那些框架帮你做的事具体是怎么实现的同行。如果你属于第三类后面的原生 DOM 渲染、状态持久化、滚动监听那几段会更有味道。1.1 这套方案能撑住什么撑不住什么先把边界说清楚免得你照着做完发现期望错位。原生三件套的个人博客系统舒适区是静态内容 少量交互文章数量在几十到一两百篇之间更新频率不高不需要登录和后台不需要实时评论和协同编辑。在这个范围内它的优势非常明显——首屏只有三个请求HTML、CSS、JS没有任何运行时开销加载速度比大部分框架站点都快而且十年后你回来改它依然能一眼看懂。不舒适的地方也要提前承认。文章正文如果多到几百篇把所有内容塞进一个 JS 文件会导致首屏加载的文件体积过大这时候就需要按需加载或者干脆改成分页。另外原生方案没有组件复用机制如果你希望页头、页脚、侧边栏在多页面间保持一致要么复制粘贴要么用一小段 JS 在页面加载时注入公共片段——文中的做法是后者。最后一个限制是 SEO纯前端渲染的内容对搜索引擎的友好度不如服务端直出不过对个人博客来说真正重要的那几十篇文章完全可以用静态 HTML 单独生成一份这个后面会讲。提示判断要不要上框架我自己的标准是交互状态是否复杂到需要统一状态管理。博客站的交互状态只有当前主题、当前筛选标签、当前搜索词这三个塞进闭包变量就够了没必要为此引入一整套响应式系统。1.2 目录结构三件套也要有工程感虽然不用构建工具但文件划分不能乱。乱放的后果是三个月后你想加个功能得先花半小时回忆哪个文件负责什么。我采用的是下面这种扁平结构没有任何深层嵌套看一眼就知道东西在哪blog/ ├── index.html # 首页文章列表 ├── post.html # 文章详情页 ├── about.html # 关于页 ├── css/ │ ├── reset.css # 归零样式 │ └── style.css # 主题变量与全站样式 ├── js/ │ ├── theme.js # 主题切换放 head 里同步执行 │ ├── data.js # 文章数据 │ ├── list.js # 列表页渲染与筛选 │ ├── post.js # 详情页渲染与目录高亮 │ └── common.js # 公共片段注入、回到顶部 └── assets/ └── avatar.jpg这里有一个关键决定theme.js必须放在head里并且同步执行不能加defer也不能挪到/body前面。原因是浏览器解析到style后就会开始渲染如果这时候>// js/data.js const POSTS [ { id: build-blog-with-vanilla-js, title: 用原生三件套搭一个博客站, date: 2024-05-18, tags: [前端, 实战], summary: 不装框架从零搭出可用的博客页面包含列表、详情、筛选与主题切换。, content: p这里是正文可以用 HTML 片段直接书写。/p h2第一个小标题/h2 p正文内容……/p }, { id: css-variables-notes, title: CSS 变量的几个实用姿势, date: 2024-06-02, tags: [CSS, 笔记], summary: 用一套变量撑起整站配色与间距改一处全站生效。, content: p正文内容……/p } ];注意content字段里我存的是 HTML 字符串而不是 Markdown。这个选择有取舍存 HTML 意味着写作时得手写标签但省掉了在浏览器里跑 Markdown 解析器这一整套东西页面加载更快也不会有解析失败的风险。如果你更习惯 Markdown可以在构建阶段比如用一段 Node 脚本把.md转成 HTML 再写进data.js开发体验和运行性能都不牺牲。2. 页面骨架从内容模型倒推 HTML 标签怎么写写 HTML 最常见的错误是想到哪写到哪先把 div 堆起来再想每个 div 装什么。这样写出来的结构后面加功能时会特别难受因为你不知道自己该往哪个层级的哪个节点挂事件。我的做法是先画数据流一篇文章在列表页要展示标题、日期、标签、摘要四项在详情页要展示标题、日期、标签、正文、目录五项。把这十个信息点列出来HTML 结构基本上就定型了剩下的只是给它们找个合适的容器。2.1 首页列表的骨架首页的职责很单一把文章按时间倒序排出来并提供筛选和搜索入口。所以结构上分成三段——顶部导航、筛选区、文章列表。筛选区里的标签按钮不是写死的而是由 JS 从数据里把所有tags去重后自动生成这样你新增一篇文章、打了个新标签筛选栏里就会自动多出来一个按钮不用回来改 HTML。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1 / title我的博客/title link relstylesheet href./css/reset.css / link relstylesheet href./css/style.css / script src./js/theme.js/script /head body header classsite-header a classlogo href./index.html我的博客/a nav classsite-nav a href./index.html首页/a a href./about.html关于/a button idthemeToggle classicon-btn typebutton aria-label切换主题主题/button /nav /header main classcontainer section classtoolbar input idsearchInput classsearch typesearch placeholder搜索标题或摘要 / div idtagBar classtag-bar/div /section section idpostList classpost-list aria-livepolite/section p idemptyTip classempty-tip hidden没有匹配的文章/p /main footer classsite-footer p© span idyear/span 我的博客/p /footer script src./js/data.js/script script src./js/common.js/script script src./js/list.js/script /body /htmlaria-livepolite这个属性值得单独说一句。它告诉屏幕阅读器这块区域的内容会动态变化变化后请朗读出来。搜索时列表被替换加了这个属性视障用户就能听到结果条数的变化。加它只花两秒钟但这就是能用和好用之间的差别。span idyear/span也是同样的思路页脚年份交给 JS 填省得每年一月回来手动改一次。2.2 详情页的骨架与目录容器详情页比列表页多一个东西右侧的目录Table of Contents。目录的每个条目对应正文里的一个标题点击可以跳转滚动时会自动高亮当前所在章节。要让它工作正文里的每个标题必须有稳定的id而且这个id不能手写——手写迟早会漏、会重复。所以我在渲染正文之后用 JS 扫描一遍所有h2和h3给没id的自动补一个同时生成目录数据。main classcontainer post-layout article classpost h1 idpostTitle/h1 div classpost-meta time idpostDate/time span idpostTags classtag-bar/span /div div idpostContent classpost-content/div /article aside classpost-toc p classtoc-title目录/p nav idtocList/nav /aside /main页面怎么知道该显示哪篇文章用的是 URL 查询参数形如post.html?idbuild-blog-with-vanilla-js。相比 hash 路由查询参数的好处是分享链接更直观而且在部分静态托管平台上同一路径带不同参数不会被当成不同页面重复收录。读取只需要一行const id new URLSearchParams(location.search).get(id); const post POSTS.find(p p.id id);如果post是undefined比如用户手动改了参数页面会显示一个文章不存在的提示并给个返回首页的按钮。这个兜底逻辑很多人不写结果访问一个失效链接就是满屏空白体验很差。2.3 语义化标签带来的实际好处有人觉得用div加 class 和用section、article、nav没什么区别反正样式都靠自己写。短期看确实没区别但有两个场景会立刻体现出差异。第一是屏幕阅读器用户可以按快捷键在各个 landmark 之间跳转直接跳到正文或者导航第二是你在浏览器里打开阅读模式语义良好的页面能被正确识别出正文和标题层级语义混乱的页面提取出来的内容会夹杂导航和页脚。我在这个项目里的语义约定是站点级的头部用header主导航用nav主要内容用main每篇文章用article相关的一组内容用section页脚用footer。标题层级严格不跳级——详情页的h1是文章标题正文里的章节从h2开始子节用h3。顺带一提正文的h2不要从h3起步否则目录生成出来会缺一层视觉上也奇怪。3. 样式层一套 CSS 自定义属性撑起整站视觉CSS 部分我踩过的最大弯路是边写边定颜色。写到一半发现某个灰不好看回去全局替换换了七八处之后发现漏了两处页面上的灰色变得深浅不一。后来统一改成先在:root里定变量所有地方只引用变量这个问题就彻底消失了。这不只是省事的问题它还让暗色模式这种需要整体换色的需求变得极其简单——只要重新定义一组变量值所有引用变量的地方自动跟着变。3.1 变量命名与配色约定变量命名我采用用途 变体的方式而不是颜色 深浅。--brand比--blue-500好因为将来你把品牌色改成绿色的时候不需要把变量名和它的含义一起改。下面这套是我用得比较顺手的配置可以直接抄:root { --bg: #ffffff; --bg-soft: #f6f7f9; --text: #1f2328; --text-soft: #5b6472; --border: #e3e6ea; --brand: #2f6fed; --code-bg: #f3f4f6; --radius: 10px; --maxw: 860px; --space-1: 4px; --space-2: 8px; --space-3: 16px; --space-4: 24px; --space-5: 40px; --font-sans: -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Microsoft YaHei, sans-serif; --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } html[data-themedark] { --bg: #14171a; --bg-soft: #1b1f23; --text: #e6e8eb; --text-soft: #9aa4b2; --border: #2a2f36; --brand: #6ea8fe; --code-bg: #1e2227; }间距变量按 4 的倍数递增不是强迫症而是因为 8px 的倍数在双眼屏和高分屏上都不会出现半像素模糊。字号我没有做成变量因为博客站的字号层级非常固定把它变量化反而增加阅读成本正文 17px、行高 1.75、小标题 1.4 倍、文章大标题 2 倍这几个数字写死在样式里就够了。--font-sans里把系统字体放在最前面是为了让中文和英文都优先使用系统自带字体。这样做的好处是页面不依赖任何外部字体文件没有字体加载导致的文字闪烁首屏渲染速度也更快。macOS 上会命中苹方Windows 上会命中微软雅黑视觉上略有差异但对个人博客来说完全可接受。3.2 Grid 和 Flex 各自负责什么布局这件事Flex 和 Grid 的分工要明确混着用会让代码越来越乱。我的原则很简单一维排列用 Flex二维布局用 Grid。导航栏的 logo 和菜单是横向一维排列用 Flex首页的文章列表是一维垂直堆叠也可以用 Flex 或者直接块级元素详情页的正文 侧边目录是两列布局这是二维用 Grid。.container { width: 100%; max-width: var(--maxw); margin: 0 auto; padding: 0 var(--space-3); } /* 详情页两列布局正文自适应目录固定宽 */ .post-layout { display: grid; grid-template-columns: minmax(0, 1fr) 200px; gap: var(--space-5); align-items: start; } /* 小屏收成一列目录挪到正文下方 */ media (max-width: 900px) { .post-layout { grid-template-columns: 1fr; gap: var(--space-3); } .post-toc { order: 2; } }这里有两个容易忽略的点。第一grid-template-columns里用的是minmax(0, 1fr)而不是1fr。区别在于1fr的最小值是auto如果正文里有一个超长的代码块或者一张大图它会把整个网格撑宽导致横向滚动条出现写成minmax(0, 1fr)就把最小值压到 0超出部分交给代码块自己的滚动条处理。第二align-items: start让两侧内容都顶对齐否则目录会被拉伸到和正文一样高看起来很奇怪。文章列表项的布局则用 Flex因为它是左边内容自适应、右边日期固定宽的一维结构.post-item { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-3); padding: var(--space-3) 0; border-bottom: 1px solid var(--border); } .post-item .title { flex: 1; min-width: 0; } .post-item time { flex: none; color: var(--text-soft); font-size: 14px; }min-width: 0又是同一个坑的另一面Flex 子项默认最小宽度是内容宽度长标题会把日期挤出容器加了这个属性之后标题才能被正常截断或换行。这个属性我几乎在每个 Flex 文本容器上都会加。3.3 主题切换的样式实现上一节把暗色的变量值定好了但还差一步让html上的>body { background: var(--bg); color: var(--text); font-family: var(--font-sans); font-size: 17px; line-height: 1.75; transition: background-color .2s ease, color .2s ease; }关于transition有一个争议点给body加颜色过渡会让主题切换很顺滑但也会让页面首次加载时出现从白色渐变到暗色的效果如果theme.js执行得够早这个渐变其实看不到。我的做法是保留transition因为大部分用户感知不到首次加载的那一瞬间而切换主题时的顺滑感是能明显感知到的。如果你追求极致可以在html上加一个preload类加载完成后再移除用来临时禁用过渡这里不展开。3.4 中文排版的几个细节中文网页的排版有很多默认值其实是错的需要手动覆盖。第一是行高浏览器默认的normal大概在 1.2 左右中文方块字在这个行高下会显得拥挤正文用 1.75、注释类文本用 1.6 比较舒服。第二是段落间距中文习惯用段间距而不是首行缩进来区分段落所以p上要加margin-bottom同时把text-indent保持为 0。.post-content p { margin: 0 0 1.2em; } .post-content h2 { font-size: 1.4em; margin: 2em 0 .8em; padding-bottom: .3em; border-bottom: 1px solid var(--border); scroll-margin-top: 80px; /* 锚点跳转时给吸顶导航留位置 */ } .post-content h3 { font-size: 1.15em; margin: 1.6em 0 .6em; } .post-content img { max-width: 100%; height: auto; border-radius: var(--radius); } .post-content pre { background: var(--code-bg); padding: var(--space-3); border-radius: var(--radius); overflow-x: auto; /* 关键代码块自己横向滚动 */ font-family: var(--font-mono); font-size: 14px; line-height: 1.6; } .post-content code { font-family: var(--font-mono); font-size: .92em; }scroll-margin-top是个特别实用但知道的人不多的属性。页面顶部有吸顶导航时点击目录跳转到某个标题标题会被导航挡住一半。以前的做法是给标题加padding-top再用负margin-top抵消现在一行scroll-margin-top就解决了而且只影响锚点定位不影响视觉间距。overflow-x: auto加在pre上而不是body上是为了让长代码行只让代码块自己滚动整个页面不会横向漂移。这是移动端阅读体验的关键——如果给body加了overflow-x: hidden来掩盖问题代码块里的内容就被裁掉了用户根本看不到。正确的做法是让代码块内部滚动。4. 交互层原生 JS 把静态页变成可用的博客系统到这一步页面已经能看了但它还是死的——没有文章列表点了标签没反应主题切换按钮也不工作。这一章是整篇的核心讲清楚每一段 JS 到底在做什么、为什么这么写。我把它拆成四块数据渲染、筛选搜索、主题持久化、目录高亮。每块都控制在几十行加起来也不过两三百行比引入一整套框架省事得多。4.1 渲染列表模板字符串加文档片段最简单直接的渲染方式是用模板字符串拼 HTML然后一次性塞进容器的innerHTML。这种写法性能很好因为只触发一次 DOM 重排比循环appendChild快得多。但它有一个必须警惕的问题XSS。如果文章标题或摘要里含有script或者img onerror...直接插进innerHTML就会被浏览器当成标签执行。我这里的做法是在拼接之前对所有来自数据的文本做一次转义。// js/list.js const escapeHtml str String(str).replace(/[]/g, ch ({ : amp;, : lt;, : gt;, : quot;, : #39; }[ch])); const formatDate iso { const d new Date(iso); return ${d.getFullYear()}-${String(d.getMonth() 1).padStart(2, 0)}-${String(d.getDate()).padStart(2, 0)}; }; function renderList(list) { const box document.getElementById(postList); if (!list.length) { box.innerHTML ; document.getElementById(emptyTip).hidden false; return; } document.getElementById(emptyTip).hidden true; const sorted [...list].sort((a, b) b.date.localeCompare(a.date)); box.innerHTML sorted.map(post article classpost-item div classtitle h2a href./post.html?id${encodeURIComponent(post.id)}${escapeHtml(post.title)}/a/h2 p classsummary${escapeHtml(post.summary)}/p div classtag-bar ${post.tags.map(t span classtag${escapeHtml(t)}/span).join()} /div /div time datetime${post.date}${formatDate(post.date)}/time /article ).join(); }这里有两个细节值得掰开说。第一post.id用encodeURIComponent处理因为 id 我习惯用英文短横线拼写如果将来出现中文 id不编码会拼出非法 URL。第二排序用的是b.date.localeCompare(a.date)而不是new Date(b.date) - new Date(a.date)。因为日期格式统一是YYYY-MM-DD字符串比较的结果和日期比较完全一致而字符串比较不需要创建 Date 对象几十篇文章的差异可以忽略但写法更干净。如果日期格式不统一就必须老老实实转成时间戳。关于innerHTML和DocumentFragment的选择我补充一句实测结论在几十到几百条数据的量级下两者的性能差异肉眼不可感知选哪个主要看代码可读性。innerHTML的写法更短但每次更新都会销毁并重建整棵子树如果有节点上挂了事件监听或者有输入框焦点会丢失。所以我的原则是纯展示内容用innerHTML包含表单或者需要保持状态的区域用DocumentFragment逐个构建。4.2 标签筛选与关键词搜索的组合逻辑筛选和搜索这两个功能单独实现都很简单难的是它们要能叠加——选中前端标签之后再输入关键词结果应该是两个条件的交集。我的做法是把全部文章当作数据源每次交互时都从原始数据重新算一遍结果而不是在上一次的结果上继续过滤。后者看起来省事实际是坑搜完再点标签结果会越筛越少而且取消条件时无法恢复。let state { tag: , keyword: }; function applyFilter() { const kw state.keyword.trim().toLowerCase(); const result POSTS.filter(post { const tagOk !state.tag || post.tags.includes(state.tag); const kwOk !kw || post.title.toLowerCase().includes(kw) || post.summary.toLowerCase().includes(kw); return tagOk kwOk; }); renderList(result); } function renderTags() { const all [全部, ...new Set(POSTS.flatMap(p p.tags))]; document.getElementById(tagBar).innerHTML all.map(t { const value t 全部 ? : t; const active state.tag value ? active : ; return button classtag-btn${active} typebutton>// js/theme.js —— 必须放在 head 中同步执行 (function () { var saved null; try { saved localStorage.getItem(theme); } catch (e) { /* 隐私模式可能抛错 */ } var prefersDark window.matchMedia window.matchMedia((prefers-color-scheme: dark)).matches; document.documentElement.dataset.theme saved || (prefersDark ? dark : light); })();// js/common.js 中的切换逻辑 document.addEventListener(click, e { if (!e.target.closest(#themeToggle)) return; const root document.documentElement; const next root.dataset.theme dark ? light : dark; root.dataset.theme next; try { localStorage.setItem(theme, next); } catch (err) {} });try...catch包住localStorage的读写是因为在 Safari 的隐私浏览模式下某些版本访问localStorage会直接抛异常。不包的话theme.js一抛错后面的代码全都不执行页面直接白屏。这种在某些浏览器里才复现的问题本地开发时根本发现不了只有用户反馈上来才追得到所以防御性写法很有必要。顺便说一个进阶细节如果用户没有手动选过主题那么系统在用户浏览过程中切换到暗色比如到了日落时间自动切换页面应该跟着变。这需要监听matchMedia的change事件并且在用户手动选过之后停止跟随。实现方式是判断localStorage里有没有值window.matchMedia((prefers-color-scheme: dark)) .addEventListener(change, e { if (localStorage.getItem(theme)) return; // 用户已手动选择不跟随 document.documentElement.dataset.theme e.matches ? dark : light; });4.4 目录高亮用 IntersectionObserver 而不是 scroll 事件文章目录的高亮是详情页里最有技术含量的部分。早期我用的是监听scroll事件每次滚动都遍历所有标题计算它们的getBoundingClientRect().top找出第一个进入视口的标题。这个方法能跑但有两个问题滚动事件触发极其频繁每一帧都在做几十次布局计算长文章滚动时会有明显掉帧而且滚动位置和标题位置的计算依赖页面布局一旦有图片未加载完成计算结果会跳变。现在的做法是用IntersectionObserver它由浏览器在合成线程里统一调度不阻塞主线程性能好得多。思路是观察所有正文标题当某个标题进入顶部以下、底部以上这个判定区域时就把它标记为当前激活项。为了让判定更符合阅读直觉我把根边界的底部设成负 70%让标题只有在靠近页面顶部时才被算作当前章节。// js/post.js 中的目录生成与高亮 function buildToc(container) { const headings [...container.querySelectorAll(h2, h3)]; const tocList document.getElementById(tocList); const items headings.map((h, i) { if (!h.id) h.id h- i; // 自动补 id避免手写遗漏 const a document.createElement(a); a.href # h.id; a.textContent h.textContent; a.className toc-link (h.tagName H3 ? level-3 : ); a.dataset.target h.id; tocList.appendChild(a); return a; }); const map new Map(items.map(a [a.dataset.target, a])); const observer new IntersectionObserver(entries { entries.forEach(entry { if (!entry.isIntersecting) return; items.forEach(a a.classList.remove(active)); const active map.get(entry.target.id); if (active) active.classList.add(active); }); }, { rootMargin: -72px 0px -70% 0px, threshold: 0 }); headings.forEach(h observer.observe(h)); }rootMargin的-72px对应吸顶导航的高度让判定区域从导航下方开始。最后一个参数threshold: 0表示只要有 1 像素进入区域就算这样连续滚动时高亮切换很及时。这个方案有个已知的小缺陷当用户滚到文章最底部时判定区域内可能一个标题都没有高亮会停在上一个标题上。如果希望最后一段始终高亮最后一个标题需要在滚动到底部时手动处理或者在正文末尾补一个空的锚点元素参与观察。实践中这个缺陷影响很小我一般不处理。4.5 代码复制、回到顶部和公共片段注入剩下几个小功能都很短但少一个体验就缺一块。代码复制给每个pre动态插入一个复制按钮点击后用navigator.clipboard.writeText写入剪贴板。这里要注意剪切板 API 在非 HTTPS 环境下不可用本地用file://打开会失败所以必须有降级提示。function bindCopyButtons(container) { container.querySelectorAll(pre).forEach(pre { const btn document.createElement(button); btn.className copy-btn; btn.type button; btn.textContent 复制; btn.addEventListener(click, async () { const code pre.querySelector(code); const text (code || pre).innerText; try { await navigator.clipboard.writeText(text); btn.textContent 已复制; } catch (err) { btn.textContent 复制失败; } setTimeout(() { btn.textContent 复制; }, 1500); }); pre.appendChild(btn); }); }回到顶部按钮越简单越好用一个position: fixed的按钮滚动超过一屏时显示点击后window.scrollTo({ top: 0, behavior: smooth })。注意滚动监听也要节流或者干脆用IntersectionObserver观察页面顶部的一个哨兵元素在它不可见时显示按钮这样连滚动事件都不用监听。公共片段注入多页面共享的页头和页脚我用一小段 JS 在DOMContentLoaded时注入数据放在独立的对象里。它的代价是首屏会有一瞬间没有页头但因为注入发生在 DOM 解析完但渲染之前实际感知不到。如果将来你要求严格的无闪烁可以把页头页脚写成独立的 HTML 文件用构建脚本在打包时拼进去那就是另一条路了。5. 踩坑记录原生方案里最容易翻车的几处前面讲的都是怎么做这一章讲为什么会做错。下面这些问题我在这个项目上基本都踩过一遍有的是自己写的 bug有的是浏览器行为跟直觉不一致。把它们整理出来是因为这些坑的共同特点是出问题时现象和原因看起来毫无关系只能靠经验或者一步步排查才能定位。5.1 主题闪烁的完整排查过程现象是这样的页面用暗色主题刷新时会先闪一下白色大约几十毫秒然后才变成暗色。第一次遇到我以为是自己 CSS 写错了检查了>let composing false; const input document.getElementById(searchInput); input.addEventListener(compositionstart, () { composing true; }); input.addEventListener(compositionend, () { composing false; state.keyword input.value; applyFilter(); }); input.addEventListener(input, e { if (composing) return; // 输入法组合中先不筛 clearTimeout(timer); timer setTimeout(() { state.keyword e.target.value; applyFilter(); }, 200); });同类的问题还有搜索大小写。我用toLowerCase()统一处理但要注意土耳其语环境下的i转换规则不同个人博客可以忽略知道了就行。5.4 移动端的两个经典问题移动端第一个问题是100vh。我原本给首屏欢迎区设了min-height: 100vh在桌面浏览器上没问题在手机浏览器上底部会被地址栏遮住一截。原因在移动浏览器里100vh指的是地址栏隐藏时的视口高度而地址栏显示时可视区域更小。现代的解法是用100dvh代替100vhd表示动态视口会随地址栏状态变化。兼容性不够时退回到100vh.hero { min-height: 100vh; min-height: 100dvh; /* 后者生效时覆盖前者 */ }第二个问题是可点击元素的尺寸。小屏上按钮如果只有 20 像素高手指根本点不准。经验值是交互元素的可点区域不小于 44×44 像素。标签按钮在我的设计里视觉高度是 28 像素通过padding和::before扩展出一个更大的命中区域视觉不变、手感变好。这是个花五分钟就能做完、但用户粘性提升很明显的改动。第 5.5 节我把响应式断点的选择也放在这里说因为它本质上也是移动端适配的一部分。我用的断点只有两个900px 和 640px。900px 以上是正文 目录两列以下收成一列640px 以下是手机导航折叠、字号从 17px 调到 16px、间距变量整体缩小一档。断点不是越多越好每多一个断点就多一套需要维护的样式两三个足够覆盖个人博客的全部场景。6. 源码之外性能、数据维护和什么时候该换方案页面能跑起来之后还有几个决定它能不能长期用下去的问题。这一章讲三件事资源加载顺序怎么排、文章数据怎么维护、以及什么信号出现时说明你该考虑换技术方案了。这部分不是必须做但它决定了这个博客是你花两天做完就扔的练手作品还是能持续用两三年的个人站点。6.1 资源加载顺序与体积控制三个 JS 文件的加载顺序不是随便排的有依赖关系data.js定义POSTS必须最先加载common.js用到主题切换和公共片段不依赖数据排第二list.js和post.js依赖前两者最后加载。因为都是普通script标签浏览器会按顺序同步执行不会出现POSTS is not defined的错误。如果你给它们加上defer执行顺序同样是按标签顺序保持的但会推迟到 DOM 解析完之后反而更好——前提是渲染逻辑都包在DOMContentLoaded或者defer之后执行。体积方面可以做几个简单的控制。第一所有小图标用内联 SVG 而不是图片文件一个 SVG 图标大约两三百字节比发一次 HTTP 请求划算得多。第二图片统一用img loadinglazy延迟加载首屏只加载视口内的图。第三如果你用了第三方代码高亮库注意它的体积往往比自己写的所有代码加起来都大个人博客上完全可以不做语法高亮或者只在详情页按需加载。一个常被忽略的点是 CSS 的渲染阻塞。link relstylesheet会阻塞首屏渲染这是必要的否则会出现无样式内容闪烁但如果有多个 CSS 文件它们会串行加载。我把归零样式和主题样式合并成一个文件就是这个原因。如果将来样式变得很复杂可以用一个内联的style放首屏关键样式剩下的异步加载不过对个人博客来说这是过度优化。6.2 文章数据怎么维护才不痛苦把文章放在js/data.js里最直接但写文章时要在 JS 字符串里嵌 HTML编辑器不会给你语法高亮和补全写起来很难受。我的实际做法是用 Markdown 写正文用一个几十行的脚本转换成 HTML 后追加到data.js里。这个脚本只需要做几件事读取posts/目录下的.md文件解析出开头的元信息标题、日期、标签把正文转成 HTML输出成data.js。用到的元信息格式就是最简单的键值对放在文件开头用三个短横线包起来--- title: 用原生三件套搭一个博客站 date: 2024-05-18 tags: 前端, 实战 summary: 不装框架从零搭出可用的博客页面。 --- 正文从这里开始……这样做的好处是写作和展示彻底分离写作时享受 Markdown 编辑器的便利展示时享受纯静态页面的性能。代价是每次写完文章要跑一次脚本但个人博客的更新频率这个成本完全可接受。如果你想更省事可以把这个脚本挂在文件系统的变化事件上保存 Markdown 就自动重新生成。另一个维护上的建议是给data.js加上版本号或者时间戳注释并且把文章的id定得足够永久。id 一旦发布就不要改否则所有外部链接都会失效而且localStorage里可能还存着基于旧 id 的阅读记录。我习惯用英文短横线拼写的标题作为 id比如build-blog-with-vanilla-js语义清晰、不易冲突、也不需要额外的编号系统。6.3 出现这几个信号就该考虑换方案了原生方案不是终点它有自己的天花板。我总结了几条判断标准如果你同时命中两条以上说明继续在这套代码上叠功能已经不划算了。文章超过两三百篇data.js会变成几百 KB 的文件每次打开首页都要完整下载。这时候要么改成按需加载每篇文章一个独立的 JSON 文件要么引入服务端渲染。前者的改动量不小但比全面重写便宜。需要多人协作或者在线编辑一旦涉及登录、草稿、权限前端的复杂度会陡增这时候后端和框架的价值才开始体现。纯静态方案在这个方向上没有任何优势。交互状态超过十个目前只有主题、标签、搜索词三个状态用闭包变量管理很轻松。如果将来加了阅读进度、收藏、笔记、排序方式等等状态之间的联动会变得难以追踪这时候引入一个状态管理层才是合理的。需要频繁的局部更新比如实时预览、拖拽排序这类高度动态的交互手写 DOM 操作会变得又长又容易出错框架的声明式渲染能省下大量代码。场景原生三件套引入框架文章几十篇、无登录推荐首屏最快收益不明显文章几百篇需要自己做按需加载有现成的路由与分包多人协作、在线编辑不适合推荐交互状态少于十个闭包变量足够略重需要 SEO 且不做预渲染依赖静态 HTML 兜底可服务端渲染我个人的判断是个人博客这个场景绝大多数人永远用不到第四列。真正需要做的是把内容写好而不是把技术栈堆高。我在这个博客上跑了两年一共改过三次代码一次加了目录高亮一次换了暗色配色一次调整了移动端字号。每次改动花的时间都在半小时以内因为有变量和清晰的文件划分我知道该改哪里。最后分享一个我自己用了很久的小习惯每次改完样式或者加完功能都把页面缩到 375 像素宽看一眼再用键盘 Tab 键从头到尾走一遍。缩到手机宽度能发现 90% 的布局问题Tab 键走一遍能发现所有焦点不可见的问题。这两个动作加起来不到两分钟但能挡掉绝大多数用户会遇到的糟糕体验。