React + TypeScript 從零開發Popup組件併發布到 npm

上篇文章中介紹瞭如何從 0 到 1 搭建一個 React 組件庫架子,但爲了一兩個組件去搭建組件庫未免顯得大材小用。css

此次以移動端常見的一個組件 Popup 爲例,以最方便快捷的形式發佈一個流程完整的 npm 包。html

preview

若是對你有所幫助,歡迎點贊 Star 以及 PR。node

若是有所錯漏還煩請評論區指正。react

本文包含如下內容:android

  1. Popup組件的開發;webpack

  2. 一些工具的使用ios

    • tsdx :項目初始化、開發以及打包大管家;
    • np:一鍵發佈 npm 包;
    • gh-pages:部署示例 demo ;
    • readme-md-generator:生成一份規範的README.md文件。

本文不會和組件庫那篇文章通常死扣打包細節,由於單個組件和組件庫的打包有本質上的區別。

組件庫須要提供按需引入的能力,因此對組件僅僅是進行了語法上的編譯(以及比較繞的樣式處理),故選擇了 gulp 管理打包流程。

單組件則不一樣,因爲不須要提供按需引入的能力,只須要打包出一個 js bundle 和 css bundle 便可,webpack 以及 rollup 就更適用於此類場景。git

項目初始化

tsdx是一個腳手架,內置三種項目模板:github

  1. basic => 工具包模板
  2. react => React 組件模板,使用 parcel 用做 example 調試
  3. react-with-storybook => 同上,使用 storybook 編寫文檔以及 example 調試

模板還內置了startbuildtest以及lint等 npm scripts,的確是零配置開箱即用(大誤)。web

爲了方便講解,此處選擇react模板。

tsdx

執行npx tsdx create react-easy-popup,選擇react完成項目建立後進入項目目錄。

目錄結構

配置 tsdx

很尷尬的一點是: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.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能夠發現項目啓動啦,樣式生效且有瀏覽器前綴。

樣式配置完成

若 example 啓動後網頁報錯,刪除 example 下的.cache 以及 dist 目錄從新 start

須要注意的是 example 的入口文件index.tsx引入的是咱們打包後的文件,即dist/index.js

可是引入路徑卻爲'../.',這是由於 tsdx 使用了 parcelaliasing

example-index.ts

同時,觀察根目錄下的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結構。

portal-test

實現 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 雙因素認證,但依舊能夠發佈成功,等後續更新。

更多配置請查看官方文檔。

結語

這篇文章寫的很快(也很累),特別是組件邏輯部分,主要依賴動畫效果,而本人 CSS 又不大好。

若是對你有所幫助,歡迎點贊 Star 以及 PR,固然啦,也歡迎使用本組件。

若是有所錯漏還煩請評論區指正。

相關文章
相關標籤/搜索