小剧起始页,图标编辑组件的实现

时间:2022-2-1 作者:剧中人

近来小剧在开发起始页,尝试了很多新的知识,学习到了不少新的技能点。

如果你正在使用电脑端查看,建议你打开 小剧起始页,体验一番再回来看图标编辑组件的实现。

小剧起始页截图

今天就围绕图标编辑组件的实现,来聊一聊 v-model 的妙用、 TypeScript 定义特殊字符类型、 watch 动态创建与解除等方面的经验。

一、要解决什么问题?

图标类型示例

这是 小剧起始页 可自定义配置图标的桌面截图。如截图所示,图标有三种类型:

为了呈现上面的效果,需要在开发图标编辑组件功能之前,把数据结构设计和交互设计预先完成。

1.1、怎么设计数据结构?

在初步设计数据结构时,很自然的想到用两个字段来描述图标。例如 iconType、iconValue,分别是图标类型以及图标值。

然而把这两个数据和桌面数据结构放在一起时,为了见名知意被拉长的字段名甚是恶心,总觉得同一配置被拆分成两个字段相当别扭。

图标数据计划存储在浏览器缓存内,多出来的 Key、Value 所承载的数据量过少,而浏览器缓存空间又少的可怜,性价比过低。

增多的字段在图标的配置上是简单了,但是在跨组件参数传递、桌面图标数据存储、数据变更监听上需要增加更多冗余的设计。

一番思考无果后,恰好受到 HomeAssistant 图标配置的启发,发现可以借助同一个字段实现不同类型图标的内容配置。

为了满足前面提到的三种图标类型,小剧决定使用一个 icon 字段,替代 iconType、iconValue。并设计了以下图标字符规则:

1.2、交互怎么设计 ?

因为图标有三种类型,很自然的会想到使用下拉框实现类型切换。其中 mdi 图标、文本图标需要用户补充输入内容,因此还需要一个输入框。

虽然 mdi 图标已经很流行了,还是可能有人不知道它是什么,或者临时想找图标不知道去哪里找图标代码。因此需要提供一个在线的链接,方便随时查找自己需要的图标。

初步统计,图标编辑组件至少需要下拉框、输入框、链接,这三个元素才能满足需求。

第一版交互稿

第一版交互稿

通过第一版交互稿,已经可以满足图标配置的基本需求了,然而三个元素平铺的交互不够简洁,视觉上也较为零散。

而且在选择抓取图标的类型时,输入框需要隐藏或禁用,UI 界面抖动较为明显。

再加上 mdi 图标的说明链接,如果常驻的话有歧义且影响观感,切换时动态显示隐藏又会加剧 UI 的抖动。

基于以上考量,小剧又设计了第二版图标编辑组件。

为了解决视觉上的抖动问题,同时强化编辑组件整体性,小剧把三个元素拉平到了同一条水平线上。

最左侧是与编辑组件融为一体的下拉框,经过统计常用网站提升配置效率,将三类图标的顺序重新调整。

选择抓取图标类型时,因为不需要用户输入,替代显示“尝试自动抓取图标”提示文字。在保证视觉稳定的前提下给用户提示说明。

于是有了下面的交互设计稿。

第二版交互稿

二、怎样开发图标编辑组件 ?

因为是编辑组件,整个模块需要能够接收传递进来的图标值,而且在编辑过程中,需要实时将变更后的图标值传回父组件。

所以编辑组件需要能够在父级和自身之间,相互传递图标配置数据。

2.1、问题:Vue 是单向数据流

经常使用 Vue 开发的小伙伴应该知道,Vue 的数据模型是单向数据流。简而言之就是父级可以通过 props 改变子组件内的数据,反之则不行。

Vue 官方文档中是这样解释的:

所有的 prop 都使得其父子 prop 之间形成了一个单向下行绑定:父级 prop 的更新会向下流动到子组件中,但是反过来则不行。这样会防止从子组件意外改变父级组件的状态,从而导致你的应用的数据流向难以理解。

单纯为了实现图标编辑组件其实不难。编辑组件内部定义一个 prop,用来接收数据,在编辑后使用 $emit 通知父级触发更改。

相对应的,父级在引用编辑组件的时候传递参数,再定义事件监听用于接收数据变动,实现数据的双向流动。

需要这么麻烦么?

2.2、v-model 的妙用

可能部分眼尖的小伙伴已经发现,在一些自定义组件中,支持使用 .sync 的修饰符来方式实现双向绑定。

inputtextarea 之类的原生组件,借助于 v-model 也可以实现双向绑定。

如果你看过 Vue 的源码,或者写过复杂的 render 渲染函数可能会明白,v-model.sync 修饰符都只是语法糖。本质上还是前面提到的 prop$emit 组合实现的。

如果有学习过 Vue3 的话,会发现 v-model.sync 修饰符已经被统一成了同一个 API。

本质上虽然相同,使用上的差异还是很明显的, v-model 是那个相对不麻烦的数据传递方式。

最终促使小剧使用 v-model 实现图标编辑组件的数据通信,有以下三点原因:

1、因为是编辑组件,v-model 的设计可以与 UI 组件定义区分开

2、在语法糖的加持下,作为图标编辑组件的使用方,使用起来比 prop$emit 更简洁

3、图标编辑组件需要内嵌到表单中,v-model 更容易与传统表单组件、校验组件相结合

2.3、模块输入输出设计

为了将复杂的逻辑抽象到模块内部,便于使用方整合,达到高内聚低耦合的效果。需要将模块数据层设计梳理好。

在图标编辑组件字符结构设计部分,小剧将图标的类型和值合并为同一个字符串,因此整个模块的输入输出仅为单一参数。

为了使用方能够达到的易用效果,借助于 v-model 即可实现这一点。

至于交互设计里提到的下拉框、输入框之类的 UI 和交互,以及图标配置字符的拆分与合并,则全部需要吸纳到图标编辑组件内部。

下面的图标编辑组件源码分析部分,会详细分的解释这一点。

三、图标编辑组件源码分析

图标编辑组件的完整版,可以参考托管在 Github 上的源代码,下面就从几个关键环节描述一下实现过程。

图标编辑组件源码:https://github.com/bh-lay/…

接下来展示的代码全部基于 Vue3 的版本开发,使用 ts 编写。碎片化的代码并不能直接运行,感兴趣的话可以将整个项目 clone 下来。

3.1、图标类型定义

为了保证编码中的严谨,需要借助 TypeScript 校验图标内容的合法性。因为这种细粒度的类型定义是第一次尝试,研究之后发现竟然是可行的。

通过使用以下方式,即可定义图标类型。

// 书签图标类型
type BookmarkIconCrab = 'crab'
type BookmarkIconMdi = `mdi:${string}`
type BookmarkIconText = `text:${string}`

export type BookmarkIcon = BookmarkIconCrab | BookmarkIconMdi | BookmarkIconText

3.2、图标编辑组件数据转换

为了便于模块使用者操作数据,需要提供干净的裸字符串给父级。

同时又为了编辑组件内部不同的状态支持,便于用户编辑操作,需要将字符拆分为图标类型、用户编辑值。

基于上述的原因,第一步工作就是需要开发数据转换逻辑。

图标编辑组件数据转换逻辑

function decodeModelValue(iconConfig: BookmarkIcon) {
  const iconSplit = (iconConfig || '').split(':')
  if (iconSplit[0] === 'mdi') {
    return ['mdi', 'mdi-' + iconSplit[1]]
  } else if (iconConfig === 'crab') {
    return ['crab', '']
  }
  return ['text', iconSplit[1]]
}
function encodeModelValue(newIconType: string, inputValue: string): BookmarkIcon {
  if (newIconType === 'mdi') {
    const iconName = (inputValue || '').replace(/^mdi-/, '')
    // 经历过 replace 前后,值若不相等,则认为是合法的 mdi 配置
    if (inputValue !== iconName) {
      return `mdi:${iconName}`
    } else {
      // 否则,更正数据
      return 'mdi:'
    }
  } else if (newIconType === 'crab') {
    return 'crab'
  }
  return `text:${inputValue || ''}`
}

3.3、应用图标数据到视图

前面的图标编辑组件数据转换只是内存过程中的格式互换,用户看到的则是更直观的 UI 视图。

所以在数据流转过程中,视图的驱动更为重要。

// 从应用图标数据,更新编辑器视图
function applyBookmarkIcon(bookmarkIcon: BookmarkIcon) {
  // 从图标数据,获取图标类型,用户输入值
  const [ newIconType, newInputValue ] = decodeModelValue(bookmarkIcon)
  // 当图标类型和用户输入值相同时,不处理
  if (newIconType === iconType.value && newInputValue === inputValue.value) {
    return
  }
  // 更新数据
  iconType.value = newIconType
  inputValue.value = newInputValue
  iconTypeLabel.value = getIconTypeConfig(newIconType).label
}

3.4、父级数据监听

由于图标编辑组件特性所致,父级元素随时可能因为初始化、数据重置、条件关联等原因修改数据,故而子组件需要实时监听父级元素的参数变化。

这里为了避免父组件数据变化引起 UI 变动,导致被误判为用户操作,在应用图标数据前解除用户操作的监听,完成后再重新绑定。

// 监听父组件传入的数据变动
watch(
  () => props.modelValue,
  (newValue: BookmarkIcon) => {
    // 解除用户操作监听
    unWatchUserIntractive()
    // 更新编辑器数据
    applyBookmarkIcon(newValue)
    // 创建用户操作监听
    watchUserIntractive()
  },
  {
    immediate: true,
  }
)

3.5、用户交互监听

用户交互监听其实只有两个,一个是图标类型切换事件,另一个是用户输入行为。

当用户交互发生后,需要适当的做默认值填充、常见错误修正工作,然后应用到视图上,最后通知父级元素触发数据修改。

可能你会有疑问:这里的数据应用、变动监听,与父级传入参数的监听之间,不会形成死循环么?

其实最开始的确有,由于 Vue 在设置前后同值时,会静默此行为,不触发数据变动事件。因而死循环在执行数次后会自然停止。

彻底解决循环回路问题是通过以下两个手段。一是父级数据变动后,用户交互的监听增加了先解除再重设的操作。二是增加 applyBookmarkIcon 内的同值检测逻辑。

// 监听用户交互操作行为
function watchUserIntractive() {
  // 监听图标类型变化
  const unWatchIconType = watch(iconType, newIconType => {
    // 获取默认填充值
    let defaultInputValue = getIconTypeConfig(newIconType).default
    let newModelValue = encodeModelValue(newIconType, defaultInputValue)
    applyBookmarkIcon(newModelValue)
    context.emit('update:modelValue', newModelValue)
  })
  // 监听用户输入
  const unWatchInput = watch(inputValue, newInputValue => {
    // 避免粘贴时出现重复的 mdi-,尝试去删除
    if (newInputValue.match(/^mdi-mdi-/)) {
      newInputValue = newInputValue.replace(/^mdi-/, '')
    }
    const newModelValue = encodeModelValue(iconType.value, newInputValue)
    applyBookmarkIcon(newModelValue)
    context.emit('update:modelValue', newModelValue)
  })
  // 标记解除监听回调
  unWatchUserIntractive = () => {
    unWatchIconType()
    unWatchInput()
  }
}
// 解除用户交互操作行为监听
let unWatchUserIntractive = () => {
  // nothind
}

以上就是图标编辑组件核心逻辑。

可能你会发现 Vue3 的代码相较于 Vue2 中的 Options 形式的组织起来更为灵活。其实Vue2 里也有很多类似于 this.$watchthis.$on('hook:beforeDestroy' API,也能够实现灵活的逻辑组织。

只是它们依旧脱离不了 ViewModel 的上下文,并且没有像 Vue3 中一样,抽象出 setup 完成模块的组织。

四、为什么会写这篇文章

经过对一段时间面试情况的总结,发现近四成的同学对 v-model 的原理并不清楚,或者是理解其中的原理,但是没有实践经历。

这可能是对方基础知识不均衡的原因,也可能是我们的吸引力不够导致。

基于这个情况,原本计划写一篇关于 v-model.sync 实现双向通信的原理,Vue3 升级后的异同点,以及项目中常见的实例。

整理素材时发现,关于前两点,Vue2、Vue3 的文档里已经说的足够清楚,无需赘述。具体可以参考以下链接。

[Vue2] 自定义组件的 v-model

[Vue2] .sync 修饰符

[Vue3] v-model

至于项目中的实例,文档里的代码也已经足够与项目经历产生联想,并没有多大必要去做项目罗列。

倒不如拿自己项目中的一个小例子,从设计到开发理一遍,可能更有用。

最后,再次建议你打开 小剧起始页,体验一番。