
导航网站源码二次开发:添加分类、搜索和网站图标
导航网站通常从一组链接开始:访客按分类浏览,或输入关键词查找目标站点。给现有源码添加这些功能,难点不只在界面上,还包括如何组织数据、避免用户输入被当作 HTML 执行、怎样处理图标失效,以及如何在不引入额外依赖的情况下运行和维护。
本文用原生 HTML、CSS 和 JavaScript 实现一个可运行的静态示例:分类由数据生成,搜索支持标题、描述和网址,网站卡片显示指定图标并在加载失败时回退;同时设置浏览器标签页使用的站点 favicon。示例适合站点数量不大、内容由开发者维护、部署为静态文件的导航页。它不是带权限、后台管理和数据库的完整内容系统。
一、先界定需求和边界
动手改代码前,先确认要添加的“图标”指什么:
- 浏览器 favicon:浏览器标签页或收藏夹中代表导航站本身的小图标,通过页面的
link rel="icon"指定。 - 导航卡片图标:每个链接站点自己的图标,通常是卡片中的小图像。这些图标由导航站点加载,和本网站的 favicon 是两件事。
本文会分别设置两者。卡片图标使用数据中明确提供的地址;对于未配置图标或图标加载失败的站点,显示文字回退图标。不要假定任意网站都开放了可稳定访问的图标接口,也不要为了显示图标而默认抓取目标站点内容。
静态方案的边界也要明确:分类和站点数据写在 JavaScript 文件中,修改后需要重新发布;搜索在浏览器本地执行;没有多用户编辑、审核、账号权限或服务器端搜索。若需要这些能力,应另行设计后端、存储、访问控制和输入校验,不能仅靠前端隐藏按钮或过滤界面代替权限控制。
二、准备项目文件
新建一个目录,放入以下三个文件。图标目录用于存放本地 SVG,例如 icons/default.svg;示例也会在没有本地站点图标时使用文字回退,因此该文件并非运行必需。
nav-site/
├── index.html
├── style.css
├── app.js
├── favicon.svg
└── icons/
└── default.svg
以下示例不依赖构建工具或第三方库。将代码分别保存为对应文件即可。生产环境建议通过 HTTPS 静态托管,并使用本地 HTTP 服务测试;直接双击打开文件时,浏览器对模块、路径和本地资源的处理可能与网站环境不同。
三、页面结构与浏览器 favicon
创建 index.html。分类按钮和卡片容器先留空,由 JavaScript 根据数据生成;搜索框使用原生表单控件,保留键盘操作和标签语义。favicon 的路径相对于网站根目录,若部署在子目录,应相应调整路径。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="按分类浏览常用网站,或搜索站点名称与说明。">
<title>常用网站导航</title>
<link rel="icon" href="./favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="./style.css">
<script src="./app.js" defer></script>
</head>
<body>
<header class="page-header">
<h1>常用网站导航</h1>
<p>按分类浏览,或输入名称、说明和网址进行搜索。</p>
</header>
<main>
<form id="search-form" role="search">
<label for="search-input">搜索网站</label>
<input
id="search-input"
type="search"
placeholder="例如:文档、设计、example.com"
autocomplete="off"
>
<button type="submit">搜索</button>
</form>
<nav id="category-list" aria-label="网站分类"></nav>
<p id="result-status" aria-live="polite"></p>
<section id="site-list" class="site-grid" aria-label="网站列表"></section>
</main>
</body>
</html>
新建 favicon.svg。这是浏览器标签页图标,不是卡片上某个链接站点的图标。SVG 能否作为 favicon 取决于浏览器支持和服务器返回的内容类型;若需兼容旧环境,可额外提供 PNG 或 ICO,并配置相应的 link 标签。
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#2563eb"/>
<path d="M16 20h32v6H16zm0 12h32v6H16zm0 12h22v6H16z" fill="#fff"/>
</svg>
四、定义站点数据和分类
每个站点使用固定字段:唯一 id、名称 title、说明 description、分类 category、跳转地址 url 和可选图标地址 icon。分类名称要与站点数据中的分类值一致,否则该站点不会出现在按分类筛选的结果中。
新建 app.js,先放入数据。示例域名仅用于说明字段结构,实际使用时请替换成你核实过的站点地址。网址必须包含协议,例如 https://example.com/。
const sites = [
{
id: "docs",
title: "示例文档",
description: "查阅产品说明与开发文档。",
category: "开发",
url: "https://example.com/docs",
icon: ""
},
{
id: "design",
title: "示例设计资源",
description: "浏览设计工具与灵感资源。",
category: "设计",
url: "https://example.org/",
icon: ""
},
{
id: "status",
title: "示例状态页",
description: "查看服务运行状态。",
category: "开发",
url: "https://status.example.net/",
icon: ""
}
];
const categories = ["全部", "开发", "设计"];
数据最好由可信的维护者编辑和审查。若改成从 API 或用户提交内容中读取,不能因为字段“看起来像网址”就直接写入链接属性;仍要在使用位置验证协议,并由服务端实施相应的认证、授权和数据校验。
五、实现分类、搜索和安全渲染
分类筛选与搜索采用“同时满足”的逻辑:选定分类后,搜索只在该分类结果中继续筛选。搜索不区分大小写,按名称、说明、分类和网址匹配。代码不把数据拼进 innerHTML,而是创建节点并使用 textContent 设置文本,避免站点标题或说明中的字符被当作 HTML 执行。
网址验证采用浏览器的 URL 解析器,并仅接受 http:、https: 协议。这个检查的用途是限制导航和图标资源地址的协议,不代表目标网站安全、可信或一定可访问;它也不等于服务端防护。卡片用新标签页打开时设置 rel="noopener noreferrer",降低新页面通过 window.opener 操作原页面的风险。
const categoryList = document.querySelector("#category-list");
const siteList = document.querySelector("#site-list");
const searchForm = document.querySelector("#search-form");
const searchInput = document.querySelector("#search-input");
const resultStatus = document.querySelector("#result-status");
let activeCategory = "全部";
function allowedWebUrl(value) {
try {
const parsed = new URL(value);
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
return parsed.href;
}
} catch {
// 无法解析的地址按无效处理。
}
return null;
}
function makeCategoryButtons() {
categoryList.replaceChildren();
for (const category of categories) {
const button = document.createElement("button");
button.type = "button";
button.textContent = category;
button.setAttribute("aria-pressed", String(category === activeCategory));
button.addEventListener("click", () => {
activeCategory = category;
makeCategoryButtons();
renderSites();
});
categoryList.append(button);
}
}
function makeSiteCard(site) {
const article = document.createElement("article");
article.className = "site-card";
const heading = document.createElement("h2");
const link = document.createElement("a");
const safeUrl = allowedWebUrl(site.url);
link.textContent = site.title;
link.href = safeUrl || "#";
link.target = "_blank";
link.rel = "noopener noreferrer";
if (!safeUrl) {
link.removeAttribute("target");
link.removeAttribute("href");
link.setAttribute("aria-disabled", "true");
}
const iconUrl = site.icon ? allowedWebUrl(site.icon) : null;
if (iconUrl) {
const image = document.createElement("img");
image.className = "site-icon";
image.src = iconUrl;
image.alt = "";
image.width = 40;
image.height = 40;
image.loading = "lazy";
image.addEventListener("error", () => {
image.replaceWith(makeTextIcon(site.title));
}, { once: true });
heading.append(image);
} else {
heading.append(makeTextIcon(site.title));
}
heading.append(link);
const description = document.createElement("p");
description.textContent = site.description;
const category = document.createElement("span");
category.className = "site-category";
category.textContent = site.category;
article.append(heading, description, category);
return article;
}
function makeTextIcon(title) {
const icon = document.createElement("span");
icon.className = "text-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = Array.from(title || "?")[0] || "?";
return icon;
}
function renderSites() {
const query = searchInput.value.trim().toLocaleLowerCase();
const filtered = sites.filter((site) => {
const matchesCategory =
activeCategory === "全部" || site.category === activeCategory;
const searchableText = [
site.title,
site.description,
site.category,
site.url
].join(" ").toLocaleLowerCase();
return matchesCategory && searchableText.includes(query);
});
siteList.replaceChildren(...filtered.map(makeSiteCard));
resultStatus.textContent = `找到 ${filtered.length} 个网站`;
}
searchForm.addEventListener("submit", (event) => {
event.preventDefault();
renderSites();
});
searchInput.addEventListener("input", renderSites);
makeCategoryButtons();
renderSites();
上面的无效网址处理会让标题保留为不可点击文本。不要把 javascript:、data: 等协议直接交给 href 或 img.src;当前函数会拒绝它们。示例允许 HTTP 和 HTTPS,因此如需强制站点链接使用 HTTPS,可将条件收紧为仅接受 https:,但也要确认目标站点确实支持 HTTPS。不能将协议限制描述为对恶意站点的全面安全保证。
搜索框的输入只参与字符串匹配,不执行代码,也没有被插入 HTML。搜索结果显示数量,空结果时会显示“找到 0 个网站”;如果希望提供专门的空状态,可以在 renderSites 中根据 filtered.length 追加提示节点。不要用正则表达式拼接未经处理的搜索词来构造匹配规则,简单搜索使用 includes 更直接。
六、添加基础样式
新建 style.css。样式不影响数据和筛选逻辑;你可以按现有源码的视觉规范调整颜色、间距和断点。图标尺寸固定,能减少图片加载前后布局跳动。按钮状态使用 aria-pressed 标记,样式和状态保持一致。
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #172033;
background: #f5f7fb;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.page-header,
main {
width: min(960px, calc(100% - 32px));
margin-inline: auto;
}
.page-header {
padding: 40px 0 20px;
}
.page-header h1 {
margin: 0 0 8px;
}
.page-header p {
margin: 0;
color: #5b6474;
}
#search-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin: 16px 0;
}
#search-form label {
flex-basis: 100%;
font-weight: 600;
}
#search-input {
flex: 1 1 240px;
min-width: 0;
padding: 10px 12px;
}
button,
#search-input {
border: 1px solid #cbd2df;
border-radius: 8px;
font: inherit;
}
button {
padding: 9px 13px;
color: #172033;
background: #fff;
cursor: pointer;
}
button[aria-pressed="true"] {
color: #fff;
border-color: #2563eb;
background: #2563eb;
}
#category-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 18px 0;
}
#result-status {
min-height: 1.5em;
color: #5b6474;
}
.site-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
gap: 14px;
padding-bottom: 40px;
}
.site-card {
padding: 16px;
border: 1px solid #e1e6ef;
border-radius: 12px;
background: #fff;
}
.site-card h2 {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 10px;
font-size: 1.05rem;
}
.site-card a {
color: #1d4ed8;
overflow-wrap: anywhere;
}
.site-card p {
min-height: 3em;
color: #5b6474;
line-height: 1.5;
}
.site-icon,
.text-icon {
flex: 0 0 40px;
width: 40px;
height: 40px;
border-radius: 9px;
}
.site-icon {
object-fit: contain;
background: #f1f4f9;
}
.text-icon {
display: grid;
place-items: center;
color: #1d4ed8;
background: #e8efff;
font-weight: 700;
}
.site-category {
color: #5b6474;
font-size: 0.9rem;
}
七、配置卡片图标的几种方式
优先使用自有静态文件。将图标放在项目目录中,数据使用相对路径,例如 icon: "./icons/docs.svg"。确认服务器会按路径提供该文件,并且部署到子目录时相对路径仍然正确。维护者应检查图标文件来源,避免把未知 SVG 当作可信内容;SVG 作为图片加载与把 SVG 内联到 HTML 的风险模型不同,但来源仍应可控。
使用远程图标时明确写入 HTTPS 地址。远程图片可能因网络故障、站点策略、图标被删除或防盗链而加载失败,因此需要回退方案。图片请求通常会让浏览器连接到图标所在的第三方主机,可能暴露访客的网络信息或浏览器请求信息;对隐私敏感的网站,可将经审核的图标托管在自己的域名下。浏览器加载图片通常不要求目标服务器开放 JavaScript 的跨域读取权限,但这并不意味着图片一定能显示,也不意味着可以读取其内容。
不建议无条件自动拼接 favicon 地址。“目标域名加固定路径”的做法依赖目标站点的部署约定,不保证存在;直接访问图标服务还可能引入第三方请求、可用性和隐私问题。若确需自动发现或代理图标,应单独处理重定向、响应类型、缓存、请求频率和目标地址限制,尤其避免服务器端代理接受任意用户 URL 而形成 SSRF 风险。本文的静态示例不包含抓取或代理功能。
八、运行和验证
在项目目录启动本地静态服务器。使用 Python 的一个可选方式是:
python -m http.server 8000
然后在浏览器打开 http://localhost:8000/。也可以使用 Node.js 生态中任意可信的静态文件服务器。测试时至少检查以下情况:
- 点击“开发”“设计”和“全部”,确认分类结果正确,按钮的选中状态同步更新。
- 输入站点名称、说明、分类和网址中的关键词,确认能搜索到对应卡片;清空搜索后应恢复当前分类下的全部结果。
- 把一个图标字段设为有效的本地路径,再设为不存在的路径,分别确认图片和文字回退状态。
- 把某个
url改为缺少协议、格式错误或javascript:地址,确认卡片不生成可执行的危险链接。 - 检查浏览器开发者工具的控制台和网络面板,排查脚本路径、样式路径、图标 404 和资源加载错误。
- 修改 favicon 后可能仍看到旧图标,这是浏览器缓存常见现象。可清理站点缓存或使用新文件名验证;不要仅凭标签页暂时未变化就判断配置无效。
九、常见错误及处理方式
1. 分类按钮有了,卡片却筛选不出来
多数情况下是分类字符串不一致,例如按钮使用“开发工具”,数据写成“开发”。分类应集中定义,并在维护数据时采用同一套值。若站点来自外部数据源,可在导入阶段检查分类是否合法;不要依赖界面上恰好没有显示某个分类来代替数据校验。
2. 搜索只能匹配名称
检查构造搜索文本时是否包含说明、分类和网址。本文把这些字段合并后统一转成小写,再用 includes 匹配。对于多语言搜索、拼音、错别字容错或大量数据的全文检索,这种实现并不充分;可以再设计索引或采用合适的搜索服务,但不要把普通子串匹配宣传成语义搜索。
3. 用模板字符串拼接卡片 HTML
例如把来自 API 的标题直接拼进 innerHTML,可能让输入被解释为标记或事件属性。本文使用 textContent 写入站点名称和说明。若确实需要渲染受限富文本,应选择经过维护的消毒方案并定义允许标签,不要自己用几个字符串替换来模拟 HTML 清理。
4. 图标加载失败后反复触发错误
错误事件中应替换为不会再次请求失效地址的内容。本文用文字节点替代图片,并通过一次性事件监听避免重复处理。回退元素使用空的 alt,因为它仅作装饰;如果图标本身承载了重要信息,则应提供合适的替代文本。
5. 站点跳转地址能打开,但图标地址被拒绝
跳转地址与图标地址都经过 HTTP/HTTPS 协议校验,但浏览器仍可能因网络、服务器响应、证书、访问策略或资源不存在而无法加载图标。协议校验只解决允许哪类 URL 的问题,不负责验证内容正确性、站点信誉、证书有效性或业务可用性。
6. 新标签页打开后未设置安全属性
使用 target="_blank" 时保留 rel="noopener noreferrer"。其中 noopener 用于阻止新页面通过 window.opener 操作来源页面;noreferrer 会抑制引用来源信息,并在常见浏览器实现中也具有不提供 opener 的效果。若业务需要发送来源信息,应理解相应隐私影响,并另行评估属性组合。
7. 在生产站点加入用户自定义链接,却只做前端校验
前端检查便于改善使用体验,但任何用户都可以绕过前端直接向接口提交请求。若将来加入编辑后台,必须在服务端验证并保存数据,按用户身份检查授权,限制可接受的字段和协议,并记录必要的审计信息。输出到页面时仍应进行与上下文匹配的安全处理。本文代码不提供这些后台能力。
十、二次开发建议
当站点数量增长时,可以把数据单独放在 JSON 文件中,由前端读取,并在构建或发布前校验必填字段、重复 ID、无效网址和未知分类。将数据外置只是组织方式的改变,不会自动提供权限、安全校验或后台编辑。若站点数据需要非技术人员维护,可考虑内容管理系统或自建服务,但应把数据编辑、发布审核和访客展示分开设计。
也可以增加“最近添加”、标签或收藏等功能,但建议先把数据结构和筛选逻辑写清楚。收藏若保存在浏览器本地,只对当前浏览器生效,清除站点数据后可能丢失,也不会自动同步到其他设备;若要跨设备同步,就需要账号和服务端存储,并应说明数据保留与隐私规则。
这套实现的核心取舍是保持简单:分类和搜索在客户端完成,站点内容由可信维护者提供,卡片仅接受明确的 HTTP/HTTPS 地址,文本按纯文本渲染,图标加载失败时有回退。了解这些边界之后,再根据实际访问量、编辑需求和安全要求决定是否引入框架、搜索服务或后台,而不是为了一个筛选按钮就把整套站点复杂化。




