基於 Vite 搭建開發體驗超級絲滑的 Vue3 元件庫開發框架

jrainlau發表於2021-12-12

說到 Vue 的元件庫,大家肯定早已耳熟能詳,隨隨便便就能列舉出一大堆。那為什麼還需要自己去搭建呢?結合自身的經驗,在業務中往往需要高度定製化的元件,無論是 UI 和互動,可能都會跟市面上現有的元件庫有著較大的出入。這個時候如果是基於現有的元件庫進行修改的話,其理解成本和修改成本也不小,甚至比自己搭建一套還要高。因此搭建一套自己的元件庫還是一個相當常見的需求。

對於一個元件庫來說,除了”元件“本身以外,另個一個非常重要的東西就是文件展示。參考市面上優秀的開源元件庫,無一不是既有高質量的元件,更有一套非常規範且詳細的文件。文件除了對元件的功能進行說明以外,同時也具備了元件互動預覽的能力,讓使用者的學習成本儘可能地降低。

對於許多程式設計師來說,最討厭的無非是兩件事。一件是別人不寫文件,另一件是自己寫文件。既然在元件庫裡文件是必不可少的,那麼我們應該儘可能地減少寫文件的痛苦,尤其是這種既要有程式碼展示、又要有文字說明的文件。

市面上對於元件文件展示的框架也有不少,比如 Story Book、Docz、Dumi 等等。它們都有一套自己的規則能夠讓你展示自己的元件,但是對於團隊來說學習成本較高,同時它們也在一定程度上割裂了“開發”和“寫文件”之間的體驗。

如果在開發元件庫的過程中能夠一邊開發一邊預覽除錯,預覽除錯的內容就是文件的一部分就好了。開發者只需要關注元件本身的開發,然後再稍微補上一點必要的 API 和事件說明即可。

我們這次就要來搭建這麼一套體驗超級絲滑的元件庫開發框架。先上一個最終成果的例子,隨後再一步一步地教大家去實現。

image

線上體驗

Github 倉庫

演示視訊

一、開發框架初始化

這一套開發框架我們把它命名為 MY-Kit。在技術選型上使用的是 Vite + Vue3 + Typescript。

在空白目錄執行下列命令:

yarn create vite

依次填寫專案名稱和選擇框架為 vue-ts 後,將會自動完成專案的初始化,程式碼結構如下:

.
├── README.md
├── index.html
├── package.json
├── public
├── src
├── tsconfig.json
├── vite.config.ts
└── yarn.lock

在根目錄下新建一個 /packages 目錄,後續元件的開發都會在該目錄進行。以一個 <my-button /> 元件為例,看看 /packages 目錄內部是什麼樣的:

packages
├── Button
│   ├── docs
│   │   ├── README.md  // 元件文件
│   │   └── demo.vue   // 互動式預覽例項
│   ├── index.ts       // 模組匯出檔案
│   └── src
│       └── index.vue  // 元件本體
├── index.ts           // 元件庫匯出檔案
└── list.json          // 元件列表

下面分別看看這些檔案都是些什麼內容。


packages/Button/src/index.vue

該檔案是元件的本體,程式碼如下:

<template>
  <button class="my-button" @click="$emit('click', $event)">
    <slot></slot>
  </button>
</template>

<script lang="ts" setup>
defineEmits(['click']);
</script>

<style scoped>
.my-button {
  // 樣式部分省略
}
</style>

packages/Button/index.ts

為了讓元件庫既允許全域性呼叫:

import { createApp } from 'vue'
import App from './app.vue'

import MyKit from 'my-kit'

createApp(App).use(MyKit)

也允許區域性呼叫:

import { Button } from 'my-kit'

Vue.component('my-button', Button)

因此需要為每一個元件定義一個 VuePlugin 的引用方式。package/Button/index.ts 的內容如下:

import { App, Plugin } from 'vue';
import Button from './src/index.vue';

export const ButtonPlugin: Plugin = {
  install(app: App) {
    app.component('q-button', Button);
  },
};

export { Button };

packages/index.ts

該檔案是作為元件庫本身的匯出檔案,它預設匯出了一個 VuePlugin,同時也匯出了不同的元件:

import { App, Plugin } from 'vue';

import { ButtonPlugin } from './Button';

const MyKitPlugin: Plugin = {
  install(app: App) {
    ButtonPlugin.install?.(app);
  },
};

export default MyKitPlugin;

export * from './Button';

/packages/list.json

最後就是元件庫的一個記述檔案,用來記錄了它裡面元件的各種說明,這個我們後面會用到:

[
  {
    "compName": "Button",
    "compZhName": "按鈕",
    "compDesc": "這是一個按鈕",
    "compClassName": "button"
  }
]

完成了上述元件庫目錄的初始化以後,此時我們的 MY-Kit 是已經可以被業務側直接使用了。

回到根目錄下找到 src/main.ts 檔案,我們把整個 MY-Kit 引入:

import { createApp } from 'vue'
import App from './App.vue'

import MyKit from '../packages';

createApp(App).use(MyKit).mount('#app')

改寫 src/App.vue,引入 <my-button></my-button> 試一下:

<template>
  <my-button>我是自定義按鈕</my-button>
</template>

執行 yarn dev 開啟 Vite 的伺服器以後,就可以直接在瀏覽器上看到效果了:

image

二、實時可互動式文件

Kapture 2021-12-12 at 11 20 50

一個元件庫肯定不止有 Button 一種元件,每個元件都應該有它獨立的文件。這個文件不僅有對元件各項功能的描述,更應該具有元件預覽、元件程式碼檢視等功能,我們可以把這種文件稱之為“可互動式文件”。同時為了良好的元件開發體驗,我們希望這個文件是實時的,這邊修改程式碼,那邊就可以在文件裡實時地看到最新的效果。接下來我們就來實現這麼一個功能。

元件的文件一般是用 Markdown 來寫,在這裡也不例外。我們希望一個 Markdown 一個頁面,因此需要使用 vue-router@next 來實現路由控制。

在根目錄的 /src 底下新建 router.ts,寫入如下程式碼:

import { createRouter, createWebHashHistory, RouterOptions } from 'vue-router'

const routes = [{
  title: '按鈕',
  name: 'Button',
  path: '/components/Button',
  component: () => import(`packages/Button/docs/README.md`),
}];

const routerConfig = {
  history: createWebHashHistory(),
  routes,
  scrollBehavior(to: any, from: any) {
    if (to.path !== from.path) {
      return { top: 0 };
    }
  },
};

const router = createRouter(routerConfig as RouterOptions);

export default router;

可以看到這是一個典型的 vue-router@next 配置,細心的讀者會發現這裡為 path 為 /components/Button 的路由引入了一個 Markdown 檔案,這個在預設的 Vite 配置裡是無效的,我們需要引入 vite-plugin-md 外掛來解析 Markdown 檔案並把它變成 Vue 檔案。回到根目錄下找到 vite.config.ts,新增該外掛:

import Markdown from 'vite-plugin-md'

export default defineConfig({
  // 預設的配置
  plugins: [
    vue({ include: [/\.vue$/, /\.md$/] }),
    Markdown(),
  ],
})

這樣配置以後,任意的 Markdown 檔案都能像一個 Vue 檔案一樣被使用了。

回到 /src/App.vue,稍作改寫,增加一個側邊欄和主區域:

<template>
  <div class="my-kit-doc">
    <aside>
      <router-link v-for="(link, index) in data.links" :key="index" :to="link.path">{{ link.name }}</router-link>
    </aside>
    <main>
      <router-view></router-view>
    </main>
  </div>
</template>

<script setup>
import ComponentList from 'packages/list.json';
import { reactive } from 'vue'

const data = reactive({
  links: ComponentList.map(item => ({
    path: `/components/${item.compName}`,
    name: item.compZhName
  }))
})
</script>

<style lang="less">
html,
body {
  margin: 0;
  padding: 0;
}
.my-kit-doc {
  display: flex;
  min-height: 100vh;
  aside {
    width: 200px;
    padding: 15px;
    border-right: 1px solid #ccc;
  }
  main {
    width: 100%;
    flex: 1;
    padding: 15px;
  }
}
</style>

最後我們往 /packages/Button/docs/README.md 裡面隨便寫點東西:

# 按鈕元件

<my-button>我是自定義按鈕</my-button>

完成以後就能在瀏覽器上看到效果了:
image

由於我們全域性引入了 MY-Kit,所以裡面所註冊的自定義元件都可以直接在 Markdown 檔案中像普通 HTML 標籤一樣被寫入並被正確渲染。但是這裡也有另一個問題,就是這些元件都是靜態的無事件的,無法執行 JS 邏輯。比如當我想要實現點選按鈕觸發 click 事件然後彈一個告警彈窗出來,是無法直接這麼寫的:

# 按鈕元件

<my-button @click="() => { alert(123) }">我是自定義按鈕</my-button>

那怎麼辦呢?還記得剛剛引入的解析 Markdown 的外掛 vite-plugin-md 嗎?仔細看它的文件,它是支援在 Markdown 裡面寫 setup 函式的!因此我們可以把需要執行 JS 邏輯的程式碼封裝成一個元件,然後在 Markdown 裡通過 setup 來引入。

首先在 packages/Button/docs 目錄下新建一個 demo.vue

<template>
  <div>
    <my-button @click="onClick(1)">第一個</my-button>
    <my-button @click="onClick(2)">第二個</my-button>
    <my-button @click="onClick(3)">第三個</my-button>
  </div>
</template>

<script setup>
const onClick = (num) => { console.log(`我是第 ${num} 個自定義按鈕`) }
</script>

然後在 Markdown 裡把它引進來:

<script setup>
import demo from './demo.vue'
</script>

# 按鈕元件

<demo />

最後就能實現點選響應了。
Kapture 2021-12-11 at 14 43 53

與此同時,如果我們對 <my-button /> 的本體 Vue 檔案進行任何的修改,都能夠實時在文件中體現出來。

三、程式碼預覽功能

可互動式文件已經基本弄好了,但還有一個問題,就是不能直觀地預覽程式碼。你可能會說,要預覽程式碼很簡單啊,直接在 Markdown 裡面把程式碼貼進去不就好了?話雖如此並沒有錯,但是秉承著“偷懶才是第一生產力”,估計沒有人喜歡把自己寫過的程式碼再抄一遍,肯定是希望能夠有個辦法既能夠在文件裡把所寫的 demo 展示出來,又能直接看到它的程式碼,比如說這樣:

Kapture 2021-12-12 at 11 26 58

只要把元件放進一個 <Preview /> 標籤內就能直接展示元件的程式碼,同時還具有程式碼高亮的功能,這才是可互動式文件真正具備的樣子!接下來我們就來研究一下應該如何實現這個功能。

在 Vite 的開發文件裡有記載到,它支援在資源的末尾加上一個字尾來控制所引入資源的型別。比如可以通過 import xx from 'xx?raw' 以字串形式引入 xx 檔案。基於這個能力,我們可以在 <Preview /> 元件中獲取所需要展示的檔案原始碼。

首先來新建一個 Preview.vue 檔案,其核心內容是通過 Props 拿到原始碼的路徑,然後通過動態 import 的方式把原始碼拿到。以下展示核心程式碼(模板部分暫時略過)

export default {
  props: {
    /** 元件名稱 */
    compName: {
      type: String,
      default: '',
      require: true,
    },
    /** 要顯示程式碼的元件 */
    demoName: {
      type: String,
      default: '',
      require: true,
    },
  },
  data() {
    return {
      sourceCode: '',
    };
  },
  mounted() {
    this.sourceCode = (
      await import(/* @vite-ignore */ `../../packages/${this.compName}/docs/${this.demoName}.vue?raw`)
    ).default;
  }
}

這裡需要加 @vite-ignore 的註釋是因為 Vite 基於 Rollup,在 Rollup 當中動態 import 是被要求傳入確定的路徑,不能是這種動態拼接的路徑。具體原因和其靜態分析有關,感興趣的同學可以自行搜尋瞭解。此處加上該註釋則會忽略 Rollup 的要求而直接支援該寫法。

但是這樣的寫法在 dev 模式下可用,待真正執行 build 構建了以後再執行會發現報錯。其原因也是同樣的,由於 Rollup 無法進行靜態分析,因此它無法在構建階段處理需要動態 import 的檔案,導致會出現找不到對應資源的情況。這個問題截止到目前(2021.12.11)暫時沒有好的辦法,只好判斷環境變數,在 build 模式下通過 fetch 請求檔案的原始碼來繞過。改寫後如下:

const isDev = import.meta.env.MODE === 'development';

if (isDev) {
  this.sourceCode = (
    await import(/* @vite-ignore */ `../../packages/${this.compName}/docs/${this.demoName}.vue?raw`)
  ).default;
} else {
  this.sourceCode = await fetch(`/packages/${this.compName}/docs/${this.demoName}.vue`).then((res) => res.text());
}
假設構建後的輸出目錄為 /docs,記得在構建後也要把 /packages 目錄複製過去,否則在 build 模式下執行會出現 404 的情況。

可能又有同學會問,為什麼要這麼麻煩,直接在 dev 模式下也走 fetch 請求的方式不行麼?答案是不行,因為在 Vite 的 dev 模式下,它本來就是通過 http 請求去拉取檔案資源並處理完了才給到了業務的那一層。因此在 dev 模式下通過 fetch 拿到的 Vue 檔案原始碼是已經被 Vite 給處理過的。

拿到了原始碼以後,只需要展示出來即可:

<template>
  <pre>{{ sourceCode }}</pre>
</template>

image

但是這樣的原始碼展示非常醜,只有乾巴巴的字元,我們有必要給它們加個高亮。高亮的方案我選擇了 PrismJS,它非常小巧又靈活,只需要引入一個相關的 CSS 主題檔案,然後執行 Prism.highlightAll() 即可。本例所使用的 CSS 主題檔案已經放置在倉庫,可以自行取用。

回到專案,執行 yarn add prismjs -D 安裝 PrismJS,然後在 <Preview /> 元件中引入:

import Prism from 'prismjs';
import '../assets/prism.css'; // 主題 CSS

export default {
  // ...省略...
  async mounted() {
    // ...省略...
    await this.$nextTick(); // 確保在原始碼都渲染好了以後再執行高亮
    Prism.highlightAll();
  },
}

由於 PrismJS 沒有支援 Vue 檔案的宣告,因此 Vue 的原始碼高亮是通過將其設定為 HTML 型別來實現的。在 <Preview /> 元件的模板中我們直接指定原始碼的型別為 HTML:

<pre class="language-html"><code class="language-html">{{ sourceCode }}</code></pre>

image

這樣調整了以後,PrismJS 就會自動高亮原始碼了。

四、命令式新建元件

到目前為止,我們的整個“實時可互動式文件”已經搭建完了,是不是意味著可以交付給其他同學進行真正的元件開發了呢?假設你是另一個開發同學,我跟你說:“你只要在這裡,這裡和這裡新建這些檔案,然後在這裡和這裡修改一下配置就可以新建一個元件了!”你會不會很想打人?作為元件開發者的你,並不想關心我的配置是怎樣的,框架是怎麼跑起來的,只希望能夠在最短時間內就能夠初始化一個新的元件然後著手開發。為了滿足這個想法,我們有必要把之前處理的步驟變得更加自動化一些,學習成本更低一些。

國際慣例,先看完成效果再看實現方式:
Kapture 2021-12-11 at 22 30 07

從效果圖可以看到,在終端回答了三個問題後,自動就生成了一個新的元件 Foo。與此同時,無論是新建檔案還是修改配置都是一鍵完成,完全不需要人工干預,接下來的工作只需要圍繞 Foo 這一個新元件開展即可。我們可以把這種一鍵生成元件的方式成為“命令式新建元件”。

要實現這個功能,我們 inquirerhandlebars 這兩個工具。前者用於建立互動式終端提出問題並收集答案;後者用於根據模板生成內容。我們首先來做互動式終端。

回到根目錄下,新建 /script/genNewComp 目錄,然後建立一個 infoCollector.js 檔案:

const inquirer = require('inquirer')
const fs = require('fs-extra')
const { resolve } = require('path')

const listFilePath = '../../packages/list.json'

// FooBar --> foo-bar
const kebabCase = string => string
  .replace(/([a-z])([A-Z])/g, "$1-$2")
  .replace(/[\s_]+/g, '-')
  .toLowerCase();

module.exports = async () => {
  const meta = await inquirer
    .prompt([
      {
        type: 'input',
        message: '請輸入你要新建的元件名(純英文,大寫開頭):',
        name: 'compName',
      },
      {
        type: 'input',
        message: '請輸入你要新建的元件名(中文):',
        name: 'compZhName'
      },
      {
        type: 'input',
        message: '請輸入元件的功能描述:',
        name: 'compDesc',
        default: '預設:這是一個新元件'
      }
    ])
  const { compName } = meta
  meta.compClassName = kebabCase(compName)
  return meta
}

通過 node 執行該檔案時,會在終端內依次提出三個元件資訊相關的問題,並把答案 compName(元件英文名),compZhName (元件中文名)和 compDesc(元件描述)儲存在 meta 物件中並匯出。

收集到了元件相關資訊後,就要通過 handlebars 替換模板中的內容,生成或修改檔案了。

/script/genNewComp 中新建一個 .template 目錄,然後根據需要去建立新元件所需的所有檔案的模板。在我們的框架中,一個元件的目錄是這樣的:

Foo
├── docs
│   ├── README.md
│   └── demo.vue
├── index.ts
└── src
    └── index.vue

一共是4個檔案,因此需要新建 index.ts.tplindex.vue.tplREADME.md.tpldemo.vue.tpl。同時由於新元件需要一個新的路由,因此router.ts 也是需要一個對應的模板。由於篇幅關係就不全展示了,只挑最核心的 index.ts.tpl 來看看:

import { App, Plugin } from 'vue';
import {{ compName }} from './src/index.vue';

export const {{ compName }}Plugin: Plugin = {
  install(app: App) {
    app.component('my-{{ compClassName }}', {{ compName }});
  },
};

export {
  {{ compName }},
};

位於雙括號{{}} 中的內容最終會被 handlebars 所替換,比如我們已經得知一個新元件的資訊如下:

{
  "compName": "Button",
  "compZhName": "按鈕",
  "compDesc": "這是一個按鈕",
  "compClassName": "button"
}

那麼模板 index.ts.tpl 最終會被替換成這樣:

import { App, Plugin } from 'vue';
import Button from './src/index.vue';

export const ButtonPlugin: Plugin = {
  install(app: App) {
    app.component('my-button', Button);
  },
};

export { Button };

模板替換的核心程式碼如下:

const fs = require('fs-extra')
const handlebars = require('handlebars')
const { resolve } = require('path')

const installTsTplReplacer = (listFileContent) => {
  // 設定輸入輸出路徑
  const installFileFrom = './.template/install.ts.tpl'
  const installFileTo = '../../packages/index.ts'

  // 讀取模板內容
  const installFileTpl = fs.readFileSync(resolve(__dirname, installFileFrom), 'utf-8')

  // 根據傳入的資訊構造資料
  const installMeta = {
    importPlugins: listFileContent.map(({ compName }) => `import { ${compName}Plugin } from './${compName}';`).join('\n'),
    installPlugins: listFileContent.map(({ compName }) => `${compName}Plugin.install?.(app);`).join('\n    '),
    exportPlugins: listFileContent.map(({ compName }) => `export * from './${compName}'`).join('\n'),
  }

  // 使用 handlebars 替換模板內容
  const installFileContent = handlebars.compile(installFileTpl, { noEscape: true })(installMeta)

  // 渲染模板並輸出至指定目錄
  fs.outputFile(resolve(__dirname, installFileTo), installFileContent, err => {
    if (err) console.log(err)
  })
}

上述程式碼中的 listFileContent 即為 /packages/list.json 中的內容,這個 JSON 檔案也是需要根據新元件而動態更新。

在完成了模板替換的相關邏輯後,就可以把它們都收歸到一個可執行檔案中了:

const infoCollector = require('./infoCollector')
const tplReplacer = require('./tplReplacer')

async function run() {
  const meta = await infoCollector()
  tplReplacer(meta)
}

run()

新增一個 npm script 到 package.json

{
  "scripts": {
    "gen": "node ./script/genNewComp/index.js"
  },
}

接下來只要執行 yarn gen 就可以進入互動式終端,回答問題自動完成新建元件檔案、修改配置的功能,並能夠在可互動式文件中實時預覽效果。

五、分開文件和庫的構建邏輯

在預設的 Vite 配置中,執行 yarn build 所構建出來的產物是“可互動式文件網站”,並非“元件庫”本身。為了構建一個 my-kit 元件庫併發布到 npm,我們需要將構建的邏輯分開。

在根目錄下新增一個 /build 目錄,依次寫入 base.jslib.jsdoc.js,分別為基礎配置、庫配置和文件配置。


base.js

基礎配置,需要確定路徑別名、配置 Vue 外掛和 Markdown 外掛用於對應檔案的解析。

import { resolve } from 'path';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Markdown from 'vite-plugin-md';

// 文件: https://vitejs.dev/config/
export default defineConfig({
  resolve: {
    alias: {
      '@': resolve(__dirname, './src'),
      packages: resolve(__dirname, './packages'),
    },
  },
  plugins: [
    vue({ include: [/\.vue$/, /\.md$/] }),
    Markdown(),
  ],
});

lib.js

庫構建,用於構建位於 /packages 目錄的元件庫,同時需要 vite-plugin-dts 來幫助把一些 TS 宣告檔案給打包出來。

import baseConfig from './base.config';
import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';

export default defineConfig({
  ...baseConfig,
  build: {
    outDir: 'dist',
    lib: {
      entry: resolve(__dirname, '../packages/index.ts'),
      name: 'MYKit',
      fileName: (format) => `my-kit.${format}.js`,
    },
    rollupOptions: {
      // 確保外部化處理那些你不想打包進庫的依賴
      external: ['vue'],
      output: {
        // 在 UMD 構建模式下為這些外部化的依賴提供一個全域性變數
        globals: {
          vue: 'Vue'
        }
      }
    }
  },
  plugins: [
    ...baseConfig.plugins,
    dts(),
  ]
});

doc.js

互動式文件構建配置,跟 base 是幾乎一樣的,只需要修改輸出目錄為 docs 即可。

import baseConfig from './vite.base.config';
import { defineConfig } from 'vite';

export default defineConfig({
  ...baseConfig,
  build: {
    outDir: 'docs',
  },
});

還記得前文有提到的構建文件時需要把 /packages 目錄也一併複製到輸出目錄嗎?親測了好幾個 Vite 的複製外掛都不好使,乾脆自己寫一個:

const child_process = require('child_process');

const copyDir = (src, dist) => {
  child_process.spawn('cp', ['-r', , src, dist]);
};

copyDir('./packages', './docs');

完成了上面這些構建配置以後,修改一下 npm script 即可:

"dev": "vite --config ./build/base.config.ts",
"build:lib": "vue-tsc --noEmit && vite build --config ./build/lib.config.ts",
"build:doc": "vue-tsc --noEmit && vite build --config ./build/doc.config.ts && node script/copyDir.js",

build:lib 的產物:

dist
├── my-kit.es.js
├── my-kit.umd.js
├── packages
│   ├── Button
│   │   ├── index.d.ts
│   │   └── src
│   │       └── index.vue.d.ts
│   ├── Foo
│   │   └── index.d.ts
│   └── index.d.ts
├── src
│   └── env.d.ts
└── style.css

build:doc 的產物:

docs
├── assets
│   ├── README.04f9b87a.js
│   ├── README.e8face78.js
│   ├── index.917a75eb.js
│   ├── index.f005ac77.css
│   └── vendor.234e3e3c.js
├── index.html
└── packages

大功告成!

六、尾聲

至此我們的元件開發框架已經基本完成了,它具備了相對完整的程式碼開發、實時互動式文件、命令式新建元件等能力,在它上面開發元件已經擁有了超級絲滑的體驗。當然它距離完美還有很長的距離,比如說單元測試、E2E測試等也還沒整合進去,元件庫的版本管理和 CHANGELOG 還需要接入,這些不完美的部分都很值得補充進去。本文純當拋磚引玉,也期待更多的交流~

相關文章