hookehuyr

fix(RichTextRenderer): 重写富文本渲染逻辑修复兼容问题

替换原有的v-html渲染方案,移除过时的transformElement与事件绑定逻辑,使用htmlparser2解析HTML为rich-text组件支持的节点格式,统一配置标签样式,添加图片预览、链接点击与长按复制等交互逻辑,彻底解决原有实现的各类兼容问题。
<template>
<view :id="containerId" class="rich-text-renderer" v-html="processedContent"></view>
<rich-text
class="rich-text-renderer"
:nodes="richTextNodes"
space="nbsp"
:user-select="true"
@tap="handleRichTextTap"
@longtap="handleRichTextLongTap"
/>
</template>
<script setup>
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
import { computed } from 'vue'
import Taro from '@tarojs/taro'
import { $ } from '@tarojs/extend'
import { parseDocument } from 'htmlparser2'
const props = defineProps({
content: {
type: String,
default: '',
},
enableTransform: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['image-preview', 'file-click', 'link-copy'])
const processedContent = ref('')
const containerId = `rich-text-renderer-${Math.random().toString(36).slice(-8)}`
const containerSelector = `#${containerId}`
const previousTransformElement = Taro.options.html?.transformElement
const BLOCK_TAGS = new Set([
'address',
'article',
'aside',
'blockquote',
'body',
'dd',
'details',
'div',
'dl',
'dt',
'fieldset',
'figcaption',
'figure',
'footer',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hgroup',
'hr',
'li',
'main',
'nav',
'ol',
'p',
'pre',
'section',
'table',
'tbody',
'thead',
'tfoot',
'tr',
'td',
'th',
'ul',
])
const TEXT_TAGS = new Set([
'a',
'abbr',
'b',
'code',
'del',
'em',
'i',
'mark',
'q',
's',
'small',
'span',
'strong',
'sub',
'sup',
'text',
'u',
])
const VOID_TAGS = new Set(['br', 'hr', 'img', 'image'])
const BASE_TEXT_STYLE = 'color:#4b5563;font-size:30rpx;line-height:1.8;word-break:break-word;'
const PARAGRAPH_STYLE = `${BASE_TEXT_STYLE}display:block;margin:0 0 20rpx;`
const HEADING_STYLE =
'color:#111827;font-weight:700;line-height:1.5;margin:24rpx 0 16rpx;word-break:break-word;'
const TAG_STYLE_MAP = {
a: 'color:#2563eb;text-decoration:underline;word-break:break-all;',
blockquote: `${PARAGRAPH_STYLE}padding:16rpx 20rpx;border-left:6rpx solid #d1d5db;background:#f9fafb;`,
br: '',
code: 'font-family:monospace;background:#f3f4f6;padding:4rpx 8rpx;border-radius:8rpx;',
div: PARAGRAPH_STYLE,
em: 'font-style:italic;',
h1: `${HEADING_STYLE}font-size:40rpx;`,
h2: `${HEADING_STYLE}font-size:36rpx;`,
h3: `${HEADING_STYLE}font-size:34rpx;`,
h4: `${HEADING_STYLE}font-size:32rpx;`,
h5: `${HEADING_STYLE}font-size:30rpx;`,
h6: `${HEADING_STYLE}font-size:28rpx;`,
hr: 'display:block;height:1rpx;background:#e5e7eb;margin:24rpx 0;',
img: 'width:100%;max-width:100%;height:auto;display:block;margin:24rpx 0;border-radius:16rpx;',
image: 'width:100%;max-width:100%;height:auto;display:block;margin:24rpx 0;border-radius:16rpx;',
li: `${BASE_TEXT_STYLE}display:list-item;margin:0 0 12rpx;`,
ol: `${BASE_TEXT_STYLE}display:block;margin:0 0 20rpx;padding-left:36rpx;list-style-type:decimal;`,
p: PARAGRAPH_STYLE,
pre: `${PARAGRAPH_STYLE}white-space:pre-wrap;background:#f9fafb;padding:20rpx;border-radius:16rpx;overflow:hidden;`,
s: 'text-decoration:line-through;',
strong: 'font-weight:700;',
table: 'display:table;width:100%;border-spacing:0;border-collapse:collapse;margin:20rpx 0;',
td: `${BASE_TEXT_STYLE}display:table-cell;padding:12rpx;border:1rpx solid #d1d5db;vertical-align:top;`,
th: `${BASE_TEXT_STYLE}display:table-cell;padding:12rpx;border:1rpx solid #d1d5db;vertical-align:top;font-weight:700;background:#f9fafb;`,
tr: 'display:table-row;',
u: 'text-decoration:underline;',
ul: `${BASE_TEXT_STYLE}display:block;margin:0 0 20rpx;padding-left:36rpx;list-style-type:disc;`,
}
const decodeHtmlEntities = html => {
if (!html) {
return ''
}
if (process.env.TARO_ENV === 'h5' && typeof document !== 'undefined') {
try {
const textArea = document.createElement('textarea')
textArea.innerHTML = html
const decoded = textArea.value
if (decoded !== html) {
return decoded
}
} catch (error) {
console.warn('[RichTextRenderer] DOM 解码失败,改用映射表', error)
}
}
const entityMap = {
'&nbsp;': '\u00A0',
'&amp;': '&',
......@@ -65,7 +148,6 @@ const decodeHtmlEntities = html => {
}
let result = html
result = result.replace(/&#(\d+);/g, (_match, dec) => String.fromCharCode(dec))
result = result.replace(/&#x([0-9a-fA-F]+);/g, (_match, hex) =>
String.fromCharCode(parseInt(hex, 16))
......@@ -78,27 +160,183 @@ const decodeHtmlEntities = html => {
return result
}
const replaceAnchorTags = html => {
let content = html
// 小程序 rich-text 对原生 a 标签交互能力有限,统一替换成可绑定事件的块级节点。
content = content.replace(/<a\s+/g, '<div class="rich-text-link" ')
content = content.replace(/href=/g, 'data-href=')
content = content.replace(/<\/a>/g, '</div>')
return content
const normalizeHtml = raw => {
if (!raw) {
return ''
}
let normalized = decodeHtmlEntities(raw)
normalized = normalized.replace(/\r\n/g, '\n')
normalized = normalized.replace(/<br\s*\/?>\n?/gi, '<br/>')
normalized = normalized.replace(/\n/g, '<br/>')
return normalized
}
const mergeStyleText = (...styles) => {
return styles
.filter(Boolean)
.map(style => String(style).trim())
.filter(Boolean)
.join('')
}
const processContent = raw => {
if (!raw) {
processedContent.value = ''
return
const normalizeTagName = name => {
if (!name) {
return 'span'
}
const lowerName = String(name).toLowerCase()
if (lowerName === 'img') {
return 'img'
}
if (BLOCK_TAGS.has(lowerName) || TEXT_TAGS.has(lowerName) || VOID_TAGS.has(lowerName)) {
return lowerName
}
let processed = raw
processed = decodeHtmlEntities(processed)
processed = replaceAnchorTags(processed)
processedContent.value = processed
return 'div'
}
const createTextNode = text => ({
type: 'text',
text,
})
const mapAttributes = (tagName, attribs = {}) => {
const attrs = {}
Object.entries(attribs).forEach(([key, value]) => {
if (value === null || value === undefined || value === '') {
return
}
if (key === 'class') {
return
}
if (key === 'href') {
attrs['data-href'] = value
return
}
if (key === 'src') {
attrs.src = value
attrs['data-src'] = value
return
}
attrs[key] = value
})
const originalStyle = attribs.style || ''
attrs.style = mergeStyleText(TAG_STYLE_MAP[tagName], originalStyle)
return attrs
}
const convertDomNode = node => {
if (!node) {
return null
}
if (node.type === 'text') {
if (!node.data) {
return null
}
return createTextNode(node.data)
}
if (node.type === 'script' || node.type === 'style') {
return null
}
if (node.type === 'tag') {
const tagName = normalizeTagName(node.name)
const children = Array.isArray(node.children)
? node.children.map(convertDomNode).filter(Boolean)
: []
if (tagName === 'br') {
return {
name: 'div',
attrs: {
style: PARAGRAPH_STYLE,
},
children: [createTextNode('\n')],
}
}
if (tagName === 'hr') {
return {
name: 'div',
attrs: {
style: TAG_STYLE_MAP.hr,
},
children: [],
}
}
return {
name: tagName,
attrs: mapAttributes(tagName, node.attribs || {}),
children,
}
}
if (node.type === 'root') {
return {
name: 'div',
attrs: {
style: BASE_TEXT_STYLE,
},
children: (node.children || []).map(convertDomNode).filter(Boolean),
}
}
return null
}
const buildNodesFromHtml = html => {
if (!html) {
return []
}
try {
const document = parseDocument(html, {
decodeEntities: false,
lowerCaseAttributeNames: false,
lowerCaseTags: true,
recognizeSelfClosing: true,
})
return (document.children || []).map(convertDomNode).filter(Boolean)
} catch (error) {
console.error('[RichTextRenderer] 富文本解析失败:', error)
return [
{
name: 'div',
attrs: {
style: PARAGRAPH_STYLE,
},
children: [createTextNode(html.replace(/<[^>]+>/g, ''))],
},
]
}
}
const richTextNodes = computed(() => {
const normalized = normalizeHtml(props.content)
if (!normalized) {
return []
}
return buildNodesFromHtml(normalized)
})
const isImageUrl = (url = '') => /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i.test(url)
const getFileNameFromUrl = (url = '') => {
......@@ -137,6 +375,9 @@ const openFileLink = async (url, fileName) => {
current: url,
indicator: 'default',
loop: false,
success: () => {
emit('image-preview', { src: url })
},
})
return
}
......@@ -174,276 +415,35 @@ const openFileLink = async (url, fileName) => {
}
}
const setupTransformElement = () => {
if (!props.enableTransform) {
Taro.options.html.transformElement = previousTransformElement
return
}
Taro.options.html.transformElement = element => {
const transformed = previousTransformElement ? previousTransformElement(element) : element
const nodeName = transformed?.nodeName?.toLowerCase() || ''
const tagName = transformed?.tagName?.toLowerCase() || ''
const isImg =
nodeName === 'img' || tagName === 'img' || nodeName === 'image' || tagName === 'image'
if (!isImg) {
return transformed
}
// 统一兜底图片样式,避免后端富文本里的宽高写死后撑坏小程序布局。
if (transformed?.setAttribute) {
transformed.setAttribute('mode', 'widthFix')
transformed.setAttribute('data-rich-image', 'true')
transformed.setAttribute(
'style',
'width:100%!important;max-width:100%!important;height:auto!important;display:block;margin:24rpx 0;border-radius:16rpx;'
)
}
return transformed
}
}
const bindImageEvents = () => {
nextTick(() => {
const container = $(containerSelector)
const imgs = container.find('.h5-img')
imgs.forEach(img => {
const $img = $(img)
$img.off('longpress')
$img.on('longpress', () => {
const src = $img.attr('src')
if (!src) {
return
}
Taro.previewImage({
urls: [src],
current: src,
indicator: 'default',
loop: false,
success: () => {
emit('image-preview', { src })
},
})
})
})
})
const getNodeUrlFromEvent = event => {
const dataset = event?.target?.dataset || event?.currentTarget?.dataset || {}
return dataset.href || dataset.src || ''
}
const bindFileLinkEvents = () => {
nextTick(() => {
const container = $(containerSelector)
const richTextLinks = container.find('.rich-text-link')
const fileLinks = container.find('._file_list')
const handleRichTextTap = async event => {
const url = getNodeUrlFromEvent(event)
let allLinks = []
if (richTextLinks.length > 0) {
allLinks = allLinks.concat(richTextLinks.toArray())
}
if (fileLinks.length > 0) {
allLinks = allLinks.concat(fileLinks.toArray())
}
allLinks.forEach(el => {
const $el = $(el)
const dataHref = $el.attr('data-href') || $el.attr('href')
if (!dataHref) {
return
}
$el.off('tap')
$el.on('tap', async () => {
const fileName =
$el.find('span span span').first().text() ||
$el.text().trim().substring(0, 50) ||
getFileNameFromUrl(dataHref)
if (!url) {
return
}
await openFileLink(dataHref, fileName)
})
})
})
const fileName = getFileNameFromUrl(url)
await openFileLink(url, fileName)
}
const bindLinkLongPressEvents = () => {
nextTick(() => {
const container = $(containerSelector)
const richTextLinks = container.find('.rich-text-link')
const anchorLinks = container.find('a[href]')
const fileLinks = container.find('._file_list')
let allLinks = []
if (richTextLinks.length > 0) {
allLinks = allLinks.concat(richTextLinks.toArray())
}
if (anchorLinks.length > 0) {
allLinks = allLinks.concat(anchorLinks.toArray())
}
if (fileLinks.length > 0) {
allLinks = allLinks.concat(fileLinks.toArray())
}
allLinks.forEach(el => {
const $el = $(el)
const dataHref = $el.attr('data-href') || $el.attr('href')
if (!dataHref) {
return
}
$el.off('longpress')
$el.on('longpress', () => {
const fileName =
$el.find('span span span').first().text() ||
$el.text().trim().substring(0, 30) ||
getFileNameFromUrl(dataHref)
const handleRichTextLongTap = event => {
const url = getNodeUrlFromEvent(event)
copyLink(dataHref, fileName)
})
})
})
}
const handleContentChange = () => {
processContent(props.content)
if (!url) {
return
}
nextTick(() => {
// 富文本每次重渲染都会替换节点,需要重新挂载图片预览和链接交互事件。
bindImageEvents()
bindFileLinkEvents()
bindLinkLongPressEvents()
})
copyLink(url, getFileNameFromUrl(url))
}
watch(() => props.content, handleContentChange, { immediate: true })
watch(() => props.enableTransform, setupTransformElement, { immediate: true })
onBeforeUnmount(() => {
Taro.options.html.transformElement = previousTransformElement
})
</script>
<style lang="less">
#rich-text-renderer,
.rich-text-renderer {
color: #4b5563;
font-size: 30rpx;
line-height: 1.8;
word-break: break-word;
.h5-html,
.h5-address,
.h5-blockquote,
.h5-body,
.h5-dd,
.h5-div,
.h5-dl,
.h5-dt,
.h5-fieldset,
.h5-form,
.h5-frame,
.h5-frameset,
.h5-h1,
.h5-h2,
.h5-h3,
.h5-h4,
.h5-h5,
.h5-h6,
.h5-noframes,
.h5-ol,
.h5-p,
.h5-ul,
.h5-center,
.h5-dir,
.h5-hr,
.h5-menu,
.h5-pre {
display: block;
unicode-bidi: embed;
}
.h5-li {
display: list-item;
margin-bottom: 12rpx;
}
.h5-head {
display: none;
}
.h5-p,
.h5-div,
.h5-blockquote,
.h5-ul,
.h5-ol {
margin: 0 0 20rpx;
}
.h5-ul,
.h5-ol {
padding-left: 36rpx;
}
.h5-img,
img {
width: 100%;
max-width: 100%;
height: auto;
display: block;
margin: 24rpx 0;
border-radius: 16rpx;
}
.rich-text-link,
.h5-a,
a,
._file_list {
color: #2563eb;
text-decoration: underline;
word-break: break-all;
}
.rich-text-link *,
._file_list * {
pointer-events: none;
}
.h5-b,
.h5-strong {
font-weight: bolder;
}
.h5-i,
.h5-em {
font-style: italic;
}
.h5-table {
display: table;
width: 100%;
border-spacing: 2px;
margin: 20rpx 0;
}
.h5-tr {
display: table-row;
}
.h5-td,
.h5-th {
display: table-cell;
padding: 12rpx;
border: 1rpx solid #d1d5db;
vertical-align: top;
}
.h5-th {
font-weight: bolder;
background: #f9fafb;
}
display: block;
}
</style>
......