likes
comments
collection
share

属于你的 Markdown 文本渲染器

作者站长头像
站长
· 阅读数 33

假设现在有一段 markdown 文本,想在浏览器中正常渲染这段文本,这时我们就需要一个 markdown 文本渲染器把这段 markdown 文本转为正确的 HTML 文本来渲染。

如果我们直接使用 markdown-it 来解析,那可以直接转为 HTML 文本,为什么我们还要去做这个渲染器呢?因为想通过一些自定义组件来拓展我们的渲染器,成为一个不一样的 markdown 文本渲染器。

解析 markdown 文本

我们使用知名的 markdown-it 来解析 markdown 文本。我们通过 markdown-it 解析获得 MarkdownIt Tokens

import Markdown from 'markdown-it';

const tokens = new Markdown().parse(markdown, {});

通过上面简单的两行代码,我们就可以得到解析后的 MarkdownIt Tokens

我们先保留着这个 tokens 来备用

定义 Tokens

我们需要定义一个属于自己的 Token 类型,通过转换来生成 AST

首先把 Token 类型分为三种

export type MarkdownTokenType = 'element' | 'text' | 'code_block';

export interface MarkdownToken {
    type: MarkdownTokenType;
    children?: MarkdownToken[];
    attrs?: Record<string, string>;
}

Element 类型

这个类型说明这个 Token 是一个元素类型,这个类型应该是可以渲染成一个 HTML 标签的

export interface MarkdownElement extends MarkdownToken {
    tag: string;
    type: 'element';
    attrs: MarkdownRendererComponentAttrs;
}

Text 类型

这个类型说明这个 Token 是一个纯文本,属于在行内渲染的类型,不会生成一个 HTML 标签的

export interface MarkdownText extends MarkdownToken {
    content: string;
    type: 'text';
}

CodeBlock 类型

这个类型说明这个 Token 是一个代码块,其实他是一个特殊的 Element 类型,它也会渲染一个元素,不过这个元素是固定为 pre

export interface MarkdownCodeBlock extends MarkdownToken {
    tag: 'pre';
    content: string;
    type: 'code_block';
    attrs: MarkdownRendererComponentAttrs;
}

转换 MarkdownIt Token 为 Markdown Token

因为 markdown-it 解析出来的 tokens 是一组一组的,所以我们要转为 AST,方便在后面渲染。

/**
* 解析 markdown 文本为 tokens
*/
export function parse(md: string): MarkdownToken[] {
    const stack: MarkdownToken[] = [];

    function _createElement(
        tag: string,
        attrs: Record<string, string> = {},
        children?: MarkdownToken[]
    ): MarkdownElement {
        return { tag, type: 'element', attrs, children };
    }

    function _createText(content: string): MarkdownText {
        return { type: 'text', content };
    }

    function _createCodeBlock(content: string, attrs: Record<string, string> = {}): MarkdownCodeBlock {
        return { type: 'code_block', content, tag: 'pre', attrs };
}

    function _appendChildInStackTop(...children: MarkdownToken[]) {
        const parent = stack.at(-1);

        if (parent) {
            if (parent.children) {
                parent.children.push(...children);
            } else {
                parent.children = children;
            }
        }
    }

    return (function _parse(originTokens: Token[]): MarkdownToken[] {
        return originTokens.reduce<MarkdownToken[]>((tokens, token) => {
            const { type, tag, children, content, markup, attrs, info } = token;
            
            const _attrs = attrs ? Object.fromEntries(attrs) : {};

            /**
            * 解析开始标签
            *
            * 有开始标签,肯定有闭合标签
            *
            * 所以应该解析完整个 group 才算结束
            */
            if (/_open$/.test(type)) {
                // 入栈 因为 group 解析还没结束
                stack.push(_createElement(tag, _attrs));
                return tokens;
            }
            
            /**
            * 解析关闭标签
            *
            * 因为是关闭标签,所以栈里面一定会有 group 的开始标签
            *
            * 所以最后一个元素应该出栈
            */
            if (/_close$/.test(type)) {
                const _token = stack.pop();
                
                // 如果栈里没有元素,则证明 originToken 有错误,抛出错误
                if (!_token) {
                    throw new Error('Markdown 解析错误');
                }

                // 如果栈是空的,证明最外层的 group 已经解析完成,推入主 token 中
                if (!stack.length) {
                    return [...tokens, _token];
                }

                // 如果栈不是空的,证明最外层的 group 仍然没解析完成,还有别的子节点,推到上层的 group 中的 children 里
                _appendChildInStackTop(_token);

                return tokens;
}


            /**
            * 解析行内元素
            *
            * 通常是 markup 后的元素
            *
            * 主要是内容部分
            */
            if (type === 'inline') {
                // 如果有子元素,则进行深度解析
                if (children?.length) {
                    const _tokens = _parse(children);

                    // 解析后的内容一定是最后入栈的元素,因为 inline 类型是紧跟 *_open 的元素
                    _appendChildInStackTop(..._tokens);

                    return tokens;
                }

                // 解析当前节点
                return [...tokens, _createElement(tag, _attrs)];
            }

            /**
            * 解析文本类型的元素
            *
            * 如果是空文本元素,则不解析
            */
            if (type === 'text' && content) {
                const _token = _createText(content);

                // 解析后的内容一定是最后入栈的元素,因为 text 类型是在 inline 的元素中的
                _appendChildInStackTop(_token);

                return tokens;
            }

            /**
            * 解析行内代码
            */
            if (type === 'code_inline') {
                const _token = _createElement('code', {}, [_createText(content)]);

                // 解析后的内容一定是最后入栈的元素,因为 code_inline 类型是在 inline 的元素中的
                _appendChildInStackTop(_token);

                return tokens;
            }
            
            /**
            * 解析代码块类型的元素
            */
            if (type === 'fence' && markup === '```') {
                return [...tokens, _createCodeBlock(content, { ..._attrs, language: info })];
            }

            // 更多元素的判断

            /**
            * 其余未做判断的元素
            *
            * 则一律不解析
            */
            return tokens;
        }, []);
    })(new Markdown().parse(md, {}));
}

通过上面这个转换,我们可以元素出现的组生成出对应的 AST

局部状态

我们通过 vc-state 来创建一个局部状态

import { createContext } from 'vc-state';


const defaultComponents = {};

/**
* Markdown 渲染器 context
*/

const [MarkdownRendererContextProvider, useMarkdownRendererContext] = createContext((props: MarkdownRendererContextProviderProps) => {
    const { components = {}, namespace = 'renderer' } = props;

    const dynamicComponents = Object.assign({}, defaultComponents, components);

    function createNamespace(...names: string[]) {
        return names.reduce((t, c) => `${t}-${c}`, namespace);
    }

    return {
        namespace,
        createNamespace,
        dynamicComponents,
    };
});

export { MarkdownRendererContextProvider, useMarkdownRendererContext };

通过注册 MarkdownRendererContextProvider,我们可以在每个自定义组件中使用共享状态。

动态渲染组件

通过 tag 名称来渲染对应的自定义组件,并把 attrs 传到对应的渲染函数中

const DynamicRenderer = defineComponent({
    name: 'DynamicRenderer',
    props: {
        tag: {
            type: String,
            required: true,
        },
        attrs: {
            type: Object as PropType<Record<string, string>>,
            default: () => ({}),
        },
    },
    setup(props, { slots }) {
            const { attrs } = toRefs(props);

            const { dynamicComponents } = useMarkdownRendererContext();

            const createComponent = dynamicComponents[props.tag] || dynamicComponents['p'];

            return () => h(createComponent(attrs.value), slots.default);
    },
});

自定义渲染组件

我们在上文的局部状态中共享了一个 defaultComponents

const defaultComponents: MarkdownRendererComponents = {
    h1: attrs => <Heading level={1} {...attrs} />,
    h2: attrs => <Heading level={2} {...attrs} />,
    h3: attrs => <Heading level={3} {...attrs} />,
    h4: attrs => <Heading level={4} {...attrs} />,
    h5: attrs => <Heading level={5} {...attrs} />,
    h6: attrs => <Heading level={6} {...attrs} />,
    blockquote: attrs => <Blockquote {...attrs} />,
    pre: attrs => <Pre {...attrs} />,
    a: attrs => <Link {...attrs} />,
    code: attrs => <Code {...attrs} />,
    p: attrs => <Paragraph {...attrs} />,
};

我们在使用 MarkdownRenderer 的时候,通过覆盖自定义组件工厂函数,来动态渲染对应的自定义组件。

useMarkdownRendererContext

在自定义组件中,可以使用 useMarkdownRendererContext 来获取渲染器的上下文

import { defineComponent } from 'vue';
import { useMarkdownRendererContext } from 'context';

// Heading.tsx
export const Heading = defineComponent({
    name: 'Heading',
    setup(props, { slots }) {
        const context = useMarkdownRendererContext();
        return () => <h1 class={`${context.namespace}-heading`}>{slots.default?.()}</h1>;
    },
});
// App.tsx

export default defineComponent({
    name: 'App',
    setup() {
        const md = '## Hello World';

        return () => {
            return (
                <MarkdownRendererContextProvider components={{ h1: attrs => <Heading {...attrs} /> }}>
                    <MarkdownRenderer content={md} />
                </MarkdownRendererContextProvider>
            );
        };
    },
});

以 MarkdownToken 渲染

我们定义一个 MarkdownTokenRenderer, 可以通过 MarkdownToken 来渲染组件,我们可以通过修改 MarkdownToken 来局部更新组件,不需要全局更新组件。适合频繁改动的 AST。

export const MarkdownTokenRenderer = defineComponent({
    name: 'MarkdownTokenRenderer',
    props: {
        tokens: {
            type: Array as PropType<MarkdownToken[]>,
            default: () => [],
        },
    },
    setup(props) {
        const { tokens } = toRefs(props);

        const { namespace } = useMarkdownRendererContext();

        return () => {
            const elements = (function render(t: MarkdownToken[]) {
                return t.map(item => {
                    if (isMarkdownText(item)) {
                        return item.content;
                    }
    
                    if (isMarkdownCodeBlock(item)) {
                        return <DynamicRenderer tag='pre' attrs={{ ...item.attrs, content: item.content }} />;
                    }

                    if (isMarkdownElement(item)) {
                        return (
                            <DynamicRenderer tag={item.tag} attrs={item.attrs}>
                                {item.children?.length && render(item.children)}
                            </DynamicRenderer>
                        );
                    }

                    return null;
                });
             })(tokens.value);

            return <div class={namespace}>{elements}</div>;
        };
    },
});

以 Markdown 文本渲染

如果不是频繁改动的场景,可以直接使用 MarkdownRenderer 来渲染

import { defineComponent, toRefs, computed } from 'vue';
import { parse } from './parse';
import { MarkdownTokenRenderer } from './MarkdownTokenRenderer';

/**
* Markdown 渲染结果组件
*/
export const MarkdownRenderer = defineComponent({
    name: 'MarkdownRenderer',
    props: {
        content: {
            type: String,
            default: '',
        },
    },
    setup(props) {
        const { content } = toRefs(props);

        const tokens = computed(() => parse(content.value));

        return () => <MarkdownTokenRenderer tokens={tokens.value} />;
    },
});

总结

到了这里,我们已经完成了一个简单的 Markdown 文本渲染器了,我们在文中并没有实现自定义组件,如果有兴趣,可以一起研究一下。

延伸阅读

转载自:https://juejin.cn/post/7145508307132743717
评论
请登录