ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

用原生JavaScript打造个人欢迎页:纯静态单文件导航与工具集成

用原生JavaScript打造个人欢迎页:纯静态单文件导航与工具集成 几周前我给自己做了一个欢迎页项目取名叫Xiao。起因很简单我手上一堆链接、工具、常用入口散落在书签、备忘录和聊天记录里每次找起来都要折腾半天。市面上现成的导航页、仪表盘工具我也试过好几个功能确实多但配置复杂界面也偏重为了开一个链接要等半天的加载动画实在不划算。我的需求很朴素一个打开就是干货的页面承载我每天高频访问的入口同时给我留一块能随手记点东西的地方。做这个小项目的过程比我预想的有意思得多。它没有用任何框架没有构建工具甚至没有引入任何第三方库就是用 HTML、CSS 和原生 JavaScript 写的单个文件。整个项目跑起来后我在手机、平板、公司电脑上试了一遍加载速度很快页面也清爽。今天把整个过程和实现细节整理出来分享给有类似需求的朋友。这个小项目适合谁呢如果你也经常为信息入口发愁或者想要一个完全由自己掌控、不会被各种推荐流干扰的个人起始页再或者你就是想找一个练手项目把原生前端的基础功扎扎实实过一遍——那这篇内容应该对你有用。1. 项目定位与设计思路1.1 我到底想做一个什么样的页面Welcome to Xiao这个名字看起来有点像一个欢迎页。实际上它也确实承担了欢迎页的角色只不过欢迎的不是访客而是我自己。打开浏览器进入新标签页第一眼看到的就是这个页面所以它的定位必须足够轻、足够快。我列了几个硬性指标打开后立即呈现内容不需要任何等待。页面上没有冗余信息不展示新闻提要不给我推送任何我不想看的东西。可以自定义分组把工作相关、学习相关、生活相关的东西分开。提供一个小工具区比如单位换算、时间戳转换、Base64 编解码之类的轻量工具随用随走。在右下角留一个极简的备忘入口临时想到什么事情可以立刻记下来。为什么不做成复杂应用因为复杂意味着维护成本高意味着每次改动都可能引入新的问题。我需要的不是一个需要精心保养的项目而是一个写完就能稳定跑很久的页面。和我有类似想法的人应该不少——很多开发者的个人起始页都是这个路线只是实现方式各有不同。1.2 为什么选择纯静态单文件方案选型阶段我认真考虑过几种方案用 React 搭一个、用 Vue 搭配 Vite 构建、套一个开源导航程序、或者直接手写静态页。先排除了框架方案。原因很简单这个项目的复杂度根本用不着虚拟 DOM 和组件化。我只需要渲染一组数据处理一些点击事件加上一点简单的状态管理。原生 JavaScript 完全够用。而且用了框架就意味着引入 node_modules、构建流程、依赖更新这些事对于一个个人欢迎页来说这些“配套工程”反而成了负担。开源导航程序也被我排除了。不是它们不好而是它们多数面向通用场景功能取舍不一定符合我的使用习惯。很多导航程序需要后台、数据库部署起来一套流程走完中间还有不少需要配置的地方。我的需求很简单用不着杀鸡用牛刀。所以最终的方案是一个 HTML 文件完成全部逻辑CSS 内联在style标签里JavaScript 内联在script标签里数据以对象数组的形式写在脚本中。整个文件几 KB 大小没有任何外部请求丢到任何支持静态托管的服务上就能跑。断网的时候也能打开因为它压根不依赖网络。1.3 整体的功能架构拆解整个页面的功能可以拆成四大块第一块是访问入口区。这是页面最核心的部分把我的常用链接按分组展示。每个链接是一个卡片卡片上显示站点名称和描述点击直接新窗口打开。第二块是快捷工具区。这里放一些高频使用的小功能。我做了一个时间戳转换和一个 Base64 编解码都是十几行代码实现的小工具但实用性很强之前用在线工具时还得忍受广告和各种弹窗现在自己写一个干净清爽的。第三块是备忘便笺。一块随时可以写字的区域内容自动保存到浏览器的 localStorage 里。不需要登录、不需要同步服务就是本地的一块备忘录。第四块是个性化设置。我预留了主题色调切换、卡片排序方式等几个自定义选项实现起来不复杂但让页面真正像“我的”。这几个功能块之间互相独立各自又都足够简单。我在写的时候有一个原则不为任何一个功能块引入额外的库。工具函数自己写存储直接用浏览器原生 API渲染逻辑手动拼字符串然后插入 DOM。这样做的收益是页面永远只需要一次请求加载之后的每次打开都是瞬开。2. 核心细节解析与实操要点2.1 数据从哪来以对象数组组织链接信息页面要展示什么本质上是数据决定的。我把每个链接条目的数据结构设计成最小可用形式包含四个字段{ id: work-github, name: GitHub, url: https://github.com, desc: 代码托管与协作平台 }id是唯一标识方便后续做排序和搜索name是站点名称展示在卡片标题位置url是跳转地址desc是一句简短的描述让鼠标悬停或扫视时能快速回忆起这个站点是干什么的。分组信息用另一个结构表达每个分组有一个 id、一个标题和一个链接列表。这样的好处是渲染逻辑很简单外层循环分组内层循环链接两层遍历之后整个页面的骨架就出来了。const groups [ { id: dev, title: 开发相关, links: [ { id: dev-github, name: GitHub, url: https://github.com, desc: 代码托管与协作平台 }, { id: dev-stackoverflow, name: Stack Overflow, url: https://stackoverflow.com, desc: 开发者问答社区 } ] }, { id: life, title: 生活效率, links: [ { id: life-calendar, name: 在线日历, url: https://calendar.example.com, desc: 日程安排与提醒 } ] } ];在实际过程中我还会往数据里加一个color字段用来给不同分组的卡片渲染不同的左侧边条颜色。这样视觉上区分度高扫一眼就知道是哪个分类下的链接不需要逐个读文字。2.2 卡片渲染与事件绑定避开常见的坑数据定义好了接下来就是渲染。我的渲染函数逻辑很直接function renderGroups() { const container document.getElementById(groups); let html ; groups.forEach(group { html section classgroup; html h2 classgroup-title${group.title}/h2; html div classlinks-grid; group.links.forEach(link { html a classlink-card href${link.url} target_blank relnoopener noreferrer>function escapeHtml(str) { return str.replace(/[]/g, function(m) { const map { : amp;, : lt;, : gt;, : quot;, : #39; }; return map[m]; }); }如果你打算把自己的数据文件也设计成可配置这个转义函数建议直接保留。事件绑定方面我选择在容器上用事件委托而不是给每个卡片绑定单独的事件。因为链接的数量可能会增长事件委托只需要一个监听器就能处理所有卡片。具体实现是给整个容器绑定click事件通过event.target.closest(a.link-card)判断是否点到了卡片然后读取>function tsToDate(ts) { const d new Date(ts * 1000); // 秒级时间戳 const pad n n.toString().padStart(2, 0); return ${d.getFullYear()}-${pad(d.getMonth() 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}; } function dateToTs(str) { const d new Date(str.replace(/-/g, /)); if (isNaN(d.getTime())) return 无效时间格式; return Math.floor(d.getTime() / 1000); }这里要注意一个点new Date(2025-01-01 08:00:00)这种格式在部分浏览器下解析结果不一致尤其是 iOS Safari。所以我先用replace(/-/g, /)把中划线统一替换成斜杠这样各浏览器的解析行为就基本一致了。Base64 工具相对简单但也要处理中文问题function encodeBase64(str) { return btoa(unescape(encodeURIComponent(str))); } function decodeBase64(str) { return decodeURIComponent(escape(atob(str))); }关键词是encodeURIComponent配合unescape否则中文会在编码时报错。这个细节你可能在很多在线工具网站看不到解释但实际用起来差别很大。2.4 备忘便笺与本地持久化备忘便笺的实现是这套项目里最没有门槛的。一个textarea元素监听input事件每次内容变化就写到localStorage。const note document.getElementById(note); note.value localStorage.getItem(xiao_note) || ; note.addEventListener(input, function() { localStorage.setItem(xiao_note, this.value); });就这么几行够了。为什么用localStorage而不是IndexedDB或者发送到后端因为个人欢迎页的使用场景就是单浏览器、单设备。localStorage的存储上限通常在 5MB 左右存纯文本的备忘录绰绰有余。而且它的 API 是同步的写入即完成不需要处理异步回调。需要注意的一点是localStorage的写入频率。如果你在input事件里每次敲一个字符都写一次理论上没有任何问题但频繁的同步写入对性能还是有微小的消耗。实际经验是用debounce防抖把写入频率控制在 300 毫秒一次let timer null; note.addEventListener(input, function() { if (timer) clearTimeout(timer); timer setTimeout(() { localStorage.setItem(xiao_note, this.value); }, 300); });这样既不会丢数据也避免了无谓的写入开销。3. 实操过程与核心环节实现3.1 环境准备只有一个文件需要折腾开始写之前我确认了一下环境需求。这个项目几乎没有环境依赖一个文本编辑器加一个浏览器就足够了。我习惯用 VS Code 写代码然后用浏览器直接打开 HTML 文件预览。不需要本地服务器因为页面里没有fetch请求没有跨域问题直接用file://协议打开就能跑。不过有一点提醒如果你想在页面上用浏览器的某些高级 API比如fetch去加载远程数据那就必须通过本地服务器访问否则file://协议下浏览器会拦截跨域请求。我这个项目因为全部内联所以完全不受影响。写代码时我把 HTML、CSS、JavaScript 分模块写在同一文件里用注释区分区域。这种做法对于单一文件项目来说比盲目拆分文件更清晰。你打开文件时从上往下滚动就能按“结构—样式—逻辑”的顺序把整个页面过一遍。3.2 布局与样式默认可用的响应式设计布局方案我选了 Flexbox CSS Grid 的组合。整体页面被分成了几个区域顶部的标题栏、中间的主体网格左边链接区右边工具区、底部的便笺。链接卡片的部分用了 Grid可以根据屏幕宽度自动调整列数.links-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }这行代码的效果是在手机窄屏上每行显示一列在中屏上显示两列在宽屏上显示三列甚至更多。auto-fill配合minmax是非常实用的响应式组合推荐记住。工具区放到了主网格的右侧便笺放在主网格下方占一整行。在手机上由于宽度不足主网格自动变成单列工具区就顺理成章落到链接区下方。我没有写媒体查询纯粹依靠 Grid 的自动换行解决了大部分适配问题只在小细节上加了几个断点微调。关于样式的更多细节我用了 CSS 变量来定义主题色:root { --bg-color: #f5f6f8; --card-bg: #ffffff; --text-primary: #2c3e50; --accent-color: #4a90d9; }这样后续想换主题只要改这几个变量就行不用每个属性单独调。3.3 主题切换与偏好保存既然用 CSS 变量做了主题色不做明暗主题切换就有点浪费了。我加了一个开关切换页面在浅色和深色两套配色之间互换。实现思路也不复杂给body加一个>body[data-themedark] { --bg-color: #1e1e1e; --card-bg: #2a2a2a; --text-primary: #e0e0e0; --accent-color: #5b9bd5; }因为所有元素的颜色都引用了 CSS 变量所以仅仅改变这几个变量的值整站配色就变了不需要任何额外的样式覆盖。主题偏好同样用localStorage存起来刷新页面后自动恢复上次的选择。3.4 将这个页面部署到服务器页面写完本地验证可通过下一步就是部署。部署一个纯静态页面有多种方式我整理了一个对比表供参考部署方式优点缺点适合场景传统虚拟主机上传简单直接FTP 上传即可可能需要额外购买流量有现成主机的用户对象存储托管稳定、速度快、支持 HTTPS配置 Bucket 策略稍有学习成本想省事且愿意花点时间配置的人自有服务器 Nginx完全可控、可自定义行为需要维护服务器已有服务器的开发者纯本地使用零部署、零成本换设备不方便个人临时使用我自己的部署选择是用 Nginx因为手上有一台一直在跑的服务器。配置块通常长这样server { listen 80; server_name xiao.example.com; root /var/www/xiao; index index.html; location / { try_files $uri $uri/ 404; } }然后在服务器上创建一个目录把 HTML 文件传上去nginx -s reload让配置生效。到这里一个干净利落的欢迎页就正式上线了。如果你没有服务器也可以直接把 HTML 文件放到对象存储服务里开启静态网站托管拿到一个公网地址同样能达到目的。3.5 添加自定义搜索框作为入口原本的四块功能已经满足了大部分需求但后来我在实际使用中总觉得缺一个全局搜索入口。我直接访问某个站点的频率不高更多时候是搜索一个关键词然后从搜索结果里点进去。于是我在顶部加了自定义搜索框。实现方式很灵活因为这个搜索框本质上就是一个表单提交时把关键词拼到搜索引擎的 URL 后面function search() { const keyword document.getElementById(searchBox).value.trim(); if (!keyword) return; const engine document.getElementById(engineSelect).value; let url ; if (engine bing) { url https://www.bing.com/search?q encodeURIComponent(keyword); } else if (engine google) { url https://www.google.com/search?q encodeURIComponent(keyword); } window.open(url, _blank); }搜索引擎的选择我放在下拉框里用户可以从预设的几个引擎中选择。这个模块加进去后页面从“导航 工具 便笺”变成了“搜索 导航 工具 便笺”使用频率反而比链接区更高。如果你也想给自己的欢迎页加搜索框这个实现完全可以参考。4. 常见问题与排查技巧实录4.1 为什么刷新后便笺内容丢失了我在测试过程中一度以为便笺功能有 Bug明明写了好长一段文字刷新页面后全没了。排查后发现问题不是出在localStorage的写入而是我在代码里把save事件绑定在了change事件上而不是input事件。这两个事件的区别很微妙change事件在输入框失焦的时候才触发而input事件在每次内容变化时触发。如果你只给textarea绑定了change事件那么在输入过程中一直没点页面其他地方内容就不会被保存。刷新之后当然什么都没了。这个坑印象很深。你把事件绑对别用change当input用。另外也提醒一个问题浏览器隐私模式下的localStorage可能不可用写入时会抛异常。稳妥的做法是包一层 try-catch异常时静默处理至少不让页面崩溃。4.2 移动端适配和软键盘顶起问题我用手机浏览器打开页面的时候发现点击输入框弹出软键盘后整个页面布局会出现一些跳动。尤其便笺区输入框在页面底部软键盘弹起来后把页面顶得乱七八糟。排查之后发现根因是视口单位使用不当。页面里有些元素的高度用了vh单位而移动端浏览器在处理vh时地址栏和软键盘的显示状态会影响视口高度导致布局跳动。解决办法有两个方向一是改用dvh动态视口单位现代浏览器支持度已经很好二是把便笺区域的高度限制改成百分比或自动撑开。我最终的选择是给便笺区设置了基础高度同时用max-height做限制不给软键盘腾出多余空间的弹性。这个移动端问题在纯桌面场景下几乎不会出现但如果你像我一样经常在手机上打开这个页面建议一开始就考虑进去。4.3 数据渲染时页面出现闪烁页面在打开时如果数据加载和 DOM 渲染套在一个很重的循环里可能会出现短暂的白屏或者闪烁。我在这个项目中遇到的闪烁情况是先看到布局过几十毫秒后链接卡片才蹦出来视觉上非常不连贯。排查后发现是因为我一开始用requestAnimationFrame包了一层渲染函数本意是等布局计算完成再渲染结果反而造成了延迟。去掉之后链接区直接在脚本加载时同步渲染就再也没有闪烁问题了。这里也总结一个经验对于纯静态的小页面同步渲染优先于异步渲染。不要为了“性能优化”而引入不必要的异步调度同步直出反而是最稳定的。4.4 主题偏好保存失败主题切换在桌面端一切正常但我把页面放到手机上后切换主题后刷新页面又跳回了浅色模式。检查逻辑后发现保存操作的代码写在beforeunload事件里而移动端浏览器在页面被切换走或刷新时beforeunload的触发并不可靠。正确的做法是在切换操作发生时立即保存不要依赖生命周期事件去兜底。function switchTheme(theme) { document.body.dataset.theme theme; localStorage.setItem(xiao_theme, theme); }这样一改问题迎刃而解。顺带说一句如果你的页面里有统计逻辑尽量不要依赖beforeunload去上报数据这个事件在移动端掉得相当厉害。4.5 常见问题速查表我整理了这段时间碰到过的几个典型问题列成一张表看起来更直观现象可能原因解决办法便笺内容刷新后丢失使用了change事件而不是input改为监听input事件并加上防抖隐私模式下页面报错localStorage不可用写入操作包 try-catch做好降级处理手机软键盘顶起布局使用了固定vh单位换用dvh或限制高度范围深色主题刷新后失效依赖beforeunload保存在切换事件触发时立即写入localStorage卡片点击后空白页没有设置relnoopener noreferrer给a标签加上完整 rel中文内容编码乱码HTML 文件缺少 UTF-8 声明在head中加入meta charsetUTF-8这里重点提一下卡片点击空白页的问题很多时候你点开一个外部链接新页面打开了但原页面还在这通常是因为目标 URL 里带了javascript:协议或者 URL 格式不对。我在渲染时只信任自己配置的数据所以这个问题很少出现但如果你的配置文件允许外部输入就一定要在存库前做协议校验只允许http和https。5. 扩展思路与后续优化空间5.1 如何给这个页面加一个“最常访问”排行用了一段时间之后我发现有些链接点得多有些链接几乎没点过。如果能有数据告诉我哪些是高频入口就能动态调整排序让真正常用的链接排在前面。实现方法也很简单给每个卡片绑定点击事件把点击数写入localStorage维护一个clickCount对象。每次渲染前对分组内的链接按点击数从高到低排序点击超过一定次数的链接自动置顶还可以在卡片右上角加一个小角标显示“热”字来表示高频。function recordClick(id) { const key xiao_click_ id; const count parseInt(localStorage.getItem(key) || 0, 10); localStorage.setItem(key, String(count 1)); }这个“点击排行”本质上是一种轻量级的自定义分析虽然简单但非常实用。唯一需要注意的就是别在数据量变大时撑着性能个人页面的数据量级根本不会触顶。5.2 能否支持多设备数据同步localStorage只在当前浏览器里生效这意味着换台电脑页面上的配置和便笺内容就全不在了。如果你像我一样办公室一台电脑家里一台电脑偶尔还用手机看那就需要考虑同步方案。最简单的同步办法是借用后端存储或对象存储。把配置数据序列化成 JSON在页面加载时从远端拉取一次本地修改后再通过接口写回去。这个方案需要一点点后端能力但逻辑非常清晰。另一种思路是使用平台的同步盘比如将单个 HTML 文件放进同步文件夹多设备共用同一个文件。这是零成本零依赖的方案缺点是同步有延迟不适合需要实时多端读写的人。我的做法是把localStorage当作日常主存储配置数据保留一份 JSON 文件放在同一目录下备用需要迁移时手动导入导出。成本很低但对于个人使用来说已经足够。5.3 代码结构再整理配置与逻辑剥离如果你想把项目做得更“正规”一点可以考虑把数据配置和逻辑代码拆分到不同的文件里。比如把链接数据放到data.js页面逻辑放到app.jsHTML 里按顺序引入script srcdata.js/script script srcapp.js/script这样做的收益是以后改链接配置时不需要在巨大的 HTML 文件里翻找打开data.js直接改就行。同时如果想要把同一套逻辑复用到不同的数据场景这个拆分的价值就非常明显了。我在实际项目中虽然保持了单文件但内部已经按区域用注释逻辑分开了。如果你准备长期维护这个欢迎页我更推荐你一开始就拆分省得后面再调整。5.4 让页面更有“个人感”加一点小细节一个纯工具性的页面很容易变得冷冰冰。为了让打开页面时有点仪式感我在顶部加了一句问候语根据时间动态变化function getGreeting() { const h new Date().getHours(); if (h 6) return 夜深了注意休息; if (h 12) return 早上好开始今天的工作吧; if (h 14) return 中午好记得吃午饭; if (h 18) return 下午好继续加油; return 晚上好今天辛苦了; }这只是个不起眼的小功能但每次打开页面看到一句应景的问候体验比冷冰冰的工具页好很多。类似的细节还有很多比如在今天日期旁边显示农历、节日提醒、随机切换鼓励语等不需要很复杂但能让页面更有温度。写在最后从有想法到Welcome to Xiao真正跑起来前后大概花了两三天时间。代码量不大但每一个细节都切中了我自己的痛点。这个项目让我重新体会到原生前端技术的直接和纯粹——没有那么多抽象概念没有需要维护的依赖一个文件就是一个完整作品。做这个页面与其说是在写代码不如说是在整理我自己的数字生活。把每天都用的链接、工具和碎片信息集中到一个地方用最简单的方式组织好这种感觉很踏实。后来我给几个朋友推荐了这个方案他们照着思路也搭了自己的版本有人加入了健身记录有人接入了 RSS 聚合还有人做成了家庭内部的小门户。同一种代码框架因为每个人的需求差异长出了完全不同的形态。如果你也想做一个类似的个人欢迎页我的建议是从最小的闭环开始先写一个能展示链接的页面跑通本地预览部署上线。之后再加工具、加便笺、加搜索一步一步来。别一开始就想着做大而全那些功能可以慢慢堆但只要骨架立住了后面的所有改动都只是在往上面添砖加瓦。
返回列表