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

625 阅读7分钟

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,内容应该是:

$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 接口,来查看用户发送的动态内容。并且参数也进行了修改:

$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 镜像:

# 安装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 即可:

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

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

docker --version
docker compose version

Docker 安装成功

Memos 镜像

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

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

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

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

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

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

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 了:

# 拉取镜像
docker compose pull
# 启动
docker compose up -d

Memos 启动成功

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

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

Memos 发说说

Nginx 反代

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

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

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

本质就需要做两件事:

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

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

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

关键源代码

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

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>';
  }
}

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

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;
  }
}

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

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 页面,然后把代码贴进去,最后在主题的配置文件中开启即可。

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

.
├── 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 文件夹下,然后在主题的配置文件中添加如下配置:

  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 当作自己的情绪“垃圾桶🗑”。