1. 项目概述与整体思路拆解
先说这个系统是干嘛的。我平时玩好几款游戏,攻略散落在论坛、B站、公众号各个地方,今天这个版本更新了,明天那个角色改了技能,想找一份最新的、集中的攻略信息特别费劲。与其等别人整理,不如自己用代码搞一套——Node.js 负责出接口、管数据,Vue 负责页面展示和交互,做成了一个游戏攻略资讯订阅系统。核心目标就三件事:把攻略资讯集中管理起来;让用户可以按游戏、按作者订阅感兴趣的内容;订阅的内容一更新,用户能第一时间收到提醒。
这套系统听起来技术含量不算高,但它几乎是前后端分离项目的标准样板。Node.js 做 RESTful 接口、Vue 做 SPA 单页应用、数据库做持久化存储、WebSocket 做实时推送,完整的业务链路都能在里面跑通。你想学前后端分离、想了解资讯类系统怎么设计订阅逻辑,或者单纯想要一个能放到简历上的实战项目,这套代码的架构思路都可以直接参考。
从实用场景来看,它不只能用来做游戏攻略。把"游戏"换成"美妆教程"、"基金解读"、"健身食谱",这套订阅逻辑一样能跑。理解了它的核心模块划分,你等于掌握了一套可复用的内容资讯系统开发套路。
1.2 技术选型:为什么是 Node.js + Vue
先说后端选 Node.js。游戏攻略资讯系统本质上是一个内容管理系统加上订阅推送功能,业务逻辑不复杂,但并发请求量可能不小——玩家通常在晚上集中刷攻略,接口压力会有一个明显的波峰。Node.js 的异步非阻塞 I/O 模型特别适合这种场景,它的单线程事件循环能扛住大量轻量级请求,不像传统多线程模型那样在大量连接下会产生明显的上下文切换开销。
再一个原因:前后端都写 JavaScript。这个优势在开发阶段体现得特别明显——后端返回的数据结构、前端要用的数据模型,两边用的是同一套思维,不涉及语言切换的心智负担。而且一个 JSON 对象从数据库到接口、再从接口到页面组件里,几乎不用做任何格式转换,数据流非常顺。
Vue 这边,我选它是因为生态成熟、上手平滑、文档对中文用户友好。Vue 的响应式数据绑定机制让"攻略列表数据更新了、页面立刻重新渲染"这种操作变成了一种本能操作,配合 Vue Router 做路由切换、Pinia 做全局状态管理,撸一个资讯展示类的 SPA 非常顺手。二来,Vue 的单文件组件方式让代码组织非常清晰——一张游戏卡片是一个 .vue 文件,一个攻略列表是一个 .vue 文件,组件化开发维护起来很舒服。
1.3 功能模块划分与数据模型设计
在动手写代码之前,先花了两个小时把系统的模块边界划清楚。我没有一上来就写接口,而是把整个系统拆成了四个核心模块:
- 游戏模块:维护游戏的基础信息,包括游戏名称、封面图、所属分类(动作、角色扮演、休闲等)、简介。这是整个系统的"分类标签"。
- 资讯攻略模块:每一篇内容挂在某个游戏名下,分为"攻略"和"资讯"两种类型。攻略是玩法教程类,资讯是版本更新、活动公告类。
- 用户模块:提供注册、登录能力,基于 JWT 做身份认证。订阅功能必须要识别用户身份。
- 订阅模块:建立用户和游戏之间的订阅关系,同时提供推送触达能力。
对应的数据库设计了五张表。游戏表存游戏元数据;文章表存攻略和资讯,通过 game_id 关联游戏表,用 type 字段区分是攻略还是资讯;用户表存账号信息;订阅表存用户的订阅关系,user_id 和 game_id 组成联合唯一索引,避免重复订阅。
数据模型设计的时候有一个关键点:订阅表必须冗余一个 created_at 时间戳。这个字段看起来不起眼,但在做"订阅内容增量推送"时是核心依据——系统只需要对比订阅时间之后的文章记录就能算出新增内容。我在文章表也加了 published_at 字段,查询时按它倒序排列。后续做"最近24小时订阅更新汇总"的时候,一条 SQL 就能搞定。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把环境搞干净:Node.js 与 Vue 开发环境搭建
2.1 Node.js 版本选择与安装
这个项目最开始踩的坑其实不是业务代码,而是环境本身。我相信很多同学都经历过:代码逻辑没问题,一跑项目报错,报错信息指向 Node.js 版本不对、npm 权限不够、依赖装不上。所以我把环境搭建放在最前面讲,这部分是最容易被忽略但又是最容易卡住人的环节。
Node.js 的版本选择,我的建议是直接装 LTS 版本,别追最新版。因为 Express、Webpack、Vite 这些工具链对最新版本的适配往往有滞后。比如 Node.js 偶数版本才是 LTS,我装的是 18.x LTS,到写这篇文章的时候仍然有大量依赖对它做兼容测试,稳定性有保障。
安装方式有两种。一种是官网下载安装包,一直点下一步就行,适合一次性配置。另一种是使用 nvm(Node Version Manager)做版本管理,适合需要在多个 Node.js 版本之间切换的场景。我这个项目要做 WebSocket 推送,对 Node.js 版本不敏感,就用的官网安装包。
下载安装包有两个细节要注意。一是安装路径,默认是 C:\Program Files\nodejs,这个路径本身没问题,但注意这个路径带空格,个别老工具解析路径会有奇怪的问题,所以我装到了 D:\nodejs。二是在安装向导里,一定要确认勾选 "Add to PATH" 选项,安装器会自动把 Node.js 和 npm 的路径加进系统环境变量。
2.2 npm 环境配置:换源与全局目录
安装完 Node.js 之后,npm 会自动附带。但在国内环境,npm 默认源安装在国外服务器上,装个依赖慢到你怀疑人生。我装 Express 的时候卡了十分钟,进度条一步都不动,最后直接 Ctrl + C 取消了。
解决方案是换淘宝镜像源。在命令行执行:
bash复制npm config set registry https://registry.npmmirror.com
换完源之后验证一下:
bash复制npm config get registry
输出 https://registry.npmmirror.com 就说明生效了。实测装依赖的速度从几分钟降到了几秒钟,体验差距非常明显。如果你团队内部还有私有的 npm 包需要安装,还可以在项目根目录建一个 .npmrc 文件,把私有仓库地址写进去,这样在项目内执行 npm install 时自动走私有源,不会污染全局配置。
另外有个我自己后期才发现的坑:npm 的全局包默认安装到系统盘用户目录下,时间长了你会发现 C 盘空间越来越小。作为习惯,我把全局包的缓存和安装目录都改到了 D 盘:
bash复制npm config set cache "D:\nodejs\npm_cache"
npm config set prefix "D:\nodejs\npm_global"
这样重装系统或者清理磁盘的时候,全局依赖还在,不用重新装一遍。
2.3 Vue 脚手架安装与项目初始化
Vue 项目的搭建,我推荐直接用官方脚手架 Vite。Vue CLI(webpack 版)虽然也可以用,但启动速度明显慢,尤其是项目大了之后,热更新动不动要好几秒,开发体验差一个档次。
创建 Vue 项目:
bash复制npm create vite@latest gametips-client -- --template vue
cd 进项目目录后,安装依赖:
bash复制npm install
这里要说一下 Vite 和 Vue 的版本关系。Vite 当前推荐配合 Vue 3.x 使用,模板创建出来的项目默认是 Vue 3。如果你之前学的是 Vue 2,那些选项式 API 的写法在 Vue 3 里仍然兼容,但组合式 API 才是 Vue 3 的主流写法,建议新项目直接上手组合式 API。
接下来把项目跑起来看看:
bash复制npm run dev
默认在 http://localhost:5173 启动。第一次看到页面正常渲染的时候,环境这一关就算过了。
2.4 PowerShell 执行策略与 npm 脚本报错
这个问题的搜索热度非常高,我在搭环境的时候也遇到了。
在 Windows 上,很多同学打开 PowerShell,执行 npm run dev,结果报错:
bash复制npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。
这个报错的根源在于 Windows PowerShell 的执行策略默认是 Restricted,它不允许任何脚本文件运行。而 npm 本质上是一个 .ps1 脚本,在 PowerShell 中执行时被拦截了。
解决办法是在 PowerShell 中修改执行策略:
powershell复制Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
这条命令的意思是:本机编写的脚本可以运行,从网上下载的脚本必须有可信签名才能运行。RemoteSigned 比 Unrestricted 安全得多,我建议就用这个级别,不要图省事设成 Unrestricted。
如果不想改执行策略,还有两个替代方案:一是在 Windows Terminal 里直接切换成 CMD 或 Git Bash 来执行 npm 命令;二是在 PowerShell 当前的权限会话中执行 powershell -ExecutionPolicy Bypass 绕过限制。但这两个方案每次都要折腾,治标不治本,我最后直接改了执行策略。
注意:修改执行策略是当前用户级别的,
CurrentUser参数保证了不改动系统级全局策略,安全风险可控。
3. 后端开发:Node.js 接口与订阅推送逻辑实现
3.1 项目初始化和目录结构
后端我用的 Express 框架,它是 Node.js 生态里最经典、最稳定的 Web 框架,中间件机制非常灵活,文档也丰富。
初始化项目:
bash复制mkdir gametips-server
cd gametips-server
npm init -y
npm install express cors jsonwebtoken better-sqlite3
我用的是 better-sqlite3,一个同步操作的 SQLite 驱动。你可能想问为什么不用 MySQL?因为这是个偏个人/小团队的工具型系统,SQLite 零配置、文件型数据库、随项目走,对开发阶段特别友好。等真到了要上线的规模再换 MySQL 不迟,业务逻辑封装在数据访问层,换数据库的影响面很有限。
后端目录结构:
code复制gametips-server/
├── src/
│ ├── app.js # 应用入口
│ ├── routes/ # 路由定义
│ │ ├── games.js
│ │ ├── articles.js
│ │ ├── auth.js
│ │ └── subscriptions.js
│ ├── middleware/ # 中间件(JWT 验证等)
│ ├── db/ # 数据库连接与初始化
│ └── utils/
├── package.json
└── .env # 环境变量
3.2 数据库初始化与表结构
设计好表结构之后,写一个数据库初始化脚本。为了开发方便,我直接用 SQLite 的 execute 方法创建表,每次服务启动时检测表是否存在,不存在就创建。
javascript复制const Database = require('better-sqlite3');
const db = new Database('gametips.db');
db.exec(`
CREATE TABLE IF NOT EXISTS games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
cover TEXT,
category TEXT,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT,
type TEXT DEFAULT 'guide',
author TEXT,
tags TEXT,
published_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (game_id) REFERENCES games(id)
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
email TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
game_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, game_id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (game_id) REFERENCES games(id)
);
`);
这里我看到有很多同学用 Sequelize 或 TypeORM 这种 ORM 框架,对于简单项目没必要,原生 SQL 反而更直观。索引方面,articles.game_id + published_at 的组合索引一定要加,攻略列表查询如果按游戏筛选、按时间排序,这个索引会带来数量级的性能提升。
3.3 RESTful API 设计与实现
接口设计遵循 RESTful 风格。核心接口如下:
| 方法 | 路径 | 功能 |
|---|---|---|
| GET | /api/games | 获取游戏列表 |
| GET | /api/articles?game_id=1&type=guide | 按游戏/类型获取文章列表 |
| GET | /api/articles/:id | 获取文章详情 |
| POST | /api/auth/register | 用户注册 |
| POST | /api/auth/login | 用户登录 |
| GET | /api/subscriptions | 获取我的订阅列表 |
| POST | /api/subscriptions | 新增订阅 |
| DELETE | /api/subscriptions/:gameId | 取消订阅 |
| GET | /api/articles/updates | 获取订阅游戏的最新更新 |
游戏列表接口的实现:
javascript复制router.get('/games', (req, res) => {
const games = db.prepare('SELECT * FROM games ORDER BY id').all();
res.json({ code: 0, data: games });
});
文章列表接口支持 game_id、type、keyword 三个查询参数,分页返回:
javascript复制router.get('/articles', (req, res) => {
const { game_id, type, keyword, page = 1, pageSize = 10 } = req.query;
let sql = 'SELECT * FROM articles WHERE 1=1';
const params = [];
if (game_id) {
sql += ' AND game_id = ?';
params.push(game_id);
}
if (type) {
sql += ' AND type = ?';
params.push(type);
}
if (keyword) {
sql += ' AND (title LIKE ? OR tags LIKE ?)';
params.push(`%${keyword}%`, `%${keyword}%`);
}
const total = db.prepare(sql).get(...params).length;
sql += ' ORDER BY published_at DESC LIMIT ? OFFSET ?';
params.push(Number(pageSize), (Number(page) - 1) * Number(pageSize));
const list = db.prepare(sql).all(...params);
res.json({ code: 0, data: { list, total, page: Number(page) } });
});
RESTful 设计里有一个容易忽略的点:订阅相关的接口都必须做身份验证。不能让别人随便拿你的接口去操作数据。所以我封装了一个 JWT 中间件,把 token 校验逻辑集中起来,需要登录态的路由挂载这个中间件就行:
javascript复制function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ code: 401, msg: '未登录' });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.userId = decoded.userId;
next();
} catch {
res.status(401).json({ code: 401, msg: 'token 无效或已过期' });
}
}
3.4 订阅逻辑与增量更新计算
订阅功能的核心逻辑不复杂:用户在页面上点击"订阅"按钮,前端把 game_id 连同 token 发到后端,后端在 subscriptions 表里插入一条记录。取消订阅就是反向操作。
真正有技术含量的是"获取订阅游戏的最新更新"这个接口。它需要把当前用户订阅的所有游戏对应的最新文章查出来:
javascript复制router.get('/articles/updates', authMiddleware, (req, res) => {
const userId = req.userId;
const subscribedGames = db.prepare(
'SELECT game_id FROM subscriptions WHERE user_id = ?'
).all(userId);
if (subscribedGames.length === 0) {
return res.json({ code: 0, data: { list: [], total: 0 } });
}
const gameIds = subscribedGames.map(item => item.game_id);
const placeholders = gameIds.map(() => '?').join(',');
const list = db.prepare(`
SELECT * FROM articles
WHERE game_id IN (${placeholders})
ORDER BY published_at DESC
LIMIT 20
`).all(...gameIds);
res.json({ code: 0, data: { list, total: list.length } });
});
这一步其实有一个性能优化点:如果订阅数量特别多,IN 子句的长度会非常长。但在个人项目阶段,订阅几十个游戏完全没问题。等将来规模大了,可以改用一张订阅关系表 join 文章表的写法,让数据库自己处理。
3.5 WebSocket 实时推送:订阅更新第一时间知道
光有轮询接口还不够"实时"。用户在系统里订阅了《原神》的攻略,结果三天后上线发现 20 篇新内容,体验感已经打折扣了。所以我在后端接入了 WebSocket 实时推送。
Node.js 端用 ws 库实现:
bash复制npm install ws
服务端初始化 WebSocket 服务:
javascript复制const WebSocket = require('ws');
const wss = new WebSocket.Server({ server, path: '/ws' });
// 存储连接映射:userId -> WebSocket 实例
const clients = new Map();
wss.on('connection', (ws, req) => {
const userId = parseUserIdFromUrl(req.url); // 从查询参数中解析
clients.set(userId, ws);
ws.on('close', () => clients.delete(userId));
});
function pushToUser(userId, message) {
const ws = clients.get(userId);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
每篇文章新增时触发的推送逻辑:
javascript复制function publishArticle(article) {
// 插入数据库 ...
// 找到订阅了该游戏的所有用户
const subscribers = db.prepare(
'SELECT user_id FROM subscriptions WHERE game_id = ?'
).all(article.game_id);
// 逐个推送
subscribers.forEach(({ user_id }) => {
pushToUser(user_id, {
type: 'ARTICLE_UPDATE',
data: { id: article.id, gameId: article.game_id, title: article.title }
});
});
}
这里需要提醒的是 WebSocket 连接和 HTTP 请求的鉴权方式不同。WebSocket 握手阶段没有标准的 Authorization 头传法,我采取的方式是在连接 URL 上附加 token 参数(ws://localhost:3000/ws?token=xxx),服务端校验通过后建立连接。生产环境下如果安全要求更高,可以考虑用 cookie 传递 token,但开发阶段 URL 参数的方式已经很够用。
4. 前端开发:Vue 页面构建与联调
4.1 前端路由与整体页面布局
后端接口就绪之后,前端开工。我先规划了页面结构:
/:首页,展示推荐攻略和最新资讯/games:游戏库,按分类浏览所有游戏/game/:id:某个游戏的详情页,下面挂该游戏的攻略/资讯列表/article/:id:攻略详情页/subscriptions:订阅管理页面,展示我订阅的游戏和它们的最新更新/login:登录注册页
Vue Router 4 配置:
javascript复制import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{ path: '/', component: () => import('./views/Home.vue') },
{ path: '/games', component: () => import('./views/Games.vue') },
{ path: '/game/:id', component: () => import('./views/GameDetail.vue') },
{ path: '/article/:id', component: () => import('./views/ArticleDetail.vue') },
{ path: '/subscriptions', component: () => import('./views/Subscriptions.vue') },
{ path: '/login', component: () => import('./views/Login.vue') },
];
const router = createRouter({
history: createWebHistory(),
routes,
});
路由懒加载是我习惯用的小技巧,每个页面组件单独打包,首屏只加载首页需要的代码,其他页面在访问时才动态加载。这个优化在资讯类网站尤其重要——首屏加载时间直接影响用户打开页面的耐心。
4.2 API 请求封装与全局状态管理
前端和后端是两套独立服务,所以 API 请求的封装必须规范。我用 axios 封装了一个请求模块:
javascript复制import axios from 'axios';
import { useAuthStore } from '../stores/auth';
const api = axios.create({
baseURL: '/api',
timeout: 10000,
});
api.interceptors.request.use(config => {
const auth = useAuthStore();
if (auth.token) {
config.headers.Authorization = `Bearer ${auth.token}`;
}
return config;
});
api.interceptors.response.use(
response => {
if (response.data.code !== 0) throw new Error(response.data.msg);
return response.data.data;
},
error => {
if (error.response?.status === 401) {
const auth = useAuthStore();
auth.logout();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
这里有一个关键设计:请求拦截器统一注入 token。只要登录成功,全局状态里存了 token,后续每一次请求都会自动带上,不用每个接口单独传。响应拦截器统一处理错误码,业务组件里拿到的不再是 { code, data, msg } 包装过的一层,而直接是 data,大大减少了重复代码。
全局状态管理用的 Pinia,建了 auth store 和 subscription store,一个管登录态,一个管订阅列表状态。为什么要单独抽订阅 store?因为订阅状态在多个页面都有——游戏详情页要显示"已订阅"按钮、订阅管理页要展示所有订阅、首页可能要展示"我的订阅更新",抽成一个状态源,任何页面改动订阅后刷新 store,其他组件自动响应。
4.3 攻略列表与详情页实现
首页攻略列表组件:
vue复制<script setup>
import { ref, onMounted } from 'vue';
import api from '../api';
import ArticleCard from '../components/ArticleCard.vue';
const articles = ref([]);
const loading = ref(false);
async function loadArticles() {
loading.value = true;
try {
articles.value = await api.get('/articles', {
params: { type: 'guide', pageSize: 12 }
});
} finally {
loading.value = false;
}
}
onMounted(loadArticles);
</script>
<template>
<div>
<h2>最新攻略</h2>
<div class="article-grid">
<ArticleCard v-for="item in articles.list" :key="item.id" :article="item" />
</div>
<button @click="loadArticles">重新加载</button>
</div>
</template>
攻略详情页要处理一个常见问题:内容从哪里来。如果攻略是纯文本的,直接渲染即可。但很多游戏攻略包含图片、视频。视频部分我用了 hls.js 来播放 m3u8 格式的流媒体——有些作者会把攻略录屏转成 m3u8 格式提供,这时前端需要做播放支持:
javascript复制import Hls from 'hls.js';
function attachVideo(videoEl, src) {
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(src);
hls.attachMedia(videoEl);
} else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
videoEl.src = src; // 原生支持 m3u8(如 Safari)
}
}
这个扩展是热搜词里关注度很高的一个点。加上它之后,攻略详情里嵌入游戏视频攻略就不再稀罕了。
4.4 代理配置与前后端联调
开发环境前后端分离,联调时最大问题就是跨域。我的做法不是在后端开 CORS,而是在前端 Vite 配置代理,把 /api 开头的请求代理到后端地址:
javascript复制// vite.config.js
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:3000',
ws: true,
}
}
}
});
代理的本质是:浏览器请求 http://localhost:5173/api/games,Vite 开发服务器接收到后,转发给 http://localhost:3000/api/games,然后响应再返回给浏览器。由于浏览器始终在跟同一个源(5173 端口)对话,不存在跨域问题,也不需要后端配 CORS,开发环境干净利落。
Vite 的 proxy 是支持 websocket 代理的,所以 /ws 路径也一起配置了。这样前端创建 WebSocket 连接时,直接连 ws://localhost:5173/ws 就行,跟后端服务的连接由开发服务器转发。
4.5 订阅交互流程实现
订阅功能的前端逻辑,核心就是按钮状态切换:
vue复制<script setup>
import { ref, watch } from 'vue';
import api from '../api';
import { useAuthStore } from '../stores/auth';
import { useSubscriptionStore } from '../stores/subscription';
const props = defineProps({
gameId: { type: Number, required: true }
});
const auth = useAuthStore();
const subscription = useSubscriptionStore();
const subscribed = ref(false);
const loading = ref(false);
watch(() => subscription.ids, (ids) => {
subscribed.value = ids.has(props.gameId);
}, { immediate: true });
async function toggleSubscribe() {
if (!auth.isLogin) {
window.location.href = '/login';
return;
}
loading.value = true;
try {
if (subscribed.value) {
await api.delete(`/subscriptions/${props.gameId}`);
subscription.remove(props.gameId);
} else {
await api.post('/subscriptions', { gameId: props.gameId });
subscription.add(props.gameId);
}
} finally {
loading.value = false;
}
}
</script>
这里有个交互细节很值得注意:如果用户未登录就点订阅,不应该报错,而是引导去登录页。很多系统在这个环节处理得很粗暴,直接弹"请先登录",用户就流失了。我把登录成功后的回跳地址存在 sessionStorage 里,用户登录完自动回到刚才想订阅的页面,这个细节让我在实际使用中省了非常多事。
5. 从开发到运行:典型问题排查手册
5.1 PowerShell 权限与 npm 脚本无法执行
前面已经讲过一次了,但这里再补充一个点。如果你用了 VS Code 或其他编辑器的内置终端,它默认集成的是 PowerShell,所以装了环境、改了 PATH,还是要记得在编辑器内部执行一次 Set-ExecutionPolicy RemoteSigned -Scope CurrentUser。很多人第一步执行了 node -v 没问题,一到 npm run dev 就报错,正因为 npm 脚本是被 PowerShell 拦截的,跟 Node 本体无关。
如果改了执行策略还是报错,检查一下是不是多个 Node.js 版本混装导致 PATH 冲突。在终端执行:
bash复制where node
where npm
看到的结果应该指向同一个目录。如果一个是 D:\nodejs 一个是 C:\Program Files\nodejs,说明环境变量里混入了两个版本的路径,把多余的那个删掉。
5.2 端口占用:启动服务报 EADDRINUSE
Node.js 服务启动报 EADDRINUSE: address already in use :::3000,基本就是 3000 端口被某个进程占用了。排查方法:
bash复制netstat -ano | findstr :3000
找到 PID 之后,在任务管理器里结束进程,或者执行:
bash复制taskkill /PID <pid> /F
这个问题在我开发过程中出现了很多次——因为后端服务是靠 node app.js 启动的,每次改完代码重启时如果没杀干净旧进程,端口就会被占住。后来我引入了 nodemon 做进程守护,文件变更自动重启服务,不再经常踩这个坑。
5.3 前后端联调跨域问题
如果你没有配 Vite 代理,而是直接在前端代码里写 http://localhost:3000/api,那浏览器一定会报 CORS 错误。解决方式有两种:要么像我前面那样配代理,要么后端开 CORS 中间件:
javascript复制const cors = require('cors');
app.use(cors());
开发阶段用后端 CORS 更省事,但对生产环境不太友好——因为 cors() 默认是允许所有来源跨域的,生产环境应该限制白名单。推荐开发环境用 Vite 代理,部署时用 Nginx 反向代理,把前后端地址统一起来。
5.4 Vue Router 刷新页面 404
这个问题在部署到 Nginx 之后才会出现。Vue Router 的 createWebHistory 模式使用的是 HTML5 History API,刷新一个非首页的路由(比如 /article/12),Nginx 找不到对应的文件或接口,就返回 404。
解决方法是 Nginx 配置 fallback 到 index.html:
nginx复制location / {
try_files $uri $uri/ /index.html;
}
如果是开发环境,Vite 会自动处理这个问题,不用额外配置。
5.5 WebSocket 连接不上的排查思路
如果你发现订阅更新一直没有实时推送,首先确认三个环节。第一,WebSocket 服务是否真的在监听,用 Postman 的 WebSocket 客户端直接连 ws://localhost:3000/ws?token=xxx 试试。第二,Vite 代理的 ws: true 是否配置正确,缺了这个参数,开发环境的 WebSocket 连接会一直 pending。第三,token 解析是否成功,如果 URL 参数里的 token 带错或过期,服务端会静默拒绝连接——这里有个坑是我在服务端没有返回明确的错误提示,后来加上日志输出才定位到问题。
5.6 问题速查表
| 报错信息 | 原因 | 解决方案 |
|---|---|---|
npm.ps1 无法加载文件,因为在此系统上禁止运行脚本 |
PowerShell 执行策略限制 | Set-ExecutionPolicy RemoteSigned -Scope CurrentUser |
EADDRINUSE :::3000 |
端口被占用 | netstat -ano | findstr :3000,杀进程 |
proxy error: Could not proxy request |
后端服务没启动 | 确认后端 node app.js 是否运行 |
Failed to fetch |
跨域或请求地址错误 | 检查代理配置,或后端开 CORS |
| 页面刷新 404 | Nginx 未配置 History 模式 fallback | try_files $uri $uri/ /index.html; |
ERESOLVE unable to resolve dependency tree |
npm 依赖版本冲突 | npm install --legacy-peer-deps |
| WebSocket 一直 pending | Vite 代理缺失 ws: true |
在 proxy 配置中补上 |
6. 部署上线与后续扩展方向
6.1 生产环境构建与部署
前端构建生成静态文件:
bash复制npm run build
构建产物在 dist 目录,里面是纯静态文件。部署方案可以很简单:用 Nginx 托管前端静态文件,同时把 /api 和 /ws 反向代理到 Node.js 服务。
nginx复制server {
listen 80;
server_name your-domain.com;
root /var/www/gametips-client/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /ws {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
location /ws 这段配置需要特别注意 Upgrade 和 Connection 这两个头,少了它们 WebSocket 会反复断开重连。
后端部署时,推荐进程管理工具 pm2:
bash复制npm install -g pm2
pm2 start src/app.js --name gametips-server
pm2 save
pm2 startup
pm2 有几个非常实用的能力:守护进程崩溃后自动重启、输出日志持久化到文件、开机自启动服务。以前我用 nohup node app.js & 方式部署,服务一崩就没了,还不能看日志,换成 pm2 后省心太多。
6.2 订阅系统还能往哪扩展
这个系统的核心是"订阅 —— 更新通知"链路。它的扩展方向很多,给几个我实际迭代过的方向:
第一,支持订阅作者。现在只支持订阅游戏,但如果一个玩家只看某位攻略作者的产出,订阅作者更精准。实现上也简单,在订阅表加一个 target_type 字段区分 game 还是 author 即可。
第二,接入推送渠道。我目前只在站内通过 WebSocket 通知,很多玩家不上线就收不到。可以接入邮件推送、钉钉/企业微信机器人、或者服务号模板消息,把"订阅更新"下沉到用户日常使用的渠道。
第三,做内容聚合爬虫。手填攻略文章的体验还不算最佳,可以写一个定时的爬虫脚本,自动抓取目标游戏官网的公告、热门论坛的攻略帖,清洗后入库。这个要控制频率,并且只抓允许转载的内容源,避免版权风险。
第四,资讯个性化推荐。基于用户订阅的游戏、浏览记录、点赞行为做推荐——这套需要的数据埋点、用户画像逻辑,属于推荐系统的范畴,难度会上一个台阶,但收获也大。
这些方向我目前只做了前两个。投入产出比最高的是第一个——作者订阅,它几乎不改动现有数据模型,却能显著提升用户对系统的粘性。
7. 写在最后:一点个人体会
整套系统从规划到跑通,我前后花了一周多的时间。回看这个过程,最大的体会是:环境配置和依赖兼容消耗的时间和业务代码一样多。很多新手倒在 npm install 这一步,不是因为他们不会写代码,而是他们的环境根本没法支撑他们写代码。所以我把环境搭建、踩坑排查放在文章前半部分反复强调,真心建议第一次接触 Node.js + Vue 的同学,先花半天时间把环境彻底打通,再开始写业务代码,后面的顺畅度会完全不同。
另一个切身体会是:先定清楚数据模型,再动手写接口。我第一版是边写接口边改表结构,结果频繁因为缺字段回改 SQL,效率很低。第二轮整理完五张表的关系后,接口基本是一次写对。尤其是订阅关系这种多对多的模型,把联合唯一索引设计好,能省掉大量代码层面的重复判断。
最后说一点关于游戏攻略资讯这个垂直场景的想法。市面上的通用内容平台非常多,但针对特定游戏圈子、服务特定玩家群体的垂直订阅工具却很少。一来内容源分散、二来受众规模有限,很多商业平台不愿意做。但对个人开发者来说,这恰好是一个刚需明确、竞争对手少的切入点。如果你也是某个游戏的深度玩家,拿这套系统的架构去定制一个属于自己圈子的攻略资讯站,投入不大,收获的成就感和实用价值却非常直接。
