接上文:5-1 React 实战之从零到一的项目环境搭建(一)
2、项目开发
当仓库搭建完毕后,并且react-master
项目的基础配置搞完后,就要进行页面开发了
本次的项目开发目标是模仿知乎首页
功能与技术点:顶部菜单、搜索、搜索历史、阅读全文、悬底操作、无限滚动、骨架屏等
一、开发之前
利其器之 VSCode 插件:React vscode 开发插件与代码补全提示 - 掘金
react-master
内,新建文件夹
mkdir src/pages src/components src/router src/utils
二、路由配置
知乎路由结构:
- 新建
router/index.tsx
,并完善
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>,
},
],
},
];
- 改造
app.tsx
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>
);
}
- 删除多余的
app.css、app2.module.less
rm src/app.css src/app2.module.less
- 启动项目:
pnpm start
,效果如下,点击可以已经可以跳转到对应页面了
三、首页初始化
- 新建 首页 相关文件,并迁移路由文件里面的代码完成初始化
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>
);
}
- 更改路由文件,将首页的 element 改一下(不贴代码了,看变动吧)
- 看浏览器,确保页面还是正常的
四、公共部分之导航栏开发
- 新建导航对应文件,并写代码
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>
);
}
- 更改
react-master/src/pages/home/index.tsx
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>
);
}
- 更改
react-master/src/pages/router/index.tsx
- 新建搜索栏对应文件,并写代码
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>
);
}
- 此时的页面效果如下
五、完善首页
- 完善首页代码,
react-master/src/pages/home/index.tsx
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>
);
}
- 新建
Card
组件,并写代码
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>
);
}
- 新建
tabs.tsx
,作为二级菜单
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>
);
}
- 目前页面效果如下
六、完善推荐列表
- 处理 mock 数据
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
- 新建推荐列表页面 && 路由更改
touch src/pages/home/commandList/index.tsx
- 推荐列表页面代码
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">
阅读全文 >
</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>
);
}
- 当前页面效果
3、继续页面开发
第二大节【2、项目开发】中已经基本成型了,这次会补充、完善一些细节
(一) 顶部导航吸顶,要求:滚动一点距离后才吸顶
- 改动页面:
react-master/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";
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>
);
}
- 改动页面:
react-master/src/pages/home/index.tsx
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>
);
}
- 改动页面:
react-master/src/pages/home/tabs.tsx
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>
);
}
(二) 无限滚动
- 改动页面:
react-master/src/pages/home/commandList.tsx
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">
阅读全文 >
</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 的
- 新增
useRefInsObsEffect.ts
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;
};
}, []);
}
- 更改
react-master/src/pages/home/commandList/index.tsx
(看变更吧)
2. useRefInsObsState
类似于 useState 的
- 新增
useRefInsObsState.ts
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;
}
- 更改
react-master/src/pages/home/commandList/index.tsx
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">
阅读全文 >
</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. 极致的本地存储库封装
- 新建文件
mkdir src/utils/store && touch src/utils/store/index.js
- 写入如下代码
/**
* 一个本地存储库
* 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
搜索框支持历史记录、上下箭头选择历史记录
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>
);
}