Skip to content

VitePress 接入 Twikoo:Cloudflare 部署、前端集成与踩坑记录

更新: 8/13/2026 字数: 0 字 时长: 0 分钟

静态博客最尴尬的一点,是评论这件事。页面能静态生成,留言却得有个地方存。以前折腾 WordPress / 自建后端时评论几乎是顺带的能力;迁到 VitePress 之后,反而要想清楚:后端放哪、前端怎么挂、本地开发怎么跨域、暗色主题怎么不崩。

这次选的是 Twikoo-Cloudflare,后端用 Cloudflare Workers + D1,前端接到主题的 doc-after 插槽。

具体流程请按照官方文档为准,上面这里超链接指向了对应的github地址,可以自行前往,并查看readme的安装步骤,本篇文章主要是讲解我在安装过程中遇到了哪些问题。

下面按「怎么搭 → 前端怎么接 → 踩了什么坑」写一遍,方便以后自己翻,也方便同样用 VitePress 的人少走弯路。

为什么是 Twikoo + Cloudflare

评论方案很多,Giscus、Waline、Twikoo 都常见。我这边更在意这几件事:

  1. 不想再养一套自己的评论数据库服务
  2. 希望冷启动别太夸张
  3. 管理入口最好嵌在页面里,别再单独搭后台
  4. 能挂自定义域名,别长期裸奔 *.workers.dev

Twikoo 的 Cloudflare 方案用 Workers 跑云函数,D1 存评论,R2 可以顺带做图片存储。冷启动通常比「Serverless + 外部 Mongo」舒服一些。部署完成后,直接访问 Worker 地址,正常会返回类似:

json
{
  "code": 100,
  "message": "Twikoo 云函数运行正常,请参考 https://twikoo.js.org/frontend.html 完成前端的配置",
  "version": "1.6.44"
}

看到这个,后端就算活了。

自定义域名:别和 R2 抢同一个子域

Worker 默认地址类似:

text
https://twikoo-cloudflare.xxx.workers.dev

生产上肯定要换成自己的域名。这里有个很容易忽略的点:Twikoo Cloudflare 部署里,R2_PUBLIC_URL 经常会先占一个子域,比如本站的:

text
https://twikoo.ylmty.cc   → R2 公共访问
https://comment.ylmty.cc  → Worker 评论接口

如果把 Worker 也绑到已经给 R2 用的 twikoo.ylmty.cc,DNS / 自定义域会打架。所以评论 API 我单独用了 comment.ylmty.cc

绑定方式可以用 Wrangler 的 triggers:

toml
name = "twikoo-cloudflare"
workers_dev = true

[[routes]]
pattern = "comment.ylmty.cc"
custom_domain = true

然后执行:

bash
npx wrangler triggers deploy --config wrangler.toml

前端 envId 就写成带 https:// 的自定义域名:

ts
const TWIKOO_PROD_ENV_ID = "https://comment.ylmty.cc";

前端接入:组件 + Layout 插槽

主题里没有单独的 Layout.vue,而是在 theme/index.ts 里用 h(DefaultTheme.Layout)。所以评论组件挂在默认主题的 doc-after 插槽:

ts
import Twikoo from "./components/Twikoo.vue";

export default {
  extends: DefaultTheme,
  Layout() {
    const props: Record<string, any> = {};
    const { frontmatter } = useData();
    props.class = frontmatter.value?.layout || "";
    return h(DefaultTheme.Layout, props, {
      "doc-after": () => h(Twikoo),
    });
  },
};

核心组件大致是这样:

vue
<template>
  <div v-if="showComment" class="lmt-twikoo">
    <div id="twikoo"></div>
  </div>
</template>

<script setup lang="ts">
import { computed, nextTick, onMounted, watch } from "vue";
import { useData, useRoute } from "vitepress";

const TWIKOO_PROD_ENV_ID = "https://comment.ylmty.cc";

function getTwikooEnvId() {
  if (import.meta.env.DEV && typeof window !== "undefined") {
    return `${window.location.origin}/twikoo-api`;
  }
  return TWIKOO_PROD_ENV_ID;
}

const route = useRoute();
const { frontmatter, page } = useData();

const showComment = computed(() => {
  if (frontmatter.value.comment === false) return false;
  if (frontmatter.value.comment === true) return true;

  const path = (page.value.relativePath || "").replace(/\\/g, "/");

  if (path === "pages/friend.md" || frontmatter.value.layout === "lmt-friend") {
    return true;
  }

  if (
    path.startsWith("articles/") &&
    path.endsWith(".md") &&
    !path.endsWith("/index.md") &&
    path !== "articles/index.md"
  ) {
    return true;
  }

  return false;
});

const initTwikoo = async () => {
  if (typeof window === "undefined" || !showComment.value) return;

  await nextTick();
  const el = document.getElementById("twikoo");
  if (!el) return;
  el.innerHTML = "";

  const twikooMod: any = await import("twikoo");
  const init =
    typeof twikooMod.default === "function"
      ? twikooMod.default
      : twikooMod.init || twikooMod.default?.init;

  if (typeof init !== "function") return;

  await init({
    envId: getTwikooEnvId(),
    el: "#twikoo",
    path: route.path,
    lang: "zh-CN",
  });
};

watch(() => route.path, () => initTwikoo());
onMounted(() => initTwikoo());
</script>

几个设计点:

  1. 只在浏览器初始化:避免 SSR 碰到 window / navigator
  2. 路由切换清空容器再 init:VitePress 是 SPA,不重新挂会串页或残留
  3. 显式传 path: route.path:评论按文章路径隔离
  4. 白名单显示:普通文章和友链开,首页 / 关于 / 标签 / 栏目 index 默认不开

单页也可以用 frontmatter 覆盖:

yaml
---
comment: false
---

或强制打开:

yaml
---
comment: true
---

坑 1:自定义 Layout 吃不到 doc-after

友链页是 layout: lmt-friend。VitePress 会把整页换成注册好的自定义组件,默认文档布局的 doc-after 根本不会渲染。

所以只把 Twikoo 塞进 Layout 插槽不够,友链页要在 Friends.vue 里再挂一次:

vue
<!-- 友链为自定义 layout,doc-after 不生效,需在此挂载评论 -->
<Twikoo />

这是静态站主题里很常见的一类坑:插槽只对默认 Doc 布局生效,自定义页要自己接。

坑 2:twikoo.init is not a function

文档和很多示例会写成:

js
const twikoo = await import("twikoo");
twikoo.init({ ... });

twikoo 这个 npm 包的 UMD 构建,默认导出本身就是 init 函数,不是 { init } 对象。于是你会在控制台看到:

text
TypeError: twikoo.init is not a function

稳妥写法:

ts
const twikooMod: any = await import("twikoo");
const init =
  typeof twikooMod.default === "function"
    ? twikooMod.default
    : twikooMod.init || twikooMod.default?.init;

await init({ envId, el: "#twikoo", path: route.path, lang: "zh-CN" });

另外它没有官方类型包,需要自己补一个声明,否则 TypeScript 会报「无法找到模块声明文件」:

ts
declare module "twikoo" {
  interface TwikooInitOptions {
    envId: string;
    el: string;
    path?: string;
    lang?: string;
  }

  function init(options: TwikooInitOptions): Promise<void> | void;
  export default init;
  export { init };
}

坑 3:本地局域网 IP 的 CORS

生产域名访问评论接口通常没问题。本地如果用:

text
http://192.168.x.x:5173

去打 https://comment.ylmty.cc,浏览器很容易直接 CORS 拦掉。localhost 在 Twikoo 里一般有特殊放行,但局域网 IP 不一定。

更省事的做法是开发环境走 Vite 同源代理:

ts
// docs/.vitepress/config.mts
server: {
  host: "0.0.0.0",
  port: 5173,
  proxy: {
    "/twikoo-api": {
      target: "https://comment.ylmty.cc",
      changeOrigin: true,
      rewrite: (path) => path.replace(/^\/twikoo-api/, "") || "/",
    },
  },
},

前端开发态把 envId 指到当前源:

ts
function getTwikooEnvId() {
  if (import.meta.env.DEV && typeof window !== "undefined") {
    return `${window.location.origin}/twikoo-api`;
  }
  return "https://comment.ylmty.cc";
}

改代理后记得重启 pnpm docs:dev,否则不生效。

管理面板里也可以配 CORS_ALLOW_ORIGIN。开发阶段可以先留空;上线后再收紧成正式域名列表。

坑 4:暗色主题下输入框「看不见边框」

Twikoo 内嵌的是偏 Element UI 风格的输入结构,昵称 / 邮箱 / 网址的边框实际画在 .el-input__inner 上,不是 Element Plus 那套 .el-input__wrapper

如果为了去白底,写成:

css
.el-input__inner {
  border: none !important;
  background: transparent !important;
}

暗色背景下就会出现:标签还在,输入框轮廓没了,看起来像字漂在页面上。

更稳的是显式给边框和背景:

css
.lmt-twikoo .twikoo {
  .el-input__inner {
    color: var(--vp-c-text-1) !important;
    background-color: var(--lmt-card-bg) !important;
    border: 1px solid var(--vp-c-divider) !important;
    border-radius: var(--lmt-radius-md) !important;
  }

  .el-textarea__inner {
    background-color: var(--lmt-card-bg) !important;
    border: 1px solid var(--vp-c-divider) !important;
    border-radius: var(--lmt-radius-lg) !important;
  }
}

管理面板同理,默认黄底提示条、小灰按钮、扁平列表,和站点气质差一截。后面单独给 .tk-admin 做了卡片化、圆角、品牌色提示条和分页样式,观感会好很多。

坑 5:QQ 邮箱预览头像和发出后不一致

这是最容易误会成「配置错了」的问题。

输入框里填 数字@qq.com 时,前端预览会直接拼 QQ 头像地址,所以你能看到自己的 QQ 头像。但评论真正发出后,服务端会走另一套逻辑:先尝试官方 QQ 头像接口;而 Twikoo 源码里已经写明这个接口失效了。失败后就回落到 Gravatar / Cravatar。

于是就出现:

  • 输入框:QQ 真人头像
  • 发出后:Cravatar 默认图(比如一张哭脸)

GRAVATAR_CDN 改成 cravatar.cn 只是换了 Gravatar 镜像,不会自动同步 QQ 头像。要两边一致,可以:

  1. 用同一个邮箱去 Cravatar / WeAvatar 上传头像
  2. 或者改 Worker,在识别到 QQ 邮箱时直接写入 q1.qlogo.cn 地址,不再依赖已失效接口

第一种改配置就能做;第二种更彻底,但要动 Cloudflare 上的 Twikoo Worker。

最终结构小结

落地上大概是这几块:

text
Cloudflare
├─ Worker: twikoo-cloudflare
│  └─ 自定义域: comment.ylmty.cc
├─ D1: twikoo(评论 / 配置)
└─ R2: twikoo(图片,公共域 twikoo.ylmty.cc)

VitePress 主题
├─ theme/index.ts          # doc-after 挂载
├─ components/Twikoo.vue   # 初始化 / 白名单 / 代理 envId
├─ pages/Friends.vue       # 自定义布局补挂评论
├─ style/twikoo.css        # 评论区 + 管理面板样式
└─ type/twikoo.d.ts        # 模块声明

整体不算复杂,但真要「本地好调、线上好看、路径不串、暗色不翻车」,细节比想象中多。静态博客接评论,难的往往不是选哪个产品,而是把主题插槽、SPA 路由、跨域和第三方包的导出方式这些边角料一次收拾干净。