跳转到内容

组件

刀刀

1/2/2025

0 字

0 分钟

组件封装

封装思路

在项目中,组件包括以下几种:

  1. 普通业务组件:SPA 中一个个页面,一般放置在 src/views
  2. 通用业务组件:好多页面中都需要的,我们提取为公共的组件,一般放置在 src/components 下,进行封装和抽取,保证更好的复用性,如属性、插槽
  3. 通用功能组件:一般 UI 开源组件中都有,如果没有或者不支持才需要自主封装。如大文件上传或断点续传。

一般情况下,我们会对 UI 组件库的组件做二次封装:

  • 统一处理复杂业务
  • 统一处理样式
  • 几个组件作为一个整体的组件

封装组件

头部导航栏封装

antd-modile 组件库提供了头部导航栏组件 NavBar ,但是每次使用都需要引入、设置标题、设置 onBack 事件,代码重复,效率不高。把它抽离出来作为公共组件,父组件只需要传递标题即可。

子组件通过父组件传参动态渲染子组件的内容到页面上,因此父组件的传参需要添加判断,给使用的父组件提供校验,避免传了错误的类型导致报错。

在 React 中,子组件的 props 参数类型通过 props-type 来设置,语法为 函数名.PropTypes 。代码如下:

jsx
import React from "react";
import PropTypes from "prop-types";
import { NavBar } from "antd-mobile";

function MyNavBar(props) {
  const { title } = props;

  // 点击返回
  const handleBack = () => {};
  return <NavBar onBack={handleBack}>{title}</NavBar>;
}

// 类型
MyNavBar.propTypes = {
  title: PropTypes.string,
};

export default MyNavBar;

也可以设置默认参数,父组件没传参时显示默认莫参数,代码如下:

jsx
MyNavBar.defaultProps = {
  title: "个人中心",
};

点击返回上一页时需要判断,如果是从某些页面执行操作跳转来的登录页,则要返回上一页,否则直接返回首页。代码如下:

js
const whiteList = [/^\/detail\/\d+$/]

const handleBack = () => {
  let to = usp.get('to')
  if(location.pathname === '/login' && whiteList.some(item => item.test(to))) {
    navigate(to, { replace: true })
    return
  }
  navigate(-1)
};

按钮封装

按钮需要怎么样的封装?

有部分场景比如支付、登录、获取验证码等,如果页面没有相应的效果,则有可能造成用户多次点击发送多次请求。因此需要给按钮添加 loading 效果直到事件结束,或者添加防抖节流等操作。

而按钮其他属性,则需要原封不动渲染到 <Button /> 按钮组件上,如下:

jsx
<MyButton size="small" type="primary" onClick={handle}>click me</MyButton>

封装成组件后需要父组件传递 sizetypeonClick 方法和 click me 按钮文本给子组件,子组件需要获取并绑定到按钮组件上去。下面依次来判断:

  • sizetype 等变量属性子组件可以通过 props 形参直接获取到,可以直接使用对象解构的方式获取设置
  • click me 内容文本子组件也能获取到,形参 props 中有一个属性 children ,值则为父组件传递的文本
  • onClick 函数方法也是通过形参 props 获取,但是由于需要为按钮添加额外的设置,也需要添加 onClick 事件,因此通过 props 获取的点击事件函数需要设置别名

需要注意的是,后续按钮子组件在获取父组件传递过来的 props 可能需要做某些改动,而 props只读属性 ,因此需要赋值给一个新的变量,从而操作那个变量。

代码
jsx
import React, { useState } from "react";
import { Button } from "antd-mobile";

export default function MyButton(props) {
  // props包含<Button></Button>组件里的所有属性
  let options = { ...props };
  let { children, onClick: handleClick } = options;
  delete options.children;
  delete options.handleClickFn;

  // 点击事件
  const handleClickFn = async () => {
    try {
      await handleClick();
    } catch (error) {
      console.log(error);
    }
  };

  return (
    <Button {...options} onClick={handleClickFn}>
      {children}
    </Button>
  );
}

上方代码中,父组件传过来的数据通过对象点语法简便的全部赋到 <Button /> 组件上。由于 onClick 事件与文本内容是独立设置,因此还需要把这两个属性方法从 options 对象中去除。

接下来就是处理这个点击事件。子组件按钮点击事件触发后,先让按钮开启 loading 加载状态,然后执行父组件传递过来的事件函数。执行完毕后关闭 loading 状态。代码如下所示:

jsx
export default function MyButton(props) {
  // ...

  // 状态
  let [loading, setLoading] = useState(false);

  // 点击事件
  const handleClickFn = async () => {
    setLoading(true);

    try {
      await handleClick(); // handleClick方法是父组件传过来的方法,等待它执行完毕再取消loading
    } catch (error) {
      console.log(error);
    }
    setLoading(false);
  };

  return (
    <Button {...options} loading={loading} onClick={handleClickFn}>
      {children}
    </Button>
  );
}

这么写已经能够实现业务了,但是还是会有一点潜在的小 BUG 需要优化。如果父组件没传点击事件函数,则使用该子组件时就会报错。该怎么办呢?

此时不能直接给按钮绑定点击事件,而是事先判断父组件有没有传点击事件,传递了点击事件后再把 onClick 事件绑定到 options 函数中。由于 options 函数通过对象的点语法赋值给 <Button /> 按钮组件,因此也能最终把点击事件赋值上去。

最终代码
jsx
import React, { useState } from "react";
import { Button } from "antd-mobile";
import "./MyButton.scss";

export default function MyButton(props) {
  // props包含<Button></Button>组件里的所有属性
  let options = { ...props };
  let { children, onClick: handleClick } = options;
  delete options.children;
  // 注意这里不能把点击事件删除了

  // 状态
  let [loading, setLoading] = useState(false);

  // 点击事件
  const handleClickFn = async () => {
    setLoading(true);

    try {
      await handleClick(); // handleClick方法是父组件传过来的方法,等待它执行完毕再取消loading
    } catch (error) {
      console.log(error);
    }
    setLoading(false);
  };

  // 如果父组件没传点击事件,则需要把我们的点击事件传进去;没有传点击事件,则不给他绑定点击事件。
  // 由于下面通过解构options,因此onClick方法也能被绑定进去
  if (handleClick) options.onClick = handleClickFn;

  return (
    <Button {...options} loading={loading}>
      {children}
    </Button>
  );
}

组件页面

首页

头部组件

静态图片引入

在引入静态图片资源时,不能使用相对路径的方法,如下:

jsx
<img src="../assets/images/logo512.png" alt="" />

保存运行后会发现图片没有渲染到页面上。这是因为打包处理后,项目的结构目录会被改变,而图片的资源路径还是原来的资源路径。

而 CSS 通过相对路径使用静态资源图片生效的原因是 CSS 打包后会处理css的图片导入,所以可以使用相对路径。其主要做以下操作:

  1. 把需要的图片打包
  2. 把打包后的地址重新覆盖css中写的地址

因此解决图片引入不生效的办法有两个:

  1. 使用绝对路径或网址路径
  2. 基于ES6Module模块的方式导入图片

代码修改为以下形式:

jsx
import React from "react";
import timg from "../assets/images/logo512.png";

export default function HomeHead() {
  return (
    <div className="home-head-box">
      <div className="photo">
        <img src={timg} alt="" />
      </div>
    </div>
  );
}

现在运行页面,可以看到图片已经生效。

后续判断 redux 中是否保存到用户信息,如果没有则尝试调用接口获取最新的用户信息。图片标签则通过三元表达式判断渲染对应的图片。

代码
jsx
import React, { useEffect } from "react";
import timg from "../assets/images/logo512.png";
import Api from '@/api'
import { connect } from 'react-dom'
import action from '@/store/action'

function HomeHead(props) {
  const {info, infoAsync} = props
  
  useEffect(() => {
    if(!info) {
      infoAsync()
    }
  }, [])
  
  return (
    <div className="home-head-box">
      <div className="photo">
        <img src={info ? info.img : timg} alt="" />
      </div>
    </div>
  );
}

export default connect(
	state => state.base,
  action.base
)(HomeHead)
计算月日

计算年月我们封装成一个函数方法,该方法使用了以下两个知识点:

  • useMemo :计算属性,缓存数据,优化性能,第一个参数表示一个 回调函数 ,第二个表示依赖的数据。

    其在依赖数据发生变化的时候,才会调用传进去的回调函数去重新计算结果,起到一个缓存的作用。

    在 React 中,还有一个属性 useCallback 也有同样的作用,二者的区别是:

    1. useMemo 缓存的结果是回调函数中 return 回来的值,主要用于缓存计算结果的值,应用场景如需要计算的状态
    2. useCallback 缓存的结果是函数,主要用于缓存函数,应用场景如需要缓存的函数,因为函数式组件每次任何一个state发生变化,会触发整个组件更新,一些函数是没有必要更新的,此时就应该缓存起来,提高性能,减少对资源的浪费;另外还需要注意的是,useCallback 应该和React.memo 配套使用,缺了一个都可能导致性能不升反而下降。
    示例代码
    js
    import React, { useState, useMemo } from 'react';
     
    function Info(props) {
      let [personalInfo , setPersonalInfo] = useState({
        name: 'kevin kang',
        gender: 'male'
      })
     
      function formatGender(gender) {
        console.log('---调用了翻译性别的方法---')
        return gender === 'male' ? '男' : '女'
      }
     
     
      // BAD 
      // 不使用useMemo的情况下,修改其他属性,也会重新调用formatGender方法,浪费计算资源
      // let gender =  formatGender(personalInfo.gender)
     
      // GOOD
      let gender = useMemo(()=>{
        return formatGender(personalInfo.gender)
      }, 
      [personalInfo.gender])
     
      return (
        <>
            <div>
              姓名: {personalInfo.name} -- 性别:  { gender } <br/>
              <button onClick={ 
                ()=> { 
                  setPersonalInfo({
                    ...personalInfo,
                    name: 'Will Kang'
                  }) 
                }  
              }> 点击修改名字</button>
            </div>
        </>
      )
    }
     
    export default Info
  • match

    match 一般都要结合 RegExp 的全局g属性来使用的,这样返回的结果是:匹配到的所有结果的数组;

    js
    let str1 = "12345678";
    let reg1 = /\d{1,3}(\d{3})*/g
    console.log(str1.match(reg1))  // ["123456", "78"]

    如果不结合g使用,则在没有分组的情况下,只会返回一个匹配结果(或者没有匹配结果时返回null);

    js
    let str0 = "12345678";
    let reg0 = /\d{1,3}/
    console.log(str0.match(reg0)) 
    // ['123', index: 0, input: '12345678', groups: undefined]
    1234
    
    // 这个例子的结果是没有使用全局匹配的正则表达式的匹配结果。说白了,就是正则表达式的末尾没跟g。由于不适用全局匹配,所以match()方法只找到源字符串中首次匹配的子串后,就立刻得到返回结果,不再比较之后剩余的部分是否还有能匹配上的内容。 
    
      // 我们可以看到,match()的结果是一个数组,该数组一共有4项。各项代表的意思如下:
      // 第0项:匹配到字符串
      // 第1项:index表示首次匹配上的子串的起始下标。
      // 第2项:input,表示源字符串
      // 第3项:groups:undefined,这表示当前的正则表达式没使用分组
      // 第4项:length,表示匹配到的结果个数,由于这里不使用全局匹配,只找到首次匹配项就结束了,所以匹配结果只有1个,length也就是1。

    如果不结合g使用,在有分组的情况下(还要没有开始(^)和结束($)符),则还会返回分组匹配到的内容,例如:

    js
    var str = 'Today is the 286th day of 2018, the 108th Thanksgiving Day.';
    var results = str.match(/\d+(t)(h)/); //匹配str中首个以数字开头,并且以th结尾的子串
    console.log(results); // ['286th', 't', 'h', index: 13, input: 'Today is the 2

前置知识点盘点完毕后,接下来开始实现计算月日的方法。步骤如下:

  1. useMemo() 监听当前时间,时间变量发生才执行方法,减少性能消耗
  2. match() 方法找出去符合条件的月和日,返回的是一个数组,用数组解构的方式分别获取
  3. 创建一个月份字典数组,通过月的值作为索引获取对应的月份
代码示例
jsx
// ...

export default function HomeHead(props) {
  const { today } = props;

  // 计算时间中的月和日
  let times = useMemo(() => {
    let a = today.match(/^\d{4}(\d{2})(\d{2})$/);
    console.log(a);
    let [, months, day] = today.match(/^\d{4}(\d{2})(\d{2})$/),
      area = [
        "一月",
        "二月",
        "三月",
        "四月",
        "五月",
        "六月",
        "七月",
        "八月",
        "九月",
        "十月",
        "十一月",
        "十二月",
      ];
    return {
      month: area[parseInt(months) - 1],
      day,
    };
  }, [today]);

  return (
    <div className={HomeHeadStyle["home-head-box"]}>
      <div className={HomeHeadStyle.info}>
        <div className={HomeHeadStyle.time}>
          <span>{times.day}</span>
          <span>{times.month}</span>
        </div>
        <div className={HomeHeadStyle.title}>知乎日报</div>
      </div>
  );
}

新闻列表

新闻列表希望做到用户点击后字体颜色变灰,表明用户已经点击过该条新闻。

想要实现这个功能,只需要依靠 CSS 中的 :visited 伪类,复习一下 CSS 提供的几个伪类:

  • :link :未访问的链接
  • :visited :已访问的链接
  • :hover :鼠标悬停链接
  • :active :已选择的链接

⚠ 注意

a:hover 必须在 CSS 定义中的 a:linka:visited 之后,才能生效!a:active 必须在 CSS 定义中的 a:hover 之后才能生效!伪类名称对大小写不敏感。

最后通过 CSS 把字体样式改的浅一点即可。代码如下:

scss
&:visited {
  .title {
    color: #999;
  }
  .author {
    color: #ccc;
  }
}

加载更多

控制组件显隐的方式有两种:

  1. 控制其是否渲染,没有数据可以不渲染,也获取不到 DOM 元素,有数据再渲染
  2. 控制元素的样式,无论是否有值都会渲染,只不过 display: none 隐藏元素

由于我们需要获取到节点判断其是否显示在可视区域内(即是否滚动到底部),因此我们要选择第二种方法。

判断元素是否出现在可视区域内可通过 new IntersectionObserver() 方法实现,该方法在 小兔鲜 有介绍,因此这里不做过多描述。代码如下所示:

jsx
// 第一次渲染完毕,设置监听器,实现触底加载
useEffect(() => {
  let ob = new IntersectionObserver(async (changes) => {
    let { isIntersecting } = changes[0];
    if (isIntersecting) {
      // 已经触底,获取新的数据
      try {
        let times = newList.length > 0 && newList[newList.length - 1]["date"];
        const res = await Api.queryNewsLatest(times);
        let newArr = newList;
        newArr.push({
          date: res.date,
          stories: res.stories,
        });
        setNewList([...newArr]);
      } catch (error) {
        console.log(error);
      }
    }
  });

  ob.observe(loadmore.current);

  // 销毁时loadmore.current已经没了,而通过变量保存的值还在,因此需要用变量保存
  let loadmoreRef = loadmore.current;

  // 事件销毁,手动销毁监听器
  return () => {
    ob.unobserve(loadmoreRef);
    ob = null;
  };
}, []);

⚠ 注意

在组件释放的时候, React 内部会移除:

  • 虚拟 DOM
  • 真实 DOM
  • 合成事件绑定
  • ......

不会移除的东西:

  • 设置的定时器
  • 设置的监听器
  • 基于 addEventListener 手动做的事件绑定
  • ...

因此为了减少不必要的性能消耗,需要手动销毁监听器!

详情

详情模块需要渲染富文本内容,直接通过 {} 方法渲染会把标签原原本本也渲染到页面上。

有一个 API ,作用和 v-html 类似,用于渲染富文本的内容,名称为 dangerouslySetInnerHTML 。复制代码保存运行,代码如下:

jsx
<div className="content" dangerouslySetInnerHTML={info.body}></div>

发现报错,报错信息如下所示:

html渲染报错

翻译报错信息,我们需要提供一个 __html 属性,因此需要传递一个对象过去,修改代码为以下形式:

jsx
<div className="content" dangerouslySetInnerHTML={{ __html: info.body }}></div>

效果实现,但是没有样式。查看后端返回的数据,发现其样式与结构是分离的,样式返回了一个 CSS 链接,需要我们动态通过 <link href="" style="" /> 标签设置。

代码
jsx
export default function Detail(props) {
  // 在配置路由组件时已经把路由相关信息传参过来了
  const { navigate, params } = props;

  const [info, setInfo] = useState(null)

  // ...

  // 处理样式、图片
  let link;
  const handleImg = () => {
    let imgPlaceHolder = document.querySelector(".img-place-holder");
    if (!imgPlaceHolder || !info) return;

    let tempImg = new Image();
    tempImg.src = info.image;
    tempImg.onload = () => {
      imgPlaceHolder.appendChild(tempImg);
    };
    tempImg.onerror = () => {
      let parent = imgPlaceHolder.parentNode;
      parent.parentNode.removeChild(parent);
    };
  };
  const handleStyle = () => {
    if (info) {
      // 确保css存在
      if (!Array.isArray(info.css)) return;
      let css = info.css[0];
      if (!css) return;

      // css存在再设置样式引入
      link = document.createElement("link");
      link.rel = "stylesheet";
      link.href = css;

      // 添加到document中
      document.head.appendChild(link);
    }
  };
  useEffect(() => {
    try {
      handleImg();
      handleStyle();
    } catch (error) {
      console.log(error);
    }

    return () => {
      // 移除添加的样式
      if (link) document.head.removeChild(link);
    };
  }, [info]);
  // ...
}

登录

表单数据收集

React 是 MVC 结构的框架,因此无法做到数据双向绑定,用户输入内容时动态获取用户输入的内容,需要手动获取。

Vue 中,v-model 数据双休绑定实际上是一个语法糖,他的本质是 :value 显示数据、@change 在用户输入时实时获取数据。因此在 React 中我们也可以从这里入手:

  • 定义一个变量方法接收修改表单数据
  • 输入框组件通过 value 显示对应数据
  • 通过 onChange 事件实时获取用户输入的最新内容

代码如下:

jsx
export default function Test() {
  const [phone, setPhone] = useState('')
  return (
  	<input value={phone} onChange={
        (e) => {
          setPhone(e.target.value)
        }
      } />
  )
}

⚠ 注意

如果是表单组件则不需要使用 onChange 实时获取,表单组件会获取到用户输入的数据。

校验

表单校验时实际上做了以下事情:

客户端:

  1. 手机规则校验
    • 防止未必要的请求
    • 防止 SQL 注入
  2. 向服务器发送请求
  3. 客户端接收服务器返回的内容
    • 失败给出提示
    • 成功开启倒计时

服务器:

  1. 手机号格式的再次校验

  2. 创建一个验证码

    存储到数据库中,格式【手机号 验证码】,方便后期登录校验

    编辑短信内容

  3. 调用第三方平台,把短信发送到用户手机上【付费操作】

  4. 服务器把发送的结果返回给客户端

本项目使用的是 Antd-Mobile 组件库,查看 Form 表单组件的文档,看表单校验规则的设置方式以及如何判断校验。

表单校验规则有两种方法:

  1. 直接使用校验字段做校验规则,在 <Form.Item> 标签通过 rules 标签设置校验规则数组即可。代码如下:

    jsx
    <Form.Item
      label="验证码"
      name="code"
      rules={[
        { required: true, message: "验证码必填" },
        { pattern: /^\d{6}$/, message: "验证码6位数字" },
      ]}
      extra={
        <MyButton
          color="primary"
          disabled={disabled}
          size="small"
          onClick={sendCode}
        >
          {sendText}
        </MyButton>
      }
    >
      <Input placeholder="请输入验证码" />
    </Form.Item>
  2. 设置自定义校验规则。注意,使用该方法不仅需要设置返回的错误提示,也需要返回相对应正确的提示,否则校验成功后无法往下执行

    jsx
    const validate = {
      phone(_, value) {
        value = value.trim();
        let reg =
          /^(13[0-9]|14[01456879]|15[0-35-9]|16[2567]|17[0-8]|18[0-9]|19[0-35-9])\d{8}$/;
        if (value.length === 0) return Promise.reject(new Error("手机号必填"));
        if (!reg.test(value)) return Promise.reject(new Error("手机号格式不正确"));
        return Promise.resolve();
      },
    };

    再通过 rules 设置,代码如下:

    jsx
    <Form.Item
      name="phone"
      label="手机号"
      rules={[{ validator: validate.phone }]}
    >
      <Input placeholder="请输入手机号" />
    </Form.Item>

登录

执行登录操作时实际上做了以下事情:

客户端:

  1. 表单校验
  2. 向服务器发送请求(传输手机号 / 验证码)
  3. 客户端接收服务器返回的结果
    • 成功:存储 token 方便后续某些操作(本地存储、redux),跳转页面
    • 失败:提示

服务器:

  1. 获取传输的内容做二次校验
  2. 在数据库中匹配手机号与验证码是否一致
    • 失败:返回登录失败
    • 成功:看手机号是否被注册,没注册过注册 + 登录,注册过直接登录
  3. 返回一个 Token 令牌,根据登录者信息 + 时间 + 密钥创建 token

对于本地存储的事件,我们可以做二次封装,前端实现 token 有效期的判断以及处理。思路如下:

  • 保存数据时,不仅保存 token,还保存保存 token 时的时间戳,后面用于判断
  • 获取数据时,判断当前的时间戳与保存的时间戳,如果二者相差预设的值,则表示过期,返回空
  • 删除数据时,直接删除该本地存储的数据即可

代码如下:

js
const storage = {
  set(key, value) {
    localStorage.setItem(key, JSON.stringify({
      time: +new Date(),
      value
    }))
  },
  get(key, cycle) {
    cycle = +cycle
    if(isNaN(cycle)) cycle = 2592000000
    let data = localStorage.getItem(key)
    if(!data) return null
    let { time, value } = JSON.parse(data)
    if((+new Data() - time) > cycle) {
      storage.remove(key)
      return null
    }
    return value
  },
  remove(key) {
    localStorage.removeItem(key)
  }
}

接下来重点看登录成功后的处理,首先梳理一下前往登录页的三种方式:

  1. 地址栏直接输入登录的路由,此时登录成功后直接返回首页
  2. 在新闻详情页点击收藏发现没登录,跳转到登录页,登录成功后要返回详情页
  3. 点击前往个人中心页时发现没登录,前往登录页,登录成功后继续前往个人中心页

代码如下:

jsx
const { usp, navigate } = props

let to = usp.get('to')
to ? navigate(to, { replace: true }) : navigate(-1)

to 为要跳转的路由,如果有值则说明是第三种情况,跳转到对应页面;没值则是第一第二种情况,直接返回上一页。

ReactRouter6 与 ReactRouter5 的区别

  • 在 5 版本中,返回上一页通过 history.go(-1) 实现,6 版本中通过 navigate(-1) 实现

  • 5 版本中 push 传参代码如下:

    js
    history.push({
      pathname: '/xxx',
      search: '',
      state: ''
    })

    6 版本代码如下:

    js
    navigate.push({
      pathname: '/xxx',
      search: ''
    }, { state: '' })

第三方组件的思考

组件支持很多属性,第三方组件如图片上传组件,都是对原生 <input type="file" accept /> 的设置和封装。例如:

  • accpet :控制上传的文件类型,为原生组件 accept 属性
  • beforeUpload :自己封装的,在选择图片上传之前触发,在这个函数中可以限制上传的大小
  • maxCount :设置允许上传图片的数量,原生组件没有提供,需要自己封装判断逻辑
  • multiple :是否支持多选,原生组件也有该方法,默认为不支持

以上只是部分属性方法,可以获取 input 的真实 DOM 元素,并通过 console.dir() 方法打印,在控制台上可以看到更多更详细的数据。

部分原生组件就有的数据,可以作为组件属性来封装,父组件需要使用的时候就传值,不需要则给默认值;没有提供的方法属性则通过自主代码封装设置。

代码如下:

jsx
export default function UploadImg(props) {
  const [pic, setPic] = useState([{ url: props.pic }])
  
  // 上传前的判断
  const limitImage = (file) => {
    // 如果返回 null 则不会往下走执行图片上传
    let limit = 1024 * 1024
    if(file.isze > limit) {
      Toast.show({
        icon: 'fail',
        content: '图片必须在1MB内'
      })
      return null
    }
    
    // 成功则返回原本的文件
    return file
  }
  
  // 图片上传
  const uploadImage = async (file) => {
    let temp
    try {
      let formData = new FormData()
      formData.append('file', file)
      
      let { code, data } = await Api.uploadImg(formData)
      if(code === 200) {
        temp = data
        setPic([{
          url: data
        }])
      } else {
        Toast.show({
        	icon: 'fail',
        	content: '图片必须在1MB内'
      	})
      }
    } catch(err) {}
    
    // 这里需要返回一个对象,否则会报错
    return { url: temp }
  }
  
  return (
  	<ImageUploader value={pic} maxCount={1} beforeUpload={limitImage} upload={uploadImage} onDelete={
        () => {
          setPic([])
        }
      } />
  )
}

⚠ 注意

antd-mobile 组件库的图片上传组件不支持自动上传,需要手动封装。

组件缓存

没有做组件缓存时,从首页进入详情页,返回后并没有在之前的滚动位置,而是返回顶部,探讨一下底层做了什么。

从首进入详情页页实际上做了以下处理:

  • 首页组件:释放真实 DOM、虚拟 DOM 释放
  • 详情组件:加载第一次渲染的逻辑

从详情页返回首页实际上做了以下处理:

  • 详情组件:释放真实 DOM、虚拟 DOM 释放
  • 首页组件:加载第一次渲染的逻辑

正常情况下,每一次路由跳转,都是原有组件释放,新组建加载的过程!

有些需求中,是需要对某个或者某几个组件做 “缓存” 的。做了缓存的组件:

  1. 每次路由跳转时,组件不销毁
  2. 再次跳转回来,直接渲染即可

组件缓存机制是 KeepAlive ,和网络中的 connect:keep-alive 不是同一个东西。

Vue 中默认就写好了 <keep-alive></keep-alive> 这样的处理机制。React 中没有现成的,需要自己去实现。

思路

主流思想上:

  1. 不是标准的组件缓存,只是数据缓存

    A -> B 在 A 组件路由跳转时,把 A 组件中需要的数据或 A 组件的全部虚拟 DOM 存储到 localStorageredux 中!

    然后 A 组件释放, B 组件加载。

    当从 B 回到 A 时,A开始加载。

    接着判断是否存储了数据或虚拟 DOM,如果没有,则说明是第一次加载逻辑处理;如果有存储,则把存储的数据拿来渲染。

  2. 修改路由的跳转机制

    在路由跳转的时候,把指定的组件不销毁,只是控制 display: none 隐藏;后期从 B 回到 A 时,直接让 A display:block

    缺点是比较麻烦,需要深知源码才能修改。

  3. 缓存真实信息

    把 A 组件的真实 DOM 等信息,直接缓存起来;从 B 跳转回 A 的时候,直接把 A 之前缓存的信息拿出来用

第三方库

NPM 上有第三方库 keepalive-react-component 处理了这个事情,官网指路:keepalive-react-component

下载完之后,使用方式如下:

  1. 在根组件 App.js 中按需导入组件

    js
    import { KeepAliveProvider } from "keepalive-react-component";
  2. 用组件包裹路由组件 <RouterView />

    jsx
    function App() {
      return (
        <>
          <KeepAliveProvider>
            <RouterView />
          </KeepAliveProvider>
        </>
      );
    }
  3. 去往路由表,按需引入 withKeepAlive 方法,把需要缓存的组件用该方法包裹起来。其需要两个参数:

    • 参数一:对应的组件
    • 参数二:一个对象,其中有两个属性。属性 cacheId 是该组件唯一的 ID,属性 scroll 表示是否要缓存其滚动位置
    js
    import { lazy } from "react";
    import { withKeepAlive } from "keepalive-react-component";
    
    // ...
    
    const routes = [
      {
        path: "/",
        name: "home",
        component: withKeepAlive(Home, {cacheId: 'Home', scroll:true}),
        meta: {
          title: "知乎日报-WebApp",
        },
      },
      // ...
    ];
    
    export default routes;

底层原理如下:

底层原理