本文轉載自掘金《從0到1發佈一個Popup組件到npm》,作者「海秋」。
點擊下方閱讀原文去點個贊吧!
上篇文章[1]中介紹瞭如何從 0 到 1 搭建一個 React 組件庫架子,但爲了一兩個組件去搭建組件庫未免顯得大材小用。
這次以移動端常見的一個組件 Popup
爲例,以最方便快捷的形式發佈一個流程完整的 npm 包。
???? 在線預覽[2]
✨ 倉庫地址[3]
如果對你有所幫助,歡迎點贊 Star 以及 PR。
如果有所錯漏還煩請評論區指正。
本文包含以下內容:
Popup
組件的開發;一些工具的使用
tsdx[4] :項目初始化、開發以及打包大管家;
np[5]:一鍵發佈 npm 包;
gh-pages[6]:部署示例 demo ;
readme-md-generator[7]:生成一份規範的
README.md
文件。
本文不會和組件庫那篇文章一般死扣打包細節,因爲單個組件和組件庫的打包有本質上的區別。
組件庫需要提供按需引入的能力,所以對組件僅僅是進行了語法上的編譯(以及比較繞的樣式處理),故選擇了 gulp 管理打包流程。
單組件則不同,由於不需要提供按需引入的能力,只需要打包出一個 js bundle 和 css bundle 即可,webpack 以及 rollup 就更適用於此類場景。
項目初始化
tsdx[8]是一個腳手架,內置三種項目模板:
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[9]這一小節進行配置。
安裝相關依賴:
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[10]。
同時,觀察根目錄下的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 # 壓縮ES Module規範的組件組件代碼
└── react-easy-popup.min.css # 樣式文件
也可以很輕易地在package.json
中找到main
、module
以及typings
相關配置。
基於 rollup 手動搭一個組件模板並不困難,但是社區已經提供了方便的輪子,就不要重複造輪子啦。既要有造輪子的能力,也要有不造輪子的覺悟。似乎我們正在造輪子?
實現 Portal
Popup
在移動端場景下極其常見,其內部基於Portal
實現,自身又可以作爲Toast
和Modal
等組件的下層組件。
要實現Popup
,就要先基於ReactDOM.createPortal[11]實現一個Portal
。
此處結合官方文檔做一個簡單總結。
什麼是傳送門?
Portal
是一種將子節點渲染到存在於父組件以外的DOM
節點的優秀的方案。爲什麼需要傳送門?父組件有
overflow: hidden
或z-index
樣式,我們又需要子組件能夠在視覺上“跳出”其容器。例如,對話框、懸浮卡以及提示框。
同時還有很重要的一點:portal
與普通的 React
子節點行爲一致,仍存在於React
樹,所以Context
依舊可以觸及。有一些彈層組件會提供xxx.show()
的 API 形式進行彈出,這種調用形式較爲方便,雖然底層也是基於Portal
,但是內部重新執行了ReactDOM.render
,脫離了當前主應用的React
數,自然自然也無法獲取到Context
。
推薦閱讀:傳送門:React Portal-程墨 Morgan[12]
清空 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[13]: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
結構。
實現 Popup
API 梳理
老規矩,先規劃 API,寫好類型定義,再動手寫代碼。
我寫這個組件的時候參考了Popup-cube-ui[14]。
最終確定 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';
前置 CSS 知識
在正式開發邏輯之前,先明確一點:
蒙層 Mask 以及內容 Content 入場以及出場均有動畫效果。具體表現爲:蒙層爲 Fade 動畫,內容則取決於當前 position,比如內容在中間(position === 'center'),則其動畫效果爲 Fade,如果在左邊(position === 'left'),則其動畫效果爲 SlideRight,其他 position 以此類推。
再回顧張鑫旭大大的一篇文章:小 tip: transition 與 visibility[15]
劃重點:
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[16]完成動畫效果,需要內置一些動畫樣式。
新建animation.less
,寫入以下動畫樣式。
完成基本邏輯
安裝相關依賴。
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
屬性,我們也順帶實現這個特點。
展開查看邏輯代碼展開查看樣式代碼代碼邏輯比較簡單,在拼接類名時注意配合樣式文件一起閱讀,重要的點都有註釋標出。
組件編寫完畢,接下來在example/index.ts
中編寫相關示例測試功能即可。
example/index.ts[17]
部署 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[18]:???? CLI that generates beautiful README.md files
npx readme-md-generator -y
README.md[19]
使用 np 發包
在上一篇文章中,專門編寫了一個腳本來處理以下六點內容:
版本更新
生成 CHANGELOG
推送至 git 倉庫
組件打包
發佈至 npm
打 tag 並推送至 git
這次就不生成 CHANGELOG 文件了,其他五點配合np
,操作十分簡單。
np[20]:A better npm publish
yarn add np --dev
package.json
{
"scripts": {
// ...
"release": "np --no-yarn --no-tests"
}
}
npm login
npm run release
--no-yarn
:不使用yarn
。發包時出現 npm 與 yarn 之間的一些問題;--no-tests
:測試用例暫時還未編寫,先跳過;首次發佈新包時可能會報錯[21],因爲 np 進行了 npm 雙因素認證,但依舊可以發佈成功,等後續更新。
更多配置請查看官方文檔。
結語
這篇文章寫的很快(也很累),特別是組件邏輯部分,主要依賴動畫效果,而本人 CSS 又不大好。
如果對你有所幫助,歡迎點贊 Star 以及 PR,當然啦,也歡迎使用本組件。
如果有所錯漏還煩請評論區指正。
倉庫地址:戳我 ✨[22]
參考資料
[1]
上篇文章: https://juejin.im/post/5ebcf12df265da7bc55df460
[2]???? 在線預覽: https://worldzhao.github.io/react-easy-popup/
[3]✨ 倉庫地址: https://github.com/worldzhao/react-easy-popup
[4]tsdx: https://github.com/jaredpalmer/tsdx
[5]np: https://github.com/sindresorhus/np
[6]gh-pages: https://www.npmjs.com/package/gh-pages
[7]readme-md-generator: https://github.com/kefranabg/readme-md-generator
[8]tsdx: https://github.com/jaredpalmer/tsdx
[9]customization-tsdx: https://github.com/jaredpalmer/tsdx#customization
[10]aliasing: https://github.com/palmerhq/tsdx/pull/88/files
[11]ReactDOM.createPortal: https://zh-hans.reactjs.org/docs/portals.html
[12]傳送門:React Portal-程墨 Morgan: https://zhuanlan.zhihu.com/p/29880992
[13]react-typescript-cheatsheet: https://github.com/typescript-cheatsheets/react-typescript-cheatsheet
[14]Popup-cube-ui: https://didi.github.io/cube-ui/#/zh-CN/docs/popup
[15]小 tip: transition 與 visibility: https://www.zhangxinxu.com/wordpress/2013/05/transition-visibility-show-hide/
[16]react-transition-group: https://github.com/reactjs/react-transition-group
[17]example/index.ts: https://github.com/worldzhao/react-easy-popup/blob/master/example/index.tsx
[18]readme-md-generator: https://github.com/kefranabg/readme-md-generator
[19]README.md: https://github.com/worldzhao/react-easy-popup/blob/master/README.md
[20]np: https://github.com/sindresorhus/np
[21]報錯: https://github.com/sindresorhus/np/issues/398
[22]戳我 ✨: https://github.com/worldzhao/react-easy-popup
支持
如果你覺得這篇內容對你挺有啓發,我想邀請你幫我三個小忙:
點個「在看」,讓更多的人也能看到這篇內容(喜歡不點在看,都是耍流氓 -_-)
關注我的官網 https://muyiy.cn,讓我們成爲長期關係
關注公衆號「高級前端進階」,公衆號後臺回覆「面試題」 送你高級前端面試題,回覆「加羣」加入面試互助交流羣
》》面試官都在用的題庫,快來看看《《