自建 Memos 服务:碎片化笔记 + 博客说说栏,一栈双用

本文最后更新于 2025年6月29日 晚上

Memos 是一款非常轻量的自托管的备忘录中心。你可以把它当作个人笔记本、备忘录,多账号功能,使得我们也可以和小伙伴共同使用,当作专属朋友圈、微博。本次,我们使用 Docker 部署 Memos,实现一个轻量级的个人博客说说栏。

Memos 介绍

为什么使用 Memos 呢? 其实,我之前就比较喜欢用 Apple 的备忘录,但是这样有两个问题:

  • 不方便分享: 如果我分享给小伙伴,那么通常是需要截屏分享。
  • 设备端不够多: 我的 MacBook 和 iPhone 都可以使用备忘录,但是 Android 手机就不太方便。

Memos 的一个好处,就是支持多平台,比如移动端你可以用 MoeMemos(Android 或者 iOS),桌面端你可以用 Memos 自带的 Web:

Memos 的界面和展示

MoeMemos 的界面和展示

而且,Memos 还支持多账号,这样我们就可以和小伙伴一起使用 Memos,共同维护一个 Memos 服务,实现一个轻量级的说说栏。

Memos API

其实我个人对于 Memos 是又爱又恨的:

一方面,它是开源的,使用的还是极其宽松的 MIT License。另一方面,它在轻量化的同时,又非常随意: 我平时用的 API 接口是 api/v1/memos,在我接触 Memos 的两年以来,升级过三次版本,每次的升级,我都需要重新修改和适配它的 API……

可能作者在最初构建 Memos 的时候,没有想到会有这么多人使用它,并没有设计好 Memos 的 API 形式;导致频繁出现破坏性变更

oh my god

旧版本 API

既然都说到 Memos 的 API 了,我们就来说一下旧版本和新版本的区别。我刚开始用 Memos 的时候,应该是 2023.12.19 版本,存在一个查看用户发送的动态内容的接口(不用鉴权,查看公开内容的接口)。

当时使用的 API 是 api/v1/memos,内容应该是:

1
$memoshost/api/v1/memo?creatorId=$creatorId&rowStatus=NORMAL&limit=$limit&offset=$offset

其中:

  • $memoshost 是 Memos 的域名,比如 memos.mintimate.cn
  • $creatorId 是用户 ID,比如 1就是 memos 内的第一个用户。
  • $limit 是返回的记录数,比如 10 就是返回 10 条记录。
  • $offset 是偏移量,比如 20 就是从第 20 条记录开始返回,也就是分页。

如果你还需要标签过滤,那么在后面直接加上 &tagName=$tagName 即可。

具体可以看之前 木木木木木 大佬的文章: Memos API 非官方不完全说明

其实,这个接口就有点奇怪,为什么我们不直接使用 api/v1/memos 呢?果然,后续的版本,就变成了 api/v1/memos……

新版本 API

在后来的版本中,Memos 的 API 就变成了 api/v1/memos,好在后来有了 API 的文档:

查看用户发送的说说

也就是说,我们这个时候需要用 api/v1/memos 接口,来查看用户发送的动态内容。并且参数也进行了修改:

1
$memoshost/api/v1/memos?creatorId=$creatorId&state=NORMAL&pageSize=$pageSize&pageToken=$pageToken

对比之下,我们发现,pageSizepageToken 参数,是用来分页的,取代原本的 limitoffset 参数;同时,state 参数,是用来过滤状态的,取代原本的 rowStatus 参数。

其实还有更多,比如标签的过滤,原本是 tagName,现在变成需要用 filter 过滤。

Memos 部署

我们已经讲完 Memos 最大的“坑”,也就就是它的 API 可能在升级后都需要重新适配。接下来,我们就来讲讲如何部署 Memos。部署就非常简单了,我们提前部署 Docker,之后创建 Docker Compose 文件来映射端口和目录,然后运行即可:

flowchart LR
    Start(("服务器安装 Docker")) --> Method{部署方式}
    
    Method --> |首次部署| A[1. 创建 docker-compose.yml]
    A --> B[2. 启动: docker-compose up -d]
    
    Method --> |版本升级| C[1. 更新: docker-compose pull]
    C --> D[2. 重启: docker-compose up -d]
    
    B --> Success(("✅ 服务运行"))
    D --> Success
    style Start fill:#555,stroke:#fff,color:white
    style Success fill:#555,stroke:#fff,color:white
    style Method fill:#7e57c2,stroke:#fff,color:white,stroke-width:2px
    
    style A fill:#e1f5fe,stroke:#039be5
    style B fill:#bbdefb,stroke:#1976d2
    style C fill:#e8f5e9,stroke#388e3c
    style D fill:#c8e6c9,stroke:#2e7d32

当然,既然需要部署,那么肯定需要一台服务器。服务器的初始化和购买我就跳过了。

准备 Docker

我们需要提前安装好 Docker-ce,这里我们使用 Docker 官方提供的 Docker 安装文档,按照步骤安装即可。
不过,和 GitHub 一样,国内连接 Docker Hub 非常慢,如果你是国内服务器,那么你可以用云厂商的 Docker-ce 镜像源并替换 Docker hub 源为云厂商的镜像源:

以腾讯云为例,我们使用腾讯云的 Debian 镜像,添加 Docker-ce 镜像:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 安装ca-certificates curl
apt update && apt install ca-certificates curl -y
# 创建证书目录
install -m 0755 -d /etc/apt/keyrings
# 下载腾讯云镜像
curl -fsSL https://mirrors.cloud.tencent.com/docker-ce/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
# 添加证书
chmod a+r /etc/apt/keyrings/docker.asc
# 添加镜像源
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://mirrors.cloud.tencent.com/docker-ce/linux/debian/ \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
# 更新apt
apt update

腾讯云镜像源

之后用软件包管理器安装 Docker-ce 即可:

1
apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin  

安装完成后,我们就可以使用 Docker 命令了:

1
2
docker --version
docker compose version

Docker 安装成功

Memos 镜像

Memos 的镜像,同时在 GitHub 和 Docker Hub 上都有,我个人更建议使用 Docker Hub 上的镜像,因为云厂商的镜像源,都有提供内网版本的 Docker Hub 镜像源,可以加速下载。

Memos 的 Docker Hub 镜像地址是: https://hub.docker.com/r/usememos/memos,我们只需要拉取镜像即可:

1
2
# 直接拉取镜像
docker pull usememos/memos

当然,最后直接用 docker compose 启动,更加方便:

1
2
3
4
5
6
# 创建合适的数据持久化目录
mkdir -p /dockerData/memos
# 进入目录
cd /dockerData/memos
# 创建 docker-compose.yml
touch docker-compose.yml

我的 docker-compose.yml 文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
services:
memos:
image: neosmemo/memos:stable
container_name: memos
restart: unless-stopped
ports:
- "5230:5230"
volumes:
- /dockerData/memos/data:/var/opt/memos
environment:
- MEMOS_MODE=prod
- MEMOS_PORT=5230

memos docker-compose.yml

之后,我们就可以启动 Memos 了:

1
2
3
4
# 拉取镜像
docker compose pull
# 启动
docker compose up -d

Memos 启动成功

到此,Memos 就部署完成了。你可以使用浏览器访问 5230 端口,即可看到 Memos 的登录页面。

简单注册账号后,发个说说:

Memos 发说说

Nginx 反代

Memos 默认的端口是 5230,如果你想使用 80 端口,那么你可以使用 Nginx 反代,具体配置如下:

1
2
3
4
5
6
7
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:5230; #注意这里的端口和 Memos 的端口保持一致
}

融入 Hexo

前文提到,我们搭建好的 Memos,提供好了 API,那么我们可以写一个 JavaScript 脚本,将 Memos 的 API 融入到 Hexo 博客中。

最后的效果:

Memos 融入 Hexo

具体效果可以看我的博客: https://www.mintimate.cn/Memos/

本质就需要做两件事:

  • 通过 Memos 的 API 获取说说列表;
  • 使用瀑布流布局,将说说列表渲染到页面上。

发现 木木木木木 大佬已经适配过一次;但是后来 Memos 频繁更新,导致适配失败,所以这里我重新适配了一下:

适配思路很简单,就是通过 Memos 的 API 获取说说列表,然后使用瀑布流布局,将说说列表渲染到页面上。

关键源代码

首先是获取说说列表,这里我封装了一个函数,直接调用即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
async function getFirstList(apiV1){
try {
AppState.bbDom.insertAdjacentHTML('afterend', load);
bindLoadMoreButton(apiV1); // 绑定按钮事件

let bbUrl = AppState.memos+"api/"+apiV1+"memos?creatorId="+bbMemo.creatorId+"&filter=creator_id == 1&pageSize="+AppState.limit;
const response = await fetch(bbUrl);

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const resdata = await response.json();

updateHTMl(resdata)

AppState.offset = resdata.nextPageToken

if (AppState.offset === '' || !resdata.memos || resdata.memos.length === 0){ // 没有下一项数据,隐藏
const loadBtn = document.querySelector("button.button-load");
loadBtn.textContent = '没有更多了';
loadBtn.disabled = true;
return
}

AppState.mePage++
getNextList(apiV1)
} catch (error) {
console.error('获取数据失败:', error);
AppState.bbDom.innerHTML = '<div class="error">加载失败,请刷新页面重试</div>';
}
}

同时,为了加载更快,我们同时加载下一页的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
async function getNextList(apiV1){
try {
if (AppState.isLoading) return; // 防止重复加载

// 已经没有下一页数据 => 隐藏并移除事件
if (AppState.offset === '') {
const loadBtn = document.querySelector("button.button-load");
loadBtn.textContent = '没有更多了';
loadBtn.disabled = true;
return; // 没有下一项数据,隐藏
}
AppState.isLoading = true;

let bbUrl = AppState.memos+"api/"+apiV1+"memos?creatorId="+bbMemo.creatorId+"&pageSize="+AppState.limit+"&pageToken="+AppState.offset;

// 存在标签过滤
if (AppState.tageFilter){
bbUrl = bbUrl + '&filter=tag in ["' + AppState.tageFilter + '"]';
}

const response = await fetch(bbUrl);

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const resdata = await response.json();
AppState.nextDom = resdata
AppState.mePage++
AppState.offset = resdata.nextPageToken

} catch (error) {
console.error('预加载下一页失败:', error);
} finally {
AppState.isLoading = false;
}
}

最后,就是渲染到页面上,这里我使用了瀑布流布局,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
function initWaterfallLayout(onlyNewItems = false) {

const container = document.querySelector('.bb-timeline');
if (!container) return;

const items = container.querySelectorAll('.memo-item');
if (items.length === 0) return;

const containerWidth = container.clientWidth;
const screenWidth = window.innerWidth;

// 响应式设计
let itemWidth, gap, columns;

if (screenWidth < 997) {
// 移动端:卡片占满屏幕宽度,根据屏幕大小调整边距
let horizontalMargin;
if (screenWidth < 480) {
horizontalMargin = 5; // 小屏幕设备边距更小
} else {
horizontalMargin = 10; // 中等屏幕设备
}

itemWidth = containerWidth - horizontalMargin;
gap = 5;
columns = 1;

// 确保最小宽度
if (itemWidth < 200) {
itemWidth = 200;
}

// 更新所有卡片的宽度
items.forEach(item => {
item.style.width = itemWidth + 'px';
});
} else {
// 桌面端:瀑布流布局
itemWidth = 280;
gap = 6;
columns = Math.floor(containerWidth / (itemWidth + gap));

// 限制最大列数
if (columns > 4) {
columns = 4;
}

// 确保至少1列
if (columns < 1) {
columns = 1;
}

// 更新所有卡片的宽度
items.forEach(item => {
item.style.width = itemWidth + 'px';
});
}

const actualGap = columns === 1 ? gap : (containerWidth - columns * itemWidth) / (columns + 1);

let columnHeights = new Array(columns).fill(actualGap * 0.5);

// 如果是增量加载,获取现有的列高度
if (onlyNewItems) {
const existingItems = Array.from(items).filter(item => item.style.opacity !== '0' && item.style.opacity !== '');
if (existingItems.length > 0) {
columnHeights = new Array(columns).fill(0);
existingItems.forEach(item => {
const left = parseInt(item.style.left);
const top = parseInt(item.style.top);

if (columns === 1) {
// 移动端单列布局
const bottom = top + item.offsetHeight;
columnHeights[0] = Math.max(columnHeights[0], bottom);
} else {
// 桌面端多列布局
const columnIndex = Math.round(left / (itemWidth + actualGap));
const bottom = top + item.offsetHeight;
if (columnIndex >= 0 && columnIndex < columns) {
columnHeights[columnIndex] = Math.max(columnHeights[columnIndex], bottom);
}
}
});
}
}

const itemsToProcess = onlyNewItems ?
Array.from(items).filter(item => item.style.opacity === '0' || item.style.opacity === '') :
Array.from(items);

itemsToProcess.forEach((item) => {
// 确保项目有实际内容再进行布局
if (item.offsetHeight === 0) {
// 强制重绘以获取正确高度
item.style.display = 'none';
item.offsetHeight; // 触发回流
item.style.display = '';
}

let left, top, columnIndex;

if (columns === 1) {
// 移动端单列布局,居中显示
left = (containerWidth - itemWidth) / 2;
top = columnHeights[0] + gap;
columnIndex = 0;
} else {
// 桌面端多列布局
const minHeight = Math.min(...columnHeights);
columnIndex = columnHeights.indexOf(minHeight);
left = actualGap + columnIndex * (itemWidth + actualGap);
top = minHeight + 4;
}

// 设置位置
item.style.left = left + 'px';
item.style.top = top + 'px';
item.style.opacity = '1';

// 更新列高度
columnHeights[columnIndex] = top + item.offsetHeight + gap;
});

// 设置容器高度
const maxHeight = Math.max(...columnHeights);
container.style.height = (maxHeight + gap) + 'px';
}

当然,具体的代码比较复杂,这里就不贴了,感兴趣的可以自己看源码。接下来我们看看如何使用。

使用方法

使用就非常简单了,其实就是自定义一个 Hexo 页面,然后把代码贴进去,最后在主题的配置文件中开启即可。

下载源代码后,你可以得到的文件结构如下:

1
2
3
4
5
6
7
8
9
10
11
.
├── LICENSE
├── README.md
└── source
└── Memos
├── bb-lmm-mk.js
├── emaction.js
├── index.md
├── lately.min.js
├── marked.min.js
└── view-image.min.js

修改 source/Memos/index.md 文件,替换其中的 memos 地址为你的 Memos 地址并保存:

修改 Memos 地址

source/Memos 文件夹复制到你的 Hexo 博客的 source 文件夹下,然后在主题的配置文件中添加如下配置:

1
2
3
4
5
6
7
8
menu:
- { key: "home", link: "/", icon: "iconfont icon-home-fill" }
- { key: "archive", link: "/archives/", icon: "iconfont icon-archive-fill" }
- { key: "category", link: "/categories/", icon: "iconfont icon-category-fill" }
- { key: "tag", link: "/tags/", icon: "iconfont icon-tags-fill" }
- { key: "links", link: "/links/", icon: "iconfont icon-link-fill" }
- { key: "about", link: "/about/", icon: "iconfont icon-user-fill" }
- { key: "Memos", link: "/Memos/", icon: "iconfont iconbg-chat" } # 添加这一行

在主题的菜单中添加 Memos

最后,你就可以在博客的 Memos 页面看到效果了:

页面内看到 Memos 导航栏

END

好啦,感谢阅读,如果觉得不错,欢迎点赞、评论、转发。如果有什么问题,欢迎在评论区留言。

其实,如果你不想用 Memos 的 API 做数据源,那么单纯作为一个私有化的“朋友圈”,亦或者是自己的备忘录,那么也是非常不错的(尤其是不用考虑每次的接口破坏性变更)。

思考一下

哈哈,有时候我也会那 Memos 发一些吐槽并设为仅自己可见。有时候,一些东西说出来,反而会好受一些,把 Memos 当作自己的情绪“垃圾桶🗑”。



自建 Memos 服务:碎片化笔记 + 博客说说栏,一栈双用
https://www.mintimate.cn/2025/06/29/baseMemosBB/
作者
Mintimate
发布于
2025年6月29日
许可协议