头图

本文作者:GaoNeng

大家好啊,又是我GaoNeng。最近在给OpenTiny做贡献,感觉renderless这个架构还是挺有意思的,就贡献了一个color-picker组件,简单写篇文章稍微记录一下。

也欢迎朋友们给 TinyVue 开源项目点个 Star 🌟支持下:

https://github.com/opentiny/tiny-vue

阅读完本文,你将会获得如下收获

  • HSV,HSL,HEX,RGB的区别
  • HSV色彩空间下,SV到XY的双向转换
  • ColorPicker 组件的实现原理
  • OpenTiny 新组件开发全流程

1 事情的起因

故事的发生非常的偶然。我在翻opentiny仓库issue的时候,偶然看到了这么一条

图片

之前也在掘金上看过opentiny的介绍,感觉还不错,但是又抢不到组件。这一次终于让我抢到一个空闲组件了,于是我立刻就回复了。

图片

2 初步分析

一般写组件前只考虑两个问题

  1. 长什么样
  2. 逻辑是什么

color-picker颜色选择组件用于在应用程序和界面中让用户选择颜色。它是一个交互式的元素,通常由一个色彩光谱、色相环和颜色值输入框组成,用户可以通过这些元素来选择所需的颜色。ColorPicker的主要功能是让用户能够精确地选择特定的颜色,以便在应用程序的各种元素中使用。

组件效果如下:

图片

ColorPicker 组件主要包含四个子组件 饱和度选择色相选择alpha选择工具栏。比较简单,所以就没画图。主要的问题在于逻辑,也就是选择什么样的色彩空间更贴合用户的日常使用和直观体验。

常见的色彩空间分为 HSVHSLCMYCMYKHSB,RGBLABYUBYCrCb

前端最常见的应该是HSV,HSL,RGB这三种。LABYUBYCrCb在日常业务中比较少见。

3 色彩空间基础知识

HSV, HSL, HEX, RGB 都是什么呢?

HSV,HSL,RGB都是色彩空间。而HEX可以看作是RGB的另一种表达方法。

3.1 什么是色彩空间?

色彩空间是为了让人们更好的认识色彩而建立的一种抽象的数学模型,它是将数值分布在N维的坐标系中,帮助人们更好地认识和理解色彩。

例如RGB色彩空间,就是将RGB分量映射在三维笛卡尔坐标系中。分量的数量代表该分量的亮度值。下图是经过归一处理的RGB色彩空间示意图

图片

而HSV与HSL色彩空间都是将颜色映射到了柱坐标系。下图展示了HSV与HSL的示意图

图片

HSV

图片

HSL

3.2 HSV,HSL,RGB 孰优孰劣?

了解了HSV,HSL,RGB色彩空间及其表达方法,我们需要考虑究竟哪一种色彩空间对于人类更加的直观呢?要不问问万能的音理吧

图片

啊这,她说不知道。那看来只能问问万能的chat-gpt

图片

不愧是你,chatgpt总是能救我于危难之间。不过话又说回来,HSL与HSV都很直观,只是一个是V(Value)另一个是L(lightness)。两种色彩空间的柱坐标系如下图所示

图片

HSV

图片

HSL

可以看到,HSV越偏向右上角饱和度和亮度越高。但HSL则是偏向于截面的中间饱和度和亮度越高。

在PS和其他软件中,也大都选择了HSV作为选色时的色彩空间。为了保持统一,color-picker组件也选择了HSV作为选色时的色彩空间。

3.3 SV与XY的双向转换

饱和度选择的时候,我们需要将XY分量转为SV分量。这存在一种表达方式。SV与XY存在一种计算关系

图片

其中width与height均为容器的宽度和高度, XY为光标位置。

4 组件设计

和普通组件开发不同,tinyvue是将逻辑抽离到了renderless下。这样做可以让开发者更着重于逻辑的编写。单测也更好测,测试的时候如果你想,可以只测renderless和被抽象的逻辑,UI层面甚至可以不测(因为UI主要是各个库来做渲染和依赖跟踪,单测是最小的可测试单元,所以库可以mock掉,只测renderless)。

一个完整的组件至少要有以下几个要素

  • 组件
    • UI
    • 逻辑
    • 类型
  • 文档
    • 中文
    • 英文
  • 测试
    • 单测
    • E2E测试

4.1 目录梳理

tiny-vue 简化目录如下所示. 带有!前缀的文件表示必选,?前缀的文件表示可选

例如!index.js表示index.js是必选的。

examples
    docs
    public
    sites
        <mpt> app
            ![component-name]
                !webdoc
                    ![component-name].cn.md // 中文文档
                    ![component-name].cn.md //英文文档
                    ![component-name].js // 组件文档配置
                ![demo].vue //示例文件
                ?[demo].spec.ts //示例的e2e测试
        overviewimage //图标
        resource
        webdoc //对应使用指南
        config.js
        !menu.js // 目录文件,需要在此追加你的组件
packages
    renderless
        src
            ![component-name]
                ?[component-name]
                    vue.ts
                    index.ts //函数抽象的地方
                vue.ts
                index.ts //函数抽象的地方
    theme // 桌面端样式
        src
            ?[component-name] // 有些组件不一定需要样式(例如: config-provider)
                index.less // 样式
                vars.less // 变量声明
    theme-mobile // 移动端样式
        src
            ?[component-name]
                index.less // 样式
                vars.less // 变量声明
    vue
        src
            ![component-name]
                !__tests__
                    ![component-name].spec.vue // 至少要有一个单元测试文件
                src
                    pc.vue // 桌面端模板
                    ?mobile.vue // 移动端模板,如果你的组件不需要移动端那么可以删除
                index.ts // 组件导出
                package.json

4.2 模块设计

tiny-vue下输入 pnpm create:ui color-picker 就可以创建最基本的模板了。

color-picker 组件主要分为以下几个部分。因为时间原因,在这里只讲解triggertools

  • trigger
  • color-select
  • sv-select
  • hue-select
  • alpha-select
  • tools

他们的层级关系是这样的

trigger
    color-select
        sv-select
        hue-select
    alpha-select
    tools

4.3 Props 定义

开发组件,我习惯先思考入参和事件。入参我是设计这样的

javascript

{
    modelValue: String, // 默认颜色,不存在即为transparent
    visible: Boolean, // 默认color-select是否可见
    alpha: Boolean // 是否启用alpha选择
}

事件则是

javascript

{
    confirm: (hex: string)=>void, // 当用户点击confirm时,返回选择的颜色
    cancel: ()=>void // 当用户点击取消或除了color-select子代的dom元素时,触发的事件
}

设计完成后,我们就可以开始开发了

5 组件开发

trigger是ColorPicker组件的关键模块,主要控制color-selectalpha-selecttools的显示状态。

5.1 组件模板开发

我们先来描述一下trigger的状态都有哪些

图片

理顺清楚状态后,我们终于可以开始写第一行代码了

html

<!-- packages/vue/color-picker/src/vue.pc -->
<template>
  <div class="tiny-color-picker__trigger" v-clickoutside="onCancel" @click="() => changeVisible(!state.isShow)">
    <div
      class="tiny-color-picker__inner" :style="{
        background: state.triggerBg ?? ''
      }"
    >
      <IconChevronDown />
    </div>
  </div>
  <div style="width: 200px;height: 200px;background: #66ccff;" v-if="state.isShow"></div>
</template>

<script>
import { renderless, api } from '@opentiny/vue-renderless/color-picker/vue'
import { props, setup, defineComponent, directive } from '@opentiny/vue-common'
import { IconChevronDown } from '@opentiny/vue-icon'
import Clickoutside from '@opentiny/vue-renderless/common/deps/clickoutside'
export default defineComponent({
  emits: ['update:modelValue', 'confirm', 'cancel'],
  props: [...props, 'modelValue', 'visible', 'alpha'],
  components: {
    IconChevronDown: IconChevronDown(),
  },
  directives: directive({ Clickoutside }),
  setup(props, context) {
    return setup({ props, context, renderless, api })
  }
})
</script>

写完上述代码之后,我们将会获得一个没有交互逻辑的空壳。但是,先别着急,我们继续写下去

5.2 组件逻辑开发

TinyVue主打一个关注点分离,所以这里简单介绍一下renderless的大概框架

typescript

export const api = [] // 允许暴露出去的api

export const renderless = (
  props, //组件的props
  context, // hooks
  { emit } // nextTick、attr……
): Record<string,any> => {
    const api = {};
    return api;
}

现在我们来补充逻辑

typescript

// renderless/src/color-picker/index.ts
import type {Ref} from 'vue';
export const onCancel = (isShow: Ref<boolean>, emit) => {
    return ()=>{
        if (isShow.value){
            emit('cancel')
        }
        isShow.value = false
    }
}
// renderless/src/color-picker/vue.ts
export const api = ['state', 'onCancel'];
export const renderless = (
  props,
  context,
  { emit }
): Record<string,any> => {
    const { modelValue, visible } = context.toRefs(props)
    const isShow = context.ref(visible?.value ?? false)
    const triggerBg = context.ref(modelValue.value ?? 'transparent');
    context.watch(visible, (visible) => {
        isShow.value = visible
    })
    const state = {
        triggerBg,
        isShow
    }
    const api = {
        state,
        onCancel: onCancel(isShow, emit)
    }
    return api;
}

补全上述代码后,运行pnpm run dev打开http://localhost:7130/,我们会发现在侧边无法搜索到自己的组件。这是因为menu.js下没有我们的组件,现在我们要开始编写文档

5.3 组件文档

打开 tiny-vue/examples/sites/demos/menus.js 找到 cmpMenus 变量。color-picker应该是算作表单组件,所以我们需要在表单组件的children字段下新增我们的组件

diff

  {
    'label': '表单组件',
    'labelEn': 'Form Components',
    'key': 'cmp_form_components',
    'children': [
      { 'nameCn': '自动完成', 'name': 'Autocomplete', 'key': 'autocomplete' },
        ...
+     { 'nameCn': '颜色选择器', 'name': 'ColorPicker', 'key': 'color-picker' }
    ]
  },

之后,我们要在demos/app下,新建color-picker文件夹。目录要求如下

![component-name]
    !webdoc
        ![component-name].cn.md // 中文文档
        ![component-name].cn.md //英文文档
        ![component-name].js // 组件文档配置
    ![demo].vue //示例文件
    ?[demo].spec.ts //示例的e2e测试

[component-name].js 该文件主要用于阐述组件props,event,slots等信息。

typescript

export default {
  demos: [
    {
      'demoId': 'demo-id',
      'name': { 'zh-CN': '中文名', 'en-US': '英文名' },
      'desc': { 'zh-CN': '中文介绍', 'en-US': '英文介绍' },
      'codeFiles': ['base.vue']
    }
  ],
  apis: [
    {
      'name': '组件名',
      'type': '组件/指令/其他',
      'properties': [
        {
          'name': '名称',
          'type': '类型',
          'defaultValue': '默认值',
          desc: {
            'zh-CN': '中文介绍',
            'en-US': '英文介绍'
          },
          demoId: 'demo示例'
        },
      ],
      'events': [
        {
          name: '事件名',
          type: '事件类型',
          defaultValue: '默认值',
          desc: {
            'zh-CN': '中文简述',
            'en-US': '英文简述'
          },
          demoId: 'demo示例'
        },
      ],
      'slots': [
        {
          'name': '插槽名',
          'type': '类型',
          'defaultValue': '默认值',
          'desc': { 'zh-CN': '中文简述', 'en-US': '英文简述' },
          'demoId': 'demo跳转'
        }
      ]
    }
  ]
}

现在我们来补充示例

html

<!-- tiny-vue/examples/sites/demos/app/color-picker/base.vue -->
<template>
  <div>
    <tiny-color-picker v-model="color" />
  </div>
</template>

<script lang="jsx">
import {ColorPicker} from '@opentiny/vue';
export default {
  components: {
    TinyColorPicker: ColorPicker
  }
}
</script>

之后,我们运行pnpm dev,打开浏览器http://localhost:7130/pc/color-picker/basic-usage后就可以看到一个刚刚写的示例了

图片

目前还比较简陋,我们可以加入一点样式

5.4 主题变量

因为要适配多套主题,所以我们先来引用一下变量。更多的变量可以在 tiny-vue/packages/theme/src/vars.less 中找到

less

// tiny-vue/packages/theme/src/color-picker/vars.less
.component-css-vars-colorpicker() {
  --ti-color-picker-background: var(--ti-common-color-transparent);
  --ti-color-picker-border-color: var(--ti-base-color-common-2);
  --ti-color-picker-border-weight: var(--ti-common-border-weight-normal);
  --ti-color-picker-border-radius-sm: var(--ti-common-border-radius-1);
  --ti-color-picker-spacing: var(--ti-common-space-base);
}

之后我们就可以愉快的开始写样式了,样式统一都写在 tiny-vue/packages/theme/src/<component-name>/index.less 中,如果单个样式文件过大可以考虑拆分,最好按照 tiny-vue/packages/theme/src/<component-name>/<child-component-name>.less 来进行拆分。color-picker样式不算太大,所以就没做拆分。

less

// tiny-vue/packages/theme/src/color-picker/index.less
@import '../custom.less';
@import './vars.less';
@colorPickerPrefix: ~'@{css-prefix}color-picker';

.@{colorPickerPrefix} {
  .component-css-vars-colorpicker();

  &__trigger {
        position: relative;
        width: 32px;
        height: 32px;
        border-radius: var(--ti-color-picker-border-radius-sm);
        border: var(--ti-color-picker-border-weight) solid var(--ti-color-picker-border-color);
        box-sizing: content-box;
        padding: var(--ti-color-picker-spacing);
        cursor: pointer;

        .@{colorPickerPrefix}__inner {
            display: flex;
            width: 100%;
            height: 100%;
            align-items: center;
            justify-content: center;
            border-radius: var(--ti-color-picker-border-radius-sm);
            background: var(--ti-color-picker-background);
        }
    }
}

图片

但是目前这样就可以了么?还不行,TinyVue自己做了一套适配层,组件开发时不允许导入Vue,这意味着我们需要自己来写类型

5.4 类型声明

因为我们这里只需要Ref,所以写起来很简单。

typescript

// tiny-vue/packages/renderless/types/color-picker.type.ts
export type IColorPickerRef<T> = {value: T}
diff

// tiny-vue/packages/renderless/types/index.ts
export * from './year-table.type'
+export * from './color-picker.type'

之后修改renderless/color-picker/index.ts即可

diff

-import type {Ref} from 'vue';
+import {IColorPickerRef as Ref} from '@/types';

5.6 国际化

我们的组件需要进行i18n的处理。因为需要用户自己手动点击确认按钮来确认颜色。但并不是所有用户都是中国人,所以我们要进行i18n的适配。现在我们回到pc.vue增加如下

diff

<template>
  <div class="tiny-color-picker__trigger" v-clickoutside="onCancel" @click="() => changeVisible(!state.isShow)">
    <div
      class="tiny-color-picker__inner" :style="{
        background: state.triggerBg ?? ''
      }"
    >
      <IconChevronDown />
    </div>
+   <Transition name="tiny-zoom-in-top">
+     <div class="tiny-color-picker__wrapper" @click.stop v-if="state.isShow">
+       <color-select
+         @hue-update="onHueUpdate"
+         @sv-update="onSVUpdate"
+         :color="state.hex"
+       />
+       <alpha-select :color="state.res" @alpha-update="onAlphaUpdate" v-if="alpha" />
+       <div class="tiny-color-picker__wrapper__tools">
+         <tiny-input v-model="state.res" />
+         <tiny-button-group>
+           <tiny-button type="text" @click="onCancel">
+             {{ t('ui.colorPicker.cancel') }}
+           </tiny-button>
+           <tiny-button @click="onConfirm">
+             {{ t('ui.colorPicker.confirm') }}
+           </tiny-button>
+         </tiny-button-group>
+       </div>
+     </div>
+   </Transition>
  </div>
</template>
<script>
+import { t } from '@opentiny/vue-locale'
...

sv-selecthue-selectalpha-select因为时间原因不做介绍,有兴趣可以自行前往TinyVue仓库观看。

增加完上述代码后,我们需要前往vue-locale/src/lang/zh-CN.ts文件和vue-locale/src/lang/en-US.ts文件添加字段。因为篇幅原因,在这里省略了其他组建的i18字段

typescript

export default {
  ui: {
    colorPicker:{
        cancel: '',
        confirm: ''
    }
  }

补充好zh-CN与en-US后,再次返回文档,就可以看到完整的组件了(下图未开启alpha选择)

图片

总结

本文主要介绍了HSV,HSL,RGB色彩空间及其数学表达方法,并分析了SV与二维XY的互相转换原理,最后以 ColorPicker 组件为例子,总结了 tiny-vue 组件开发的流程。

主要包含:

  1. 组件模块设计
  2. 组件 API 定义
  3. 组件模板和逻辑开发
  4. 组件文档编写
  5. 主题变量
  6. 类型声明
  7. 组件的国际化

如有错漏之处,还望斧正。

OpenTiny 社区招募贡献者啦

OpenTiny Vue 正在招募社区贡献者,欢迎加入我们🎉

你可以通过以下方式参与贡献:

  • 在 issue 列表中选择自己喜欢的任务
  • 阅读贡献者指南,开始参与贡献

你可以根据自己的喜好认领以下类型的任务:

  • 编写单元测试
  • 修复组件缺陷
  • 为组件添加新特性
  • 完善组件的文档

如何贡献单元测试:

  • packages/vue目录下搜索it.todo关键字,找到待补充的单元测试
  • 按照以上指南编写组件单元测试
  • 执行单个组件的单元测试:pnpm test:unit3 button

如果你是一位经验丰富的开发者,想接受一些有挑战的任务,可以考虑以下任务:

  • ✨ [Feature]: 希望提供 Skeleton 骨架屏组件
  • ✨ [Feature]: 希望提供 Divider 分割线组件
  • ✨ [Feature]: tree树形控件能增加虚拟滚动功能
  • ✨ [Feature]: 增加视频播放组件
  • ✨ [Feature]: 增加思维导图组件
  • ✨ [Feature]: 添加类似飞书的多维表格组件
  • ✨ [Feature]: 添加到 unplugin-vue-components
  • ✨ [Feature]: 兼容formily

参与 OpenTiny 开源社区贡献,你将收获:

直接的价值:

  1. 通过参与一个实际的跨端、跨框架组件库项目,学习最新的Vite+Vue3+TypeScript+Vitest技术
  2. 学习从 0 到 1 搭建一个自己的组件库的整套流程和方法论,包括组件库工程化、组件的设计和开发等
  3. 为自己的简历和职业生涯添彩,参与过优秀的开源项目,这本身就是受面试官青睐的亮点
  4. 结识一群优秀的、热爱学习、热爱开源的小伙伴,大家一起打造一个伟大的产品

长远的价值:

  1. 打造个人品牌,提升个人影响力
  2. 培养良好的编码习惯
  3. 获得华为云 OpenTiny 团队的荣誉和定制小礼物
  4. 受邀参加各类技术大会
  5. 成为 PMC 和 Committer 之后还能参与 OpenTiny 整个开源生态的决策和长远规划,培养自己的管理和规划能力
  6. 未来有更多机会和可能

关于 OpenTiny

OpenTiny 是一套企业级组件库解决方案,适配 PC 端 / 移动端等多端,涵盖 Vue2 / Vue3 / Angular 多技术栈,拥有主题配置系统 / 中后台模板 / CLI 命令行等效率提升工具,可帮助开发者高效开发 Web 应用。

核心亮点:

  1. 跨端跨框架:使用 Renderless 无渲染组件设计架构,实现了一套代码同时支持 Vue2 / Vue3,PC / Mobile 端,并支持函数级别的逻辑定制和全模板替换,灵活性好、二次开发能力强。
  2. 组件丰富:PC 端有100+组件,移动端有30+组件,包含高频组件 Table、Tree、Select 等,内置虚拟滚动,保证大数据场景下的流畅体验,除了业界常见组件之外,我们还提供了一些独有的特色组件,如:Split 面板分割器、IpAddress IP地址输入框、Calendar 日历、Crop 图片裁切等
  3. 配置式组件:组件支持模板式和配置式两种使用方式,适合低代码平台,目前团队已经将 OpenTiny 集成到内部的低代码平台,针对低码平台做了大量优化
  4. 周边生态齐全:提供了基于 Angular + TypeScript 的 TinyNG 组件库,提供包含 10+ 实用功能、20+ 典型页面的 TinyPro 中后台模板,提供覆盖前端开发全流程的 TinyCLI 工程化工具,提供强大的在线主题配置平台 TinyTheme

联系我们:

更多视频内容也可以关注OpenTiny社区,B站/抖音/小红书/视频号。


OpenTiny社区
58 声望10 粉丝

OpenTiny官网:[链接] 源码:[链接] (欢迎star)我们是华为云体验技术团队~欢迎关注共同交流~