描述
基于React + Webpack + Mobx + Less + TS + rem适配方案,构建H5模板脚手架
项目地址:github
项目预览:查看 demo 建议手机端查看,如pc端浏览器打开需切换到手机调试模式
Node 版本要求
本示例 Node.js 16.15.0
你可以使用 nvm 或 nvm-windows 在同一台电脑中管理多个 Node 版本。
启动项目
git clone https://github.com/talktao/talk-react-app.git
cd talk-react-app
yarn
yarn start
复制代码
talk-scripts
基于create-react-app(简称CRA)配置,对此我直接使用了大佬配置好了lemon-scripts,站在巨人的肩膀上进行开发
支持less
原始cra默认支持的sass,笔者为什么喜欢less,可能是因为喜欢单调的颜色统一,比如下图
如何在cra里面改造支持less呢?
当然,这还不够,还需要引入
less-loader
,安装好less-loader
,进入到webpack.config.js
文件下替换掉与sass有关的代码
移动端适配
项目使用了 px2rem-loader
,当然开发者可以自行控制是否需要适配移动端
如上图,本项目通过designSize
来判断是否启动px2rem-loader
,而designSize
需要在package.json
中添加如下代码,此处designSize
的值可以根据设计稿的尺寸
而自行定义;如果未设置,将对应PC端
项目内还有诸多配置,这里就不一一展开,具体信息可以参考lemon-scripts
mobx 状态管理
目录结构
├── store
│ ├── count.ts
│ ├── magic.ts
复制代码
首先我们先在magic.ts
中定义好我们的默认数据和修改数据的方法,如下图
然后再去组件中使用
注意
此处默认导出组件时,必须使用 observer
包裹该组件,否则组件无法更新,如下图对比
官方如何解释observer
observer
HOC 将自动订阅 React components 中任何 在渲染期间 被使用的 可被观察的对象 。 因此, 当任何可被观察的对象 变化 发生时候 组件会自动进行重新渲染(re-render)。 它还会确保组件在 没有变化 发生的时候不会进行重新渲染(re-render)。 但是, 更改组件的可观察对象的不可读属性, 也不会触发重新渲染(re-render)。
强制更新组件的其他方式
当然我们还有其他方式强制更新,在不使用observer
的情况下,我们可以引入 ahook
的 useUpdate
来对组件进行强制更新,也可以达到我们想要的效果,既然提到了ahook
,下面我们就来初探一下这个可以提高工作效率的hooks库
ahook 初探
在本项目中,我们将axios
同ahook
的useRequest
结合,实现了首屏加载时的骨架屏效果,相信看了demo的同学已经体验了一下
useRequest与axios封装
useRequest
的第一个参数是一个异步函数,在组件初次加载时,会自动触发该函数执行。同时自动管理该异步函数的 loading
, data
, error
等状态。所以本项目的请求都是基于useRequest
来实现的,比如我们先在 src/helpers/axios.ts
目录下,新建一个请求的url根路径
笔者这里使用了fastmock在线mock来模拟真实的请求,如下图笔者建立了两个api
建立完成之后,就可以到src/const/apis
文件下,新增请求方法,如下图
然后我们就可以使用useRequest
进行请求了,如下图
上图,笔者在Home
页面组件中,使用了useMockRequest
来请求数据,从而得到了data, error, loading
这些返回的数据,而useMockRequest
正是笔者基于ahook 的 useRequest
新封装的hook,接下来我们一起看一下useMockRequest
中的代码
效果
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首页骨架屏
每一个部分都可以自定义形状
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;
效果如下
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
内置分页列表滚动
既然是滚动分页,我们就需要监听滑动是否触底,触底就进行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;
部署
未来
会持续更新一些通用的好用的组件
总结
关于我
如果对你有帮助送我一颗小星星❤
转载请联系作者!