终于,历时一个多月的开发 bug 和测试,这个目前问题很多很不成熟很难用的系统终于上线了…也了结了我一直以来重构这个网站的小愿望,算是我的第一个全栈中小型项目,之前的感觉大多都是小型玩具项目,虽然也给学校学了好多项目,有几万用户和几千并发,但是这次算是略微复杂的业务逻辑,还要考虑用户体验,尤其是我还为了这个才学得怎么用 Next.js
(更新于2025-01-05,socket果然是cdn的问题,已经能正常用了
写在前面
总之就是,它还是如愿成功上线了,也证明这个我曾经天马行空的想法是可以实现的。目前还仍旧有许多问题,比如 WebSocket 似乎由于性能问题无法正常工作(在启用 ES 的第二天就出问题了,感觉是性能问题),点赞和推荐的逻辑还没有想好怎么实现,推荐只是留了接口还没有正式应用…开发的过程中遇到的问题还是很多的,接下来考虑慢慢更新一些,如果你也遇到了相关问题不妨看看我帮你踩好的坑。
技术选择
这是一个开源项目,地址在 Github ,我会长期进行维护。
文档在grtblog
它采用了前后端分离的方式进行开发,
为了平衡 SSG 和 SSR 的优势,我选择了 Next.js 框架用于构建用户界面。这样可以在构建初期对于一些不频繁变更的内容生成静态页面,当然也可以增量生成。对于动态内容较多的页面也会采取 SSR 来保证内容网站的 SEO 完整,此外,构建页面时的请求结构能够被缓存,这样既能加速构建,也能减轻服务器压力。
后端方面采用了生态极佳性能也比较优秀的 Spring Boot,权限采用 Spring Security,使用 Spring Cloud 调用周边微服务等等,数据库选择了 mysql,后期计划迁移到我更偏爱的 postgresql
对于用户推荐部分,我用 word2vector 简单弄了一个推荐算法,使用 FastAPI,由主框架使用 API 调用;搜索则使用 ElasticSearch 实现,由主框架调用。
中台管理为了方便目前选择的阿里的 Umi.js + Ant Design Pro
一些细节
实时在线人数
悲,首先这个不知道为什么用不了了
这个采用了socket.io实现,原理就是加入聊天室传递信息,每次链接会发送消息,用定时任务防抖一下就好
其后端核心代码是这样,在用户进入页面发送enterpage事件,根据其传递的URI匹配页面并创建聊天室
@OnEvent("enterPage")
public void onEnterPage(SocketIOClient client, String page) {
UUID clientId = client.getSessionId();
SocketAddress remoteAddress = client.getRemoteAddress();
String pageName = pageMatcher.matchPath(page, remoteAddress);
String previousPage = clientPageMap.put(clientId, pageName);
if (previousPage != null && !previousPage.equals(pageName)) {
Set<UUID> previousUsers = pageUserMap.getOrDefault(previousPage, ConcurrentHashMap.newKeySet());
previousUsers.remove(clientId);
if (previousUsers.isEmpty()) {
pageUserMap.remove(previousPage);
} else {
pageUserMap.put(previousPage, previousUsers);
}
debounceUpdatePageViewCount(previousPage);
}
pageUserMap.computeIfAbsent(pageName, k -> ConcurrentHashMap.newKeySet()).add(clientId);
debounceUpdatePageViewCount(pageName);
debounceUpdateTotalOnlineCount();
}
而前端部分就是加入并获取信息咯
借助socketio的自定义事件就可以传递页面和人数的信息
const [socket, setSocket] = useState<Socket | null>(null); // Socket.IO 实例
const [pageViewCount, setPageViewCount] = useState(0); // 当前页面在线人数
const [totalOnlineCount, setTotalOnlineCount] = useState(0); // 总在线人数
const param = usePathname();
const dispatch = useAppDispatch();
// 初始化 Socket.IO 连接
useEffect(() => {
const newSocket = io(url);
setSocket(newSocket);
getPageView().then((res) => {
dispatch({
type: "onlineCount/initPageView",
payload: res
})
});
// 监听总在线人数事件
newSocket.on("totalOnlineCount", (count: number) => {
setTotalOnlineCount(count);
dispatch({type: "onlineCount/updateOnlineCount", payload: count});
});
// 监听页面在线人数事件
newSocket.on("pageViewCount", (page, count) => {
if (page === param) {
setPageViewCount(count);
}
dispatch({
type: "onlineCount/updatePageView", payload: {
name: page,
count: count
}
});
});
// 在组件卸载时关闭连接
return () => {
newSocket.disconnect();
};
}, [param, dispatch, totalOnlineCount, pageViewCount]);
// 发送当前页面信息
useEffect(() => {
if (socket) {
socket.emit("enterPage", param);
}
}, [socket, param]);
标签云实现
说实话这个还挺好玩的,利用的是framer-motion这个库实现的动画,然后排版一下实现的效果
它的组成就是单个项目形成一行,反复渲染每一行直到填满区域
单个项目就简单设计就好
const TagItem: React.FC<TagItemProps> = ({ icon: Icon, text, isLeft, delay }) => (
<motion.div
initial={{ opacity: 0, x: isLeft ? 100 : -100 }}
animate={{ opacity: [0, 0.8, 0], x: isLeft ? [-100, 0, 100] : [100, 0, -100] }}
transition={{ duration: 5, delay, repeat: Infinity, repeatType: 'loop', ease: 'linear' }}
>
<div
className={clsx('inline-flex items-center space-x-2 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-400 px-3 py-1 text-sm shadow-sm', article_font.className)}>
<Icon size={14} />
<span>{text}</span>
</div>
</motion.div>
);
然后我们为其传递随机的图标和标签不够随机来凑的标签内容
const icons: LucideIcon[] = [Book, Bookmark, FileText, Hash, Link, Paperclip, Tag, Type];
const getRandomIcon = (): LucideIcon => icons[Math.floor(Math.random() * icons.length)];
const getRandomTag = (tags: string[]): string => tags[Math.floor(Math.random() * tags.length)];
对于左右半的内容使用一个isLeft来区分
const TagRow: React.FC<TagRowProps> = ({ isLeft, rowIndex, tags }) => {
const rowTags = Array.from({ length: 8 }, (_, i) => ({
icon: getRandomIcon(),
text: getRandomTag(tags),
delay: i * 0.5 + rowIndex * 0.2,
}));
return (
<div className={`flex ${isLeft ? 'justify-start' : 'justify-end'} space-x-4 my-8`}>
{rowTags.map((tag, index) => (
<TagItem key={index} {...tag} isLeft={isLeft} />
))}
</div>
);
};
最后利用数组方法来传递参数,渲染满区域~
export default function TagCloudBackground({ tags }: { tags: string[] }) {
const [rows, setRows] = useState<Row[]>([]);
useEffect(() => {
const numberOfRows = Math.ceil(window.innerHeight / 50); // Approximate row height
setRows(Array.from({ length: numberOfRows }, (_, i) => ({ isLeft: i % 2 === 0, index: i })));
}, []);
return (
<div className="absolute inset-1 h-[85vh] overflow-hidden pointer-events-none z-0">
{rows.map((row, index) => (
<TagRow key={index} isLeft={row.isLeft} rowIndex={row.index} tags={tags} />
))}
</div>
);
}
简要总结与挖坑
这个项目可能还是入门项目吧…但是从设计到独立完成花费我的精力还是蛮多的,接下来会连续更新讲讲我这个项目的过程遇到的一系列问题
包括但不限于
水合不匹配问题,OAauth2实现,RSC的主题切换问题,移动端适配实现,推荐算法的简单实现,Nextjs的痛苦开发代理服务器问题,反代 301优化SEO问题,静态资源路径问题
一份学习一分收获吧,虽然现在我还很菜,但是还是不断学习不断进步
希望自己能成为独当一面的前端工程师,在热爱的领域闪闪发光,仅此而已