普通视图

发现新文章,点击刷新页面。
昨天 — 2025年4月18日首页

[Uni][微信小程序]wx小程序遇到的奇葩事情

作者 七月十二
2025年4月18日 15:28

[Uni][微信小程序]wx小程序遇到的奇葩事情

问题背景
  1. 后端返回的markdown字符串,通过流推送时,ios会偶发序号和内容错行问题,使用mp-htmltowxml都会出现
  2. 迫不得已,重写有序列表、无序列表相关内容,取消默认的序号和标记,改为新增元素到html结构中
  3. 通过span设置新的有序列表,但是部分有序列表意外出现错行
  4. 经研究,当li标签内没有a标签时,rich-text会解析成多个,然后导致意外换行
出现错乱

富文本解析中没有a标签,行错乱

  • 代码 在这里插入图片描述

  • 效果

在这里插入图片描述

解决错乱
  • 代码

在这里插入图片描述

  • 效果

在这里插入图片描述

所有代码

  • text.vue页面代码
<!-- 页面模板 -->
<template>
  <view class="container">
    <div>==========渲染==========</div>
    <div>
      <MdRenderOne :content="content" :cu-style="tagStyle" />
    </div>
  </view>
</template>

<script>
import MdRenderOne from '../../components/MdRender.vue'

const mock = `**国家层面**  \n1. **权益保障与公平待遇**:国家保障外商投资企业依法平等适用支持政策,参与政府采购、标准制定等活动,严禁行业壁垒和地方保护[[1]](https://xxxx.com/detail/1900799531931537408?index=31)。修订《外商投资企业投诉工作办法》,建立跨部门协调机制解决外资企业诉求[[2]](https://xxxx.com/detail/1900810173766438912?index=27)。  \n2. **产业支持与再投资优惠**:鼓励外资投向高新技术、绿色产业等领域,对利润再投资人工智能、生物医药等关键领域的企业提供融资支持,并纳入地方重大项目保障土地、能源等要素[专栏2]。  \n3. **区域开放合作**:支持与共建“一带一路”国家投资合作,引导外资参与区域全面经济伙伴关系协定(RCEP)框架下的农业、跨境电商等领域合作[[1]](https://xxxx.com/detail/1900799531931537408?index=15)。  \n\n**上海市层面**  \n1. **税收与财政奖励**:落实境外投资者利润再投资暂不征收预提所得税政策,各区可按经济贡献度给予外资企业奖励[[3]](https://xxxx.com/detail/1906519738394873856?index=11)[[4]](https://xxxx.com/detail/1906519553627394048?index=12)。对符合产业导向的外资项目优先供应土地,并免征进口设备关税[[3]](https://xxxx.com/detail/1906519738394873856?index=5)[[4]](https://xxxx.com/detail/1906519553627394048?index=5)。  \n2. **服务业开放试点**:在浦东试点基因治疗药品临床试验、增值电信业务开放等,推动金融、生物医药等领域扩大开放[[5]](https://xxxx.com/detail/1900798784980520960?index=3)。  \n3. **便利化服务**:建立外商投资标准化协作平台,优化外籍人员工作居留“单一窗口”服务,推广“五星卡”在沪应用场景[[5]](https://xxxx.com/detail/1900798784980520960?index=8)[[6]](https://xxxx.com/detail/1906518201815797760?index=16)。  \n\n**浦东新区层面**  \n1. **专项产业支持**:对存量外资企业实缴资金超1000万美元的按1%给予资助(上限1000万元);新认定的外资研发中心、跨国公司地区总部分别资助300万元、600万元[[7]](https://xxxx.com/detail/1906519702772649984?index=3)[[8]](https://xxxx.com/detail/1906519501953568768?index=2)。  \n2. **营商环境创新**:试点生物医药全产业链优化方案,拓展数字人民币应用,允许涉外商事纠纷自主约定仲裁规则[[6]](https://xxxx.com/detail/1906518201815797760?index=24)[[9]](https://xxxx.com/detail/1906519503828422656?index=24)。实施市场准营承诺即入制,信用评价结果挂钩政策扶持[[10]](https://xxxx.com/detail/1906518202574966784?index=2)。  \n3. **全球营运计划(GOP)**:对符合条件的跨国地区总部给予最高500万元开办资助、1000万元租房补贴,并配套离岸贸易、境外投资奖励[[11]](https://xxxx.com/detail/1900800858921242624?index=0)。  \n4. **人才与通关便利**:为重点产业外籍人才提供签证、永居便利;优化高新技术产品通关效率,支持保税维修业务[[12]](https://xxxx.com/detail/1904162073484201984?index=1)[[13]](https://xxxx.com/detail/1906519466364899328?index=39)。  \n\n(注:各层级政策具体执行以最新官方文件为准。) `

export default {
  components: {
    MdRenderOne
  },
  data() {
    return {
      content: mock,
      tagStyle: {
        'ul>ol': 'color:red'
      }
    }
  },
  onLoad() {
    console.log('onLoad')
  }
}
</script>

<style lang="scss" scoped>
.container {
  padding: 30rpx;
  background: rgba(201, 225, 225, 0.05);
}
</style>

  • MdRender.vuemarkdown渲染组件
<template>
  <view>
    <view class="container-wxml">
      <mp-html
        :content="processedHtmlContent"
        :copy-link="false"
        :tag-style="tagStyle"
        :selectable="true"
        @ready="readyComplete"
        @linktap="handleLinkClick"
      />
    </view>
    <view :style="{ height: emptyHeight + 'px' }"></view>
  </view>
</template>

<script setup>
import { ref, watch, onMounted, nextTick, computed, getCurrentInstance } from 'vue'
import MpHtml from 'mp-html/dist/uni-app/components/mp-html/mp-html'
import { marked } from 'marked'
import { processOrderedAndUnorderedLists } from './marked_ancillary.js'

const tokenizer = new marked.Tokenizer()
// 禁用URL自动检测
tokenizer.url = function () {
  return false
}
marked.setOptions({ tokenizer })

// 定义组件 props
const props = defineProps({
  content: {
    type: String,
    default: ''
  },
  // 自定义样式
  cuStyle: {
    type: Object,
    default: {}
  }
})

// 强制更新时 的占位元素
const instance = getCurrentInstance()
const emptyHeight = ref(0)

// 定义响应式数据
const htmlContent = ref('')
const tagStyle = ref({
  a: 'color: #427CE8;',
  h1: 'margin-top: 16px;margin-bottom: 16px;font-size: 21px;',
  h2: 'margin-top: 16px;margin-bottom: 16px;font-size: 20px;',
  h3: 'margin-top: 16px;margin-bottom: 16px;font-size: 19px;',
  p: 'font-size: 32rpx; line-height: 1.8; font-weight: 300;',
  ol: 'padding-left: 40rpx;',
  ul: 'padding-left: 40rpx;',
  li: 'font-size: 32rpx; line-height: 1.8; font-weight: 300; list-style-type: none;',
  table: 'width: max-content; border-collapse: collapse; border: 1px solid #dedede;',
  th: 'min-width: 100rpx; max-width: 500rpx; padding: 8px 16px; word-break: break-word; white-space: normal; box-sizing: border-box; border: 1px solid #dedede; background-color: #f6f6f6;',
  td: 'min-width: 100rpx; max-width: 500rpx; padding: 8px 16px; word-break: break-word; white-space: normal; box-sizing: border-box; border: 1px solid #dedede;',
  ...props.cuStyle
})

// 使用计算属性处理HTML,将a标签替换为带有自定义类的span
const processedHtmlContent = computed(() => {
  // 第一步:使用正则表达式替换a标签为自定义的span
  let content = htmlContent.value.replace(/<a\s+href=["'](.*?)["'](.*?)>(.*?)<\/a>/g, '<a data-href="$1">$3</a>')
  // 第二步:处理table标签,在外部包裹div.tableContainer
  content = content.replace(/(<table[\s\S]*?<\/table>)/g, '<div style="overflow-x: auto; width: fit-content; max-width: 100%;">$1</div>')
  // 第三步:处理内容中的有序列表和无需列表
  content = processOrderedAndUnorderedLists(content)
  return content
})

// 准备标识
const isReady = ref(false)
// 渲染次数计数
const renderCount = ref(0)
const maxRenderAttempts = 4
// 渲染内容
const toRender = () => {
  if (isReady.value) {
    renderCount.value++
    if (renderCount.value >= maxRenderAttempts) {
      console.log('多次尝试渲染未完成,强制重置')
      isReady.value = false
      renderCount.value = 0
    }
    return
  }

  renderCount.value = 0
  // console.log('开始渲染')
  isReady.value = true
  const _html = marked(props.content)
  htmlContent.value = _html
}
// 处理 ready 事件
const readyComplete = () => {
  // console.log('渲染完成')
  renderCount.value = 0
  isReady.value = false
}
// 监听 content 变化
onMounted(() => {
  watch(
    // 监听该变量
    () => props.content,
    // 触发该方法
    () => {
      toRender()
    },
    // 首次也进行执行
    { immediate: true }
  )
})

// 获取 emit 函数
const emit = defineEmits(['click-link'])

// 链接点击处理函数
const handleLinkClick = e => {
  emit('click-link', {
    innerText: e.innerText,
    href: e['data-href']
  })

  // 如果需要阻止默认跳转(小程序中默认会跳转),返回 false
  return false // 阻止默认行为
}

// 强制渲染
const repaint = () => {
  uni
    .createSelectorQuery()
    .in(instance)
    .select('.container-wxml')
    .boundingClientRect(res => {
      if (res) {
        const _html = marked(props.content)
        emptyHeight.value = res.height
        // console.log('res.height---', res.height)
        htmlContent.value = ''

        nextTick(() => {
          htmlContent.value = _html
          emptyHeight.value = 0
        })
      }
    })
    .exec()
}

defineExpose({ repaint })
</script>
  • marked_ancillary.jsmarked解析补充方法

取消之前的有序列表和无序列表的左侧数字和图标,改为写入的自定义的内容

/**
 * marked结果辅助处理方法
 */
// 根据层级获取无序列表符号
const getUnorderedListBullet = level => {
  // 定义不同层级的无序列表符号
  // const bullets = ['★', '◆', '●', '○']
  const bullets = ['•', '▸', '▹']

  // 层级从0开始,所以需要减1,同时确保不超过数组范围
  const index = Math.min(level - 1, bullets.length - 1)
  return bullets[Math.max(0, index)] // 确保索引不小于0
}

// 检查是否是指定标签的开始 - 使用更高效的方式
const isTagStart = (html, pos, tagName) => {
  // 如果剩余字符数不足,直接返回false
  if (pos + tagName.length + 1 > html.length) return false

  // 检查"<标签名"
  if (html.charAt(pos) !== '<') return false

  // 避免逐字符比较,直接比较子字符串
  if (html.substring(pos + 1, pos + tagName.length + 1) !== tagName) return false

  // 检查后面的字符
  const nextChar = html.charAt(pos + tagName.length + 1)
  return nextChar === ' ' || nextChar === '>' || nextChar === '/' || nextChar === '\t' || nextChar === '\n' || nextChar === '\r'
}

// 查找标签的结束位置 - 优化实现
const findTagEnd = (html, startPos) => {
  let i = startPos
  let inQuote = false
  let quoteChar = ''
  const htmlLength = html.length

  while (i < htmlLength) {
    const char = html.charAt(i)

    if (!inQuote) {
      if (char === '>') {
        return i
      } else if (char === '"' || char === "'") {
        inQuote = true
        quoteChar = char
      }
    } else if (char === quoteChar) {
      inQuote = false
    }

    i++
  }

  return -1 // 没有找到标签结束
}

// 解析标签属性 - 缓存正则表达式和优化实现
const parseTagAttributes = (html, startPos, endPos) => {
  const tagContent = html.substring(startPos, endPos + 1)
  const attributesMatch = tagContent.match(/\s+([^>]+)/)

  if (!attributesMatch) {
    return {}
  }

  const attributesStr = attributesMatch[1]
  const attributes = {}

  // 解析各种属性 - 使用静态正则表达式避免重复创建
  const attrRegex = /(\w+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]*))|)/g
  let match

  while ((match = attrRegex.exec(attributesStr)) !== null) {
    const name = match[1]
    const value = match[2] || match[3] || match[4] || ''
    attributes[name] = value
  }

  return attributes
}

// 处理有序列表和无需列表
export const processOrderedAndUnorderedLists = html => {
  let result = ''
  let i = 0
  const htmlLength = html.length
  let contextStack = [] // 用于跟踪当前是在哪种列表环境中
  let olItemCounter = {} // 用于跟踪每层ol的当前项计数
  let indentationLevel = 0 // 当前缩进级别
  let ulNestLevel = 0 // 无序列表嵌套级别

  // 缓存常用的子字符串比较结果
  const olEndTag = '</ol>'
  const ulEndTag = '</ul>'
  const liEndTag = '</li>'
  const pStartTag = '<p>'
  const pEndTag = '</p>'

  // 使用常量存储常用字符串,避免字符串拼接的开销
  const CUSTOM_OL_CLASS = 'custom-ol'
  const CUSTOM_UL_CLASS = 'custom-ul'
  // const SPAN_START_OL = '<span style="color: #2196f3; user-select: none;">'
  // const SPAN_START_UL = '<span style="color: #2196f3; user-select: none;">'
  // const SPAN_END = '</span>'
  const SPAN_START_OL = '<a style="color: #2196f3; user-select: none;">'
  const SPAN_START_UL = '<a style="color: #2196f3; user-select: none;">'
  const SPAN_END = '</a>'

  while (i < htmlLength) {
    // 检查ol开始标签
    if (isTagStart(html, i, 'ol')) {
      const tagEndPos = findTagEnd(html, i)
      if (tagEndPos === -1) {
        result += html.charAt(i)
        i++
        continue
      }

      // 记录新的缩进级别
      indentationLevel++

      // 解析ol标签的属性
      const tagAttributes = parseTagAttributes(html, i, tagEndPos)
      const olId = 'ol_' + indentationLevel + '_' + contextStack.length
      let startValue = parseInt(tagAttributes.start) || 1

      // 添加到环境栈
      contextStack.push({
        type: 'ol',
        id: olId,
        start: startValue,
        level: indentationLevel
      })
      olItemCounter[olId] = 0

      // 保留原始属性,但添加自定义类
      let classAttr = `class="${CUSTOM_OL_CLASS}"`
      if (tagAttributes.class) {
        classAttr = `class="${tagAttributes.class} ${CUSTOM_OL_CLASS}"`
      }

      // 重建ol标签,但忽略start属性
      let olTag = '<ol ' + classAttr
      for (const key in tagAttributes) {
        if (key !== 'class' && key !== 'start') {
          olTag += ` ${key}="${tagAttributes[key]}"`
        }
      }
      olTag += '>'

      result += olTag
      i = tagEndPos + 1
    }
    // 检查ol结束标签
    else if (html.substring(i, i + olEndTag.length) === olEndTag) {
      if (contextStack.length > 0 && contextStack[contextStack.length - 1].type === 'ol') {
        // 减少缩进级别
        if (indentationLevel > 0) indentationLevel--
        contextStack.pop()
      }
      result += olEndTag
      i += olEndTag.length
    }
    // 检查ul开始标签
    else if (isTagStart(html, i, 'ul')) {
      const tagEndPos = findTagEnd(html, i)
      if (tagEndPos === -1) {
        result += html.charAt(i)
        i++
        continue
      }

      // 增加无序列表嵌套级别
      ulNestLevel++

      // 记录新的缩进级别
      indentationLevel++

      // 添加自定义类和级别类到ul标签
      const tagAttributes = parseTagAttributes(html, i, tagEndPos)
      let classAttr = `class="${CUSTOM_UL_CLASS} ul-level-${ulNestLevel}"`
      if (tagAttributes.class) {
        classAttr = `class="${tagAttributes.class} ${CUSTOM_UL_CLASS} ul-level-${ulNestLevel}"`
      }

      // 重建ul标签
      let ulTag = '<ul ' + classAttr
      for (const key in tagAttributes) {
        if (key !== 'class') {
          ulTag += ` ${key}="${tagAttributes[key]}"`
        }
      }
      ulTag += '>'

      contextStack.push({
        type: 'ul',
        id: 'ul_' + indentationLevel + '_' + contextStack.length,
        level: indentationLevel,
        ulLevel: ulNestLevel
      })

      result += ulTag
      i = tagEndPos + 1
    }
    // 检查ul结束标签
    else if (html.substring(i, i + ulEndTag.length) === ulEndTag) {
      if (contextStack.length > 0 && contextStack[contextStack.length - 1].type === 'ul') {
        // 减少无序列表嵌套级别
        ulNestLevel--

        // 减少缩进级别
        if (indentationLevel > 0) indentationLevel--
        contextStack.pop()
      }
      result += ulEndTag
      i += ulEndTag.length
    }
    // 检查li开始标签
    else if (isTagStart(html, i, 'li')) {
      const tagEndPos = findTagEnd(html, i)
      if (tagEndPos === -1) {
        result += html.charAt(i)
        i++
        continue
      }

      const tagContent = html.substring(i, tagEndPos + 1)

      if (contextStack.length > 0) {
        const currentContext = contextStack[contextStack.length - 1]

        // 添加li标签,不带自定义内容,保存当前位置以检查后续内容
        result += tagContent
        i = tagEndPos + 1

        // 查找li标签后的内容,检查是否有紧接着的<p>标签
        let nextNonWhitespace = i
        // 跳过空白字符
        while (
          nextNonWhitespace < htmlLength &&
          (html.charAt(nextNonWhitespace) === ' ' ||
            html.charAt(nextNonWhitespace) === '\n' ||
            html.charAt(nextNonWhitespace) === '\t' ||
            html.charAt(nextNonWhitespace) === '\r')
        ) {
          nextNonWhitespace++
        }

        // 检查是否是<p>标签
        const hasParagraph = isTagStart(html, nextNonWhitespace, 'p')

        if (currentContext.type === 'ol') {
          // 在有序列表中
          olItemCounter[currentContext.id]++
          const itemPosition = olItemCounter[currentContext.id] + currentContext.start - 1
          const originalNumber = itemPosition

          // 添加序号并处理<p>标签情况
          if (hasParagraph) {
            // 跳过<p>标签,直接在<p>内部开头添加序号
            const pTagEndPos = findTagEnd(html, nextNonWhitespace)
            if (pTagEndPos !== -1) {
              // 添加<p>标签开始
              result += pStartTag
              // 添加序号
              result += `${SPAN_START_OL}${originalNumber}. ${SPAN_END}`
              // 移动到<p>标签后,继续处理<p>标签内的内容
              i = pTagEndPos + 1
            }
          } else {
            // 没有<p>标签,正常添加序号
            result += `${SPAN_START_OL}${originalNumber}. ${SPAN_END}`
          }
        } else if (currentContext.type === 'ul') {
          // 在无序列表中,处理类似的情况
          const ulLevel = currentContext.ulLevel || 1
          const bullet = getUnorderedListBullet(ulLevel)

          if (hasParagraph) {
            // 跳过<p>标签,直接在<p>内部开头添加符号
            const pTagEndPos = findTagEnd(html, nextNonWhitespace)
            if (pTagEndPos !== -1) {
              // 添加<p>标签开始
              result += pStartTag
              // 添加符号
              result += `${SPAN_START_UL}${bullet}${SPAN_END}`
              // 移动到<p>标签后,继续处理<p>标签内的内容
              i = pTagEndPos + 1
            }
          } else {
            // 没有<p>标签,正常添加符号
            result += `${SPAN_START_UL}${bullet}${SPAN_END}`
          }
        }
      } else {
        // 不在任何列表中
        result += tagContent
        i = tagEndPos + 1
      }
    }
    // 检查li结束标签
    else if (html.substring(i, i + liEndTag.length) === liEndTag) {
      result += liEndTag
      i += liEndTag.length
    }
    // 跳过列表项内部的<p>标签(因为我们已经处理了)
    else if (contextStack.length > 0 && isTagStart(html, i, 'p') && i > 0 && html.substring(i - liEndTag.length, i) !== liEndTag) {
      // 如果在列表项内部且不是刚刚处理过li结束标签后的p标签,则跳过这个p标签开始
      // 因为我们已经在li处理部分自己添加了<p>标签
      const pTagEndPos = findTagEnd(html, i)
      if (pTagEndPos !== -1) {
        i = pTagEndPos + 1
      } else {
        // 如果找不到标签结束,就添加当前字符并向前移动
        result += html.charAt(i)
        i++
      }
    }
    // 检查p结束标签 - 在列表项内部的情况下需要保留
    else if (html.substring(i, i + pEndTag.length) === pEndTag && contextStack.length > 0) {
      result += pEndTag
      i += pEndTag.length
    }
    // 其他字符
    else {
      result += html.charAt(i)
      i++
    }
  }

  console.log('======================')
  console.log(result)

  return result
}
❌
❌