【由淺入深】vue元件庫實戰開發總結分享

2022-12-27 22:01:18
很慶幸標題能夠趕上2022結束的腳步。本文由淺入深層層遞進,對元件庫的開發過程做個了小結。

由於篇幅有限,陰影部分的內容將在中/下篇介紹。

話不多說,直入主題。

yarn workspace + lerna: 管理元件庫及其生態專案

考慮到元件庫整體需要有多邊資源支援,比如元件原始碼,庫檔案站點,color-gen等類庫工具,程式碼規範設定,vite外掛,腳手架,storybook等等,需要分出很多packages,package之間存在彼此聯絡,因此考慮使用monorepo的管理方式,同時使用yarn作為包管理工具,lerna作為包釋出工具。【相關推薦:、】

在monorepo之前,根目錄就是一個workspace,我們直接通過yarn add/remove/run等就可以對包進行管理。但在monorepo專案中,根目錄下存在多個子包,yarn 命令無法直接操作子包,比如根目錄下無法通過yarn run dev啟動子包package-a中的dev命令,這時我們就需要開啟yarn的workspaces功能,每個子包對應一個workspace,之後我們就可以通過yarn workspace package-a run dev啟動package-a中的dev命令了。

你可能會想,我們直接cd到package-a下執行就可以了,不錯,但yarn workspaces的用武之地並不只此,像auto link,依賴提升,單.lock等才是它在monorepo中的價值所在。

啟用yarn workspaces

我們在根目錄packge.json中啟用yarn workspaces:

{
  "private": true,
  "workspaces": [
    "packages/*"
  ]
}
登入後複製

packages目錄下的每個直接子目錄作為一個workspace。由於我們的根專案是不需要釋出出去的,因此設定private為true。

安裝lerna並初始化

不得不說,yarn workspaces已經具備了lerna部分功能,之所以使用它,是想借用它的釋出工作流以彌補workspaces在monorepo下在這方面的不足。下面我們開始將lerna整合到專案中。

首先我們先安裝一下lerna:

# W指workspace-root,即在專案根目錄下安裝,下同
yarn add lerna -D -W
# 由於經常使用lerna命令也推薦全域性安裝
yarn global add lerna
or
npm i lerna -g
登入後複製

執行lerna init初始化專案,成功之後會幫我們建立了一個lerna.json檔案

lerna init
登入後複製
// lerna.json
{
  "$schema": "node_modules/lerna/schemas/lerna-schema.json",
  "useWorkspaces": true,
  "version": "0.0.0"
}
登入後複製
  • $schema指向的lerna-schema.json描述瞭如何設定lerna.json,設定此欄位後,滑鼠懸浮在屬性上會有對應的描述。注意,以上的路徑值需要你在專案根目錄下安裝lerna。

  • useWorkspaces定義了在lerna bootstrap期間是否結合yarn workspace。

  • 由於lerna預設的工作模式是固定模式,即釋出時每個包的版本號一致。這裡我們修改為independent獨立模式,同時將npm使用者端設定為yarn。如果你喜歡pnpm,just do it!

// lerna.json
{
  "version": "independent",
  "npmClient": "yarn"
}
登入後複製

至此yarn workspaces搭配lerna的monorepo專案就設定好了,非常簡單!

額外的lerna設定

By the way!由於專案會使用commitlint對提交資訊進行校驗是否符合Argular規範,而lerna version預設為我們commit的資訊是"Publish",因此我們需要進行一些額外的設定。

// lerna.json
{
  "command": {
    "version": {
      "message": "chore(release): publish",
      "conventionalCommits": true
    }
  }
}
登入後複製

可以看到,我們使用符合Argular團隊提交規範的"chore(release): publish"代替預設的"Publish"。

conventionalCommits表示當我們執行lerna version,實際上會執行lerna version --conventional-commits幫助我們生成CHANGELOG.md。

小結

在lerna剛釋出的時候,那時的包管理工具還沒有可用的workspaces解決方案,因此lerna自身實現了一套解決方案。時至今日,現代的包管理工具幾乎都內建了workspaces功能,這使得lerna和yarn有許多功能重疊,比如執行包pkg-a的dev命令lerna run dev --stream --scope=pkg-a,我們完全可以使用yarn workspace pkg-a run dev代替。lerna bootstrap --hoist將安裝包提升到根目錄,而在yarn workspaces中直接執行yarn就可以了。

Anyway, 使用yarn作為軟體包管理工具,lerna作為軟體包釋出工具,是在monorepo管理方式下一個不錯的實踐!

整合Lint工具規範化程式碼

很無奈,我知道大部分人都不喜歡Lint,但對我而言,這是必須的。

整合eslint

packages目錄下建立名為@argo-design/eslint-config(非資料夾名)的package

1. 安裝eslint

cd argo-eslint-config
yarn add eslint
npx eslint --init
登入後複製

注意這裡沒有-D或者--save-dev。選擇如下:

安裝完成後手動將devDependencies下的依賴拷貝到dependencies中。或者你手動安裝這一系列依賴。

2. 使用

// argo-eslint-config/package.json
{
  scripts: {
    "lint:script": "npx eslint --ext .js,.jsx,.ts,.tsx --fix --quiet ./"
  }
}
登入後複製

執行yarn lint:script,將會自動修復程式碼規範錯誤警告(如果可以的話)。

3. VSCode儲存時自動修復

安裝VSCode Eslint外掛並進行如下設定,此時在你儲存程式碼時,也會自動修復程式碼規範錯誤警告。

// settings.json
{
  "editor.defaultFormatter": "dbaeumer.vscode-eslint",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  }
}
登入後複製

4. 整合到專案全域性

argo-eslint-config中新建包入口檔案index.js,並將.eslintrc.js的內容拷貝到index.js中

module.exports = {
  env: {
    browser: true,
    es2021: true,
    node: true
  },
  extends: ['plugin:vue/vue3-essential', 'standard-with-typescript'],
  overrides: [],
  parserOptions: {
    ecmaVersion: 'latest',
    sourceType: 'module'
  },
  plugins: ['vue'],
  rules: {}
}
登入後複製

確保package.json設定main指向我們剛剛建立的index.js。

// argo-eslint-config/package.json
{
   "main": "index.js"
}
登入後複製

根目錄package.json新增如下設定

// argo-eslint-config/package.json
{
  "devDependencies": {
    "@argo-design/eslint-config": "^1.0.0"
  },
  "eslintConfig": {
    "root": true,
    "extends": [
      "@argo-design"
    ]
  }
}
登入後複製

最後執行yarn重新安裝依賴。

注意包命名與extends書寫規則;root表示根設定,對eslint組態檔冒泡查詢到此為止。

整合prettier

接下來我們引入formatter工具prettier。首先我們需要關閉eslint規則中那些與prettier衝突或者不必要的規則,最後由prettier代為實現這些規則。前者我們通過eslint-config-prettier實現,後者藉助外掛eslint-plugin-prettier實現。比如衝突規則尾逗號,eslint-config-prettier幫我們遮蔽了與之衝突的eslint規則:

{
  "comma-dangle": "off",
  "no-comma-dangle": "off",
  "@typescript-eslint/comma-dangle": "off",
  "vue/comma-dangle": "off",
}
登入後複製

通過設定eslint規則"prettier/prettier": "error"讓錯誤暴露出來,這些錯誤交給eslint-plugin-prettier收拾。

prettier設定我們也新建一個package@argo-design/prettier-config

1. 安裝

cd argo-prettier-config
yarn add prettier
yarn add eslint-config-prettier eslint-plugin-prettier
登入後複製

2. 使用

// argo-prettier-config/index.js
module.exports = {
  printWidth: 80, //一行的字元數,如果超過會進行換行,預設為80
  semi: false, // 行尾是否使用分號,預設為true
  trailingComma: 'none', // 是否使用尾逗號
  bracketSpacing: true // 物件大括號直接是否有空格
};
登入後複製

完整設定參考官網

3. 設定eslint

回到argo-eslint-config/index.js,只需新增如下一條設定即可

module.exports = {
   "extends": ["plugin:prettier/recommended"]
};
登入後複製

plugin:prettier/recommended指的eslint-plugin-prettierpackage下的recommended.js。該擴充套件已經幫我們設定好了

{
  "extends": ["eslint-config-prettier"],
  "plugins": ["eslint-plugin-prettier"],
  "rules": {
    "prettier/prettier": "error",
    "arrow-body-style": "off",
    "prefer-arrow-callback": "off"
  }
}
登入後複製

4. 整合到專案全域性

根目錄package.json新增如下設定

{
  "devDependencies": {
    "@argo-design/prettier-config": "^1.0.0"
  },
  "prettier": "@argo-design/prettier-config"
}
登入後複製

執行yarn重新安裝依賴。

5. VSCode安裝prettier擴充套件並將其設定成預設格式化工具

// settings.json
{
  "editor.defaultFormatter": "esbenp.prettier-vscode"
}
登入後複製

整合stylelint

stylelint設定我們也新建一個package@argo-design/stylelint-config

1. 安裝

cd argo-stylelint-config
yarn add stylelint stylelint-prettier stylelint-config-prettier stylelint-order stylelint-config-rational-order postcss-html postcss-less
# 單獨postcss8
yarn add postcss@^8.0.0
登入後複製

對於結合prettier這裡不在贅述。

stylelint-order允許我們自定義樣式屬性名稱順序。而stylelint-config-rational-order為我們提供了一套合理的開箱即用的順序。

值得注意的是,stylelint14版本不在預設支援less,sass等預處理語言。並且stylelint14依賴postcss8版本,可能需要單獨安裝,否則vscode 的stylellint擴充套件可能提示報錯TypeError: this.getPosition is not a function at LessParser.inlineComment....

2. 使用

// argo-stylelint-config/index.js
module.exports = {
  plugins: [
    "stylelint-prettier",
  ],
  extends: [
    // "stylelint-config-standard",
    "stylelint-config-standard-vue", 
    "stylelint-config-rational-order",
    "stylelint-prettier/recommended"
  ],
  rules: {
    "length-zero-no-unit": true, // 值為0不需要單位
    "plugin/rational-order": [
      true,
      {
        "border-in-box-model": true, // Border理應作為盒子模型的一部分 預設false
        "empty-line-between-groups": false // 組之間新增空行 預設false
      }
    ]
  },
  overrides: [
    {
      files: ["*.html", "**/*.html"],
      customSyntax: "postcss-html"
    },
    {
      files: ["**/*.{less,css}"],
      customSyntax: "postcss-less"
    }
  ]
};
登入後複製

3. 整合到專案全域性

根目錄package.json新增如下設定

{
  "devDependencies": {
    "@argo-design/stylelint-config": "^1.0.0"
  },
  "stylelint": {
    "extends": [
      "@argo-design/stylelint-config"
    ]
  }
}
登入後複製

執行yarn重新安裝依賴。

4. VSCode儲存時自動修復

VSCode安裝Stylelint擴充套件並新增設定

// settings.json
{
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true,
    "source.fixAll.stylelint": true
  },
  "stylelint.validate": ["css", "less", "vue", "html"],
  "css.validate": false,
  "less.validate": false
}
登入後複製

修改settings.json之後如不能及時生效,可以重新啟動一下vscode。如果你喜歡,可以將eslint,prettier,stylelint設定安裝到全域性並整合到編輯器。

整合husky

為防止一些非法的commitpush,我們藉助git hooks工具在對程式碼提交前進行 ESLint 與 Stylelint的校驗,如果校驗通過,則成功commit,否則取消commit。

1. 安裝

# 在根目錄安裝husky
yarn add husky -D -W
登入後複製

2. 使用

npm pkg set scripts.prepare="husky install"
npm run prepare
# 新增pre-commit勾點,在提交前執行程式碼lint
npx husky add .husky/pre-commit "yarn lint"
登入後複製

至此,當我們執行git commit -m "xxx"時就會先執行lint校驗我們的程式碼,如果lint通過,成功commit,否則終止commit。具體的lint命令請自行新增。

整合lint-staged: 僅校驗staged中檔案

現在,當我們git commit時,會對整個工作區的程式碼進行lint。當工作區檔案過多,lint的速度就會變慢,進而影響開發體驗。實際上我們只需要對暫存區中的檔案進行lint即可。下面我們引入·lint-staged解決我們的問題。

1. 安裝

在根目錄安裝lint-staged

yarn add lint-staged -D -W
登入後複製

2. 使用

在根目錄package.json中新增如下的設定:

{
  "lint-staged": {
    "*.{js,ts,jsx,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{less,css}": [
      "stylelint --fix",
      "prettier --write"
    ],
    "**/*.vue": [
      "eslint --fix",
      "stylelint --fix",
      "prettier --write"
    ]
  }
}
登入後複製

在monorepo中,lint-staged執行時,將始終向上查詢並應用最接近暫存檔案的設定,因此我們可以在根目錄下的package.json中設定lint-staged。值得注意的是,每個glob匹配的陣列中的命令是從左至右依次執行,和webpack的loder應用機制不同!

最後,我們在.husky資料夾中找到pre-commit,並將yarn lint修改為npx --no-install lint-staged

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx --no-install lint-staged
登入後複製

至此,當我們執行git commit -m "xxx"時,lint-staged會如期執行幫我們校驗staged(暫存區)中的程式碼,避免了對工作區的全量檢查。

整合commitlint: 規範化commit message

除了程式碼規範檢查之後,Git 提交資訊的規範也是不容忽視的一個環節,規範精準的 commit 資訊能夠方便自己和他人追蹤專案和把控進度。這裡,我們使用大名鼎鼎的Angular團隊提交規範

commit message格式規範

commit message 由 HeaderBodyFooter 組成。其中Herder時必需的,Body和Footer可選。

Header

Header 部分包括三個欄位 typescopesubject

<type>(<scope>): <subject>
登入後複製
type

其中type 用於說明 commit 的提交型別(必須是以下幾種之一)。

描述
featFeature) 新增一個功能
fixBug修復
docsDocumentation) 檔案相關
style程式碼格式(不影響功能,例如空格、分號等格式修正),並非css樣式更改
refactor程式碼重構
perfPerforment) 效能優化
test測試相關
build構建相關(例如 scopes: webpack、gulp、npm 等)
ci更改持續整合軟體的組態檔和 package 中的 scripts 命令,例如 scopes: Travis, Circle 等
chore變更構建流程或輔助工具,日常事務
revertgit revert
scope

scope 用於指定本次 commit 影響的範圍。

subject

subject 是本次 commit 的簡潔描述,通常遵循以下幾個規範:

  • 用動詞開頭,第一人稱現在時表述,例如:change 代替 changed 或 changes

  • 第一個字母小寫

  • 結尾不加句號.

Body(可選)

body 是對本次 commit 的詳細描述,可以分成多行。跟 subject 類似。

Footer(可選)

如果本次提交的程式碼是突破性的變更或關閉Issue,則 Footer 必需,否則可以省略。

整合commitizen(可選)

我們可以藉助工具幫我們生成規範的message。

1. 安裝

yarn add commitizen -D -W
登入後複製

2. 使用

安裝介面卡

yarn add cz-conventional-changelog -D -W
登入後複製

這行命令做了兩件事:

  • 安裝cz-conventional-changelog到開發依賴

  • 在根目錄下的package.json中增加了:

"config": {
  "commitizen": {
    "path": "./node_modules/cz-conventional-changelog"
  }
}
登入後複製

新增npm scriptscm

"scripts": {
  "cm": "cz"
},
登入後複製

至此,執行yarn cm,就能看到互動介面了!跟著互動一步步操作就能自動生成規範的message了。

整合commitlint: 對最終提交的message進行校驗

1. 安裝

首先在根目錄安裝依賴:

yarn add commitlint @commitlint/cli @commitlint/config-conventional -D -W
登入後複製

2. 使用

接著新建.commitlintrc.js:

module.exports = {
  extends: ["@commitlint/config-conventional"]
};
登入後複製

最後向husky中新增commit-msg勾點,終端執行:

npx husky add .husky/commit-msg "npx --no-install commitlint -e $HUSKY_GIT_PARAMS"
登入後複製

執行成功之後就會在.husky資料夾中看到commit-msg檔案了:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx --no-install commitlint -e
登入後複製

至此,當你提交程式碼時,如果pre-commit勾點執行成功,緊接著在commit-msg勾點中,commitlint會如期執行對我們提交的message進行校驗。

關於lint工具的整合到此就告一段落了,在實際開發中,我們還會對lint設定進行一些小改動,比如ignore,相關rules等等。這些和具體專案有關,我們不會變更package裡的設定。

千萬別投機取巧拷貝別人的組態檔!複製一時爽,程式碼火葬場。

圖示庫

巧婦難為無米之炊。元件庫通常依賴很多圖示,因此我們先開發一個支援按需引入的圖示庫。

假設我們現在已經拿到了一些漂亮的svg圖示,我們要做的就是將每一個圖示轉化生成.vue元件與一個元件入口index.ts檔案。然後再生成彙總所有元件的入口檔案。比如我們現在有foo.svg與bar.svg兩個圖示,最終生成的檔案及結構如下:

相應的內容如下:

// bar.ts
import _Bar from "./bar.vue";

const Bar = Object.assign(_Bar, {
  install: (app) => {
    app.component(_Bar.name, _Bar);
  }
});

export default Bar;
登入後複製
// foo.ts
import _Foo from "./foo.vue";

const Foo = Object.assign(_Foo, {
  install: (app) => {
    app.component(_Foo.name, _Foo);
  }
});

export default Foo;
登入後複製
// argoIcon.ts
import Foo from "./foo";
import Bar from "./bar";

const icons = [Foo, Bar];

const install = (app) => {
  for (const key of Object.keys(icons)) {
    app.use(icons[key]);
  }
};

const ArgoIcon = {
  ...icons,
  install
};

export default ArgoIcon;
登入後複製
// index.ts
export { default } from "./argoIcon";

export { default as Foo } from "./foo";
export { default as Bar } from "./bar";
登入後複製

之所以這麼設計是由圖示庫最終如何使用決定的,除此之外argoIcon.ts也將會是打包umd的入口檔案。

// 全量引入import ArgoIcon from "圖示庫";
app.use(ArgoIcon); 

// 按需引入import { Foo } from "圖示庫";
app.use(Foo);
登入後複製

圖示庫的整個構建流程大概分為以下3步:

1. svg圖片轉.vue檔案

整個流程很簡單,我們通過glob匹配到.svg拿到所有svg的路徑,對於每一個路徑,我們讀取svg的原始文字資訊交由第三方庫svgo處理,期間包括刪除無用程式碼,壓縮,自定義屬性等,其中最重要的是為svg標籤注入我們想要的自定義屬性,就像這樣:

<svg 
  :class="cls" 
  :style="innerStyle"
  :stroke-linecap="strokeLinecap"
  :stroke-linejoin="strokeLinejoin"
  :stroke-width="strokeWidth">
  <path d="..."></path>
</svg>
登入後複製

之後這段svgHtml會傳送給我們預先準備好的摸板字串:

const template = `
<template>
  ${svgHtml}
</template>

<script setup>
defineProps({
    "stroke-linecap": String;
    // ...
  })
  // 省略邏輯程式碼...
</script>
`
登入後複製

為摸板字串填充資料後,通過fs模組的writeFile生成我們想要的.vue檔案。

2. 打包vue元件

在打包構建方案上直接選擇vite為我們提供的lib模式即可,開箱即用,外掛擴充套件(後面會講到),基於rollup,能幫助我們打包生成ESM這是按需引入的基礎。當然,commonjsumd也是少不了的。整個過程我們通過Vite 的JavaScript API實現:

import { build } from "vite";
import fs from "fs-extra";

const CWD = process.cwd();
const ES_DIR = resolve(CWD, "es");
const LIB_DIR = resolve(CWD, "lib");

interface compileOptions {
  umd: boolean;
  target: "component" | "icon";
}

async function compileComponent({
  umd = false,
  target = "component"
}: compileOptions): Promise<void> {
  await fs.emptyDir(ES_DIR);
  await fs.emptyDir(LIB_DIR);
  const config = getModuleConfig(target);
  await build(config);

  if (umd) {
    await fs.emptyDir(DIST_DIR);
    const umdConfig = getUmdConfig(target);
    await build(umdConfig);
  }
}
登入後複製
import { InlineConfig } from "vite";
import glob from "glob";
const langFiles = glob.sync("components/locale/lang/*.ts");

export default function getModuleConfig(type: "component" | "icon"): InlineConfig {
  const entry = "components/index.ts";
  const input = type === "component" ? [entry, ...langFiles] : entry;
  return {
    mode: "production",
    build: {
      emptyOutDir: true,
      minify: false,
      brotliSize: false,
      rollupOptions: {
        input,
        output: [
          {
            format: "es", // 打包模式
            dir: "es", // 產物存放路徑
            entryFileNames: "[name].js", // 入口模組的產物檔名
            preserveModules: true, // 保留模組結構,否則所有模組都將打包在一個bundle檔案中
            /*
             * 保留模組的根路徑,該值會在打包後的output.dir中被移除
             * 我們的入口是components/index.ts,打包後檔案結構為:es/components/index.js
             * preserveModulesRoot設為"components",打包後就是:es/index.js
            */
            preserveModulesRoot: "components" 
          },
          {
            format: "commonjs",
            dir: "lib",
            entryFileNames: "[name].js",
            preserveModules: true,
            preserveModulesRoot: "components",
            exports: "named" // 匯出模式
          }
        ]
      },
      // 開啟lib模式
      lib: {
        entry,
        formats: ["es", "cjs"]
      }
    },
    plugins: [
      // 自定義external忽略node_modules
      external(),
      // 打包宣告檔案
      dts({
        outputDir: "es",
        entryRoot: C_DIR
      })
    ]
  };
};
登入後複製
export default function getUmdConfig(type: "component" | "icon"): InlineConfig {
  const entry =
    type === "component"
      ? "components/argo-components.ts"
      : "components/argo-icons.ts";
  const entryFileName = type === "component" ? "argo" : "argo-icon";
  const name = type === "component" ? "Argo" : "ArgoIcon";


  return {
    mode: "production",
    build: {
      target: "modules", // 支援原生 ES 模組的瀏覽器
      outDir: "dist", // 打包產物存放路徑
      emptyOutDir: true, // 如果outDir在根目錄下,則清空outDir
      sourcemap: true, // 生成sourcemap 
      minify: false, // 是否壓縮
      brotliSize: false, // 禁用 brotli 壓縮大小報告。
      rollupOptions: { // rollup打包選項
        external: "vue", // 匹配到的模組不會被打包到bundle
        output: [
          {
            format: "umd", // umd格式
            entryFileNames: `${entryFileName}.js`, // 即bundle名
            globals: {
              /*
               * format為umd/iife時,標記外部依賴vue,打包後以Vue取代
               * 未定義時打包結果如下
               * var ArgoIcon = function(vue2) {}(vue);
               * rollup自動猜測是vue,但實際是Vue.這會導致報錯
               * 定義後
               * var ArgoIcon = function(vue) {}(Vue);
              */
              vue: "Vue"
            }
          },
          {
            format: "umd",
            entryFileNames: `${entryFileName}.min.js`,
            globals: {
              vue: "Vue"
            },
            plugins: [terser()] // terser壓縮
          },
        ]
      },
      // 開啟lib模式
      lib: {
        entry, // 打包入口
        name // 全域性變數名
      }
    },
    plugins: [vue(), vueJsx()]
  };
};
登入後複製
export const CWD = process.cwd();
export const C_DIR = resolve(CWD, "components");
登入後複製

可以看到,我們通過type區分元件庫和圖示庫打包。實際上打包圖示庫和元件庫都是差不多的,元件庫需要額外打包國際化相關的語言套件檔案。圖示樣式內建在元件之中,因此也不需要額外打包。

3. 打包宣告檔案

我們直接通過第三方庫 打包圖示庫的宣告檔案。

import dts from "vite-plugin-dts";

plugins: [
  dts({
    outputDir: "es",
    entryRoot: C_DIR
  })
]
登入後複製

關於打包原理可參考外掛作者的這片文章。

4. 實現按需引入

我們都知道實現tree-shaking的一種方式是基於ESM的靜態性,即在編譯的時候就能摸清依賴之間的關係,對於"孤兒"會殘忍的移除。但是對於import "icon.css"這種沒匯入匯出的模組,打包工具並不知道它是否具有副作用,索性移除,這樣就導致頁面缺少樣式了。sideEffects就是npm與構建工具聯合推出的一個欄位,旨在幫助構建工具更好的為npm包進行tree-shaking。

使用上,sideEffects設定為false表示所有模組都沒有副作用,也可以設定陣列,每一項可以是具體的模組名或Glob匹配。因此,實現圖示庫的按需引入,只需要在argo-icons專案下的package.json裡新增以下設定即可:

{
  "sideEffects": false,
}
登入後複製

這將告訴構建工具,圖示庫沒有任何副作用,一切沒有被引入的程式碼或模組都將被移除。前提是你使用的是ESM。

指定入口

Last but important!當圖示庫在被作為npm包匯入時,我們需要在package.json為其設定相應的入口檔案。

{
  "main": "lib/index.js", // 以esm形式被引入時的入口
  "module": "es/index.js", // 以commonjs形式被引入時的入口
  "types": "es/index.d.ts" // 指定宣告檔案
}
登入後複製

引入storybook:是時候預覽我們的成果了!

顧名思義,storybook就是一本"書",講了很多個"故事"。在這裡,"書"就是argo-icons,我為它講了3個故事:

  • 基本使用

  • 按需引入

  • 使用iconfont.cn專案

初始化storybook

新建@argo-design/ui-storybookpackage,並在該目錄下執行:

npx storybook init -t vue3 -b webpack5
登入後複製

-t (即--type): 指定專案型別,storybook會根據專案依賴及組態檔等推算專案型別,但顯然我們僅僅是通過npm init新建立的專案,storybook無法自動判斷專案型別,故需要指定type為vue3,然後storybook會幫我們初始化storybook vue3 app。

-b (--builder): 指定構建工具,預設是webpack4,另外支援webpack5, vite。這裡指定webpack5,否則後續會有類似報錯:cannot read property of undefine(reading 'get')...因為storybook預設以webpack4構建,但是@storybook/vue3依賴webpack5,會衝突導致報錯。這裡是天坑!!

storybook預設使用yarn安裝,如需指定npm請使用--use-npm。

這行命令主要幫我們做以下事情:

  • 注入必要的依賴到packages.json(如若沒有指定-s,將幫我們自動安裝依賴)。

  • 注入啟動,打包專案的指令碼。

  • 新增Storybook設定,詳見.storybook目錄。

  • 新增Story範例檔案以幫助我們上手,詳見stories目錄。

其中1,2步具體程式碼如下:

{
  "scripts": {
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook"
  },
  "devDependencies": {
    "@storybook/vue3": "^6.5.13",
    "@storybook/addon-links": "^6.5.13",
    "@storybook/addon-essentials": "^6.5.13",
    "@storybook/addon-actions": "^6.5.13",
    "@storybook/addon-interactions": "^6.5.13",
    "@storybook/testing-library": "^0.0.13",
    "vue-loader": "^16.8.3",
    "@storybook/builder-webpack5": "^6.5.13",
    "@storybook/manager-webpack5": "^6.5.13",
    "@babel/core": "^7.19.6",
    "babel-loader": "^8.2.5"
  }
}
登入後複製

接下來把目光放到.storybook下的main.js與preview.js

preview.js

preview.js可以具名匯出parameters,decorators,argTypes,用於全域性設定UI(stories,介面,控制元件等)的渲染行為。比如預設設定中的controls.matchers:

export const parameters = {
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/
    }
  }
};
登入後複製

它定義瞭如果屬性值是以background或color結尾,那麼將為其啟用color控制元件,我們可以選擇或輸入顏色值,date同理。

除此之外你可以在這裡引入全域性樣式,註冊元件等等。更多詳情見官網

main.js

最後來看看最重要的專案組態檔。

module.exports = {
  stories: [
    "../stories/**/*.stories.mdx",
    "../stories/**/*.stories.@(js|jsx|ts|tsx)"
  ],
  addons: [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@storybook/addon-interactions"
  ],
  framework: "@storybook/vue3",
  core: {
    builder: "@storybook/builder-webpack5"
  },
}
登入後複製
  • stories, 即查詢stroy檔案的Glob。

  • addons, 設定需要的擴充套件。慶幸的是,當前一些重要的擴充套件都已經整合到@storybook/addon-essentials。

  • framework和core即是我們初識化傳遞的-t vue3 -b webpack5

更多詳情見官網

設定並啟動storybook

less設定

由於專案使用到less因此我們需要設定一下less,安裝less以及相關loader。來到.storybook/main.js

module.exports = {
  webpackFinal: (config) => {
    config.module.rules.push({
      test: /.less$/,
      use: [
        {
          loader: "style-loader"
        },
        {
          loader: "css-loader"
        },
        {
          loader: "less-loader",
          options: {
            lessOptions: {
              javascriptEnabled: true
            }
          }
        }
      ]
    });

    return config;
  },
}
登入後複製

設定JSX

storybook預設支援解析jsx/tsx,但你如果需要使用jsx書寫vue3的stories,仍需要安裝相關外掛。

在argo-ui-storybook下安裝 @vue/babel-plugin-jsx

yarn add @vue/babel-plugin-jsx -D
登入後複製

新建.babelrc

{
  "plugins": ["@vue/babel-plugin-jsx"]
}
登入後複製

關於如何書寫story,篇幅受限,請自行查閱範例檔案或官網。

設定完後終端執行yarn storybook即可啟動我們的專案,辛苦的成果也將躍然紙上。

對於UI,在我們的元件庫逐漸豐富之後,將會自建一個獨具元件庫風格的檔案站點,拭目以待。

元件庫

元件通訊

在Vue2時代,元件跨層級通訊方式可謂「百花齊放」,provide/inject就是其中一種。時至今日,在composition,es6,ts加持下,provide/inject可以更加大展身手。

provide/inject原理

在建立元件範例時,會在自身掛載一個provides物件,預設指向父範例的provides。

const instance = {
  provides: parent ? parent.provides : Object.create(appContext.provides)
}
登入後複製

appContext.provides即createApp建立的app的provides屬性,預設是null

在自身需要為子元件供資料時,即呼叫provide()時,會建立一個新物件,該物件的原型指向父範例的provides,同時將provide提供的選項新增到新物件上,這個新物件就是範例新的provides值。程式碼簡化就是

function provide(key, value) { 
  const parentProvides = currentInstance.parent && currentInstance.parent.provides; 
  const newObj = Object.create(parentProvides);
  currentInstance.provides = newObj;
  newObj[key] = value;
}
登入後複製

而inject的實現原理則時通過key去查詢祖先provides對應的值:

function inject(key, defaultValue) { 
  const instance = currentInstance; 
  const provides = instance.parent == null
    ? instance.vnode.appContent && instance.vnode.appContent.provides
    :	instance.parent.provides;

  if(provides && key in provides) {
    return provides[key]
  }
}
登入後複製

你可能會疑惑,為什麼這裡是直接去查父元件,而不是先查自身範例的provides呢?前面不是說範例的provides預設指向父範例的provides麼。但是請注意,是「預設」。如果當前範例執行了provide()是不是把instance.provides「汙染」了呢?這時再執行inject(key),如果provide(key)的key與你inject的key一致,就從當前範例provides取key對應的值了,而不是取父範例的provides!

最後,我畫了2張圖幫助大家理解

新增button元件並完成打包

篇幅有限,本文不會對元件的具體實現講解哦,簡單介紹下檔案

  • __demo__元件使用事例
  • constants.ts定義的常數
  • context.ts上下文相關
  • interface.ts元件介面
  • TEMPLATE.md用於生成README.md的模版
  • button/style下存放元件樣式
  • style下存放全域性樣式

打包esm與commonjs模組

關於打包元件的esmcommonjs模組在之前打包圖示庫章節已經做了介紹,這裡不再贅述。

打包樣式

相對於圖示庫,元件庫的打包需要額外打包樣式檔案,大概流程如下:

  • 生成總入口components/index.less並編譯成css。

  • 編譯元件less。

  • 生成dist下的argo.css與argo.min.css。

  • 構建元件style/index.ts。

1. 生成總入口components/index.less

import path from "path";
import { outputFileSync } from "fs-extra";
import glob from "glob";

export const CWD = process.cwd();
export const C_DIR = path.resolve(CWD, "components");

export const lessgen = async () => {
  let lessContent = `@import "./style/index.less";\n`; // 全域性樣式檔案
  const lessFiles = glob.sync("**/style/index.less", {
    cwd: C_DIR,
    ignore: ["style/index.less"]
  });
  lessFiles.forEach((value) => {
    lessContent += `@import "./${value}";\n`;
  });

  outputFileSync(path.resolve(C_DIR, "index.less"), lessContent);
  log.success("genless", "generate index.less success!");
};
登入後複製

程式碼很簡單,值得一提就是為什麼不將lessContent初始化為空,glob中將ignore移除,這不是更簡潔嗎。這是因為style/index.less作為全域性樣式,我希望它在參照的最頂部。最終將會在components目錄下生成index.less內容如下:

@import "./style/index.less";
@import "./button/style/index.less";
/* other less of components */
登入後複製

2. 打包元件樣式

import path from "path";
import { readFile, copySync } from "fs-extra"
import { render } from "less";

export const ES_DIR = path.resolve(CWD, "es");
export const LIB_DIR = path.resolve(CWD, "lib");

const less2css = (lessPath: string): string => {
  const source = await readFile(lessPath, "utf-8");
  const { css } = await render(source, { filename: lessPath });
  return css;
}

const files = glob.sync("**/*.{less,js}", {
  cwd: C_DIR
});

for (const filename of files) {
  const lessPath = path.resolve(C_DIR, `${filename}`);
  // less檔案拷貝到es和lib相對應目錄下
  copySync(lessPath, path.resolve(ES_DIR, `${filename}`));
  copySync(lessPath, path.resolve(LIB_DIR, `${filename}`));

  // 元件樣式/總入口檔案/全域性樣式的入口檔案編譯成css
  if (/index.less$/.test(filename)) {
    const cssFilename = filename.replace(".less", ".css");
    const ES_DEST = path.resolve(ES_DIR, `${cssFilename}`);
    const LIB_DEST = path.resolve(LIB_DIR, `${cssFilename}`);
    const css = await less2css(lessPath);

    writeFileSync(ES_DEST, css, "utf-8");
    writeFileSync(LIB_DEST, css, "utf-8");
  }
}
登入後複製

3. 生成dist下的argo.css與argo.min.css

import path from "path";
import CleanCSS, { Output } from "clean-css";
import { ensureDirSync } from "fs-extra";
export const DIST_DIR = path.resolve(CWD, "dist");

console.log("start build components/index.less to dist/argo(.min).css");
const indexCssPath = path.resolve(ES_DIR, "index.css");
const css = readFileSync(indexCssPath, "utf8");
const minContent: Output = new CleanCSS().minify(css);

ensureDirSync(DIST_DIR);
writeFileSync(path.resolve("dist/argo.css"), css);
writeFileSync(path.resolve("dist/argo.min.css"), minContent.styles);
log.success(`build components/index.less to dist/argo(.min).css`);
登入後複製

其中最重要的就是使用clean-css壓縮css。

4. 構建元件style/index.ts

如果你使用過babel-plugin-import,那一定熟悉這項設定:

  • ["import", { "libraryName": "antd", "style": true }]: import js and css modularly (LESS/Sass source files)
  • ["import", { "libraryName": "antd", "style": "css" }]: import js and css modularly (css built files)

通過指定style: true,babel-plugin-import可以幫助我們自動引入元件的less檔案,如果你擔心less檔案定義的變數會被覆蓋或衝突,可以指定'css',即可引入元件的css檔案樣式。

這一步就是要接入這點。但目前不是很必要,且涉及到vite外掛開發,暫可略過,後面會講。

來看看最終實現的樣子。

其中button/style/index.js內容也就是匯入less:

import "../../style/index.less";
import "./index.less";
登入後複製

button/style/css.js內容也就是匯入css:

import "../../style/index.css";
import "./index.css";
登入後複製

最後你可能會好奇,諸如上面提及的compileComponentcompileStyle等函數是如何被排程使用的,這其實都歸功於腳手架@argo-design/scripts。當它作為依賴被安裝到專案中時,會為我們提供諸多命令如argo-scripts geniconargo-scripts compileComponent等,這些函數都在執行命令時被呼叫。

設定sideEffects

"sideEffects": [
  "dist/*",
  "es/**/style/*",
  "lib/**/style/*",
  "*.less"
]
登入後複製

國際化

基本實現

// locale.ts
import { ref, reactive, computed, inject } from "vue";
import { isString } from "../_utils/is";
import zhCN from "./lang/zh-cn";

export interface ArgoLang {
  locale: string;
  button: {
    defaultText: string;
  }
}

type ArgoI18nMessages = Record<string, ArgoLang>;

// 預設使用中文
const LOCALE = ref("zh-CN");
const I18N_MESSAGES = reactive<ArgoI18nMessages>({
  "zh-CN": zhCN
});

// 新增語言套件
export const addI18nMessages = (
  messages: ArgoI18nMessages,
  options?: {
    overwrite?: boolean;
  }
) => {
  for (const key of Object.keys(messages)) {
    if (!I18N_MESSAGES[key] || options?.overwrite) {
      I18N_MESSAGES[key] = messages[key];
    }
  }
};

// 切換語言套件
export const useLocale = (locale: string) => {
  if (!I18N_MESSAGES[locale]) {
    console.warn(`use ${locale} failed! Please add ${locale} first`);
    return;
  }
  LOCALE.value = locale;
};

// 獲取當前語言
export const getLocale = () => {
  return LOCALE.value;
};

export const useI18n = () => {
  const i18nMessage = computed<ArgoLang>(() => I18N_MESSAGES[LOCALE.value]);
  const locale = computed(() => i18nMessage.value.locale);

  const transform = (key: string): string => {
    const keyArray = key.split(".");
    let temp: any = i18nMessage.value;

    for (const keyItem of keyArray) {
      if (!temp[keyItem]) {
        return key;
      }
      temp = temp[keyItem];
    }
    return temp;
  };

  return {
    locale,
    t: transform
  };
};
登入後複製

新增需要支援的語言套件,這裡預設支援中文和英文。

// lang/zh-CN.ts
const lang: ArgoLang = {
  locale: "zh-CN",
  button: {
    defaultText: "按鈕"
  },
}
登入後複製
// lang/en-US.ts
const lang: ArgoLang = {
  locale: "en-US",
  button: {
    defaultText: "Button",
  },
}
登入後複製

button元件中接入

<template>
  <button>
    <slot> {{ t("button.defaultText") }} </slot>
  </button>
</template>

<script>
import { defineComponent } from "vue";
import { useI18n } from "../locale";

export default defineComponent({
  name: "Button",
  setup(props, { emit }) {
    const { t } = useI18n();

    return {
      t
    };
  }
});
</script>
登入後複製

Button的國際化僅做演示,實際上國際化在日期日曆等元件中才有用武之地。

國際化演示

argo-ui-storybook/stories中新增locale.stories.ts

import { computed } from "vue";
import { Meta, StoryFn } from "@storybook/vue3";
import {
  Button,
  addI18nMessages,
  useLocale,
  getLocale
} from "@argo-design/argo-ui/components/index"; // 原始檔形式引入方便開發時偵錯
import enUS from "@argo-design/argo-ui/components/locale/lang/en-us";

interface Args {}

export default {
  title: "Component/locale",
  argTypes: {}
} as Meta<Args>;

const BasicTemplate: StoryFn<Args> = (args) => {
  return {
    components: { Button },
    setup() {
      addI18nMessages({ "en-US": enUS });
      const currentLang = computed(() => getLocale());
      const changeLang = () => {
        const lang = getLocale();
        if (lang === "en-US") {
          useLocale("zh-CN");
        } else {
          useLocale("en-US");
        }
      };
      return { args, changeLang, currentLang };
    },
    template: `
      <h1>內部切換語言,當前語言: {{currentLang}}</h1>
      <p>僅在未提供ConfigProvider時生效</p>
      <Button type="primary" @click="changeLang">點選切換語言</Button>
      <Button long style="marginTop: 20px;"></Button>
    `
  };
};
export const Basic = BasicTemplate.bind({});
Basic.storyName = "基本使用";
Basic.args = {};
登入後複製

.preview.js中全域性引入元件庫樣式

import "@argo-design/argo-ui/components/index.less";
登入後複製

終端啟動專案就可以看到效果了。

實現config-provider元件

通常元件庫都會提供config-provider元件來使用國際化,就像下面這樣

<template>
  <a-config-provider :locale="enUS">
    <a-button />
  </a-config-provider>
</template>
登入後複製

下面我們來實現一下config-provider元件:

<template>
  <slot />
</template>

<script>
import type { PropType } from "vue";
import {
  defineComponent,
  provide,
  reactive,
  toRefs,
} from "vue";
import { configProviderInjectionKey } from "./context";

export default defineComponent({
  name: "ConfigProvider",
  props: {
    locale: {
      type: Object as PropType<ArgoLang>
    },
  },
  setup(props, { slots }) {
    const { locale } = toRefs(props);
    const config = reactive({
      locale,
    });

    provide(configProviderInjectionKey, config);
  }
});
</script>
登入後複製
export interface ConfigProvider {
  locale?: ArgoLang;
}

export const configProviderInjectionKey: InjectionKey<ConfigProvider> =
  Symbol("ArgoConfigProvider");
登入後複製

修改locale/index.ts中計算屬性i18nMessage的獲取邏輯

import { configProviderInjectionKey } from "../config-provider/context";

export const useI18n = () => {
  const configProvider = inject(configProviderInjectionKey, undefined);
  const i18nMessage = computed<ArgoLang>(
    () => configProvider?.locale ?? I18N_MESSAGES[LOCALE.value]
  );
  const locale = computed(() => i18nMessage.value.locale);

  const transform = (key: string): string => {
    const keyArray = key.split(".");
    let temp: any = i18nMessage.value;

    for (const keyItem of keyArray) {
      if (!temp[keyItem]) {
        return key;
      }
      temp = temp[keyItem];
    }
    return temp;
  };

  return {
    locale,
    t: transform
  };
};
登入後複製

編寫stories驗證一下:

const ProviderTemplate: StoryFn<Args> = (args) => {
  return {
    components: { Button, ConfigProvider },
    render() {
      return (
        <ConfigProvider {...args}>
          <Button long={true} />
        </ConfigProvider>
      );
    }
  };
};
export const Provider = ProviderTemplate.bind({});
Provider.storyName = "在config-provider中使用";
Provider.args = {
  // 在這裡把enUS傳給ConfigProvider的locale
  locale: enUS
};
登入後複製

以上stories使用到了jsx,請確保安裝並設定了@vue/babel-plugin-jsx

可以看到,Button預設是英文的,表單控制元件也接收到enUS語言套件了,符合預期。

自動引入元件樣式

值得注意的是,上面提到的按需引入只是引入了元件js邏輯程式碼,但對於樣式依然沒有引入。

下面我們通過開發vite外掛vite-plugin-auto-import-style,讓元件庫可以自動引入元件樣式。

效果演示

現在我們書寫的程式碼如下,現在我們已經知道了,這樣僅僅是載入了元件而已。

import { createApp } from "vue";
import App from "./App.vue";
import { Button, Empty, ConfigProvider } from "@argo-design/argo-ui";
import { Anchor } from "@argo-design/argo-ui";
createApp(App)
  .use(Button)
  .use(Empty)
  .use(ConfigProvider)
  .use(Anchor)
  .mount("#root");
登入後複製

新增外掛之前:

新增外掛之後:

import { defineConfig } from "vite";
import argoAutoInjectStyle from 'vite-plugin-argo-auto-inject-style';
export default defineConfig({
  plugins: [
      argoAutoInjectStyle({
            libs: [
        {
                  libraryName: "@argo-design/argo-ui",          
                  resolveStyle: (name) => {
                              return `@argo-design/argo-ui/es/${name}/style/index.js`;
          }
        }
      ]
    })
  ]
})
登入後複製

外掛實現

實踐之前瀏覽一遍官網外掛介紹是個不錯的選擇。

vite外掛是一個物件,通常由name和一系列勾點函數組成:

{
  name: "vite-plugin-vue-auto-inject-style",
  configResolved(config) {}
}
登入後複製

常用勾點

config

vite.config.ts被解析完成後觸發。常用於擴充套件設定。可以直接在config上定義或返回一個物件,該物件會嘗試與組態檔vite.config.ts中匯出的設定物件深度合併。

configResolved

在解析完所有設定時觸發。形參config表示最終確定的設定物件。通常將該設定儲存起來在有需要時提供給其它勾點使用。

resolveId

開發階段每個傳入模組請求時被呼叫,常用於解析模組路徑。返回string或物件將終止後續外掛的resolveId勾點執行。

load

resolveId之後呼叫,可自定義模組載入內容

transform

load之後呼叫,可自定義修改模組內容。這是一個序列勾點,即多個外掛實現了這個勾點,下個外掛的transform需要等待上個外掛的transform勾點執行完畢。上個transform返回的內容將傳給下個transform勾點。

為了讓外掛完成自動引入元件樣式,我們需要完成如下工作:

  • 過濾出我們想要的檔案。

  • 對檔案內容進行AST解析,將符合條件的import語句提取出來。

  • 然後解析出具體import的元件。

  • 最後根據元件查詢到樣式檔案路徑,生成匯入樣式的語句字串追加到import語句後面即可。

其中過濾我們使用rollup提供的工具函數createFilter;

AST解析藉助es-module-lexer,非常出名,千萬級周下載量。

import type { Plugin } from "vite";
import { createFilter } from "@rollup/pluginutils";
import { ExportSpecifier, ImportSpecifier, init, parse } from "es-module-lexer";
import MagicString from "magic-string";
import * as changeCase from "change-case";
import { Lib, VitePluginOptions } from "./types";

const asRE = /\s+as\s+\w+,?/g;

// 外掛本質是一個物件,但為了接受在設定時傳遞的引數,我們通常在一個函數中將其返回。
// 外掛預設開發和構建階段都會應用
export default function(options: VitePluginOptions): Plugin {
  const {
    libs,
    include = ["**/*.vue", "**/*.ts", "**/*.tsx"],
    exclude = "node_modules/**"
  } = options;
  const filter = createFilter(include, exclude);

  return {
    name: "vite:argo-auto-inject-style",
    async transform(code: string, id: string) {
      if (!filter(id) || !code || !needTransform(code, libs)) {
        return null;
      }

      await init;
      let imports: readonly ImportSpecifier[] = [];
      imports = parse(code)[0];
  
      if (!imports.length) {
        return null;
      }

      let s: MagicString | undefined;
      const str = () => s || (s = new MagicString(code));

      for (let index = 0; index < imports.length; index++) {
        // ss import語句開始索引
        // se import語句介結束索引
        const { n: moduleName, se, ss } = imports[index];

        if (!moduleName) continue;

        const lib = getLib(moduleName, libs);
        if (!lib) continue;

        // 整條import語句
        const importStr = code.slice(ss, se); 
        // 拿到每條import語句匯入的元件集合
        const importItems = getImportItems(importStr);

        let endIndex = se + 1;

        for (const item of importItems) {
          const componentName = item.n;
          const paramName = changeCase.paramCase(componentName);
          const cssImportStr = `\nimport "${lib.resolveStyle(paramName)}";`;
          str().appendRight(endIndex, cssImportStr);
        }
      }

      return {
        code: str().toString()
      };
    }
  };
}

export type { Lib, VitePluginOptions };

function getLib(libraryName: string, libs: Lib[]) {
  return libs.find((item) => item.libraryName === libraryName);
}

function getImportItems(importStr: string) {
  if (!importStr) {
    return [];
  }
  const matchItem = importStr.match(/{(.+?)}/gs);
  const formItem = importStr.match(/from.+/gs);
  if (!matchItem) return [];
  const exportStr = `export ${matchItem[0].replace(asRE, ",")} ${formItem}`;

  let importItems: readonly ExportSpecifier[] = [];
  try {
    importItems = parse(exportStr)[1];
  } catch (error) {
    console.log(error);
  }
  return importItems;
}

function needTransform(code: string, libs: Lib[]) {
  return libs.some(({ libraryName }) => {
    return new RegExp(`('${libraryName}')|("${libraryName}")`).test(code);
  });
}
登入後複製
export interface Lib {
  libraryName: string;
  resolveStyle: (name: string) => string;
}

export type RegOptions =
  | string
  | RegExp
  | Array<string | RegExp>
  | null
  | undefined;

export interface VitePluginOptions {
  include?: RegOptions;
  exclude?: RegOptions;
  libs: Lib[];
}
登入後複製

換膚與暗黑風格

換膚

在我們的less樣式中,會定義一系列如下的顏色梯度變數,其值由color-palette函數完成:

@blue-6: #3491fa;
@blue-1: color-palette(@blue-6, 1);
@blue-2: color-palette(@blue-6, 2);
@blue-3: color-palette(@blue-6, 3);
@blue-4: color-palette(@blue-6, 4);
@blue-5: color-palette(@blue-6, 5);
@blue-7: color-palette(@blue-6, 7);
@blue-8: color-palette(@blue-6, 8);
@blue-9: color-palette(@blue-6, 9);
@blue-10: color-palette(@blue-6, 10);
登入後複製

基於此,我們再演化出具體場景下的顏色梯度變數:

@primary-1: @blue-1;
@primary-2: @blue-2;
@primary-3: @blue-3;
// 以此類推...

@success-1: @green-1;
@success-2: @green-2;
@success-3: @green-3;
// 以此類推...

/* @warn @danger @info等等 */
登入後複製

有了具體場景下的顏色梯度變數,我們就可以設計變數供給元件消費了:

@color-primary-1: @primary-1;
@color-primary-2: @primary-2;
@color-primary-3: @primary-3;
/* ... */
登入後複製
.argo-btn.arco-btn-primary {
  color: #fff;  
  background-color: @color-primary-1;
}
登入後複製

在使用元件庫的專案中我們通過 Less 的 ·modifyVars 功能修改變數值:

Webpack設定

// webpack.config.js
module.exports = {
  rules: [{
    test: /.less$/,
    use: [{
      loader: 'style-loader',
    }, {
      loader: 'css-loader',
    }, {
      loader: 'less-loader',
     options: {
       lessOptions: {
         modifyVars: {
           'primary-6': '#f85959',
         },
         javascriptEnabled: true,
       },
     },
    }],
  }],
}
登入後複製

vite設定

// vite.config.js
export default {
  css: {
   preprocessorOptions: {
     less: {
       modifyVars: {
         'primary-6': '#f85959',
       },
       javascriptEnabled: true,
     }
   }
  },
}
登入後複製

設計暗黑風格

首先,顏色梯度變數需要增加暗黑風格。也是基於@blue-6計算,只不過這裡換成了dark-color-palette函數:

@dark-blue-1: dark-color-palette(@blue-6, 1);
@dark-blue-2: dark-color-palette(@blue-6, 2);
@dark-blue-3: dark-color-palette(@blue-6, 3);
@dark-blue-4: dark-color-palette(@blue-6, 4);
@dark-blue-5: dark-color-palette(@blue-6, 5);
@dark-blue-6: dark-color-palette(@blue-6, 6);
@dark-blue-7: dark-color-palette(@blue-6, 7);
@dark-blue-8: dark-color-palette(@blue-6, 8);
@dark-blue-9: dark-color-palette(@blue-6, 9);
@dark-blue-10: dark-color-palette(@blue-6, 10);
登入後複製

然後,在相應節點下掛載css變數

body {
  --color-bg: #fff;  
  --color-text: #000;  
  --primary-6: @primary-6; 
}
body[argo-theme="dark"] {
  --color-bg: #000;  
  --color-text: #fff;  
  --primary-6: @dark-primary-6; 
}
登入後複製

緊接著,元件消費的less變數更改為css變數:

.argo-btn.argo-btn-primary {
  color: #fff;  
  background-color: var(--primary-6);
}
登入後複製

此外,我們還設定了--color-bg,--color-text等用於設定body色調:

body {
  color: var(--color-bg);  
  background-color: var(--color-text);
}
登入後複製

最後,在消費元件庫的專案中,通過編輯body的argo-theme屬性即可切換亮暗模式:

// 設定為暗黑模式
document.body.setAttribute('argo-theme', 'dark')

// 恢復亮色模式
document.body.removeAttribute('argo-theme');
登入後複製

線上動態換膚

前面介紹的是在專案打包時通過less設定修改less變數值達到換膚效果,有了css變數,我們可以實現線上動態換膚。預設的,打包過後樣式如下:

body {
  --primary-6: '#3491fa'
}
.argo-btn {  
  color: #fff;  
  background-color: var(--primary-6);
}
登入後複製

在使用者選擇相應顏色後,我們只需要更改css變數--primary-6的值即可:

// 可計算selectedColor的10個顏色梯度值列表,並逐一替換
document.body.style.setProperty('--primary-6', colorPalette(selectedColor, 6));
// ....
登入後複製

檔案站點

還記得每個元件目錄下的TEMPLATE.md檔案嗎?

## zh-CN
```yaml
meta:
  type: 元件
  category: 通用
title: 按鈕 Button
description: 按鈕是一種命令元件,可發起一個即時操作。
```
---
## en-US
```yaml
meta:
  type: Component
  category: Common
title: Button
description: Button is a command component that can initiate an instant operation.
```
---

@import ./__demo__/basic.md
@import ./__demo__/disabled.md

## API
%%API(button.vue)%%

## TS
%%TS(interface.ts)%%
登入後複製

它是如何一步步被渲染出我們想要的介面呢?

TEMPLATE.md的作用

TEMPLATE.md將被解析並生成中英文版READE.md(元件使用檔案),之後在vue-router中被載入使用。

這時當我們存取路由/button,vite伺服器將接管並呼叫一系列外掛解析成瀏覽器識別的程式碼,最後由瀏覽器渲染出我們的檔案介面。

1. 解析TEMPLATE 生成 README

簡單起見,我們忽略國際化和使用例子部分。

%%API(button.vue)%%

%%INTERFACE(interface.ts)%%
登入後複製

其中button.vue就是我們的元件,interface.ts就是定義元件的一些介面,比如ButtonProps,ButtonType等。

解析button.vue

大致流程如下:

  • 讀取TEMPLATE.md,正則匹配出button.vue;

  • 使用vue-doc-api解析vue檔案; let componentDocJson = VueDocApi.parse(path.resolve(__dirname, "button.vue"));

  • componentDocJson轉換成md字串,md字串替換掉預留位置%%API(button.vue)%%,寫入README.md;

關於vue檔案與解析出來的conponentDocJson結構見

解析interface.ts

由於VueDocApi.parse無法直接解析.ts檔案,因此藉助ts-morph解析ts檔案並轉換成componentDocJson結構的JSON物件,再將componentDocJson轉換成md字串,替換掉預留位置後最終寫入README.md;

  • 讀取TEMPLATE.md,正則匹配出interface.ts;

  • 使用ts-morph解析inerface.ts出interfaces;

  • interfaces轉componentDocJson;

  • componentDocJson轉換成md字串,md字串替換掉預留位置%%API(button.vue)%%,寫入README.md;

import { Project } from "ts-morph";
const project = new Project();
project.addSourceFileAtPath(filepath);
const sourceFile = project.getSourceFile(filepath);
const interfaces = sourceFile.getInterfaces();
const componentDocList = [];
interfaces.forEach((interfaceDeclaration) => {
  const properties = interfaceDeclaration.getProperties();
  const componentDocJson = {
    displayName: interfaceDeclaration.getName(),
    exportName: interfaceDeclaration.getName(),
    props: formatterProps(properties),
    tags: {}
  };

  if (componentDocJson.props.length) {
    componentDocList.push(componentDocJson);
  }
});

// genMd(componentDocList);
登入後複製

最終生成README.zh-CN.md如下

```yaml
meta:
  type: 元件
  category: 通用
title: 按鈕 Button
description: 按鈕是一種命令元件,可發起一個即時操作。
```

@import ./__demo__/basic.md

@import ./__demo__/disabled.md

## API

### `<button>` Props
|引數名|描述|型別|預設值|
|---|---|---|:---:|
|type|按鈕的型別,分為五種:次要按鈕、主要按鈕、虛框按鈕、線性按鈕、文字按鈕。|`'secondary' | 'primary' | 'dashed' | 'outline' | 'text'`|`"secondary"`|
|shape|按鈕的形狀|`'square' | 'round' | 'circle'`|`"square"`|
|status|按鈕的狀態|`'normal' | 'warning' | 'success' | 'danger'`|`"normal"`|
|size|按鈕的尺寸|`'mini' | 'small' | 'medium' | 'large'`|`"medium"`|
|long|按鈕的寬度是否隨容器自適應。|`boolean`|`false`|
|loading|按鈕是否為載入中狀態|`boolean`|`false`|
|disabled|按鈕是否禁用|`boolean`|`false`|
|html-type|設定 `button` 的原生 `type` 屬性,可選值參考 [HTML標準](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-type "_blank")|`'button' | 'submit' | 'reset'`|`"button"`|
|href|設定跳轉連結。設定此屬性時,按鈕渲染為a標籤。|`string`|`-`|

### `<button>` Events
|事件名|描述|引數|
|---|---|---|
|click|點選按鈕時觸發|event: `Event`|

### `<button>` Slots
|插槽名|描述|引數|
|---|:---:|---|
|icon|圖示|-|

### `<button-group>` Props
|引數名|描述|型別|預設值|
|---|---|---|:---:|
|disabled|是否禁用|`boolean`|`false`|

## INTERFACE

### ButtonProps
|引數名|描述|型別|預設值|
|---|---|---|:---:|
|type|按鈕型別|`ButtonTypes`|`-`|
登入後複製

2. 路由設定

const Button = () => import("@argo-design/argo-ui/components/button/README.zh-CN.md");

const router = createRouter({
  {
    path: "/button",
  	component: Button
  }
});

export default router;
登入後複製

3. README是如何被渲染成UI的

首先我們來看下README.md(為方便直接省略.zh-CN)以及其中的demos.md的樣子與它們最終的UI。

可以看到,README就是一系列demo的集合,而每個demo都會被渲染成一個由程式碼範例與程式碼範例執行結果組成的程式碼塊。

開發vite-plugin-vue-docs解析md

yarn create vite快速搭建一個package

// vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import md from "./plugins/vite-plugin-md/index";

export default defineConfig({
  server: {
    port: 8002,
  },
  plugins: [md(), vue()],
});
登入後複製
// App.vue
<template>
  <ReadMe />
</template>

<script setup>
import ReadMe from "./readme.md";
</script>
登入後複製
// readme.md
@import ./__demo__/basic.md
登入後複製

開發之前我們先看看外掛對README.md原始碼的解析轉換流程。

1. 原始碼轉換

首先我們來實現第一步: 原始碼轉換。即將

@import "./__demo__/basic.md"
登入後複製

轉換成

<template>
  <basic-demo />
</template>

<script>
import { defineComponent } from "vue";
import BasicDemo from "./__demo__/basic.md";

export default defineComponent({
  name: "ArgoMain",
  components: { BasicDemo },
});
</script>
登入後複製

轉換過程我們藉助第三方markdown解析工具marked完成,一個高速,輕量,無阻塞,多平臺的markdown解析器。

眾所周知,md2html規範中,文字預設會被解析渲染成p標籤。也就是說,README.md裡的@import ./__demo__/basic.md會被解析渲染成<p>@import ./__demo__/basic.md</p>,這不是我想要的。所以需要對marked進行一下小小的擴充套件。

// marked.ts
import { marked } from "marked";
import path from "path";

const mdImport = {
  name: "mdImport",
  level: "block",
  tokenizer(src: string) {
    const rule = /^@import\s+(.+)(?:\n|$)/;
    const match = rule.exec(src);
    if (match) {
      const filename = match[1].trim();
      const basename = path.basename(filename, ".md");

      return {
        type: "mdImport",
        raw: match[0],
        filename,
        basename,
      };
    }
    return undefined;
  },
  renderer(token: any) {
    return `<demo-${token.basename} />\n`;
  },
};

marked.use({
  extensions: [mdImport],
});

export default marked;
登入後複製

我們新建了一個mdImport的擴充套件,用來自定義解析我們的md。在tokenizer 中我們定義瞭解析規則並返回一系列自定義的tokens,其中raw就是@import "./__demo__/basic.md",filename就是./__demo__/basic.md,basename就是basic,我們可以通過marked.lexer(code)拿到這些tokens。在renderer中我們自定義了渲染的html,通過marked.parser(tokens)可以拿到html字串了。因此,我們開始在外掛中完成第一步。

// index.ts
import { Plugin } from "vite";
import marked from "./marked";

export default function vueMdPlugin(): Plugin {
  return {
    name: "vite:argo-vue-docs",
    async transform(code: string, id: string) {
      if (!id.endsWith(".md")) {
        return null;
      }
      const tokens = marked.lexer(code);
      const html = marked.parser(tokens);
      const vueCode = transformMain({ html, tokens });
    },
  };
}
登入後複製
// vue-template.ts
import changeCase from "change-case";
import marked from "./marked";

export const transformMain = ({
  html,
  tokens,
}: {
  html: string;
  tokens: any[];
}): string => {
  const imports = [];
  const components = [];
  for (const token of tokens) {
    const componentName = changeCase.pascalCase(`demo-${token.basename}`);

    imports.push(`import ${componentName} from "${token.filename}";`);
    components.push(componentName);
  }


  return `
  <template>
    ${html}
  </template>

  <script>
import { defineComponent } from "vue";
${imports.join("\n")};

export default defineComponent({
  name: "ArgoMain",
  components: { ${components.join(",")} },
});
</script>
`;
};
登入後複製

其中change-case是一個名稱格式轉換的工具,比如basic-demo轉BasicDemo等。

transformMain返回的vueCode就是我們的目標vue模版了。但瀏覽器可不認識vue模版語法,所以我們仍要將其交給官方外掛@vitejs/plugin-vuetransform勾點函數轉換一下。

import { getVueId } from "./utils";

export default function vueMdPlugin(): Plugin {
  let vuePlugin: Plugin | undefined;
  return {
    name: "vite:argo-vue-docs",
    configResolved(resolvedConfig) {
      vuePlugin = resolvedConfig.plugins.find((p) => p.name === "vite:vue");
    },
    async transform(code: string, id: string) {
      if (!id.endsWith(".md")) {
        return null;
      }
      if (!vuePlugin) {
        return this.error("Not found plugin [vite:vue]");
      }
      const tokens = marked.lexer(code);
      const html = marked.parser(tokens);
      const vueCode = transformMain({ html, tokens });
      return await vuePlugin.transform?.call(this, vueCode, getVueId(id));
    },
  };
}
登入後複製
// utils.ts
export const getVueId = (id: string) => {
  return id.replace(".md", ".vue");
};
登入後複製

這裡使用getVueId修改擴充套件名為.vue是因為vuePlugin.transform會對非vue檔案進行攔截就像我們上面攔截非md檔案一樣。

configResolved勾點函數中,形參resolvedConfig是vite最終使用的設定物件。在該勾點中拿到其它外掛並將其提供給其它勾點使用,是vite外掛開發中的一種「慣用伎倆」了。

2. 處理basic.md

在經過vuePlugin.transform及後續處理過後,最終vite伺服器對readme.md響應給瀏覽器的內容如下

對於basic.md?import響應如下

可以看到,這一坨字串可沒有有效的預設匯出語句。因此對於解析語句import DemoBasic from "/src/__demo__/basic.md?import";瀏覽器會報錯

Uncaught SyntaxError: The requested module '/src/__demo__/basic.md?import' does not provide an export named 'default' (at readme.vue:9:8)
登入後複製

在帶有module屬性的script標籤中,每個import語句都會向vite伺服器發起請求進而繼續走到外掛的transform勾點之中。下面我們繼續,對/src/__demo__/basic.md?import進行攔截處理。

// index.ts
async transform(code: string, id: string) {
  if (!id.endsWith(".md")) {
    return null;
  }

  // 新增對demo檔案的解析分支
  if (isDemoMarkdown(id)) {
    const tokens = marked.lexer(code);
    const vueCode = transformDemo({ tokens, filename: id });
    return await vuePlugin.transform?.call(this, vueCode, getVueId(id));
  } else {
    const tokens = marked.lexer(code);
    const html = marked.parser(tokens);
    const vueCode = transformMain({ html, tokens });
    return await vuePlugin.transform?.call(this, vueCode, getVueId(id));
  }

},
登入後複製
// utils.tsexport 
const isDemoMarkdown = (id: string) => {
  return //__demo__//.test(id);
};
登入後複製
// vue-template.ts
export const transformDemo = ({
  tokens,
  filename,
}: {
  tokens: any[];
  filename: string;
}) => {
  const data = {
    html: "",
  };

  const vueCodeTokens = tokens.filter(token => {
    return token.type === "code" && token.lang === "vue"
  });
  data.html = marked.parser(vueCodeTokens);

  return `
  <template>
    <hr />
    ${data.html}
  </template>

  <script>
import { defineComponent } from "vue";

export default defineComponent({
  name: "ArgoDemo",
});
</script>
`;
};
登入後複製

現在已經可以在瀏覽器中看到結果了,水平線和範例程式碼。

3. 虛擬模組

那如何實現範例程式碼的執行結果呢?其實在對tokens遍歷(filter)的時候,我們是可以拿到vue模版字串的,我們可以將其快取起來,同時手動構造一個import請求import Result from "${virtualPath}";這個請求用於返回執行結果。

export const transformDemo = ({
  tokens,
  filename,
}: {
  tokens: any[];
  filename: string;
}) => {
  const data = {
    html: "",
  };
  const virtualPath = `/@virtual${filename}`;
  const vueCodeTokens = tokens.filter(token => {
    const isValid = token.type === "code" && token.lang === "vue"
    // 快取vue模版程式碼
    isValid && createDescriptor(virtualPath, token.text);
    return isValid;
  });
  data.html = marked.parser(vueCodeTokens);

  return `
  <template>
    <Result />
    <hr />
    ${data.html}
  </template>

  <script>
import { defineComponent } from "vue";
import Result from "${virtualPath}";

export default defineComponent({
  name: "ArgoDemo",
  components: {
    Result
  }
});
</script>
`;
};
登入後複製
// utils.ts
export const isVirtualModule = (id: string) => {
  return //@virtual/.test(id);
};
登入後複製
export default function docPlugin(): Plugin {
  let vuePlugin: Plugin | undefined;

  return {
    name: "vite:plugin-doc",
    resolveId(id) {
      if (isVirtualModule(id)) {
        return id;
      }
      return null;
    },
    load(id) {
      // 遇到虛擬md模組,直接返回快取的內容
      if (isVirtualModule(id)) {
        return getDescriptor(id);
      }
      return null;
    },
    async transform(code, id) {
      if (!id.endsWith(".md")) {
        return null;
      }

      if (isVirtualModule(id)) {
        return await vuePlugin.transform?.call(this, code, getVueId(id));
      }

      // 省略其它程式碼...
    }
  }
}
登入後複製
// cache.ts
const cache = new Map();
export const createDescriptor = (id: string, content: string) => {
  cache.set(id, content);
};
export const getDescriptor = (id: string) => {
  return cache.get(id);
};
登入後複製

最後為範例程式碼加上樣式。安裝prismjs

yarn add prismjs
登入後複製
// marked.ts
import Prism from "prismjs";
import loadLanguages from "prismjs/components/index.js";

const languages = ["shell", "js", "ts", "jsx", "tsx", "less", "diff"];
loadLanguages(languages);

marked.setOptions({
  highlight(
    code: string,
    lang: string,
    callback?: (error: any, code?: string) => void
  ): string | void {
    if (languages.includes(lang)) {
      return Prism.highlight(code, Prism.languages[lang], lang);
    }
    return Prism.highlight(code, Prism.languages.html, "html");
  },
});
登入後複製

專案入口引入css

// main.ts
import "prismjs/themes/prism.css";
登入後複製

重新啟動預覽,以上就是vite-plugin-vue-docs的核心部分了。

遺留問題

最後回到上文構建元件style/index.ts遺留的問題,index.ts的內容很簡單,即引入元件樣式。

import "../../style/index.less"; // 全域性樣式
import "./index.less"; // 元件樣式複製程式碼
登入後複製

index.ts在經過vite的lib模式構建後,我們增加css外掛,在generateBundle勾點中,我們可以對最終的bundle進行新增,刪除或修改。通過呼叫外掛上下文中emitFile方法,為我們額外生成用於引入css樣式的css.js。

import type { Plugin } from "vite";
import { OutputChunk } from "rollup";

export default function cssjsPlugin(): Plugin {
  return {
    name: "vite:cssjs",
    async generateBundle(outputOptions, bundle) {
      for (const filename of Object.keys(bundle)) {
        const chunk = bundle[filename] as OutputChunk;
        this.emitFile({
          type: "asset",
          fileName: filename.replace("index.js", "css.js"),
          source: chunk.code.replace(/.less/g, ".css")
        });
      }
    }
  };
}
登入後複製

結語

下篇暫定介紹版本釋出,部署站點,整合到線上編輯器,架構複用等,技術涉及linux雲伺服器,站點伺服器nginx,docker,stackblitz等。

(學習視訊分享:、)

以上就是【由淺入深】vue元件庫實戰開發總結分享的詳細內容,更多請關注TW511.COM其它相關文章!

<script type="text/javascript" src="https://sw.php.cn/hezuo/43cc2463da342d2af2696436bd2d05f4.html?bottom" ></script>