
去年年底的时候,为了发布所谓的年终总结,特意改造了一下博客系统,是有点为了这碟醋,包了这盘饺子。
不过话说回来,之前博客的发布链路还是有很多痛点,比如:
我其实很想要在手机上,或者至少 —— 在 Web 端完成 Blog 的撰写,并且不想每次都走一遍构建流程。我并没有打算折腾一个很复杂且庞大的富文本编辑器,但至少要改进一下上面的一些痛点。
早在半年前逛 Github 的时候,就看到了一个很精美的 Rich-text editor 框架(Plate.js)。但实际用下来,对我来说有点太开箱即用了,并且我个人认为 —— 这么厚重的一个框架真的不应该用 Shadcn 来承载,for what?
所以我找到了一个更好的替代 —— Tiptap,非常 Headless,非常原始。我的诉求并没有那么多,只要满足我自己可以流畅编辑即可。
我的 Blog 并不是 Vanilla Markdown,我还是有一些自定义组件的诉求。
但是我又想实现编辑侧 & 渲染侧的同构。其实这个表述有点不准确,我想实现的是尽量减少新组建的开发成本。
比如对于组件 A,我首先需要实现它的渲染;然后,还需要将其注册到编辑器里面,并且维护一套 MDX -> HTML -> React Component 的序列化 & 反序列化的链路。
所以,我实现了一个简单的统一注册机制。每当新增一个组件,我只需要把它注册进去,并且 Implement 一个 props 和 onPropsChange 实现,以及处理它们在渲染侧 & 编辑侧的渲染逻辑(mode: 'mdx' | 'editor'),至于其中的序列化 & 反序列化的细节,我不想重复编写。
举个例子 ——
我想实现一个简单的 MDX 动态组件,叫做 Foo,我只需要在编辑时输入 :::lazy.foo 即可看到以下的样子:
在这里,我可以定义组件的 props,并且提供一个预览能力。
而我的 Foo 组件是如何编写的呢?
const Foo: ReactMdxComponent<FooProps> = ({ props, mode }) => {
const { message = 'Hello from Foo!', color = '#10b981' } = props
return (
<div
className="p-6 rounded-lg my-4 border-l-4"
style={{
backgroundColor: `${color}10`,
borderLeftColor: color,
}}
>
<div className="text-lg font-semibold" style={{ color }}>
{message}
</div>
{mode === 'editor' && (
<div className="text-xs text-neutral-400 mt-2">Mode: {mode}</div>
)}
</div>
)
}
然后,再把它注册到一个统一的地方:
/**
* Register all custom MDX components here
*/
export const ComponentMap: Record<string, ComponentMapEntry> = {
/**
* Following components are examples of lazy-loaded components
*/
'lazy.foo': {
lazy: true,
displayName: 'Lazy Foo',
defaultProps: {
message: 'Hello from Foo!',
color: '#10b981',
},
lazyLoader: () => import('../../lazy/foo'),
},
}
Voilà! 就这么简单,这里的 key 就是在编辑时用的 :::x.key 这个指令,而所有的 React 组件只需要 follow 下面的定义:
export interface IReactMdxComponentProps<T = Record<string, any>> {
props: T
onPropsUpdate?: (newProps: T) => void
mode?: 'editor' | 'mdx'
}
这样做的好处,就是我不必去处理复杂的序列化和反序列化步骤,只需专心实现一些自定义组件即可。这些操作被封装到了一个统一的分发组件中:
export const ReactMdxNodeView: React.FC<NodeViewProps> = (props) => {
// 这个就是上面演示的统一编辑 props 的能力
if (componentEntry.lazy) {
return (
<NodeViewWrapper className="react-mdx-node-wrapper">
<div
className={`relative my-4 ${
selected ? 'ring-2 ring-blue-500 ring-offset-2 rounded' : ''
}`}
contentEditable={false}
>
<LazyEditor
componentName={name}
propsJson={propsJson}
onPropsUpdate={handlePropsUpdate}
LazyComponent={lazyState.Component}
loading={lazyState.loading}
error={lazyState.error}
/>
</div>
</NodeViewWrapper>
)
}
const Component = componentEntry.component
return (
<NodeViewWrapper className="react-mdx-node-wrapper">
<div
className={`relative my-4 ${
selected ? 'ring-2 ring-blue-500 ring-offset-2 rounded' : ''
}`}
contentEditable={false}
>
<Component
props={parsedProps}
onPropsUpdate={handlePropsUpdate}
mode="editor"
/>
</div>
</NodeViewWrapper>
)
}
你也许注意到了 lazy 这个关键词。是的,有些 ”组件“ 可能是一过性的,就比如我在年终总结中使用到的:
这些组件可能只是会在某一个 Blog 中被消费,我并不希望做不必要的 Bundle。所以,只要带了 lazy,这些组件只会在需要时去加载。并且我也不需要过度关心它们的「编辑交互体验」,所以把它们收口在了一个统一的 <LazyEditor /> 中。
上面的这个组件还是比较简单,而且我并没有 implement onPropsChange
图片是个很重要的能力,以往我都是需要先将图片上传到 CDN,在 hard-code 一个 React 组件,类似于:
<Image src="https://..." />
这个很麻烦,现在我把它改造了一下,并且我不希望它的编辑体验那么”通用“,像上面演示的那样。
const MdxImage: ReactMdxComponent<IMdxImage> = ({
props,
onPropsUpdate,
mode,
}) => {
// ...
const handleUpload = async (toastOnSuccess?: boolean) => {
const input = document.createElement('input')
// ...
input.onchange = async () => {
try {
const uploadedFiles = (
await Promise.all(compressedImg.map((c) => uploadFile2Oss(c, 'blog')))
).filter(Boolean)
onPropsUpdate({
...props,
urls: [...url, ...uploadedFiles].join(','),
})
} catch {
toast('blog.uploadFailed', { type: 'error' })
} finally {
setLoading(false)
}
}
}
if (mode === 'editor') {
return <ImageEditor ... />
}
// mode === 'mdx'
return <Image ... />
}
现在,当你输入 :::img 时,会出现下面的交互界面:
然后,你可以选择上传图片 ——
Just like that!
你还可以添加 caption,或者选择 append 更多的图片。每当我上传了一张图片,就会触发 onPropsChange 事件,至于后续如何转换成 MDX 我就不需要关心了。我只需要处理好渲染逻辑就好。实际上 MDX 中它们长这样:
<ReactMdxComponent
name="img"
props='{"urls":"https://...","caption":"..."'
/>
不过,还是做了一些优化,借助 tiptap 的一些 API,可以实现一些粘贴自动插入图片的能力。
哦对了,这是整个编辑界面的大体样子: