保姆级Vue3+Vite项目实战正则在线校验工具

6,374

写在前面

本文为 Vue3+Vite 项目实战系列教程文章第五篇,系列文章建议从头观看效果更佳,大家可关注 Vue3 实战系列 防走失!点个赞再看有助于全文完整阅读!

此系列文章主要使用到的主要技术站栈为 Vue3+Vite,那既然是 Vue3,状态库我们使用的是 Pinia 而不是 Vuex,在写法上也肯定是以 CompositionAPI 为主而不是 OptionsAPI,组件库方面我们使用的是 ArcoDesign (赶紧丢掉 ElementUI 吧!)。

终于到了写正经项目代码了,那之前也说过,第一个小功能我们写正则校验工具,因为平常写正则老是找在线的校验工具测试,所以就想着自己写一个,以后每次写的正则都补充到在线工具里,慢慢的积攒的多了,也能少写一点点(其实还是因为懒)。

👉🏻 项目 GitHub 地址

👉🏻 项目在线预览

如果大家不想从头来过可以直接下载截止到上文内容的代码,👉🏻 toolsdog tag v0.0.3-dev

代码拉下来之后,npm install || pnpm install 下载依赖,然后 npm run serve || pnpm serve 启动,如果一切没问题的话,当前项目运行起来是这样的:

功能构思

写之前我们先来说一说大概的一个构思。由于我目前暂时算是写完了,所以可以先给大家放一个写完的图看下功能,稍候给大家介绍,如下:

大概就是上面这么个样子,其实也没什么特别复杂的地方。

一共也就 5 排内容,我们一排一排介绍。

先说第一排内容吧,第一排最左边那个图标是是否开启预设匹配,因为我们要在内部预设一些正则,开启预设匹配之后,在正则输入框输入对应正则中文名就可以模糊匹配我们预设的正则,这个是懒癌患者必备。

中间输入框就是正则输入框,可以输入正则字符串,当然开启了自动匹配预设之后也可以输入中文匹配预设。

输入框右边漏斗图标是修饰符筛选项,就是选择正则匹配的一些修饰符(g、i、m、s)。

再往右小眼睛图标是可视化预览,开启之后我们输入正则就会自动渲染图中第二排那个图形化正则匹配步骤,只有可视化正则步骤开启并且正则表达式无误才会有第一排最右边倒数第二个即图片下载按钮,用来下载正则可视化 png 图。

最右边按钮则是复制正则对象按钮。

第三排输入框是待匹配字符串,输入一些字符串用来做正则匹配检测的。

第四排就是展示匹配到的字符串,匹配到多个用不同的颜色去展示。

第五排就是统计一共匹配到了那些字符串。

功能大概就是这样。

正则校验页面

开始写代码了,先做下准备工作,之前我们已经在 src/views 文件夹下创建了正则匹配页面 RegularPage.vue,现在我们修改一下,把这个页面修改为 RegularPage/index.vue,因为后面我们要写个正则可视化预览的组件,所以这里先把正则校验页面改成文件夹,那这里改了,路由页面也需要改一下,修改 src/router/menuRouter.js 文件如下:

import IconMaterialSymbolsCodeBlocksOutline from '~icons/material-symbols/code-blocks-outline'

export const menuRouter = [
  {
    path: 'devtools',
    name: 'DevTools',
    meta: {
      title: '开发工具',
      icon: markRaw(IconMaterialSymbolsCodeBlocksOutline)
    },
    redirect: { name: 'RegularPage' },
    children: [
      {
        path: 'regular',
        name: 'RegularPage',
        meta: {
          title: '正则在线校验'
        },
				// 修改如下
        component: () => import('@/views/RegularPage/index.vue')
        // component: () => import('@/views/RegularPage.vue')
      }
    ]
  }
]

// ...

OK,再次刷新页面没问题的话就可以继续了!

准备正则预设

之前我们也说了,要写一些预设的正则让用户可以直接搜正则功能名匹配,那写之前,我们先来准备一下这些预设的正则,在 src/utils 文件夹下新建 regexp.js 文件,我们把一些常用正则写进去,如下:

export const isNumber = /^[0-9]*$/g
isNumber.name = '匹配数字'

export const isNonnegativeInteger = /^\d+$/g
isNonnegativeInteger.name = '匹配非负整数(正整数 + 0)'

export const isPositiveInteger = /^[0-9]*[1-9][0-9]*$/g
isPositiveInteger.name = '匹配正整数'

export const isNegativeInteger = /^-[0-9]*[1-9][0-9]*$/g
isNegativeInteger.name = '匹配负整数'

export const isInteger = /^-?\d+$/g
isInteger.name = '匹配整数'

export const isNonpositiveInteger = /^((-\d+)|(0+))$/g
isNonpositiveInteger.name = '匹配非正整数(负整数 + 0)'

export const isNonnegativeFloat = /^\d+(\.\d+)?$/g
isNonnegativeFloat.name = '匹配非负浮点数(正浮点数 + 0)'

export const isPositiveFloat =
  /^((0\.\d*[1-9]\d*)|([1-9]\d*\.\d*)|([1-9]\d*))$/g
isPositiveFloat.name = '匹配正浮点数'

export const isNegativeFloat =
  /^-((0\.\d*[1-9]\d*)|([1-9]\d*\.\d*)|([1-9]\d*))$/g
isNegativeFloat.name = '匹配负浮点数'

export const isFloat = /^(-?\d+)(\.\d+)?$/g
isFloat.name = '匹配浮点数'

export const isNonpositiveFloat = /^((-\d+(\.\d+)?)|(0+(\.0+)?))$/g
isNonpositiveFloat.name = '匹配非正浮点数(负浮点数 + 0)'

export const isEnglish = /^[A-Za-z]+$/g
isEnglish.name = '匹配由26个英文字母组成的字符串'

export const isLowercaseEnglish = /^[a-z]+$/g
isLowercaseEnglish.name = '匹配由26个英文字母的小写组成的字符串'

export const isUppercaseEnglish = /^[A-Z]+$/g
isUppercaseEnglish.name = '匹配由26个英文字母的大写组成的字符串'

export const isEnglishAndNumber = /^[A-Za-z0-9]+$/g
isEnglishAndNumber.name = '匹配由数字和26个英文字母组成的字符串'

export const isEnglishAndNumberAndUnderline = /^\w+$/g
isEnglishAndNumberAndUnderline.name =
  '匹配由数字、26个英文字母或者下划线组成的字符串'

export const isChinese = /^[\u4e00-\u9fa5]{0,}$/g
isChinese.name = '匹配中文'

export const isPhone = /^1[3456789]\d{9}$/g
isPhone.name = '匹配手机号'

export const isIdCard = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/g
isIdCard.name = '匹配身份证(一代&二代)'

export const isFirstGenerationIDCard = /(^\d{15}$)/g
isFirstGenerationIDCard.name = '匹配一代身份证'

export const isSecondGenerationIDCard = /(^\d{18}$)|(^\d{17}(\d|X|x)$)/g
isSecondGenerationIDCard.name = '匹配二代身份证'

export const isUrl = /^((https|http|ftp|rtsp|mms)?:\/\/)[^\s]+/g
isUrl.name = '匹配URL'

export const isIP = /^((25[0-5]|2[0-4]\d|[01]?\d\d?)($|(?!\.$)\.)){4}$/g
isIP.name = '匹配IP'

export const isDate = /^(\d{4})-(\d{2})-(\d{2})$/g
isDate.name = '匹配日期'

export const isTime = /^([01]\d|2[0-3])(:[0-5]\d){1,2}$/g
isTime.name = '匹配时间'

export const isDateTime =
  /^(\d{4})-(\d{2})-(\d{2})\s([01]\d|2[0-3])(:[0-5]\d){1,2}$/g
isDateTime.name = '匹配日期时间'

export const isColor = /^#([0-9a-fA-f]{3}|[0-9a-fA-f]{6})$/g
isColor.name = '匹配颜色'

export const isQQ = /^[1-9][0-9]{4,9}$/g
isQQ.name = '匹配QQ'

export const isWeChat = /^[a-zA-Z]([-_a-zA-Z0-9]{5,19})+$/g
isWeChat.name = '匹配微信'

export const isPostalCode = /[1-9]\d{5}(?!\d)/g
isPostalCode.name = '匹配邮编'

export const isMacAddress = /^([A-Fa-f0-9]{2}:){5}[A-Fa-f0-9]{2}$/g
isMacAddress.name = '匹配MAC地址'

export const isIPV4 = /^((25[0-5]|2[0-4]\d|[01]?\d\d?)($|(?!\.$)\.)){4}$/g
isIPV4.name = '匹配IPV4地址'

export const isIPV6 = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/g
isIPV6.name = '匹配IPV6地址'

export const isBase64 = /[^A-Za-z0-9\+\/\=]/g
isBase64.name = '匹配Base64'

export const isPasswordStrong = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[^]{8,16}$/g
isPasswordStrong.name = '匹配强密码'

export const isPasswordMedium = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[^]{6,16}$/g
isPasswordMedium.name = '匹配中等密码'

export const isPasswordSimple = /^[a-zA-Z0-9_-]{6,16}$/g
isPasswordSimple.name = '匹配简单密码'

export const isCarNumber = /^[\u4e00-\u9fa5]{1}[A-Z]{1}[A-Z_0-9]{5}$/g
isCarNumber.name = '匹配车牌号(简单)'

export const isLicensePlateNumber =
  /^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-HJ-NP-Z]{1}[A-Z0-9]{4}[A-Z0-9挂学警港澳]{1}$/g
isLicensePlateNumber.name = '匹配车牌号(严格)'

export const isStockCode =
  /^(s[hz]|S[HZ])(000[\d]{3}|002[\d]{3}|300[\d]{3}|600[\d]{3}|60[\d]{4})$/g
isStockCode.name = '匹配股票代码'

export const isBankCard = /^([1-9]{1})(\d{14}|\d{18})$/g
isBankCard.name = '匹配银行卡'

export const is126Email = /((^([a-zA-Z]))(\w){5,17})@126.com$/g
is126Email.name = '匹配126邮箱'

export const is163Email = /((^([a-zA-Z]))(\w){5,17})@163.com$/g
is163Email.name = '匹配163邮箱'

export const isGmailEmail = /((^([a-zA-Z]))(\w){5,17})@gmail.com$/g
isGmailEmail.name = '匹配Gmail邮箱'

export const isQQEmail = /((^([a-zA-Z]))(\w){5,17})@qq.com$/g
isQQEmail.name = '匹配QQ邮箱'

export const isSinaEmail = /((^([a-zA-Z]))(\w){5,17})@sina.com$/g
isSinaEmail.name = '匹配新浪邮箱'

export const isSohuEmail = /((^([a-zA-Z]))(\w){5,17})@sohu.com$/g
isSohuEmail.name = '匹配搜狐邮箱'

export const isYahooEmail = /((^([a-zA-Z]))(\w){5,17})@yahoo.com$/g
isYahooEmail.name = '匹配雅虎邮箱'

export const isOutlookEmail = /((^([a-zA-Z]))(\w){5,17})@outlook.com$/g
isOutlookEmail.name = '匹配Outlook邮箱'

export const isHotmailEmail = /((^([a-zA-Z]))(\w){5,17})@hotmail.com$/g
isHotmailEmail.name = '匹配Hotmail邮箱'

export const isEmail = /^([a-zA-Z0-9_-])+@([a-zA-Z0-9_-])+(.[a-zA-Z0-9_-])+$/g
isEmail.name = '匹配邮箱'

如上,我们字面量创建并导出了很多正则对象,而且给每个正则对象都加了名字留作后用!

PS: 网上随便摘抄了些,简单看了下,暂时没有验证对错,不保真哈,后面用着不对再改吧,大家有什么补充的或者发现有错误,可以直接去 GitHubPR 或者 issues 哈,可以一块完善下,方面大家嘛!!

正则输入框

接下来我们开始写代码,上面说过我们可以将整个页面分为上中下三块,先来简单写个布局,修改 RegularPage/index.vue 正则校验页面,如下:

<script setup></script>

<template>
  <div class="max-w-1200px w-full p-20px box-border">
    <!-- 上 -->
    <div class="flex justify-start items-center"></div>
    <!-- 中 -->
    <div class="mt-20px bg-[var(--color-fill-2)]"></div>
    <!-- 下 -->
    <div class="w-full mt-20px"></div>
  </div>
</template>

OK,整活吧!我们先看下代码,也就是正则输入框相关的代码,修改 RegularPage/index.vue 正则校验页面如下:

<script setup>
import * as regPresetsObj from '@/utils/regexp.js'

// 大小
const size = ref('large')
// 正则表达式字符串
const regStr = ref('')
// 正则表达式修饰符
const regFlag = ref([])
// 正则表达式对象
const reg = ref(null)
watchEffect(() => {
  try {
    reg.value = regStr.value
      ? new RegExp(regStr.value, regFlag.value.join(''))
      : null
  } catch (err) {
    reg.value = null
  }
})

// 预设自动搜索
const auto = ref(true)
// 正则预设列表
const regPresets = ref([])
// 正则预设匹配方法
const regPresetMatch = value => {
  if (value && auto.value) {
    regPresets.value = [...Object.values(regPresetsObj)]
      .filter(r => r.name.includes(value) || r.source.includes(value))
      .map(v => {
        return {
          label: v.name,
          regexp: v,
          value: v.source
        }
      })
  } else {
    regPresets.value = []
  }
}
// 正则预设选中方法
const regPresetSelect = r => {
  let selected = regPresets.value.find(v => v.value === r)
  if (selected) regFlag.value = selected.regexp.flags.split('')
}
</script>
<template>
  <div class="max-w-1200px w-full p-20px box-border">
    <!-- 上 -->
    <div class="flex justify-start items-center">
      <a-tooltip
        :content="`点击${auto ? '关闭' : '开启'}预设正则自动匹配`"
        position="bottom"
      >
        <a-button :size="size" @click="auto = !auto">
          <template #icon>
            <icon-material-symbols-astrophotography-auto
              :class="auto ? 'text-[rgb(var(--arcoblue-6))]' : ''"
            />
          </template>
        </a-button>
      </a-tooltip>

      <a-auto-complete
        class="ml-10px"
        v-model="regStr"
        :data="regPresets"
        @search="regPresetMatch"
        @select="regPresetSelect"
        :placeholder="`请输入正则表达式${
          auto ? '或输入文字选择自动匹配的预设正则' : ''
        }`"
        allow-clear
        :size="size"
      >
        <template #prepend>
          <a-tooltip
            :content="reg ? '正则表达式正确' : '正则表达式有误'"
            position="bottom"
            mini
          >
            <icon-jam-triangle-danger-f
              class="text-[rgb(var(--orange-6))]"
              v-if="!reg"
            />
            <icon-mdi-hand-okay class="text-[rgb(var(--green-6))]" v-else />
          </a-tooltip>

          <span class="ml-5px">/</span>
        </template>
        <template #append>
          <span> /{{ reg?.flags || regFlag?.join('') }}</span>
        </template>
      </a-auto-complete>

      <a-popover title="选择修饰符" position="bl">
        <a-button :size="size" class="ml-10px">
          <template #icon>
            <icon-mdi-filter-multiple
              :class="regFlag.length > 0 ? 'text-[rgb(var(--arcoblue-6))]' : ''"
            />
          </template>
        </a-button>
        <template #content>
          <div class="min-w-200px">
            <a-checkbox-group
              :size="size"
              v-model="regFlag"
              direction="vertical"
            >
              <a-checkbox value="g"> -g:全局匹配 </a-checkbox>
              <a-checkbox value="i"> -i:忽略大小写</a-checkbox>
              <a-checkbox value="m"> -m:多行匹配</a-checkbox>
              <a-checkbox value="s"> -s:特殊字符. 包含换行符</a-checkbox>
            </a-checkbox-group>
          </div>
        </template>
      </a-popover>
    </div>
		
    <!-- 中 -->
    <div class="mt-20px bg-[var(--color-fill-2)]"></div>
		
    <!-- 下 -->
    <div class="w-full mt-20px"></div>
  </div>
</template>

来简单介绍下上面代码内容:

这里我们还是使用 ArcoDesign 组件,我们创建了一个响应式属性 size 用来控制页面上输入框等 ArcoDesign 组件大小。

创建正则表达式字符串 regStr,注意这里的正则字符串是不带双斜杠 // 的。

创建正则修饰符 regFlag,修饰符这里我们创建的是个响应式数组,数组中每一项代表一个正则修饰符(修饰符是正则表达式的标记,用于指定额外的匹配策略,不理解的同学可以百度谷歌下)。

正则字符串和修饰符有了,那正则表达式也就有了,创建正则表达式对象 reg,我们通过 Vue APIwatchEffect 方法来监听 regStrregFlag 改变,随之给正则对象 reg 赋值,创建的时候使用 try…catch 包裹,这样如果创建失败会走 catch,我们在 catch 中把正则对象清空,别问为啥不用计算属性,因为正则对象 reg 后面用的很多,使用计算属性后面会很麻烦。

接着还创建了一个是否开起正则预设搜索的属性 auto,默认为 true,代表可以输入中文模糊匹配预设的正则。

OK,再看模板,Template 模板中每个功能按钮我们都使用 ArcoDesigna-tooltip 组件做了个悬浮气泡提示,下文所有功能按钮都有气泡提示,不在赘述。

在上中下三个模块的上模块中,我们先写了一个预设匹配开启按钮,通过 auto 属性值渲染不同图标颜色来区分是否开启预设匹配,图标使用的是 iconify 图标库的 material-symbols:astrophotography-auto 图标(自动引入配置看前文,后面 iconify 图标库的图标我就不提了哈,看下图标组件名就知道是哪个图标了,如果有用到自定义的图标我再单独说),并且给该按钮写了点击事件,点击修改 auto 属性值,代码片段如下:

<a-tooltip
  :content="`点击${auto ? '关闭' : '开启'}预设正则自动匹配`"
  position="bottom"
>
  <a-button :size="size" @click="auto = !auto">
    <template #icon>
      <icon-material-symbols-astrophotography-auto
        :class="auto ? 'text-[rgb(var(--arcoblue-6))]' : ''"
      />
    </template>
  </a-button>
</a-tooltip>

接着是输入框,由于输入框有预设匹配的需求,所以这里我们使用 ArcoDesigna-auto-complete 组件,组件 v-model 值即上面创建的正则字符串属性 regStr

在开头我们导入了 regexp.js 文件中全部的正则预设对象 regPresetsObj

a-auto-complete 组件的 data 属性即下拉列表数据,我们填入预设数组 regPresets,默认是空的数组即没有匹配中项。组件的 search 方法即自定义的匹配方法,我们写了 regPresetMatch 方法去匹配。regPresetMatch 方法中参数 value 即正则字符串 regStr 值,我们在方法中校验 value 值存在并且预设匹配 auto 属性为 true 的情况下去匹配默认预设列表 regPresetsObj 中的数据匹配并返回一个匹配到的列表赋值给预设数组 regPresets,如果 value 值为空或者 auto 属性为 false 则重置预设数组 regPresets 。这样我们每次写内容时预设列表中有匹配到就会以下拉的形式展示出来。

PS: 模糊匹配主要是用 ES6includesfilter 方法,新同学可以查查文档。

预设数组 regPresets 的格式是根据组件要求来的,即数组对象,数组中每个对象属性如下:

  • label 预设正则项中文名。
  • value 预设正则项 key ,我们使用正则对象的 source 值来作为 key ,正则对象的 source 属性即正则字符串(不带双斜杠不带修饰符的那种)。
  • regexp 预设正则对象,这个是我们自定义的属性,用来后面下拉选中时给修饰符赋值。

代码片段如下:

// 正则预设匹配方法
const regPresetMatch = value => {
  if (value && auto.value) {
    regPresets.value = [...Object.values(regPresetsObj)]
      .filter(r => r.name.includes(value) || r.source.includes(value))
      .map(v => {
        return {
          label: v.name,
          regexp: v,
          value: v.source
        }
      })
  } else {
    regPresets.value = []
  }
}

默认 a-auto-complete 组件选中时会将下拉列表选中项的 value 值赋值给组件 v-model 属性,匹配项的 value 值我们之前设置的是正则字符串,即匹配到预设选中后 value 值就会赋值给 regStr 属性,由于我们的预设正则中可能携带修饰符,所以,我们还需要在组件 select 选中事件中给修饰符 regFlag 属性赋值,即 regPresetSelect 方法内容,如下:

// 正则预设选中方法
const regPresetSelect = r => {
  let selected = regPresets.value.find(v => v.value === r)
  if (selected) regFlag.value = selected.regexp.flags.split('')
}

PS: 正则对象中的 source 属性是正则字符串,而 flags 属性也是一个字符串,即该正则的修饰符,不晓得的同学可以控制台随便打印一个正则对象看下。

OK,我们在 a-auto-complete 组件中还写了两个插槽,同样都是组件插槽哈,可以看看文档,前置插槽 prepend 和后置插槽 append,前置插槽中除了 / 之外,我们还简单用 2 个图标来提示当前输入正则的对错,后置插槽中除了 / 之外,我们还渲染了当前选中的修饰符 regFlag

代码片段如下:

<a-auto-complete
  class="ml-10px"
  v-model="regStr"
  :data="regPresets"
  @search="regPresetMatch"
  @select="regPresetSelect"
  :placeholder="`请输入正则表达式${
    auto ? '或输入文字选择自动匹配的预设正则' : ''
  }`"
  allow-clear
  :size="size"
>
  <template #prepend>
    <a-tooltip
      :content="reg ? '正则表达式正确' : '正则表达式有误'"
      position="bottom"
      mini
    >
      <icon-jam-triangle-danger-f
        class="text-[rgb(var(--orange-6))]"
        v-if="!reg"
      />
      <icon-mdi-hand-okay class="text-[rgb(var(--green-6))]" v-else />
    </a-tooltip>

    <span class="ml-5px">/</span>
  </template>
  <template #append>
    <span> /{{ reg?.flags || regFlag?.join('') }}</span>
  </template>
</a-auto-complete>

OK,最后就是修饰符选项了,使用同一个图标按钮,以不同颜色区分是否选中有修饰符,使用 ArcoDesigna-popover 组件使鼠标悬浮到图标按钮时出现气泡提示弹窗,弹窗内以多选框组 a-checkbox-group 组件渲染几个修饰符选项,v-model 值设置为修饰符属性 regFlag (数组)即可。

代码片段如下:

<a-popover title="选择修饰符" position="bl">
  <a-button :size="size" class="ml-10px">
    <template #icon>
      <icon-mdi-filter-multiple
        :class="regFlag.length > 0 ? 'text-[rgb(var(--arcoblue-6))]' : ''"
      />
    </template>
  </a-button>
  <template #content>
    <div class="min-w-200px">
      <a-checkbox-group
        :size="size"
        v-model="regFlag"
        direction="vertical"
      >
        <a-checkbox value="g"> -g:全局匹配 </a-checkbox>
        <a-checkbox value="i"> -i:忽略大小写</a-checkbox>
        <a-checkbox value="m"> -m:多行匹配</a-checkbox>
        <a-checkbox value="s"> -s:特殊字符. 包含换行符</a-checkbox>
      </a-checkbox-group>
    </div>
  </template>
</a-popover>

修饰符我们写了四个,分别是:

  • -g 匹配全局。
  • -i 忽略大小写。
  • -m 匹配多行。
  • -s 特殊字符.,包含换行符。

OK,简单解释了一下上面代码的意思,保存刷新页面,当前效果如下:

正则对象有了,接下来我们来写匹配结果!

正则匹配结果预览

想要匹配结果我们要先声明一个待匹配的字符串属性 matchStr,然后使用 ArcoDesigna-textarea 组件写个多行文本即可,代码片段如下:

<script setup>
// 待匹配字符串
const matchStr = ref('')
</script>

<a-textarea
  v-model="matchStr"
  default-value=""
  placeholder="请输入要匹配的字符串"
  :auto-size="{
    minRows: 3,
    maxRows: 6
  }"
/>

接着我们需要展示当前待匹配字符串基于正则对象匹配到的内容,这里直接用计算属性来监听待匹配字符串改变然后使用 match 方法在待匹配字符串中匹配正则对应值即可。

PS: match 方法可在字符串内检索指定的值,或找到一个或多个正则表达式的匹配,不了解的同学还是查下文档。

如下:

// 匹配结果数组 reg即上文写的正则对象 filter过滤空值,默认空数组
const matchingResults = computed(
  () => matchStr.value.match(reg.value)?.filter(v => v) || []
)

OK,现在我们可以在 Template 模板中简单写一下匹配结果的 HTML 了,校验当匹配到的结果数组 matchingResults 长度大于 0,即展示一共多少项匹配,然后依次列出匹配的结果值。数组长度小于等于 0 则不展示。

<div
  v-if="matchingResults.length > 0"
  class="w-full min-h-100px bg-[var(--color-fill-2)] whitespace-pre-wrap break-words indent-0 leading-22px mt-20px px-12px py-4px box-border"
>
  <div>共 {{ matchingResults.length }} 处匹配:</div>
  <div v-for="(res, i) of matchingResults" :key="i">{{ res }}</div>
</div>

上面我们只展示了匹配到的各个结果,我们还需要在完整待匹配字符串中展示一下各个匹配结果的位置,即展示完整字符串,并把匹配结果以不同的颜色在原字符串中标识出来。

想一想,原始字符串我们有,匹配到的结果我们也有,所以基于原始字符串和匹配结果我们写个格式化方法给匹配到的字符串加一下标签和样式然后返回渲染到页面就行了,代码片段如下:

<script>
// 待匹配字符串
const matchStr = ref('')
// 匹配结果数组 reg即上文写的正则对象 filter过滤空值,默认空数组
const matchingResults = computed(
  () => matchStr.value.match(reg.value)?.filter(v => v) || []
)
// 匹配结果格式化
const matchingFormat = computed(() => {
  let res = '',
    str = matchStr.value
  let n = 1
  matchingResults.value.forEach(v => {
    if (n > 4) n = 1
    let matchStr = str.substr(0, v.length + str.indexOf(v))
    str = str.substr(v.length + str.indexOf(v))
    res += matchStr.replace(
      v,
      `<span class="matching matching${n}">${v}</span>`
    )
    n++
  })

  return res + str
})
</script>

<div
  class="w-full min-h-100px bg-[var(--color-fill-2)] whitespace-pre-wrap break-words indent-0 leading-22px mt-20px px-12px py-4px box-border"
  v-html="matchingFormat || '无匹配结果'"
></div>

<style>
.matching {
  @apply px-0px rounded-4px box-border;
}
.matching1 {
  @apply bg-[rgb(var(--arcoblue-2))];
}
.matching2 {
  @apply bg-[rgb(var(--green-2))];
}
.matching3 {
  @apply bg-[rgb(var(--orange-2))];
}
.matching4 {
  @apply bg-[rgb(var(--red-2))];
}
</style>

OK,把上面这些内容组合一下,放到 RegularPage 页面中,目前正则校验页面的代码如下:

<script setup>
import * as regPresetsObj from '@/utils/regexp.js'

// 大小
const size = ref('large')
// 正则表达式对象
const reg = ref(null)
// 正则表达式字符串
const regStr = ref('')
// 正则表达式修饰符
const regFlag = ref([])
watchEffect(() => {
  try {
    reg.value = regStr.value
      ? new RegExp(regStr.value, regFlag.value.join(''))
      : null
  } catch (err) {
    reg.value = null
  }
})

// 预设自动搜索
const auto = ref(true)
// 正则预设列表
const regPresets = ref([])
// 正则预设匹配方法
const regPresetMatch = value => {
  if (value && auto.value) {
    regPresets.value = [...Object.values(regPresetsObj)]
      .filter(r => r.name.includes(value) || r.source.includes(value))
      .map(v => {
        return {
          label: v.name,
          regexp: v,
          value: v.source
        }
      })
  } else {
    regPresets.value = []
  }
}
// 正则预设选中方法
const regPresetSelect = r => {
  let selected = regPresets.value.find(v => v.value === r)
  if (selected) regFlag.value = selected.regexp.flags.split('')
}

// 待匹配字符串
const matchStr = ref('')
// 匹配结果
const matchingResults = computed(
  () => matchStr.value.match(reg.value)?.filter(v => v) || []
)
// 匹配结果格式化
const matchingFormat = computed(() => {
  let res = '',
    str = matchStr.value
  let n = 1
  matchingResults.value.forEach(v => {
    if (n > 4) n = 1
    let matchStr = str.substr(0, v.length + str.indexOf(v))
    str = str.substr(v.length + str.indexOf(v))
    res += matchStr.replace(
      v,
      `<span class="matching matching${n}">${v}</span>`
    )
    n++
  })

  return res + str
})
</script>
<template>
  <div class="max-w-1200px w-full p-20px box-border">
    <div class="flex justify-start items-center">
      <a-tooltip
        :content="`点击${auto ? '关闭' : '开启'}预设正则自动匹配`"
        position="bottom"
      >
        <a-button :size="size" @click="auto = !auto">
          <template #icon>
            <icon-material-symbols-astrophotography-auto
              :class="auto ? 'text-[rgb(var(--arcoblue-6))]' : ''"
            />
          </template>
        </a-button>
      </a-tooltip>

      <a-auto-complete
        class="ml-10px"
        v-model="regStr"
        :data="regPresets"
        @search="regPresetMatch"
        @select="regPresetSelect"
        :placeholder="`请输入正则表达式${
          auto ? '或输入文字选择自动匹配的预设正则' : ''
        }`"
        allow-clear
        :size="size"
      >
        <template #prepend>
          <a-tooltip
            :content="reg ? '正则表达式正确' : '正则表达式有误'"
            position="bottom"
            mini
          >
            <icon-jam-triangle-danger-f
              class="text-[rgb(var(--orange-6))]"
              v-if="!reg"
            />
            <icon-mdi-hand-okay class="text-[rgb(var(--green-6))]" v-else />
          </a-tooltip>

          <span class="ml-5px">/</span>
        </template>
        <template #append>
          <span> /{{ reg?.flags || regFlag?.join('') }}</span>
        </template>
      </a-auto-complete>

      <a-popover title="选择修饰符" position="bl">
        <a-button :size="size" class="ml-10px">
          <template #icon>
            <icon-mdi-filter-multiple
              :class="regFlag.length > 0 ? 'text-[rgb(var(--arcoblue-6))]' : ''"
            />
          </template>
        </a-button>
        <template #content>
          <div class="min-w-200px">
            <a-checkbox-group
              :size="size"
              v-model="regFlag"
              direction="vertical"
            >
              <a-checkbox value="g"> -g:全局匹配 </a-checkbox>
              <a-checkbox value="i"> -i:忽略大小写</a-checkbox>
              <a-checkbox value="m"> -m:多行匹配</a-checkbox>
              <a-checkbox value="s"> -s:特殊字符. 包含换行符</a-checkbox>
            </a-checkbox-group>
          </div>
        </template>
      </a-popover>
    </div>

    <!-- 中 -->
    <div class="mt-20px bg-[var(--color-fill-2)]"></div>

    <!-- 下 -->
    <div class="w-full mt-20px">
      <a-textarea
        v-model="matchStr"
        default-value=""
        placeholder="请输入要匹配的字符串"
        :auto-size="{
          minRows: 3,
          maxRows: 6
        }"
      />

      <div
        class="w-full min-h-100px bg-[var(--color-fill-2)] whitespace-pre-wrap break-words indent-0 leading-22px mt-20px px-12px py-4px box-border"
        v-html="matchingFormat || '无匹配结果'"
      ></div>

      <div
        v-if="matchingResults.length > 0"
        class="w-full min-h-100px bg-[var(--color-fill-2)] whitespace-pre-wrap break-words indent-0 leading-22px mt-20px px-12px py-4px box-border"
      >
        <div>共 {{ matchingResults.length }} 处匹配:</div>
        <div v-for="(res, i) of matchingResults" :key="i">{{ res }}</div>
      </div>
    </div>
  </div>
</template>

<style>
.matching {
  @apply px-0px rounded-4px box-border;
}
.matching1 {
  @apply bg-[rgb(var(--arcoblue-2))];
}
.matching2 {
  @apply bg-[rgb(var(--green-2))];
}
.matching3 {
  @apply bg-[rgb(var(--orange-2))];
}
.matching4 {
  @apply bg-[rgb(var(--red-2))];
}
</style>

保存刷新页面,输入一个手机号正则,选中修饰符 m 即多行匹配,在待匹配输入框中输入一些正确或者错误的手机号,效果如下:

正则可视化预览

接下来写下正则对象的可视化图表预览,首先我们要写一个按钮来控制是否开启可视化预览,声明一个是否开启可视化预览属性 visualization (默认为 true 开启即可),然后和上面开启预设一样,写一下按钮(按钮图标还是 iconify 图标库中随便找的图标哈,看代码就行)放在第一行修饰符按钮右边即可,代码片段如下:

<script setup>
// 可视化
const visualization = ref(true)
</script>

<a-tooltip
  :content="`点击${visualization ? '关闭' : '开启'}可视化解析`"
  position="bottom"
>
  <a-button
    :size="size"
    class="ml-10px"
    @click="visualization = !visualization"
  >
    <template #icon>
      <icon-ic-sharp-visibility
        class="text-[rgb(var(--arcoblue-6))]"
        v-if="visualization"
      />
      <icon-ic-sharp-visibility-off v-else />
    </template>
  </a-button>
</a-tooltip>

接下来我们开始写可视化组件了,简单描述流程就是先给正则字符串做 AST 解析,然后分析正则的 AST 数据,最后渲染图表。

说起来很简单,但是真写的话还是比较麻烦的,这里就直接用三方包了,我们使用 regulex 来做这件事。

regulex 是一个 JavaScript 正则表达式解析器和可视化工具,这个包也比较老了,好几年没更新了,由于我们项目有黑白模式切换,但是这个包是不支持修改颜色的,于是我 clone 了下来代码修改了渲染方法,让它支持了下黑白模式。在克隆的时候由于主分支代码有点问题,所以我使用的是 legacy 分支代码做的修改,由于 legacy 分支代码构建时使用的是 require.js 输出的 AMD 规范代码,所以。。。先凑活用吧,因为它很久不更新了,后面有时间我们基于这个库,只使用它的正则 AST 解析以及规则分析两个核心方法,然后自己再写一个渲染方法构建发个 ESModule 包,到时候我补充一篇文章,目前这个包没啥可说的,所以我们一会儿只介绍下核心方法能使用就行了,因为实在是太老旧了。。。

PS: AMDESModule 以及 require.js 不了解可以看看另一篇文章 「前端工程四部曲」模块化的前世今生(上)

后面可视化组件代码中我们会使用到 regulex 库的三个核心方法,如下:

  • parse() - 做正则 AST 规则解析,该方法接收一个正则字符串,返回一个解析后的数据对象。
  • Raphael() - 这方法来源于 RaphaelJS ,它是一个基于 SVG 的绘图库,所以它是 regulex 依赖的三方包,主要就是用来绘制正则对象的 SVG 图表的,它的用法大家想要详细了解看文档吧。。。该方法返回一个 Raphael 实例,这里我们也就只需要创建个实例就行了。
  • visualize() - 有了 AST 和绘图实例,那核心的渲染逻辑就是这个方法了,主要作用就是基于解析后的正则对象规则使用 Raphael 实例来绘制图形。我这边改的也是只改了这个方法,其余都没动,而且也不影响原来的代码逻辑,此方法本来接收三个参数,分别是解析后的正则数据对象(即 parse() 方法返回值)、正则修饰符字串和 Raphael 绘图实例,由于我们加了黑白模式,所以给这个渲染方法加了第四个参数即 mode 模式,接收 dark、light 两个模式值,渲染出的图表是对应的两套颜色。当然,不传这个参数也可以的。

OK,放一个我这边本地修改后重新构建压缩好的 regulex 包,就是一个 AMD 规范的 JS 文件,地址在这里 👉🏻 regulex.js,自行下载吧,要是你不需要模式切换时图表颜色也改变的话,你也可以直接克隆官方代码构建一个官方包,不过还得本地构建,太麻烦,直接用我这个改过的方便,下载个 JS 文件就行了!

我们以 CDN 方式引入这个 JS 文件,当然,我们没有 CDN,所以下载下来 regulex.js 文件后,直接把这个文件放在项目根目录下的静态资源文件中,即项目根目录下 public 文件夹下新建一个 static 文件夹,把 regulex.js 文件放在这个文件夹下即可,我们项目代码默认构建时输出的静态资源文件就在 dist/static 文件夹下,这里我们直接在 public 文件夹下创建的文件构建时是不会被打包重写的,而是直接复制文件到 dist 目录下,所以打包时,会直接把 public 文件夹下的文件 copy 到最终项目输出的 dist 目录下,public/static 文件夹下的文件也就会全部 copydist/static 文件夹下。

文件放好之后,修改下入口 HTML 文件,引入该文件(注意这里引入是需要绝对路径的),即根目录下的 index.html 文件如下:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <!-- 引入regulex包 -->
    <script src="/static/regulex.js"></script>
    <title>Vite App</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

由于这是个 AMD 规范的文件,内置了 requireJS,使用的时候直接调用 require 方法引入模块即可,如下例:

let { parse, visualize, Raphael } = require('regulex')

准备工作就绪,可以开始写正则可视化组件了,其实很简单哈,先来看代码,在 src/views/RegularPage 文件夹下新建 components/RegularVisualization.vue 文件,组件代码如下:

<script setup>
// 注意需全局引入regulex.js
defineExpose({
  exportImage
})

const props = defineProps({
  // 正则表达式对象
  modelValue: {
    type: [Object, null],
    required: true
  },
  // 主题明暗模式
  mode: {
    type: String,
    required: true
  }
})

watch(
  [() => props.modelValue, () => props.mode],
  () => {
    nextTick(() => init(props.modelValue))
  },
  { immediate: true }
)

// 导出图片
function exportImage() {
  let ratio = window.devicePixelRatio || 1
  // 获取SVG画布元素
  let svg = window.graphCt.getElementsByTagName('svg')[0]
  // 获取SVG画布宽高
  let w = svg.clientWidth
  let h = svg.clientHeight
  // 创建Image对象
  let img = new Image()
  img.width = w
  img.height = h
  img.setAttribute('src', svgToBase64(svg))

  // 创建Canvas对象
  let canvas = document.createElement('canvas')

  canvas.width = w * ratio
  canvas.height = h * ratio
  let ctx = canvas.getContext('2d')
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0)

  // Image对象onload事件
  img.onload = function () {
    // 给一个背景颜色,不然导出的图片是透明的
    let bgColor = props.mode === 'dark' ? '#2e2e31' : '#f2f3f5'
    ctx.fillStyle = bgColor

    // 绘制Image对象到canvas画布
    ctx.fillRect(0, 0, canvas.width, canvas.height)
    ctx.drawImage(img, 0, 0)

    // canvas画布转 Base64 并导出 png
    canvasToPictureDownload(canvas, `regexp_visualization_${Date.now()}`)
  }
}

// canvas转图片下载
function canvasToPictureDownload(canvas, name) {
  let a = document.createElement('a')
  // 将画布内的信息导出为png图片数据 Base64
  a.href = canvas.toDataURL('image/png')
  // 设定下载名称
  a.download = name
  // 点击触发下载
  a.click()
}

// 将svg转换为base64
function svgToBase64(svg) {
  return 'data:image/svg+xml,' + encodeURIComponent(svg.outerHTML)
}

// 初始化方法
function init(reg) {
  if (!reg) return
  document.getElementById('graphCt').innerHTML = ''
  let { parse, visualize, Raphael } = require('regulex')
  // 创建 Raphael 实例
  let paper = Raphael('graphCt', 0, 0)
  // 清空画布
  paper.clear()
  try {
    // 渲染正则图表
    visualize(parse(reg.source), reg.flags, paper, props.mode)
  } catch (e) {
    if (e instanceof parse.RegexSyntaxError) {
      let msg = ['Error:' + e.message, '']
      if (typeof e.lastIndex === 'number') {
        msg.push(reg)
        msg.push(new Array(e.lastIndex).join('-') + '^')
      }
      console.error(msg.join('\n'))
    } else {
      throw e
    }
  }
}
</script>

<template>
  <div
    v-if="props.modelValue"
    id="graphCt"
    class="w-full overflow-x-auto overflow-y-hidden"
  ></div>
</template>

简单介绍一下代码大概逻辑,该组件接收两个参数,一个 modelValue,即 v-model 传入的是正则对象,另一是 mode 即模式字符串。

我们使用 Vuewatch 方法监听了这两个参数,组件内部监听到参数改变时,会重新执行初始化方法。

该组件的核心就是初始化 init 方法,接下来看下这块的代码片段:

// 初始化方法
function init(reg) {
  if (!reg) return
  document.getElementById('graphCt').innerHTML = ''
  let { parse, visualize, Raphael } = require('regulex')
  // 创建 Raphael 实例
  let paper = Raphael('graphCt', 0, 0)
  // 清空画布
  paper.clear()
  try {
    // 渲染正则图表
    visualize(parse(reg.source), reg.flags, paper, props.mode)
  } catch (e) {
    if (e instanceof parse.RegexSyntaxError) {
      let msg = ['Error:' + e.message, '']
      if (typeof e.lastIndex === 'number') {
        msg.push(reg)
        msg.push(new Array(e.lastIndex).join('-') + '^')
      }
      console.error(msg.join('\n'))
    } else {
      throw e
    }
  }
}

我们在 Template 模板中创建了一个 div 元素,给它了个 IDgraphCt,这个元素就是将来 SVG 图表渲染的目标画布元素了。

上面初始化方法内我们获取到画布元素,然后导出 regulex 包的三个核心方法。

先使用 Raphael 方法,传入画布 ID 和宽高创建 SVG 绘图库实例,这里宽高传入 0 即可,因为还没有内容。紧接着调用绘图库实例中的 clear 方法,先清空一下画布,避免重复渲染。

然后使用 visualize 方法,第一个参数传入的是由 regulex 库的 parse 方法创建的正则解析对象,第二个参数传入正则对象的修饰符字符串,第三个参数传入上面创建的绘图库实例,第四个参数即传入我们的 mode 模式字符串就可以了。

每当正则对象或者模式字符串改变时就会重新执行 init 方法渲染画布。

上面代码 catch 代码块中我们做了一些错误处理,不用在意。OK,一个正则可视化渲染组件核心就写好了,那由于我们还要有一个可视化图表转图片下载的功能,所以还需要在组件内部写一下画布转图片下载方法暴露出去。

核心代码片段如下:

// 导出图片
function exportImage() {
  let ratio = window.devicePixelRatio || 1
  // 获取SVG画布元素
  let svg = window.graphCt.getElementsByTagName('svg')[0]
  // 获取SVG画布宽高
  let w = svg.clientWidth
  let h = svg.clientHeight
  // 创建Image对象
  let img = new Image()
  img.width = w
  img.height = h
  img.setAttribute('src', svgToBase64(svg))

  // 创建Canvas对象
  let canvas = document.createElement('canvas')

  canvas.width = w * ratio
  canvas.height = h * ratio
  let ctx = canvas.getContext('2d')
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0)

  // Image对象onload事件
  img.onload = function () {
    // 给一个背景颜色,不然导出的图片是透明的
    let bgColor = props.mode === 'dark' ? '#2e2e31' : '#f2f3f5'
    ctx.fillStyle = bgColor

    // 绘制Image对象到canvas画布
    ctx.fillRect(0, 0, canvas.width, canvas.height)
    ctx.drawImage(img, 0, 0)

    // canvas画布转 Base64 并导出 png
    canvasToPictureDownload(canvas, `regexp_visualization_${Date.now()}`)
  }
}

// canvas转图片下载
function canvasToPictureDownload(canvas, name) {
  let a = document.createElement('a')
  // 将画布内的信息导出为png图片数据 Base64
  a.href = canvas.toDataURL('image/png')
  // 设定下载名称
  a.download = name
  // 点击触发下载
  a.click()
}

// 将svg转换为base64
function svgToBase64(svg) {
  return 'data:image/svg+xml,' + encodeURIComponent(svg.outerHTML)
}

defineExpose({
  exportImage
})

这块一共有三个方法,简单解释下,由于我们渲染的图表是 SVG 图表,所以 exportImage 导出图片方法中我们通过画布 ID 先直接获取到 SVG 元素,拿到 SVG 元素之后,我们需要将 SVG 先转成 Base64 格式(即 svgToBase64 方法),拿到了 SVGBase64 数据,我们直接 new Image 创建一个 Image 对象把这个 Base64 数据作为该对象 href 属性值写入,Image 对象宽高获取一下 SVG 画布,使用 SVG 画布的宽高即可。

接着我们创建一个 Canvas 画布对象,在 Image 对象加载完成后,把 Image 对象绘制到 Canvas 画布中,最后调用 canvasToPictureDownload 方法,该方法即把 Canvas 画布转成 png 格式的 Base64 数据,然后 JS 创建一个 a 标签写入 href 值,再给 a 标签对象写入 download 属性,其属性值为下载的默认图片名,这里我们给了一个时间戳组成的字符串,最后手动触发下 click 点击事件触发下载即可。

最后我们在可视化组件中暴露出了图片下载方法,如下:

defineExpose({
  exportImage
})

// ...

PS: 由于组件我们时使用 setup 模式写的,所以想要暴露方法,必须使用 defineExpose 方法手动暴露。

还有之前文章也说过:因为我们使用了 setup,那试想 setupVue 组件中的哪个时期才会调用?它是在组件调用时被调用,并且是在组件的 beforeCreate 生命周期之前执行,也就是想要拿到 setup 中的数据,那至少得等组件调用了才行,组件还没调用的时候,是绝对获取不了 setup 中属性或方法的。

这样的话我们在父级组件中拿到组件实例对象后就可以调用其导出的 exportImage 方法了!!

OK,组件写完了,来在正则页面 RegularPage/index.vue 文件中使用一下。

由于这里要用到模式属性,所以直接在文件开头,需要导入我们上一篇文章中创建的 pinia system 模块中的当前模式对象,如下:

import { useSystemStore } from '@/stores/system'
const { currentMode } = storeToRefs(useSystemStore())

这里有个问题哈,我们这里 visualize 方法的参数 mode 只支持传入字符串 dark 或者 light,但是我们的当前模式对象中 name 属性在自动模式下会是 auto ,这就会存在问题,我们需要获取到 auto 模式下到底是 dark 还是 light

所以我们需要借助 VueUse 的另一个方法 useDark 通过和之前 useColorMode 方法一样的属性配置来获取当前模式,其实 useDark 方法内部也是靠 useColorMode 方法实现的,在 useDark 方法源码中,会使用 useColorMode 传入配置项生成一个响应式的 mode 属性,而 useDark 方法返回的 isDark 属性,其实就是一个计算属性,其源码核心大至如下:

export function useDark(options = {}) {

  const mode = useColorMode({
    ...options,
    // ...
  })

  const isDark = computed({
    get() {
      return mode.value === 'dark'
    },
    set(v) {
      // ...
    },
  })

  return isDark
}

看,是不是很简单,跑题了,回到正题哈。我们来写下当前模式获取的代码:

// 是否是黑暗模式
const isDark = useDark({
  selector: 'body',
  attribute: 'arco-theme',
  valueDark: 'dark',
  valueLight: 'light',
  initialValue: currentMode.value?.name,
  storageKey: null
})
// 当前颜色模式
const mode = computed(() => {
  if (currentMode.value?.name == 'auto') {
    return isDark.value ? 'dark' : 'light'
  }
  return currentMode.value?.name
})

OK,这样的话每当 pinia 中全局的当前模式对象改变,我们这个页面的模式值也会跟着改变,并且当全局模式值为 auto 时,我们这里也可以知道它到底是 dark 还是 light

拿到了当前模式数据,接下来我们在 Template 模板就可以使用下组件了。

还记得开始时我们把整个正则的页面分为上中下三个模块吗,中间那个模块就是放可视化图表的,我们直接把可视化组件代码塞到中间模块的 div 中,匿名组件的组件名默认就是文件名哈,组件还要做校验,只有 visualization 属性为 true 时才加载可视化组件(reg 还是我们之前写的那个正则对象哈):

<div class="mt-20px bg-[var(--color-fill-2)]">
  <RegularVisualization
    ref="regVisualizationRef"
    v-if="visualization"
    v-model="reg"
    :mode="mode"
  />
</div>

趁热打铁,再来给下载可视化图片的按钮写一下,同样和上面其他按钮差不多,放在是否显示可视化预览按钮右边就行,不过这个下载图片的按钮也需要校验,只有当 reg 正则对象存在且是否显示可视化预览的 visualization 属性值为 true 时才显示,并且我们给按钮加了一个点击事件 exportRegVisualizationToImg,即下载图片方法,代码片段如下:

<a-tooltip content="下载解析图片" position="bottom">
  <a-button
    :size="size"
    class="ml-10px"
    v-if="visualization && reg"
    @click="exportRegVisualizationToImg"
  >
    <template #icon>
      <icon-icon-park-outline-down-picture />
    </template>
  </a-button>
</a-tooltip>

OK,上面我们写可视化组件时,在可视化组件标签中还写了个 ref 属性,值为 regVisualizationRef,那我们可以通过 ref 属性来获取一下可视化组件实例,和 Vue2refs 类似,但是在 setup 中我们需要创建一个和组件中 ref 属性值 regVisualizationRef 同名的 ref() 数据,默认是 null,当组件渲染之后此属性值即组件实例对象。

然后我们补下下载按钮的点击事件内容,在下载按钮点击事件中调用可视化组件实例抛出的下载图片方法,如下:

// 可视化组件实例
const regVisualizationRef = ref(null)

// 导出正则可视化图片
const exportRegVisualizationToImg = () => {
  regVisualizationRef.value && regVisualizationRef.value?.exportImage()
}

可视化模块到此差不多就写完了,看下完整代码:

<script setup>
import * as regPresetsObj from '@/utils/regexp.js'
import { useSystemStore } from '@/stores/system'
const { currentMode } = storeToRefs(useSystemStore())

// 大小
const size = ref('large')
// 正则表达式对象
const reg = ref(null)
// 正则表达式字符串
const regStr = ref('')
// 正则表达式修饰符
const regFlag = ref([])
watchEffect(() => {
  try {
    reg.value = regStr.value
      ? new RegExp(regStr.value, regFlag.value.join(''))
      : null
  } catch (err) {
    reg.value = null
  }
})

// 预设自动搜索
const auto = ref(true)
// 正则预设列表
const regPresets = ref([])
// 正则预设匹配方法
const regPresetMatch = value => {
  if (value && auto.value) {
    regPresets.value = [...Object.values(regPresetsObj)]
      .filter(r => r.name.includes(value) || r.source.includes(value))
      .map(v => {
        return {
          label: v.name,
          regexp: v,
          value: v.source
        }
      })
  } else {
    regPresets.value = []
  }
}
// 正则预设选中方法
const regPresetSelect = r => {
  let selected = regPresets.value.find(v => v.value === r)
  if (selected) regFlag.value = selected.regexp.flags.split('')
}

// 待匹配字符串
const matchStr = ref('')
// 匹配结果
const matchingResults = computed(
  () => matchStr.value.match(reg.value)?.filter(v => v) || []
)
// 匹配结果格式化
const matchingFormat = computed(() => {
  let res = '',
    str = matchStr.value
  let n = 1
  matchingResults.value.forEach(v => {
    if (n > 4) n = 1
    let matchStr = str.substr(0, v.length + str.indexOf(v))
    str = str.substr(v.length + str.indexOf(v))
    res += matchStr.replace(
      v,
      `<span class="matching matching${n}">${v}</span>`
    )
    n++
  })

  return res + str
})

// 可视化
const visualization = ref(true)
// 可视化组件实例
const regVisualizationRef = ref(null)
// 是否是黑暗模式
const isDark = useDark({
  selector: 'body',
  attribute: 'arco-theme',
  valueDark: 'dark',
  valueLight: 'light',
  initialValue: currentMode.value?.name,
  storageKey: null
})
// 当前颜色模式
const mode = computed(() => {
  if (currentMode.value?.name == 'auto') {
    return isDark.value ? 'dark' : 'light'
  }
  return currentMode.value?.name
})
// 导出正则可视化图片
const exportRegVisualizationToImg = () => {
  regVisualizationRef.value && regVisualizationRef.value?.exportImage()
}
</script>
<template>
  <div class="max-w-1200px w-full p-20px box-border">
    <div class="flex justify-start items-center">
      <a-tooltip
        :content="`点击${auto ? '关闭' : '开启'}预设正则自动匹配`"
        position="bottom"
      >
        <a-button :size="size" @click="auto = !auto">
          <template #icon>
            <icon-material-symbols-astrophotography-auto
              :class="auto ? 'text-[rgb(var(--arcoblue-6))]' : ''"
            />
          </template>
        </a-button>
      </a-tooltip>

      <a-auto-complete
        class="ml-10px"
        v-model="regStr"
        :data="regPresets"
        @search="regPresetMatch"
        @select="regPresetSelect"
        :placeholder="`请输入正则表达式${
          auto ? '或输入文字选择自动匹配的预设正则' : ''
        }`"
        allow-clear
        :size="size"
      >
        <template #prepend>
          <a-tooltip
            :content="reg ? '正则表达式正确' : '正则表达式有误'"
            position="bottom"
            mini
          >
            <icon-jam-triangle-danger-f
              class="text-[rgb(var(--orange-6))]"
              v-if="!reg"
            />
            <icon-mdi-hand-okay class="text-[rgb(var(--green-6))]" v-else />
          </a-tooltip>

          <span class="ml-5px">/</span>
        </template>
        <template #append>
          <span> /{{ reg?.flags || regFlag?.join('') }}</span>
        </template>
      </a-auto-complete>

      <a-popover title="选择修饰符" position="bl">
        <a-button :size="size" class="ml-10px">
          <template #icon>
            <icon-mdi-filter-multiple
              :class="regFlag.length > 0 ? 'text-[rgb(var(--arcoblue-6))]' : ''"
            />
          </template>
        </a-button>
        <template #content>
          <div class="min-w-200px">
            <a-checkbox-group
              :size="size"
              v-model="regFlag"
              direction="vertical"
            >
              <a-checkbox value="g"> -g:全局匹配 </a-checkbox>
              <a-checkbox value="i"> -i:忽略大小写</a-checkbox>
              <a-checkbox value="m"> -m:多行匹配</a-checkbox>
              <a-checkbox value="s"> -s:特殊字符. 包含换行符</a-checkbox>
            </a-checkbox-group>
          </div>
        </template>
      </a-popover>

      <a-tooltip
        :content="`点击${visualization ? '关闭' : '开启'}可视化解析`"
        position="bottom"
      >
        <a-button
          :size="size"
          class="ml-10px"
          @click="visualization = !visualization"
        >
          <template #icon>
            <icon-ic-sharp-visibility
              class="text-[rgb(var(--arcoblue-6))]"
              v-if="visualization"
            />
            <icon-ic-sharp-visibility-off v-else />
          </template>
        </a-button>
      </a-tooltip>

      <a-tooltip content="下载解析图片" position="bottom">
        <a-button
          :size="size"
          class="ml-10px"
          v-if="visualization && reg"
          @click="exportRegVisualizationToImg"
        >
          <template #icon>
            <icon-icon-park-outline-down-picture />
          </template>
        </a-button>
      </a-tooltip>
    </div>

    <div class="mt-20px bg-[var(--color-fill-2)]">
      <RegularVisualization
        ref="regVisualizationRef"
        v-if="visualization"
        v-model="reg"
        :mode="mode"
      />
    </div>

    <div class="w-full mt-20px">
      <a-textarea
        v-model="matchStr"
        default-value=""
        placeholder="请输入要匹配的字符串"
        :auto-size="{
          minRows: 3,
          maxRows: 6
        }"
      />

      <div
        class="w-full min-h-100px bg-[var(--color-fill-2)] whitespace-pre-wrap break-words indent-0 leading-22px mt-20px px-12px py-4px box-border"
        v-html="matchingFormat || '无匹配结果'"
      ></div>

      <div
        v-if="matchingResults.length > 0"
        class="w-full min-h-100px bg-[var(--color-fill-2)] whitespace-pre-wrap break-words indent-0 leading-22px mt-20px px-12px py-4px box-border"
      >
        <div>共 {{ matchingResults.length }} 处匹配:</div>
        <div v-for="(res, i) of matchingResults" :key="i">{{ res }}</div>
      </div>
    </div>
  </div>
</template>

<style>
.matching {
  @apply px-0px rounded-4px box-border;
}
.matching1 {
  @apply bg-[rgb(var(--arcoblue-2))];
}
.matching2 {
  @apply bg-[rgb(var(--green-2))];
}
.matching3 {
  @apply bg-[rgb(var(--orange-2))];
}
.matching4 {
  @apply bg-[rgb(var(--red-2))];
}
</style>

保存刷新页面,看下效果。

dark 模式下:

light 模式下:

正则复制

最后来一个复制的小功能,就是开头我们说的,正则存在时,第一行最右边要显示一个 copy 按钮,用来把当前正则复制到剪切板。

代码很简单,还是在 RegularPage/index.vue 正则页面,script setup 中新增 JS

const { copy, text, isSupported } = useClipboard({ source: reg })
// Copy正则到剪贴板
const regCopy = async () => {
  if (!isSupported.value) return AMessage.info('不支持复制')
  await copy()
  AMessage.success('复制成功:' + text.value)
}

接着和之前的按钮一样,在 Template 模板的上模块最右侧加个复制图标按钮,在 reg 存在时显示,新增模板内容如下:

<a-tooltip content="点击 copy 正则表达式" position="bottom">
  <a-button :size="size" class="ml-10px" v-if="reg" @click="regCopy">
    <template #icon>
      <icon-icon-park-solid-copy />
    </template>
  </a-button>
</a-tooltip>

还是借助了 VueUseuseClipboard 方法哈,这个方法比较简单,所以大家看看就行,或者去刷下文档,复制到剪切板有兼容性问题以及浏览器策略问题( 生产环境下 http 不可复制,得 https 才行,开发环境没事)。

我们使用该方法返回的 isSupported 属性判断下当前页面是否支持复制到剪切板。

返回的 copy 方法就如方法名用来做复制。

返回的 text 属性是复制成功后剪切板的值。

先看下复制的效果:

到此正则校验页面功能就先告一段落了,完整代码在下一小节看吧

完整代码

<script setup>
import * as regPresetsObj from '@/utils/regexp.js'
import { useSystemStore } from '@/stores/system'
const { currentMode } = storeToRefs(useSystemStore())

// 大小
const size = ref('large')
// 正则表达式对象
const reg = ref(null)
// 正则表达式字符串
const regStr = ref('')
// 正则表达式修饰符
const regFlag = ref([])
watchEffect(() => {
  try {
    reg.value = regStr.value
      ? new RegExp(regStr.value, regFlag.value.join(''))
      : null
  } catch (err) {
    reg.value = null
  }
})

// 预设自动搜索
const auto = ref(true)
// 正则预设列表
const regPresets = ref([])
// 正则预设匹配方法
const regPresetMatch = value => {
  if (value && auto.value) {
    regPresets.value = [...Object.values(regPresetsObj)]
      .filter(r => r.name.includes(value) || r.source.includes(value))
      .map(v => {
        return {
          label: v.name,
          regexp: v,
          value: v.source
        }
      })
  } else {
    regPresets.value = []
  }
}
// 正则预设选中方法
const regPresetSelect = r => {
  let selected = regPresets.value.find(v => v.value === r)
  if (selected) regFlag.value = selected.regexp.flags.split('')
}

// 待匹配字符串
const matchStr = ref('')
// 匹配结果
const matchingResults = computed(
  () => matchStr.value.match(reg.value)?.filter(v => v) || []
)
// 匹配结果格式化
const matchingFormat = computed(() => {
  let res = '',
    str = matchStr.value
  let n = 1
  matchingResults.value.forEach(v => {
    if (n > 4) n = 1
    let matchStr = str.substr(0, v.length + str.indexOf(v))
    str = str.substr(v.length + str.indexOf(v))
    res += matchStr.replace(
      v,
      `<span class="matching matching${n}">${v}</span>`
    )
    n++
  })

  return res + str
})

// 可视化
const visualization = ref(true)
// 可视化组件实例
const regVisualizationRef = ref(null)
// 是否是黑暗模式
const isDark = useDark({
  selector: 'body',
  attribute: 'arco-theme',
  valueDark: 'dark',
  valueLight: 'light',
  initialValue: currentMode.value?.name,
  storageKey: null
})
// 当前颜色模式
const mode = computed(() => {
  if (currentMode.value?.name == 'auto') {
    return isDark.value ? 'dark' : 'light'
  }
  return currentMode.value?.name
})
// 导出正则可视化图片
const exportRegVisualizationToImg = () => {
  regVisualizationRef.value && regVisualizationRef.value?.exportImage()
}

const { copy, text, isSupported } = useClipboard({ source: reg })
// Copy正则到剪贴板
const regCopy = async () => {
  if (!isSupported.value) return AMessage.info('不支持复制')
  await copy()
  AMessage.success('复制成功:' + text.value)
}
</script>
<template>
  <div class="max-w-1200px w-full p-20px box-border">
    <div class="flex justify-start items-center">
      <a-tooltip
        :content="`点击${auto ? '关闭' : '开启'}预设正则自动匹配`"
        position="bottom"
      >
        <a-button :size="size" @click="auto = !auto">
          <template #icon>
            <icon-material-symbols-astrophotography-auto
              :class="auto ? 'text-[rgb(var(--arcoblue-6))]' : ''"
            />
          </template>
        </a-button>
      </a-tooltip>

      <a-auto-complete
        class="ml-10px"
        v-model="regStr"
        :data="regPresets"
        @search="regPresetMatch"
        @select="regPresetSelect"
        :placeholder="`请输入正则表达式${
          auto ? '或输入文字选择自动匹配的预设正则' : ''
        }`"
        allow-clear
        :size="size"
      >
        <template #prepend>
          <a-tooltip
            :content="reg ? '正则表达式正确' : '正则表达式有误'"
            position="bottom"
            mini
          >
            <icon-jam-triangle-danger-f
              class="text-[rgb(var(--orange-6))]"
              v-if="!reg"
            />
            <icon-mdi-hand-okay class="text-[rgb(var(--green-6))]" v-else />
          </a-tooltip>

          <span class="ml-5px">/</span>
        </template>
        <template #append>
          <span> /{{ reg?.flags || regFlag?.join('') }}</span>
        </template>
      </a-auto-complete>

      <a-popover title="选择修饰符" position="bl">
        <a-button :size="size" class="ml-10px">
          <template #icon>
            <icon-mdi-filter-multiple
              :class="regFlag.length > 0 ? 'text-[rgb(var(--arcoblue-6))]' : ''"
            />
          </template>
        </a-button>
        <template #content>
          <div class="min-w-200px">
            <a-checkbox-group
              :size="size"
              v-model="regFlag"
              direction="vertical"
            >
              <a-checkbox value="g"> -g:全局匹配 </a-checkbox>
              <a-checkbox value="i"> -i:忽略大小写</a-checkbox>
              <a-checkbox value="m"> -m:多行匹配</a-checkbox>
              <a-checkbox value="s"> -s:特殊字符. 包含换行符</a-checkbox>
            </a-checkbox-group>
          </div>
        </template>
      </a-popover>

      <a-tooltip
        :content="`点击${visualization ? '关闭' : '开启'}可视化解析`"
        position="bottom"
      >
        <a-button
          :size="size"
          class="ml-10px"
          @click="visualization = !visualization"
        >
          <template #icon>
            <icon-ic-sharp-visibility
              class="text-[rgb(var(--arcoblue-6))]"
              v-if="visualization"
            />
            <icon-ic-sharp-visibility-off v-else />
          </template>
        </a-button>
      </a-tooltip>

      <a-tooltip content="下载解析图片" position="bottom">
        <a-button
          :size="size"
          class="ml-10px"
          v-if="visualization && reg"
          @click="exportRegVisualizationToImg"
        >
          <template #icon>
            <icon-icon-park-outline-down-picture />
          </template>
        </a-button>
      </a-tooltip>

      <a-tooltip content="点击 copy 正则表达式" position="bottom">
        <a-button :size="size" class="ml-10px" v-if="reg" @click="regCopy">
          <template #icon>
            <icon-icon-park-solid-copy />
          </template>
        </a-button>
      </a-tooltip>
    </div>

    <div class="mt-20px bg-[var(--color-fill-2)]">
      <RegularVisualization
        ref="regVisualizationRef"
        v-if="visualization"
        v-model="reg"
        :mode="mode"
      />
    </div>

    <div class="w-full mt-20px">
      <a-textarea
        v-model="matchStr"
        default-value=""
        placeholder="请输入要匹配的字符串"
        :auto-size="{
          minRows: 3,
          maxRows: 6
        }"
      />

      <div
        class="w-full min-h-100px bg-[var(--color-fill-2)] whitespace-pre-wrap break-words indent-0 leading-22px mt-20px px-12px py-4px box-border"
        v-html="matchingFormat || '无匹配结果'"
      ></div>

      <div
        v-if="matchingResults.length > 0"
        class="w-full min-h-100px bg-[var(--color-fill-2)] whitespace-pre-wrap break-words indent-0 leading-22px mt-20px px-12px py-4px box-border"
      >
        <div>共 {{ matchingResults.length }} 处匹配:</div>
        <div v-for="(res, i) of matchingResults" :key="i">{{ res }}</div>
      </div>
    </div>
  </div>
</template>

<style>
.matching {
  @apply px-0px rounded-4px box-border;
}
.matching1 {
  @apply bg-[rgb(var(--arcoblue-2))];
}
.matching2 {
  @apply bg-[rgb(var(--green-2))];
}
.matching3 {
  @apply bg-[rgb(var(--orange-2))];
}
.matching4 {
  @apply bg-[rgb(var(--red-2))];
}
</style>

如果大家跟着写了的话,不知道大家有没有亲身体验到 ComponsitionAPI 的一个众所周知的小优点,上面 JS 代码中我把不同的小功能模块以空白行分开了,每个小模块用到的属性值、方法都在一块,不需要像 OptionsAPI 那样上下来回跳转去改东西。当然也可能没啥感觉,毕竟这个页面功能很少,有点简单,没事儿,后面还有机会。

其实到目前为止我们用了很多 VueUse 的方法,实在是因为它很香啊,目前我们还没有写过 hooks,当我们后面写写 hooks 之后,你再回头看看 VueUse 库的一些方法,就会体会到 ComponsitionAPIhooks 的精髓了。没事的话可以扒拉一下 VueUse 库,没用过看看文档熟悉下,用过的挑其中一些 hooks 的源码学习学习,会有很多收获,VueUse 中每个 hooks 的源码并不复杂也不多,适合入门学习。

增加路由跳转动画

我们现在路由跳转太生硬了,所以咱们使用 Vue 内置的 transition 组件来做个过渡动画。

官方文档:transition 组件

没用过的同学赶紧刷下文档,我们这里直接写了,修改可切换布局组件 DefaultLayoutSidebarLayout,找到 router-view 标签,修改如下:

<!-- 修改前 -->
<router-view v-slot="{ Component }">
  <component :is="Component" />
</router-view>

<!-- 修改后 -->
<router-view v-slot="{ Component }">
  <transition name="fade-x">
    <component :is="Component" />
  </transition>
</router-view>

注意,两个布局中都需要改。

transition 组件 name 属性我们设置成 fade-x,接下来写下进入离开的过渡样式,加个平移淡入就可以了。

这个过渡样式在两个组件中都可以用到,并且后面说不定哪里也能用到,我们给它写到公共样式中。

src/assets 文件夹下新增 css/index.css 文件,暂时先把公共样式写在这个文件里,如下:

/* 路由过度动画 */
.fade-x-enter-active {
  transition: all 0.3s ease-out;
}
.fade-x-leave-active {
  transition: all 0;
}
.fade-x-enter-from {
  transform: translateX(20px);
  opacity: 0;
}
.fade-x-leave-from {
  opacity: 0;
}

main.js 入口文件中引入下公共样式:

// 公共样式
import '@/assets/css/index.css'

// ...

OK,保存刷新页面,跳转下路由就可以看到切换路由时有个圆润的平移淡入过渡动画了。

部部部部署下

因为终于写到了一个功能页面,所以可以部署上去预览了!

没有部署教程,因为目前这就是一个静态页面,所以我这边直接使用 docker nginx 镜像部署了一个 Web 服务,其实啥都没有,直接 build 构建下,然后把构建好的静态资源扔上去就可以了,大家可以访问看看啥的,服务器不太行,稍微有点慢,自动化部署的教程得后面项目陆续变得复杂了再写。

哦,还没买域名,暂时就先用个二级域名吧!!!

预览地址:toolsdog.isboyjc.com

嗯。。。也还没来得及搞证书,So,不是 https 网站,所以碍于浏览器安全策略目前是不支持我们上面写的复制到剪切板的功能,回头我有空搞下证书就好了!

写在最后

嗯,就这样!

截止本文的代码已经打了 Tag 发布,可下载查看:

👉🏻 toolsdog tag v0.0.4-dev

👉🏻 项目 GitHub 地址

谢阅,如有错误请评论纠正,有什么疑问或者不理解的地方都可以私信咨询我,由于不经常写实战文章,也为了不同程度同学都可以看下去,文章可能稍微有些啰嗦,见谅,再次欢迎关注专栏 Vue3实战系列 ,点赞关注不迷路,回见 !

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!