Behind the scenes I

Jan 29, 2026 15:52 | #nsfw #bts

去年年底的时候,为了发布所谓的年终总结,特意改造了一下博客系统,是有点为了这碟醋,包了这盘饺子

不过话说回来,之前博客的发布链路还是有很多痛点,比如:

  • 由 Next.js 静态构建,每次发布都要重新 Build
  • 非常 Vanilla 的 MDX 编写

我其实很想要在手机上,或者至少 —— 在 Web 端完成 Blog 的撰写,并且不想每次都走一遍构建流程。我并没有打算折腾一个很复杂且庞大的富文本编辑器,但至少要改进一下上面的一些痛点。

富文本编辑器框架

早在半年前逛 Github 的时候,就看到了一个很精美的 Rich-text editor 框架(Plate.js)。但实际用下来,对我来说有点太开箱即用了,并且我个人认为 —— 这么厚重的一个框架真的不应该用 Shadcn 来承载,for what?

所以我找到了一个更好的替代 —— Tiptap,非常 Headless,非常原始。我的诉求并没有那么多,只要满足我自己可以流畅编辑即可。

MDX 动态组件 & 同构

我的 Blog 并不是 Vanilla Markdown,我还是有一些自定义组件的诉求。

但是我又想实现编辑侧 & 渲染侧的同构。其实这个表述有点不准确,我想实现的是尽量减少新组建的开发成本。

比如对于组件 A,我首先需要实现它的渲染;然后,还需要将其注册到编辑器里面,并且维护一套 MDX -> HTML -> React Component 的序列化 & 反序列化的链路。

所以,我实现了一个简单的统一注册机制。每当新增一个组件,我只需要把它注册进去,并且 Implement 一个 propsonPropsChange 实现,以及处理它们在渲染侧 & 编辑侧的渲染逻辑(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,可以实现一些粘贴自动插入图片的能力。

哦对了,这是整个编辑界面的大体样子:


Update at 05/01 13:13
1
152
0 / 500

Copyright © 2026 hong97.ltd.