# 用一个月的时间写一个自己的博客系统——Grtblog的技术介绍

原文：https://blog.grtsinry43.com/posts/grtblog-simple-introduction/

作者：grtsinry43

发布时间：2024-12-13T16:44:51Z

内容更新时间：2024-12-13T16:44:51Z

终于，历时一个多月的开发 ~~bug~~ 和测试，这个目前问题很多很不成熟很难用的系统终于上线了...也了结了我一直以来重构这个网站的小愿望，算是我的第一个全栈中小型项目，之前的感觉大多都是小型玩具项目，虽然也给学校学了好多项目，有几万用户和几千并发，但是这次算是略微复杂的业务逻辑，还要考虑用户体验，尤其是我还为了这个才学得怎么用 Next.js

(更新于2025-01-05，socket果然是cdn的问题，已经能正常用了

## 写在前面

![还是挺多次提交的](https://blog.grtsinry43.com/uploads/2024/12/14/_20241214_001129.png_7d351389-699a-4dae-bef4-553dc041ae53.png)

总之就是，它还是如愿成功上线了，也证明这个我曾经天马行空的想法是可以实现的。目前还仍旧有许多问题，比如 WebSocket 似乎由于性能问题无法正常工作（在启用 ES 的第二天就出问题了，感觉是性能问题），点赞和推荐的逻辑还没有想好怎么实现，推荐只是留了接口还没有正式应用...开发的过程中遇到的问题还是很多的，接下来考虑慢慢更新一些，如果你也遇到了相关问题不妨看看我帮你踩好的坑。

## 技术选择

这是一个开源项目，地址在 [Github](https://github.com/grtsinry43/grtblog)，我会长期进行维护。

![项目地址](https://blog.grtsinry43.com/uploads/2024/12/14/_20241214_001614.png_2a19bb2a-43f5-49f0-899c-5e8a418753d9.png)

文档在[grtblog](https://grtblog.js.org/)

![文档网站](https://blog.grtsinry43.com/uploads/2024/12/14/_20241206_120451.png_e7719f5b-9fd3-4a0b-9de5-a297dc402f16.png)

它采用了前后端分离的方式进行开发，

为了平衡 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匹配页面并创建聊天室

```java
@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的自定义事件就可以传递页面和人数的信息

```tsx
	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这个库实现的动画，然后排版一下实现的效果

它的组成就是单个项目形成一行，反复渲染每一行直到填满区域

单个项目就简单设计就好

```tsx
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>
);
```

然后我们为其传递随机的图标和~~标签不够随机来凑~~的标签内容

```tsx
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来区分

```tsx
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>
  );
};
```

最后利用数组方法来传递参数，渲染满区域~

```tsx
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>
  );
}
```

![效果是这样的](https://blog.grtsinry43.com/uploads/2024/12/14/_20241214_003439.png_88a58322-5abf-4bf0-af31-c45c7da19637.png)

## 简要总结与挖坑

这个项目可能还是入门项目吧..但是从设计到独立完成花费我的精力还是蛮多的，接下来会连续更新讲讲我这个项目的过程遇到的一系列问题

包括但不限于

水合不匹配问题，OAauth2实现，RSC的主题切换问题，移动端适配实现，推荐算法的简单实现，Nextjs的痛苦开发代理服务器问题，反代 301优化SEO问题，静态资源路径问题

一份学习一分收获吧，虽然现在我还很菜，但是还是不断学习不断进步

> 希望自己能成为独当一面的前端工程师，在热爱的领域闪闪发光，仅此而已
