
如何部署导航网站源码并配置自己的网址目录
网址导航看起来只是把一组链接排成页面,实际部署时仍要处理目录维护、输入校验、域名解析、HTTPS 和静态文件服务等问题。本文从一个可运行的静态导航站开始:网址数据放在 JSON 文件中,页面使用原生 HTML、CSS 和 JavaScript,不依赖数据库或前端构建工具;之后用 Nginx 和 Docker 部署,并说明如何绑定自己的域名。
这种方案适合个人主页、团队内部入口页、项目链接集合和小型公开导航。它不是带用户账号、在线编辑和权限管理的完整内容管理系统。如果多人需要通过网页编辑链接,或需要用户收藏、登录、统计访问等功能,应在此基础上增加后端和访问控制,不能仅靠静态文件实现。
一、先明确部署边界
静态导航站通常由网页文件和链接数据组成。浏览器下载网页后,在本地显示目录、过滤分类并搜索;服务器只负责返回文件。它不需要数据库,也不需要为每个访客运行应用进程。
- 适合:链接变化不频繁、由站点维护者更新内容的目录站。
- 不适合直接承担:用户提交链接、账号权限、动态审核、个性化收藏、后台管理等业务。
- 需要注意:公开页面中的链接对所有访客可见。把内网地址写进页面,不等于建立了安全的内网访问控制。
- 部署责任:托管服务商、域名注册商和 DNS 服务商通常是不同的服务;部署文件不会自动完成域名解析或 HTTPS 配置。
下面的示例采用一项明确的应用策略:最多展示 300 个有效网址;标题不超过 80 个字符,分类不超过 40 个字符,说明不超过 200 个字符;链接长度不超过 2048 个字符,只允许 HTTP 和 HTTPS。它们是示例代码主动设定的限制,不是互联网协议规定的统一上限。实际项目可以调整,但应保持前后端规则一致。
二、准备项目文件
创建目录 my-nav,文件结构如下:
my-nav/
├── index.html
├── sites.json
├── app.js
├── styles.css
├── Dockerfile
└── nginx.conf
网页和数据分开,维护者只需编辑 sites.json 就能增删目录项。页面不会把目录数据当作 HTML 插入,因此链接名称中的特殊字符会作为文本显示,而不是被浏览器解释成标签。
1. 编写链接数据
新建 sites.json,初始内容如下:
[
{
"title": "MDN Web Docs",
"url": "https://developer.mozilla.org/",
"category": "开发",
"description": "Web 平台、HTML、CSS 和 JavaScript 文档"
},
{
"title": "GitHub",
"url": "https://github.com/",
"category": "开发",
"description": "代码托管与协作平台"
},
{
"title": "Example 内部入口",
"url": "https://intranet.example.com/",
"category": "工作",
"description": "仅适用于已接入组织网络的人员"
}
]
JSON 是严格格式:字符串必须使用双引号,不能写注释,数组最后一项后不能保留逗号。URL 应使用完整地址,例如 https://example.com/;如果写成 example.com,浏览器可能把它当成当前站点下的相对路径。
内部系统入口是否应该出现在公开导航中,要结合访问控制判断。把内网地址公开会暴露主机名、系统用途等信息,且可能诱使不具备访问权限的访客点击。真正的保护应由 VPN、身份认证、网络隔离和服务端权限控制提供。
2. 编写页面结构
新建 index.html。页面包含标题、搜索框、分类选择框和卡片容器;样式与逻辑分别放在独立文件中。
<!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="stylesheet" href="/styles.css">
<script src="/app.js" defer></script>
</head>
<body>
<main class="page">
<header class="header">
<p class="eyebrow">LINK DIRECTORY</p>
<h1>我的网址导航</h1>
<p>把常用入口整理在一个页面中。</p>
</header>
<section class="tools" aria-label="筛选网址">
<label for="search">搜索</label>
<input id="search" type="search" placeholder="搜索名称、分类或说明">
<label for="category">分类</label>
<select id="category">
<option value="">全部分类</option>
</select>
</section>
<p id="status" class="status" role="status">正在加载网址…</p>
<section id="directory" class="directory" aria-label="网址目录"></section>
</main>
</body>
</html>
3. 添加样式
新建 styles.css。这里使用响应式网格,窄屏时卡片自动变为单列,不需要额外的 CSS 框架。
:root {
color-scheme: light;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: #202938;
background: #f5f7fb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.page {
width: min(100% - 32px, 1040px);
margin: 0 auto;
padding: 56px 0;
}
.header {
margin-bottom: 28px;
}
.eyebrow {
color: #5b6fc7;
font-size: 12px;
font-weight: 700;
letter-spacing: .12em;
}
h1 {
margin: 8px 0;
font-size: clamp(30px, 6vw, 48px);
}
.header p {
color: #657084;
}
.tools {
display: grid;
grid-template-columns: auto minmax(180px, 1fr) auto minmax(150px, 220px);
align-items: center;
gap: 12px;
margin: 24px 0;
}
input,
select {
min-height: 42px;
padding: 8px 12px;
border: 1px solid #d8deea;
border-radius: 8px;
background: #fff;
font: inherit;
}
.status {
min-height: 24px;
color: #657084;
}
.directory {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
gap: 16px;
}
.card {
min-width: 0;
padding: 18px;
border: 1px solid #e1e6ef;
border-radius: 12px;
background: #fff;
}
.card h2 {
margin: 0 0 8px;
font-size: 18px;
}
.card p {
color: #657084;
line-height: 1.6;
}
.card .category {
color: #5b6fc7;
font-size: 13px;
}
.card a {
color: inherit;
text-decoration: none;
}
.card a:hover,
.card a:focus-visible {
color: #4058b6;
text-decoration: underline;
}
@media (max-width: 620px) {
.page {
padding-top: 32px;
}
.tools {
grid-template-columns: 1fr;
}
}
4. 加载、校验和展示数据
新建 app.js。浏览器端校验不是用来替代服务器安全控制,而是避免格式错误、过长数据和不需要的协议进入页面。代码仅创建 DOM 节点并写入 textContent,不使用 innerHTML 拼接外部字符串。
"use strict";
const searchInput = document.querySelector("#search");
const categorySelect = document.querySelector("#category");
const directory = document.querySelector("#directory");
const status = document.querySelector("#status");
const MAX_ITEMS = 300;
const MAX_TITLE = 80;
const MAX_CATEGORY = 40;
const MAX_DESCRIPTION = 200;
const MAX_URL = 2048;
function cleanText(value, maxLength) {
if (typeof value !== "string") return "";
return value.trim().slice(0, maxLength);
}
function validUrl(value) {
const text = cleanText(value, MAX_URL);
if (!text || value.trim().length > MAX_URL) return null;
try {
const parsed = new URL(text);
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return null;
}
if (parsed.username || parsed.password) return null;
return parsed.href;
} catch {
return null;
}
}
function normalizeItem(item) {
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
const title = cleanText(item.title, MAX_TITLE);
const category = cleanText(item.category, MAX_CATEGORY);
const description = cleanText(item.description, MAX_DESCRIPTION);
const url = typeof item.url === "string" ? validUrl(item.url) : null;
if (!title || !category || !url) return null;
return { title, category, description, url };
}
function render(items) {
const query = searchInput.value.trim().toLocaleLowerCase();
const selectedCategory = categorySelect.value;
const filtered = items.filter((item) => {
const matchesCategory =
!selectedCategory || item.category === selectedCategory;
const text = `${item.title} ${item.category} ${item.description}`
.toLocaleLowerCase();
return matchesCategory && text.includes(query);
});
directory.replaceChildren();
for (const item of filtered) {
const card = document.createElement("article");
card.className = "card";
const heading = document.createElement("h2");
const link = document.createElement("a");
link.href = item.url;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = item.title;
heading.append(link);
const category = document.createElement("p");
category.className = "category";
category.textContent = item.category;
const description = document.createElement("p");
description.textContent = item.description;
card.append(heading, category, description);
directory.append(card);
}
status.textContent = `显示 ${filtered.length} 个网址`;
}
async function start() {
try {
const response = await fetch("/sites.json", { cache: "no-cache" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (!Array.isArray(data)) {
throw new Error("网址数据必须是 JSON 数组");
}
const items = data
.slice(0, MAX_ITEMS)
.map(normalizeItem)
.filter(Boolean);
const categories = [...new Set(items.map((item) => item.category))].sort();
for (const name of categories) {
const option = document.createElement("option");
option.value = name;
option.textContent = name;
categorySelect.append(option);
}
render(items);
if (items.length < data.length) {
status.textContent += `;最多读取前 ${MAX_ITEMS} 项`;
}
} catch (error) {
console.error("无法加载网址目录:", error);
status.textContent = "网址目录加载失败,请检查 sites.json 和服务器配置。";
}
}
searchInput.addEventListener("input", () => {
if (Array.isArray(window.navItems)) render(window.navItems);
});
categorySelect.addEventListener("change", () => {
if (Array.isArray(window.navItems)) render(window.navItems);
});
async function loadDirectory() {
try {
const response = await fetch("/sites.json", { cache: "no-cache" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (!Array.isArray(data)) throw new Error("数据不是数组");
window.navItems = data
.slice(0, MAX_ITEMS)
.map(normalizeItem)
.filter(Boolean);
const categories = [...new Set(window.navItems.map((item) => item.category))].sort();
for (const name of categories) {
const option = document.createElement("option");
option.value = name;
option.textContent = name;
categorySelect.append(option);
}
render(window.navItems);
if (window.navItems.length < data.length) {
status.textContent += `;最多读取前 ${MAX_ITEMS} 项`;
}
} catch (error) {
console.error("无法加载网址目录:", error);
status.textContent = "网址目录加载失败,请检查 sites.json 和服务器配置。";
}
}
searchInput.addEventListener("input", () => render(window.navItems || []));
categorySelect.addEventListener("change", () => render(window.navItems || []));
loadDirectory();
上面的示例应删除 start() 函数和它之前的两组事件监听,只保留末尾的 loadDirectory() 实现及最后两组监听。为避免复制时产生重复逻辑,更推荐直接采用下面这个整理规则:文件中只保留一个加载函数、一组事件监听。也可以将前面的代码中从 async function start() 开始的部分整体替换为末尾的 loadDirectory() 版本。
如果希望避免上述替换步骤,以下是 app.js 中必须保留的完整尾部逻辑:也就是说,实际文件中不要重复声明 searchInput、categorySelect 等变量,加载逻辑只执行一次。完成后搜索框会匹配名称、分类和说明,分类框会根据有效数据动态生成选项。
校验的安全边界:只允许 HTTP/HTTPS,可以排除 javascript:、data: 等不适合直接作为导航目标的协议;拒绝 URL 中的用户名和密码,可避免展示类似 https://user:[email protected]/ 的链接。它不能判断一个 HTTP/HTTPS 网站是否可信,也不能阻止钓鱼站、恶意下载或跳转链接。链接的最终目的地还可能在目标网站一侧变化,维护者应审核来源并定期复查。
此外,点击外部链接前,浏览器地址栏显示的域名仍是判断目的地的重要依据。rel="noopener noreferrer" 用于新标签页链接:noopener 防止新页面通过 window.opener 操作来源页,noreferrer 会抑制来源页地址作为 Referer 发送;它们不是对目标网站的信誉背书。
三、本地检查和调试
不要直接双击 index.html 作为正式测试方式。页面使用 fetch("/sites.json") 请求数据,本地文件地址可能受到浏览器的源策略限制。可用 Python 自带的静态服务器从项目目录启动临时服务:
cd my-nav
python3 -m http.server 8000
然后打开 http://localhost:8000/。如果电脑没有 Python,也可以用其他静态文件服务器。这个临时服务器适合本机检查,不应直接作为面向公网的生产服务。
检查以下内容:
- 页面打开后是否显示网址卡片,搜索和分类筛选是否工作。
- 在开发者工具的 Network 面板中,
/sites.json是否返回 200,响应是否为合法 JSON。 - 控制台是否有 JavaScript 错误;若目录为空,检查文件名大小写、JSON 逗号和访问路径。
- 故意把某条 URL 改成
javascript:alert(1),该条目应被忽略,而不是生成可点击链接。
四、使用 Nginx 和 Docker 部署
容器部署的好处是可以把文件和 Web 服务配置一同交付。这里的 Nginx 只提供静态文件,不负责 TLS 证书签发,也不提供用户认证。
新建 nginx.conf:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
location = /sites.json {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location ~* \.(css|js)$ {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
}
try_files 在文件不存在时返回 404,避免把所有路径都错误地返回主页。示例对数据、CSS 和 JS 设置不缓存或重新验证的策略,便于维护者更新目录后尽快看到变化。若改用带文件名哈希的构建产物,可对版本化静态资源设置长期缓存;不要在不了解缓存行为的情况下给可变的 sites.json 设置很长的强缓存时间。
新建 Dockerfile:
FROM nginx:stable-alpine
COPY index.html styles.css app.js sites.json /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
这里使用官方 Nginx 镜像作为运行环境。EXPOSE 80 是镜像元数据,表示容器预期使用的端口,并不会自动把端口发布到宿主机;端口映射要在运行命令或 Compose 配置中指定。
在项目目录构建并启动:
docker build -t my-nav:1.0 .
docker run -d --name my-nav --restart unless-stopped -p 8080:80 my-nav:1.0
此时可在部署机器上访问 http://服务器地址:8080/。若部署机器的防火墙或云平台安全组没有放行相应端口,外部访问仍会失败。将 8080 换成其他宿主机端口时,冒号右侧的容器端口仍应是 80,例如 -p 9000:80。
也可以新建 compose.yaml,用 Compose 管理容器:
services:
nav:
build: .
container_name: my-nav
restart: unless-stopped
ports:
- "8080:80"
在文件所在目录执行 docker compose up -d --build 启动;修改源码后再次执行该命令重建并更新。要查看错误,可运行 docker compose logs。不同服务器的 Docker、Compose 版本和安装方式可能不同,命令选项应以当前版本的文档为准。
五、配置自己的域名和 HTTPS
域名绑定一般分为四步:
- 准备域名:在注册商处拥有一个域名,并确认 DNS 管理入口。
- 配置 DNS:为域名或子域名添加解析记录。常见做法是用 A 记录指向服务器 IPv4 地址;有可用 IPv6 时可配置 AAAA 记录。CNAME 用于把名称指向另一个主机名,通常不能与同一主机名下的其他记录随意混用。具体规则以 DNS 服务商为准。
- 开放网络入口:确保服务器、防火墙和云安全组允许所需的入站端口。域名解析成功不代表网络端口已经开放。
- 配置 Web 服务与证书:Nginx 应匹配对应的
server_name,并部署有效证书,让 HTTPS 请求到达正确的站点。
DNS 解析到服务器后,仍需有服务监听相应端口。生产环境常见做法是在宿主机或独立入口服务上运行反向代理,由它监听 80/443、处理证书和 HTTPS,再把请求转发到导航容器的 80 端口。另一种方式是使用提供 HTTPS 的静态托管平台。选择哪种方式取决于你的基础设施,不存在仅修改 sites.json 就能自动启用 HTTPS 的通用步骤。
如果使用反向代理,确认代理目标、域名和证书覆盖的名称一致。例如证书只覆盖 nav.example.com,并不当然覆盖 example.com。证书申请与自动续期需要按照所用平台或证书工具配置;证书过期、域名解析错误、代理没有监听 443,都会导致 HTTPS 访问失败。不要为了绕过证书错误而要求访客忽略浏览器警告。
本文示例的 Nginx 容器只监听容器内的 80 端口,没有配置 HTTPS。若将容器端口直接发布到公网,它提供的是明文 HTTP。通过反向代理对外提供 HTTPS 时,还应确认代理到容器之间的网络暴露范围符合预期;不需要公网直连时,不要额外开放容器服务端口。
六、更新目录和部署版本
增加网址时,在 sites.json 数组中添加对象;删除时删除对应对象;调整分类只需修改 category。字段名称要与代码一致:title、url、category 和 description。标题或分类为空、URL 无效的条目会被忽略。
若使用 Docker 镜像部署,修改本地 JSON 文件不会自动改变已经构建好的镜像,需要重新构建并替换容器。生产环境宜把源码存入版本控制,先在本地检查,再通过 CI 构建镜像并发布;不要把服务器密码、访问令牌、私钥或任何秘密信息写进公开的 JSON、HTML 或 JavaScript。浏览器收到的前端文件和数据都可被任何访问者查看。
如果采用静态托管平台,通常将代码仓库连接到平台并设置发布目录即可,但具体入口、构建命令、域名绑定和证书策略因平台而异。当前示例没有构建步骤,发布目录就是项目中的静态文件所在目录。部署后要确认托管平台没有把路径前缀、缓存规则或访问控制配置成与页面请求不一致。
七、常见错误及排查顺序
页面能打开,但没有任何卡片
先查看 /sites.json 是否返回成功,再检查响应内容是否为合法 JSON。随后检查数据中的 URL 是否以 HTTP 或 HTTPS 开头,字段名是否拼写正确。若服务器使用子路径发布,代码中的 /sites.json 是从域名根路径请求;站点如果位于 /nav/ 这样的子目录,就要相应调整页面资源路径和数据请求路径。
本地正常,部署后 JavaScript 或 CSS 404
检查文件是否被复制到 Nginx 根目录、大小写是否一致,以及部署平台的发布目录设置。Linux 文件系统通常区分大小写,App.js 与 app.js 可能不是同一个文件。也要检查反向代理是否改写了路径。
浏览器显示旧目录
分别检查浏览器缓存、CDN 缓存和反向代理缓存。本文 Nginx 配置为数据文件设置 no-cache,含义是使用前应重新验证缓存,并非完全禁止缓存。CDN 仍可能有自己的缓存规则;必要时通过平台提供的刷新功能清理对应资源,并核对实际响应头。
域名无法访问,但服务器本机可以访问
依次检查 DNS 记录是否生效且指向正确地址、防火墙和云安全组是否放行、服务是否监听预期端口、反向代理是否配置正确。DNS 变更的生效时间受缓存影响,不能仅凭本机解析结果判断所有网络都已更新。
HTTPS 报证书错误或跳回 HTTP
核对证书是否包含正在访问的主机名、证书链是否完整、有效期是否正常,并检查代理的 HTTPS 终止和跳转规则。若 CDN 或代理处于 HTTPS 之后,还要按其产品文档配置源站连接模式,避免配置不一致形成重定向循环。
链接在新标签页打开,但无法判断是否可信
导航站只能提供链接,不能替目标网站做安全认证。应由维护者核对域名拼写和来源;公开站点可考虑标注链接用途,并及时删除失效或可疑条目。不要根据网页标题、HTTPS 锁标志或本站校验通过就推断目标网站一定安全。
八、上线前检查清单
- 确认所有公开链接均为预期的完整地址,分类和说明没有泄露不该公开的信息。
- 确认 JSON 合法,页面在窄屏和桌面浏览器中都可操作,搜索与分类筛选正常。
- 确认无效协议和含凭据的 URL 会被拒绝,用户提供的文本没有通过
innerHTML插入页面。 - 确认 DNS 指向正确服务器,入站端口、反向代理、证书和自动续期安排符合部署环境要求。
- 确认数据、页面脚本和服务器访问日志中没有密码、令牌或私钥。
- 确认更新发布、回滚和缓存刷新流程可执行,并留有源码版本记录。
静态导航站的优势是组成简单、资源消耗低、部署方式灵活。把链接数据独立维护、把输入当作不可信文本处理,并清楚区分“页面能展示链接”和“目标服务受到保护”,就能避免不少常见误区。后续若需要在线编辑、用户权限或访问统计,再根据实际需求增加后端,而不是把静态目录误当成具备这些能力的应用。




