上篇文章中介绍了如何从 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,固然啦,也欢迎使用本组件。
若是有所错漏还烦请评论区指正。