OverRainbow

Add MDX Embed support

☕️ 3 min read

你可以从本文了解到

如何让 mdx 支持各种嵌入式内容,使得可展示的内容大大扩展。包括了几十种内容。

最终效果 demo

如何安装和使用

1、安装依赖

mdx-embed

yarn add mdx-embed

2、将页面用MDXEmbedProvider包裹起来

import {MDXEmbedProvider} from 'mdx-embed';

// ...
export default function ({children}) {
    return (
        <MDXProvider
            components={{
                a: Link,
                wrapper: ({children}) => (
                    <MDXEmbedProvider>                        <div className={styles.content}>{children}</div>
                    </MDXEmbedProvider>                )
            }}
        >
            <MDXComponent />
        </MDXProvider>
    );
}

原理小探

首先看MDXEmbedProvider的代码,如下,基本上就是在原来的MDXProvider上又增加一层封装,使得支持Embed组件

import React, { FunctionComponent } from 'react';
import { MDXProvider } from '@mdx-js/react';

import { components } from './components';

interface IMdxProviderProps {
  /** React Children */
  children: React.ReactNode;
}
export const MDXEmbedProvider: FunctionComponent<IMdxProviderProps> = ({ children }) => (  <MDXProvider components={components}>{children}</MDXProvider>
);

那我们去看一下@mdx-js/react中的MDXProvider做了什么

export const MDXContext = React.createContext({})

const emptyObject = {}

export function useMDXComponents(components) {
  const contextComponents = React.useContext(MDXContext)
  // Memoize to avoid unnecessary top-level context changes
  return React.useMemo(() => {
    // Custom merge via a function prop
    if (typeof components === 'function') {
      return components(contextComponents)
    }

    return {...contextComponents, ...components} // 合并 components和contextComponents
  }, [contextComponents, components])
}


export function MDXProvider({components, children, disableParentContext}) {
  let allComponents = useMDXComponents(components) 

  if (disableParentContext) {
    allComponents = components || emptyObject
  }

  return React.createElement(
    MDXContext.Provider,
    {value: allComponents}, // 将这些components提供给children
    children
  )
}

下面看一个test case,这说明内层定义的组件优先先进行转换,未命中的才会又上层组件来处理。

test('should support components as a function', async () => {
  const {default: Content} = await evaluate('# hi\n## hello', {
    ...runtime,
    useMDXComponents
  })

  assert.equal(
    renderToString(
      <MDXProvider
        components={{
          h1: (props) => <h1 style={{color: 'tomato'}} {...props} />,
          h2: (props) => <h2 style={{color: 'rebeccapurple'}} {...props} />
        }}
      >
        <MDXProvider
          components={() => ({
            h2: (props) => <h2 style={{color: 'papayawhip'}} {...props} />
          })}
        >
          <Content />
        </MDXProvider>
      </MDXProvider>
    ),
    '<h1>hi</h1>\n<h2 style="color:papayawhip">hello</h2>'
  )
})

上面代码的运行顺序是

1、'# hi\n## hello'先被内层组件转换成'# hi\n<h2 style="color:papayawhip">hello</h2>'

2、'# hi\n<h2 style="color:papayawhip">hello</h2>'被外层组件转换成'<h1>hi</h1>\n<h2 style="color:papayawhip">hello</h2>'

那有人就要问了,为什么evaluate会执行这个转换,他怎么知道# hi要命中h1组件呢?

那么我们就需要来看evaluate这个函数,他调用了compile进行编译,

// packages/mdx/lib/evaluate\.js
export async function evaluate(vfileCompatible, evaluateOptions) {
  const {compiletime, runtime} = resolveEvaluateOptions(evaluateOptions) // 分离编译时参数和运行时参数
  // V8 on Erbium.
  /* c8 ignore next 2 */
  return run(await compile(vfileCompatible, compiletime), runtime)
}
// packages/mdx/lib/compile\.js
/**
 * Compile MDX to JS.
 *
 * @param {VFileCompatible} vfileCompatible
 *   MDX document to parse (`string`, `Buffer`, `vfile`, anything that can be
 *   given to `vfile`).
 * @param {CompileOptions} [compileOptions]
 * @return {Promise<VFile>}
 */
export function compile(vfileCompatible, compileOptions) {
  const {file, options} = resolveFileAndOptions(vfileCompatible, compileOptions) // 生成一个VFile对象
  return createProcessor(options).process(file)
}

VFile是unified中用到的一个数据结构,用于在内存中进行文件操作。

调用了createProcessor方法,代码如下。其主要工作就是将mdx解析成AST,最终经过一系列插件运算,转换成JSON对象

其中经过了3个AST转换,分别是mdast,hast,esast

MDAST(Markdown Abstract Syntax Tree)是一种用于表示 Markdown 文本的抽象语法树(AST)格式。它是使用 Remark(一个用于处理 Markdown 文本的 JavaScript 库)解析 Markdown 文本后得到的结果。

HAST(HTML Abstract Syntax Tree)是一种用于表示 HTML 文本的抽象语法树(AST)格式。它是使用 Rehype(一个用于处理 HTML 文本的 JavaScript 库)解析 HTML 文本后得到的结果。

ESAST(JavaScript Abstract Syntax Tree)是一种用于表示 JavaScript 代码的抽象语法树(AST)格式。它是使用 Recast(一个用于处理 JavaScript 代码的 JavaScript 库)解析 JavaScript 代码后得到的结果。

因此,MDAST 用于表示 Markdown 文本,HAST 用于表示 HTML 文本,ESAST 用于表示 JavaScript 代码。

经过mdx-loader的处理后,会输出JS文件

const mdx = require('@mdx-js/mdx')
// ...
module.exports = async function(source) {
  let result
  // ...

  try {
    result = await mdx(mdxContent, options)
  } catch(err) {
    return callback(err)
  }

  // ...
  let code = `
import React from 'react'
import { mdx } from '@mdx-js/react' // 注入react  binding
export const readingTime = ${JSON.stringify(estimatedReadingTime)}
${result}
`

  return callback(null, code)
}

那么mdx binding是个啥呢?如下

compile的过程中,会在处理这个mdx文件的时候会给上面这段代码增加一个注释/* @jsx mdx */,这就使得替换了React.createElement方法,也就起到了从components中映射自定义组件的能力。

// node_modules/@mdx-js/mdx/index.js
async function compile(mdx, options = {}) {
  const opts = Object.assign({}, DEFAULT_OPTIONS, options)
  const compiler = createCompiler(opts)

  const fileOpts = {contents: mdx}
  if (opts.filepath) {
    fileOpts.path = opts.filepath
  }

  const {contents} = await compiler.process(fileOpts)

  return `/* @jsx mdx */
${contents}`
}

总结一下就是

1、编译时,md通过mdx-loader转换成js文件

2、运行时,createElement被劫持,注入了自定义组件