🌟纯前端实现一个精致的中英文挖空提示功能

1,433 阅读7分钟

前言

这两天给我的卡盒小程序新增了一个提示功能,在卡片正面的时候,点击左下角的小灯泡,就会弹出背面内容的提示,这个提示是挖了空的,这种方式可以帮助我们循序渐进的回忆内容,而不是直接看答案。

大卡片提示.gif

实现

在实现之前,我们要先梳理一下需求,我要实现的效果是,有一个提示按钮,第一次点击,提示百分之二十的内容,第二次点击,基于第一次提示的基础之上,再提示百分之三十的内容,第三次点击就不再增加提示内容了,只修改一下提示文案。这里还涉及一个中英文挖空逻辑的问题:

中文:每个字都可以用于挖空,不要挖掉标点符号

英文:要先将词汇分开,挖空时只挖完整的单词,如果内容只有一个单词,就不执行挖空逻辑,也不要挖掉标点符号

首先我们定义一些正则规则:

const PATTERNS = {
  // 匹配中文字符
  CHINESE: /^[\u4e00-\u9fa5]+$/,
  // 匹配英文单词
  ENGLISH: /^[a-zA-Z]+$/,
  // 匹配标点符号和空白字符
  PUNCTUATION: /^[,。!?、;:""''()【】《》,.!?;:()[\]{}'"/\s]+$/,
  // 分词正则
  SEGMENT: /([\u4e00-\u9fa5]+|[a-zA-Z]+|[,。?、;:""''()【】《》,.!?;:()[\]{}'"/\s]+)/,
}
  • CHINESE (/^[\u4e00-\u9fa5]+$/): 这个正则表达式用于匹配仅包含中文字符的字符串。 \u4e00-\u9fa5 是 Unicode 范围,涵盖了基本的汉字字符。
  • ENGLISH (/^[a-zA-Z]+$/): 这个正则表达式用于匹配仅包含英文字母的字符串。 [a-zA-Z] 表示匹配任何小写或大写英文字母。
  • PUNCTUATION (/^[,。!?、;:""''()【】《》,.!?;:()[\]{}'"/\s]+$/): 这个正则表达式用于匹配仅包含中文标点符号、英文标点符号和空白字符的字符串。 字符串中包括中文标点符号(如,。!?、;:)、英文标点符号(如,.!?;:()[]{}'")和空白字符(\s)。
  • SEGMENT (/([\u4e00-\u9fa5]+|[a-zA-Z]+|[,。?、;:""''()【】《》,.!?;:()[\]{}'"/\s]+)/): 这个正则表达式用于分词,即把字符串分割成中文字符串、英文字符串或标点符号/空白字符的序列。 ([\u4e00-\u9fa5]+|[a-zA-Z]+|[,。?、;:""''()【】《》,.!?;:()[\]{}'"/\s]+) 是一个分组,其中包含三个选项: [\u4e00-\u9fa5]+:匹配一个或多个连续的中文字符。 [a-zA-Z]+:匹配一个或多个连续的英文字符。 [,。?、;:""''()【】《》,.!?;:()[]{}'"/\s]

前三个好理解,第四个 SEGMENT 用于把中英文基于一些标点符号给分割开,方便下一步处理:

const regex = /([\u4e00-\u9fa5]+|[a-zA-Z]+|[,。?、;:""''()【】《》,.!?;:()[\]{}'"/\s]+)/g;
const text = "这是一个测试。This is a test。";
const matches = text.match(regex);
 
console.log(matches);
// 输出是:["这是一个测试", "。", "This", " ", "is", " ", "a", " ", "test", "。"]

这个百分比的概率如何匹配呢?我们可以将一个字符串分割成单个的中文字符,或者是英文单词,我们称它为 SEGMENT 片段,每一个片段去进行一个判断逻辑,这个判断逻辑就是我们给定一个概率,假设是 20%,也就是 0.2 那么判断的时候,我们就生成一个 0-1 的随机数,如果大于它就不展示,如果小于它就展示,每个片段这么操作之后,最终再拼接起来,虽然不是整体展示的内容只有 20% ,但是也能实现一个大致的随机挖空效果。

 // 处理分段内容的通用函数
const processSegment = (
  segment: string,          // 要处理的文本片段
  segmentIndex: number,     // 片段在整体文本中的索引位置
  probability: number,      // 显示提示的概率(0-1之间)
  positions: Set<number>,   // 存储需要显示提示的位置集合
) => {
  // 判断是否为英文单词
  if (PATTERNS.ENGLISH.test(segment)) {
    // 按概率决定是否显示整个英文单词
    if (Math.random() < probability) {
      positions.add(segmentIndex)
    }
  } 
  // 判断是否为中文字符
  else if (PATTERNS.CHINESE.test(segment)) {
    // 遍历中文字符串的每个字
    Array.from(segment).forEach((_, idx) => {
      // 对每个字符按概率决定是否显示
      if (Math.random() < probability) {
        // 将需要显示的字符位置添加到集合中
        positions.add(segmentIndex + idx)
      }
    })
  }
}

概率计算的原理知道后,我们可以进行字符串的处理:

 const tipPositions = ref<Set<number>>(new Set())

  const initializeTipPositions = () => {
    if (!cardContent.value) return
    const segments = getSegments(cardContent.value)
    segments.forEach((segment, segmentIndex) => {
      processSegment(segment, segmentIndex, 0.2, tipPositions.value)
    })
  }

这里我维护了 tipPositions 这个 set 集合,它用于记录所有需要展示的字符的索引。第一次点击的时候,我们调用前面定义的概率匹配函数,如果满足可以展示的条件,就把它的索引放到 set 集合里面去。

const getTipContent = computed(() => {
  // 如果 cardContent 没有值,则直接返回一个空数组
  if (!cardContent.value) return []
 
  // 根据预定义的正则表达式将 cardContent 分割成多个段
  const segments = getSegments(cardContent.value)
 
  // 对每个段进行处理,并生成对应的提示内容数组
  return segments
    .map((segment, segmentIndex) => {
      // 如果当前段为空,则返回一个空数组
      if (!segment) return []
 
      // 如果当前段是标点符号,则直接返回包含该段文本的提示内容
      if (PATTERNS.PUNCTUATION.test(segment)) {
        return [{ type: 'text', text: segment }]
      }
 
      // 如果当前段是英文,则根据是否已设置提示位置来决定返回完整文本还是空白占位符
      if (PATTERNS.ENGLISH.test(segment)) {
        return tipPositions.value.has(segmentIndex)
          ? [{ type: 'text', text: segment }] // 如果已设置提示位置,则返回完整文本
          : segment.split('').map(() => ({ type: 'blank-en', text: ' ' })) // 否则,返回下划线
      }
 
      // 对于非英文段(假设主要是中文),逐字处理并生成提示内容
      const chars = Array.from(segment)
      return chars.map((char, idx) => ({
        // 根据是否已设置对应字符位置的提示来决定类型和内容
        type: tipPositions.value.has(segmentIndex + idx) ? 'text' : 'blank',
        text: tipPositions.value.has(segmentIndex + idx) ? char : ' ',
      }))
    })
    .flat() // 将二维数组展平为一维数组
  as TipContent[]
})

这一步遍历我们的字符串,先做一些边界情况的处理,然后根据前面我们的 tipPositions 变量返回一个最终用于渲染的数组。

  <text
    v-for="(item, index) in getTipContent"
    :key="index"
    :class="[
      item.type === 'blank' && 'tip-blank',
      item.type === 'blank-en' && 'tip-blank-en',
      item.type === 'text' && 'animate-fade-in-up animate-duration-200',
    ]"
    :style="{
      borderBottom:
        item.type === 'blank' || item.type === 'blank-en' ? '2px solid #ddd' : 'none',
      display:
        item.type === 'blank' || item.type === 'blank-en' ? 'inline-block' : 'inline',
      width: item.type === 'blank' ? '1em' : item.type === 'blank-en' ? '0.5em' : 'auto',
      verticalAlign:
        item.type === 'blank' || item.type === 'blank-en' ? 'bottom' : 'baseline',
      opacity: item.type === 'text' ? 1 : 0.5,
      margin: '0 0.5px',
    }"
  >
    {{ item.text }}
  </text>

最后我们把字符串渲染出来就好啦~ 第二次点击的时候,我们只要遍历字符串,避开已经展示的索引,再执行一次概率判断的函数,就能实现基于第一次的内容展示更多文本内容的效果了。除此以外,我们还可以增加一个保底机制,毕竟目前的策略运气差有可能一个词都出不来:

// 如果一个字符都没有展示,随机选择一个展示
    if (tipPositions.value.size === 0) {
      const validSegments = segments.reduce<{ index: number; length: number }[]>(
        (acc, segment, index) => {
          if (PATTERNS.ENGLISH.test(segment) || PATTERNS.CHINESE.test(segment)) {
            acc.push({
              index,
              length: PATTERNS.ENGLISH.test(segment) ? 1 : segment.length,
            })
          }
          return acc
        },
        [],
      )

      if (validSegments.length > 0) {
        const randomSegment = validSegments[Math.floor(Math.random() * validSegments.length)]
        if (PATTERNS.ENGLISH.test(segments[randomSegment.index])) {
          // 如果是英文单词,展示整个单词
          tipPositions.value.add(randomSegment.index)
        } else {
          // 如果是中文,随机展示一个字
          const randomCharIndex = Math.floor(Math.random() * randomSegment.length)
          tipPositions.value.add(randomSegment.index + randomCharIndex)
        }
      }
    }
  }

第一个处理会筛选出需要处理的有效文本(中英文),记录每个有效片段的:位置信息(index)长度信息(length),为后续随机选择提示位置做准备。

// 会生成类似这样的数据:
validSegments = [
  { index: 0, length: 1 },    // "Hello" 作为一个英文单词
  { index: 2, length: 3 }     // "你好啊" 作为3个中文字符
]

第二步就是展示一个保底的词汇逻辑了。

原本我想用下划线字符 “_” 来作为挖空位置,但是我发现下划线的话效果不太好,虽说性能会好一点,可以整个字符串一起渲染,性能会好一些,但是下划线的宽度太窄了,样式也不好单独调整,所以我还是将整个字符串里的每个字符单独渲染,再根据不同的条件展示文字还是挖空,挖空的效果我使用了灰色的下边框去替代,这样看起来的效果会好很多。这里我有前后对比的截图给大家看看:

修改前修改后
d12815fee8bf93ff561e238278adad7.png318feea5dee0fe0c2d9516a55a52b6a.png

总结

这个功能的实现还是有点点复杂的,因为边界情况比较多,我现在也还陆陆续续在优化这个逻辑。目前这个功能已经上线到 学习卡盒 小程序了,大家可以去体验一下。小程序的 AI 功能已经 ready 了,现在还在调试中,后续也会写文章介绍实现方式的,如果文章对你有帮助,欢迎点个赞支持下,respect~