上篇文章

中介紹瞭如何從 0 到 1 搭建一個 React 元件庫架子,但為了一兩個元件去搭建元件庫未免顯得大材小用。

這次以移動端常見的一個元件

Popup

為例,以最方便快捷的形式釋出一個流程完整的 npm 包。

React + TypeScript 從零開發Popup元件併發布到 npm

線上預覽

✨ 倉庫地址

如果對你有所幫助,歡迎點贊 Star 以及 PR。

如果有所錯漏還煩請評論區指正。

本文包含以下內容:

Popup

元件的開發;

一些工具的使用

tsdx

:專案初始化、開發以及打包大管家;

np

:一鍵釋出 npm 包;

gh-pages

:部署示例 demo ;

readme-md-generator

:生成一份規範的

README。md

檔案。

本文不會和元件庫那篇文章一般死扣打包細節,因為單個元件和元件庫的打包有本質上的區別。

元件庫需要提供按需引入的能力,所以對元件僅僅是進行了語法上的編譯(以及比較繞的樣式處理),故選擇了 gulp 管理打包流程。

單元件則不同,由於不需要提供按需引入的能力,只需要打包出一個 js bundle 和 css bundle 即可,webpack 以及 rollup 就更適用於此類場景。

專案初始化

tsdx

是一個腳手架,內建三種專案模板:

basic => 工具包模板

react => React 元件模板,使用 parcel 用作 example 除錯

react-with-storybook => 同上,使用 storybook 編寫文件以及 example 除錯

模板還內建了

start

build

test

以及

lint

等 npm scripts,的確是零配置開箱即用(大誤)。

為了方便講解,此處選擇

react

模板。

React + TypeScript 從零開發Popup元件併發布到 npm

執行

npx tsdx create react-easy-popup

,選擇

react

完成專案建立後進入專案目錄。

React + TypeScript 從零開發Popup元件併發布到 npm

配置 tsdx

很尷尬的一點是:

tsdx

沒有提供樣式檔案打包支援(國外的開發者真的很偏愛

css in js

呢)。

而我們的初衷只是開發一個元件,不至於讓使用者額外引入一個

styled-components

依賴,所以還是需要配置一下樣式檔案的處理支援(less)。

參照

customization-tsdx

這一小節進行配置。

安裝相關依賴:

yarn add rollup-plugin-postcss autoprefixer cssnano less ——dev

新建

tsdx。config。js

,寫入以下內容:

tsdx.config.js

const postcss = require(‘rollup-plugin-postcss’);

const autoprefixer = require(‘autoprefixer’);

const cssnano = require(‘cssnano’);

module。exports = {

rollup(config, options) {

config。plugins。push(

postcss({

plugins: [

autoprefixer(),

cssnano({

preset: ‘default’,

}),

],

inject: false,

extract: ‘react-easy-popup。min。css’,

})

);

return config;

},

};

package。json

中配置

browserslist

欄位。

package.json

// 。。。

+ “browserslist”: [

+ “last 2 versions”,

+ “Android >= 4。4”,

+ “iOS >= 9”

+ ],

// 。。。

清空

src

目錄,新建

index。tsx

index。less

src/index.tsx

import * as React from ‘react’;

import ‘。/index。less’;

const Popup = () => (

hello,react-easy-popup

);

export default Popup;

src/index.less

。react-easy-popup {

display: flex;

color: skyblue;

}

example/index.tsx

import ‘react-app-polyfill/ie11’;

import * as React from ‘react’;

import * as ReactDOM from ‘react-dom’;

import Popup from ‘。。/。’; // 此處存在parcel alias 見下文

import ‘。。/dist/react-easy-popup。min。css’; // 此處不存在parcel alias 寫好相對路徑

const App = () => {

return (

);

};

ReactDOM。render(, document。getElementById(‘root’));

進入專案根目錄,執行以下命令:

yarn start

現在

src

目錄下的內容的變更會被實時監聽,在根目錄下生成的

dist

資料夾包含打包後的內容。

開發時除錯的資料夾為

example

,另起一個終端。執行以下命令:

cd example

yarn # 安裝依賴

yarn start # 啟動example

localhost:1234

可以發現專案啟動啦,樣式生效且有瀏覽器字首。

React + TypeScript 從零開發Popup元件併發布到 npm

若 example 啟動後網頁報錯,刪除 example 下的。cache 以及 dist 目錄重新 start

需要注意的是

example

的入口檔案

index。tsx

引入的是我們打包後的檔案,即

dist/index。js

但是引入路徑卻為

‘。。/。’

,這是因為

tsdx

使用了

parcel

aliasing

React + TypeScript 從零開發Popup元件併發布到 npm

同時,觀察根目錄下的

dist

資料夾:

dist

├── index。d。ts # 元件宣告檔案

├── index。js # 元件入口

├── react-easy-popup。cjs。development。js # 開發時引入的元件程式碼 Commonjs規範

├── react-easy-popup。cjs。development。js。map # soucemap

├── react-easy-popup。cjs。production。min。js # 壓縮後的元件程式碼

├── react-easy-popup。cjs。production。min。js。map # sourcemap

├── react-easy-popup。esm。js # ES Module規範的元件元件程式碼

├── react-easy-popup。esm。js。map # sourcemap

└── react-easy-popup。min。css # 樣式檔案

也可以很輕易地在

package。json

中找到

main

module

以及

typings

相關配置。

基於 rollup 手動搭一個元件模板並不困難,但是社群已經提供了方便的輪子,就不要重複造輪子啦。既要有造輪子的能力,也要有不造輪子的覺悟。似乎我們正在造輪子?

實現 Portal

Popup

在移動端場景下極其常見,其內部基於

Portal

實現,自身又可以作為

Toast

Modal

等元件的下層元件。

要實現

Popup

,就要先基於

ReactDOM.createPortal

實現一個

Portal

此處結合官方文件做一個簡單總結。

什麼是傳送門?

Portal

是一種將子節點渲染到存在於父元件以外的

DOM

節點的優秀的方案。

為什麼需要傳送門?父元件有

overflow: hidden

z-index

樣式,我們又需要子元件能夠在視覺上“跳出”其容器。例如,對話方塊、懸浮卡以及提示框。

同時還有很重要的一點:

portal

與普通的

React

子節點行為一致,仍存在於

React

樹,所以

Context

依舊可以觸及。有一些彈層元件會提供

xxx。show()

的 API 形式進行彈出,這種呼叫形式較為方便,雖然底層也是基於

Portal

,但是內部重新執行了

ReactDOM。render

,脫離了當前主應用的

React

樹,自然也無法獲取到

Context

推薦閱讀:

傳送門:React Portal-程墨 Morgan

清空 src 目錄,新建以下檔案:

├── index。less # 樣式檔案

├── index。ts # 入口檔案

├── popup。tsx # popup 元件

├── portal。tsx # portal 元件

└── type。ts # 型別定義檔案

在編寫程式碼之前,需要確定好

Portal

元件的 API。

ReactDOM。createPortal

方法接受的引數基本一致:指定的掛載節點以及內容。唯一的區別是:

Portal

在未傳入指定的掛載節點時,會建立一個節點以供使用。

屬性說明型別預設值

node可選,自定義容器節點HTMLElement-children需要傳送的內容ReactNode-

type。ts

中寫入

Portal

Props

型別定義。

src/type.ts

export type PortalProps = React。PropsWithChildren<{

node?: HTMLElement;

}>;

現在開始編寫程式碼:

import * as React from ‘react’;

import * as ReactDOM from ‘react-dom’;

import { PortalProps } from ‘。/type’;

const Portal = ({ node, children }: PortalProps) => {

return ReactDOM。createPortal(children, node);

};

export default Portal;

注意:此處沒有使用 React。FC 去進行宣告

react-typescript-cheatsheet

:Section 2: Getting Started => Function Components => What about

React。FC

/

React。FunctionComponent

程式碼實現比較簡單,就是呼叫了一下

ReactDOM。createPortal

,沒有考慮到使用者未傳入

node

的情況:需要內部建立,元件銷燬時銷燬該

node

import * as React from “react”;

import * as ReactDOM from “react-dom”;

import { PortalProps } from “。/type”;

// 判斷是否為瀏覽器環境

const canUseDOM = !!(

typeof window !== “undefined” &&

window。document &&

window。document。createElement

);

const Portal = ({ node, children }: PortalProps) => {

// 使用ref記錄內部建立的節點 初始值為null

const defaultNodeRef = React。useRef(null);

// 元件解除安裝時 移除該節點

React。useEffect(

() => () => {

if (defaultNodeRef。current) {

document。body。removeChild(defaultNodeRef。current);

}

},

[]

);

// 如果非瀏覽器環境 直接返回 null 服務端渲染需要

if (!canUseDOM) return null;

// 若使用者未傳入節點,Portal也未建立節點,則建立節點並新增至body

if (!node && !defaultNodeRef。current) {

const defaultNode = document。createElement(“div”);

defaultNode。className = “react-easy-popup__portal”;

defaultNodeRef。current = defaultNode;

document。body。appendChild(defaultNode);

}

return ReactDOM。createPortal(children, (node || defaultNodeRef。current)!); // 這裡需要進行斷言

};

export default Portal;

同時為了讓非 ts 使用者能夠享受到良好的執行時錯誤提示,需要安裝

prop-types

yarn add prop-types

src/portal.tsx

// 。。。

+ Portal。propTypes = {

+ node: canUseDOM ? PropTypes。instanceOf(HTMLElement) : PropTypes。any,

+ children: PropTypes。node,

+ };

export default Portal;

這樣就完成了

Portal

元件的編寫,在入口檔案進行匯出。

src/index.ts

export { default as Portal } from ‘。/portal’;

example/index。ts

中引入

Portal

,進行測試。

example/index.tsx

import “react-app-polyfill/ie11”;

import * as React from “react”;

import * as ReactDOM from “react-dom”;

- import Popup from “。。/。”; // 此處存在parcel alias 見下文

- import “。。/dist/react-easy-popup。min。css”; // 此處不存在

+ import { Portal } from ‘。。/。’;

// 建立自定義node節點

+ const node = document。createElement(‘div’);

+ node。className = ‘react-easy-popup__test-node’;

+ document。body。appendChild(node);

const App = () => {

return (

-

+ 123

+ 456

);

};

ReactDOM。render(, document。getElementById(“root”));

在網頁中看到預期的

DOM

結構。

React + TypeScript 從零開發Popup元件併發布到 npm

實現 Popup

API 梳理

老規矩,先規劃 API,寫好型別定義,再動手寫程式碼。

我寫這個元件的時候參考了

Popup-cube-ui

最終確定 API 如下:

屬性說明型別預設值

visible可選,控制 popup 顯隱booleanfalseposition可選,內容定位‘center’ / ‘top’ / ‘bottom’ / ‘left’ / ‘right’‘center’mask可選,控制蒙層顯隱booleantruemaskClosable可選,點選蒙層是否可以關閉booleanfalseonClose可選,關閉函式,若 maskClosable 為 true,點選蒙層呼叫該函式function()=>{}node可選,元素掛載節點HTMLElement-destroyOnClose可選,關閉是否解除安裝內部元素booleanfalsewrapClassName可選,自定義 Popup 外層容器類名string‘’

src/type.ts

export type Position = ‘top’ | ‘right’ | ‘bottom’ | ‘left’ | ‘center’;

type PopupPropsWithoutChildren = {

node?: HTMLElement;

} & typeof defaultProps;

export type PopupProps = React。PropsWithChildren

// 預設屬性寫在這兒很難受 實在是typescript 對react元件預設屬性的宣告就是得這麼擰巴

export const defaultProps = {

visible: false,

position: ‘center’ as Position,

mask: true,

maskClosable: false,

onClose: () => {},

destroyOnClose: false,

};

編寫

Popup

的基本結構。

src/popup.tsx

import * as React from ‘react’;

import PropTypes from ‘prop-types’;

import { PopupProps, defaultProps } from ‘。/type’;

import ‘。/index。less’;

const Popup = (props: PopupProps) => {

console。log(props);

return

hello,react-easy-popup

};

Popup。propTypes = {

visible: PropTypes。bool,

position: PropTypes。oneOf([‘top’, ‘right’, ‘bottom’, ‘left’, ‘center’]),

mask: PropTypes。bool,

maskClosable: PropTypes。bool,

onClose: PropTypes。func,

stopScrollUnderMask: PropTypes。bool,

destroyOnClose: PropTypes。bool,

};

Popup。defaultProps = defaultProps;

export default Popup;

在入口檔案進行匯出。

src/index.ts

+ export { default as Popup } from ‘。/popup’;

前置 CSS 知識

在正式開發邏輯之前,先明確一點:

蒙層 Mask 以及內容 Content 入場以及出場均有動畫效果。具體表現為:蒙層為 Fade 動畫,內容則取決於當前 position,比如內容在中間(position === ‘center’),則其動畫效果為 Fade,如果在左邊(position === ‘left’),則其動畫效果為 SlideRight,其他 position 以此類推。

再回顧張鑫旭大大的一篇文章:

小 tip: transition 與 visibility

劃重點:

opacity

的值在

0

1

之間相互過渡(

transition

)可以實現 Fade 動畫。然而元素即使透明度變成 0,肉眼看不見,在頁面上卻依舊點選,還是可以覆蓋其他元素的,我們希望元素淡出動畫結束後,元素可以自動隱藏;

元素隱藏很容易想到

display:none

。而

display:none

無法應用

transition

效果,甚至是破壞作用;

visibility:hidden

可以看成

visibility:0

visibility:visible

可以看成

visibility:1

。實際上,只要

visibility

的值大於

0

就是顯示的。

總結一下:我們想用

opacity

實現淡入淡出的 Fade 動畫,但是希望元素淡出後,能夠隱藏,而不僅僅是透明度為

0

,覆蓋在其他元素上。所以需要配置

visibility

屬性,淡出動畫結束時,

visibility

值也由

visible

變為了

hidden

,元素成功隱藏。

如果蒙層淡出動畫結束後僅僅是透明度變為 0,卻未隱藏,那麼蒙層在視覺上雖然消失了,實際還是覆蓋在頁面上,就無法觸發頁面上的事件。

預設動畫樣式

藉助

react-transition-group

完成動畫效果,需要內建一些動畫樣式。

新建

animation。less

,寫入以下動畫樣式。

展開檢視程式碼

@animationDuration: 300ms;

。react-easy-popup {

/* Fade */

&-fade-enter,

&-fade-appear,

&-fade-exit-done {

visibility: hidden;

opacity: 0;

}

&-fade-appear-active,

&-fade-enter-active {

visibility: visible;

opacity: 1;

transition: opacity @animationDuration, visibility @animationDuration;

}

&-fade-exit,

&-fade-enter-done {

visibility: visible;

opacity: 1;

}

&-fade-exit-active {

visibility: hidden;

opacity: 0;

transition: opacity @animationDuration, visibility @animationDuration;

}

/* SlideUp */

&-slide-up-enter,

&-slide-up-appear,

&-slide-up-exit-done {

transform: translate(0, 100%);

}

&-slide-up-enter-active,

&-slide-up-appear-active {

transform: translate(0, 0);

transition: transform @animationDuration;

}

&-slide-up-exit,

&-slide-up-enter-done {

transform: translate(0, 0);

}

&-slide-up-exit-active {

transform: translate(0, 100%);

transition: transform @animationDuration;

}

/* SlideDown */

&-slide-down-enter,

&-slide-down-appear,

&-slide-down-exit-done {

transform: translate(0, -100%);

}

&-slide-down-enter-active,

&-slide-down-appear-active {

transform: translate(0, 0);

transition: transform @animationDuration;

}

&-slide-down-exit,

&-slide-down-enter-done {

transform: translate(0, 0);

}

&-slide-down-exit-active {

transform: translate(0, -100%);

transition: transform @animationDuration;

}

/* SlideLeft */

&-slide-left-enter,

&-slide-left-appear,

&-slide-left-exit-done {

transform: translate(100%, 0);

}

&-slide-left-enter-active,

&-slide-left-appear-active {

transform: translate(0, 0);

transition: transform @animationDuration;

}

&-slide-left-exit,

&-slide-left-enter-done {

transform: translate(0, 0);

}

&-slide-left-exit-active {

transform: translate(100%, 0);

transition: transform @animationDuration;

}

/* SlideRight */

&-slide-right-enter,

&-slide-right-appear,

&-slide-right-exit-done {

transform: translate(-100%, 0);

}

&-slide-right-enter-active,

&-slide-right-appear-active {

transform: translate(0, 0);

transition: transform @animationDuration;

}

&-slide-right-exit,

&-slide-right-enter-done {

transform: translate(0, 0);

}

&-slide-right-exit-active {

transform: translate(-100%, 0);

transition: transform @animationDuration;

}

}

完成基本邏輯

安裝相關依賴。

yarn add react-transition-group classnames

yarn add @types/classnames @types/react-transition-group ——dev

node: 透傳給

Portal

即可;

visible: 將該屬性賦值給蒙層以及內容外層

CSSTransition

元件的

in

屬性,控制蒙層以及內容的過渡顯隱;

destroyOnClose: 將該屬性賦值給內容外層

CSSTransition

元件的

unmountOnExit

屬性,決定隱藏時是否解除安裝內容節點;

wrapClassName: 拼接在外層容器節點的

className

position: 1)用於獲取內容節點的對應動畫名稱;2)決定容器節點以及內容節點類名,配合樣式決定內容節點位置;

mask: 決定蒙層節點的

className

,從而控制蒙層有無;

maskClose: 決定點選蒙層是否觸發 onClose 函式。

用過

antd

的同學都知道,

antd

modal

在首次

visible === true

之前,內容節點是不會被掛載的,只有首次

visible === true

,內容節點才掛載,而後都是樣式上隱藏,而不會去解除安裝內容節點,除非手動設定

destroyOnClose

屬性,我們也順帶實現這個特點。

程式碼邏輯比較簡單,在拼接類名時注意配合樣式檔案一起閱讀,重要的點都有註釋標出。

展開檢視邏輯程式碼

// 類名字首

const prefixCls = “react-easy-popup”;

// 動畫時長

const duration = 300;

// 位置與動畫的對映

const animations: { [key in Position]: string } = {

bottom: `${prefixCls}-slide-up`,

right: `${prefixCls}-slide-left`,

left: `${prefixCls}-slide-right`,

top: `${prefixCls}-slide-down`,

center: `${prefixCls}-fade`,

};

const Popup = (props: PopupProps) => {

const firstRenderRef = React。useRef(false);

const { visible } = props;

// 在首次visible === true之前 都返回null

if (!firstRenderRef。current && !visible) return null;

if (!firstRenderRef。current) {

firstRenderRef。current = true;

}

const {

node,

mask,

maskClosable,

onClose,

wrapClassName,

position,

destroyOnClose,

children,

} = props;

// 蒙層點選事件

const onMaskClick = () => {

if (maskClosable) {

onClose();

}

};

// 拼接容器節點類名

const rootCls = classnames(

prefixCls,

wrapClassName,

`${prefixCls}__${position}`

);

// 拼接蒙層節點類名

const maskCls = classnames(`${prefixCls}-mask`, {

[`${prefixCls}-mask__visible`]: mask,

});

// 拼接內容節點類名

const contentCls = classnames(

`${prefixCls}-content`,

`${prefixCls}-content__${position}`

);

// 內容過渡動畫

const contentAnimation = animations[position];

return (

in={visible}

timeout={duration}

classNames={`${prefixCls}-fade`}

appear

>

in={visible}

timeout={duration}

classNames={contentAnimation}

unmountOnExit={destroyOnClose}

appear

>

{children}

);

};

展開檢視樣式程式碼

@import ‘。/animation。less’;

@popupPrefix: react-easy-popup;

。@{popupPrefix} {

position: fixed;

top: 0;

right: 0;

bottom: 0;

left: 0;

z-index: 1999;

pointer-events: none; // 特別注意:為none時可以產生點透的效果 可以理解為容器節點壓根不存在

。@{popupPrefix}-mask {

position: absolute;

top: 0;

left: 0;

display: none; // mask預設隱藏

width: 100%;

height: 100%;

overflow: hidden;

background-color: rgba(0, 0, 0, 0。72);

pointer-events: auto;

&__visible {

display: block; // 展示mask

}

// fix some android webview opacity render bug

&::before {

display: block;

width: 1px;

height: 1px;

margin-left: -10px;

background-color: rgba(0, 0, 0, 0。1);

content: ‘。’;

}

}

/* position為center時 使用flex居中 */

&__center {

display: flex;

align-items: center;

justify-content: center;

}

。@{popupPrefix}-content {

position: relative;

width: 100%;

color: rgba(113, 113, 113, 1);

pointer-events: auto;

-webkit-overflow-scrolling: touch; /* ios5+ */

::-webkit-scrollbar {

display: none;

}

&__top {

position: absolute;

left: 0;

top: 0;

}

&__bottom {

position: absolute;

left: 0;

bottom: 0;

}

&__left {

position: absolute;

width: auto;

max-width: 100%;

height: 100%;

}

&__right {

position: absolute;

right: 0;

width: auto;

max-width: 100%;

height: 100%;

}

&__center {

width: auto;

max-width: 100%;

}

}

}

元件編寫完畢,接下來在

example/index。ts

中編寫相關示例測試功能即可。

example/index.ts

部署 github pages

相信大多數人使用一個 npm 包會先看示例再看文件。

接下來將

example

中的示例專案打包,並部署到 github pages 上。

安裝

gh-pages

yarn add gh-pages ——dev

package。json 新增指令碼。

package.json

{

“scripts”: {

//。。。

“predeploy”: “npm run build && cd example && npm run build”,

“deploy”: “gh-pages -d 。/example/dist”

}

}

由於 gh-pages 預設部署在

https://username。github。io/repo

下,而非根路徑。為了能夠正確引用到靜態資源,還需要修改打包的

public-url

修改 example 的 package。json 中的打包命令:

{

“scripts”:{

- “build”: “parcel build index。html”

+ “build”: “parcel build index。html ——public-url https://username。github。io/repo”

}

}

https://username。github。io/repo

記得換成你自己的哦。

在根目錄下執行

yarn deploy

,等指令碼執行完再去看看吧。

編寫 README.md

一份規範的 README 會顯得作者很專業,此處使用

readme-md-generator

生成基本框架,向裡面填充內容即可。

readme-md-generator

: CLI that generates beautiful README。md files

npx readme-md-generator -y

README.md

使用 np 發包

在上一篇文章中,專門編寫了一個指令碼來處理以下六點內容:

版本更新

生成 CHANGELOG

推送至 git 倉庫

元件打包

釋出至 npm

打 tag 並推送至 git

這次就不生成 CHANGELOG 檔案了,其他五點配合

np

,操作十分簡單。

np

:A better

npm publish

yarn add np ——dev

package.json

{

“scripts”: {

// 。。。

“release”: “np ——no-yarn ——no-tests ——no-cleanup”

}

}

npm login

npm run release

——no-yarn

: 不使用

yarn

。發包時出現 npm 與 yarn 之間的一些問題;

——no-tests

:測試用例暫時還未編寫,先跳過;

——no-cleanup

:發包時不要重新安裝 node_modules;

首次釋出新包時可能會

報錯

,因為 np 進行了 npm 雙因素認證,但依舊可以釋出成功,等後續更新。

更多配置請檢視官方文件。

結語

這篇文章寫的很快(也很累),特別是元件邏輯部分,主要依賴動畫效果,而本人 CSS 又不大好。

如果對你有所幫助,歡迎點贊 Star 以及 PR,當然啦,也歡迎使用本元件。

如果有所錯漏還煩請評論區指正。

倉庫地址:

戳我 ✨