likes
comments
collection
share

React18 Mobx TS Less H5移动端架构

作者站长头像
站长
· 阅读数 11

描述

基于React + Webpack + Mobx + Less + TS + rem适配方案,构建H5模板脚手架 

项目地址:github

项目预览:查看 demo 建议手机端查看,如pc端浏览器打开需切换到手机调试模式

Node 版本要求

本示例 Node.js 16.15.0

启动项目

git clone https://github.com/talktao/talk-react-app.git

cd talk-react-app

yarn

yarn start
复制代码

talk-scripts

支持less

原始cra默认支持的sass,笔者为什么喜欢less,可能是因为喜欢单调的颜色统一,比如下图

React18 Mobx TS Less H5移动端架构

如何在cra里面改造支持less呢?

React18 Mobx TS Less H5移动端架构 当然,这还不够,还需要引入less-loader,安装好less-loader,进入到webpack.config.js文件下替换掉与sass有关的代码

React18 Mobx TS Less H5移动端架构

移动端适配

项目使用了 px2rem-loader,当然开发者可以自行控制是否需要适配移动端

React18 Mobx TS Less H5移动端架构

如上图,本项目通过designSize来判断是否启动px2rem-loader,而designSize需要在package.json中添加如下代码,此处designSize的值可以根据设计稿的尺寸而自行定义;如果未设置,将对应PC端

React18 Mobx TS Less H5移动端架构

mobx 状态管理

目录结构

├── store
│   ├── count.ts
│   ├── magic.ts
复制代码

首先我们先在magic.ts中定义好我们的默认数据和修改数据的方法,如下图

React18 Mobx TS Less H5移动端架构

然后再去组件中使用

React18 Mobx TS Less H5移动端架构

注意

此处默认导出组件时,必须使用 observer 包裹该组件,否则组件无法更新,如下图对比

React18 Mobx TS Less H5移动端架构

React18 Mobx TS Less H5移动端架构

官方如何解释observer

observer HOC 将自动订阅 React components 中任何 在渲染期间 被使用的 可被观察的对象 。 因此, 当任何可被观察的对象 变化 发生时候 组件会自动进行重新渲染(re-render)。 它还会确保组件在 没有变化 发生的时候不会进行重新渲染(re-render)。 但是, 更改组件的可观察对象的不可读属性, 也不会触发重新渲染(re-render)。

强制更新组件的其他方式

当然我们还有其他方式强制更新,在不使用observer的情况下,我们可以引入 ahookuseUpdate来对组件进行强制更新,也可以达到我们想要的效果,既然提到了ahook,下面我们就来初探一下这个可以提高工作效率的hooks库

ahook 初探

在本项目中,我们将axiosahookuseRequest结合,实现了首屏加载时的骨架屏效果,相信看了demo的同学已经体验了一下

useRequest与axios封装

useRequest 的第一个参数是一个异步函数,在组件初次加载时,会自动触发该函数执行。同时自动管理该异步函数的 loading , data , error 等状态。所以本项目的请求都是基于useRequest来实现的,比如我们先在 src/helpers/axios.ts 目录下,新建一个请求的url根路径

React18 Mobx TS Less H5移动端架构

笔者这里使用了fastmock在线mock来模拟真实的请求,如下图笔者建立了两个api

React18 Mobx TS Less H5移动端架构

建立完成之后,就可以到src/const/apis文件下,新增请求方法,如下图

React18 Mobx TS Less H5移动端架构

然后我们就可以使用useRequest进行请求了,如下图

React18 Mobx TS Less H5移动端架构

上图,笔者在Home页面组件中,使用了useMockRequest 来请求数据,从而得到了data, error, loading这些返回的数据,而useMockRequest 正是笔者基于ahook 的 useRequest新封装的hook,接下来我们一起看一下useMockRequest中的代码

React18 Mobx TS Less H5移动端架构

效果

React18 Mobx TS Less H5移动端架构

React-Router@6

本案例采用hash模式,history模式需要服务端配置对应的录音路径,否则会404,由于本项目会部署到github上,所以只能使用hash模式了,进入到项目目录src/App.tsx

import React, { Suspense } from "react";
import { RouteObject, createHashRouter, RouterProvider } from 'react-router-dom';
import KeepAlive from "@/components/keepalive";
import RootBoundary from "@/components/rootBoundary";

const Home = React.lazy(() => import('@/pages/home/index'));
const List = React.lazy(() => import('@/pages/list/index'));
const My = React.lazy(() => import('@/pages/my/index'));

// 路由映射表
const routes: RouteObject[] = [
    {
        path: '/',
        element: <KeepAlive />,
        children: [
            {
                path: '/home',
                element: <Home />,
                errorElement: <RootBoundary />,
            },
            {
                path: '/list',
                element: <List />,
                errorElement: <RootBoundary />,
            },
        ]
    },
    {
        path: '/my',
        element: <My />
    },

    // 路由重定向
    {
        path: '/',
        element: <Home />,
        errorElement: <RootBoundary />
    }
];

const router = createHashRouter(routes);

function App() {
    return (
        <Suspense fallback={<div />} >
            <RouterProvider router={router} />
        </Suspense>
    );
}

export default App;

useNavigate

useNavigate编程式导航

import { useNavigate } from 'react-router';

const Home: FC = () => {
    
    const navigate = useNavigate();
    
    navigate('路由路径') // navigate('/list')
}

export default Home

类型声明

declare function useNavigate(): NavigateFunction;

interface NavigateFunction {
  (
    to: To,
    options?: {
      replace?: boolean;
      state?: any;
      relative?: RelativeRoutingType;
    }
  ): void;
  (delta: number): void;
}

navigate函数有两个签名:

  • 使用可选的第二个参数传递一个To值(与 相同类型<Link to>{ replace, state }
  • 在历史堆栈中传递你想要去的增量。例如,navigate(-1)相当于按下后退按钮。

如果使用replace: true,导航将替换历史堆栈中的当前条目,而不是添加新条目

useLocation

这个钩子返回当前location对象。如果您想在当前位置更改时执行一些副作用,这将很有用;比如本项目就通过location中的pathname来判断tabbar组件的选中

本项目的tabbar

项目目录src/component/tabbar/index.tsx

import { FC, useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { tabbarConfig } from './config';
import style from './index.module.less';

const Tabbar: FC = () => {
    const navigate = useNavigate();
    const { pathname } = useLocation();

    const tabbarList = useMemo(() => tabbarConfig.map(tab => (
        <div key={tab.name} className={style.tabbarItem} onClick={() => navigate(tab.route)}>
            <img src={pathname === tab.route ? tab.active : tab.icon} alt="" />
            <div className={pathname === tab.route ? style.active : ''}>{tab.name}</div>
        </div>
    )), [pathname]);

    return <div className={style.tabbar}>
        {tabbarList}
    </div>;
};

export default Tabbar;

项目目录src/component/tabbar/config.ts下配置tabbar组件内容

import Home from '@/images/tabbar/home.svg';
import HomeActive from '@/images/tabbar/home-active.svg';
import My from '@/images/tabbar/my.svg';
import MyActive from '@/images/tabbar/my-active.svg';

export const tabbarConfig = [
    {
        name: '首页',
        icon: Home,
        active: HomeActive,
        route: '/home',
        title: '首页'
    },
    {
        name: '我的',
        icon: My,
        active: MyActive,
        route: '/my',
        title: '我的'
    }
];

跨域配置

如果你的项目需要跨域设置,可以使用http-proxy-middleware来进行配置,在src目录下新建一个setupProxy.js,内容如下

const { createProxyMiddleware } = require('http-proxy-middleware');

module.exports = function (app) {
    app.use(
        '/api',
        createProxyMiddleware({
            target: `https://www.fastmock.site/mock/c00624da6261543b2897e35dff28607c`,
            changeOrigin: true,
            pathRewrite: {
                '^/api': ''
            },
            onProxyReq(proxyReq, req, res) {
                // add custom header to request
                // proxyReq.setHeader('Authorization', 'xxxxx');
                // console.log(req)
                // or log the req
            }
        })
    );
};

骨架屏

通过react-content-loader来自定义自己的骨架屏,本项目目前实现了home页面list页面的首屏加载时的骨架屏优化

HomeLoader首页骨架屏

每一个部分都可以自定义形状

React18 Mobx TS Less H5移动端架构

import ContentLoader from "react-content-loader";

const HomeLoader = (props) => {

    let screenWidht = window.screen.width;
    let screenHeight = window.screen.height;

    return <ContentLoader
        speed={2}
        width={screenWidht}
        height={screenHeight}
        viewBox={`0 0 ${screenWidht} ${screenHeight}`}
        backgroundColor="#f3f3f3"
        foregroundColor="#85acd5"
        {...props}
    >
        <rect x="0" y="20" width={screenWidht} height="60" />
        <rect x="0" y="125" rx="5" ry="5" width={screenWidht} height="20" />
        <rect x="0" y="165" rx="5" ry="5" width={screenWidht} height="20" />
        <rect x="0" y="205" rx="5" ry="5" width={screenWidht} height="20" />
        <rect x="0" y="245" rx="5" ry="5" width={screenWidht} height="20" />
        <rect x="0" y="285" rx="5" ry="5" width={screenWidht} height="20" />
        <rect x="0" y="325" rx="5" ry="5" width={screenWidht} height="20" />
        <rect x="0" y="365" rx="5" ry="5" width={screenWidht} height="20" />
        <rect x="0" y="405" rx="5" ry="5" width={screenWidht} height="20" />
        <rect x="0" y="445" rx="5" ry="5" width={screenWidht} height="20" />
        <rect x="0" y="485" rx="5" ry="5" width={screenWidht} height="20" />
        <rect x="0" y="525" rx="5" ry="5" width={screenWidht} height="20" />

    </ContentLoader>;
};

export default HomeLoader;

ListLoader列表页骨架屏

list页面的骨架屏主要由多个个CardLoader组成,而ListLoader组件里渲染的CardLoader页面可视区域高度/卡片高度向下取整 Math.floor

ListCard代码

import { FC, ReactNode, useState } from 'react';
import CardLoader from '../cardLoader';
import React from 'react';

const ListLoader: FC = () => {

    // 卡片高度
    const [cardHeight, setCardHeight] = useState(100);

    // 获取当前设备高度
    let screenHeight = window.screen.height;

    // 根据页面高度获取可渲染CardLoader的数量
    let renderCardLoaderNum = Math.floor(screenHeight / cardHeight);

    const loader = () => {
        let data = [] as ReactNode[];

        for (let i = 0; i < renderCardLoaderNum; i++) {
            data.push(<CardLoader height={cardHeight} />);
        }
        return data.map((item, index) => <div key={index}>{item}</div>);
    };

    return <React.Fragment>
        {loader()}
    </React.Fragment>;
};

export default ListLoader;

CardLoader代码

import { FC } from 'react';
import ContentLoader from 'react-content-loader';

const CardLoader: FC<any> = props => {
    let screenWidht = window.screen.width;
    let height = props.height as any;

    return (
        <ContentLoader
            viewBox={`0 0 ${screenWidht} ${height}`}
            height={height}
            width={screenWidht}
            backgroundColor="#f3f3f3"
            foregroundColor="#85acd5"
            {...props}
        >
            <rect x="20" y="20" rx="10" ry="10" width="120" height="80" />
            <rect x="150" y="25" rx="5" ry="5" width={screenWidht - 150 - 20} height="20" />
            <rect x="150" y="55" rx="5" ry="5" width={screenWidht - 150 - 40} height="15" />
            <rect x="150" y="80" rx="5" ry="5" width={screenWidht - 150 - 60} height="10" />
        </ContentLoader>
    );
};

export default CardLoader;

效果如下

React18 Mobx TS Less H5移动端架构

alias 别名

tsconfig.paths.json文件下配置

tsconfig.paths.json

{
  "compilerOptions": {
    "baseUrl": "./",
    "strict": false,
    "paths": { // 指定模块的路径,和baseUrl有关联,和webpack中resolve.alias配置一样
      "@/global/*": [
        "src/global/*"
      ],
      "@/helpers/*": [
        "src/helpers/*"
      ],
      "@/components/*": [
        "src/components/*"
      ],
      "@/store/*": [
        "src/store/*"
      ],
      "@/hooks/*": [
        "src/hooks/*"
      ],
      "@/images/*": [
        "src/images/*"
      ],
      "@/const/*": [
        "src/const/*"
      ],
      "@/type/*": [
        "src/type/*"
      ],
      "@/pages/*": [
        "src/pages/*"
      ],
    },
    "jsx": "react"
  }
}

tsconfig.json

React18 Mobx TS Less H5移动端架构

内置分页列表滚动

既然是滚动分页,我们就需要监听滑动是否触底,触底就进行pageNum+1并传入到请求中,然后请求新数据;并且在请求过程中需要显示加载中...,没有更多数据就显示没有更多了;接下来我们就先实现触底hook

useReachBottom 滚动触底hook

/*
 * @Description: listen reach bottom
 */

import { useEffect } from "react";
import debounce from 'lodash/debounce';

/**
 * 
 * @param f 触底执行的函数
 * @param ifStop 是否停止
 */

export default function useReachBottom(f: Function, ifStop?: boolean) {

    useEffect(() => {
        const handleScroll = debounce(listenScroll, 250);
        window.addEventListener('scroll', handleScroll);
        return () => window.removeEventListener('scroll', handleScroll);
    }, [f]);

    const listenScroll = () => {
        const preLord = 20; // 指定提前加载的距离

        if (ifStop) {
            return;
        }

        const scrollHeight = document.body.clientHeight;
        const clientHeight = window.innerHeight;
        const scrollTop = window.scrollY;

        if (scrollHeight - (clientHeight + scrollTop) <= preLord) {
            try {
                f();
            } catch (err) {
                console.log('bottom-fetch error', err);
            } finally {
                console.log('reach bottom');
            }
        }
    };
};

useMockPagination api分页hook

import { mockAxios } from "@/helpers/axios";
import RequestProps, { RequestTuple } from "@/type/request";
import get from 'lodash/get';
import useAxiosMethods from "./useAxiosMethods";
import { useRef, useState } from "react";
import { toast } from "@/components/toast";
import { useRequest } from "ahooks";

/**
 * 
 * @param request method:请求方式,url:请求路径
 * @param params data: 接口请求参数,config:ahook的useRequest的第二个参数
 * @returns {
 *  list: [], 分页数据
 *  clear:()=>void, 清除list数据,并回到初始pageConfig
 *  getList:() => void, 继续请求
 *  ifDone, 是否完成所有数据加载
 *  initList, 初始化
 *}
 */

export default function useMockPagination<T>(request: RequestTuple, params: RequestProps<T>) {
    const { method, url } = request;
    const { data = {}, config = {} } = params;

    const [list, setList] = useState<any[]>([]);
    const pageConfig = useRef({
        pageSize: 10,
        pageNum: 1,
        ifDone: false
    });

    const controller = useAxiosMethods(mockAxios);

    if (!controller[method]) throw new Error('当前请求方法仅支持get/post/put/delete');

    // 请求接口的函数
    const http: () => any = async () => {

        if (pageConfig.current.ifDone) return;

        const res = await controller[method](url, {
            ...data,
            pageSize: pageConfig.current.pageSize,
            pageNum: pageConfig.current.pageNum,
        });

        const returnCode = get(res, 'data.code', '');
        const returnDesc = get(res, 'data.desc', '');

        // 判断接口是否正常
        if (returnCode !== '0000') return toast(returnDesc, 2000);

        const returnData = get(res, 'data.data', {}) as any;

        // 此处的 rows,total 根据后端接口定义的字段来取
        const { rows, total } = returnData as any;

        // 核心代码
        setList(i => {
            const current = [...i, ...rows];

            // 如果当前已经渲染的条数 > 总条数 就停止
            if (current.length >= total) {
                pageConfig.current.ifDone = true;
            }
            pageConfig.current.pageNum += 1;

            return current;
        });

    };

    const alibabaHook = useRequest(http, config);

    const clear = () => {
        setList(() => {
            pageConfig.current.pageNum = 1;
            pageConfig.current.ifDone = false;
            return [];
        });
    };

    const initList = () => {
        clear();
        setTimeout(http, 0);
    };

    return {
        ...alibabaHook,
        list,
        clear,
        getList: http,
        ifDone: pageConfig.current.ifDone,
        initList
    };
}

useListPages 列表页使用分页的hook

import useMockPagination from "./useMockPagination";
import useReachBottom from "./useReachBottom";
import { RequestTuple } from "@/type/request";


export default function useListPages(request: RequestTuple, params = {}) {
    const { loading, list, initList, getList, ifDone } = useMockPagination(request, params);
    
    // 触底后继续请求
    useReachBottom(getList);

    return { loading, list, ifDone };
}

页面中使用

import { FC } from 'react';
import ApiCollector from '@/const/apis';
import useListPages from '@/hooks/useListPages';
import FetchTips from '@/components/fetchTips';
import Card from './card';
import Layout from '@/components/layout';
import ListLoader from '@/components/skeleton/listLoader';
import styles from './index.module.less';

const List: FC = () => {
    const { loading, list, ifDone } = useListPages(ApiCollector.getList, {});

    // 如果请求还在加载,则渲染骨架屏
    if (loading) return <ListLoader />;

    return <Layout title='分页列表'>
        <div className={styles.list}>
            {
                list?.map((li, index) => (
                    <div className={styles.item} key={index}>
                        <Card li={li} />
                    </div>
                ))
            }
            {/* 底部加载时的请求提示 */}
            <FetchTips ifDone={ifDone} />
        </div>
    </Layout>;

};

export default List;

虚拟滚动列表

useVirtualList提供虚拟化列表能力的 Hook,用于解决展示海量数据渲染时首屏渲染缓慢和滚动卡顿问题。

页面使用

import { FC, useRef } from 'react';
import ApiCollector from '@/const/apis';
import Layout from '@/components/layout';
import ListLoader from '@/components/skeleton/listLoader';
import styles from './index.module.less';
import Card from '@/components/card';
import useMockRequest from '@/hooks/useMockRequest';
import { useVirtualList } from 'ahooks';

const VirtuaList: FC = () => {

    const containerRef = useRef(null);
    const wrapperRef = useRef(null);

    // 请求数据
    const { data, error, loading } = useMockRequest<any>(ApiCollector.getVirtuaList, {});

    const { list: virtuaList = [] } = data;

    const [list] = useVirtualList(virtuaList, {
        containerTarget: containerRef,
        wrapperTarget: wrapperRef,
        itemHeight: 120, // 行高尽量跟渲染的item整体高度一致,否则滑动时会卡顿
        overscan: 10,
    });

    console.log(list, 'list');

    // 如果请求还在加载,则渲染骨架屏
    if (loading) return <ListLoader />;

    return <Layout title='虚拟列表'>
        <div ref={containerRef} style={{ height: '100vh', overflow: 'auto', }}>
            <div ref={wrapperRef} className={styles.list}>
                {
                    list?.map((li, index) => (
                        <div className={styles.item} key={index}>
                            <Card li={li.data} index={li.index} />
                        </div>
                    ))
                }
            </div>
        </div>
    </Layout>;

};

export default VirtuaList;

部署

部署到github

未来

会持续更新一些通用的好用的组件

总结

项目github地址

关于我

如果对你有帮助送我一颗小星星❤

转载请联系作者!

转载自:https://juejin.cn/post/7234917363195625509
评论
请登录