從零搭建react+ts元件庫(封裝antd)

2022-05-27 21:00:36

為什麼會有這樣一篇文章?因為網上的教學/範例只說了怎麼做,沒有系統詳細的介紹引入這些依賴、為什麼要這樣設定,甚至有些文章還是錯的!迫於技術潔癖,我希望更多的開發小夥伴能夠真正的理解一個專案搭建各個方面的細節,做到面對對於工程出現的錯誤能夠做到有把握。

最近使用阿里低開引擎的時候,想要封裝一套元件庫作為物料給低開引擎引入。根據低開引擎的物料封層模式,我的訴求是做一套元件庫,並且將該元件庫以umd方式生成。當然,從零開始開發元件庫也是一個比較耗時耗力的事情,所以我想到將antd元件封裝,於是催生出了本篇文章。

在封裝元件並生成umd程式碼過程中,踩了很多的坑,也更加系統的瞭解了babel。

整體需求

  1. react元件庫,取名r-ui,能夠匯出r-ui.umd.jsr-ui.umd.css
  2. 程式碼使用typescript進行開發。
  3. 樣式使用less進行開發。
  4. 引入antd元件庫作為底層原子元件庫,並且r-ui.umd.js和r-ui.umd.css包含antd元件程式碼和樣式程式碼。
  5. 依賴的react、react-dom模組以外部參照方式

開發與打包工具選型

使用webpack作為打包工具

老牌而又經典的打包工具,廣泛的使用,豐富的外掛生態以及各種易得的樣例。

使用babel來處理typescript程式碼

由於 TypeScript 和 Babel 團隊官方合作了一年的專案:TypeScript plugin for Babel@babel/preset-typescript),TypeScript 的使用變得比以往任何時候都容易。 —— 摘自《TypeScript With Babel: A Beautiful Marriage (TypeScript 和 Babel:美麗的結合)

建議各位讀者可以先閱讀一下上面的文章(有中文翻譯文章)。

使用less-loader、css-loader等處理樣式程式碼

使用MiniCssExtractPlugin分離CSS

專案搭建思路

整體結構

- r-ui
  |- src
     |- components
        |- button
           |- index.tsx
  |- index.tsx

方案思路

編寫webpack.config.js組態檔,新增核心loader:

  1. babel-loader。接收ts檔案,交給babel-core以及相關babel外掛進行處理,得到js程式碼。
  2. less-loader。接收less樣式檔案,處理得到css樣式程式碼。
  3. css-loader+MiniCssExtractPlugin.loader。接收css樣式程式碼進行處理,並分離匯出元件庫樣式檔案。

專案搭建實施

初始化

初始化r-ui專案

mkdir r-ui && cd r-ui && npm init
# 設定專案基本資訊(name、version......)

初始化git倉庫,新增gitignore檔案(後續所有命令非特殊情況,均相對於專案根目錄)

git init
# .gitignore檔案內容請直接檢視專案內檔案
# 完成後,初始提交:
# git add . && git commit -m "init"

安裝webpack(包管理器使用yarn)

yarn add -D webpack webpack-cli webpack-dev-server
# 安裝webpack-dev-server是為後續構建樣例頁面做準備,前期可以不安裝。
diff --git a/package.json b/package.json
index e01c1b1..53dd9a3 100644
--- a/package.json
+++ b/package.json
@@ -8,5 +8,9 @@
   },
   "author": "",
   "license": "MIT",
-  "devDependencies": {}
+  "devDependencies": {
+    "webpack": "^5.72.1",
+    "webpack-cli": "^4.9.2",
+    "webpack-dev-server": "^4.9.0"
+  }
 }

專案根目錄新增webpack.config.js並進行初始設定

// webpack.config.js
const {resolve} = require("path");
module.exports = {
  // 元件庫的起點入口
  entry: './src/index.tsx',
  output: {
    filename: "r-ui.umd.js", // 打包後的檔名
    path: resolve(__dirname, 'dist'), // 打包後的檔案目錄:根目錄/dist/
    library: 'rui', // 匯出的UMD js會在window掛rui,即可以存取window.rui
    libraryTarget: 'umd' // 匯出庫為UMD形式
  },
  resolve: {
    // webpack 預設只處理js、jsx等js程式碼
    extensions: ['.js', '.jsx', '.ts', '.tsx']
  },
  externals: {},
  // 模組
  module: {
    // 規則
    rules: []
  }
};

Babel引入

引入babel-loader以及相關babel庫

yarn add -D babel-loader @babel/core @babel/preset-env @babel/preset-typescript @babel/preset-react @babel/plugin-proposal-class-properties @babel/plugin-proposal-object-rest-spread
diff --git a/package.json b/package.json
index 53dd9a3..33c32b6 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,13 @@
   "author": "",
   "license": "MIT",
   "devDependencies": {
+    "@babel/core": "^7.18.2",
+    "@babel/plugin-proposal-class-properties": "^7.17.12",
+    "@babel/plugin-proposal-object-rest-spread": "^7.18.0",
+    "@babel/preset-env": "^7.18.2",
+    "@babel/preset-react": "^7.17.12",
+    "@babel/preset-typescript": "^7.17.12",
+    "babel-loader": "^8.2.5",
     "webpack": "^5.72.1",
     "webpack-cli": "^4.9.2",
     "webpack-dev-server": "^4.9.0"
(END)

瞭解Babel

如果對於babel不太熟悉,可能對這一堆的依賴感到恐懼,這裡如果讀者有時間,我推薦這篇深入瞭解babel的文章:一口(很長的)氣了解 babel - 知乎 (zhihu.com)。當然,如果這口氣憋不住(哈哈),我做一個簡單摘抄:

babel 總共分為三個階段:解析,轉換,生成。

babel 本身不具有任何轉化功能,它把轉化的功能都分解到一個個 plugin 裡面。因此當我們不設定任何外掛時,經過 babel 的程式碼和輸入是相同的。

外掛總共分為兩種:

  • 當我們新增 語法外掛 之後,在解析這一步就使得 babel 能夠解析更多的語法。(順帶一提,babel 內部使用的解析類庫叫做 babylon,並非 babel 自行開發)

舉個簡單的例子,當我們定義或者呼叫方法時,最後一個引數之後是不允許增加逗號的,如 callFoo(param1, param2,) 就是非法的。如果原始碼是這種寫法,經過 babel 之後就會提示語法錯誤。

但最近的 JS 提案中已經允許了這種新的寫法(讓程式碼 diff 更加清晰)。為了避免 babel 報錯,就需要增加語法外掛 babel-plugin-syntax-trailing-function-commas

  • 當我們新增 轉譯外掛 之後,在轉換這一步把原始碼轉換並輸出。這也是我們使用 babel 最本質的需求。

比起語法外掛,轉譯外掛其實更好理解,比如箭頭函數 (a) => a 就會轉化為 function (a) {return a}。完成這個工作的外掛叫做 babel-plugin-transform-es2015-arrow-functions

同一類語法可能同時存在語法外掛版本和轉譯外掛版本。如果我們使用了轉譯外掛,就不用再使用語法外掛了。

簡單來講,使用babel就像如下流程:

原始碼 =babel=> 目的碼

如果沒有使用任何外掛,原始碼和目的碼就沒有任何差異。當我們引入各種外掛的時候,就像如下流程一樣:

原始碼
|
進入babel
|
babel外掛1處理程式碼:移除某些符號
|
babel外掛2處理程式碼:將形如() => {}的箭頭函數,轉換成function xxx() {}
|
目的碼

因為babel的外掛處理的力度很細,我們程式碼的語法、語意內容規範有很多,如果我們要處理這些語法,可能需要設定一大堆的外掛,所以babel提出,將一堆外掛組合成一個preset(預置外掛包),這樣,我們只需要引入一個外掛組合包,就能處理程式碼的各種語法、語意。

所以,回到我們上述的那些@babel開頭的npm包,再回首可能不會那麼迷茫:

@babel/core
@babel/preset-env
@babel/preset-typescript
@babel/preset-react
@babel/plugin-proposal-class-properties
@babel/plugin-proposal-object-rest-spread

@babel/core毋庸置疑,babel的核心模組,實現了上述的流程運轉以及程式碼語法、語意分析的功能。

以plugin開頭的就是外掛,這裡我們引入了兩個:@babel/plugin-proposal-class-properties允許類具有屬性)和@babel/plugin-proposal-object-rest-spread物件展開)。

以preset開頭的就是預置元件包合集,其中@babel/preset-env表示使用了可以根據實際的瀏覽器執行環境,會選擇相關的跳脫外掛包:

env 的核心目的是通過設定得知目標環境的特點,然後只做必要的轉換。

如果不寫任何設定項,env 等價於 latest,也等價於 es2015 + es2016 + es2017 三個相加(不包含 stage-x 中的外掛)。

{
 "presets": [
   ["env", {
     "targets": {
       "browsers": ["last 2 versions", "safari >= 7"]
     }
   }]
 ]
}

如上設定將考慮所有瀏覽器的最新2個版本(safari大於等於7.0的版本)的特性,將必要的程式碼進行轉換。而這些版本已有的功能就不進行轉化了。

—— 摘自《一口(很長的)氣了解 babel - 知乎 (zhihu.com)

@babel/preset-typescript會處理所有ts的程式碼的語法和語意規則,並轉換為js程式碼;@babel/preset-react

故名思義,可以幫助處理使用React相關特性,例如JSX標籤語法等。

webpack的基於babel-loader的處理流程

講了這麼多,我們的打包工具webpack如何使用babel相關元件處理程式碼的呢?還記得我們安裝過babel-loader嗎?

實際上,我們通過設定webpack.config.js,使用babel-loader建立起webpack處理程式碼與babel處理程式碼的連線:

diff --git a/webpack.config.js b/webpack.config.js
index 8bfbb63..6767fd8 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -17,6 +17,11 @@ module.exports = {
   // 模組
   module: {
     // 規則
-    rules: []
+    rules: [
+      {
+        test: /\.tsx?$/,
+        use: 'babel-loader'
+      }
+    ]
   }
 };
(END)

這一步的設定,就是讓webpack遇到ts或tsx的時候,將這些程式碼交給babel-loader,babel-loader作為橋接把程式碼交給內部參照的@babel/core相關API進行處理。

那麼,@babel/core如何知道要使用我們安裝的各種plugin外掛和preset預置外掛包的呢?通過.babelrc檔案(注:實際上還有其他設定方式,但個人傾向於.babelrc)。這裡,我們在專案根目錄建立.babelrc檔案,並新增一下內容:

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-typescript",
    "@babel/preset-react"
  ],
  "plugins": [
    "@babel/plugin-proposal-class-properties",
    "@babel/plugin-proposal-object-rest-spread"
  ]
}

這裡的設定不難理解,plugins欄位存放要使用的外掛,presets欄位存放預置外掛包名稱,具體的設定可以查閱官方檔案。

總結一下,設定babel可以按照如下思路進行:

  1. xxx.ts(x)程式碼交給webpack打包;
  2. webpack遇到ts(x)結尾的程式碼檔案,根據webpack.config.js設定,交給babel-loader;
  3. babel-loader交給@babel/core;
  4. @babel/core根據.babelrc設定交給相關的外掛處理程式碼,轉為js程式碼;
  5. webpack進行後續的打包操作。

引入React相關庫(externals方式)

還記得我們的需求嗎?

依賴的react、react-dom模組以外部參照方式

什麼是外部參照方式?簡單來講,我希望react、react-dom等元件庫的包,不會被打入到元件庫中,而是在html中引入(Add React to a Website – React (reactjs.org)):

  <!-- ... other HTML ... -->
  <!-- Load React. -->
  <!-- Note: when deploying, replace "development.js" with "production.min.js". -->
  <script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script>
  <script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin></script>
  <!-- 元件庫JS -->
  <script src="r-ui.js"></script>
</body>

要實現這樣的效果,第一步是設定webapck.config.js:

diff --git a/webpack.config.js b/webpack.config.js
index 6767fd8..54fc0e5 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -13,7 +13,13 @@ module.exports = {
     // webpack 預設只處理js、jsx等js程式碼
     extensions: ['.js', '.jsx', '.ts', '.tsx']
   },
-  externals: {},
+  externals: {
+    // 打包過程遇到以下依賴匯入,不會打包對應庫程式碼,而是呼叫window上的React和ReactDOM
+    // import React from 'react'
+    // import ReactDOM from 'react-dom'
+    'react': 'React',
+    'react-dom': 'ReactDOM'
+  },
   // 模組
   module: {
     // 規則
(END)

第二部,在引入react相關庫的時候,可以不用引入到dependencies執行時依賴,而只需要引入對應的型別定義到devDependencies開發依賴中:

yarn add -D @types/[email protected] @types/[email protected]
diff --git a/package.json b/package.json
index 33c32b6..bd17763 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,8 @@
     "@babel/preset-env": "^7.18.2",
     "@babel/preset-react": "^7.17.12",
     "@babel/preset-typescript": "^7.17.12",
+    "@types/react": "17.0.39",
+    "@types/react-dom": "17.0.17",
     "babel-loader": "^8.2.5",
     "webpack": "^5.72.1",
     "webpack-cli": "^4.9.2",

至此,我們已經完成了處理基於TypeScriptReact專案的webpack設定,此時我們的專案結構如下:

- r-ui
  |- .babelrc
  |- package.json
  |- webpack.config.js

階段演示1:基於TypeScript的React元件專案的webpack設定可行性

編寫元件程式碼

新增src目錄,在src目錄下新增index.tsx(用於將所有的元件匯出)

src目錄下新增components/button目錄,並建立index.tsx檔案。具體結構與目錄如下:

- r-ui
  |- src/components/button/index.tsx
  |- src/index.tsx
  |- ... ...

src/components/button/index.tsx

import * as React from 'react';

interface ButtonProps {
}

const Button: React.FC<ButtonProps> = (props) => {
    const {children, ...rest} = props;
    return <button {...rest} >{children}</button>
}

export default Button;

src/index.tsx

export {default as Button} from './components/button';

修改package.json

新增webpack處理指令碼

diff --git a/package.json b/package.json
index bd17763..01565ad 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
   "description": "",
   "main": "index.js",
   "scripts": {
+    "build": "webpack --config webpack.config.js",
     "test": "echo \"Error: no test specified\" && exit 1"
   },
   "author": "",
(END)

編譯打包元件庫

yarn run build

打包完成後,在專案根目錄/dist目錄下,會生成一個r-ui.umd.js檔案。

效果演示

想要檢視效果,可以在dist目錄下新增如下的html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>r-ui example</title>
    <script src="https://unpkg.com/react@17/umd/react.development.js"></script>
    <script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
  	<!-- 注意r-ui.umd.js的路徑 -->
    <script src="r-ui.umd.js"></script>
</head>
<body>
<div id="example"></div>
<script>
  const onClick = () => {
    alert('hello');
  };
  // window上存在rui,是因為我們將元件包匯出為了umd包,取名為rui
  // 使用React原生方法建立Button的react元件範例
  // 等價於:
  // <Button onClick={onClick}>hello, world</Button>
  const button = React.createElement(rui.Button, {onClick}, 'hello, world');
  // 呼叫ReactDOM方法,將button元件範例掛載到example DOM節點上
  ReactDOM.render(button, document.getElementById('example'));
</script>
</body>
</html>
- r-ui
  |- dist
     |- index.html
     |- r-ui.umd.js
  |- ... ...

此時,可以直接使用瀏覽器開啟index.html檢視效果:

處理樣式(less編譯與css匯出)

依賴引入

根據上述內容,我們已經搭建了基礎的專案結構,但是目前來說我們還需要處理我們的less樣式,並且能夠支援匯出r-ui.umd.css樣式檔案。基於此考慮,我們需要引入:

  1. less-loader。處理less樣式程式碼,轉為css;
  2. less。由於less-loader內部是呼叫了less模組進行less程式碼編譯,故還需要引入less(模式和babel-loader內部使用@babel/core一樣);
  3. css-loader。處理css樣式程式碼,進行適當加工;
  4. mini-css-extract-plugin。MiniCssExtractPlugin的loader用於進一步處理css,並且該外掛用於匯出獨立樣式檔案。
yarn add -D less-loader less css-loader mini-css-extract-plugin
diff --git a/package.json b/package.json
index 01565ad..3070d07 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,10 @@
     "@types/react": "17.0.39",
     "@types/react-dom": "17.0.17",
     "babel-loader": "^8.2.5",
+    "css-loader": "^6.7.1",
+    "less": "^4.1.2",
+    "less-loader": "^11.0.0",
+    "mini-css-extract-plugin": "^2.6.0",
     "webpack": "^5.72.1",
     "webpack-cli": "^4.9.2",
     "webpack-dev-server": "^4.9.0"

設定webpack

根據上述依賴,我們可以知道需要less-loader、css-loader以及MiniCssExtractPlugin的內建loader來處理我們的樣式程式碼。但是設定到webpack需要注意: webpack中的順序是【從後向前】鏈式呼叫的,所以注意下面設定的程式碼中use陣列的順序:

diff --git a/webpack.config.js b/webpack.config.js
index 54fc0e5..9db43b8 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,5 +1,6 @@
 // webpack.config.js
 const {resolve} = require("path");
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
 module.exports = {
   // 元件庫的起點入口
   entry: './src/index.tsx',
@@ -27,7 +28,28 @@ module.exports = {
       {
         test: /\.tsx?$/,
         use: 'babel-loader'
+      },
+      {
+        test: /\.less$/,
+        use: [
+          // webpack中的順序是【從後向前】鏈式呼叫的
+          // 所以對於less先交給less-loader處理,轉為css
+          // 再交給css-loader
+          // 最後匯出css(MiniCssExtractPlugin.loader)
+          // 所以注意loader的設定順序
+          {
+            loader: MiniCssExtractPlugin.loader,
+          },
+          'css-loader',
+          'less-loader'
+        ]
       }
     ]
-  }
+  },
+  plugins: [
+    // 外掛用於最終的匯出獨立的css的工作
+    new MiniCssExtractPlugin({
+      filename: 'r-ui.umd.css'
+    }),
+  ]
 };

階段演示2:less樣式處理設定可行性

編寫樣式程式碼

新增src/components/button/index.less

@color: #006fde;

.my-button {
  color: @color;
}

修改src/components/button/index.tsx

 import * as React from 'react';
+// 引入less樣式
+import './index.less';
 
 interface ButtonProps {
 }
 
 const Button: React.FC<ButtonProps> = (props) => {
     const {children, ...rest} = props;
-    return <button {...rest} >{children}</button>
+    // 使用my-button樣式
+    return <button {...rest} className='my-button'>{children}</button>
 }
 
 export default Button;

編譯元件庫

再次打包元件庫以後,dist目錄下會額外生成檔案:r-ui.umd.css。所以,我們需要在index.html中新增樣式檔案的引入:

 <head>
     <meta charset="UTF-8">
     <title>r-ui example</title>
     <script src="https://unpkg.com/react@17/umd/react.development.js"></script>
     <script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
     <script src="r-ui.umd.js"></script>
+    <link href="r-ui.umd.css" rel="stylesheet"/>
 </head>

效果演示

重新整理頁面後,可以看到按鈕的文字顏色已經生效

引入AntDesign

根據我們的需求,我們希望將antd元件程式碼參照到我們元件內部進行封裝,所以需要以dependencies方式引入:

yarn add antd
diff --git a/package.json b/package.json
index 3070d07..09ca792 100644
--- a/package.json
+++ b/package.json
@@ -26,5 +26,8 @@
     "webpack": "^5.72.1",
     "webpack-cli": "^4.9.2",
     "webpack-dev-server": "^4.9.0"
+  },
+  "dependencies": {
+    "antd": "^4.20.6"
   }
 }

參照antd的button樣式

src/components/button/index.less

-@color: #006fde;
-
-.my-button {
-  color: @color;
-}
+@import "~antd/lib/button/style/index.css";

參照antd的button元件

 import * as React from 'react';
+// 使用antd的Button和ButtonProps
+// 為了不和我們的Button衝突,需要改匯出名
+import {Button as AntdButton, ButtonProps as AntdButtonProps} from 'antd';
 // 引入less樣式
 import './index.less';
 
-interface ButtonProps {
+interface ButtonProps extends AntdButtonProps {
 }
 
 const Button: React.FC<ButtonProps> = (props) => {
     const {children, ...rest} = props;
-    // 使用my-button樣式
-    return <button {...rest} className='my-button'>{children}</button>
+    // 使用AntdButton
+    return <AntdButton {...rest}>{children}</AntdButton>
 }
 
 export default Button;

階段演示3:antd元件引入可行性

通過上述的程式碼修改以後,我們直接進行編譯,然後檢查效果即可:

寫在最後

實際上,程式碼開發過程中,還有很多可以輔助開發的模組、流程,例如eslint檢查,熱更新等。但是那些內容不在本文的討論範圍。後續會出相關的文章再進一步進行介紹。

本文所搭建的整個專案,我都按照文章一步一步進行了git提交,開發小夥伴可以邊閱讀文章邊對照git提交一步一步來看。

github地址:w4ngzhen/r-ui (github.com)