注意:为了让篇幅尽可能简洁一丢丢,在有些地方贴源码时,我尽可能贴最能反映要讲解内容的源码,其他重复性的代码就略去了,所以如果你自己尝试去阅读源码时,可能会发现和文章里的代码有出入。文章跑通 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,并给予这个方案设计了一套插件系统,目前主要有两个插件:

本文中主要专注于 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 } = plugin

const style = cB(

  'container',

  [

    cE(

      'left, right', 

      {

        width: '50%'

      }

    ),

    cM(

      'dark', 

      [

        cE(

          'left, right',

          {

            backgroundColor: 'black'

          }

        )

      ]

    )

  ]

)



// 查看渲染的 CSS 样式字符串

console.log(style.render())



// 将样式挂载到 head 标签里面,可以提供 options

style.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 } = plugin

const style = cB(

  'container',

  [

    cE(

      'left, right', 

      {

        width: '50%'

      }

    ),

    cM(

      'dark', 

      [

        cE(

          'left, right',

          {

            backgroundColor: 'black'

          }

        )

      ]

    )

  ]

)



// 查看渲染的 CSS 样式字符串

console.log(style.render())



// 将样式挂载到 head 标签里面,可以提供 options

style.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>

    }

  : T



useTheme<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

/感谢支持/

以上便是本次分享的全部内容,希望对你有所帮助ღ( ´・ᴗ・` )

喜欢的话别忘了分享、点赞、收藏三连哦~

欢迎关注公众号 程序员巴士,来自字节、虾皮、招银的三端兄弟,分享编程经验、技术干货与职业规划,帮助你少走弯路进大厂。