基于 Taro 的 Markdown AST 渲染器实现

基于 Taro 的 Markdown AST 渲染器实现

在微信小程序或 Taro 项目中,我们经常需要将 Markdown 内容渲染成页面组件。然而,小程序自带的 <rich-text> 组件存在诸多限制,例如 无法自定义样式、无法完整支持复制和选择文本 。为了解决这些问题,我实现了一个 Markdown → AST → Taro 组件 的渲染方案。本文将详细介绍实现思路、核心代码和后续优化方向。


1. Markdown 转 AST

核心类型定义

ini 复制代码
export type ASTNode = {
  type: string;
  attrs?: Record<string, any>;
  children?: ASTNode[];
  text?: string;
};
  • type:节点类型(如 paragraphheadingimage 等)
  • attrs:节点属性,如 <map>latitudelongitude
  • children:子节点
  • text:文本内容(行内节点或文本节点使用)

tokens 转 AST

我们使用 markdown-it 解析 Markdown,得到 token 列表,然后递归转换为 AST:

typescript 复制代码
import MarkdownIt from 'markdown-it';

export function tokensToAST(tokens: any[]): ASTNode[] {
  const ast: ASTNode[] = [];
  const stack: { node: ASTNode }[] = [];

  const addNode = (node: ASTNode) => {
    if (stack.length > 0) stack[stack.length - 1].node.children!.push(node);
    else ast.push(node);
  };

  tokens.forEach(token => {
    if (token.type === 'text' || token.type === 'code_inline') {
      addNode({ type: 'text', text: token.content });
      return;
    }

    if (token.type === 'html_inline' || token.type === 'html_block') {
      const content = token.content.trim();
      const MAP_TAG_OPEN_REGEX = /^<map\s+([^>]*)>/i;
      const MAP_TAG_CLOSE_REGEX = /^</map>/i;
      const parseAttrs = (str: string) => Object.fromEntries([...str.matchAll(/([\w-:]+)="([^"]*)"/g)].map(m => [m[1], m[2]]));

      if (MAP_TAG_CLOSE_REGEX.test(content)) return;
      const match = content.match(MAP_TAG_OPEN_REGEX);
      if (match) {
        const attrs = parseAttrs(match[1]);
        addNode({ type: 'map', attrs, children: [] });
        const remaining = content.replace(match[0], '').trim();
        if (remaining) addNode({ type: 'text', text: remaining });
        return;
      }

      addNode({ type: 'text', text: content });
      return;
    }

    if (token.type.endsWith('_open')) {
      const type = token.type.replace('_open', '');
      const node: ASTNode = { type, attrs: {}, children: [] };
      if (token.attrs) token.attrs.forEach(([k, v]: [string, string]) => (node.attrs![k] = v));
      addNode(node);
      stack.push({ node });
      return;
    }

    if (token.type.endsWith('_close')) {
      stack.pop();
      return;
    }

    if (token.type === 'inline' && token.children) {
      tokensToAST(token.children).forEach(addNode);
      return;
    }
  });

  return ast;
}

export function mdToAST(markdown: string): ASTNode[] {
  const md = new MarkdownIt({ html: true });
  const tokens = md.parse(markdown, {});
  return tokensToAST(tokens);
}

亮点

  • 支持自定义 HTML 标签 <map> 并解析属性
  • 支持行内和块级节点递归解析
  • 自动处理文本、换行和代码块

2. AST 渲染为 Taro 组件

渲染原则

  1. 块级节点 使用 <View> 包裹
  2. 纯文本或行内节点 使用 <Text>
  3. 支持自定义组件(如 <Map><Image>)和样式
  4. 保留原生文本选择能力(userSelect="text"

核心组件

typescript 复制代码
import React from 'react';
import { Text, View, Image, Map } from '@tarojs/components';
import { ASTNode } from './mdToAST';

interface RendererProps {
  nodes: ASTNode[];
  keyPrefix?: string;
}

export const ASTRenderer: React.FC<RendererProps> = ({ nodes, keyPrefix = 'node' }) => {
  if (!Array.isArray(nodes) || nodes.length === 0) return null;

  const isBlockNode = (node: ASTNode) => [
    'map', 'image', 'code_block', 'fence', 'hr', 'heading', 'paragraph'
  ].includes(node.type);

  const isAllTextNodes = (nodes: ASTNode[]): boolean =>
    nodes.every(n => !isBlockNode(n) && (!n.children || isAllTextNodes(n.children)));

  const renderInlineNode = (node: ASTNode, key: string) => {
    switch (node.type) {
      case 'text': return <Text key={key}>{node.text}</Text>;
      case 'strong': return <Text key={key} style={{ fontWeight: 'bold' }}>{node.children?.map((c, i) => renderInlineNode(c, `${key}-${i}`))}</Text>;
      case 'em': return <Text key={key} style={{ fontStyle: 'italic' }}>{node.children?.map((c, i) => renderInlineNode(c, `${key}-${i}`))}</Text>;
      case 'link': return <Text key={key} style={{ color: '#1a0dab' }}>{node.children?.map((c, i) => renderInlineNode(c, `${key}-${i}`))}</Text>;
      case 'softbreak':
      case 'hardbreak': return <Text key={key}>{'\n'}</Text>;
      default: return node.text ? <Text key={key}>{node.text}</Text> : null;
    }
  };

  const renderNode = (node: ASTNode, key: string) => {
    if (!node) return null;
    switch (node.type) {
      case 'paragraph':
        if (node.children && node.children.every(c => !isBlockNode(c))) {
          return <Text key={key} userSelect="text">{node.children.map((c, i) => renderInlineNode(c, `${key}-${i}`))}</Text>;
        } else {
          return <View key={key}>{node.children?.map((c, i) => renderNode(c, `${key}-${i}`))}</View>;
        }
      case 'heading':
        const level = node.attrs?.level || 1;
        return <Text key={key} style={{ fontWeight: 'bold', fontSize: [24, 20, 18, 16, 14, 12][level] || 16 }}>{node.children?.map((c, i) => renderInlineNode(c, `${key}-${i}`))}</Text>;
      case 'image':
        return <Image key={key} src={node.attrs?.src} style={{ width: 200, height: 200 }} />;
      case 'map':
        return <Map key={key} latitude={Number(node.attrs?.latitude)} longitude={Number(node.attrs?.longitude)} scale={16} style={{ width: '100%', height: 200 }} />;
      default:
        return <Text key={key}>{node.text || ''}</Text>;
    }
  };

  if (isAllTextNodes(nodes)) {
    return <Text userSelect="text">{nodes.map((n, i) => renderInlineNode(n, `${keyPrefix}-${i}`))}</Text>;
  }

  return <>{nodes.map((n, i) => renderNode(n, `${keyPrefix}-${i}`))}</>;
};

亮点

  • 纯文本优化 :整篇都是文本时直接用一个 <Text> 包裹
  • 块级/行内分离 :段落内有块级节点时用 <View>,否则用 <Text>
  • 支持自定义组件 :地图 <Map>、图片 <Image>
  • 可选择文本:保留原生复制能力

3. 待优化方向

3.1 性能优化

  • 对连续文本节点进行 合并 ,减少 <Text> 层级
  • 对列表和表格等复杂结构进行 虚拟化渲染
  • 缓存 isAllTextNodesisParagraphAllText 的计算结果

3.2 样式扩展

  • 目前样式固定,后续可以支持:

    • 主题化(暗黑模式、字号调整)
    • 通过 props 注入自定义样式,如 paragraphStyleheadingStyle

3.3 交互增强

  • 链接点击支持外部 URL
  • 图片点击增加预览功能
  • 地图组件增加标记点、点击事件

3.4 Markdown 特性覆盖

  • 支持更多 Markdown 特性:

    • 表格单元格合并(colspan/rowspan)
    • Task list(勾选列表)
    • 脚注、引用、数学公式

3.5 渲染组件优化

  • 相邻行内文本节点可以合并,减少 DOM 层级
  • 段落换行表现可进一步优化,使其在小程序中显示一致

4. 总结

通过 Markdown → AST → Taro 渲染 的方案,我们实现了:

  • 支持自定义组件和样式的 Markdown 渲染
  • 性能可控,支持纯文本优化
  • 保留原生复制能力

后续优化方向包括 性能、样式、交互和 Markdown 特性覆盖 。整体方案可扩展性强,适用于微信小程序、Taro 或 React Native 项目,满足比 <rich-text> 更灵活的 Markdown 渲染需求。

相关推荐
晚星star2 小时前
在 Web 前端实现流式 TTS 播放
前端·vue.js
薄雾晚晴2 小时前
Rspack 性能优化实战:JS/CSS 压缩 + 代码分割,让产物体积直降 40%
前端·javascript
本末倒置1832 小时前
前端面试高频题:18个经典技术难点深度解析与解决方案
前端·vue.js·面试
狗头大军之江苏分军3 小时前
Meta万人裁员亲历者自述:小扎尝到了降本的甜头
前端·后端·github
秃顶老男孩.3 小时前
web中的循环遍历
开发语言·前端·javascript
龙在天3 小时前
vue 请求接口快慢 覆盖 解决方案
前端
跟橙姐学代码4 小时前
Python 类的正确打开方式:从新手到进阶的第一步
前端·python·ipython
Jagger_4 小时前
SonarQube:提升代码质量的前后端解决方案
前端·后端·ai编程
Becauseofyou1374 小时前
如果你刚入门Three.js,这几个开源项目值得你去学习
前端·three.js