5-1 React 实战之从零到一的项目开发(二)

接上文:5-1 React 实战之从零到一的项目环境搭建(一)

2、项目开发

当仓库搭建完毕后,并且react-master项目的基础配置搞完后,就要进行页面开发了

本次的项目开发目标是模仿知乎首页

功能与技术点:顶部菜单、搜索、搜索历史、阅读全文、悬底操作、无限滚动、骨架屏等

一、开发之前

利其器之 VSCode 插件:React vscode 开发插件与代码补全提示 - 掘金

  1. react-master内,新建文件夹
bash 复制代码
mkdir src/pages src/components src/router src/utils

二、路由配置

知乎路由结构:

  1. 新建router/index.tsx,并完善
typescript 复制代码
touch router/index.tsx

// 并写如下代码:

import React from "react";
import { Outlet, RouteObject } from "react-router-dom";

// 自己扩展的类型
export interface extraBizObject {
	title?: string;
	isShow?: boolean; // 是否显示
}

export const router: Array<RouteObject & extraBizObject> = [
	// https://www.zhihu.com/
	{
		path: "/",
		element: (
			<div>
				<div className="flex gap-4 text-blue-500	underline">
					<a href="">首页</a>
					<a href="#education">知乎知学堂</a>
					<a href="#explore">发现</a>
					<a href="#question">等你来答</a>
				</div>
				<div>
					首页自身内容
					<div>
						<div className="flex gap-4 text-blue-500	underline">
							<a href="#command">command</a>
							<a href="#follow">follow</a>
							<a href="#hot">hot</a>
							<a href="#zvideo">zvideo</a>
						</div>
						首页二级菜单内容
						<Outlet />
					</div>
				</div>
			</div>
		),
		title: "首页",
		isShow: true,
		children: [
			{
				path: "/",
				element: <div>command</div>,
			},

			{
				path: "follow",
				element: <div>follow</div>,
			},

			{
				path: "hot",
				element: <div>hot</div>,
			},

			{
				path: "zvideo",
				element: <div>zvideo</div>,
			},
		],
	},
	// https://www.zhihu.com/education/learning
	{
		path: "/education",
		element: <div>education</div>,
		title: "知乎知学堂",
		children: [
			{
				path: "learning",
				element: <div>learning</div>,
			},
		],
	},

	// https://www.zhihu.com/explore
	{
		path: "/explore",
		element: <div>explore</div>,
		title: "发现",
	},

	// https://www.zhihu.com/question/waiting
	{
		path: "/question",
		element: <div>question</div>,
		title: "等你来答",
		children: [
			{
				path: "waiting",
				element: <div>waiting</div>,
			},
		],
	},
];
  1. 改造app.tsx
typescript 复制代码
import React from "react";
import { HashRouter, useRoutes } from "react-router-dom";
import { router } from "./router";

type Props = {
	name?: string;
};

// 放在 App 外面,防止每次渲染都重新生成
const Routers = () => useRoutes(router);

export function App({}: Props) {
	return (
		<HashRouter>
			<Routers />
		</HashRouter>
	);
}
  1. 删除多余的app.css、app2.module.less
bash 复制代码
rm src/app.css src/app2.module.less 
  1. 启动项目:pnpm start,效果如下,点击可以已经可以跳转到对应页面了

三、首页初始化

  1. 新建 首页 相关文件,并迁移路由文件里面的代码完成初始化
javascript 复制代码
mkdir src/pages/home && touch src/pages/home/index.tsx

// 并写如下代码(只是将 router/index.tsx 里面的 / 对应的 element 复制过来):
import React from "react";
import { Outlet } from "react-router-dom";

type Props = {};

export default function Home({}: Props) {
	return (
		<div>
			<div className="flex gap-4 text-blue-500	underline">
				<a href="">首页</a>
				<a href="#education">知乎知学堂</a>
				<a href="#explore">发现</a>
				<a href="#question">等你来答</a>
			</div>
			<div>
				首页 page 自身内容
				<div>
					<div className="flex gap-4 text-blue-500	underline">
						<a href="#command">command</a>
						<a href="#follow">follow</a>
						<a href="#hot">hot</a>
						<a href="#zvideo">zvideo</a>
					</div>
					首页二级菜单内容
					<Outlet />
				</div>
			</div>
		</div>
	);
}
  1. 更改路由文件,将首页的 element 改一下(不贴代码了,看变动吧)
  1. 看浏览器,确保页面还是正常的

四、公共部分之导航栏开发

  1. 新建导航对应文件,并写代码
typescript 复制代码
mkdir src/components/navigation && touch src/components/navigation/index.tsx

// 写如下代码:
import React, { FC } from "react";
import { ZHRouter, router } from "../../router";
import { BellIcon } from "@heroicons/react/24/outline";
import { NavLink } from "react-router-dom";
import Search from "../search";

type Props = {};

const Logo = () => {
	return (
		<div className=" px-2">
			<svg
				viewBox="0 0 64 30"
				fill="#1772F6"
				width="64"
				height="30"
				className="css-1hlrcxk"
			>
				<path d="M29.05 4.582H16.733V25.94h3.018l.403 2.572 4.081-2.572h4.815V4.582zm-5.207 18.69l-2.396 1.509-.235-1.508h-1.724V7.233h6.78v16.04h-2.425zM14.46 14.191H9.982c0-.471.033-.954.039-1.458v-5.5h5.106V5.935a1.352 1.352 0 0 0-.404-.957 1.378 1.378 0 0 0-.968-.396H5.783c.028-.088.056-.177.084-.255.274-.82 1.153-3.326 1.153-3.326a4.262 4.262 0 0 0-2.413.698c-.57.4-.912.682-1.371 1.946-.532 1.453-.997 2.856-1.31 3.693C1.444 8.674.28 11.025.28 11.025a5.85 5.85 0 0 0 2.52-.61c1.119-.593 1.679-1.502 2.054-2.883l.09-.3h2.334v5.5c0 .5-.045.982-.073 1.46h-4.12c-.71 0-1.39.278-1.893.775a2.638 2.638 0 0 0-.783 1.874h6.527a17.717 17.717 0 0 1-.778 3.649 16.796 16.796 0 0 1-3.012 5.273A33.104 33.104 0 0 1 0 28.74s3.13 1.175 5.425-.954c1.388-1.292 2.631-3.814 3.23-5.727a28.09 28.09 0 0 0 1.12-5.229h5.967v-1.37a1.254 1.254 0 0 0-.373-.899 1.279 1.279 0 0 0-.909-.37z"></path>
				<path d="M11.27 19.675l-2.312 1.491 5.038 7.458a6.905 6.905 0 0 0 .672-2.218 3.15 3.15 0 0 0-.28-2.168l-3.118-4.563zM51.449 15.195V5.842c4.181-.205 7.988-.405 9.438-.483l.851-.05c.387-.399.885-2.395.689-3.021-.073-.25-.213-.666-.638-.555a33.279 33.279 0 0 1-4.277.727c-2.766.321-3.97.404-7.804.682-6.718.487-12.709.72-12.709.72a2.518 2.518 0 0 0 .788 1.834 2.567 2.567 0 0 0 1.883.706c2.278-.095 5.598-.25 8.996-.41v9.203h-12.78c0 .703.281 1.377.783 1.874a2.69 2.69 0 0 0 1.892.777h10.105v7.075c0 .887-.464 1.192-1.231 1.214h-3.92a4.15 4.15 0 0 0 .837 1.544 4.2 4.2 0 0 0 1.403 1.067 6.215 6.215 0 0 0 2.71.277c1.36-.066 2.967-.826 2.967-3.57v-7.607h11.28c.342 0 .67-.135.91-.374.242-.239.378-.563.378-.902v-1.375H51.449z"></path>
				<path d="M42.614 8.873a2.304 2.304 0 0 0-1.508-.926 2.334 2.334 0 0 0-1.727.405l-.376.272 4.255 5.85 2.24-1.62-2.884-3.98zM57.35 8.68l-3.125 4.097 2.24 1.663 4.517-5.927-.375-.277a2.32 2.32 0 0 0-1.722-.452 2.327 2.327 0 0 0-1.536.896z"></path>
			</svg>
		</div>
	);
};

interface NavProps {
	navs: ZHRouter;
}

type NavLinkRenderProps = {
	isActive?: boolean;
	isPending?: boolean;
	isTransitioning?: boolean;
};

const NavTab: FC<NavProps> = ({ navs }) => {
	const getStyles = ({ isActive }: NavLinkRenderProps) =>
		"hover:text-black mx-4 h-full py-3.5 transition-all " +
		(isActive
			? "font-extrabold text-black border-b-4 border-blue-600"
			: "text-gray-400");

	return (
		<div className=" flex mx-6 box-border">
			{navs.map((item) => (
				<NavLink
					key={item.path + "__"}
					to={item.path || "/"}
					className={getStyles}
				>
					{item.title}
				</NavLink>
			))}
		</div>
	);
};

const MenuAlarm = () => (
	<div className="flex mr-10 gap-4">
		<div className=" flex flex-col justify-center items-center">
			<BellIcon className=" h-5 w-5 text-gray-400 fill-gray-400" />
			<span className=" text-gray-400 text-xs">消息</span>
		</div>
		<div className=" flex flex-col justify-center items-center">
			<BellIcon className=" h-5 w-5 text-gray-400 fill-gray-400" />
			<span className=" text-gray-400 text-xs">私信</span>
		</div>
	</div>
);

export default function Navigation({}: Props) {
	return (
		<div className=" bg-white w-screen shadow-lg">
			<div className=" max-w-6xl mx-auto my-0 flex justify-center w-full">
				<div className=" h-14 flex justify-between items-center min-w-max w-full">
					<div className=" flex items-center">
						<Logo />
						<NavTab navs={router} />
					</div>
					<Search />
					<MenuAlarm />
				</div>
			</div>
		</div>
	);
}
  1. 更改react-master/src/pages/home/index.tsx
javascript 复制代码
import React from "react";
import { Outlet } from "react-router-dom";
import Navigation from "../../components/navigation";

type Props = {};

export default function Home({}: Props) {
	return (
		<div>
			<Navigation />
			<Outlet />
		</div>
	);
}
  1. 更改react-master/src/pages/router/index.tsx
  1. 新建搜索栏对应文件,并写代码
ini 复制代码
mkdir src/components/search && touch src/components/search/index.tsx

// 写如下代码:
import React from "react";

type Props = {};

export default function Search({}: Props) {
	return (
		<div className=" flex items-center">
			<input
				type="text"
				className=" w-98 h-8 border border-gray-100 px-4 rounded-full bg-gray-50"
				placeholder="福建软考报名入口"
			/>
			<button className=" w-16 h-8 mx-4 text-sm bg-blue-500 text-white flex justify-center items-center rounded-full hover:bg-blue-800 transition-all">
				提问
			</button>
		</div>
	);
}
  1. 此时的页面效果如下

五、完善首页

  1. 完善首页代码,react-master/src/pages/home/index.tsx
javascript 复制代码
import React from "react";
import Navigation from "../../components/navigation";
import Card from "../../components/card";
import Tabs from "./tabs";

type Props = {};

export default function Home({}: Props) {
	return (
		<div>
			<Navigation />
			<div className=" mx-auto max-w-6xl flex my-2 px-20">
				<Card className=" w-2/3">
					<Tabs />
				</Card>
				<div className=" flex-1 w-1/3">
					<Card className=" w-full">创作中心</Card>
					<Card className=" w-full">推荐关注</Card>
					<Card className=" w-full">其他功能</Card>
				</div>
			</div>
		</div>
	);
}
  1. 新建Card组件,并写代码
typescript 复制代码
mkdir src/components/card && touch src/components/card/index.tsx

// 写如下代码:
import React, { ReactNode } from "react";

type Props = {
	className?: string;
	children?: ReactNode;
};

export default function Card({ className, children }: Props) {
	return (
		<div
			className={` bg-white border border-gray-200 m-2 rounded-sm shadow-md ${className}`}
		>
			{children}
		</div>
	);
}
  1. 新建tabs.tsx,作为二级菜单
typescript 复制代码
touch src/pages/home/tabs.tsx

// 写如下代码:
import React from "react";
import { NavLink, Outlet } from "react-router-dom";

type Props = {
	className?: string;
};

const tabs = [
	{
		name: "关注",
		to: "/follow",
	},
	{
		name: "推荐",
		to: "/",
	},
	{
		name: "热榜",
		to: "/hot",
	},
	{
		name: "视频",
		to: "/zvideo",
	},
];

export default function Tabs({}: Props) {
	return (
		<div className=" w-full">
			<div className=" flex mx-6 box-border">
				{tabs.map((item) => (
					<NavLink
						key={item.to}
						to={item.to}
						className={({ isActive }) =>
							" whitespace-nowrap py-4 px-4 text-base transition-all " +
							(isActive
								? "text-blue-600 font-bold"
								: "text-black hover:text-blue-700")
						}
					>
						{item.name}
					</NavLink>
				))}
			</div>

			<Outlet />
		</div>
	);
}
  1. 目前页面效果如下

六、完善推荐列表

  1. 处理 mock 数据
bash 复制代码
mkdir src/pages/home/commandList && touch src/pages/home/commandList/mock.js

// 写入代码(数据太长了,去 github 上 copy 吧):

https://github.com/MrHzq/react-actual-combat/blob/main/packages/apps/react-master/src/pages/home/commandList/mock.js
  1. 新建推荐列表页面 && 路由更改
bash 复制代码
touch src/pages/home/commandList/index.tsx
  1. 推荐列表页面代码
typescript 复制代码
import React, { FC, MouseEventHandler, useState } from "react";
import { mockList } from "./mock";

type Props = {};

interface ICommandItem {
	key: string;
	item: any;
}
const CommandData: FC<ICommandItem> = ({ item }) => {
	const [selected, setSelected] = useState(false);

	const handleClick: MouseEventHandler<Element> = (event) => {
		event.preventDefault();
		setSelected(!selected);
	};

	return (
		<div className=" flex flex-col items-start p-4 border-b">
			{/* 标题部分 */}
			<div className=" flex h-auto">
				<a className=" font-bold text-lg leading-10">
					{item?.target?.question?.title || item?.target?.title}
				</a>
			</div>

			{/* 文章卡片 */}
			{selected ? (
				<div dangerouslySetInnerHTML={{ __html: item?.target?.content }} />
			) : (
				<a
					href="/"
					onClick={handleClick}
					className=" cursor-pointer hover:text-gray-600 text-gray-800"
				>
					{item?.target?.excerpt?.substring(0, 80) + "..."}
					<span className=" text-sm leading-7 text-blue-500 ml-2">
						阅读全文 &gt;
					</span>
				</a>
			)}

			{/* 底部 bar */}
			<div
				className={`flex justify-between items-center p-3 bg-white w-full ${selected ? " bottom-0 left-0 shadow-sm border-t sticky" : ""}`}
			>
				<div className=" flex items-center flex-1">
					<div
						className="
                        flex justify-center items-center h-8 px-4 bg-blue-100 text-sm text-blue-600 rounded-sm  cursor-pointer hover:bg-blue-200 transition-all"
					>
						赞同 {item?.target?.thanks_count || 0}
					</div>
					<div className=" flex justify-center items-center h-8 px-4 bg-blue-100 text-sm text-blue-600 rounded-sm  cursor-pointer hover:bg-blue-200 transition-all ml-2">
						踩
					</div>
					<div className=" flex items-center flex-1 gap-8 text-gray-400 text-sm ml-8">
						<div>{item?.target?.comment_count} 评论</div>
						<div>收藏</div>
						<div>举报</div>
						<div>...</div>
					</div>
				</div>
				{selected && (
					<div>
						<span
							className=" text-gray-500 text-sm cursor-pointer"
							onClick={handleClick}
						>
							收起
						</span>
					</div>
				)}
			</div>
		</div>
	);
};

export default function CommandList({}: Props) {
	return (
		<div className=" flex flex-col border-t">
			{mockList.map((item, idx) => (
				<CommandData key={item.id + idx} item={item} />
			))}
		</div>
	);
}
  1. 当前页面效果

3、继续页面开发

第二大节【2、项目开发】中已经基本成型了,这次会补充、完善一些细节

(一) 顶部导航吸顶,要求:滚动一点距离后才吸顶

  1. 改动页面:react-master/src/components/navigation/index.tsx
typescript 复制代码
import React, { FC } from "react";
import { ZHRouter, router } from "../../router";
import { BellIcon } from "@heroicons/react/24/outline";
import { NavLink } from "react-router-dom";
import Search from "../search";
import { Tab } from "../../pages/home/tabs";

const Logo = () => {
	return (
		<div className=" px-2">
			<svg
				viewBox="0 0 64 30"
				fill="#1772F6"
				width="64"
				height="30"
				className="css-1hlrcxk"
			>
				<path d="M29.05 4.582H16.733V25.94h3.018l.403 2.572 4.081-2.572h4.815V4.582zm-5.207 18.69l-2.396 1.509-.235-1.508h-1.724V7.233h6.78v16.04h-2.425zM14.46 14.191H9.982c0-.471.033-.954.039-1.458v-5.5h5.106V5.935a1.352 1.352 0 0 0-.404-.957 1.378 1.378 0 0 0-.968-.396H5.783c.028-.088.056-.177.084-.255.274-.82 1.153-3.326 1.153-3.326a4.262 4.262 0 0 0-2.413.698c-.57.4-.912.682-1.371 1.946-.532 1.453-.997 2.856-1.31 3.693C1.444 8.674.28 11.025.28 11.025a5.85 5.85 0 0 0 2.52-.61c1.119-.593 1.679-1.502 2.054-2.883l.09-.3h2.334v5.5c0 .5-.045.982-.073 1.46h-4.12c-.71 0-1.39.278-1.893.775a2.638 2.638 0 0 0-.783 1.874h6.527a17.717 17.717 0 0 1-.778 3.649 16.796 16.796 0 0 1-3.012 5.273A33.104 33.104 0 0 1 0 28.74s3.13 1.175 5.425-.954c1.388-1.292 2.631-3.814 3.23-5.727a28.09 28.09 0 0 0 1.12-5.229h5.967v-1.37a1.254 1.254 0 0 0-.373-.899 1.279 1.279 0 0 0-.909-.37z"></path>
				<path d="M11.27 19.675l-2.312 1.491 5.038 7.458a6.905 6.905 0 0 0 .672-2.218 3.15 3.15 0 0 0-.28-2.168l-3.118-4.563zM51.449 15.195V5.842c4.181-.205 7.988-.405 9.438-.483l.851-.05c.387-.399.885-2.395.689-3.021-.073-.25-.213-.666-.638-.555a33.279 33.279 0 0 1-4.277.727c-2.766.321-3.97.404-7.804.682-6.718.487-12.709.72-12.709.72a2.518 2.518 0 0 0 .788 1.834 2.567 2.567 0 0 0 1.883.706c2.278-.095 5.598-.25 8.996-.41v9.203h-12.78c0 .703.281 1.377.783 1.874a2.69 2.69 0 0 0 1.892.777h10.105v7.075c0 .887-.464 1.192-1.231 1.214h-3.92a4.15 4.15 0 0 0 .837 1.544 4.2 4.2 0 0 0 1.403 1.067 6.215 6.215 0 0 0 2.71.277c1.36-.066 2.967-.826 2.967-3.57v-7.607h11.28c.342 0 .67-.135.91-.374.242-.239.378-.563.378-.902v-1.375H51.449z"></path>
				<path d="M42.614 8.873a2.304 2.304 0 0 0-1.508-.926 2.334 2.334 0 0 0-1.727.405l-.376.272 4.255 5.85 2.24-1.62-2.884-3.98zM57.35 8.68l-3.125 4.097 2.24 1.663 4.517-5.927-.375-.277a2.32 2.32 0 0 0-1.722-.452 2.327 2.327 0 0 0-1.536.896z"></path>
			</svg>
		</div>
	);
};

interface NavProps {
	navs: ZHRouter;
}

type NavLinkRenderProps = {
	isActive?: boolean;
	isPending?: boolean;
	isTransitioning?: boolean;
};

const NavTab: FC<NavProps> = ({ navs }) => {
	const getStyles = ({ isActive }: NavLinkRenderProps) =>
		"hover:text-black mx-4 h-full py-3.5 transition-all " +
		(isActive
			? "font-extrabold text-black border-b-4 border-blue-600"
			: "text-gray-400");

	return (
		<div className=" flex mx-6 box-border">
			{navs.map((item) => (
				<NavLink
					key={item.path + "__"}
					to={item.path || "/"}
					className={getStyles}
				>
					{item.title}
				</NavLink>
			))}
		</div>
	);
};

const MenuAlarm = () => (
	<div className="flex mr-10 gap-4">
		<div className=" flex flex-col justify-center items-center">
			<BellIcon className=" h-5 w-5 text-gray-400 fill-gray-400" />
			<span className=" text-gray-400 text-xs">消息</span>
		</div>
		<div className=" flex flex-col justify-center items-center">
			<BellIcon className=" h-5 w-5 text-gray-400 fill-gray-400" />
			<span className=" text-gray-400 text-xs">私信</span>
		</div>
	</div>
);

type Props = {
	className: string;
	hide: boolean;
};

export default function Navigation({ className, hide }: Props) {
	return (
		<div
			className={` bg-white w-screen shadow-lg overflow-hidden ${className}`}
		>
			<div className=" max-w-6xl mx-auto my-0 flex justify-center w-full">
				<div
					className={` relative h-14 flex flex-col justify-between items-center min-w-max w-full transition-all duration-300 ${hide ? "top-0" : "-top-14"}`}
				>
					{/* 未吸顶时展示这个 */}
					<div className=" w-full h-14 flex justify-between items-center min-w-max">
						<div className=" flex items-center">
							<Logo />
							<NavTab navs={router} />
						</div>
						<Search />
						<MenuAlarm />
					</div>

					{/* 吸顶时展示这个 */}
					<div className=" w-full h-14 flex justify-between items-center min-w-max">
						<div className=" flex items-center">
							<Logo />
							<Tab activeStyle="border-b-4 border-blue-600" />
						</div>
						<Search />
					</div>
				</div>
			</div>
		</div>
	);
}
  1. 改动页面:react-master/src/pages/home/index.tsx
typescript 复制代码
import React, { useState } from "react";
import Navigation from "../../components/navigation";
import Card from "../../components/card";
import Tabs from "./tabs";

type Props = {};

export default function Home({}: Props) {
	const [hide, setHide] = useState(true);

	const handleChange = (flag: boolean) => {
		setHide(flag);
	};
	return (
		<div>
			<Navigation className=" sticky top-0" hide={hide} />
			<div className=" mx-auto max-w-6xl flex my-2 px-20">
				<Card className=" w-2/3">
					<Tabs onChange={handleChange} />
				</Card>
				<div className=" flex-1 w-1/3">
					<Card className=" w-full">创作中心</Card>
					<Card className=" w-full">推荐关注</Card>
					<Card className=" w-full">其他功能</Card>
				</div>
			</div>
		</div>
	);
}
  1. 改动页面:react-master/src/pages/home/tabs.tsx
typescript 复制代码
import React, { FC, useEffect, useRef } from "react";
import { NavLink, Outlet } from "react-router-dom";

export const tabs = [
	{
		title: "关注",
		path: "/follow",
	},
	{
		title: "推荐",
		path: "/",
	},
	{
		title: "热榜",
		path: "/hot",
	},
	{
		title: "视频",
		path: "/zvideo",
	},
];

type TabProps = {
	activeStyle?: string;
};

export const Tab: FC<TabProps> = ({ activeStyle }) => (
	<div className=" flex mx-6 box-border">
		{tabs.map((item) => (
			<NavLink
				key={item.path}
				to={item.path}
				className={({ isActive }) =>
					" whitespace-nowrap py-4 mx-4 text-base transition-all " +
					(isActive
						? "text-blue-600 font-bold " + activeStyle
						: "text-black hover:text-blue-700")
				}
			>
				{item.title}
			</NavLink>
		))}
	</div>
);

type Props = {
	className?: string;
	onChange?: (bool: boolean) => void;
};

export default function Tabs({ onChange }: Props) {
	const scrollRef = useRef<HTMLDivElement>(null);

	// 当这个 ref 的 div 到顶后,则进行吸顶处理

	// 判断到顶
	// 1、getBoundingClientRect 获取到元素的位置信息,然后计算
	// 2、IntersectionObserver 监听元素进入可视区域
	useEffect(() => {
		let intersectionObserver: IntersectionObserver | undefined =
			new IntersectionObserver((entries) => {
				// 当进入可视区域内时,执行一次,entries[0]?.isIntersecting 为 true
				// 当离开可视区域内时,执行一次,entries[0]?.isIntersecting 为 false
				// 所以当为 false 时处理吸顶
				onChange?.(entries[0]?.isIntersecting);
			});

		scrollRef.current && intersectionObserver.observe(scrollRef.current);

		return () => {
			scrollRef.current && intersectionObserver!.unobserve(scrollRef.current);

			intersectionObserver = undefined;
		};
	}, []);

	return (
		<div className=" w-full">
			<div ref={scrollRef}></div>
			<Tab />
			<Outlet />
		</div>
	);
}

(二) 无限滚动

  1. 改动页面:react-master/src/pages/home/commandList.tsx
typescript 复制代码
import React, {
	FC,
	MouseEventHandler,
	useEffect,
	useRef,
	useState,
} from "react";
import { mockList } from "./mock";

type Props = {};

interface ICommandItem {
	key: string;
	item: any;
}
const CommandData: FC<ICommandItem> = ({ item }) => {
	const [selected, setSelected] = useState(false);

	const handleClick: MouseEventHandler<Element> = (event) => {
		event.preventDefault();
		setSelected(!selected);
	};

	return (
		<div className=" flex flex-col items-start p-4 border-b">
			{/* 标题部分 */}
			<div className=" flex h-auto">
				<a className=" font-bold text-lg leading-10">
					{item?.target?.question?.title || item?.target?.title}
				</a>
			</div>

			{/* 文章卡片 */}
			{selected ? (
				<div dangerouslySetInnerHTML={{ __html: item?.target?.content }} />
			) : (
				<a
					href="/"
					onClick={handleClick}
					className=" cursor-pointer hover:text-gray-600 text-gray-800"
				>
					{item?.target?.excerpt?.substring(0, 80) + "..."}
					<span className=" text-sm leading-7 text-blue-500 ml-2">
						阅读全文 &gt;
					</span>
				</a>
			)}

			{/* 底部 bar */}
			<div
				className={`flex justify-between items-center p-3 bg-white w-full ${selected ? " bottom-0 left-0 shadow-sm border-t sticky" : ""}`}
			>
				<div className=" flex items-center flex-1">
					<div
						className="
                        flex justify-center items-center h-8 px-4 bg-blue-100 text-sm text-blue-600 rounded-sm  cursor-pointer hover:bg-blue-200 transition-all"
					>
						赞同 {item?.target?.thanks_count || 0}
					</div>
					<div className=" flex justify-center items-center h-8 px-4 bg-blue-100 text-sm text-blue-600 rounded-sm  cursor-pointer hover:bg-blue-200 transition-all ml-2">
						踩
					</div>
					<div className=" flex items-center flex-1 gap-8 text-gray-400 text-sm ml-8">
						<div>{item?.target?.comment_count} 评论</div>
						<div>收藏</div>
						<div>举报</div>
						<div>...</div>
					</div>
				</div>
				{selected && (
					<div>
						<span
							className=" text-gray-500 text-sm cursor-pointer"
							onClick={handleClick}
						>
							收起
						</span>
					</div>
				)}
			</div>
		</div>
	);
};

const fetchList = () =>
	new Promise<Array<any>>((resolve) => {
		setTimeout(() => {
			resolve(mockList.slice(5, 10));
		}, 500);
	});

export default function CommandList({}: Props) {
	const [list, setList] = useState(mockList.slice(0, 5));

	const scrollRef = useRef<HTMLDivElement>(null);

	useEffect(() => {
		let intersectionObserver: IntersectionObserver | undefined =
			new IntersectionObserver((entries) => {
				// 这个函数执行时,拿不到最新的 list
				const isIntersecting = entries[0]?.isIntersecting;

				if (isIntersecting) {
					// 加载更多数据
					fetchList().then((res: Array<any>) => {
						setList((list) => [...list, ...res]);

						// setList([...list, ...res]); 这样写,list 不会更新
					});
				}
			});

		scrollRef.current && intersectionObserver.observe(scrollRef.current);

		return () => {
			scrollRef.current && intersectionObserver!.unobserve(scrollRef.current);
			intersectionObserver = void 0;
		};
	}, []);

	return (
		<div className=" flex flex-col border-t">
			{list.map((item, idx) => (
				<CommandData key={item.id + idx} item={item} />
			))}
			<div ref={scrollRef}>loading......</div>
		</div>
	);
}

(三) use* API 封装

React 的 useApi 有 useState 这种有返回值的,也有 useEffect 这种"生命周期"类的

1. useRefInsObsEffect

类似于 useEffect 的

  1. 新增useRefInsObsEffect.ts
javascript 复制代码
touch src/pages/home/commandList/useRefInsObsEffect.ts

// 写如下代码:
import { RefObject, useEffect } from "react";

export function useRefInsObsEffect(
	fn: (b: boolean) => void,
	scrollRef: RefObject<HTMLDivElement>,
) {
	useEffect(() => {
		let intersectionObserver: IntersectionObserver | undefined =
			new IntersectionObserver((entries) => {
				fn(entries[0]?.isIntersecting);
			});

		scrollRef.current && intersectionObserver.observe(scrollRef.current);

		return () => {
			scrollRef.current && intersectionObserver!.unobserve(scrollRef.current);
			intersectionObserver = void 0;
		};
	}, []);
}
  1. 更改react-master/src/pages/home/commandList/index.tsx(看变更吧)

2. useRefInsObsState

类似于 useState 的

  1. 新增useRefInsObsState.ts
typescript 复制代码
import { RefObject, useState } from "react";
import { useRefInsObsEffect } from "./useRefInsObsEffect";

import { mockList } from "./mock";

const fetchList = () =>
	new Promise<Array<any>>((resolve) => {
		setTimeout(() => {
			resolve(mockList.slice(5, 10));
		}, 1000);
	});

export function useRefInsObsState(scrollRef: RefObject<HTMLDivElement>) {
	const [list, setList] = useState(mockList.slice(0, 5));

	useRefInsObsEffect((isIntersecting) => {
		if (isIntersecting) {
			// 加载更多数据
			fetchList().then((res: Array<any>) => {
				setList((list) => [...list, ...res]);
			});
		}
	}, scrollRef);

	return list;
}
  1. 更改react-master/src/pages/home/commandList/index.tsx
typescript 复制代码
import React, { FC, MouseEventHandler, useRef, useState } from "react";
import { useRefInsObsState } from "./useRefInsObsState";

type Props = {};

interface ICommandItem {
	key: string;
	item: any;
}
const CommandData: FC<ICommandItem> = ({ item }) => {
	const [selected, setSelected] = useState(false);

	const handleClick: MouseEventHandler<Element> = (event) => {
		event.preventDefault();
		setSelected(!selected);
	};

	return (
		<div className=" flex flex-col items-start p-4 border-b">
			{/* 标题部分 */}
			<div className=" flex h-auto">
				<a className=" font-bold text-lg leading-10">
					{item?.target?.question?.title || item?.target?.title}
				</a>
			</div>

			{/* 文章卡片 */}
			{selected ? (
				<div dangerouslySetInnerHTML={{ __html: item?.target?.content }} />
			) : (
				<a
					href="/"
					onClick={handleClick}
					className=" cursor-pointer hover:text-gray-600 text-gray-800"
				>
					{item?.target?.excerpt?.substring(0, 80) + "..."}
					<span className=" text-sm leading-7 text-blue-500 ml-2">
						阅读全文 &gt;
					</span>
				</a>
			)}

			{/* 底部 bar */}
			<div
				className={`flex justify-between items-center p-3 bg-white w-full ${selected ? " bottom-0 left-0 shadow-sm border-t sticky" : ""}`}
			>
				<div className=" flex items-center flex-1">
					<div
						className="
                        flex justify-center items-center h-8 px-4 bg-blue-100 text-sm text-blue-600 rounded-sm  cursor-pointer hover:bg-blue-200 transition-all"
					>
						赞同 {item?.target?.thanks_count || 0}
					</div>
					<div className=" flex justify-center items-center h-8 px-4 bg-blue-100 text-sm text-blue-600 rounded-sm  cursor-pointer hover:bg-blue-200 transition-all ml-2">
						踩
					</div>
					<div className=" flex items-center flex-1 gap-8 text-gray-400 text-sm ml-8">
						<div>{item?.target?.comment_count} 评论</div>
						<div>收藏</div>
						<div>举报</div>
						<div>...</div>
					</div>
				</div>
				{selected && (
					<div>
						<span
							className=" text-gray-500 text-sm cursor-pointer"
							onClick={handleClick}
						>
							收起
						</span>
					</div>
				)}
			</div>
		</div>
	);
};

export default function CommandList({}: Props) {
	const scrollRef = useRef<HTMLDivElement>(null);

	const list = useRefInsObsState(scrollRef);

	return (
		<div className=" flex flex-col border-t">
			{list.map((item, idx) => (
				<CommandData key={item.id + idx} item={item} />
			))}
			<div ref={scrollRef} className=" h-auto">
				<svg
					width="656"
					height="108"
					viewBox="0 0 656 108"
					className="w-full text-gray-100"
				>
					<path
						d="M0 0h656v108H0V0zm0 0h350v12H0V0zm20 32h238v12H20V32zM0 32h12v12H0V32zm0 32h540v12H0V64zm0 32h470v12H0V96z"
						fill="currentColor"
						fill-rule="evenodd"
					></path>
				</svg>
			</div>
		</div>
	);
}

(四) 搜索历史记录功能

知乎原功能

1. 极致的本地存储库封装

  1. 新建文件
bash 复制代码
mkdir src/utils/store && touch src/utils/store/index.js
  1. 写入如下代码
ini 复制代码
/**
 * 一个本地存储库
 * 1. 初始化时可选择 localStorage、sessionStorage
 * 2. 若浏览器出现了异步问题、高频线程问题,也能解决
 * 3. 若本地存储有问题,可以降级处理
 * 4. 不用自己去解析 json,支持各种数组操作
 */
/**
 * 如何讲一个小工具封装到极致(过度设计)
 */

const CreateStore = function (
	unLocal = false,
	maxLength = 30,
	expireTime = NaN,
) {
	this.unLocal = unLocal;
	this.maxLength = maxLength;
	this.expireTime = expireTime;

	this.observe();
};

CreateStore.prototype.observe = function () {
	const context = this;
	this.__mock__storage = new Proxy(
		{},
		{
			get(target, propKey, receiver) {
				let result = Reflect.get(target, propKey, receiver);
				if (!this.unLocal) {
					// 存储在本地时,直接 getItem
					result = (context.getItem && context.getItem(propKey)) || void 0;

					// if (result !== Reflect.get(target, propKey, receiver)) {
					// 	throw new Error("数据不一致");
					// }
				}

				return result;
			},
			set(target, propKey, value, receiver) {
				let _value = value;

				// 数据处理
				if (value instanceof Array && value.length > context.maxLength) {
					_value = value.slice(0, context.maxLength); // 截取数据,多余丢弃
				}

				// 当 unLocal 为 false 时,在合适的时间将数据存储到本地
				if (!this.unLocal) {
					context.setItem && context.setItem(propKey, _value);
				}

				return Reflect.set(target, propKey, value, receiver);
			},
		},
	);
};

CreateStore.prototype.getItem = function (type) {
	if (!window) throw new Error("请在浏览器环境下运行");

	// 依赖反转:将操作抽象,不依赖于自己的实现,通过初始化时传入的storageMethod自行实现 getItem
	const data = window[this.storageMethod].getItem(type);

	let dataJson;
	try {
		dataJson = JSON.parse(data);
	} catch (error) {
		throw new Error(error);
	}

	return dataJson;
};

CreateStore.prototype.setItem = function (type, data) {
	if (!window) throw new Error("请在浏览器环境下运行");

	const dataJson = JSON.stringify(data);

	// 依赖反转:将操作抽象,不依赖于自己的实现,通过初始化时传入的storageMethod自行实现 setItem
	window[this.storageMethod].setItem(type, dataJson);
};

CreateStore.prototype.set = function (type, data) {
	this.__mock__storage[`${this.key}__${type}`] = data;
};

CreateStore.prototype.get = function (type) {
	return this.__mock__storage[`${this.key}__${type}`];
};

// 支持数组的方法
["pop", "push", "shift", "unshift", "reverse", "splice"].forEach((method) => {
	CreateStore.prototype[method] = function (type, ...rest) {
		// 当没有数组时,要用数组方法,直接初始化一个空数组
		if (!this.get(type)) this.set(type, []);

		if ((!this.get(type)) instanceof Array) throw new Error("必须为数组类型");

		const dataList = this.get(type);
		Array.prototype[method].apply(dataList, rest);
		this.set(type, dataList);
	};
});

const CreateLocalStorage = function (key, ...rest) {
	CreateStore.apply(this, rest);

	this.storageMethod = "localStorage";
	this.key = key;
};

CreateLocalStorage.prototype = Object.create(CreateStore.prototype);
CreateLocalStorage.prototype.constructor = CreateLocalStorage;

const CreateSessionlStorage = function (key, ...rest) {
	CreateStore.apply(this, rest);

	this.storageMethod = "sessionlStorage";
	this.key = key;
};

CreateSessionlStorage.prototype = Object.create(CreateStore.prototype);
CreateSessionlStorage.prototype.constructor = CreateSessionlStorage;

export const localStore = new CreateLocalStorage("local");
思考:函数与 SDK 的区别

SDK 一般采用类来写,它的扩展性更强。并且可以自行分层,逻辑月隔离与清晰

2. 更改react-master/src/components/search/index.tsx

搜索框支持历史记录、上下箭头选择历史记录

typescript 复制代码
import React, {
	ChangeEventHandler,
	FocusEventHandler,
	Fragment,
	KeyboardEventHandler,
	useRef,
	useState,
} from "react";
import { localStore } from "../../utils/store/index.js";

type Props = {};

export default function Search({}: Props) {
	const inputRef = useRef<HTMLInputElement>(null);

	// 下拉框的数据
	const [relatedList, setRelatedList] = useState<string[]>([]);

	// 是否展示下拉框
	const [isShow, setIsShow] = useState<boolean>(false);

	// 输入框内容
	const [inputValue, setInputValue] = useState<string>("");

	// 当前选择的数据下标
	const [selectedIdx, setSelectedIdx] = useState<number>(-1);

	const handleFocus: FocusEventHandler<HTMLInputElement> = (e) => {
		// 获取历史记录数据
		setRelatedList(
			// @ts-ignore
			(localStore.get("searchHistoryList") || [])
				.reduce((setArr: string[], item: string) => {
					return setArr.includes(item) ? setArr : [...setArr, item];
				}, [])
				.filter((item: string) => Boolean(item))
				.filter(
					(item: string) =>
						!e.target.value ||
						(e.target.value && item.includes(e.target.value)),
				)
				.slice(0, 5),
		);

		setIsShow(true);
	};

	const handleBlur = () => {
		setIsShow(false);
	};

	const handleChangge: ChangeEventHandler<HTMLInputElement> = (e) => {
		setInputValue(e.target.value);
		setSelectedIdx(-1);
		handleFocus(e as any);
	};

	const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (e) => {
		console.log("[ handleKeyDown ] >");
		switch (e.key) {
			case "Enter": {
				// 监听回车事件
				const currentValue =
					selectedIdx !== -1 ? relatedList[selectedIdx] : inputValue;

				// 将值放到输入框内
				setInputValue(currentValue);

				// @ts-ignore
				localStore.unshift("searchHistoryList", currentValue);

				setIsShow(false);

				break;
			}
			case "ArrowUp": {
				// 监听上箭头事件
				if (relatedList.length) {
					if (selectedIdx < 1) {
						setSelectedIdx(relatedList.length - 1);
					} else {
						setSelectedIdx((idx: number) => idx - 1);
					}
				}
				break;
			}
			case "ArrowDown": {
				// 监听下箭头事件
				if (relatedList.length) {
					if (selectedIdx === relatedList.length - 1) {
						setSelectedIdx(0);
					} else {
						setSelectedIdx((idx: number) => idx + 1);
					}
				}
				break;
			}

			default:
				break;
		}
	};

	const handleSearchBtnClick = () => {
		const currentValue = inputValue || inputRef.current?.placeholder;

		// 将值放到输入框内
		setInputValue(currentValue!);

		// @ts-ignore
		localStore.unshift("searchHistoryList", currentValue);

		setIsShow(false);
	};

	return (
		// Fragment 内置组件,用于在 JSX 中返回多个元素而不必包裹在一个额外的 HTML 元素中。
		<Fragment>
			<div className=" flex items-center">
				<input
					onFocus={handleFocus}
					onBlur={handleBlur}
					onChange={handleChangge}
					onKeyDown={handleKeyDown}
					ref={inputRef}
					value={inputValue}
					type="text"
					className=" w-98 h-8 border border-gray-100 px-4 rounded-full bg-gray-50"
					placeholder="福建软考报名入口"
				/>
				<button
					className=" w-16 h-8 mx-4 text-sm bg-blue-500 text-white flex justify-center items-center rounded-full hover:bg-blue-800 transition-all"
					onClick={handleSearchBtnClick}
				>
					提问
				</button>
			</div>
			{relatedList?.length && isShow ? (
				<div
					className="fixed top-16 w-96 z-10 bg-white border h-auto"
					style={{ left: inputRef.current?.getBoundingClientRect()?.x }}
				>
					{relatedList.map((item, idx) => {
						return (
							<div
								key={idx}
								className={`mb-2 last:mb-0 py-2 px-4 hover:bg-gray-100 cursor-pointer flex justify-between hover:*:flex ${idx === selectedIdx ? "bg-gray-100 text-blue-400" : ""}`}
							>
								<span>{item}</span>
								<span className="text-gray-500 text-sm hidden">X</span>
							</div>
						);
					})}
				</div>
			) : (
				<></>
			)}
		</Fragment>
	);
}
相关推荐
everyStudy40 分钟前
前端五种排序
前端·算法·排序算法
甜兒.2 小时前
鸿蒙小技巧
前端·华为·typescript·harmonyos
Jiaberrr5 小时前
前端实战:使用JS和Canvas实现运算图形验证码(uniapp、微信小程序同样可用)
前端·javascript·vue.js·微信小程序·uni-app
everyStudy6 小时前
JS中判断字符串中是否包含指定字符
开发语言·前端·javascript
城南云小白6 小时前
web基础+http协议+httpd详细配置
前端·网络协议·http
前端小趴菜、6 小时前
Web Worker 简单使用
前端
web_learning_3216 小时前
信息收集常用指令
前端·搜索引擎
tabzzz6 小时前
Webpack 概念速通:从入门到掌握构建工具的精髓
前端·webpack
200不是二百6 小时前
Vuex详解
前端·javascript·vue.js
滔滔不绝tao6 小时前
自动化测试常用函数
前端·css·html5