? 黃瓜 UI: 一個即插即用的 React 元件庫

lijinke666發表於2018-12-26

? 黃瓜 UI: 一個即插即用的 React 元件庫

1. 前言

2018年馬上結束啦, 今年是 充實忙碌的一年啊, 年中有一天腦殼一熱,突然想開發一個 React 元件庫, 之前偶爾寫過一些 小玩具, 所以想能不能寫一個 大玩具呢? 慶幸自己不是三分鐘熱度, 花了三個月時間, 週末,和工作日休息時間, 搞了一個 cuke-ui 在這裡,記錄一下心得吧

GITHUB | 官網

2. 元件化

2011年 我還在讀初中, Twitter 的兩位大佬,由於 老闆給他們安排的工作太多了,很多重複性的東西, 由於他們太懶了, 一不小心就 開發了 Bootstrap, 這個東西不用多說, 雖然我不太喜歡, 但是它無疑是 最火,最早的一批 前端 Ui 庫, 也是在那時候,我認識到, 能 CV 程式設計 儘量 不 BB 的重要性

到現在 三大框架 一統天下, 元件 成了不可或缺的一部分, 各種 UI 庫 層出不窮. 最火的還是當屬 antd , 於是 我覺得 借鑑 (抄襲) 一波, 開始幹活了

3. 搭建專案

https://user-gold-cdn.xitu.io/2018/12/26/167e99f8ca02460a?w=606&h=1050&f=png&s=102502

  • .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 就基本完事了, 配置就不貼了, 常規操作

這時候 看效果

WX20181226-140129@2x.png

哇塞, 好像是那麼回事, 美滋滋, 這裡雖然幾句話 就講完了, 實際我擼的時候 , 還是遇到了很多 很繁瑣的麻煩, 比如 webpack4 babel@7.xstorybook 版本不相容啊 之類的, 各種搜 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 看到當前 部署好的 靜態網站

WX20181226-140129@2x.png

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 編寫打包配置

通常元件庫 會提供兩種 引入的方式

  1. 通過 babel 打包的方式
babel components -d lib
複製程式碼
  1. 通過 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 為 生產環境, 自動幫你優化, 重點說下 entryoutput

找到打包入口 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. 編寫其餘元件

其他元件, 雖然各自邏輯 不一樣, 但是套路是差不多的, 經過我的努力奮鬥, 完成了以下 元件, 下面重點說一些值得說的點

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
  ) 
複製程式碼

https://user-gold-cdn.xitu.io/2018/12/26/167e99f8cc3df6fa?w=1172&h=610&f=png&s=63448

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 拖拖拽拽, 視覺化的搭建好了 網站首頁

landing

最後 只需要 手寫一些 webpack 配置 , 打包好釋出到 github page 即可

7. 結語

沒錯, 又是一個 類 antd 的庫, 也許沒啥意義, 通過這個 元件庫, 我學到了很多 平時 接觸不到的知識點, 也體會到了平時 框架,庫作者的辛苦, 真心不容易, 吃力不討好, 也得到了 偏右 等大佬 的 star, 同事也很熱心的幫我提了一些 Bug fixPR, 不管怎麼說, 今年的學習目標完成了, 還是美滋滋的, 明年一月份 開始 搞 nestflutter 了, 加油吧 騷豬

landing

landing

相關文章