本文转载自掘金《从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
本文详细介绍如何使用tsdx、np等工具快速开发并发布Popup组件到npm仓库,涵盖组件开发、样式处理、示例部署及README生成全过程。
293

被折叠的 条评论
为什么被折叠?



