上篇文章中介紹瞭如何從 0 到 1 搭建一個 React 組件庫架子,但爲了一兩個組件去搭建組件庫未免顯得大材小用。css
此次以移動端常見的一個組件 Popup
爲例,以最方便快捷的形式發佈一個流程完整的 npm 包。html
若是對你有所幫助,歡迎點贊 Star 以及 PR。node
若是有所錯漏還煩請評論區指正。react
本文包含如下內容:android
Popup
組件的開發;webpack
一些工具的使用ios
README.md
文件。本文不會和組件庫那篇文章通常死扣打包細節,由於單個組件和組件庫的打包有本質上的區別。
組件庫須要提供按需引入的能力,因此對組件僅僅是進行了語法上的編譯(以及比較繞的樣式處理),故選擇了 gulp 管理打包流程。
單組件則不一樣,因爲不須要提供按需引入的能力,只須要打包出一個 js bundle 和 css bundle 便可,webpack 以及 rollup 就更適用於此類場景。git
tsdx是一個腳手架,內置三種項目模板:github
模板還內置了start
、build
、test
以及lint
等 npm scripts,的確是零配置開箱即用(大誤)。web
爲了方便講解,此處選擇react
模板。
執行npx tsdx create react-easy-popup
,選擇react
完成項目建立後進入項目目錄。
很尷尬的一點是: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 = () => ( <div className="react-easy-popup">hello,react-easy-popup</div> ); 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 ( <div> <Popup /> </div> ); }; ReactDOM.render(<App />, document.getElementById('root')); 複製代碼
進入項目根目錄,執行如下命令:
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 手動搭一個組件模板並不困難,可是社區已經提供了方便的輪子,就不要重複造輪子啦。既要有造輪子的能力,也要有不造輪子的覺悟。彷佛咱們正在造輪子?
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
。
清空 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 aboutReact.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<HTMLElement | null>(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 ( <div> - <Popup /> + <Portal>123</Portal> + <Portal node={node}>456</Portal> </div> ); }; ReactDOM.render(<App />, document.getElementById("root")); 複製代碼
在網頁中看到預期的DOM
結構。
老規矩,先規劃 API,寫好類型定義,再動手寫代碼。
我寫這個組件的時候參考了Popup-cube-ui。
最終肯定 API 以下:
屬性 | 說明 | 類型 | 默認值 |
---|---|---|---|
visible | 可選,控制 popup 顯隱 | boolean | false |
position | 可選,內容定位 | 'center' / 'top' / 'bottom' / 'left' / 'right' | 'center' |
mask | 可選,控制蒙層顯隱 | boolean | true |
maskClosable | 可選,點擊蒙層是否能夠關閉 | boolean | false |
onClose | 可選,關閉函數,若 maskClosable 爲 true,點擊蒙層調用該函數 | function | ()=>{} |
node | 可選,元素掛載節點 | HTMLElement | - |
destroyOnClose | 可選,關閉是否卸載內部元素 | boolean | false |
wrapClassName | 可選,自定義 Popup 外層容器類名 | string | '' |
src/type.ts
export type Position = 'top' | 'right' | 'bottom' | 'left' | 'center'; type PopupPropsWithoutChildren = { node?: HTMLElement; } & typeof defaultProps; export type PopupProps = React.PropsWithChildren<PopupPropsWithoutChildren>; // 默認屬性寫在這兒很難受 實在是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 <div className="react-easy-popup">hello,react-easy-popup</div>; }; 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'; 複製代碼
在正式開發邏輯以前,先明確一點:
蒙層 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
複製代碼
Portal
便可;CSSTransition
組件的in
屬性,控制蒙層以及內容的過渡顯隱;CSSTransition
組件的unmountOnExit
屬性,決定隱藏時是否卸載內容節點;className
;className
,從而控制蒙層有無;用過 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 ( <Portal node={node}> <div className={rootCls}> <CSSTransition in={visible} timeout={duration} classNames={`${prefixCls}-fade`} appear > <div className={maskCls} onClick={onMaskClick}></div> </CSSTransition> <CSSTransition in={visible} timeout={duration} classNames={contentAnimation} unmountOnExit={destroyOnClose} appear > <div className={contentCls}>{children}</div> </CSSTransition> </div> </Portal> ); }; 複製代碼
@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
中編寫相關示例測試功能便可。
相信大多數人使用一個 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 會顯得做者很專業,此處使用readme-md-generator
生成基本框架,向裏面填充內容便可。
readme-md-generator:📄 CLI that generates beautiful README.md files
npx readme-md-generator -y
複製代碼
在上一篇文章中,專門編寫了一個腳原本處理如下六點內容:
此次就不生成 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;更多配置請查看官方文檔。
這篇文章寫的很快(也很累),特別是組件邏輯部分,主要依賴動畫效果,而本人 CSS 又不大好。
若是對你有所幫助,歡迎點贊 Star 以及 PR,固然啦,也歡迎使用本組件。
若是有所錯漏還煩請評論區指正。