使用鸿蒙 Web 组件渲染富文本

在之前的 使用 TaroJS 提供的能力渲染富文本 一文中,我们介绍了在 TaroJS(小程序/H5)中渲染富文本的方式。本文将介绍鸿蒙 ArkUI 中使用 Web 组件 渲染富文本的实现方案。

其中图片 URL 提取(getAllImageUrls)和事件委托的思路与前文一致,此处不再重复。

1. WebView 方案(完整文章页)

对于包含复杂样式、表格、代码块等内容的完整文章,推荐使用 Web 组件加载 HTML。

1.1 生成 HTML 文档

将后端返回的 HTML 内容包装为完整的 HTML 文档,包含内联 CSS 样式:

generateHtmlContent(): string {
  if (!this.article) return '';
  return `<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=0.9" />

    <script>
      var h5Port;
      // 监听来自 ArkTS 侧的消息端口初始化
      window.addEventListener("message", function (event) {
        if (event.data === "__init_port__" && event.ports[0]) {
          h5Port = event.ports[0];
        }
      });

      // 向 ArkTS 侧发送消息
      function PostMsgToEts(data) {
        if (h5Port) { h5Port.postMessage(data); }
      }

      // 拦截所有点击事件
      document.addEventListener("DOMContentLoaded", function () {
        document.addEventListener("click", function (event) {
          const target = event.target;
          const tagName = target.tagName;
          const dataset = target.dataset;

          if (tagName === "A") {
            PostMsgToEts(JSON.stringify({
              type: "linkClick",
              data: {
                tagName: "A",
                href: target.href || "",
                dataset: dataset ? Object.assign({}, dataset) : {},
              }
            }));
            return;
          }

          if (tagName === "SPAN") {
            PostMsgToEts(JSON.stringify({
              type: "spanClick",
              data: {
                tagName: "SPAN",
                dataset: dataset ? Object.assign({}, dataset) : {},
              }
            }));
            return;
          }

          if (tagName === "IMG" || tagName === "IMAGE") {
            PostMsgToEts(JSON.stringify({
              type: "imageClick",
              data: {
                tagName: "IMG",
                src: target.src,
                dataset: dataset ? Object.assign({}, dataset) : {},
              }
            }));
            return;
          }
        });
      });
    </script>
  </head>
  <body>
    <div  id="content">\${this.article.content}</div>
  </body>
</html>`;
}

1.2 加载 HTML 并建立通信

使用 Web 组件的 onControllerAttached 生命周期加载内容,在 onPageEnd 中建立 WebMessagePort 双向通信:

import { webview } from '@kit.ArkWeb';

@State images: DefaultMediaModel[] = [];
private controller: webview.WebviewController = new webview.WebviewController();
private ports: webview.WebMessagePort[] = [];

// 在 build() 中
Web({ src: "", controller: this.controller })
  .width('100%')
  .backgroundColor("#f5f5f5")
  .javaScriptAccess(true)
  .zoomAccess(false)
  .onControllerAttached(() => {
    // 加载 HTML 内容
    this.controller.loadData(
      this.generateHtmlContent(), "text/html", "UTF-8", " ", " "
    );
  })
  .onPageEnd(() => {
    // 1. 创建两个消息端口
    this.ports = this.controller.createWebMessagePorts();
    if (this.ports && this.ports[0] && this.ports[1]) {
      // 2. 在端口1上注册回调,接收 HTML 侧消息
      this.ports[1].onMessageEvent((result: webview.WebMessage) => {
        if (typeof result === 'string') {
          const messageData = JSON.parse(result) as DOMEvent;
          if (messageData.type === 'linkClick' || messageData.type === 'spanClick') {
            this.handleLinkClick(messageData.data as LinkData);
          }
          if (messageData.type === 'imageClick') {
            this.handleImageClick(messageData.data as ImageData);
          }
        }
      });
      // 3. 将端口0发送给 HTML 侧
      this.controller.postMessage('__init_port__', [this.ports[0]], '*');
    }
  })

WebMessagePort 通信流程:

  1. createWebMessagePorts() 创建端口 0 和端口 1
  2. 端口 1 在 ArkTS 侧注册 onMessageEvent 回调接收消息
  3. 通过 postMessage('__init_port__', [端口0], '*') 将端口 0 传递给 HTML 侧
  4. HTML 侧通过 window.addEventListener("message", ...) 接收端口 0 并保存为 h5Port
  5. HTML 侧通过 h5Port.postMessage(data) 向 ArkTS 侧发送消息

2. 链接点击处理

与 TaroJS 方案类似,需要区分外部链接、内部链接和嵌套在 <span> 中的链接。后端需要在提交 HTML 前给标签添加 data-* 属性(data-tag-namedata-hrefdata-a-hrefdata-has-parent-a 等),具体实现参考 前文的 transformHtml 函数

interface LinkData {
  href: string;
  tagName: string;
  dataset: Record<string, string>;
}

handleLinkClick(linkData: LinkData) {
  const { href, tagName, dataset } = linkData;
  const aHref = dataset.aHref || "";
  const datasetHref = dataset.href || "";
  const hasParentA = dataset.hasParentA || "";

  // 处理 <a><span/></a> 的点击,事件会触发在 span 上
  const isSpanLink = tagName === 'SPAN' && Boolean(aHref) && Boolean(hasParentA);
  if (isSpanLink || tagName === 'A') {
    const realLink = href || datasetHref || aHref || '';

    const isExternalLink = realLink.startsWith('http://') || realLink.startsWith('https://');

    if (isExternalLink) {
      // 外部链接处理
    } else {
      // 内部链接:根据业务自行处理路由跳转
    }
  }
}

3. 图片预览

图片 URL 提取逻辑(getAllImageUrls)参考 前文实现。这里补充鸿蒙端的图片预览。

预览图片

加载文章时提取所有图片 URL 并构建预览列表,点击图片时定位到对应位置:

import { DefaultMediaModel, DefaultMediaType, MediaPreview, MediaPreviewOptions } from '@lyb/media-preview';

// 加载文章时构建预览列表
const list = getAllImageUrls(this.article.content)
  ?.filter(Boolean)
  ?.map(src => ({
    type: DefaultMediaType.IMAGE,
    sourceSrc: src,
    placeholder: "预览图片",
  } as DefaultMediaModel));
this.images = list;
this.mediaPreviewOptions.setMedias(list);

// 点击图片时打开预览
handleImageClick(data: ImageData) {
  const src = data.src ?? "";
  const idx = this.images.findIndex(item => item.sourceSrc === src);
  this.mediaPreviewOptions.setInitIndex(idx === -1 ? 0 : idx);
  MediaPreview.open(this.getUIContext(), this.mediaPreviewOptions);
}

4. 参考链接