
1. 千年之恋这个项目到底想做一个什么样的网站先解释一下这个名字。很多人听到千年之恋四个字第一反应是言情故事、古风游戏之类但它其实是我给自己一个背包客主题网站起的名字。做这个网站的念头是在一次长途旅行途中冒出来的。当时我在青旅里翻一本留言簿看到天南地北的人写下各种关于路上的人和事有人写如果能重来我还会选这条路有人写希望下次能带喜欢的人一起来。我突然觉得旅行者对一个地方、一段经历的感情某种程度上就是一场跨越时间的恋慕——可能隔了很多年再去那个地方已经变了但心里的记忆还在。于是千年之恋就变成了这个网站的代号网站的slogan我也顺手定成了走很远的路记住很旧的事。从技术角度说这就是一个非常典型的网页制作项目用HTML、CSS、JavaScript做一个面向背包客群体的内容展示站点包含首页、旅行日记、路线推荐、关于我们几个核心板块。整个项目用到的都是web前端开发的基础能力没有复杂框架没有后端服务但要做到能看、能点、能适配手机、能部署上线。它适合三类人参考一是刚学完HTML/CSS/JS但没完整做过一个网站的新手二是想给自己或某个圈子做一个内容站点的运营型玩家三是需要把手头静态页面从本地一路部署到服务器上的开发者。这篇文章把整个过程拆开讲一遍——从构思、建项目、写页面到部署、配Nginx、处理安全细节每个环节里有哪些坑、为什么这样做我都尽量说明白。需要提前说明的是背包客网页制作这个方向听起来偏内容而非技术但真正做起来你会发现它几乎覆盖了web项目的常见问题目录结构怎么摆、本地服务器端口冲突怎么解决、Service Worker为什么注册失败、Nginx如何托管多个站点、静态站也要不要考虑安全……这些问题我在这个项目里全都遇到了。与其东一篇西一篇地查资料不如直接对着一个完整案例走一遍。下面按我实际开发顺序来写中间穿插踩坑记录。2. 建项目这一步就劝退了不少人IDEA创建Web项目、目录结构与端口占用的连环坑2.1 用IDEA从零创建一个Web项目的完整操作千年之恋这个项目我最初是用IDEA 2024版本创建的。之所以选IDEA而不是VS Code或HBuilder是因为我当时还在同时维护一个Java Web练习项目不想在编辑器之间来回切换。IDEA对纯静态web项目的支持其实被很多人低估了新建一个普通项目手动补上文件夹结构配置好浏览器调试体验并不差。具体步骤如下IDEA 2024/2025通用打开IDEA选择 New Project左侧选 Empty Project输入项目名millennium-love-travelSDK可以不选或随意选一个已安装的JDK因为静态网页用不到编译。创建后在项目根目录下手动新建这些文件夹css、js、assets/images、assets/icons、pages并在根目录放一个index.html。右上角打开 Edit Configurations点加号新增一个 JavaScript Debug 类型的运行配置URL填http://localhost:63342/项目名/index.html这是IDEA内置Web服务器默认端口Browser选Chrome。保存后直接点Debug就能在浏览器里实时预览。如果是偏Java Web的路子则需要创建 Maven 项目ArtifactId选择maven-archetype-webapp补上src/main/java和src/main/webapp结构用Maven打包成War再丢给Tomcat。这属于企业级web开发的玩法对千年之恋这种纯展示站来说属于杀鸡用牛刀但如果你想练手这条路径也值得走一遍。两种方式没有绝对的对错。我的建议是如果网站不需要动态数据就走纯静态方案如果后续要接入留言、用户登录、内容管理系统那从一开始就按Java Web标准目录结构或前后端分离来搭不然后期重构成本很高。2.2 目录结构不能随手乱建前后端方案差异很大很多人建网页喜欢把css和js全部堆在根目录或者在本地能跑就完事。直到部署上Nginx或者打包发布时才发现路径全乱了到处404。这里把两种典型结构的逻辑说清楚。纯静态站点的标准结构millennium-love-travel/ ├── index.html ├── pages/ │ ├── diaries.html │ ├── routes.html │ └── about.html ├── css/ │ ├── style.css │ ├── responsive.css │ └── print.css ├── js/ │ ├── data.js │ ├── render.js │ └── map.js └── assets/ ├── images/ └── icons/这个结构的核心逻辑是按资源类型分目录页面路径用相对路径引用资源css/style.css、../css/style.css这样整个文件夹拷到任何一台服务器上都能直接跑。**我踩过的一个坑是图片路径用了绝对路径/assets/images/xx.jpg本地打开文件没问题一旦部署到Nginx子目录或者Tomcat上下文路径非根路径时所有图片全部404。**后来统一改成相对路径并用./和../明确层级问题才解决。Java Web项目标准目录结构如果你走MavenTomcat路线src/main/java/ src/main/resources/ src/main/webapp/ ├── WEB-INF/ │ ├── web.xml │ └── views/ ├── css/ ├── js/ └── index.htmlJava Web里页面或静态资源必须放在webapp下WEB-INF下的内容不能直接通过URL访问只能通过服务端转发。这个规则很多人第一次接触时完全不理解甚至报com.ibm.ws.webcontainer.internal.webcontainer handlerequest srve0255e这类的容器错误时第一反应是代码问题其实是资源放错了位置。对纯前端项目来说Web项目的目录结构原则就是高内聚、按类型分、引用相对化记住这三点就够了。2.3 8080端口被占用和Service Worker注册失败其实是同一类问题我第一次在IDEA里尝试用Tomcat跑一个Java Web测试项目时直接弹了idea web server failed to start. port 8080 was already in use。这句报错很经典意思是本机8080端口已经被其他进程占用了。排查链路如下Windows下在命令行执行netstat -ano | findstr :8080找到占用端口的PID。再执行tasklist | findstr PID号看是哪个程序占用了端口。常见占用者之前没关干净的Tomcat、另一个IDEA实例、或者某些开发工具的本地服务。解决要么结束进程要么改端口Tomcat在conf/server.xml里改Connector端口IDEA内置服务器在运行配置里改要么直接换用静态方案不依赖Tomcat。Service Worker的问题则是另一回事。我在给千年之恋做离线缓存实验时浏览器控制台报了could not register service worker: InvalidStateError。查了一圈才知道Service Worker的注册有硬性约束页面的协议必须是localhost或HTTPS普通HTTP协议下的非localhost站点不允许注册而且Service Worker文件的路径决定了它的作用域文件放得不对控制台就会报这个InvalidStateError。后来把sw.js放到站点根目录并通过navigator.serviceWorker.register(./sw.js)来注册问题就消失了。这两个问题本质上都指向同一个教训**web项目跑不起来先怀疑环境再怀疑代码。**端口冲突是环境问题SW注册失败是环境限制问题跟你的JS逻辑没关系。如果第一步就埋头改代码半天也找不出原因。3. 页面实现的核心语义化HTML、响应式布局与一点不复杂的JavaScript3.1 首页内容架构从旅行者的视角倒推页面模块千年之恋首页需要承担的任务是第一屏让访客知道这个站是干嘛的第二屏给出最有吸引力的旅行日记入口第三屏展示推荐路线底部再放关于本站的信息。对应到代码结构就是典型的header classsite-header nav classmain-nav aria-label主导航 a hrefindex.html classlogo千年之恋/a ul lia hrefpages/diaries.html旅行日记/a/li lia hrefpages/routes.html路线推荐/a/li lia hrefpages/about.html关于/a/li /ul /nav /header main section classhero h1走很远的路记住很旧的事/h1 p一个背包客的十年旅行记录/p a hrefpages/diaries.html classbtn-primary开始阅读/a /section section classfeatured-diaries h2最近的故事/h2 div iddiary-cards classcard-grid!-- 由JS渲染 --/div /section section classroute-preview h2热门路线/h2 !-- 静态卡片列表 -- /section /main footer classsite-footer p© 千年之恋 · 背包客网页制作项目/p /footer这里有几个容易忽略的点导航区加aria-label让读屏软件能分清多个navhero区的标题用h1页面内只保留一个h1卡片区不直接硬编码HTML而是交给JS渲染——这样后续更新内容只需要改数据文件不需要动结构。语义化标签不只是给SEO看的更是给维护者看的半年后再回来改代码看到section、article、aside就能知道哪块是哪块这比满屏的div省心得多。3.2 响应式布局断点怎么选、卡片怎么排、图片怎么变千年之恋的目标用户是背包客他们绝大多数用手机看攻略。所以这个项目的CSS一定要走移动端优先的思路。我采用的基础断点是断点典型设备布局策略小于576px手机竖屏单列、导航折叠为汉堡菜单576px - 992px平板/手机横屏双列卡片、导航展开大于992px桌面端三列卡片、内容最大宽度1200px居中移动端优先的含义是先写单列样式作为默认再用media (min-width: 576px)和media (min-width: 992px)逐级增强。很多人习惯反过来写max-width也能实现效果但在维护时默认移动端、逐级放大更符合响应式的直觉。日记卡片用Flex还是Grid这个场景我选了Grid因为卡片数量动态变化且需要对齐.card-grid { display: grid; grid-template-columns: 1fr; gap: 24px; } media (min-width: 576px) { .card-grid { grid-template-columns: repeat(2, 1fr); } } media (min-width: 992px) { .card-grid { grid-template-columns: repeat(3, 1fr); } }图片的响应式处理比盒子布局更容易被忽略。原始照片如果是单反拍的一张可能8MB直接放进网页就是灾难。我做了三层处理用工具把图片压到宽度不超过1600px、体积控制在200KB以内HTML里加srcset让浏览器按屏幕密度选图再给非首屏图片加loadinglazy延迟加载。这一套下来首页从将近20MB的加载量降到不到2MB对手机流量用户非常友好。3.3 JavaScript只要做一件事数据和视图分开这个站点的日记列表如果用静态HTML写死每新增一篇日记就得复制一大段结构还要保证class不写错。我换了一种组织方式把日记内容放在js/data.js里渲染逻辑放在js/render.js里页面里只留一个容器元素。data.js的结构大概是这样const diaries [ { id: 1, title: 雨崩徒步三天没有信号的慢生活, location: 云南 · 迪庆, date: 2025-03-12, cover: assets/images/yubeng.jpg, excerpt: 进山第二天手机就没电了反而看见了银河。, tags: [徒步, 云南] }, { id: 2, title: 搭车去喀什陌生人的善意让我一路向南, location: 新疆 · 喀什, date: 2024-10-08, cover: assets/images/kashgar.jpg, excerpt: 维吾尔族大叔递来一块馕那一刻差点落泪。, tags: [搭车, 新疆] } ];render.js里用数组遍历生成卡片function renderDiaryCards(data, containerId) { const container document.getElementById(containerId); container.innerHTML data.map(item article classdiary-card img src${item.cover} alt${item.title} loadinglazy div classdiary-card-body span classdiary-location${item.location}/span h3${item.title}/h3 p${item.excerpt}/p time datetime${item.date}${item.date}/time /div /article ).join(); } renderDiaryCards(diaries, diary-cards);这样设计的最大好处是以后加内容只需要在data.js里push一个对象页面自动多一张卡片。如果哪天想接后端比如用Python FastAPI SQLAlchemy从数据库读日记列表前端也只需要把diaries换成fetch(/api/diaries)的返回结果渲染函数完全不用改。这也回答了热搜里fastapi和sqlalchemy构建高性能web服务和pythondash快速web应用开发这类话题的定位它们适合数据驱动、需要管理后台的场景而像千年之恋这种纯内容展示站先用数据与视图分离的JS方案已经赢过了80%的静态博客。4. 部署上线Nginx托管千年之恋以及一台服务器如何跑多个Web项目4.1 最小可用的Nginx站点配置开发完成后我把千年之恋部署到了一台Linux服务器上。因为整个站点就是一堆静态文件Nginx是最合适的Web服务器性能好、配置简单、资源占用低。先看最简单的单站点配置server { listen 80; server_name millennium.example.com; root /var/www/millennium-love-travel; index index.html; location / { try_files $uri $uri/ 404; } }这段配置里root指向站点文件实际存放的目录index指定默认访问的首页文件。try_files $uri $uri/ 404这行的作用是先尝试按当前路径找文件找不到就按目录找再找不到就返回404。不加这行的话访问/pages/diaries.html这类真实存在的文件没问题但访问/pages/diaries/带斜杠目录形式就可能会403或404。部署动作本身很简单把项目文件夹传上服务器放到/var/www/下在/etc/nginx/conf.d/里新建配置文件执行nginx -t检查语法再systemctl reload nginx即可。新手最容易出的问题是文件权限不对Nginx工作进程没有读取权限直接403。排查命令是ps aux | grep nginx看worker进程的用户通常是nginx或www-data然后把站点目录的读权限放开chown -R nginx:nginx /var/www/millennium-love-travel。4.2 一台机器部署多个项目的三种姿势这个热搜词很实在。很多人手里只有一台服务器不可能一个网站买一台机器所以nginx部署多个web项目是刚需。常见做法有三种方式适用场景配置要点不同端口内部测试、临时演示每个server块分别listen 8081、listen 8082不同域名正式上线、对外服务每个server块配不同server_name共用一个80端口同一域名不同路径微服务聚合、单IP多站用location /site1/、location /site2/区分不同端口的配置最简单server { listen 8081; root /var/www/millennium-love-travel; } server { listen 8082; root /var/www/some-other-site; }不同域名的方式是最推荐的正式方案。每个站点一个配置文件彼此完全隔离# /etc/nginx/conf.d/millennium.conf server { listen 80; server_name millennium.example.com; root /var/www/millennium-love-travel; } # /etc/nginx/conf.d/blog.conf server { listen 80; server_name blog.example.com; root /var/www/blog; }同一域名不同路径的方式最灵活但也最容易踩坑。因为页面里的相对路径是基于站点根目录的如果通过http://ip/千年之恋/去访问页面里写的css/style.css会被解析成http://ip/css/style.css而不是http://ip/千年之恋/css/style.css于是所有样式和图片全部丢失。我当时的解决办法是用location块配合alias指令location /travel/ { alias /var/www/millennium-love-travel/; index index.html; try_files $uri $uri/ 404; }alias和root的区别是root会把URI路径拼在root目录后面alias则会用URI里location匹配到的部分替换为指定目录。很多人在Nginx下部署多个web项目时样式全丢十有八九就是root和alias没搞清。4.3 静态站点也要讲Web安全这不是小题大做看到web安全基础web服务器安全这些热搜有人会觉得与我无关——我又没写后端数据库都没有安全个啥这种想法很危险。静态站同样会被攻击被刷流量、被放钓鱼页面、被篡改内容的事并不罕见。即使纯静态Nginx层面也需要做几件基本防护。我在千年之恋的配置里加了这些# 隐藏服务器版本号 server_tokens off; # 禁止列出目录 autoindex off; # 只允许常见的HTTP方法 if ($request_method !~ ^(GET|HEAD|POST)$) { return 405; } # 防止嵌入iframe引发点击劫持 add_header X-Frame-Options SAMEORIGIN; # 基本内容安全策略 add_header Content-Security-Policy default-src self; img-src self https:; style-src self unsafe-inline;Content-Security-Policy这个头值得单独解释一下。它告诉浏览器当前页面只能加载哪些来源的资源。default-src self表示默认只允许加载同源资源img-src self https:表示图片可以来自本站或任意HTTPS源style-src self unsafe-inline允许本站样式表和内联style。有了它即使攻击者往页面里注入了指向外部域名的脚本浏览器也会直接拒绝加载。对于想在Web安全方向深入的朋友我建议去刷一遍CTF里的Web类型题目——热搜词里ctf web解题 找flag夺旗赛说的就是这类。CTF Web题会把SQL注入、XSS、文件上传漏洞、SSRF这些真实攻击手法浓缩成一个个小谜题刷完能让你在写代码时自带这种写法会被绕过吗的条件反射。即便不搞安全方向对理解Web服务器工作原理也有很大帮助。但要提醒的是动手实验请在本地虚拟机或CTF平台进行不要去公网搞真实站点。如果未来千年之恋要加后端接口——比如用FastAPI写一个留言板SQL注入、身份验证越权这些就真的要提上日程了Web安全基础那时候就不是选修课而是必修课。4.4 服务器本身的安全基线除了Nginx配置服务器层面的安全基线也很重要。我给千年之恋所在服务器做了这几件事关闭密码登录只保留SSH密钥登录修改SSH默认端口到高位端口降低被暴力破解的概率。用ufw或firewalld只放行22、80、443三个端口。配置自动安全更新内核有漏洞时第一时间补上。定期备份整个站点目录备份文件放在不同机器上。静态站被攻破的常见路径其实不是代码漏洞而是服务器本身被入侵。比如SSH弱口令爆破成功攻击者拿到权限后往站点目录塞恶意脚本。所以服务器安全这一层无论站点多简单都不能省。5. 上线前后的加分项性能优化与网页打印5.1 加载性能立竿见影的几项优化部署完成不等于优化完成。我给千年之恋做了几项性能优化用Lighthouse测试移动端性能分数从68分提升到了94分。核心动作如下开启Gzip压缩。Nginx配置里加上gzip on; gzip_types text/html text/css application/javascript application/json image/svgxml;文本类资源能压掉60%到80%的体积。CSS和JS这类纯文本文件收益最大。设置静态资源缓存。图片、CSS、JS这种文件名不变的文件加一个location ~* \.(jpg|jpeg|png|css|js)$ { expires 7d; }让浏览器7天内不用重复下载。图片压缩与响应式适配。这个在做页面时已经提到上线后我又用工具把所有JPEG重新压了一遍。把字体和脚本的加载时机调对。首屏不需要的JS加上deferCSS只有打印时才用的放到mediaprint。不要小看这些动作。背包客在偏远地区用的可能是很差的网络一个页面加载超过3秒访客大概率直接关掉。静态站能做到秒开是内容站最大的优势。5.2 Web页面PDF打印需求怎么做最省心web页面pdf打印这个热搜词也出现在了我的项目里。背包客用户有一个实际需求想把某篇路线攻略打印成PDF带到路上没信号时也能看。实现方案我梳理下来有三种方案一浏览器内置打印最简单直接调用window.print()或者用户在浏览器里按CtrlP选择另存为PDF。关键是要写好打印样式media print { .site-header, .site-footer, .route-preview { display: none; } body { font-size: 12pt; } .diary-card { break-inside: avoid; } }break-inside: avoid的意思是尽量不要把一个卡片/段落截断到两页之间这是打印样式里最实用的一个属性。我实测下来Chrome和Edge对这套CSS的支持很好Firefox稍差但也能接受。方案二html2canvas jsPDF最折腾把DOM节点转成canvas图像再嵌入PDF。缺点很明显文字变成图片不可复制中文字体容易糊长页面一页截不完。这个方案适合做截图式的分享海报不适合做正经文档。方案三服务端生成PDF最正规如果以后加了后端可以用服务端的PDF生成库比如FastReport Web这类商业组件或者开源的wkhtmltopdf/WeasyPrint。服务端渲染PDF的好处是排版稳定、字体可控缺点是引入了服务端依赖。对千年之恋这种纯静态站我选方案一配好media print样式后用户一键打印成PDF的排版效果已经足够好。这里有个经验打印样式的调试不能省你以为手机上看到的卡片在A4纸上也会同样好看实际上会显示得一塌糊涂。在Chrome DevTools里可以模拟打印效果记得每个页面都过一遍。6. 复盘这个项目带给我最值钱的几点积累项目收尾阶段我回头整理了一下整个过程中真正有价值的东西不是某个具体技术而是一套做web项目的思考方式。目录结构是一切的地基。无论是纯静态还是Java Web项目文件夹结构就是给未来自己看的说明书。结构清晰的项目隔半年打开还能3分钟找到要改的文件结构混乱的项目哪怕代码写得再漂亮维护时也想重写。环境问题占排错的大头。这次遇到的端口占用、Service Worker注册失败、Nginx 403、加载web视图出错几乎全是环境配置问题而非代码逻辑问题。排查时先看报错信息的字面意思再确认环境约束最后才怀疑代码。顺序反了效率会差很多。内容驱动的网站技术选型越轻越好。背包客站点的核心是内容更新和阅读体验不需要复杂的架构。这个项目让我更加认同一个观点能用静态HTML解决的问题就不要急着上框架、上数据库。等真有动态需求时再演进远比一开始过度设计要好。安全是习惯而不是功能。不做后端不代表不需要安全意识。Nginx的基础加固、服务器的SSH配置、文件的权限管理这些都是花几分钟就能做好的事但很多人不做。等有问题时再补代价就不是几分钟的问题了。根据我个人的实际体验这类小项目、全流程的练习带给人的成长往往比看很多教程更扎实。如果你也正打算做一个自己的网站不用纠结名字够不够酷、技术够不够新先把一个页面做出来、把流程跑通再慢慢打磨内容和细节。做网站这件事真正难的不是技术而是把一个想法坚持到上线那一刻。