上篇文章
中介紹瞭如何從 0 到 1 搭建一個 React 元件庫架子,但為了一兩個元件去搭建元件庫未免顯得大材小用。
這次以移動端常見的一個元件
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
模板。
執行
npx tsdx create react-easy-popup
,選擇
react
完成專案建立後進入專案目錄。
配置 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 = () => (
);
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(
進入專案根目錄,執行以下命令:
yarn start
現在
src
目錄下的內容的變更會被實時監聽,在根目錄下生成的
dist
資料夾包含打包後的內容。
開發時除錯的資料夾為
example
,另起一個終端。執行以下命令:
cd example
yarn # 安裝依賴
yarn start # 啟動example
在
localhost:1234
可以發現專案啟動啦,樣式生效且有瀏覽器字首。
若 example 啟動後網頁報錯,刪除 example 下的。cache 以及 dist 目錄重新 start
需要注意的是
example
的入口檔案
index。tsx
引入的是我們打包後的檔案,即
dist/index。js
。
但是引入路徑卻為
‘。。/。’
,這是因為
tsdx
使用了
parcel
的
aliasing
。
同時,觀察根目錄下的
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
// 元件解除安裝時 移除該節點
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 (
-
+
+
);
};
ReactDOM。render(
在網頁中看到預期的
DOM
結構。
實現 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
};
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 >
);
};
展開檢視樣式程式碼
@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,當然啦,也歡迎使用本元件。
如果有所錯漏還煩請評論區指正。
倉庫地址:
戳我 ✨