基础前端

写一个 Antd-spin 组件

2020-11-27  本文已影响0人  CondorHero

目的

如果你使用 Vue 开发项目,那么你一定用过或听过大名鼎鼎的 Element-UI,在 Element-UI 众多好用的组件中,有一个组件叫 Loading 组件,这个组件使用起来特别的灵活,支持:

可惜的是,强大无比的 Antd ,它的 Spin 组件竟然就只支持指令方式,而且配置选项还无法支持我们指定 DOM 进行渲染,尤其是在项目中使用非常的不方便。

所以就用 Antd UI 框架的 Spin 组件和 Icon 组件来实现 Element-UILoading 组件的服务方式 功能。

先感受下 Antd-spin 综合案例演示效果:

Antd-spin 案例演示 gif

环境准备

这个之前写过见文章 React 起步实现 hello world,这里快速搞一下,新建一个目录 app-react-demo,先看下文件结构:

├── main.js
├── main.less
├── package-lock.json
├── package.json
├── public
│   └── index.html
└── webpack.config.js

然后在文件夹里面执行命令:

# 生成 package.json 依赖文件
$ npm init -y
# 安装项目依赖,wepack-cli 要指定版本3 ,less-loader 要指定版本5
$ npm i -D webpack webpack-cli@3 webpack-dev-server @babel/core @babel/preset-env babel-loader @babel/preset-react antd react react-dom style-loader css-loader less-loader@5 less webpackbar friendly-errors-webpack-plugin webpack-bundle-analyzer address
# webpackbar 打包进度条

新建 webpack.config.js 文件内容为:

const path =  require("path");
const WebpackBar = require("webpackbar");
const FriendlyErrorsWebpackPlugin = require("friendly-errors-webpack-plugin");
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
const address = require("address");
// address.ip 的实现思路是使用 os.platform 辨别平台,在去读取 os.networkInterfaces 里面的 IP
const IP = address.ip();

const PORT = 6666;
module.exports = {

    mode: "development",

    entry: "./main.js",

    output: {
        // webpack要求的输出路径
        // path: path.resolve(__dirname,"dist"),
        // webpack-dev-server的虚拟输出路径
        publicPath: "virtual",
        filename: "all.js"
    },
    module: {
        rules: [
            {
                // 以less结尾的文件
                test: /\.less$/,
                use: [
                    {
                        loader: "style-loader"  // creates style nodes from JS strings
                    },
                    {
                        loader: "css-loader"        // translates CSS into CommonJS
                    },
                    {
                        loader: "less-loader",
                        options: {
                            javascriptEnabled: true
                        }   // compiles Less to CSS
                    }
                ]
            },
            {
                test: /\.m?js$/,//匹配.mjs和.js结束的文件
                exclude: /(node_modules|bower_components)/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env', '@babel/preset-react']
                    }
                }
            }
        ]
    },
    plugins: [
        new WebpackBar(),
        new FriendlyErrorsWebpackPlugin({
            compilationSuccessInfo: {
                messages: [ `You can now view liuguoci in the browser.\n        Local:            http://localhost:${PORT}\n        On Your Network:  http://${IP}:${PORT}` ],
                notes: [ `Note that the development build is not optimized \n To create a production build, use npm run build.` ]
            },
            onErrors: (severity, errors) => {
                console.log(severity, errors);
            },
            // default is true
            clearConsole: true,
        }),
        new BundleAnalyzerPlugin({
            // module 依赖关系
            generateStatsFile: false,
            // 默认 8888
            analyzerPort: 8888,
            // 打包完成默认打开分析页面
            openAnalyzer: false
        })
    ],
    resolve: {
        //自动解析确定的扩展。默认值为:
        extensions: [".js", ".json", ".jsx", ".css"],
        //解析目录时要使用的文件名。默认:
        mainFiles: ["index", "Index"]
    },
    devServer: {
        /* webpack-dev-server 结合 friendly-errors-webpack-plugin 的设置 */
        quiet: true,
        contentBase: path.join(__dirname, "public"),   // public目录开启服务器
        hot: true,   // 开启热更新
        compress: true,    // 是否使用gzip压缩
        // port: PORT,    // 端口号
        // open : true   // 自动打开网页
        // https: true,
        // proxy: {
        //     "/api": "http://localhost:9999"
        // }
    },
}

新建 main.js 文件内容为:

import React from "react";
import ReactDOM from "react-dom";
import "./main.less";
import 'antd/dist/antd.less'; // or 'antd/dist/antd.less';
ReactDOM.render(<h1>hello world!</h1>, document.getElementById("app"));

public 文件夹新建 index.html 文件内容为:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>写一个 Antd-spin 组件</title>
</head>
<body>
    <div id="app"></div>
    <script src="virtual/all.js"></script>
</body>
</html>

package.json 修改 scripts 字段为:

"scripts": {
    "dev": "webpack-dev-server"
},

项目终端执行命令 npm run dev,浏览器打开 http://localhost:6666 链接即可看到项目启动完成。

本来打包工具我是想用 webpack 的,因为只对 webpack 比较熟,结果大意了,结果配了一天的环境,愣是没配好,越来越感觉前端在工具化这方便还有很长的路要走,一些周边工具官方不维护也不指定,导致各种方案层出不穷,容易选择困难症,而且各自迭代也是非常的快,往往就会出现兼容问题。webpack 也是其代表之一,顺便了解下:

webpack 为什么这么难用?

文章发布于 webpack4 发版前期,现在 webpack5 都出来了,发现没问题,核心问题还是没解决。不过不要怕我们的战神尤大神,已经在解决这个问题了,Vite 横空出世,等它稳定了,绝对吊打一切 JS 打包工具,而且绝对贼其简单,文档绝对简单易读。期待中...

在 app-react-demo 同级目录下,在新建一个项目 antd-spin 目录。进入目录执行命令:

# 生成 package.json 依赖文件
$ npm init -y

# 安装项目依赖
npm i -D @babel/cli @babel/core @babel/plugin-proposal-class-properties @babel/preset-env @babel/preset-react @rollup/plugin-babel antd babel-eslint eslint eslint-config-prettier eslint-plugin-prettier eslint-plugin-react husky lint-staged prettier react react-dom rollup rollup-plugin-postcss

#

配置文件过多,请到 github antd-spin 获取,顺便可以学学项目工程化配置的内容,这部分我参考了三个特别有用的资源。

设计思路:

1. 组件如何渲染上树?

使用 React 的核心 API,ReactDOM.render() 方法来实现。

2. 组件如何实现 target?

简单,target 没有赋,也就是值默认情况下,ReactDOM.render() 渲染到 body 元素下,就实现了全局 loading,当 target 有值值分三种情况:

  1. JS 原生 DOM
  2. React 的 createRef/uesRef 创建的 DOM
  3. 传入字符串时,通过 docuemnt.querySelector 来查找 DOM

有值的三种情况,都是取 DOM,有了DOM 把 ReactDOM.render() 之后的内容渲染到 DOM 中。就实现了局部 loading。

需要注意的是全局 loading 使用的 fixed 定位,局部 loading 使用的是 absolute 定位。因为局部 loading 使用 absolute 定位,会造成脱标,所以通过 getComputedStyle 和 getPropertyValue 拿到父元素的定位值,当 position 不是 inherit 或 static 添加 relative,来避免脱标的影响。

3. 组件如何实现多次创建只有一个 loading?

loading 的单例模式,这个很简单通过一个信号量变量 requestFlag 来控制,请求的时候为 true,请求结束为 false,分别加条件判断就行了,防抖思想的运用。

4. 支持 Antd 的 Icon 组件的所有属性

记住一句话:

Only Call Hooks from React Functions
React 的 hooks 只能在函数组件里面调用

我们封装的组件 antdSpin,其实就是一个普通函数(虽然它是一个类,本质还是函数),所以无法直接调用 hooks 的,只能使用 JSX 语法来定义 hook,而 Icon 组件很多属性都是都通过 React Functions 来控制的,这点不像 Element-UI,是通过类名来控制的。所以在实现的时候:

  1. hook 尽量写在 antdSpin 里面。
  2. 比较遗憾的是,Icon 组件本身就是通过函数组件来使用的,所以只能委屈的用动态 import("@ant-design/icons") 来实现,使用的时候传 Icon 名字的字符串就行了。(PS:这个卡我半天,最难受的地方😣

注意,如果 loading/Spin 的图标是自定义的,我们就要用到自己图标了,那怎么办呢?当当当,当然是让 UI 小姐姐给我们图了,记住不要 PNG、不要 JPG就要 SVG 的。SVG 是支持代码编辑的,我们就利用 SVG 的这个特性,把 SVG 封装一个函数组件,然后配合 component 属性来使用。

不要急就快写完了,还差一个功能,使用 iconfont.cn 在线图标。

千万记住是 JS 文件,我第一次就弄错了点击的 font class 模块复制的 CSS 文件。

在线图标使用

就这四个难点,没别的了,代码剩下的就是大量逻辑判断,用来处理些边边角角的东西的。组件的核心代码在这 antd-spin 核心代码,注释我写的非常清楚就不继续一行一行的解释了。组件的用法也不写了,在这 antd-spin README

使用演示

给一个综合使用案例,案例演示动图在文章开头处,下面是源代码:

一点样式:

html, body, #root {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-wrap: wrap;
}
section {
  width: 200px;
  height: 200px;
  margin: 10px;
  border: 1px solid skyblue;
}
main {
  width: 200px;
  height: 200px;
  margin: 10px;
  border: 1px solid rgb(27, 218, 110);
}
.global-text {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  text-align: center;
  color: #b03fc3;
  font-size: 50px;
  line-height: 100px;
  border-radius: 5px;
}

核心 JS 代码:


import './App.css';
// 引入 Spin 服务:
import antdSpin from "antd-spin";
import { useEffect, useRef, useState } from "react";
const delay = (instance, ms) => new Promise((resolve, reject) => setTimeout(() => {
    instance && instance.close();
    resolve();
}, ms * 1000));

const HeartSvg = (props) => (
    <svg { ...props} width="1em" height="1em" fill="currentColor" viewBox="0 0 1024 1024">
        <path d="M923 283.6c-13.4-31.1-32.6-58.9-56.9-82.8-24.3-23.8-52.5-42.4-84-55.5-32.5-13.5-66.9-20.3-102.4-20.3-49.3 0-97.4 13.5-139.2 39-10 6.1-19.5 12.8-28.5 20.1-9-7.3-18.5-14-28.5-20.1-41.8-25.5-89.9-39-139.2-39-35.5 0-69.9 6.8-102.4 20.3-31.4 13-59.7 31.7-84 55.5-24.4 23.9-43.5 51.7-56.9 82.8-13.9 32.3-21 66.6-21 101.9 0 33.3 6.8 68 20.3 103.3 11.3 29.5 27.5 60.1 48.2 91 32.8 48.9 77.9 99.9 133.9 151.6 92.8 85.7 184.7 144.9 188.6 147.3l23.7 15.2c10.5 6.7 24 6.7 34.5 0l23.7-15.2c3.9-2.5 95.7-61.6 188.6-147.3 56-51.7 101.1-102.7 133.9-151.6 20.7-30.9 37-61.5 48.2-91 13.5-35.3 20.3-70 20.3-103.3 0.1-35.3-7-69.6-20.9-101.9z" />
    </svg>
);

function App() {
    const ref = useRef();
    const [ text, setText ] = useState();
    
    useEffect(() => {
        (async function () {
            await delay(null, 1);
            await delay(setText("Are u ready?"), 1);
            await delay(setText("please count of three!"), 1);
            await delay(setText(3), 1);
            await delay(setText(2), 1);
            await delay(setText(1), 1);
            await delay(setText("ready go!"), 1);
            await delay(setText(""), 0);
            // options 参数支持的配置对象
            let options = {
                target: ref.current,
                lock: false,
                text: "传入 ReactDOM 演示",
                background: "rgba(0, 0, 0, .1)",
                customClass: "antd-spin"
            };
            // 在需要调用时,以服务的方式调用的 antdSpin 且是单例的
            let antdSpinInstance = antdSpin.service(options);
            // 关闭
            await delay(antdSpinInstance, 3);

            const mainId = document.getElementById("main-id");
            options = {
                target: mainId,
                lock: false,
                indicator: "PlusSquareTwoTone",
                loadingConfig: {
                    spin: true
                },
                text: "双色图标和JS传入DOM演示...",
                background: "rgba(0, 0, 0, .2)",
                customClass: "antd-spin",
                twoToneColor: "#73c41d"
            };
    
            antdSpinInstance = antdSpin.service(options);

            await delay(antdSpinInstance, 3);
            options = {
                target: "aside",
                lock: false,
                text: "自动搜索 DOM 演示",
                indicator: "LoadingOutlined",
                background: "rgba(0, 0, 0, .3)",
                customClass: "antd-spin"
            };
    
            antdSpinInstance = antdSpin.service(options);
            await delay(antdSpinInstance, 3);
            options = {
                target: "sys-icon",
                text: "图标动画配置演示",
                indicator: "ReloadOutlined",
                loadingConfig: {
                    spinner: "icon-class",
                    /* 图标旋转角度(IE9 无效) */
                    rotate: 180,
                    /* 是否有旋转动画 */
                    spin: true
                }
            };

            antdSpinInstance = antdSpin.service(options);
            await delay(antdSpinInstance, 3);

            options = {
                target: "sys-icon",
                text: "在线 icon 演示",
                IconFont: {
                    type: "icon-tuichu",
                    scriptUrl: "//at.alicdn.com/t/font_8d5l8fzk5b87iudi.js"
                },
                loadingConfig: {
                    rotate: 90,
                    spin: true,
                    style: { fontSize: 40, color: "red" }
                }
            };

            antdSpinInstance = antdSpin.service(options);
            await delay(antdSpinInstance, 3);

            options = {
                target: "sys-svg",
                text: "自定义组件演示",
                component: HeartSvg,
                loadingConfig: {
                    rotate: 10,
                    spin: true,
                    style: { fontSize: 40, color: "red" }
                }
            };

            antdSpinInstance = antdSpin.service(options);
            await delay(antdSpinInstance, 3);

            options = {
                background: "rgba(0, 0, 0, .75)",
                text: "加载中..."
            };
            antdSpinInstance = antdSpin.service(options);
            await delay(antdSpinInstance, 3);

            await delay(setText("game over!"), 2);

        })();
    
    }, []);

    return (
        <>
            <span className="global-text">{text}</span>
            <section ref={ref}></section>
            <main id="main-id"></main>
            <main id="aside"></main>
            <main id="sys-icon"></main>
            <main id="sys-svg"></main>
        </>
    );
}

export default App;

Ajax/fetch 封装的一些思考🤔

  1. 页面开始加载,并发十个请求,也就是需要同时请求十个接口 needRequestCount = 10
    • loading 组件无单例模式时,创建了十个 loading 图,造成 loading 闪动问题。
    • loading 组件有单例模式时,第一个请求创建了 loading 图,因为是并发十个请求,所以之后的九个请求不再创建 loading,第一个请求结束拿到数据 loading 就会立刻关闭,问题在于其他九个接口可能返回数据慢,但是 loading 已经结束,loading 图的作用没有完全发挥出来。

并发请求我们很明显要选择单例模式的,接下来的问题在于找到开始第一个请求开始和最后一个请求结束,用它们之间的时间,用作 loading 图的时间,核心实现代码如下。

// 页面开始加载,并发十个请求, needRequestCount = 10:
        
let needRequestCount = 0;

// 1. interceptors.request ++,  请求之前
if (needRequestCount === 0) {
    startLoading();
};
needRequestCount++;

// 2. interceptors.response --  返回数据之后
if( needRequestCount <= 0 )  return
needRequestCount--;
needRequestCount = Math.max(needRequestCount, 0);
if(needRequestCount === 0){
    endLoading()
};

/*
    fetch backward
    1. 不能获取进度
    2. 不能设置超时
*/
  1. 接口之间有依赖性,比如我要联动十个接口

并发请求用的是 减法逻辑,本来联动请求我想用加法逻辑发现行不通,封装完不好用👎。坐地铁回家的时候想来了,联动十个请求可不就是隐藏 loading 图的功能吗。前九个隐藏 loading 图,只有第十个请求是能创建 loading 图的。你还可以视接口返回时间长短,或减少用户等待时间等,把创建 loading 图这个动作,放在第五个接口。不过一般联动接口也就两三个,就放在最后一个接口上面创建 loading 就行了。也算完美解决了😂。

最后

不得不讲,那些封装库给我们用的人是在是太厉害了,我就写了这么个小东西把我给累的半死,现在版本都发到 v1.0.6 了,更新了六个版本才算稳定,编程经验不够,但是需要考虑的东西还要求多,还是有点顾此失彼的感觉,前路漫漫,还是猥琐发育,别浪。

周五了,刚入职的时候做的那个项目请客吃饭,晚上又能省一顿饭钱💰,哈哈哈就是这么没出息。

当前时间 Friday, November 27, 2020 14:27:12

上一篇下一篇

猜你喜欢

热点阅读