• GaoNeng:我是如何为OpenTiny贡献新组件的?


    本文共10076字,预计阅读20分钟

    大家好啊,又是我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选择, 工具栏。比较简单,所以就没画图。主要的问题在于逻辑,也就是选择什么样的色彩空间更贴合用户的日常使用和直观体验。

    常见的色彩空间分为 HSV, HSL, CMY, CMYK, HSB,RGB, LAB, YUB, YCrCb

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

    3 色彩空间基础知识

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

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

    3.1 什么是色彩空间?

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

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

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

    HSV

    HSV

    HSL

    HSL

    3.2 HSV,HSL,RGB 孰优孰劣?

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

    让音理告诉你吧

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

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

    HSV

    HSV

    HSL

    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
             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 定义

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

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

    事件则是

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

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

    5 组件开发

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

    5.1 组件模板开发

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

    color-select state
    true
    false
    trigger
    outside
    visible
    click
    show
    hidden
    color-select state

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

    
    <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的大概框架

    export const api = [] // 允许暴露出去的api
    
    export const renderless = (
      props, //组件的props
      context, // hooks
      { emit } // nextTick、attr……
    ): Record<string,any> => {
        const api = {};
        return api;
    }
    

    现在我们来补充逻辑

    // 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字段下新增我们的组件

      {
        '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等信息。

    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跳转'
            }
          ]
        }
      ]
    }
    

    现在我们来补充示例

    
    <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后就可以看到一个刚刚写的示例了

    ColorPicker效果

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

    5.4 主题变量

    因为要适配多套主题,所以我们先来引用一下变量。更多的变量可以在 tiny-vue/packages/theme/src/vars.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//index.less 中,如果单个样式文件过大可以考虑拆分,最好按照 tiny-vue/packages/theme/src//.less 来进行拆分。color-picker样式不算太大,所以就没做拆分。

    // 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,所以写起来很简单。

    // tiny-vue/packages/renderless/types/color-picker.type.ts
    export type IColorPickerRef = {value: T}
    
    // tiny-vue/packages/renderless/types/index.ts
    export * from './year-table.type'
    +export * from './color-picker.type'
    

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

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

    5.6 国际化

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