Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React + TypeScript 从零开发Popup组件并发布到 npm #2

Open
worldzhao opened this issue May 9, 2021 · 4 comments
Open

React + TypeScript 从零开发Popup组件并发布到 npm #2

worldzhao opened this issue May 9, 2021 · 4 comments

Comments

@worldzhao
Copy link
Owner

前言

在组件库系列文章中介绍了如何从 0 到 1 搭建一个 React 组件库架子,但为了一两个组件去搭建组件库未免显得大材小用。

这次以移动端一个常用组件 Popup 为例,以最方便快捷的形式发布一个完整的 npm 包。

✨ 仓库传送门
🚀 在线预览

本文包含以下内容:

  1. Popup组件的开发;
  2. 一些工具的使用
    • tsdx :项目初始化、开发以及打包大管家;
    • np:一键发布 npm 包;
    • gh-pages:部署示例 demo ;
    • readme-md-generator:生成一份规范的README.md文件。

本文不会和组件库那篇文章一般死扣打包细节,因为单个组件和组件库的打包有本质上的区别:

  • 组件库需要提供按需引入的能力,所以对组件仅仅是进行了语法上的编译(以及比较绕的样式处理),故选择了 gulp 管理打包流程;
  • 单组件一般不需要提供按需引入的能力,只需要打包出一个 js bundle 和 css bundle 即可,webpack 与 rollup 就更适用于此类场景。

react-popup-1

项目初始化

tsdx 内置三种项目模板:

  1. basic => 工具包模板
  2. react => React 组件模板,使用 parcel 用作 example 调试
  3. react-with-storybook => 同上,使用 storybook 编写文档以及 example 调试

模板还内置了startbuildtest以及lint等 npm scripts,的确是零配置开箱即用(大误)。

为了方便讲解,此处选择react模板。

react-popup-2

执行npx tsdx create react-easy-popup,选择react完成项目创建后进入项目目录。

react-popup-3

配置 tsdx

由于tsdx没有提供样式文件打包支持,使用css in js方案会带来额外的依赖以及运行时消耗,所以需要简单配置一下tsdx以支持 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.tsxindex.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可以发现项目启动啦,样式生效且有浏览器前缀。

react-popup-4

若 example 启动后网页报错,删除 example 下的.cache 以及 dist 目录重新 start

需要注意的是 example 的入口文件index.tsx引入的是我们打包后的文件,即dist/index.js

但是引入路径却为'../.',这是因为 tsdx 使用了 parcelaliasing

react-popup-5

同时,观察根目录下的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中找到mainmodule以及typings相关配置。

基于 rollup 手动搭一个组件模板并不困难,但是社区已经提供了方便的轮子,就不要重复造轮子啦。既要有造轮子的能力,也要有不造轮子的觉悟。似乎我们正在造轮子?

实现 Portal

Popup在移动端场景下极其常见,其内部基于Portal实现,自身又可以作为ToastModal等组件的下层组件。

要实现Popup,就要先基于ReactDOM.createPortal实现一个Portal

此处结合官方文档做一个简单总结。

  1. 什么是传送门?Portal 是一种将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案。

  2. 为什么需要传送门?父组件有 overflow: hiddenz-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中写入PortalProps类型定义。

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<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结构。

react-popup-6

实现 Popup

API 梳理

老规矩,先规划 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';

前置 CSS 知识

在正式开发逻辑之前,先明确一点:

蒙层 Mask 以及内容 Content 入场以及出场均有动画效果。具体表现为:蒙层为 Fade 动画,内容则取决于当前 position,比如内容在中间(position === 'center'),则其动画效果为 Fade,如果在左边(position === 'left'),则其动画效果为 SlideRight,其他 position 以此类推。

再回顾张鑫旭大大的一篇文章:小 tip: transition 与 visibility

划重点:

  1. opacity的值在 01 之间相互过渡(transition)可以实现 Fade 动画。然而元素即使透明度变成 0,肉眼看不见,在页面上却依旧点击,还是可以覆盖其他元素的,我们希望元素淡出动画结束后,元素可以自动隐藏;
  2. 元素隐藏很容易想到display:none。而display:none 无法应用 transition 效果,甚至是破坏作用;
  3. visibility:hidden 可以看成 visibility:0visibility: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 的同学都知道,antdmodal在首次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中编写相关示例测试功能即可。

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 发包

在上一篇文章中,专门编写了一个脚本来处理以下六点内容:

  1. 版本更新
  2. 生成 CHANGELOG
  3. 推送至 git 仓库
  4. 组件打包
  5. 发布至 npm
  6. 打 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 双因素认证,但依旧可以发布成功,等后续更新。

更多配置请查看官方文档。

@Zebzhong
Copy link

Zebzhong commented Sep 9, 2022

我加了配置以后,修改src组件代码,Example得代码不会更新,up有遇到这种情况吗?

@worldzhao
Copy link
Owner Author

我加了配置以后,修改src组件代码,Example得代码不会更新,up有遇到这种情况吗?

tsdx 感觉很久没有维护了... 我两年前处理是没有问题 🤣

@Zebzhong
Copy link

Zebzhong commented Sep 9, 2022

这个库2年没维护了,现在以用,挺多坑得

@worldzhao
Copy link
Owner Author

我加了配置以后,修改src组件代码,Example得代码不会更新,up有遇到这种情况吗?

tsdx 感觉很久没有维护了... 我两年前处理是没有问题 🤣

推荐用 microbundle 打包,storybook 开发。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants