☰
从零搭建 Node.js 学习官网:Express + SQLite + ESM 全流程实战
2026/10/1 13:40:41 网站建设 项目流程

一句话就能生成一个 Node.js 学习官网,并且直接发布上线——这个标题第一次看到的时候,我第一反应是"又是标题党"。但仔细拆解下来,这件事在当下确实是可以做到的,而且整个链路并不复杂:用 Express 搭一个内容站点,用 SQLite 存学习资料和课程数据,用 ESM 组织模块,前端用原生 JavaScript 做交互,最后部署到一台普通服务器上。真正花时间的不是写代码,而是把"学习官网"这个模糊需求拆成可执行的结构。

这篇内容适合两类人看:一类是刚学完 Node.js 基础、想找个完整项目练手的开发者;另一类是手里有一堆学习笔记、想快速做成一个可访问网站的技术博主。我会把从零到上线的完整过程拆开讲,包括技术选型的理由、目录结构怎么设计、数据库表怎么建、页面怎么渲染、部署时容易踩的坑,以及上线之后怎么继续维护。代码部分我会给出可直接运行的版本,环境配置和命令也会写清楚,尽量让你照着做就能跑起来。

1. 为什么这个项目选 Express + SQLite + ESM 这套组合

1.1 学习官网的本质是一个内容管理系统

很多人一听到"官网"就想到企业站、CMS、后台管理,觉得要上 React、要上数据库集群、要搞微服务。但 Node.js 学习官网的本质其实很简单:它就是一个内容管理系统,只不过内容全部围绕 Node.js 学习资料展开。核心功能无非是几个:展示课程或文章列表、展示单篇详情、支持分类筛选、可能还有一个简单的搜索。这些功能用 Express 做路由、用 SQLite 存数据、用模板引擎或原生 JS 渲染页面,完全够用。

我见过太多人一上来就选重型框架,结果光环境配置就耗掉两天,最后项目还没跑起来就放弃了。选型的核心原则是:让技术栈的复杂度匹配需求的复杂度。学习官网这种项目,需求复杂度低、并发量低、数据量小,选轻量方案反而更容易做完整、做上线。

1.2 Express 在这个场景下的真实优势

Express 是 Node.js 生态里最成熟的 Web 框架之一,它的优势不在于功能多,而在于中间件模型足够简单。一个请求进来,经过一系列中间件处理,最后返回响应,这个心智模型非常直观。对于学习官网这种项目,你需要的功能无非是:静态文件服务、路由分发、请求体解析、模板渲染,Express 全部内置或一行代码就能接入。

对比一下其他选择:Koa 更现代但生态相对小,Fastify 性能更好但对新手不够友好,NestJS 功能全但学习曲线陡。Express 的文档和社区案例是最多的,遇到问题搜索一下基本都能找到答案。对于一个练手项目来说,能快速解决问题比技术先进更重要。

1.3 SQLite 为什么比 MySQL 更适合这个项目

SQLite 是一个嵌入式数据库,整个数据库就是一个文件,不需要单独安装数据库服务,不需要配置用户名密码,不需要管理连接池。对于学习官网这种单机部署、读多写少的场景,SQLite 的性能完全够用,甚至在某些读场景下比 MySQL 还快,因为它省去了网络通信开销。

更重要的是部署体验。用 MySQL 的话,你需要在服务器上装 MySQL、建库、建用户、配权限、改配置文件,每一步都可能出问题。用 SQLite 的话,你只需要确保数据库文件存在,代码里指定路径就行。我实测下来,一个几千条数据的学习官网,SQLite 的查询响应基本在毫秒级,完全感受不到差异。

注意:SQLite 不适合高并发写入场景。如果你的官网需要支持大量用户同时提交评论或点赞,写入会锁库。但学习官网主要是读操作,这个问题基本不存在。

1.4 ESM 带来的模块组织方式变化

ESM 是 ECMAScript 模块系统,也就是import和export那套语法。Node.js 早期用的是 CommonJS,也就是require和module.exports。现在 Node.js 对 ESM 的支持已经非常成熟,新项目直接用 ESM 是更好的选择。

ESM 的好处有几个:语法更清晰,静态分析更友好,和前端 JavaScript 的模块语法统一。你在写前端代码的时候用import,写后端也用import,心智负担更小。不过要注意,用 ESM 需要在package.json里加"type": "module",或者把文件后缀改成.mjs。我建议直接加"type": "module",这样所有.js文件都按 ESM 解析。

2. 项目骨架搭建:从空目录到可运行的最小系统

2.1 环境准备与 Node.js 版本选择

第一步是确认 Node.js 版本。ESM 的稳定支持从 Node.js 12 开始,但建议用 18 LTS 或更高版本,因为 18 之后 ESM 的各种边界情况处理得更完善。截至我写这篇内容的时候,Node.js 22 已经是 LTS 版本,可以直接用。

检查版本:

node -v npm -v

如果版本太低,去 Node.js 官网下载对应系统的安装包。Windows 用户直接下载.msi安装,macOS 用户可以用.pkg或者 Homebrew,Linux 用户建议用 nvm 管理版本。安装完之后再跑一次node -v确认。

提示:如果你在服务器上部署,CentOS 7.9 这类老系统自带的 Node.js 版本可能很低,建议用 nvm 安装新版本,不要用系统包管理器直接装。

2.2 初始化项目与依赖安装

新建一个目录,初始化项目:

mkdir nodejs-learn-site cd nodejs-learn-site npm init -y

然后修改package.json,加上"type": "module":

{ "name": "nodejs-learn-site", "version": "1.0.0", "type": "module", "scripts": { "start": "node src/app.js", "dev": "node --watch src/app.js" } }

--watch是 Node.js 18.11 之后内置的热重载功能,改代码后自动重启,不需要装 nodemon。这个细节很多人不知道,还在用 nodemon,其实内置的已经够用了。

安装依赖:

npm install express better-sqlite3 ejs

这里解释一下三个依赖的选择:

  • express:Web 框架,负责路由和中间件。
  • better-sqlite3:SQLite 的 Node.js 驱动。相比sqlite3包,它是同步 API,代码写起来更直观,性能也更好。对于学习官网这种低并发场景,同步 API 完全不会成为瓶颈。
  • ejs:模板引擎,用来渲染 HTML 页面。选 EJS 是因为它的语法接近原生 HTML,学习成本低。

2.3 目录结构设计与理由

我建议的目录结构是这样的:

nodejs-learn-site/ ├── src/ │ ├── app.js # 应用入口 │ ├── db.js # 数据库连接与初始化 │ ├── routes/ │ │ ├── index.js # 首页路由 │ │ ├── courses.js # 课程路由 │ │ └── api.js # API 路由 │ └── views/ │ ├── layout.ejs # 布局模板 │ ├── index.ejs # 首页 │ └── course.ejs # 课程详情 ├── public/ │ ├── css/ │ │ └── style.css │ └── js/ │ └── main.js ├── data/ │ └── site.db # SQLite 数据库文件 └── package.json

这个结构的设计逻辑是:按职责分层,而不是按文件类型分层。routes目录放路由,views放模板,public放静态资源,data放数据文件。这样当项目变大时,你知道该去哪里找对应的代码。

2.4 数据库初始化与表结构设计

在src/db.js里初始化数据库:

import Database from 'better-sqlite3'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const db = new Database(join(__dirname, '../data/site.db')); db.pragma('journal_mode = WAL'); db.exec(` CREATE TABLE IF NOT EXISTS courses ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, summary TEXT, content TEXT, category TEXT, level TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, slug TEXT UNIQUE NOT NULL ); `); export default db;

这里有几个关键点:

  • journal_mode = WAL是 SQLite 的写前日志模式,能提升并发读性能,建议开启。
  • slug字段用来做 URL 友好路径,比如/course/nodejs-basics比/course/1更利于分享和 SEO。
  • created_at用CURRENT_TIMESTAMP自动填充,省去手动维护时间字段。

3. 路由与页面渲染:让内容真正显示出来

3.1 Express 应用入口的完整配置

src/app.js是整个应用的入口:

import express from 'express'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import indexRouter from './routes/index.js'; import coursesRouter from './routes/courses.js'; import apiRouter from './routes/api.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const app = express(); const PORT = process.env.PORT || 3000; app.set('view engine', 'ejs'); app.set('views', join(__dirname, 'views')); app.use(express.static(join(__dirname, '../public'))); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use('/', indexRouter); app.use('/courses', coursesRouter); app.use('/api', apiRouter); app.use((req, res) => { res.status(404).render('404', { title: '页面未找到' }); }); app.listen(PORT, () => { console.log(`服务已启动:http://localhost:${PORT}`); });

这段代码里,express.static负责静态文件服务,express.json和express.urlencoded负责解析请求体,三个路由分别处理首页、课程页和 API。最后加了一个 404 兜底中间件。

3.2 首页路由与数据查询

src/routes/index.js:

import { Router } from 'express'; import db from '../db.js'; const router = Router(); router.get('/', (req, res) => { const courses = db.prepare( 'SELECT id, title, slug, summary, category, level FROM courses ORDER BY created_at DESC LIMIT 12' ).all(); const categories = db.prepare('SELECT * FROM categories').all(); res.render('index', { title: 'Node.js 学习官网', courses, categories }); }); export default router;

这里用better-sqlite3的prepare和all方法查询数据。prepare会预编译 SQL 语句,重复执行时性能更好,同时也能防止 SQL 注入。

3.3 课程详情页与动态路由

src/routes/courses.js:

import { Router } from 'express'; import db from '../db.js'; const router = Router(); router.get('/:slug', (req, res) => { const course = db.prepare( 'SELECT * FROM courses WHERE slug = ?' ).get(req.params.slug); if (!course) { return res.status(404).render('404', { title: '课程未找到' }); } const related = db.prepare( 'SELECT id, title, slug FROM courses WHERE category = ? AND id != ? LIMIT 5' ).all(course.category, course.id); res.render('course', { title: course.title, course, related }); }); export default router;

动态路由/:slug会匹配/courses/nodejs-basics这样的路径,req.params.slug拿到nodejs-basics,然后去数据库查询对应课程。查不到就返回 404,查到了就渲染详情页,同时查出同分类的相关课程。

3.4 EJS 模板的布局复用技巧

EJS 本身没有布局继承,但可以通过include实现类似效果。views/layout.ejs放公共部分:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title><%= title %></title> <link rel="stylesheet" href="/css/style.css"> </head> <body> <header class="site-header"> <a href="/" class="logo">Node.js 学习站</a> <nav> <a href="/">首页</a> <a href="/courses">课程</a> </nav> </header> <main class="container"> <%- body %> </main> <footer class="site-footer"> <p>持续更新 Node.js 学习资料</p> </footer> <script src="/js/main.js"></script> </body> </html>

然后在index.ejs里这样用:

<%- include('layout', { body: ` <section class="hero"> <h1>系统学习 Node.js</h1> <p>从基础到实战,覆盖 Express、SQLite、ESM 等核心内容</p> </section> <section class="course-grid"> ${courses.map(c => ` <article class="course-card"> <h3><a href="/courses/${c.slug}">${c.title}</a></h3> <p>${c.summary}</p> <span class="tag">${c.category}</span> </article> `).join('')} </section> ` }) %>

这种写法把页面内容作为字符串传给布局,虽然不如专门的布局引擎优雅,但胜在简单直接,不需要额外依赖。

4. 前端交互与 API 设计:让官网不只是静态页面

4.1 用原生 JavaScript 实现搜索与筛选

学习官网如果只能翻列表,体验会很差。加一个搜索框和分类筛选,实用性会大幅提升。public/js/main.js:

const searchInput = document.querySelector('#search-input'); const categorySelect = document.querySelector('#category-select'); const courseGrid = document.querySelector('#course-grid'); let debounceTimer = null; async function fetchCourses() { const keyword = searchInput?.value.trim() || ''; const category = categorySelect?.value || ''; const params = new URLSearchParams(); if (keyword) params.set('q', keyword); if (category) params.set('category', category); const res = await fetch(`/api/courses?${params.toString()}`); const data = await res.json(); if (!courseGrid) return; if (data.courses.length === 0) { courseGrid.innerHTML = '<p class="empty">没有找到匹配的课程</p>'; return; } courseGrid.innerHTML = data.courses.map(c => ` <article class="course-card"> <h3><a href="/courses/${c.slug}">${c.title}</a></h3> <p>${c.summary || ''}</p> <span class="tag">${c.category || '未分类'}</span> </article> `).join(''); } function debounce(fn, delay = 300) { return (...args) => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => fn(...args), delay); }; } searchInput?.addEventListener('input', debounce(fetchCourses)); categorySelect?.addEventListener('change', fetchCourses);

这里用了debounce防抖,避免用户每输入一个字符就发一次请求。300 毫秒的延迟在输入体验和请求频率之间是个不错的平衡点。

4.2 API 路由的实现与参数校验

src/routes/api.js:

import { Router } from 'express'; import db from '../db.js'; const router = Router(); router.get('/courses', (req, res) => { const { q, category } = req.query; let sql = 'SELECT id, title, slug, summary, category, level FROM courses WHERE 1=1'; const params = []; if (q) { sql += ' AND (title LIKE ? OR summary LIKE ?)'; params.push(`%${q}%`, `%${q}%`); } if (category) { sql += ' AND category = ?'; params.push(category); } sql += ' ORDER BY created_at DESC LIMIT 50'; const courses = db.prepare(sql).all(...params); res.json({ courses }); }); export default router;

这个 API 支持关键词搜索和分类筛选,返回 JSON 数据。前端拿到数据后动态渲染,页面不需要刷新。

4.3 移动端适配的几个关键细节

学习官网的用户很可能在手机上看,移动端适配不能忽略。CSS 里几个关键点:

* { box-sizing: border-box; } body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.6; color: #333; } .container { max-width: 1100px; margin: 0 auto; padding: 0 16px; } .course-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; } @media (max-width: 600px) { .course-grid { grid-template-columns: 1fr; } }

grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))这行是关键,它会根据容器宽度自动决定列数,宽屏多列、窄屏单列,不需要写一堆媒体查询。

提示:移动端记得加<meta name="viewport" content="width=device-width, initial-scale=1.0">,否则页面会按桌面宽度渲染,手机上看起来会很小。

5. 部署上线:从本地到公网可访问

5.1 服务器环境准备与 Node.js 安装

部署到服务器,第一步是装 Node.js。以常见的 Linux 服务器为例,推荐用 nvm:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash source ~/.bashrc nvm install 22 nvm use 22

装完之后node -v确认版本。然后安装 pm2 做进程管理:

npm install -g pm2

pm2 的好处是进程挂了会自动重启,服务器重启后也能自动拉起,比直接node app.js靠谱得多。

5.2 用 pm2 守护进程与开机自启

把代码传到服务器后,在项目目录执行:

npm install --production pm2 start src/app.js --name nodejs-learn-site pm2 save pm2 startup

pm2 save保存当前进程列表,pm2 startup生成开机自启脚本。执行pm2 startup后会输出一行命令,复制执行即可。

查看运行状态:

pm2 status pm2 logs nodejs-learn-site

5.3 反向代理配置与静态资源缓存

Node.js 应用直接监听 3000 端口对外访问不太合适,一般用 Nginx 做反向代理。Nginx 配置:

server { listen 80; server_name your-domain.com; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /css/ { alias /path/to/nodejs-learn-site/public/css/; expires 7d; } location /js/ { alias /path/to/nodejs-learn-site/public/js/; expires 7d; } }

静态资源单独配置并设置缓存时间,能减少 Node.js 进程的压力,同时提升用户加载速度。

5.4 数据库文件备份与迁移注意事项

SQLite 的数据库就是一个文件,备份非常简单,直接复制data/site.db就行。但要注意:复制之前先停掉应用或者执行 WAL 检查点,否则可能复制到不完整的数据。

pm2 stop nodejs-learn-site cp data/site.db data/site.db.backup pm2 start nodejs-learn-site

迁移到新服务器时,把整个项目目录打包传过去,数据库文件一起带上,npm install之后直接启动就行。这也是 SQLite 相比 MySQL 的一大优势——迁移成本极低。

6. 上线之后:内容维护与性能优化的实际经验

6.1 内容录入的几种方式

官网跑起来之后,内容从哪来?最直接的方式是写一个简单的管理接口,或者直接用 SQLite 客户端工具手动插入。我推荐用 DB Browser for SQLite 这个开源工具,图形化界面,打开数据库文件就能编辑表数据,比写 SQL 方便。

如果要批量导入,可以写一个脚本:

import db from './src/db.js'; const courses = [ { title: 'Node.js 基础入门', slug: 'nodejs-basics', summary: '从零开始了解 Node.js 的运行机制与核心模块', content: '...', category: '基础', level: '入门' }, // 更多课程... ]; const insert = db.prepare(` INSERT OR IGNORE INTO courses (title, slug, summary, content, category, level) VALUES (@title, @slug, @summary, @content, @category, @level) `); const insertMany = db.transaction((items) => { for (const item of items) insert.run(item); }); insertMany(courses); console.log(`已导入 ${courses.length} 条课程数据`);

用事务批量插入,几千条数据几秒钟就能完成,比逐条插入快几十倍。

6.2 查询性能优化的几个实用手段

SQLite 在数据量不大时性能很好,但数据量上去之后还是要注意优化。几个实用手段:

  • 加索引:经常用来查询的字段加索引,比如category、slug。
CREATE INDEX IF NOT EXISTS idx_courses_category ON courses(category); CREATE INDEX IF NOT EXISTS idx_courses_slug ON courses(slug);
  • 避免SELECT *:只查需要的字段,减少数据传输量。
  • 分页查询:列表页不要一次查全部,用LIMIT和OFFSET分页。
SELECT id, title, slug, summary FROM courses ORDER BY created_at DESC LIMIT 12 OFFSET 0;
  • 开启 WAL 模式:前面提到的journal_mode = WAL,对读多写少的场景提升明显。

6.3 常见部署问题排查清单

部署过程中最容易遇到的问题,我整理了一个排查清单:

问题现象可能原因排查方法
启动报错找不到模块依赖没装或路径错误检查node_modules是否存在,npm install重装
页面 502Node 进程挂了pm2 status查看状态,pm2 logs看错误日志
数据库写入失败文件权限不足ls -l data/site.db检查权限,chmod 664修正
静态资源 404Nginx 路径配置错误检查alias路径是否指向实际目录
端口被占用其他进程占用 3000lsof -i:3000查看,换端口或杀掉进程

提示:ESM 模式下__dirname不存在,必须用fileURLToPath(import.meta.url)转换。这是从 CommonJS 迁移到 ESM 时最容易踩的坑,报错信息通常是__dirname is not defined。

6.4 后续可扩展的方向

这个项目跑起来之后,可以继续扩展的方向不少。比如加一个 Markdown 渲染,让课程内容支持 Markdown 格式;加一个评论功能,用 SQLite 存评论数据;加一个 RSS 订阅,方便读者订阅更新;加一个简单的后台管理页面,用表单录入课程。

但我的建议是:先把核心功能做扎实,再考虑扩展。很多项目死在功能太多、每个都做不完整。学习官网的核心就是内容展示和检索,把这两件事做好,比加十个花哨功能更有价值。

我在实际维护这个站的过程中发现,真正影响体验的往往不是功能多少,而是内容质量和加载速度。内容持续更新、页面打开够快,用户就愿意留下来。技术选型上,Express + SQLite + ESM 这套组合我已经用了好几个项目,稳定性和开发效率都很满意,推荐你也试试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询