普通视图

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

uniapp运行在app端如何使用缓存

作者 aklry
2025年4月18日 15:54

uniapp运行在app端如何使用缓存

众所周知,uniapp可以一套代码,多端运行。但是需要注意的是,window对象以及document是浏览器特有的(所以app端无法使用localStorage等api),因此,uniapp贴心的为我们准备了getStorage以及setStorage等操作缓存的api。除此以外,还有vue官方推荐的pinia也可以操作缓存。那么当这两者同时存在于一个项目时,我们应该是他们完美结合起来呢?

为什么uniapp提供了所需的api之后还要用到pinia呢?

有这么一个场景:在程序启动时我需要轮询服务器接口获取是否新数据的产生,如果有,则将hasRead字段设为true,又因为这个hasRead字段我需要在程序启动时就能拿到值,因此我是用setStorageapi将其写进缓存。那么,问题来了,我如何将缓存的字段变为响应式的呢?为什么需要响应式?因为当有新数据产生时,我需要在界面中提示用户,而这个字段我是设置在缓存当中的。最开始我是用了以下2种方式来达到响应式的结果。

// 第一种
const cache = uni.getStorageSync('hasRead')
const hasRead = ref(cache)

watch(() => hasRead.value, (newval) => {
if (newval) {
hasRead.value = newval
}
})

// 第二种
const hasRead = ref(true)
const hasReadCache = computed(() => {
const cache = uni.getStorageSync('hasRead')
if (cache) {
return cache
}
return hasRead.value
})

答案显而易见,以上2种方案都无法使缓存变化的同时响应到页面当中(有小伙伴知道原因的话,评论区留言告诉我一下为什么不可以),于是,我秉着试一试的想法使用了pinia以及pinia-plugin-persistedstate

import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useSettingStore = defineStore('setting', () => {
    const hasRead = ref(true)
    const setHasRead = (value) => {
        hasRead.value = value
    }
}, {
    persist: {
        storage: localStorage,
        pick: ['hasRead']
    }
})

通过pinia,我成功实现的数据的响应式,并且能够将数据存于缓存。那么,问题解决了吗?并没有。为什么呢?因为程序最终时需要运行在Android以及IOS环境的,而以上环境并没有window对象,因此也不会有localStorage。那么应该怎么办呢?机缘巧合之下,当我鼠标移入persist中的storage对象时,编辑器跳出来他的类型为StorageLike,直译一下就是像Storage,那么是不是可以理解为我声明一个Storage对象,只要具有像localStorage对象的api也可以呢?刚好uniapp也提供了类似的api,说干就干,我创建了一个类,如下:

// utils/storage.js

class Storage {
  /**
   * 设置缓存
   * @param {string} key 缓存键
   * @param {any} value 缓存值
   * @param {number} [expire] 过期时间(单位:秒)
   * @returns {boolean} 是否设置成功
   */
  setItem(key, value, expire) {
    try {
      const data = {
        value,
        // 计算过期时间戳(如果设置了过期时间)
        expireTime: expire ? Date.now() + expire * 1000 : null,
      };
      uni.setStorageSync(key, data);
      return true;
    } catch (e) {
      console.error('Storage setItem error:', e);
      return false;
    }
  }

  /**
   * 获取缓存(自动处理过期数据)
   * @param {string} key 缓存键
   * @returns {any|null} 缓存值或null
   */
  getItem(key) {
    try {
      const data = uni.getStorageSync(key);
      if (!data) return null;

      // 检查是否过期
      if (data.expireTime && Date.now() > data.expireTime) {
        this.removeItem(key);
        return null;
      }
      return data.value;
    } catch (e) {
      console.error('Storage getItem error:', e);
      return null;
    }
  }

  /**
   * 删除指定缓存
   * @param {string} key 缓存键
   */
  removeItem(key) {
    try {
      uni.removeStorageSync(key);
    } catch (e) {
      console.error('Storage removeItem error:', e);
    }
  }

  /**
   * 清空所有缓存
   */
  clear() {
    try {
      uni.clearStorageSync();
    } catch (e) {
      console.error('Storage clear error:', e);
    }
  }

  /**
   * 检查缓存是否存在
   * @param {string} key 缓存键
   * @returns {boolean}
   */
  has(key) {
    return this.keys().includes(key);
  }

  /**
   * 获取所有缓存键
   * @returns {string[]}
   */
  keys() {
    try {
      const { keys } = uni.getStorageInfoSync();
      return keys;
    } catch (e) {
      console.error('Storage keys error:', e);
      return [];
    }
  }

  /**
   * 获取缓存信息
   * @returns {{
   *   keys: string[],
   *   currentSize: number,
   *   limitSize: number
   * }}
   */
  getInfo() {
    try {
      return uni.getStorageInfoSync();
    } catch (e) {
      console.error('Storage getInfo error:', e);
      return { keys: [], currentSize: 0, limitSize: 0 };
    }
  }

  /**
   * 设置缓存过期时间
   * @param {string} key 缓存键
   * @param {number} expire 过期时间(单位:秒)
   * @returns {boolean}
   */
  setExpire(key, expire) {
    const value = this.getItem(key);
    if (value === null) return false;
    return this.setItem(key, value, expire);
  }

  /**
   * 检查并清理所有过期缓存
   */
  checkExpire() {
    this.keys().forEach((key) => {
      // 通过getItem自动触发过期检查
      this.getItem(key);
    });
  }
}

// 创建单例实例
export const storage = new Storage();

利用storage对象,我将store变成以下代码:

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { storage } from '@utils'
export const useSettingStore = defineStore('setting', () => {
    const hasRead = ref(true)
    const setHasRead = (value) => {
        hasRead.value = value
    }
}, {
    persist: {
        storage: {
            getItem: (key) => storage.getItem(key),
            setItem: (key, value) => storage.setItem(key, value),
            removeItem: (key) => storage.removeItem(key),
          },
        pick: ['hasRead']
    }
})

通过以上思路,我成功解决了uniapp的storagepinia的结合,并解决了缓存的响应式问题。

最后,虽然这个轮询的方案被我废弃了,但是我还是从其中学到了之前我从未接触到的东西。还有就是如果小伙伴学到了的话,也请点赞,关注。感谢您的支持!!

[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
}
昨天以前首页

🚀惊了,这个国产软件居然这么牛,比 uniapp 还全能❤️

2025年4月17日 10:32

最近跟同事闲聊,大家都在吐槽一个问题: ! App 是越做越像平台了,但开发却越做越痛苦了。

你想加个活动页,产品说今晚上线;
你想做个业务扩展,运营说要不你再写个低代码工具;
你想适配鸿蒙,领导说最好做个 React Native 得了;

同事活成了“加班工具人”,App 也做成了臃肿的 “功能集成器”。

难道开发一个通用的 App ,就非得这么累吗?

于是,我们试着去找更轻、更灵活的解决方案。

我们只是想做一个“活动页托管方案”,不想每次上线都发版,更不想因为临时需求牵扯整个开发团队。

但随着调研的深入,我们发现这种痛点其实根本不是“活动页”本身,而是:App 缺乏一个**“包容性很强的容器”**。

比如:

  • 新功能不用频繁发版;
  • 能复用现有页面或者组件;
  • 可以独立上线,不干扰主应用。

我们对比了几个方向:

  • WebView + H5:快是快,但弱得可怕,尤其是 JSBridge 管理地狱,体验不佳;
  • 低代码平台:适合特定场景,但定制性不足,复杂页面性能堪忧;
  • RN/Flutter 微模块化:维护成本太高,涉及太多客户端改动。

直到我在调研中遇到了 FinClip,才意识到这事完全可以换个方式。

让人眼前一亮的 FinClip

FinClip 是什么?

一句话说完:把小程序能力,通用化、标准化,塞进任何 App。

从技术架构来说,FinClip 提供的是一个极其轻量的小程序容器 SDK(3MB都不到),可以直接嵌进你的 iOSAndroidHarmonyOS App,甚至 React NativeFluttermacOS、车机都能跑。

强大的能力

开发者只要写一套小程序代码,放进去就能运行。不用重新适配底层系统,也不用改框架结构。

而且它兼容微信/支付宝/抖音小程序语法,意味着你过去写的项目,可能几乎零改动就能跑起来。

于是,我们立刻拉了群,软磨硬泡,搞来了二组同事开发的活动页项目,

这个是开源的活动页

需要的同学请戳这里:github.com/FernAbby/H5…

导入之后

然后通过 FinClip Studio 打包上传,再嵌入 App

FinClip Studio,真的有点香

讲真,刚开始用 FinClip Studio,我也做好了“将就一下”的心理准备。

结果没想到是真香警告。

首先,新建项目一键生成模板,跟微信小程序开发工具 99% 像;

创建工程

你也可以和我一样选择导入已有的项目,

导入之后

其次,模拟器支持多终端调试,拖拉缩放,全程无需真机;

另外,发布打包一条龙服务,你只需要上传至云端后台:

上传

输入相关上传信息:

上传信息

等待上传成功即可!

上传成功

后台是平台运营的“指挥中心”

接下来的重头戏,需要我们登陆后台系统,

一个超级应用不是靠开发者单打独斗,而是靠多个角色协同。FinClip 的后台做得非常细腻,功能齐全,不管是开发还是运维同学,都可以轻松驾驭!

首页后台

小程序管理模块,不仅可以新建、管理前面上传的小程序,还可以体验预览版、发布审核;

首先,在隐私设置栏目里设置隐私保护指引:

配置隐私设置

然后我们就可以配置审核版本或者体验版本了!

审核流程

体验发布

接着我们就可以直接跳转到待办中心通过审核!

通过审核

除此之外,常用的灰度发布、权限范围、自动上架全都支持;

小程序详情

数据分析清晰易读,不需要 BI 工具也能看懂;

数据看板

页面数据

让你不再为如何做好运维而发愁!

用了一周的真实感受

流程

使用一周多了,整体的流程是这样的:

  1. 本地写代码,IDE 模拟器预览;
  2. 上传代码,后台提交审核;
  3. 设置灰度策略,用户扫码体验;
  4. 最终发布上线。

优点

我们没改动原生代码,甚至没有重新接入任何 SDK,只是增加一个容器模块 + 几行配置。

团队有个原来的 RN 老项目,直接用 FinClip 的容器跑起来,居然都不用重写,兼容度真的惊人。

缺点

但是缺点也有:

比如,导入已有项目会进行检测,并且明确的告知用户,其实可以后台默认执行,用户体验会更好!

导入

检测过程

另外最主要的是,后台和编辑器的登陆状态是临时的,不会长期保持!每次登陆挺麻烦的

彩蛋

首先,FinClip 贴心的内置了 AI 助手,你使用过程遇到的任何问题都可以在这里找到答案!

内置的 AI 助手

最重要的是,FinClip 提供了基于接口的 AI 能力,可以通过 RAG 技术为小程序注入了智能化能力,涵盖内容生成、上下文交互、数据分析等多方面功能。

这不仅提升了用户体验,也为开发者提供了便捷的 AI 集成工具,进一步增强了 FinClip 生态的竞争力!

基于 RAG 的 AI 能力

总结

如果再给我造一次 App 的机会,我一定毫不犹豫地选择 FinClip

当我们从“做功能”切换到“建生态”,思路就会完全不一样:

  • App 不再是“巨石应用”,而是一个个业务模块的拼图
  • 小程序就像“微服务 UI 化”,能独立更新、上线、下架
  • 技术架构也从“一体化耦合”变成“解耦 + 动态加载”

FinClip 帮助开发者从“重复搬砖” 变成 “生态平台管理员”!

如果你也有和我一样的困惑,你也可以试试:

  • 把一个已有的活动页,用 FinClip 打包成小程序;
  • 嵌进你现有 App 中,再用 FinClip Studio 发布版本;
  • 后台配置白名单,手机扫码预览。

1 天内,你就能体验一把“做平台”的感觉。

时代正在变化。我们不该再为“发布一个功能”耗尽精力,而应该把更多时间留给真正重要的东西 —— 创新、体验、增长。

FinClip 不只是工具,更是重构开发者角色的机会。

你准备好了吗?

❌
❌