大家好,我是tiantian。
最近程序员巴士学习交流群里有小伙伴想要了解一下如何看源码,正好最近有一点心得感悟,之前也写过一篇实际跑通 NaiveUI 源码的文章:尤大都推荐的组件库是如何开发出来的?[1] 源码的经验,来给大家分享一下。
心理认知要到位
首先要认识到,看源码是一个开始比较枯燥、同时时间跨度相对比较长的一个过程。所以看源码的第一步是找到自己想要了解领域、或者自己所在业务领域高度相关的项目,并且在这个领域比较出名,且维护活跃。
打个比方,皮汤我因为是一名前端,而前端这个领域有很多新兴的内容,如 Unbundled 方案 Vite,新兴框架 Svelte,新汇编语言 WebAssembly,CSS 工程化方案 TailwindCSS,组件库如抖音很火的开源库 Semi Design[2]、或者社区比较火的 Vue3 组件库 NaiveUI 等。
而皮汤我一直对组件库、CSS 方向比较痴迷,且是组内最近负责前端工程化 CSS 方面基建的负责人之一,所以让我去研究一个组件库的源码,如 NaiveUI,那么我是很有兴趣、动力和理由的,而这也是驱动你啃下一个源码的核心驱动力之一。
理解高潮 MVP
其次我们看源码要有一定的技巧,复杂如 React[3],可以算作一个简单的操作系统了,如果你上来通过比较简单粗暴的从代码入口开始,一路打断点了解源码,那你再怎么坚持也会想吐的。
那这里的技巧是什么呢?就像我们互联网创业一样,如果你有一个大而全的点子,但是你的第一步肯定不是找一个空旷的屋子,备好半年的粮食,准备几台电脑,然后高强度开发几个月,然后祈求一问世就惊艳世人。一个是这种情况非常少见,二个是你也得有坚持几个月的资本和耐心。
而在创业领域一个比较知名且流传深远的技巧就是 MVP,即最小可行性产品,你需要先做出一个非常小的,刚好能够使用以及能够测试你想法的产品,然后快速推向市场,接收用户反馈,接着跟进用户反馈,不断迭代产品,满足用户需求,直至达到 PMF(产品与市场匹配),这个时候你基本上就可以找投资,进行规模化,然后就是融资、去纳斯达克敲钟。
所以对应到我们看源码这个领域,第二个需要注意的,就是你需要找到一个开源项目能跑起来的最小 MVP,去除其他繁杂的依赖,最最核心的流程与机制。这个能够帮助你理解项目核心的 MVP,我称之为 高潮 MVP – 即如果你能够跑通一个项目这样的 MVP,那么你内心会异常幸福,感觉自己成就慢慢,兴奋达到了高潮,而接下来其他内容、分支基本上就是复用这套 MVP,往上面添砖加瓦,补齐一些兼容细节等。
开始之前
那么对于 NaiveUI 来说,它的高潮 MVP 是什么呢?
我们首先打开它的官网:
点开开始使用:
作为一个组件库来说,它一般需要谈论自己的价值观、设计原则、定制方式,ICON 图标相关的内容,但是这对于你搞懂这份源码其实没有多大帮助,所以需要略去这些干扰项。
让我们再来看看它的源码仓库:
确保这个库使用何种语言编写,这样你在看源码之前可以先衡量你当前的知识储备是否能够支撑你看懂这份源码,当然如果你没有对应的支持储备,但是又坚持想要看这份源码,那么你首先应该考虑根据它使用的语言,提前进行语言学习储备。
看透本质
让我们回到 NaiveUI 的官网:
可以看到,对于一个 “组件库” 来说,实际上最最基础的其实就是 “组件”,而组成 “组件” 的背后则需要一系列更加基础的元素,如颜色、间距、边框、背景、字体等。
那么我们的目标是不是很明确了呢?把一个 “按钮” 组件拿下,理解能够完整使用到这样一个按钮背后所需要的所有必须的流程、知识、细节,那么针对其他的组件,基本上 90 % 的逻辑可以复用,只需要理解剩余 10% 特定功能需求就可以搞懂。
类似下面这张冰山图:
冰山之下就属于那 90%,我们基于一个看似简单的 “按钮” 组件,来梳理整个组件库的核心流程,就可以帮助我们快速、精准的搞懂整份源码,所以我们的高潮 MVP 就是搞懂一个 “按钮” 组件的全流程。
了解上下文
理解我们的高潮 MVP 目标是什么了之后,接下来就是带着这个目标先详细读一下文档中关于 Button 的所有相关说明,可以看到这个按钮包含如下内容:
通过右侧的目录,了解到一个按钮首先会有基础内容,包含 default
、primary
、info
、success
、warning
和 error
这几类,然后需要处理:
边框相关:虚线
尺寸相关:尺寸、行政
颜色:自定义颜色
状态:文本、禁用、加载中
事件
上述表明了这个 Button 可以达到的效果,可以完成的操作,了解之后,接着可以了解按钮相关的使用 API,通过 API 以及可以达到的效果,我们大致可以理解这个按钮接收的输入和输出有哪些。
一个一个使用 Button 的例子长什么样子:
<template>
<n-space>
<n-button>Default</n-button>
<n-button type="primary">Primary</n-button>
<n-button type="info">Info</n-button>
<n-button type="success">Success</n-button>
<n-button type="warning">Warning</n-button>
<n-button type="error">Error</n-button>
</n-space>
</template>
了解如何开启项目
通常开源项目比较方便的一点是它会有详细的文档,同时它非常渴望有贡献者加入,所以会有完善的 贡献指南,比如 NaiveUI 的贡献指南[4]如下:
通过贡献指南,你能够了解如何安装依赖、处理一些启动项目的问题,能够把项目跑起来进行调试,这通常是你了解整个代码运行过程的初次体验。
理解目标项目的项目结构
通常你到这个步骤时,你应该需要知道如下内容:
对应到 NaiveUI 我们的这三点分别如下:
高潮 MVP:跑通一个 Button 并能够使用,保有和现有 Button 一样的特性,接收一样的输入,产生一样的输出
Button 包含边框、尺寸、颜色、状态、事件等相关的内容,输入这些参数,产出对应条件下的输出
项目的技术栈是 Vue3、TypeScript,构建工具是 Vite,同时使用了 CSS BEM 框架 CSS Render[5],同时包管理工具使用 pnpm
理解这三点之后,接下来我们就需要对照着源码来理解一下整份文件目录,了解各个目录之前的依赖关系,见下图。
我们可以先了解一下大致每个文件夹是干什么的:
src
:这个是主要放组件库相关的组件代码,以及导出一些国际化、样式、主题定制相关的内容,一般是一个开源项目的核心开发目录
scripts
:一些运行代码、构建、发版相关的脚本逻辑
theme
:则为 NaiveUI 内置的默认主题,类似这种组件库一般都允许用户自定义主题,整个 NaiveUI 各个组件在使用各种 UI 属性时都是遵从这套主题进行设置的,也就是可以修改 theme 里面的内容,或者自己完全自定义一套主题
.github
、.husky
等都是一些配置,无需过多关注,可以直接加入到你的 MVP 模板工程里
playground
是用于此时代码在各种环境下运行的支持代码,如 SSR 等
demo
则是引入 src
相关内容用于展示组件实际效果的网站例子,实际上对于 NaiveUI 也就是我们之前看到的文档官网
其他的如 build
、design-notes
等是构建产物,或者一些主题设计的笔记等,基本上不属于本次源码需要阅读的部门,看兴趣的同学可以看看
然后就是一些用于各种工程化配置的文件如:
.prettierrc
:Prettier 相关
.gitignore
:Git 相关
.eslintrc.js
:ESLint 相关
babel.config.js
:Babel 相关
jest.config.js
:Jest 测试相关
postcss.config.js
:处理 CSS 相关
tsconfig.xx.json
处理 TypeScript 相关
vite.config.js
:Vite 构建工具相关的配置
以及一些和项目强相关,用于了解整个想法发展上下文的 CHANGELOG.xx.md
,还有我们之前提到的用于跑通代码的 CONTRIBUTING
贡献指南。
有点看懵了。🤯
创建你的高潮 MVP 项目
了解了整个 NaiveUI 的项目目录结构之后,我们就可以着手创建我们的高潮 MVP 项目了,但在这之前我们可以再进行一波简化,即我们有些内容可以不要:
针对目录的来说
.github
、.husky
、playground
、scripts
这种我们可以不要,我们只需要测试最基础的环境,以及在开发时可以跑通即可
theme
这种只是整个 NaiveUI 遵循的设计体系,在其他部分会遵循这个体系,但是不会直接引用,所以我们也可以不要
这样我们只剩下 demo
和 src
,而更近一步,我们可以把 demo 做到 src 里面,整个 src 我们将其职责变为高潮 MVP 网站入口,然后原剩下的 src 下面的代码则用于导入到 src 入口文件里面使用
针对配置文件来说:
测试相关的,Jest 等我们并不需要
TypeScript 相关的,我们后续可以迭代,不用引入不必要的复杂度以及类型体操
ESLint 和 Prettier 等我们也可以不需要,依赖于编辑器默认的格式化就可,当然引入这两个到我们初始的高潮 MVP 项目里也不碍事
经过简化之后,我们的高潮 MVP 项目就只需要如下几个文件了:
构建项目和提供开发服务器的 Vite 相关内容:vite.config.js
用于提供语法转译的 babel.config.js
项目依赖文件 package.json
用于跑通项目的主要代码 src
以及 index.html
入口模板
目录结构如下:
.
├── babel.config.js
├── index.html
├── node_modules
├── package.json
├── public
├── src
├── vite.config.js
└── yarn.lock
很精简,没有多余繁杂的内容对吧?同时也非常易懂。
这些剩下要创建的文件内容,从 NaiveUI 的工程目录里面 Copy 过来,然后安装对应的依赖即可。
跑通流程
当我们根据源码库创建了我们的高潮 MVP 项目之后,现在应该可以跑起来了,只不过内容只是一个简单的 Button,因为为了快速跑起来项目,我们的入口文件 src/App.vue
会如下:
<template>
<t-button>hello tuture</t-button>
</template>
<script>
import { defineComponent } from "vue";
import { TButton } from "./components";
export default defineComponent({
name: "App",
components: {
TButton,
},
});
</script>
而对应的 src/components/TButton.vue
如下:
<template>
<button>{$slots.default}</button>
</template>
<script>
import { defineComponent } from "vue";
import { TButton } from "./components";
export default defineComponent({
name: "Button"
});
</script>
接下来我们就尝试一遍了解 NaiveUI 的代码,一遍将这些主干代码迁移到我们的高潮 MVP 项目中来,然后确保迁移过程中能够持续跑起来,虽然我们可能会遇到有时候一个依赖需要大量的前置依赖,所以需要迁移一大段代码才能将项目跑起来。
找到核心入口
我们要完成一个 Button 的所有前置依赖,只需要去到 NaiveUI 对应的工程目录文件里面,找到 Button 对应的代码,如下:
其实解析一下组件文件的代码,就是下面几部分:
而上图代码中的所有和 TS 定义相关的内容我们都是不需要的,所以可以删除 ButtonProps
、NativeButtonProps
、MergedProps
、XButton
这些类型定义相关的内容。
而导入部分涉及到类型定义相关的我们也可以删除掉:
import type { ThemeProps } from '../../_mixins'
import type { BaseWaveRef } from '../../_internal'
import type { ExtractPublicPropTypes, MaybeArray } from '../../_utils'
import type { ButtonTheme } from '../styles'
import type { Type, Size } from './interface'
删除完这些无关的代码之后,我们的代码还剩下那些内容呢?
导入依赖部分:
import {
h,
ref,
computed,
inject,
nextTick,
defineComponent,
PropType,
renderSlot,
CSSProperties,
ButtonHTMLAttributes
} from 'vue'
import { useMemo } from 'vooks'
import { createHoverColor, createPressedColor } from '../../_utils/color/index'
import { useConfig, useFormItem, useTheme } from '../../_mixins'
import {
NFadeInExpandTransition,
NIconSwitchTransition,
NBaseLoading,
NBaseWave
} from '../../_internal'
import { call, createKey } from '../../_utils'
import { buttonLight } from '../styles'
import { buttonGroupInjectionKey } from './ButtonGroup'
import style from './styles/button.cssr'
import useRtl from '../../_mixins/use-rtl'
组件声明部分:
const Button = defineComponent({
name: 'Button',
props: buttonProps,
setup(props) {
// 定义组件状态
const selfRef = ref<HTMLElement | null>(null)
const waveRef = ref<BaseWaveRef | null>(null)
const enterPressedRef = ref(false)
// 使用 Props 或注入全局状态
const NButtonGroup = inject(buttonGroupInjectionKey, {})
const { mergedSizeRef } = useFormItem(...)
const mergedFocusableRef = computed(() => {...})
// 定义组件事件处理
const handleMouseDown = (e: MouseEvent): void => {...}
const handleClick = (e: MouseEvent): void => {...}
const handleKeyUp = (e: KeyboardEvent): void => {...}
const handleKeyDown = (e: KeyboardEvent): void => {...}
const handleBlur = (): void => {...}
// 处理组件的主题,获取该 Button 组件在整个全局设计系统中的对应样式
const { mergedClsPrefixRef, NConfigProvider } = useConfig(props)
const themeRef = useTheme(...)
const rtlEnabledRef = useRtl(...)
// 将自身状态、全局状态相关的主题样式、各个 CSS 属性的值、事件相关的内容处理之后返回给模板使用
return {
selfRef,
waveRef,
mergedClsPrefix: mergedClsPrefixRef,
mergedFocusable: mergedFocusableRef,
mergedSize: mergedSizeRef,
showBorder: showBorderRef,
enterPressed: enterPressedRef,
rtlEnabled: rtlEnabledRef,
handleMouseDown,
handleKeyDown,
handleBlur,
handleKeyUp,
handleClick,
customColorCssVars: computed(() => {...}),
cssVars: computed(() => {...})
}
},
render() {
// 处理各种组件相关的样式渲染、事件处理相关的内容,这里的样式渲染对应着在文档里提到的 Button 可以呈现的状态和能处理的操作
const { $slots, mergedClsPrefix, tag: Component } = this
return (
<Component
ref="selfRef"
class={[
`${mergedClsPrefix}-button`,
`{mergedClsPrefix}-button--{this.type}-type`,
{
}
]}
tabindex={this.mergedFocusable ? 0 : -1}
type={this.attrType}
style={this.cssVars as CSSProperties}
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\`}>{slots}</div>
) : null}
<NFadeInExpandTransition></NFadeInExpandTransition>
{$slots.default && this.iconPlacement === 'left' ? (
<span class={`{mergedClsPrefix}-button__content\`}>{slots}</span>
) : null}
{!this.text ? (
<NBaseWave ref="waveRef" clsPrefix={mergedClsPrefix} />
) : null}
{this.showBorder ? ( ...)}
{this.showBorder ? (...)}
</Component>
)
}
})
进一步简化代码
从上述还剩下的代码,我们可以看到,其实对于理解组件库来说,我们其实绝大部分内容是在做定制主题,然后如果根据各种传入的 props,展示不同的主题的工作,所以你会看到 Button 组件里充斥着大量的 CSS 变量,如 this.color
、this.ghost
、this.text
、this.cssVars
,所以我们的核心就是理解这些主题是如何定制的,包含哪些变量和依赖,这些变量和依赖是如何影响 Button 可以承载不同样式和功能的。
所以上述代码中,有一些内容其实我们就可以删掉了:
所以我们需要近一步删除这些代码:
import { buttonGroupInjectionKey } from './ButtonGroup'
import useRtl from '../../_mixins/use-rtl'
const NButtonGroup = inject(buttonGroupInjectionKey, {})
以及其他使用到 buttonGroup
相关的内容。
理解输入
通过上一步,我们基本上去除了所有无关的内容,达到了我们最终高潮 MVP 项目里需要的 Button 的所有的、最精简的内容,也就是说我们核心入口代码自身和依赖的部分已经确定了,那么接下来就需要处理全部的输入,以及删除这些输入中相关的依赖与 Button 处理无关的逻辑。
我们可以看到 Button 主要有如下一种输入:
我们可以看到,import
相关的输入主要分为两类:
而钩子 useFormItem
、或全局状态注入 inject(...)
相关的输入则也依赖于 import
里自身项目的其他相对路径引入。
我们需要顺着如下的这些依赖,进行依赖分析:
import { createHoverColor, createPressedColor } from '../../_utils/color/index'
import { useConfig, useFormItem, useTheme } from '../../_mixins'
import {
NFadeInExpandTransition,
NIconSwitchTransition,
NBaseLoading,
NBaseWave
} from '../../_internal'
import { call, createKey } from '../../_utils'
import { buttonLight } from '../styles'
import { buttonGroupInjectionKey } from './ButtonGroup'
import style from './styles/button.cssr'
这些依赖里面有些自己本就是叶子依赖,并无其它依赖,如:
import { createHoverColor, createPressedColor } from "../../_utils/color/index";
// 其中某几项
import { useFormItem } from "../../_mixins";
// 下面的某几项
import {
NFadeInExpandTransition,
NIconSwitchTransition,
} from "../../_internal";
import { call, createKey, getSlot, flatten } from "../../_utils";
这些叶子依赖可以直接对照着原仓库建立对应的目录结构和文件命名,然后把代码拷贝过来。
对于那些非叶子依赖,我们需要再下一番功夫继续解析其依赖,重复之前的两项操作:
最后就是对照着源码的目录结构创建一样的结构,将处理完无关内容的代码拷贝过去。
打个比方,对于非叶子依赖 style
:
import style from "./styles/button.cssr.js";
我们需要去到对应的文件下,查看其依赖:
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'
发现其依赖了用于进行 BEM 规范定义的 cssr
库(自建)、以及处理动画的一些 fadeInWidthExpandTransition
和 iconSwitchTransition
依赖,那么接着要继续进入这些依赖,如:
import { c, cB, cE, cM, cNotM } from '../../../_utils/cssr'
它的依赖如下:
/* eslint-disable @typescript-eslint/restrict-template-expressions */
import CSSRender, { CNode, CProperties } from 'css-render'
import BEMPlugin from '@css-render/plugin-bem'
发现没有其他再需要继续递归寻找的依赖了,都是引入的第三方库,那么就可以去查阅一下对应的第三方库的文档,了解 API 的含义即可。
如此往复进行上述的依赖分析,直至收敛,最后我们会得到一个如下的文件组织图:
.
├── App.vue
├── _internal
│ ├── fade-in-expand-transition
│ │ ├── index.js
│ │ └── src
│ │ └── FadeInExpandTransition.jsx
│ ├── icon
│ │ ├── index.js
│ │ └── src
│ │ ├── Icon.jsx
│ │ └── styles
│ │ └── index.cssr.js
│ ├── icon-switch-transition
│ │ ├── index.js
│ │ └── src
│ │ └── IconSwitchTransition.jsx
│ ├── index.js
│ ├── loading
│ │ ├── index.js
│ │ └── src
│ │ ├── Loading.jsx
│ │ └── styles
│ │ └── index.cssr.js
│ └── wave
│ ├── index.js
│ └── src
│ ├── Wave.jsx
│ └── styles
│ └── index.cssr.js
├── _mixins
│ ├── index.js
│ ├── use-config.js
│ ├── use-form-item.js
│ ├── use-style.js
│ └── use-theme.js
├── _styles
│ ├── common
│ │ ├── _common.js
│ │ ├── index.js
│ │ └── light.js
│ ├── global
│ │ └── index.cssr.js
│ └── transitions
│ ├── fade-in-width-expand.cssr.js
│ └── icon-switch.cssr.js
├── _utils
│ ├── color
│ │ └── index.js
│ ├── cssr
│ │ ├── create-key.js
│ │ └── index.js
│ ├── index.js
│ ├── naive
│ │ ├── index.js
│ │ └── warn.js
│ └── vue
│ ├── call.js
│ ├── flatten.js
│ ├── get-slot.js
│ └── index.js
├── assets
│ └── logo.png
├── button
│ ├── src
│ │ ├── Button.jsx
│ │ └── styles
│ │ └── button.cssr.js
│ └── styles
│ ├── _common.js
│ ├── index.js
│ └── light.js
├── components
│ └── Button.jsx
├── config-provider
│ └── src
│ └── ConfigProvider.js
└── main.js
32 directories, 45 files
一个简单的 Button 竟然要包含 45 个文件,32个目录来进行支撑,我们基本上可以确定组件库中 90% 的内容是共通的,只需要理解了一个 Button 需要的所有底层依赖和设计理念,理解这个组件库只需要再努力一步,了解剩下 10 % 的各组件特殊设计,就可以弄懂整个组件库的源码。
上述核心整理的一个 Button 的全部依赖代码可以进入我的 Github 仓库查阅:https://github.com/pftom/naive-app。
抽丝剥茧
当我们能够拿到一个 Button 能够完美运行背后所需要的所有 “必要” 和 “最简” 的依赖之后,我们就可以边运行这个项目,边通过查阅资料,画思维导图理解这份最简必要代码了。
我们首先把代码跑起来,然后逐层理解代码逻辑,如前置的几个钩子函数是干嘛的:
核心的 useTheme
钩子是干嘛的:
用户自定义相关的钩子函数又是干嘛的,它包含哪些 CSS 变量:
Vue3 组件里面的 setup
返回值有哪些:
最终用于渲染的 render
函数逻辑是干嘛的:
通过查阅 Vue3 文档、梳理整个代码流程,然后了解各个分支是如何运作的,我们就能慢慢理解 Button 组件是如何跑起来的。得益于我们进行了代码的最精简化处理,所以整个看代码的流程虽然会慢一点,但是整体需要理解的内容相比之前我们拿到一整份源码,几百上千个文件来一股脑从入口开始打断点调试会好很多。
写在最后
相信大家在看这篇源码阅读文章之前,应该也看过各种大牛的源码解读文章,但是相信每个人都有自己比较独特的看源码技巧,虽然我这里是拿如何看懂 NaiveUI 的源码举例子,但是相信所有看源码的过程都是如此,遵循如下步骤:
这是皮汤在看 Vite、NaiveUI 源码过程中总结出来的经验,相信能够为徘徊在看源码路上却没有方法的同学提供一点指引,你完全可以应用这个技巧去看其他的源码,如 Webpack?qiankun?Ant Design?或者抖音最近发布的 Semi Design。共勉 💪
参考资料
[1]
尤大都推荐的组件库是如何开发出来的?: https://mp.weixin.qq.com/s?\_\_biz=MzkxMjI3OTA3NQ==&mid=2247485296&idx=1&sn=61b6de490f9d437215cadde9502d75df&chksm=c10e143cf6799d2afe13b5aecebc2b6608bd0fab3e61149b80910ce87757ceb2219651ed9a07&token=586597170&lang=zh\_CN#rd
[2]
Semi Design: https://github.com/DouyinFE/semi-design
[3]
React: https://github.com/facebook/react
[4]
贡献指南: https://github.com/TuSimple/naive-ui/blob/main/CONTRIBUTING.md
[5]
CSS Render: https://github.com/07akioni/css-render