留神:为了让篇幅尽可能简洁一丢丢,在有些中央贴源码时,我尽可能贴最能反映要解说内容的源码,其余重复性的代码就略去了,所以如果你本人尝试去浏览源码时,可能会发现和文章里的代码有出入。文章跑通 Naive UI 所用到的源码仓库为:https://github.com/pftom/naive-app

简洁的形象

前端开发者当初简直曾经离不开 UI 组件库了,典型的如 Ant Design、Material Design、以及最近 Vue 生态衰亡的 Naive UI 等,组件库提供了简略、灵便、易用的应用模式,如一个页面中最常见的 Button 的应用如下:

<template>  <n-button>Default</n-button>  <n-button type="primary">Default</n-button>  <n-button type="info" dashed>Default</n-button>  <n-button type="success" dashed size="small">Default</n-button>  <n-button text>Default</n-button>  <n-button text tag="a" href="https://anyway.fm/news.php" type="warning"    >安妮薇时报</n-button  >  <n-button disabled> 不许点 </n-button></template>

上述几行简略的代码就能够实现如下有意思的成果:

甚至是,能够一键切换皮肤,如 Dark Mode:

当然还能够处理事件、增加 Icon、解决 Loading 等,通过简略给定一些 Props,咱们就能够领有一个难看、实用的 Button,相比原始的 HTML 标签来说,切实是不可同日而语...

冰山实践

组件库在带来灵便、不便的同时,其外部的原理却并非如它应用般简略,就像上述的冰山图一样引人深思。

让咱们翻一翻最近的 Vue 组件库新秀 Naive UI 的 CHANGELOG,就能够窥见编写一个入门的组件库大抵须要多少工夫:

能够看到,2020-03-21 就公布了 1.x 版本,而在 1.x 之前又是漫长的思考、设计与开发,至今应该差不多两年无余。

而为了跑通一个 Naive UI 的 Button,大抵须要如下的文件或代码:

.|_____utils| |____color| | |____index.js| |____vue| | |____index.js| | |____flatten.js| | |____call.js| | |____get-slot.js| |____index.js| |____naive| | |____warn.js| | |____index.js| |____cssr| | |____create-key.js| | |____index.js|_____internal| |____loading| | |____index.js| | |____src| | | |____Loading.jsx| | | |____styles| | | | |____index.cssr.js| |____index.js| |____icon-switch-transition| | |____index.js| | |____src| | | |____IconSwitchTransition.jsx| |____fade-in-expand-transition| | |____index.js| | |____src| | | |____FadeInExpandTransition.jsx| |____wave| | |____index.js| | |____src| | | |____Wave.jsx| | | |____styles| | | | |____index.cssr.js| |____icon| | |____index.js| | |____src| | | |____Icon.jsx| | | |____styles| | | | |____index.cssr.js|_____styles| |____common| | |_____common.js| | |____light.js| | |____index.js| |____transitions| | |____fade-in-width-expand.cssr.js| | |____icon-switch.cssr.js| |____global| | |____index.cssr.js|____config-provider| |____src| | |____ConfigProvider.js|____button| |____styles| | |_____common.js| | |____light.js| | |____index.js| |____src| | |____Button.jsx| | |____styles| | | |____button.cssr.js|____assets| |____logo.png|_____mixins| |____use-style.js| |____use-theme.js| |____index.js| |____use-form-item.js| |____use-config.js

看似艰难的背地

尽管跑通一个看似简略的 <Button /> 背地须要大量的工作,波及到几十个文件的依赖,但对于一个组件库来说,复杂度是量级近似的,即从一个简略的 <Button /> 到一个简单的 <Form /> ,其实在组件库的畛域内,90% 的内容是类似的,所以如果搞懂了 <Button /> 的运行流程,那么根本能够说搞懂了组件库近 90% 的内容,剩下的 10% 则是具体组件的具体实现。

所以理解一个前端组件库最外围还是须要弄懂一个 <Button /> 跑通背地所须要的各种筹备工作,也就是上图中的第一根高柱,而开发一个组件库首先也应该专一于设计让至多一个 Button 跑通的计划。

Button 背地的技术链

咱们以 Naive UI 为钻研对象,来具体分析其 <Button /> 实现背地的各种原理,起因有比拟直观的 2 点:

  1. 其技术栈以 Vite 、Vue3、TypeScript 为主,合乎笔者最近的技术栈
  2. 相比其余组件库而言,其在成熟度、知名度和代码优良层面都处于一个绝对折中的程度,不太简单但又波及绝对比拟多的常识,比拟适宜学习和钻研其原理

从模板登程

想理解一个组件,第一件事件当然是理解它的骨架了,也就是咱们常说的 HTML/JSX 相干内容了,首先看一下 Naive UI 的 Button 组件的模板:

const Button = defineComponent({  name: 'Button',  props: {},  setup(props) {},  render() {    // 第一局部    // n    const { $slots, mergedClsPrefix, tag: Component } = this;    const children = flatten(getSlot(this));    return (      <Component         ref="selfRef"        // 第二局部        class={[          `${mergedClsPrefix}-button`,          `${mergedClsPrefix}-button--${this.type}-type`,          {            [`${mergedClsPrefix}-button--disabled`]: this.disabled,            [`${mergedClsPrefix}-button--block`]: this.block,            [`${mergedClsPrefix}-button--pressed`]: this.enterPressed,            [`${mergedClsPrefix}-button--dashed`]: !this.text && this.dashed,            [`${mergedClsPrefix}-button--color`]: this.color,            [`${mergedClsPrefix}-button--ghost`]: this.ghost, // required for button group border collapse          },        ]}        tabindex={this.mergedFocusable ? 0 : -1}        type={this.attrType}        style={this.cssVars}        disabled={this.disabled}        onClick={this.handleClick}        onBlur={this.handleBlur}        onMousedown={this.handleMouseDown}        onKeyup={this.handleKeyUp}        onKeydown={this.handleKeyDown}        >        // 第三局部        {$slots.default && this.iconPlacement === "right" ? (          <div class={`${mergedClsPrefix}-button__content`}>{children}</div>        ) : null}        // 第四局部        <NFadeInExpandTransition width>          {{            default: () =>              $slots.icon || this.loading ? (                <span                  class={`${mergedClsPrefix}-button__icon`}                  style={{                    margin: !$slots.default ? 0 : "",                  }}                >                  <NIconSwitchTransition>                    {{                      default: () =>                        this.loading ? (                          <NBaseLoading                            clsPrefix={mergedClsPrefix}                            key="loading"                            class={`${mergedClsPrefix}-icon-slot`}                            strokeWidth={20}                          />                        ) : (                          <div                            key="icon"                            class={`${mergedClsPrefix}-icon-slot`}                            role="none"                          >                            {renderSlot($slots, "icon")}                          </div>                        ),                    }}                  </NIconSwitchTransition>                </span>              ) : null,          }}        </NFadeInExpandTransition>        // 第三局部        {$slots.default && this.iconPlacement === "left" ? (          <span class={`${mergedClsPrefix}-button__content`}>{children}</span>        ) : null}        // 第五局部        {!this.text ? (          <NBaseWave ref="waveRef" clsPrefix={mergedClsPrefix} />        ) : null}        // 第六局部        {this.showBorder ? (          <div            aria-hidden            class={`${mergedClsPrefix}-button__border`}            style={this.customColorCssVars}          />        ) : null}        // 第六局部        {this.showBorder ? (          <div            aria-hidden            class={`${mergedClsPrefix}-button__state-border`}            style={this.customColorCssVars}          />        ) : null}    </Component>    )  }});

能够看到,上述的次要展现出了 <Button /> 组件的模板局部,基于 Vue3 的 defineComponent 来定义组件,基于 render 办法应用 JSX 的模式来编写模板,其中模板局部又次要分为 6 局部,在代码中以正文的形式标注出:

  1. 次要是取属性相干,次要有三个属性:$slotsmergedClsPrefixtag ,其中 $slots 在 Vue 畛域内相似孩子节点所属的对象,mergedClsPrefix 则为整个组件库的命名空间前缀,在 Naive UI 中这个前缀为 ntag 则示意此组件应该以什么样的标签进行展现,默认是 <button /> ,你也能够换成 <a /> ,让按钮长得像一个链接

  1. 次要是定义 Button 相干的属性:

    1. 其中 class 则依据传进来的属性来断定属于哪种 typeprimaryinfowarningsuccesserror ,以及以后处于什么状态:disabledblockpresseddashedcolorghost ,依据这些 type 和状态给予适合的类名,从而为组件定义对应类名所属的 CSS 款式
    2. tabIndex 则示意在应用 tab 键时,此按钮是否会被选中,0 示意可被选中,-1 示意不可选中 ;
    3. type 则示意为 buttonsubmitreset 等按钮类型,使得按钮能够被整合进 <Form /> 组件来实现更加简单的操作,如表单提交的触发等;
    4. style 则是为此组件传入所需的 CSS Variables,即 CSS变量,而在 setup 函数时,会通过 useTheme (后续谈判到)钩子去挂载 Button 相干的款式,这些款式中大量应用 CSS Variables 来自定义组件各种 CSS 属性,以及解决全局的主题切换,如 Dark Mode 等
    5. disabled 则是管制此按钮是否可操作,true 代表被禁用,不可操作,false 代表可操作为默认值
    6. 剩下的则是相干的事件处理函数:clickblurmouseupkeyupkeydown

  1. 次要是决定在 iconPlacementleftright 时,组件孩子节点的展现模式,即图标在左和右时,孩子节点散布以 <span /><div /> 标签的模式展现,当为 right 时,设置为 <div /> 则是为了更好的解决布局与定位

  1. 为图标相干内容,NFadeInExpandTransition 为管制 Icon 呈现和隐没的过渡动画,NIconSwitchTransition 则是管制 loading 模式的 Icon 和其余 Icon 的切换过渡动画

  1. 当按钮不以 text 节点的模式展现时,其上应该有解决反馈的波纹,通过上述视频也能够看到在点按钮时会有对应的波纹成果来给出点击反馈,如下图展现为类文本模式,在点击时就不能呈现波纹扩散成果

  1. 次要是通过 <div /> 去模仿组件的边框:borderstate-border ,前者次要动态、默认的解决边框色彩、宽度等,后者则是解决在不同状态下:focushoveractivepressed 等下的 border 款式

能够通过一个理论的例子看一下这两者所起的作用:

.n-button .n-button__border {    border: var(--border);}.n-button .n-button__border, .n-button .n-button__state-border {    position: absolute;    left: 0;    top: 0;    right: 0;    bottom: 0;    border-radius: inherit;    transition: border-color .3s var(--bezier);    pointer-events: none;}.n-button:not(.n-button--disabled):hover .n-button__state-border {    border: var(--border-hover);}.n-button:not(.n-button--disabled):pressed .n-button__state-border {    border: var(--border-pressed);}style attribute {    --bezier: ;    --bezier-ease-out: ;    --border: 1px  ;    --border-hover: 1px  ;    --border-pressed: 1px  ;    --border-focus: 1px  ;    --border-disabled: 1px  ;}

能够看到 state-border 次要是解决一些会动态变化的成果,如在 hoverpressed 等状态下的边框展现成果,而 border 则负责初始默认的成果。

理解了次要模板相干的内容之后,你可能对在解说整个模板中呈现频度最高的一个内容示意纳闷,即:

  • ${mergedClsPrefix}-button
  • ${mergedClsPrefix}-button--${`this`.type}-type
  • ${mergedClsPrefix}-button__content
  • ${mergedClsPrefix}-button--disabled

为什么会有这么奇怪的 CSS 类写法?以及在给组件赋值属性时:

  • style`={this.cssVars}`

一个典型的例子为:

const cssVars = {  // default type  color: "#0000",  colorHover: "#0000",  colorPressed: "#0000",  colorFocus: "#0000",  colorDisabled: "#0000",  textColor: textColor2,}

为什么须要赋值一堆的 CSS Variables?

如果你对这几个问题纳闷不解,并想探究其背地的原理,那么此时你应该舒一口气,而后放弃专一持续阅读文章下一部分内容:款式的组织艺术。

款式的组织艺术

在组件库这个畛域,绝大部分工夫都花在如何更好的、更加自定义的组织整体的款式零碎。

而 Naive UI 这个框架有个有意思的个性,它不应用任何预处理、后处理款式语言如 Less/Sass/PostCSS 等,而是自造了为框架而生、且框架无关、带 SSR 个性的类 CSS in JS 的计划:css-render,并给予这个方案设计了一套插件零碎,目前次要有两个插件:

  • vue3-ssr
  • plugin-bem

本文中次要专一于 CSR 方面的解说,所以只会关注 plugin-bem 相干的内容。

css-render 目前的根本应用场景为搭配 plugin-bem 插件应用,编写基于 BEM 格调的、易于组织的类 CSS in JS 代码,至于这里为什么说是 “类” CSS in JS 解决方案,后续会进行解说。

当咱们装置了对应的包之后:

$ npm install --save-dev css-render @css-render/plugin-bem

能够依照如下模式来应用:

import CssRender from 'css-render'import bem from '@css-render/plugin-bem'const cssr = CssRender()const plugin = bem({  blockPrefix: '.ggl-'})cssr.use(plugin) // 为 cssr 注册 bem 插件const { cB, cE, cM } = pluginconst style = cB(  'container',  [    cE(      'left, right',       {        width: '50%'      }    ),    cM(      'dark',       [        cE(          'left, right',          {            backgroundColor: 'black'          }        )      ]    )  ])// 查看渲染的 CSS 款式字符串console.log(style.render())// 将款式挂载到 head 标签外面,能够提供 optionsstyle.mount(/* options */)// 删除挂载的款式style.unmount(/* options */)

上述的 Log 的成果如下:

.ggl-container .ggl-container__left, .ggl-container .ggl-container__right {  width: 50%;}.ggl-container.ggl-container--dark .ggl-container__left, .ggl-container.ggl-container--dark .ggl-container__right{  background-color: black;}

能够看到上述代码次要应用了 cBcEcM 函数来进行各种标签、款式的嵌套组合,来达到定义标准 CSS 类和对应款式的成果,为了更近一步解说这个库的作用以及它在 Naive UI 中所达到的成果,咱们有必要先理解一下什么是 BEM。

什么是 BEM?

B(Block)、E(Element)、M(Modifier),即块、元素与修饰符,是一种宽泛应用的对 HTML/CSS 外面应用到的类命名标准:

 /* 块 */.btn {} /* 依赖块的元素 */ .btn__price {}.btn__text {} /* 批改块状态的修饰符 */.btn--orange {} .btn--big {}
  • 上述中块(Block),即 btn ,代表一个形象的最顶级的新组件,即块外面不能蕴含块,也被视为一棵树中的父节点,应用 .btn 示意
  • 元素(Element),即 pricetext ,代表从属于某个块,是这个块的子元素,跟在块前面,以双下划线为距离,应用 .btn__price.btn__text 示意
  • 修饰符(Modifier),即 orangebig ,用于批改块的状态,为块增加特定的主题或款式,跟在块前面,以双连字符为距离,应用 .btn--orange.btn--big 示意

上述的 CSS 模式反映到 HTML 外面,会失去如下构造:

<a class="btn btn--big btn--orange" href="">  <span class="btn__price">¥9.99</span>  <span class="btn__text">订购</span></a>

应用这种 BEM 模式的类命名格调根本有如下几种长处:

  1. 能够示意简直所有的元素及其从属关系,且关系明确、语义明确
  2. 且即便其余畛域的开发者,如客户端开发,或者设计师们,不理解 CSS 语言,也能从这种命名格调外面理解元素、元素的层级所属关系和状态
  3. 搭建了相似的命名构造之后,之后只须要变动少许的类名就能够取得不同格调的元素,如按钮:
/* Block */.btn {  text-decoration: none;  background-color: white;  color: #888;  border-radius: 5px;  display: inline-block;  margin: 10px;  font-size: 18px;  text-transform: uppercase;  font-weight: 600;  padding: 10px 5px;}/* Element */.btn__price {  background-color: white;  color: #fff;  padding-right: 12px;  padding-left: 12px;  margin-right: -10px; /* realign button text padding */  font-weight: 600;  background-color: #333;  opacity: .4;  border-radius: 5px 0 0 5px;}/* Element */.btn__text {  padding: 0 10px;  border-radius: 0 5px 5px 0;}/* Modifier */.btn--big {  font-size: 28px;  padding: 10px;  font-weight: 400;}/* Modifier */.btn--blue {  border-color: #0074D9;  color: white;  background-color: #0074D9;}/* Modifier */.btn--orange {  border-color: #FF4136;  color: white;  background-color: #FF4136;}/* Modifier */.btn--green {  border-color: #3D9970;  color: white;  background-color: #3D9970;}body {  font-family: "fira-sans-2", sans-serif;  background-color: #ccc;}

上述只须要批改修饰符 orangegreenbluebig 等,就能够取得不同的成果:

CSS Render 是如何运作的?

CSS Render 实质上是一个 CSS 生成器,而后提供了 mountunmount API,用于将生成的 CSS 字符串挂载到 HTML 模板里和从 HTML 外面删除此 CSS 款式标签,它借助 BEM 命名标准插件和 CSS Variables 来实现 Sass/Less/CSS-in-JS 模式的计划,能够缩小整体 CSS 的反复逻辑和包大小。

理解了 BEM 和上述对于 CSS Render 的介绍之后,咱们再来回顾一下以下的代码:

import CssRender from 'css-render'import bem from '@css-render/plugin-bem'const cssr = CssRender()const plugin = bem({  blockPrefix: '.ggl-'})cssr.use(plugin) // 为 cssr 注册 bem 插件const { cB, cE, cM } = pluginconst style = cB(  'container',  [    cE(      'left, right',       {        width: '50%'      }    ),    cM(      'dark',       [        cE(          'left, right',          {            backgroundColor: 'black'          }        )      ]    )  ])// 查看渲染的 CSS 款式字符串console.log(style.render())// 将款式挂载到 head 标签外面,能够提供 optionsstyle.mount(/* options */)// 删除挂载的款式style.unmount(/* options */)

上述代码次要做了如下工作:

  1. 初始化 CSS Render 实例,而后初始化 BEM 插件实例,并为整体款式类加上 .ggl- 前缀
  2. 从 BEM 插件外面导出相干的 cBcEcM 办法,而后基于这三个办法听从 BEM 的概念进行款式类的排列、嵌套、组合来造成咱们最终的款式类和对应的款式

    1. 首先是 cB ,定义某个顶层块元素为 container
    2. 而后是此块蕴含两个子元素,别离是 cE ,代表从属于父块的子元素 leftright,对应对于 width 的款式 ;以及 cM ,对父块进行润饰的修饰符 dark
    3. dark 修饰符又蕴含一个子元素,属于 cE ,代表从属于此修饰符所润饰块、蕴含子元素 leftright ,对应对于 backgroundColor 的款式

理解了上述的层级嵌套关系之后,咱们就能够写出上述 style 进行 render 之后的成果:

// .ggl- 前缀,以及 cB('container', [cE('left, right', { width: '50%' } )]).ggl-container .ggl-container__left, .ggl-container .ggl-container__right {  width: 50%;}// .ggl- 前缀,以及 cB('container', [cM('dark', [cE('left, right', { backgroundColor: 'black' } )])]).ggl-container.ggl-container--dark .ggl-container--left, .ggl-container.ggl-container--dark .ggl-container__right { background-color: black;}

能够看到 cM 定义的修饰符,其实是间接润饰块,也就是在类生成上会是 .ggl-container.ggl-container--dark 与父块的类间接写在一起,属于润饰关系,而不是从属关系。

Naive UI 的款式组织

Naive UI 在款式组织上次要遵循如下逻辑,仍然以 Button 为例:

  • 挂载 CSS Variables,这里存在默认的变量和用户传进来自定义的变量,将 cssVars 传给标签的 style 字段来挂载
  • 挂载 Button 相干根底款式、主题(theme)相干的款式,生成 CSS 类名
  • 挂载全局默认款式(这一步在最初,确保全局默认款式不会被笼罩)

通过下面三步走的形式,就能够定义好 Button 相干的所有类、款式,并通过 CSS Variables 反对主题定制、主题重载等性能。

上述三步走次要是在 setup 函数外面调用 useTheme 钩子,解决 Button 相干款式挂载和全局默认款式挂载,而后解决 CSS Variables 定义和应用:

const Button = defineComponent({  name: "Button",  props: buttonProps,  setup(props) {    const themeRef = useTheme(      "Button",      "Button",      style,      buttonLight,      props,      mergedClsPrefixRef    );        return {      // 定义边框色彩相干      customColorCssVars: computed(() => {}),      // 定义 字体、边框、色彩、大小相干      cssVars: computed(() => {}),    }  }  render() {    // 定义 button 相干的 CSS 变量    <Component style={this.cssVars}>      // 定义边框色彩独有的 CSS 变量      <div class={`${mergedClsPrefix}-button__border`} style={this. customColorCssVars} />      <div class={`${mergedClsPrefix}-button__state-border`} style={this. customColorCssVars} />    </Component>  }});

挂载 Buttn 相干款式

Button 相干款式挂载与全局款式挂载相干的内容存在于 Button 组件的 setup 办法外面的 useTheme Hooks,useTheme 是一个如下构造的钩子函数:

/* 全局 CSS Variables 的类型 */type ThemeCommonVars = typeof { primaryHover: '#36ad6a', errorHover: '#de576d', ... }// Button 独有的 CSS Variable 类型type ButtonThemeVars = ThemeCommonVars & { /*  Button 相干的 CSS Variables 的类型 */ }// Theme 的类型interface Theme<N, T = {}, R = any> {  // 主题名  name: N  // 主题一些通用的 CSS Variables  common?: ThemeCommonVars  // 相干依赖组件的一些 CSS Variables,如 Form 外面依赖 Button,对应的 Button  // 须要蕴含的 CSS Variables 要有限度  peers?: R  // 主题本身的一些个性化的 CSS Variables  self?: (vars: ThemeCommonVars) => T}// Button Theme 的类型type ButtonTheme = Theme<'Button', ButtonThemeVars >interface GlobalThemeWithoutCommon {  Button?: ButtonTheme  Icon?: IconTheme}// useTheme 办法传入 props 的类型type UseThemeProps<T> = Readonly<{  // 主题相干变量,如 darkTheme  theme?: T | undefined  // 主题中能够被重载的变量  themeOverrides?: ExtractThemeOverrides<T>  // 内建主题中能够被重载的变量  builtinThemeOverrides?: ExtractThemeOverrides<T>}>// 最终合并的 Theme 的类型type MergedTheme<T> = T extends Theme<unknown, infer V, infer W>  ? {      common: ThemeCommonVars      self: V       // 相干依赖组件的一些 CSS Variables,如 Form 外面依赖 Button,对应的 Button      // 须要蕴含的 CSS Variables 要有限度      peers: W       // 相干依赖组件的一些 CSS Variables,如 Form 外面依赖 Button,对应的 Button      // 须要蕴含的 CSS Variables 要有限度,这些 CSS Variables 中能够被重载的变量      peerOverrides: ExtractMergedPeerOverrides<T>    }  : TuseTheme<N, T, R>(  resolveId: keyof GlobalThemeWithoutCommon,  mountId: string,  style: CNode | undefined,  defaultTheme: Theme<N, T, R>,  props: UseThemeProps<Theme<N, T, R>>,  // n  clsPrefixRef?: Ref<string | undefined>) => ComputedRef<MergedTheme<Theme<N, T, R>>>

能够看到,useTheme 次要接管 6 个参数:

  • resolveId 用于定位在全局款式主题中的键值,这里是 'Button'
  • mountId 款式挂载到 head 标签时,styleid
  • style 组件的 CSS Render 模式生成的款式标签、款式的字符串,也就是 Button 相干的类、类与款式的对应的骨架,外面是一系列待应用的 CSS Variables
  • defaultThemeButton 的默认主题相干的 CSS Variables
  • props 为用户应用组件时可自定义传入的属性,用于笼罩默认的款式变量
  • clsPrefixRef 为整体的款式类前缀,在 Naive UI 中,这个为 n

useTheme 返回一个合并了内建款式、全局定义的对于 Button 相干的款式、用户自定义款式三者的款式合集 ComputedRef<MergedTheme<Theme<N, T, R>>>

理解了 useTheme 钩子函数的输出与输入之后,能够持续来看一下其函数主体逻辑:

function useTheme(  resolveId,  mountId,  style,  defaultTheme,  props,  clsPrefixRef) {  if (style) {    const mountStyle = () => {      const clsPrefix = clsPrefixRef?.value;      style.mount({        id: clsPrefix === undefined ? mountId : clsPrefix + mountId,        head: true,        props: {          bPrefix: clsPrefix ? `.${clsPrefix}-` : undefined,        },      });      globalStyle.mount({        id: "naive-ui/global",        head: true,      });    };    onBeforeMount(mountStyle);  }  const NConfigProvider = inject(configProviderInjectionKey, null);  const mergedThemeRef = computed(() => {    const {      theme: { common: selfCommon, self, peers = {} } = {},      themeOverrides: selfOverrides = {},      builtinThemeOverrides: builtinOverrides = {},    } = props;    const { common: selfCommonOverrides, peers: peersOverrides } =      selfOverrides;    const {      common: globalCommon = undefined,            common: globalSelfCommon = undefined,        self: globalSelf = undefined,        peers: globalPeers = {},      } = {},    } = NConfigProvider?.mergedThemeRef.value || {};    const {      common: globalCommonOverrides = undefined,     = {},    } = NConfigProvider?.mergedThemeOverridesRef.value || {};    const {      common: globalSelfCommonOverrides,      peers: globalPeersOverrides = {},    } = globalSelfOverrides;    const mergedCommon = merge(      {},      selfCommon || globalSelfCommon || globalCommon || defaultTheme.common,      globalCommonOverrides,      globalSelfCommonOverrides,      selfCommonOverrides    );    const mergedSelf = merge(      // {}, executed every time, no need for empty obj      (self || globalSelf || defaultTheme.self)?.(mergedCommon),      builtinOverrides,      globalSelfOverrides,      selfOverrides    );    return {      common: mergedCommon,      self: mergedSelf,      peers: merge({}, defaultTheme.peers, globalPeers, peers),      peerOverrides: merge({}, globalPeersOverrides, peersOverrides),    };  });  return mergedThemeRef;}

能够看到 useTheme 主体逻辑蕴含两个局部:

  • 第一部分为挂载 button 相干的款式到 clsPrefix + mountId ,蕴含 button 相干的款式类骨架,以及挂载全局通用款式到 naive-ui/global ,并且这个款式的挂载过程是在 onBeforeMount 钩子调用时,对应到之前解说的款式挂载程序就能够理分明了:

    • 程序为 setup 外面返回 CSS Variables,而后通过标签的 style 注册 CSS Variables
    • 而后挂载 Button 相干的的款式骨架
    • 而后挂载全局通用的款式骨架,确保 Button 相干的款式骨架不会笼罩全局通用的款式
  • 第二局部为将用户自定义的主题、外部配置的主题进行整合生成新的主题变量集

    • 用户自定义的主题 props :蕴含 themethemeOverridesbuiltinThemeOverrides
    • 外部配置的主题 NConfigProvider?.mergedThemeRef.valueNConfigProvider?.mergedThemeOverridesRef.value

接下来着重解说对于这两部的具体代码和相干变量的含意。

第一局部中的 button 相干的款式如下:

import { c, cB, cE, cM, cNotM } from "../../../_utils/cssr";import fadeInWidthExpandTransition from "../../../_styles/transitions/fade-in-width-expand.cssr";import iconSwitchTransition from "../../../_styles/transitions/icon-switch.cssr";export default c([  cB(    "button",    `    font-weight: var(--font-weight);    line-height: 1;    font-family: inherit;    padding: var(--padding);    // .... 更多的定义    `, [    // border ,边框相干的款式类骨架      cM("color", [        cE("border", {          borderColor: "var(--border-color)",        }),        cM("disabled", [          cE("border", {            borderColor: "var(--border-color-disabled)",          }),        ]),        cNotM("disabled", [          c("&:focus", [            cE("state-border", {              borderColor: "var(--border-color-focus)",            }),          ]),          c("&:hover", [            cE("state-border", {              borderColor: "var(--border-color-hover)",            }),          ]),          c("&:active", [            cE("state-border", {              borderColor: "var(--border-color-pressed)",            }),          ]),          cM("pressed", [            cE("state-border", {              borderColor: "var(--border-color-pressed)",            }),          ]),        ]),      ]),      // icon 相干的款式类骨架      cE(        "icon",        `      margin: var(--icon-margin);      margin-left: 0;      height: var(--icon-size);      width: var(--icon-size);      max-width: var(--icon-size);      font-size: var(--icon-size);      position: relative;      flex-shrink: 0;    `,        [          cB(            "icon-slot",            `        height: var(--icon-size);        width: var(--icon-size);        position: absolute;        left: 0;        top: 50%;        transform: translateY(-50%);        display: flex;      `,            [              iconSwitchTransition({                top: "50%",                originalTransform: "translateY(-50%)",              }),            ]          ),          fadeInWidthExpandTransition(),        ]      ),      // content 子元素内容相干的款式类骨架      cE(        "content",        `      display: flex;      align-items: center;      flex-wrap: nowrap;    `,        [          c("~", [            cE("icon", {              margin: "var(--icon-margin)",              marginRight: 0,            }),          ]),        ]      ),      // 更多的对于 backgroundColor、base-wave 点击反馈波纹,icon,content,block 相干的款式定义    ],    // 动画、过渡相干的款式类骨架    c("@keyframes button-wave-spread", {    from: {      boxShadow: "0 0 0.5px 0 var(--ripple-color)",    },    to: {      // don't use exact 5px since chrome will display the animation with glitches      boxShadow: "0 0 0.5px 4.5px var(--ripple-color)",    },  }),  c("@keyframes button-wave-opacity", {    from: {      opacity: "var(--wave-opacity)",    },    to: {      opacity: 0,    },  }),]);

下面的 CSS Render 相干的代码最终会产出类型上面的内容:

.n-button {    font-weight: var(--font-weight);    line-height: 1;    font-family: inherit;    padding: var(--padding);    transition:      color .3s var(--bezier),      background-color .3s var(--bezier),      opacity .3s var(--bezier),      border-color .3s var(--bezier);  }.n-button.n-button--color .n-button__border {  border-color: var(--border-color);}.n-button.n-button--color.n-button--disabled .n-button__border {  border-color: var(--border-color-disabled);}.n-button.n-button--color:not(.n-button--disabled):focus .n-button__state-border {  border-color: var(--border-color-focus);}.n-button .n-base-wave {      pointer-events: none;      top: 0;      right: 0;      bottom: 0;      left: 0;      animation-iteration-count: 1;      animation-duration: var(--ripple-duration);      animation-timing-function: var(--bezier-ease-out), var(--bezier-ease-out);    }.n-button .n-base-wave.n-base-wave--active {  z-index: 1;  animation-name: button-wave-spread, button-wave-opacity;}.n-button .n-button__border, .n-button .n-button__state-border {      position: absolute;      left: 0;      top: 0;      right: 0;      bottom: 0;      border-radius: inherit;      transition: border-color .3s var(--bezier);      pointer-events: none;    }.n-button .n-button__icon {      margin: var(--icon-margin);      margin-left: 0;      height: var(--icon-size);      width: var(--icon-size);      max-width: var(--icon-size);      font-size: var(--icon-size);      position: relative;      flex-shrink: 0;    }.n-button .n-button__icon .n-icon-slot {        height: var(--icon-size);        width: var(--icon-size);        position: absolute;        left: 0;        top: 50%;        transform: translateY(-50%);        display: flex;      }.n-button .n-button__icon.fade-in-width-expand-transition-enter-active {      overflow: hidden;      transition:        opacity .2s cubic-bezier(.4, 0, .2, 1) .1s,        max-width .2s cubic-bezier(.4, 0, .2, 1),        margin-left .2s cubic-bezier(.4, 0, .2, 1),        margin-right .2s cubic-bezier(.4, 0, .2, 1);    }.n-button .n-button__content {      display: flex;      align-items: center;      flex-wrap: nowrap;    }.n-button .n-button__content ~ .n-button__icon {  margin: var(--icon-margin);  margin-right: 0;}.n-button.n-button--block {      display: flex;      width: 100%;    }.n-button.n-button--dashed .n-button__border, .n-button.n-button--dashed .n-button__state-border {  border-style: dashed !important;}.n-button.n-button--disabled {  cursor: not-allowed;  opacity: var(--opacity-disabled);}@keyframes button-wave-spread {  from {    box-shadow: 0 0 0.5px 0 var(--ripple-color);  }  to {    box-shadow: 0 0 0.5px 4.5px var(--ripple-color);  }}@keyframes button-wave-opacity {  from {    opacity: var(--wave-opacity);  }  to {    opacity: 0;  }}

能够看到 button 相干的款式应用 BEM 命名格调解决了各种场景:

  • border 与 state-border ,对于 disabled、pressed、hover 、active 等状态下的款式
.n-button.n-button--color:not(.n-button--disabled):focus .n-button__state-border {  border-color: var(--border-color-focus);}
  • 按钮被点击时呈现波纹的款式 .n-button .n-base-wave
  • 按钮中的 icon 相干的款式 .n-button .n-button__icon
  • 按钮中的文本等内容的款式 .n-button .n-button__content

同时能够看到在款式中为各种属性预留了对应的 CSS Variables,包含 box-shadow 的 --ripple-color ,icon 宽高的 --icon-size ,过渡动画 transition--bezier ,这些变量是为前面定制各种款式、主题留出空间。

即在设计一个组件库的款式零碎时,组件相干的款式模板应用 BEM 格调提前就定义好,而后对于须要定制的主题相干的变量等通过 CSS Variables 来进行个性化的更改,达到定制主题的成果。

挂载全局款式

全局相干的款式次要是一些简略的根底配置,代码如下:

import { c } from "../../_utils/cssr";import commonVariables from "../common/_common";export default c(  "body",  `  margin: 0;  font-size: ${commonVariables.fontSize};  font-family: ${commonVariables.fontFamily};  line-height: ${commonVariables.lineHeight};  -webkit-text-size-adjust: 100%;`,  [    c(      "input",      `    font-family: inherit;    font-size: inherit;  `    ),  ]);

次要为 marginfont-sizefont-familyline-height 等相干的内容,是为了兼容浏览器所必要进行的 CSS 代码标准化,比拟典型的有 Normalize.css: Make browsers render all elements more consistently.。

其中 commonVariables 如下:

export default {  fontFamily:    'v-sans, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',  fontFamilyMono: "v-mono, SFMono-Regular, Menlo, Consolas, Courier, monospace",  fontWeight: "400",  fontWeightStrong: "500",  cubicBezierEaseInOut: "cubic-bezier(.4, 0, .2, 1)",  cubicBezierEaseOut: "cubic-bezier(0, 0, .2, 1)",  cubicBezierEaseIn: "cubic-bezier(.4, 0, 1, 1)",  borderRadius: "3px",  borderRadiusSmall: "2px",  fontSize: "14px",  fontSizeTiny: "12px",  fontSizeSmall: "14px",  fontSizeMedium: "14px",  fontSizeLarge: "15px",  fontSizeHuge: "16px",  lineHeight: "1.6",  heightTiny: "22px",  heightSmall: "28px",  heightMedium: "34px",  heightLarge: "40px",  heightHuge: "46px",  transformDebounceScale: "scale(1)",};

上述的通用变量是 UI 组件库向上构建的一些最根底的 “原材料”,也是默认不倡议批改的、业界的最佳实际,如定义 size 有 5 类,别离为 tinysmallmediumlargehuge ,定义字体、代码字体 等。

定义与注册 CSS Variables

这块的次要代码为:

const NConfigProvider = inject(configProviderInjectionKey, null);const mergedThemeRef = computed(() => {const {  theme: { common: selfCommon, self, peers = {} } = {},  themeOverrides: selfOverrides = {},  builtinThemeOverrides: builtinOverrides = {},} = props;const { common: selfCommonOverrides, peers: peersOverrides } =  selfOverrides;const {  common: globalCommon = undefined,  [resolveId]: {    common: globalSelfCommon = undefined,    self: globalSelf = undefined,    peers: globalPeers = {},  } = {},} = NConfigProvider?.mergedThemeRef.value || {};const {  common: globalCommonOverrides = undefined,  [resolveId]: globalSelfOverrides = {},} = NConfigProvider?.mergedThemeOverridesRef.value || {};const {  common: globalSelfCommonOverrides,  peers: globalPeersOverrides = {},} = globalSelfOverrides;const mergedCommon = merge(  {},  selfCommon || globalSelfCommon || globalCommon || defaultTheme.common,  globalCommonOverrides,  globalSelfCommonOverrides,  selfCommonOverrides);const mergedSelf = merge(  // {}, executed every time, no need for empty obj  (self || globalSelf || defaultTheme.self)?.(mergedCommon),  builtinOverrides,  globalSelfOverrides,  selfOverrides);return {  common: mergedCommon,  self: mergedSelf,  peers: merge({}, defaultTheme.peers, globalPeers, peers),  peerOverrides: merge({}, globalPeersOverrides, peersOverrides),};

首先是从通过 inject 拿到 configProviderInjectionKey 相干的内容,其中 configProviderInjectionKey 相干内容定义在如下:

provide(configProviderInjectionKey, {  mergedRtlRef,  mergedIconsRef,  mergedComponentPropsRef,  mergedBorderedRef,  mergedNamespaceRef,  mergedClsPrefixRef,  mergedLocaleRef: computed(() => {    // xxx  }),  mergedDateLocaleRef: computed(() => {    // xxx  }),  mergedHljsRef: computed(() => {    // ...  }),  mergedThemeRef,  mergedThemeOverridesRef})

能够看到蕴含 rtl、icon、border、namespace、clsPrefix、locale(国际化)、date、theme、themeOverrides 等简直所有的配置,而这里次要是想拿到主题相干的配置:

  • mergedThemeRef :可调整的主题,如
<template>  <n-config-provider :theme="darkTheme">    <app />  </n-config-provider></template><script>  import { darkTheme } from 'naive-ui'  export default {    setup() {      return {        darkTheme      }    }  }</script>
  • mergedThemeOverridesRef :可调整的主题变量,如
const themeOverrides = {    common: {      primaryColor: '#FF0000'    },    Button: {      textColor: '#FF0000'      backgroundColor: '#FFF000',    },    Select: {      peers: {        InternalSelection: {          textColor: '#FF0000'        }      }    }    // ...  }

上述的这两者有次要蕴含全局 common 相干的,以及 Buttoncommon 相干的对立变量、self 相干的 Button 自定义的一些变量,以及 Button 在与其余组件应用时波及相干限度的 peers 变量。

useTheme 钩子函数中返回 themeRef 之后,themeRef 相干的内容会拿来组装 Button 波及到的各种款式,次要从以下四个方向进行解决:

  • fontProps
  • colorProps
  • borderProps
  • sizeProps
cssVars: computed(() => {  // fontProps  // colorProps  // borderProps  // sizeProps    return {  // 解决 动画过渡函数、透明度相干的变量    "--bezier": cubicBezierEaseInOut,      "--bezier-ease-out": cubicBezierEaseOut,      "--ripple-duration": rippleDuration,      "--opacity-disabled": opacityDisabled,      "--wave-opacity": waveOpacity,      // 解决字体、色彩、边框、大小相干的变量    ...fontProps,    ...colorProps,    ...borderProps,    ...sizeProps,  };});

fontProps 相干代码如下:

const theme = themeRef.value;const {  self,} = theme;const {  rippleDuration,  opacityDisabled,  fontWeightText,  fontWeighGhost,  fontWeight,} = self;const { dashed, type, ghost, text, color, round, circle } = props;        // fontconst fontProps = {  fontWeight: text    ? fontWeightText    : ghost    ? fontWeighGhost    : fontWeight,};

次要判断当 Button 以 text 节点进行展现时、以通明背景进行展现时、标准状态下的字体相干的 CSS 变量与值。

colorProps 相干代码如下

let colorProps = {  "--color": "initial",  "--color-hover": "initial",  "--color-pressed": "initial",  "--color-focus": "initial",  "--color-disabled": "initial",  "--ripple-color": "initial",  "--text-color": "initial",  "--text-color-hover": "initial",  "--text-color-pressed": "initial",  "--text-color-focus": "initial",  "--text-color-disabled": "initial",};if (text) {  const { depth } = props;  const textColor =    color ||    (type === "default" && depth !== undefined      ? self[createKey("textColorTextDepth", String(depth))]      : self[createKey("textColorText", type)]);  colorProps = {    "--color": "#0000",    "--color-hover": "#0000",    "--color-pressed": "#0000",    "--color-focus": "#0000",    "--color-disabled": "#0000",    "--ripple-color": "#0000",    "--text-color": textColor,    "--text-color-hover": color      ? createHoverColor(color)      : self[createKey("textColorTextHover", type)],    "--text-color-pressed": color      ? createPressedColor(color)      : self[createKey("textColorTextPressed", type)],    "--text-color-focus": color      ? createHoverColor(color)      : self[createKey("textColorTextHover", type)],    "--text-color-disabled":      color || self[createKey("textColorTextDisabled", type)],  };} else if (ghost || dashed) {  colorProps = {    "--color": "#0000",    "--color-hover": "#0000",    "--color-pressed": "#0000",    "--color-focus": "#0000",    "--color-disabled": "#0000",    "--ripple-color": color || self[createKey("rippleColor", type)],    "--text-color": color || self[createKey("textColorGhost", type)],    "--text-color-hover": color      ? createHoverColor(color)      : self[createKey("textColorGhostHover", type)],    "--text-color-pressed": color      ? createPressedColor(color)      : self[createKey("textColorGhostPressed", type)],    "--text-color-focus": color      ? createHoverColor(color)      : self[createKey("textColorGhostHover", type)],    "--text-color-disabled":      color || self[createKey("textColorGhostDisabled", type)],  };} else {  colorProps = {    "--color": color || self[createKey("color", type)],    "--color-hover": color      ? createHoverColor(color)      : self[createKey("colorHover", type)],    "--color-pressed": color      ? createPressedColor(color)      : self[createKey("colorPressed", type)],    "--color-focus": color      ? createHoverColor(color)      : self[createKey("colorFocus", type)],    "--color-disabled": color || self[createKey("colorDisabled", type)],    "--ripple-color": color || self[createKey("rippleColor", type)],    "--text-color": color      ? self.textColorPrimary      : self[createKey("textColor", type)],    "--text-color-hover": color      ? self.textColorHoverPrimary      : self[createKey("textColorHover", type)],    "--text-color-pressed": color      ? self.textColorPressedPrimary      : self[createKey("textColorPressed", type)],    "--text-color-focus": color      ? self.textColorFocusPrimary      : self[createKey("textColorFocus", type)],    "--text-color-disabled": color      ? self.textColorDisabledPrimary      : self[createKey("textColorDisabled", type)],  };}

次要解决在四种模式:一般、text 节点、ghost 背景通明、dashed 虚线模式下,对不同状态 规范 、pressedhoverfocusdisabled 等解决相干的 CSS 属性和值

borderProps 相干的代码如下:

let borderProps = {  "--border": "initial",  "--border-hover": "initial",  "--border-pressed": "initial",  "--border-focus": "initial",  "--border-disabled": "initial",};if (text) {  borderProps = {    "--border": "none",    "--border-hover": "none",    "--border-pressed": "none",    "--border-focus": "none",    "--border-disabled": "none",  };} else {  borderProps = {    "--border": self[createKey("border", type)],    "--border-hover": self[createKey("borderHover", type)],    "--border-pressed": self[createKey("borderPressed", type)],    "--border-focus": self[createKey("borderFocus", type)],    "--border-disabled": self[createKey("borderDisabled", type)],  };}

次要解决在以 text 模式进行展现和一般模式展现下,五种不同状态 规范 、pressedhoverfocusdisabled等状况下的解决。

这里 borderProps 其实次要是定义整个 border 属性,而边框色彩相干的属性其实是通过 setup 外面的 customColorCssVars 进行定义的,代码如下:

customColorCssVars: computed(() => {    const { color } = props;    if (!color) return null;    const hoverColor = createHoverColor(color);    return {      "--border-color": color,      "--border-color-hover": hoverColor,      "--border-color-pressed": createPressedColor(color),      "--border-color-focus": hoverColor,      "--border-color-disabled": color,    };  })

sizeProps 相干的代码如下:

const sizeProps = {  "--width": circle && !text ? height : "initial",  "--height": text ? "initial" : height,  "--font-size": fontSize,  "--padding": circle    ? "initial"    : text    ? "initial"    : round    ? paddingRound    : padding,  "--icon-size": iconSize,  "--icon-margin": iconMargin,  "--border-radius": text    ? "initial"    : circle || round    ? height    : borderRadius,};

次要解决 widthheightfont-sizepaddingiconborder 相干的大小内容,其中 margin 在挂载全局默认款式的时候进行了解决,默认为 0。

小结

通过下面三步走:

  1. 挂载 button 相干的款式类骨架,留出大量 CSS Variables 用于自定义款式
  2. 挂载全局默认款式
  3. 组装、定义相干的 CSS Variables 来填充款式类骨架

咱们就胜利利用 CSS Render、 BEM plugin、CSS Variables 实现了 Button 整体款式的设计,它既易于了解、还易于定制。

不过也值得注意的是,纵观上述组件中款式的解决逻辑,只定义在 setup 里,也少用生命周期相干的钩子,其实也能够看出 CSS Render 的次要应用场景:即当时将所有的状况都标准好,相干的 CSS Variables 都预设好,而后给出

必要的事件处理也不能少

Naive UI 次要提供了以下几类事件的解决:

  • mousedown : handleMouseDown
  • keyuphandleKeyUp
  • keydown: handleKeyDown
  • click : handleClick
  • blur : handleBlur

能够来别离看一下其中的代码:

handleMouseDown

const handleMouseDown = (e) => {  e.preventDefault();  if (props.disabled) {    return;  }  if (mergedFocusableRef.value) {    selfRef.value?.focus({ preventScroll: true });  }};

次要解决 disabled 状况下不响应、以及如果能够 focus 状况下,调用 selfRef 进行 focus,并激活对应的款式。

handleKeyUp

const handleKeyUp = (e) => {  switch (e.code) {    case "Enter":    case "NumpadEnter":      if (!props.keyboard) {        e.preventDefault();        return;      }      enterPressedRef.value = false;      void nextTick(() => {        if (!props.disabled) {          selfRef.value?.click();        }      });  }};

次要解决 EnterNumpadEnter 键,判断是否反对键盘解决,并在适合的状况下激活按钮点击。

handleKeyDown

const handleKeyDown = (e) => {  switch (e.code) {    case "Enter":    case "NumpadEnter":      if (!props.keyboard) return;      e.preventDefault();      enterPressedRef.value = true;  }};

次要解决 EnterNumpadEnter 键,判断是否反对键盘解决,并在适合的状况下更新 enterPressedRef 的值,标记以后是 keydown 过。

handleClick

const handleClick = (e) => {  if (!props.disabled) {    const { onClick } = props;    if (onClick) call(onClick, e);    if (!props.text) {      const { value } = waveRef;      if (value) {        value.play();      }    }  }};

依据状态调用对应的点击处理函数,以及非 text 节点下播放按钮的点击波纹动效。

handleBlur

const handleBlur = () => {  enterPressedRef.value = false;};

更新 enterPressedRef 的值,标记以后是 blur 了。

总结与瞻望

本文通过一层层、源码级分析了 Naive UI 的 Button 残缺过程,能够发现对于组件库这个畛域来说,绝大部分的构思都是花在如何设计可扩大的款式零碎上,从 Ant Design、Element UI 应用 Less 来组织款式零碎,再到 Material Design 应用 CSS-in-JS,如 styled-components 来组织款式零碎,再到当初 Naive UI 应用 CSS Render 来组织款式零碎,尽管组织款式零碎的模式繁多,但实际上就我了解而言,在设计款式类、对应的款式、款式的扩大和主题定制上应该大体放弃类似。

如果你能通过这篇芜杂的文章了解了 Button 运行的整个过程,还放弃着对 Naive UI 整体的源码、工程化方向建设的趣味,你齐全能够依照这个逻辑去了解其余组件的设计原理,正如我在开始放的那张图一样,你理解整体代码的过程中会感觉越来越简略:

理解优良库的源码设计、研读大牛的源码能够帮忙咱们理解业界最佳实际、优良的设计思维和改良编写代码的形式,成为更加优良的开发者,你我共勉!

参考资料

  • https://css-tricks.com/bem-101/
  • https://www.smashingmagazine....
  • http://getbem.com/introduction/
  • https://necolas.github.io/nor...
  • https://www.naiveui.com/zh-CN...
  • https://github.com/07akioni/c...
  • http://www.woshipm.com/ucd/42...
  • http://getbem.com/introduction/