1. 项目概述:当React遇见Notion,一个强大的内容渲染引擎
如果你和我一样,既是开发者,又是Notion的重度用户,那你一定有过这样的想法:我能不能把Notion里那些精心编排的页面,直接搬到我的个人网站、博客或者产品文档里?让Notion强大的编辑器成为我的内容创作后台,而前端则用我熟悉的React技术栈来呈现。几年前,这个想法实现起来颇为复杂,需要自己解析Notion的私有API,处理各种复杂的块类型和样式。但现在,有了react-notion-x这个项目,这一切变得前所未有的简单。
react-notion-x是一个用于在React应用中渲染Notion页面的高质量、高性能React库。它的核心价值在于,它不仅仅是一个简单的“页面查看器”,而是一个功能完整、高度可定制、且与Notion官方体验高度一致的渲染引擎。你可以把它理解为一个“Notion渲染器SDK”。它负责将Notion页面数据(通过官方API或第三方工具获取)转换为一整套React组件,包括页面布局、文本样式、列表、待办事项、代码块、数据库(表格、看板、画廊等)、嵌入内容(如视频、地图、Figma)等等。这意味着,你可以用Notion来管理你的所有内容,然后通过react-notion-x无缝地将这些内容集成到任何React应用中,无论是Next.js、Gatsby、Vite还是Create React App项目。
这个库特别适合几类人:独立开发者或小团队,希望快速搭建一个内容驱动的网站(如博客、作品集、文档站),而不想从头构建复杂的内容管理系统(CMS);产品经理或运营人员,希望用更友好的方式(Notion)来维护产品更新日志、帮助文档,并自动同步到官网;以及任何希望将Notion的协作和编辑能力与自定义前端结合的开发者。我自己的技术博客和项目文档就完全基于此构建,实测下来,开发和内容更新效率提升了不止一个量级。
2. 核心架构与设计哲学:不只是渲染,更是生态
在深入代码之前,理解react-notion-x的设计思路至关重要。它没有试图重新发明轮子去模拟一个Notion客户端,而是聪明地扮演了“渲染层”的角色。其架构可以清晰地分为三层:数据获取层、核心渲染层和扩展生态层。
2.1 数据获取:官方API与第三方方案的权衡
react-notion-x本身不处理数据获取。它需要一个符合其预期的页面数据对象(通常是RecordMap类型)。这带来了极大的灵活性。你可以通过以下两种主流方式获取数据:
官方Notion API:这是最“正统”的方式。你需要创建Notion集成,获取API密钥,然后调用
notion.pages.retrieve或notion.blocks.children.list等接口。这种方式安全、稳定,能获取到最新的数据结构和功能。但缺点是需要处理OAuth授权(对于公开页面可简化),并且API有速率限制。notion-client:这是react-notion-x官方推荐的兄弟库。它提供了一个更友好的客户端,封装了与Notion API的交互,并内置了缓存等优化。更重要的是,对于公开页面,它可以使用一种更高效、无需认证的“民间”方式(通过解析页面HTML)来获取数据,这非常适合构建静态网站。许多知名的Notion博客框架(如Next.js Notion Starter Kit)都基于此组合。
注意:使用非官方API方式获取公开页面数据虽然方便,但存在被Notion更改页面结构而中断的风险。对于生产环境,尤其是涉及私有内容时,强烈建议使用官方API。
2.2 核心渲染器:组件化与样式隔离
库的核心是<NotionRenderer />组件。你只需要将获取到的recordMap数据和一个rootPageId传递给它,它就能渲染出整个页面树。其内部实现非常精巧:
- 块类型映射:Notion中有数十种块类型(
paragraph,heading_1,to_do,code,embed,collection_view等)。react-notion-x为每一种块都预先定义了对应的React组件。这些组件负责将块的原始数据(如文本内容、样式属性、子块列表)转换为正确的HTML结构和CSS样式。 - 样式系统:它自带一套精心打磨的CSS样式,力求与Notion官方的视觉效果保持一致,包括字体(通常使用Inter)、颜色、间距、交互状态(如悬停)等。你可以完全覆盖这些样式,也可以只做局部调整。样式通常通过独立的CSS文件引入,确保了样式的模块化和可替换性。
- 动态加载与性能:对于大型页面,库会智能地处理渲染。它不是一次性渲染所有内容,而是遵循React的渲染流程。对于数据库视图(如表格)这类复杂组件,渲染开销较大,但库做了相应优化以保持流畅。
2.3 可扩展性与自定义覆盖
这是react-notion-x最强大的地方之一。它几乎允许你自定义每一个环节。
- 组件覆盖:你可以通过
components属性,传入一个自定义组件映射表。例如,你可以用一个更强大的语法高亮组件(如Prism)替换默认的代码块组件;或者为图片组件添加懒加载和灯箱效果。import { Code } from './my-custom-code'; import { Image } from './my-lazy-image'; const myComponents = { code: Code, image: Image, }; <NotionRenderer recordMap={recordMap} components={myComponents} /> - 页面链接解析:默认情况下,页面内的链接(指向其他Notion页面)会使用
next/link(如果检测到)或普通的<a>标签。你可以通过mapPageUrl属性完全控制页面URL的生成逻辑,轻松将其适配到你的路由系统(如React Router)。 - 属性覆盖:几乎所有内部组件接受的Props(如
className,style)都可以通过顶层配置传递下去,让你能进行细粒度的样式和功能控制。
3. 从零开始:构建一个基于Notion的个人博客
理论说了这么多,我们来点实际的。我将带你一步步用react-notion-x和 Next.js(App Router)搭建一个最简单的个人博客。这个博客的内容完全由Notion中的一个页面数据库驱动。
3.1 项目初始化与依赖安装
首先,创建一个新的Next.js项目,并安装核心依赖。
npx create-next-app@latest notion-blog --typescript --tailwind --app cd notion-blog npm install react-notion-x notion-client这里我们选择了TypeScript、Tailwind CSS和最新的App Router。notion-client用于获取数据。
3.2 获取Notion API权限并准备数据源
- 创建集成:访问 Notion Developers ,创建一个新的“Internal Integration”。获取到
NOTION_SECRET(即API Key)。 - 分享数据库给集成:在你的Notion工作区,创建一个“Database”页面,这就是你的博客文章库。添加一些属性,如
Title、Slug、Published(复选框)、Date(日期)。然后,在这个数据库页面的右上角点击“Share”,邀请你刚刚创建的集成(输入集成的名称),并赋予“Read”权限。复制这个数据库的ID(URL中?v=后面,&之前的那串长字符)。 - 环境变量:在项目根目录创建
.env.local文件:NOTION_SECRET=your_secret_here NOTION_DATABASE_ID=your_database_id_here
3.3 实现核心数据获取函数
我们将创建一个服务层,专门负责与Notion交互。新建lib/notion.ts:
import { Client } from '@notionhq/client'; import { NotionAPI } from 'notion-client'; // 使用官方客户端查询数据库列表 const notionClient = new Client({ auth: process.env.NOTION_SECRET, }); // 使用 notion-client 获取完整的页面渲染数据 const notionXClient = new NotionAPI(); export async function getPublishedPosts() { const response = await notionClient.databases.query({ database_id: process.env.NOTION_DATABASE_ID!, filter: { property: 'Published', checkbox: { equals: true, }, }, sorts: [ { property: 'Date', direction: 'descending', }, ], }); return response.results; } export async function getPageRecordMap(pageId: string) { // 使用 notion-client 获取渲染所需的 recordMap // 这里使用了更高效的第三方获取方式(针对公开页面) // 如需使用官方API,可配置 `notionXClient` 的 `auth` 参数 const recordMap = await notionXClient.getPage(pageId); return recordMap; } export function getPageSlug(page: any): string { // 从页面属性中获取自定义的Slug,如果没有则使用Notion生成的ID const slugProperty = page.properties.Slug; if (slugProperty && slugProperty.type === 'rich_text' && slugProperty.rich_text[0]) { return slugProperty.rich_text[0].plain_text; } return page.id.replace(/-/g, ''); }3.4 构建博客首页与文章详情页
首页 (app/page.tsx):展示文章列表。
import Link from 'next/link'; import { getPublishedPosts, getPageSlug } from '@/lib/notion'; export default async function Home() { const posts = await getPublishedPosts(); return ( <div className="max-w-4xl mx-auto p-8"> <h1 className="text-4xl font-bold mb-10">我的Notion博客</h1> <div className="space-y-6"> {posts.map((post) => { const title = post.properties.Title?.title[0]?.plain_text || 'Untitled'; const slug = getPageSlug(post); return ( <article key={post.id} className="border-b pb-6"> <Link href={`/blog/${slug}`}> <h2 className="text-2xl font-semibold hover:text-blue-600">{title}</h2> </Link> <p className="text-gray-500 mt-2"> {new Date(post.last_edited_time).toLocaleDateString()} </p> </article> ); })} </div> </div> ); }文章详情页 (app/blog/[slug]/page.tsx):使用react-notion-x渲染完整页面。
import { NotionRenderer } from 'react-notion-x'; import { getPageRecordMap } from '@/lib/notion'; import { getPublishedPosts, getPageSlug } from '@/lib/notion'; import { notFound } from 'next/navigation'; // 导入必要的样式和组件 import 'react-notion-x/src/styles.css'; import { Code } from 'react-notion-x/build/third-party/code'; import { Collection } from 'react-notion-x/build/third-party/collection'; interface BlogPageProps { params: Promise<{ slug: string }>; } export default async function BlogPage({ params }: BlogPageProps) { const { slug } = await params; const posts = await getPublishedPosts(); const targetPost = posts.find((post) => getPageSlug(post) === slug); if (!targetPost) { notFound(); } const recordMap = await getPageRecordMap(targetPost.id); return ( <div className="max-w-4xl mx-auto p-4 md:p-8"> <NotionRenderer recordMap={recordMap} fullPage={true} // 渲染为完整页面布局 darkMode={false} // 可根据主题切换 components={{ code: Code, // 使用增强的代码组件(带语法高亮) collection: Collection, // 支持渲染数据库视图 }} mapPageUrl={(pageId) => `/blog/${pageId}`} // 自定义内部页面链接映射 /> </div> ); } // 生成静态路径 export async function generateStaticParams() { const posts = await getPublishedPosts(); return posts.map((post) => ({ slug: getPageSlug(post), })); }3.5 样式优化与集成
- 全局样式:在
app/globals.css中,确保导入了Notion的样式,并可以覆盖一些变量来自定义主题。@import 'react-notion-x/src/styles.css'; /* 自定义主题色 */ :root { --notion-max-width: 720px; } .notion { font-family: var(--font-sans), -apple-system, 'Inter', sans-serif; } - 元数据:在
app/layout.tsx中设置合适的SEO元数据。你也可以从Notion页面属性中动态获取标题和描述。
至此,一个最基本的、内容完全由Notion驱动的博客就搭建完成了。运行npm run dev,你就可以看到效果。在Notion中编辑文章并勾选“Published”,重新构建或刷新页面后,变化就会立刻体现在你的网站上。
4. 高级功能与深度定制实践
基础功能跑通后,我们会面临更多实际需求。react-notion-x提供了丰富的接口来满足这些需求。
4.1 自定义渲染组件:以代码块和图片为例
默认的代码块支持语法高亮,但你可能想换成你喜欢的主题,或者增加“复制代码”按钮。图片组件可能缺少懒加载。
自定义代码块组件 (components/MyCode.tsx):
'use client'; import { Code as NotionCode } from 'react-notion-x/build/third-party/code'; import { CopyToClipboard } from 'react-copy-to-clipboard'; import { useState } from 'react'; export function MyCode(props: any) { const [copied, setCopied] = useState(false); const code = props.content?.[0]?.[0] || ''; const handleCopy = () => { setCopied(true); setTimeout(() => setCopied(false), 2000); }; return ( <div className="relative my-4"> <div className="absolute right-2 top-2 z-10"> <CopyToClipboard text={code} onCopy={handleCopy}> <button className="px-3 py-1 text-xs bg-gray-800 text-white rounded hover:bg-gray-700"> {copied ? '已复制!' : '复制'} </button> </CopyToClipboard> </div> {/* 使用原生的Code组件,但可以传递自定义的语法高亮主题 */} <NotionCode {...props} className="!mt-0" /> </div> ); }自定义图片组件 (components/MyImage.tsx):
'use client'; import Image from 'next/image'; import { useState } from 'react'; export function MyImage({ src, alt, ...props }: any) { const [isLoading, setIsLoading] = useState(true); if (!src) return null; // 处理Notion图片URL,可能需要代理或直接使用 const imageUrl = src.startsWith('http') ? src : `https://www.notion.so${src}`; return ( <div className="my-4 overflow-hidden rounded-lg"> <Image src={imageUrl} alt={alt || 'Notion image'} width={1200} height={630} className={` duration-300 ease-in-out ${isLoading ? 'scale-105 blur-lg' : 'scale-100 blur-0'} `} onLoadingComplete={() => setIsLoading(false)} {...props} /> </div> ); }然后在渲染器中替换它们:
<NotionRenderer recordMap={recordMap} components={{ code: MyCode, image: MyImage, // ... 其他覆盖 }} />4.2 处理复杂块类型:数据库与嵌入
- 数据库视图:
Collection组件已经能很好地渲染表格、列表等视图。但你可能需要自定义每个数据库项的样式,或者过滤、排序数据。这可以通过在Notion中配置不同的视图来实现,react-notion-x会尊重这些视图设置。更复杂的交互(如前端筛选)则需要你自行获取collection_data并渲染。 - 嵌入块:Notion支持嵌入大量第三方内容(YouTube, Twitter, Codepen, Figma等)。
react-notion-x默认会渲染一个iframe或链接。为了更好的性能和体验,我建议使用专门的React库来渲染这些嵌入。例如,对于YouTube,你可以用react-lite-youtube-embed替换默认的embed组件,它能提供更快的加载和预览图。
4.3 性能优化与缓存策略
对于内容站点,性能至关重要。
- 静态生成:如上例所示,在Next.js中使用
generateStaticParams和getPageRecordMap在构建时获取所有页面数据并生成静态HTML。这是最快的方案。 - 增量静态再生:如果内容更新频繁,可以使用Next.js的ISR。为详情页设置一个
revalidate时间,Next.js会在后台按需重新生成页面。// 在详情页组件中 export const revalidate = 3600; // 每1小时重新验证一次 - 客户端缓存:
notion-client本身有内存缓存。对于更持久的缓存,可以考虑使用Redis或文件系统缓存层,在数据获取函数中实现。 - 图片优化:使用Next.js的
next/image组件(如我们的MyImage示例)自动处理图片的懒加载、尺寸优化和WebP格式转换。注意,这需要配置next.config.js允许Notion的图片域名。
5. 常见问题、排查技巧与避坑指南
在实际使用中,我踩过不少坑,也总结了一些经验。
5.1 数据获取失败与认证错误
- 症状:控制台报错
403或Cannot read properties of undefined。 - 排查:
- 检查环境变量:确保
NOTION_SECRET和NOTION_DATABASE_ID已正确设置在.env.local中,并且已重启开发服务器。 - 检查分享权限:确认你已在Notion页面中分享了数据库或页面给你的集成(Integration),而不是个人账户。这是最常见的错误。
- 检查API版本:Notion API有时会更新。确保你安装的
@notionhq/client版本不是太旧。 - 使用正确的页面ID:确保你传递的是页面的UUID,而不是URL。正确的ID是
https://www.notion.so/username/Page-Title-a1b2c3d4e5f6g7h8i9j0中最后一部分。
- 检查环境变量:确保
5.2 样式错乱或丢失
- 症状:页面布局混乱,没有Notion的样式。
- 排查:
- 确认导入了CSS:确保在使用的组件或全局入口文件中导入了
import 'react-notion-x/src/styles.css'。 - 检查CSS冲突:如果你使用了Tailwind CSS等框架,其预检样式可能会重置某些属性。在
tailwind.config.js中设置corePlugins: { preflight: false }可以禁用预检,但可能影响其他组件。更好的做法是提高Notion样式选择器的优先级,或者将Notion渲染部分包裹在一个容器内,使用CSS模块或scoped样式。 - 检查
fullPage属性:fullPage={true}会应用完整的页面布局样式(如最大宽度、背景色)。如果你只想要内容区的样式,可以设为false,然后自己用容器控制布局。
- 确认导入了CSS:确保在使用的组件或全局入口文件中导入了
5.3 数据库视图不显示或显示不全
- 症状:页面中的表格、看板视图显示为空白或加载失败。
- 排查:
- 引入
Collection组件:确保你已将Collection组件传入components属性。 - 检查数据权限:数据库视图的数据获取可能需要额外的权限。确保你的集成对该数据库有“Read”权限,并且数据库本身及其父页面都已分享给集成。
- 使用
notion-client:某些复杂的数据库视图数据,使用官方的@notionhq/client可能获取不全。notion-client库在获取recordMap时通常更可靠。
- 引入
5.4 构建时内存溢出或超时
- 症状:在Vercel或Netlify上构建时失败,报内存错误或函数执行超时。
- 排查:
- 页面数量过多:如果你有上百篇文章,在构建时一次性获取所有页面的
recordMap可能会消耗大量内存和API调用。考虑分批次生成,或只对热门文章预渲染,其余使用客户端动态渲染(CSR)。 - 优化数据获取:在
getPageRecordMap中,可以尝试使用官方API(配置auth)并设置更低的超时和重试,或者实现一个更健壮的缓存层,避免重复获取未变更的页面。 - 增加构建资源:在部署平台上,可以尝试增加构建机器的内存配置。
- 页面数量过多:如果你有上百篇文章,在构建时一次性获取所有页面的
5.5 内容更新延迟
- 症状:在Notion中更新了内容,但网站上看不到变化。
- 解决方案:
- 静态站点:如果你使用静态生成,需要重新触发构建(如Vercel的Git Hook,或手动部署)。
- ISR:确保你正确配置了
revalidate。注意,ISR的重新验证是在请求到来时触发的。第一个访问过期页面的用户会得到旧页面,同时触发后台更新。 - 客户端缓存:检查浏览器缓存。
notion-client的缓存可能导致客户端在短时间内看不到更新。在开发中,可以暂时禁用缓存。
我个人最实用的一个技巧:创建一个“开发专用”的Notion页面,里面包含所有你想测试的块类型(标题、列表、待办、代码、数据库、嵌入等)。在开发时,先用这个固定的页面ID进行渲染调试,可以快速隔离问题是出在数据获取还是组件渲染上,避免被具体文章内容的不确定性干扰。