6-7K Star 的知名開源項目源碼,該怎麼看?

大家好,我是皮湯。最近程序員巴士學習交流羣裏有小夥伴想要了解一下如何看源碼,正好最近有一點心得感悟,之前也寫過一篇實際跑通 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 的所有相關說明,可以看到這個按鈕包含如下內容:

通過右側的目錄,瞭解到一個按鈕首先會有基礎內容,包含 defaultprimaryinfosuccesswarningerror 這幾類,然後需要處理:

上述表明了這個 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 我們的這三點分別如下:

理解這三點之後,接下來我們就需要對照着源碼來理解一下整份文件目錄,瞭解各個目錄之前的依賴關係,見下圖。

我們可以先了解一下大致每個文件夾是幹什麼的:

然後就是一些用於各種工程化配置的文件如:

以及一些和項目強相關,用於瞭解整個想法發展上下文的 CHANGELOG.xx.md ,還有我們之前提到的用於跑通代碼的 CONTRIBUTING 貢獻指南。

有點看懵了。🤯

創建你的高潮 MVP 項目

瞭解了整個 NaiveUI 的項目目錄結構之後,我們就可以着手創建我們的高潮 MVP 項目了,但在這之前我們可以再進行一波簡化,即我們有些內容可以不要:

經過簡化之後,我們的高潮 MVP 項目就只需要如下幾個文件了:

目錄結構如下:

.

├── 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 定義相關的內容我們都是不需要的,所以可以刪除 ButtonPropsNativeButtonPropsMergedPropsXButton 這些類型定義相關的內容。

而導入部分涉及到類型定義相關的我們也可以刪除掉:

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`,
          {
            [`${mergedClsPrefix}-button--rtl`]: this.rtlEnabled,
            [`${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 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.colorthis.ghostthis.textthis.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 庫(自建)、以及處理動畫的一些 fadeInWidthExpandTransitioniconSwitchTransition 依賴,那麼接着要繼續進入這些依賴,如:

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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/XgQqypxGlljWesFU4UcvyA