hookehuyr

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

替换原有的v-html渲染方案,移除过时的transformElement与事件绑定逻辑,使用htmlparser2解析HTML为rich-text组件支持的节点格式,统一配置标签样式,添加图片预览、链接点击与长按复制等交互逻辑,彻底解决原有实现的各类兼容问题。
1 <template> 1 <template>
2 - <view :id="containerId" class="rich-text-renderer" v-html="processedContent"></view> 2 + <rich-text
3 + class="rich-text-renderer"
4 + :nodes="richTextNodes"
5 + space="nbsp"
6 + :user-select="true"
7 + @tap="handleRichTextTap"
8 + @longtap="handleRichTextLongTap"
9 + />
3 </template> 10 </template>
4 11
5 <script setup> 12 <script setup>
6 -import { ref, watch, nextTick, onBeforeUnmount } from 'vue' 13 +import { computed } from 'vue'
7 import Taro from '@tarojs/taro' 14 import Taro from '@tarojs/taro'
8 -import { $ } from '@tarojs/extend' 15 +import { parseDocument } from 'htmlparser2'
9 16
10 const props = defineProps({ 17 const props = defineProps({
11 content: { 18 content: {
12 type: String, 19 type: String,
13 default: '', 20 default: '',
14 }, 21 },
15 - enableTransform: {
16 - type: Boolean,
17 - default: true,
18 - },
19 }) 22 })
20 23
21 const emit = defineEmits(['image-preview', 'file-click', 'link-copy']) 24 const emit = defineEmits(['image-preview', 'file-click', 'link-copy'])
22 25
23 -const processedContent = ref('') 26 +const BLOCK_TAGS = new Set([
24 -const containerId = `rich-text-renderer-${Math.random().toString(36).slice(-8)}` 27 + 'address',
25 -const containerSelector = `#${containerId}` 28 + 'article',
26 -const previousTransformElement = Taro.options.html?.transformElement 29 + 'aside',
30 + 'blockquote',
31 + 'body',
32 + 'dd',
33 + 'details',
34 + 'div',
35 + 'dl',
36 + 'dt',
37 + 'fieldset',
38 + 'figcaption',
39 + 'figure',
40 + 'footer',
41 + 'form',
42 + 'h1',
43 + 'h2',
44 + 'h3',
45 + 'h4',
46 + 'h5',
47 + 'h6',
48 + 'header',
49 + 'hgroup',
50 + 'hr',
51 + 'li',
52 + 'main',
53 + 'nav',
54 + 'ol',
55 + 'p',
56 + 'pre',
57 + 'section',
58 + 'table',
59 + 'tbody',
60 + 'thead',
61 + 'tfoot',
62 + 'tr',
63 + 'td',
64 + 'th',
65 + 'ul',
66 +])
67 +
68 +const TEXT_TAGS = new Set([
69 + 'a',
70 + 'abbr',
71 + 'b',
72 + 'code',
73 + 'del',
74 + 'em',
75 + 'i',
76 + 'mark',
77 + 'q',
78 + 's',
79 + 'small',
80 + 'span',
81 + 'strong',
82 + 'sub',
83 + 'sup',
84 + 'text',
85 + 'u',
86 +])
87 +
88 +const VOID_TAGS = new Set(['br', 'hr', 'img', 'image'])
89 +
90 +const BASE_TEXT_STYLE = 'color:#4b5563;font-size:30rpx;line-height:1.8;word-break:break-word;'
91 +const PARAGRAPH_STYLE = `${BASE_TEXT_STYLE}display:block;margin:0 0 20rpx;`
92 +const HEADING_STYLE =
93 + 'color:#111827;font-weight:700;line-height:1.5;margin:24rpx 0 16rpx;word-break:break-word;'
94 +const TAG_STYLE_MAP = {
95 + a: 'color:#2563eb;text-decoration:underline;word-break:break-all;',
96 + blockquote: `${PARAGRAPH_STYLE}padding:16rpx 20rpx;border-left:6rpx solid #d1d5db;background:#f9fafb;`,
97 + br: '',
98 + code: 'font-family:monospace;background:#f3f4f6;padding:4rpx 8rpx;border-radius:8rpx;',
99 + div: PARAGRAPH_STYLE,
100 + em: 'font-style:italic;',
101 + h1: `${HEADING_STYLE}font-size:40rpx;`,
102 + h2: `${HEADING_STYLE}font-size:36rpx;`,
103 + h3: `${HEADING_STYLE}font-size:34rpx;`,
104 + h4: `${HEADING_STYLE}font-size:32rpx;`,
105 + h5: `${HEADING_STYLE}font-size:30rpx;`,
106 + h6: `${HEADING_STYLE}font-size:28rpx;`,
107 + hr: 'display:block;height:1rpx;background:#e5e7eb;margin:24rpx 0;',
108 + img: 'width:100%;max-width:100%;height:auto;display:block;margin:24rpx 0;border-radius:16rpx;',
109 + image: 'width:100%;max-width:100%;height:auto;display:block;margin:24rpx 0;border-radius:16rpx;',
110 + li: `${BASE_TEXT_STYLE}display:list-item;margin:0 0 12rpx;`,
111 + ol: `${BASE_TEXT_STYLE}display:block;margin:0 0 20rpx;padding-left:36rpx;list-style-type:decimal;`,
112 + p: PARAGRAPH_STYLE,
113 + pre: `${PARAGRAPH_STYLE}white-space:pre-wrap;background:#f9fafb;padding:20rpx;border-radius:16rpx;overflow:hidden;`,
114 + s: 'text-decoration:line-through;',
115 + strong: 'font-weight:700;',
116 + table: 'display:table;width:100%;border-spacing:0;border-collapse:collapse;margin:20rpx 0;',
117 + td: `${BASE_TEXT_STYLE}display:table-cell;padding:12rpx;border:1rpx solid #d1d5db;vertical-align:top;`,
118 + th: `${BASE_TEXT_STYLE}display:table-cell;padding:12rpx;border:1rpx solid #d1d5db;vertical-align:top;font-weight:700;background:#f9fafb;`,
119 + tr: 'display:table-row;',
120 + u: 'text-decoration:underline;',
121 + ul: `${BASE_TEXT_STYLE}display:block;margin:0 0 20rpx;padding-left:36rpx;list-style-type:disc;`,
122 +}
27 123
28 const decodeHtmlEntities = html => { 124 const decodeHtmlEntities = html => {
29 if (!html) { 125 if (!html) {
30 return '' 126 return ''
31 } 127 }
32 128
33 - if (process.env.TARO_ENV === 'h5' && typeof document !== 'undefined') {
34 - try {
35 - const textArea = document.createElement('textarea')
36 - textArea.innerHTML = html
37 - const decoded = textArea.value
38 - if (decoded !== html) {
39 - return decoded
40 - }
41 - } catch (error) {
42 - console.warn('[RichTextRenderer] DOM 解码失败,改用映射表', error)
43 - }
44 - }
45 -
46 const entityMap = { 129 const entityMap = {
47 '&nbsp;': '\u00A0', 130 '&nbsp;': '\u00A0',
48 '&amp;': '&', 131 '&amp;': '&',
...@@ -65,7 +148,6 @@ const decodeHtmlEntities = html => { ...@@ -65,7 +148,6 @@ const decodeHtmlEntities = html => {
65 } 148 }
66 149
67 let result = html 150 let result = html
68 -
69 result = result.replace(/&#(\d+);/g, (_match, dec) => String.fromCharCode(dec)) 151 result = result.replace(/&#(\d+);/g, (_match, dec) => String.fromCharCode(dec))
70 result = result.replace(/&#x([0-9a-fA-F]+);/g, (_match, hex) => 152 result = result.replace(/&#x([0-9a-fA-F]+);/g, (_match, hex) =>
71 String.fromCharCode(parseInt(hex, 16)) 153 String.fromCharCode(parseInt(hex, 16))
...@@ -78,27 +160,183 @@ const decodeHtmlEntities = html => { ...@@ -78,27 +160,183 @@ const decodeHtmlEntities = html => {
78 return result 160 return result
79 } 161 }
80 162
81 -const replaceAnchorTags = html => { 163 +const normalizeHtml = raw => {
82 - let content = html 164 + if (!raw) {
83 - // 小程序 rich-text 对原生 a 标签交互能力有限,统一替换成可绑定事件的块级节点。 165 + return ''
84 - content = content.replace(/<a\s+/g, '<div class="rich-text-link" ') 166 + }
85 - content = content.replace(/href=/g, 'data-href=') 167 +
86 - content = content.replace(/<\/a>/g, '</div>') 168 + let normalized = decodeHtmlEntities(raw)
87 - return content 169 + normalized = normalized.replace(/\r\n/g, '\n')
170 + normalized = normalized.replace(/<br\s*\/?>\n?/gi, '<br/>')
171 + normalized = normalized.replace(/\n/g, '<br/>')
172 + return normalized
173 +}
174 +
175 +const mergeStyleText = (...styles) => {
176 + return styles
177 + .filter(Boolean)
178 + .map(style => String(style).trim())
179 + .filter(Boolean)
180 + .join('')
88 } 181 }
89 182
90 -const processContent = raw => { 183 +const normalizeTagName = name => {
91 - if (!raw) { 184 + if (!name) {
92 - processedContent.value = '' 185 + return 'span'
93 - return 186 + }
187 +
188 + const lowerName = String(name).toLowerCase()
189 +
190 + if (lowerName === 'img') {
191 + return 'img'
192 + }
193 +
194 + if (BLOCK_TAGS.has(lowerName) || TEXT_TAGS.has(lowerName) || VOID_TAGS.has(lowerName)) {
195 + return lowerName
94 } 196 }
95 197
96 - let processed = raw 198 + return 'div'
97 - processed = decodeHtmlEntities(processed)
98 - processed = replaceAnchorTags(processed)
99 - processedContent.value = processed
100 } 199 }
101 200
201 +const createTextNode = text => ({
202 + type: 'text',
203 + text,
204 +})
205 +
206 +const mapAttributes = (tagName, attribs = {}) => {
207 + const attrs = {}
208 +
209 + Object.entries(attribs).forEach(([key, value]) => {
210 + if (value === null || value === undefined || value === '') {
211 + return
212 + }
213 +
214 + if (key === 'class') {
215 + return
216 + }
217 +
218 + if (key === 'href') {
219 + attrs['data-href'] = value
220 + return
221 + }
222 +
223 + if (key === 'src') {
224 + attrs.src = value
225 + attrs['data-src'] = value
226 + return
227 + }
228 +
229 + attrs[key] = value
230 + })
231 +
232 + const originalStyle = attribs.style || ''
233 + attrs.style = mergeStyleText(TAG_STYLE_MAP[tagName], originalStyle)
234 +
235 + return attrs
236 +}
237 +
238 +const convertDomNode = node => {
239 + if (!node) {
240 + return null
241 + }
242 +
243 + if (node.type === 'text') {
244 + if (!node.data) {
245 + return null
246 + }
247 +
248 + return createTextNode(node.data)
249 + }
250 +
251 + if (node.type === 'script' || node.type === 'style') {
252 + return null
253 + }
254 +
255 + if (node.type === 'tag') {
256 + const tagName = normalizeTagName(node.name)
257 +
258 + const children = Array.isArray(node.children)
259 + ? node.children.map(convertDomNode).filter(Boolean)
260 + : []
261 +
262 + if (tagName === 'br') {
263 + return {
264 + name: 'div',
265 + attrs: {
266 + style: PARAGRAPH_STYLE,
267 + },
268 + children: [createTextNode('\n')],
269 + }
270 + }
271 +
272 + if (tagName === 'hr') {
273 + return {
274 + name: 'div',
275 + attrs: {
276 + style: TAG_STYLE_MAP.hr,
277 + },
278 + children: [],
279 + }
280 + }
281 +
282 + return {
283 + name: tagName,
284 + attrs: mapAttributes(tagName, node.attribs || {}),
285 + children,
286 + }
287 + }
288 +
289 + if (node.type === 'root') {
290 + return {
291 + name: 'div',
292 + attrs: {
293 + style: BASE_TEXT_STYLE,
294 + },
295 + children: (node.children || []).map(convertDomNode).filter(Boolean),
296 + }
297 + }
298 +
299 + return null
300 +}
301 +
302 +const buildNodesFromHtml = html => {
303 + if (!html) {
304 + return []
305 + }
306 +
307 + try {
308 + const document = parseDocument(html, {
309 + decodeEntities: false,
310 + lowerCaseAttributeNames: false,
311 + lowerCaseTags: true,
312 + recognizeSelfClosing: true,
313 + })
314 +
315 + return (document.children || []).map(convertDomNode).filter(Boolean)
316 + } catch (error) {
317 + console.error('[RichTextRenderer] 富文本解析失败:', error)
318 + return [
319 + {
320 + name: 'div',
321 + attrs: {
322 + style: PARAGRAPH_STYLE,
323 + },
324 + children: [createTextNode(html.replace(/<[^>]+>/g, ''))],
325 + },
326 + ]
327 + }
328 +}
329 +
330 +const richTextNodes = computed(() => {
331 + const normalized = normalizeHtml(props.content)
332 +
333 + if (!normalized) {
334 + return []
335 + }
336 +
337 + return buildNodesFromHtml(normalized)
338 +})
339 +
102 const isImageUrl = (url = '') => /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i.test(url) 340 const isImageUrl = (url = '') => /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i.test(url)
103 341
104 const getFileNameFromUrl = (url = '') => { 342 const getFileNameFromUrl = (url = '') => {
...@@ -137,6 +375,9 @@ const openFileLink = async (url, fileName) => { ...@@ -137,6 +375,9 @@ const openFileLink = async (url, fileName) => {
137 current: url, 375 current: url,
138 indicator: 'default', 376 indicator: 'default',
139 loop: false, 377 loop: false,
378 + success: () => {
379 + emit('image-preview', { src: url })
380 + },
140 }) 381 })
141 return 382 return
142 } 383 }
...@@ -174,276 +415,35 @@ const openFileLink = async (url, fileName) => { ...@@ -174,276 +415,35 @@ const openFileLink = async (url, fileName) => {
174 } 415 }
175 } 416 }
176 417
177 -const setupTransformElement = () => { 418 +const getNodeUrlFromEvent = event => {
178 - if (!props.enableTransform) { 419 + const dataset = event?.target?.dataset || event?.currentTarget?.dataset || {}
179 - Taro.options.html.transformElement = previousTransformElement 420 + return dataset.href || dataset.src || ''
180 - return
181 - }
182 -
183 - Taro.options.html.transformElement = element => {
184 - const transformed = previousTransformElement ? previousTransformElement(element) : element
185 - const nodeName = transformed?.nodeName?.toLowerCase() || ''
186 - const tagName = transformed?.tagName?.toLowerCase() || ''
187 - const isImg =
188 - nodeName === 'img' || tagName === 'img' || nodeName === 'image' || tagName === 'image'
189 -
190 - if (!isImg) {
191 - return transformed
192 - }
193 -
194 - // 统一兜底图片样式,避免后端富文本里的宽高写死后撑坏小程序布局。
195 - if (transformed?.setAttribute) {
196 - transformed.setAttribute('mode', 'widthFix')
197 - transformed.setAttribute('data-rich-image', 'true')
198 - transformed.setAttribute(
199 - 'style',
200 - 'width:100%!important;max-width:100%!important;height:auto!important;display:block;margin:24rpx 0;border-radius:16rpx;'
201 - )
202 - }
203 -
204 - return transformed
205 - }
206 -}
207 -
208 -const bindImageEvents = () => {
209 - nextTick(() => {
210 - const container = $(containerSelector)
211 - const imgs = container.find('.h5-img')
212 -
213 - imgs.forEach(img => {
214 - const $img = $(img)
215 - $img.off('longpress')
216 - $img.on('longpress', () => {
217 - const src = $img.attr('src')
218 -
219 - if (!src) {
220 - return
221 - }
222 -
223 - Taro.previewImage({
224 - urls: [src],
225 - current: src,
226 - indicator: 'default',
227 - loop: false,
228 - success: () => {
229 - emit('image-preview', { src })
230 - },
231 - })
232 - })
233 - })
234 - })
235 } 421 }
236 422
237 -const bindFileLinkEvents = () => { 423 +const handleRichTextTap = async event => {
238 - nextTick(() => { 424 + const url = getNodeUrlFromEvent(event)
239 - const container = $(containerSelector)
240 - const richTextLinks = container.find('.rich-text-link')
241 - const fileLinks = container.find('._file_list')
242 425
243 - let allLinks = [] 426 + if (!url) {
244 - if (richTextLinks.length > 0) { 427 + return
245 - allLinks = allLinks.concat(richTextLinks.toArray()) 428 + }
246 - }
247 - if (fileLinks.length > 0) {
248 - allLinks = allLinks.concat(fileLinks.toArray())
249 - }
250 -
251 - allLinks.forEach(el => {
252 - const $el = $(el)
253 - const dataHref = $el.attr('data-href') || $el.attr('href')
254 -
255 - if (!dataHref) {
256 - return
257 - }
258 -
259 - $el.off('tap')
260 - $el.on('tap', async () => {
261 - const fileName =
262 - $el.find('span span span').first().text() ||
263 - $el.text().trim().substring(0, 50) ||
264 - getFileNameFromUrl(dataHref)
265 429
266 - await openFileLink(dataHref, fileName) 430 + const fileName = getFileNameFromUrl(url)
267 - }) 431 + await openFileLink(url, fileName)
268 - })
269 - })
270 } 432 }
271 433
272 -const bindLinkLongPressEvents = () => { 434 +const handleRichTextLongTap = event => {
273 - nextTick(() => { 435 + const url = getNodeUrlFromEvent(event)
274 - const container = $(containerSelector)
275 - const richTextLinks = container.find('.rich-text-link')
276 - const anchorLinks = container.find('a[href]')
277 - const fileLinks = container.find('._file_list')
278 -
279 - let allLinks = []
280 - if (richTextLinks.length > 0) {
281 - allLinks = allLinks.concat(richTextLinks.toArray())
282 - }
283 - if (anchorLinks.length > 0) {
284 - allLinks = allLinks.concat(anchorLinks.toArray())
285 - }
286 - if (fileLinks.length > 0) {
287 - allLinks = allLinks.concat(fileLinks.toArray())
288 - }
289 -
290 - allLinks.forEach(el => {
291 - const $el = $(el)
292 - const dataHref = $el.attr('data-href') || $el.attr('href')
293 -
294 - if (!dataHref) {
295 - return
296 - }
297 -
298 - $el.off('longpress')
299 - $el.on('longpress', () => {
300 - const fileName =
301 - $el.find('span span span').first().text() ||
302 - $el.text().trim().substring(0, 30) ||
303 - getFileNameFromUrl(dataHref)
304 436
305 - copyLink(dataHref, fileName) 437 + if (!url) {
306 - }) 438 + return
307 - }) 439 + }
308 - })
309 -}
310 -
311 -const handleContentChange = () => {
312 - processContent(props.content)
313 440
314 - nextTick(() => { 441 + copyLink(url, getFileNameFromUrl(url))
315 - // 富文本每次重渲染都会替换节点,需要重新挂载图片预览和链接交互事件。
316 - bindImageEvents()
317 - bindFileLinkEvents()
318 - bindLinkLongPressEvents()
319 - })
320 } 442 }
321 -
322 -watch(() => props.content, handleContentChange, { immediate: true })
323 -watch(() => props.enableTransform, setupTransformElement, { immediate: true })
324 -
325 -onBeforeUnmount(() => {
326 - Taro.options.html.transformElement = previousTransformElement
327 -})
328 </script> 443 </script>
329 444
330 <style lang="less"> 445 <style lang="less">
331 -#rich-text-renderer,
332 .rich-text-renderer { 446 .rich-text-renderer {
333 - color: #4b5563; 447 + display: block;
334 - font-size: 30rpx;
335 - line-height: 1.8;
336 - word-break: break-word;
337 -
338 - .h5-html,
339 - .h5-address,
340 - .h5-blockquote,
341 - .h5-body,
342 - .h5-dd,
343 - .h5-div,
344 - .h5-dl,
345 - .h5-dt,
346 - .h5-fieldset,
347 - .h5-form,
348 - .h5-frame,
349 - .h5-frameset,
350 - .h5-h1,
351 - .h5-h2,
352 - .h5-h3,
353 - .h5-h4,
354 - .h5-h5,
355 - .h5-h6,
356 - .h5-noframes,
357 - .h5-ol,
358 - .h5-p,
359 - .h5-ul,
360 - .h5-center,
361 - .h5-dir,
362 - .h5-hr,
363 - .h5-menu,
364 - .h5-pre {
365 - display: block;
366 - unicode-bidi: embed;
367 - }
368 -
369 - .h5-li {
370 - display: list-item;
371 - margin-bottom: 12rpx;
372 - }
373 -
374 - .h5-head {
375 - display: none;
376 - }
377 -
378 - .h5-p,
379 - .h5-div,
380 - .h5-blockquote,
381 - .h5-ul,
382 - .h5-ol {
383 - margin: 0 0 20rpx;
384 - }
385 -
386 - .h5-ul,
387 - .h5-ol {
388 - padding-left: 36rpx;
389 - }
390 -
391 - .h5-img,
392 - img {
393 - width: 100%;
394 - max-width: 100%;
395 - height: auto;
396 - display: block;
397 - margin: 24rpx 0;
398 - border-radius: 16rpx;
399 - }
400 -
401 - .rich-text-link,
402 - .h5-a,
403 - a,
404 - ._file_list {
405 - color: #2563eb;
406 - text-decoration: underline;
407 - word-break: break-all;
408 - }
409 -
410 - .rich-text-link *,
411 - ._file_list * {
412 - pointer-events: none;
413 - }
414 -
415 - .h5-b,
416 - .h5-strong {
417 - font-weight: bolder;
418 - }
419 -
420 - .h5-i,
421 - .h5-em {
422 - font-style: italic;
423 - }
424 -
425 - .h5-table {
426 - display: table;
427 - width: 100%;
428 - border-spacing: 2px;
429 - margin: 20rpx 0;
430 - }
431 -
432 - .h5-tr {
433 - display: table-row;
434 - }
435 -
436 - .h5-td,
437 - .h5-th {
438 - display: table-cell;
439 - padding: 12rpx;
440 - border: 1rpx solid #d1d5db;
441 - vertical-align: top;
442 - }
443 -
444 - .h5-th {
445 - font-weight: bolder;
446 - background: #f9fafb;
447 - }
448 } 448 }
449 </style> 449 </style>
......