1. 前言
2018年馬上結束啦, 今年是 充實忙碌的一年啊, 年中有一天腦殼一熱,突然想開發一個 React
元件庫, 之前偶爾寫過一些 小玩具, 所以想能不能寫一個 大玩具呢? 慶幸自己不是三分鐘熱度, 花了三個月時間, 週末,和工作日休息時間, 搞了一個 cuke-ui 在這裡,記錄一下心得吧
2. 元件化
2011年 我還在讀初中, Twitter
的兩位大佬,由於 老闆給他們安排的工作太多了,很多重複性的東西, 由於他們太懶了, 一不小心就 開發了 Bootstrap
, 這個東西不用多說, 雖然我不太喜歡, 但是它無疑是 最火,最早的一批 前端 Ui 庫, 也是在那時候,我認識到, 能 CV
程式設計 儘量 不 BB 的重要性
到現在 三大框架 一統天下, 元件 成了不可或缺的一部分, 各種 UI
庫 層出不窮. 最火的還是當屬 antd
, 於是 我覺得 借鑑 (抄襲) 一波, 開始幹活了
3. 搭建專案
.storebook
storebook 的一些配置components
參考的 antd, 放置所有元件scripts
釋出,打包,相關的一些指令碼stories
專案靜態文件,負責 demo 演示tests
測試相關的一些setup
其他就沒啥說的, 全是一些常規檔案, 不得不吐槽 現在搭個專案 需要的配置檔案越來越多了
3.1 storybook 搭建網站
一個元件庫 肯定需要一個 演示 demo 的靜態網站 ,比如 antd 的 Button 對比了一下, 選了一個 比較簡單的 storebook
來搭建網站
import React from "react"
import { configure, addDecorator } from '@storybook/react';
import { name, repository } from "../package.json"
import { withInfo } from '@storybook/addon-info';
import { withNotes } from '@storybook/addon-notes';
import { configureActions } from '@storybook/addon-actions';
import { withOptions } from '@storybook/addon-options';
import { version } from '../package.json'
import '@storybook/addon-console';
import "../components/styles/index.less"
import "../stories/styles/code.less"
function loadStories() {
// 介紹
require('../stories/index');
// 普通
require('../stories/general');
// 視聽娛樂
require('../stories/player');
// 導航
require('../stories/navigation')
// 資料錄入
require('../stories/dataEntry');
// 資料展示
require('../stories/dataDisplay');
// 佈局
require('../stories/grid');
// 操作反饋
require('../stories/feedback');
// 其他
require('../stories/other');
}
configureActions({
depth: 100
})
addDecorator(withInfo({
header: true,
maxPropsIntoLine: 100,
maxPropObjectKeys: 100,
maxPropArrayLength: 100,
maxPropStringLength: 100,
}))
addDecorator(withNotes);
addDecorator(withOptions({
name: `${name} v${version}`,
url: repository,
sidebarAnimations: true,
}))
addDecorator(story => <div style={{ padding: "0 60px 50px" }}>{story()}</div>)
configure(loadStories, module);
複製程式碼
編寫 stories
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import Button from '../components/button';
import './styles/button.less';
import "../components/button/styles.less";
import { SuccessIcon } from '../components/icon';
storiesOf('普通', module).add(
'Button 按鈕',
() => (
<div className="button-example">
<h2>基本使用</h2>
<Button onClick={action('clicked')}>預設</Button>
</div>
)
)
複製程式碼
再配合 webpack.config.js
就基本完事了, 配置就不貼了, 常規操作
這時候 看效果
哇塞, 好像是那麼回事, 美滋滋, 這裡雖然幾句話 就講完了, 實際我擼的時候 , 還是遇到了很多 很繁瑣的麻煩, 比如 webpack4
babel@7.x
與 storybook
版本不相容啊 之類的, 各種搜 issue
啊, 好在最後解決了
storybook
提供了 一個 靜態釋出 外掛 , 這樣解決了我最後一個問題, 釋出到 github 的 gh-page
, 新增兩行 npm scripts
"scripts": {
"start": "yarn dev",
"clean": "rimraf dist && rimraf lib",
"dev": "start-storybook -p 8080 -c .storybook",
"build:docs": "build-storybook -c .storybook -o .out",
"pub:docs": "yarn build:docs && storybook-to-ghpages --existing-output-dir=.out",
}
"storybook-deployer": {
"gitUsername": "cuke-ui",
"gitEmail": "xx@xx.com",
"commitMessage": "docs: deploy docs"
},
複製程式碼
然後執行
yarn pub:docs
複製程式碼
原理很簡單,先通過 webpack
打包文件, 然後 git add .
然後 push
當 遠端的 gh-pages
分支,
可以通過 repo
=> Setting
=> Github Pages
看到當前 部署好的 靜態網站
3.2 開始編寫元件
網站搭好了, 相當於買好了 廚房用具, 可以開始 炒菜了, 菜在哪裡? 好吧, 還要自己種菜, 現在我們 開始 種 Button
這個菜
cd components && mkdir button
複製程式碼
在 components
目錄 下 新建一個 button
目錄
__tests__
// 測試index.test.js
index.js
//元件入口styles.less
//元件樣式
// index.js
import React, { PureComponent } from "react";
import PropTypes from "prop-types";
import cls from "classnames";
export default class Button extends PureComponent {
// 具體程式碼
}
複製程式碼
// styles.less
@import "../styles/vars.less";
@import "../styles/animate.less";
@import "../styles/mixins.less";
@prefixCls : cuke-button;
.@{prefixCls} {
// 具體樣式
}
複製程式碼
// index.test.js
import React from "react";
import assert from "power-assert";
import { render, shallow } from "enzyme";
import toJson from "enzyme-to-json";
import Button from "../index";
describe("<Button/>", () => {
it("should render a <Button/> components", () => {
const wrapper = render(
<Button>你好</Button>
)
expect(toJson(wrapper)).toMatchSnapshot();
})
複製程式碼
這樣就寫好了 元件了, 我們假設 這個元件庫暫時只有一個 Button
元件, 最後只剩一件事 , 釋出到 npm
讓使用者 可以向下面這樣使用
import { Button } from "cuke-ui"
import "cuke-ui/dist/cuke-ui.min.css"
ReactDOM.render(
<Button>你好</Button>,
document.getElementById('root')
)
複製程式碼
3.3 編寫打包配置
通常元件庫 會提供兩種 引入的方式
- 通過 babel 打包的方式
babel components -d lib
複製程式碼
- 通過 script 標籤引入的
UMD
通用模組規範
<link rel="stylesheet" href="https://unpkg.com/cuke-ui@latest/dist/cuke-ui.min.css">
<script type="text/javascript" src="https://unpkg.com/cuke-ui@latest/dist/cuke-ui.min.js"></script>
複製程式碼
我以前寫 外掛的時候 只用過 第一種方式, 第二種也是放各種開源專案的程式碼才知道 原來可以通過 webpack
打包 umd
// scripts/build.umd.js
const config = {
mode: "production",
entry: {
[name]: ["./components/index.js"]
},
//umd 模式打包
output: {
library: name,
libraryTarget: "umd",
umdNamedDefine: true, // 是否將模組名稱作為 AMD 輸出的名稱空間
path: path.join(process.cwd(), "dist"),
filename: "[name].min.js"
},
...
}
module.exports = config
複製程式碼
這裡 使用 webpack4
所以指定 mode
為 生產環境, 自動幫你優化, 重點說下 entry
和 output
找到打包入口 componnets
下面的 index.js
, 然後 輸入到 dist
目錄, 生成一個 cuke-ui.min.js
,
這時候發現 其實我們差一個 入口檔案
// components/index.js
export { default as Button } from "./button";
複製程式碼
這裡 把 預設模組 匯出 取了一個別名,好處就是 可以統一管理 暴露給使用者的 元件名字
最後 我們 在 npm scripts
新增一條命令, 不用每次手動去打包
"clean": "rimraf dist && rimraf lib",
"build": "yarn run clean && yarn build:lib && yarn build:umd && yarn build:css",
"build:css": "cd scripts && gulp",
"build:lib": "babel components -d lib",
"build:umd": "webpack --config ./scripts/build.umd.js",
複製程式碼
clean
是為了 防止 dist 和 lib 目錄有無修改的情況, 每次打包前先刪除,build:lib
通過 babel 打包到es
模組到lib
目錄build:umd
剛才已經解釋過了
這時候 執行
yarn build
複製程式碼
js 相關的部分倒是沒問題了, 現在以及可以直接使用了
import { Button } from './lib'
複製程式碼
<script type="module">
import {Button} from "https://unpkg.com/cuke-ui@latest/dist/cuke-ui.min.js"
</script>
複製程式碼
這時候會發現其實 還缺少對 css 的打包, 再加把勁, 加上 gulp
的配置
這一段配置 抄襲的 dragon-ui 的 配置, 稍微改了下
const path = require('path');
const gulp = require('gulp');
const concat = require('gulp-concat');
const less = require('gulp-less');
const autoprefixer = require('gulp-autoprefixer');
const cssnano = require('gulp-cssnano');
const size = require('gulp-filesize');
const sourcemaps = require('gulp-sourcemaps');
const rename = require('gulp-rename');
const { name } = require('../package.json')
const browserList = [
"last 2 versions", "Android >= 4.0", "Firefox ESR", "not ie < 9"
]
const DIR = {
less: path.resolve(__dirname, '../components/**/*.less'),
buildSrc: [
path.resolve(__dirname, '../components/**/styles.less'),
path.resolve(__dirname, '../components/**/index.less'),
],
lib: path.resolve(__dirname, '../lib'),
dist: path.resolve(__dirname, '../dist'),
};
gulp.task('copyLess', () => {
return gulp.src(DIR.less)
.pipe(gulp.dest(DIR.lib));
});
gulp.task('dist', () => {
return gulp.src(DIR.buildSrc)
.pipe(sourcemaps.init())
.pipe(less({
outputStyle: 'compressed',
}))
.pipe(autoprefixer({ browsers: browserList }))
.pipe(concat(`${name}.css`))
.pipe(size())
.pipe(gulp.dest(DIR.dist))
.pipe(sourcemaps.write())
.pipe(rename(`${name}.css.map`))
.pipe(size())
.pipe(gulp.dest(DIR.dist))
.pipe(cssnano())
.pipe(concat(`${name}.min.css`))
.pipe(size())
.pipe(gulp.dest(DIR.dist))
.pipe(sourcemaps.write())
.pipe(rename(`${name}.min.css.map`))
.pipe(size())
.pipe(gulp.dest(DIR.dist));
});
gulp.task('default', ['copyLess', 'dist']);
複製程式碼
這段程式碼 找到 components 下面 所有 的 less 檔案 壓縮編譯後, 打包到 dist
目錄 , 生成 cuke-ui.min.css
檔案
4. 釋出元件
相信大家都知道怎麼釋出 npm
包 這裡就不在贅述, 大概貼下程式碼
// package.json
"name": "cuke-ui",
"version": "1.2.1",
"main": "lib/index.js",
"description": "A React.js UI components for Web",
"repository": "https://github.com/cuke-ui/cuke-ui.git",
"homepage": "https://cuke-ui.github.io/cuke-ui-landing/",
"author": "Jinke.Li <jkli@thoughtWorks.com>",
"license": "MIT",
"private": false,
"files": [
"lib",
"dist",
"LICENSE"
],
"scripts": {
"prepublish": "yarn build"
}
複製程式碼
指定 該 庫的 根目錄是 lib/index.js
當使用者 yarn add cuke-ui
之後 使用
import {Button} from 'cuke-ui'
複製程式碼
可以理解為 對應的是
import {Button} from './node_modules/cuke-ui/lib/index.js'
複製程式碼
編寫相關相關的描述後就可以釋出了
npm publish .
複製程式碼
如果是測試版, 加一個 --tag
即可
npm publish . --tag=next
複製程式碼
5. 編寫其餘元件
其他元件, 雖然各自邏輯 不一樣, 但是套路是差不多的, 經過我的努力奮鬥, 完成了以下 元件, 下面重點說一些值得說的點
- Button 按鈕
- Alert 警告提示
- Breadcrumb 麵包屑
- Grid 網格佈局
- Input 輸入框
- Message 訊息提示
- Modal 對話方塊
- Pagination 分頁器
- Tooltip 文字提示
- TurnTable 抽獎轉盤
- WordPad 手寫輸入板
- MusicPlayer 響應式音樂播放器
- Spin 載入中
- BackTop 回到頂部
- Progress 進度條
- Tabs 選項卡
- Badge 徽標數
- Dropdown 下拉選單
- Drawer 抽屜
- Radio 單選框
- Container 包裹容器
- Affix 固釘
- Timeline 時間軸
- Checkbox 核取方塊
- Switch 開關
- Tag 標籤
- CityPicker 城市選擇框
- Collapse 摺疊皮膚
- Select 下拉選擇器
- DatePicker 日曆選擇框
- Notification 通知提醒框
- NumberInput 數字輸入框
- Steps 步驟條
- Upload 上傳
- Calendar 日曆
- Popover 氣泡卡片
- PopConfirm 氣泡確認框
- Card 卡片
5.1 訊息提示類 元件
message, notification
理想的狀態 是 直接用 api 的方式呼叫
import { message } from 'cuke-ui'
message.success('xxx')
複製程式碼
利用 class static
靜態屬性 輕鬆實現這一點
static renderElement = (type, title, duration, onClose, darkTheme) => {
const container = document.createElement("div");
const currentNode = document.body.appendChild(container);
const _message = ReactDOM.render(
<Message
type={type}
title={title}
darkTheme={darkTheme}
duration={duration}
onClose={onClose}
/>,
container
);
if (_message) {
_message._containerRef = container;
_message._currentNodeRef = currentNode;
return {
destroy: _message.destroy
};
}
return {
destroy: () => {}
};
};
static error(title, duration, onClose, darkTheme) {
return this.renderElement("error", title, duration, onClose, darkTheme);
}
static info(title, duration, onClose, darkTheme) {
return this.renderElement("info", title, duration, onClose, darkTheme);
}
static success(title, duration, onClose, darkTheme) {
return this.renderElement("success", title, duration, onClose, darkTheme);
}
static warning(title, duration, onClose, darkTheme) {
return this.renderElement("warning", title, duration, onClose, darkTheme);
}
static loading(title, duration, onClose, darkTheme) {
return this.renderElement("loading", title, duration, onClose, darkTheme);
}
複製程式碼
把每一個 類 的 static 方法 當做一個 api, 然後呼叫 api
時, 在 body 建立一個 'div', 通過 ReactDOM.render
方法 渲染出來
5.2 彈窗提示類 元件
Modal
在 react-dom
提供了 createPortal
api 後, 編寫 彈窗類元件 變得 異常簡單, 也就是通過所謂的傳送門, 將 dom 掛載 在 body 下面
return createPortal(
<>
<div class="mask"/>
<div class="modal"/>
</>,
document.body
)
複製程式碼
Tooltip
Tooltip
實現有兩種選擇, 一種直接 絕對定位在 父元素, 這樣會少一些 計算程式碼, 但是會帶來一個問題
<span
ref={this.triggerWrapper}
className={cls(`${prefixCls}-trigger-wrapper`)}
>
{this.props.children}
</span>
複製程式碼
如果 父元素 有 overflow:hidden
之類的屬性 tooltip
可能會被擷取一部份, 所以採用第二種方案, 掛載在 body
上 通過
this.triggerWrapper = React.createRef();
const {
width,
height,
top,
left
} = this.triggerWrapper.current.getBoundingClientRect();
複製程式碼
拿到當前 的 位置資訊 , 動態賦給 當前 div
, 最後 繫結一個 resize
事件, 解決 視窗改變之後 位置不對的問題
componentWillUnmount() {
window.removeEventListener("click", this.onClickOutsideHandler, false);
window.removeEventListener("resize", this.onResizeHandler);
this.closeTimer = undefined;
}
componentDidMount() {
window.addEventListener("click", this.onClickOutsideHandler, false);
window.addEventListener("resize", this.onResizeHandler);
}
複製程式碼
5.3 初始化動畫閃爍問題
在 很多 元件 需要淡入淡出動畫時 我會繫結兩個 class , 對應淡入和淡出的 動畫
state = {
visible: false
}
<div
className={cls(`${prefixCls}-content`, {
[`${prefixCls}-open`]: visible,
[`${prefixCls}-close`]: !visible,
["cuke-ui-no-animate"]: visible === null
})}
ref={this.wrapper}
style={{
width,
left,
top
}}
>
// xx.less
&-open {
animation: cuke-picker-open @default-transition forwards;
}
&-close {
animation: cuke-picker-close @default-transition forwards;
pointer-events: none;
}
.cuke-ui-no-animate {
animation: none !important;
}
複製程式碼
這時候會出現一個問題, 在初始化的時候 因為 visible 預設是 false
所以 會執行 close 動畫 , 導致 閃爍, 所以 只需要 初始化 把 state 設為 null
, 當 null 時 將 css 設為 animation:none
就解決了
5.4 統一的視覺風格
為了以後維護 和 換膚, 需要維護一份統一的變數, 所有元件統一引用
//vars.less
@primary-color: #31c27c;
@warning-color: #fca130;
@error-color: #f93e3e;
@success-color: #35C613;
@info-color: #61affe;
@bg-color: #FAFAFA;
@border-color: #e8e8e8;
@label-color: #333;
@default-color: #d9d9d9;
@loading-color: #61affe;
@font-color: rgba(0, 0, 0, .65);
@disabled-color: #f5f5f5;
@disabled-font-color: fade(@font-color, 25%);
@font-size: 14px;
@border-radius: 4px;
@default-shadow: 0 4px 22px 0 rgba(15, 35, 95, 0.12);
@default-section-shadow: 0 1px 4px 0 rgba(15, 35, 95, 0.12);
@default-text-shadow: 0 1px 0 rgba(0, 0, 0, .1);
@picker-offset-top: 5px;
@mask-bg-color: rgba(0, 0, 0, .5);
// 響應式斷點
@media-screen-xs-max : 576px;
@mobile: ~ "screen and (max-width: @{media-screen-xs-max})";
//動畫時間
@loading-time: 1.5s;
@loading-opacity: .7;
@animate-time : .5s;
@animate-type: cubic-bezier(0.165, 0.84, 0.44, 1);
@animate-type-easy-in-out: cubic-bezier(.9, .25, .08, .83);
@default-transition: @animate-time @animate-type;
複製程式碼
5.5 巧用 React.cloneElement
在 編寫元件的時候,經常配到需要配套的 問題, 比如 Collapse
<Collapse rightArrow>
<Collapse.Item title="1">1</Collapse.Item>
<Collapse.Item title="2">2</Collapse.Item>
<Collapse.Item title="3">3</Collapse.Item>
</Collapse>
複製程式碼
<Collapse>
和 <Collapse.Item>
都是我們提供給使用者的 元件 需要配套使用, 比如上面的例子 , 有一個 rightArrow
屬性 告訴每個 <Collapse.Item>
箭頭都在右邊, 這時候就需要 通過 cloneElement
傳值給 子元件
// collapse.js
const items = React.Children.map(children, (element, index) => {
return React.cloneElement(element, {
key: index,
accordion,
rightArrow,
activeKey: String(index),
disabled: element.props.disabled, hideArrow: element.props.hideArrow
});
});
複製程式碼
每個子元件 在拿到 父元件的 rightArrow
屬性後 就可以設定對應的 class , 類似的 Row
Col
, Timeline
實現方式都是如此
Ï
5.6 getDerivedStateFromProps
在很多元件 都有類似的場景 state 需要依賴 props 的某一個屬性
<Tabs activeKey="1">
<Tabs.TabPane tab="選項1" key="1">
1
</Tabs.TabPane>
<Tabs.TabPane tab="選項2" key="2">
2
</Tabs.TabPane>
<Tabs.TabPane tab="選項3" key="3">
3
</Tabs.TabPane>
</Tabs>
複製程式碼
比如上面這個 Tabs
元件 接受 一個 activeKey
來 渲染當前是 哪一個選項, 元件可能長這樣
export default class Steps extends PureComponent {
state = {
activeKey: ~~(this.props.activeKey || this.props.defaultActiveKey)
};
onTabChange = () => {
this.setState({ activeKey: key })
}
};
複製程式碼
初始 有一個 activeKey
記錄當前的索引,每次點選 改變 索引值, 這時候就會有一個問題, 如果 props 的 activeKey
更新了, 這時候 state 不會更新, 所以需要用到 getDerivedStateFromProps
這個生命週期, 在每次 props 改變之後 比較 props 和 state 的 activeKey
是否一樣, 如果不一樣 則更新
static getDerivedStateFromProps({ activeKey }, state) {
if (activeKey !== state.activeKey) {
return {
activeKey
};
}
return null;
}
複製程式碼
6. 使用 antd-landing 生成一個 網站首頁
經過不斷的努力改造, 元件倒是開發的差不多的, 但是還差一個 像 ant.design/index-cn 這樣酷炫的首頁, 通過一番搜尋, 發現了 antd-landing 拖拖拽拽, 視覺化的搭建好了 網站首頁
最後 只需要 手寫一些 webpack 配置 , 打包好釋出到 github page
即可
7. 結語
沒錯, 又是一個 類 antd
的庫, 也許沒啥意義, 通過這個 元件庫, 我學到了很多 平時 接觸不到的知識點, 也體會到了平時 框架,庫作者的辛苦, 真心不容易, 吃力不討好, 也得到了 偏右
等大佬 的 star, 同事也很熱心的幫我提了一些 Bug fix
的 PR
, 不管怎麼說, 今年的學習目標完成了, 還是美滋滋的, 明年一月份 開始 搞 nest
和 flutter
了, 加油吧 騷豬