Angular官网的英雄之旅hero-list,你会用React+TS写吗?

450 阅读9分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

看过Angular官网的小伙伴应该都知道,有一个比较🔥的demo叫英雄之旅👬传送门,也是一个很好的练手小项目(比todoList功能更丰富),用来熟悉Angular的基本使用再合适不过了。

image.png

咱就是说,有一定的基础,那么跟着这个教程写还是比较容易,里面的知识点除了订阅部分呢会有些难度,大部分业务逻辑、代码都比较容易理解。

但是现在国内比较流行的还是VUE和React呀。。。。。。angular的英雄之旅有啥用啊?!

我最初给自己计划一周写完这个demo,其实只用了two days就写好了,独立的用React写一遍英雄之旅(白嫖Angular的样式和思路),你对React就可以说是入门了。不要害怕自己写不明白,不知道用什么方法,怎么构建业务逻辑,先把基础的学了,一步一步的思考,问题就会迎刃而解😉(或者继续看我的文章来做参考)

📃正文开始

👉前情提要

请务必对前端三件套HCJ,ES6、TS和路由的概念、React Hooks有一定的了解。或者对vue有了解

React Hooks可以看看这两个例子📎 Hooks demo点击按钮改变文字颜色 (react学习之useContext、useReducer) - 掘金 (juejin.cn)

【全网首发(完整)】最深度细致的『React Hooks事件待办』项目实战_哔哩哔哩_bilibili

react 官网

✂️组件拆分

Angular官网介绍

1.首先需要一个英雄的首页

image.png

点击heroes或者dashborad(导航)会展示不同的页面(通过路由跳转)

2.英雄们的展示和添加英雄 heroes

点击heroes就会出现如图的input框和英雄列表

image.png

input框可以输入英雄名字,添加英雄

英雄列表可以点击查看细节或者❌删除(数据通过json-server模拟获得)

3.英雄的仪表盘hero-dashboard

image.png

点击dashboard后展示前5个英雄,和下方的一个搜索框(搜索单独为一个组件)

4.点击任意英雄的名字,展示英雄细节 hero-details

image.png

5.在dashboard中的搜索框,我们可以单独抽取出来作为一个组件hero-search

image.png

输入搜索关键词进行搜索,下方会显示搜索出的结果列表,可以添加防抖做性能优化

image.png

构建项目

先确保node和npm已经安装完成

npx creat-react-app my-app
npx create-react-app my-app --template typescript
cd my-app
npm start

这几个命令可以直接安装并启动一个react项目,可以看到项目的基本结构已经创建好了

image.png

接下来构建组件和路由(TS)

先把拆分好的组件都构建出来,在src文件下创建一个pages文件夹,在pages文件夹下就是我们需要的页面,我们只需创建一个hero-list文件夹就好,因为里面的页面切换都是通过路由进行跳转的,在hero-list下可以都会有一个主页面的index页面,每一个react组件都对应有一个index和样式文件

其他页面创建pages文件夹下

路由创建在constant的文件夹下

组件创建再components文件夹下

通用的接口创建在models文件夹下

访问接口服务创建再services文件夹下

如图:

image.png

路由的constant文件夹我放在了src外面,放在src中也👌

❗如果用TS的话,创建了index.tsx文件后是会报错的,因为ESlint的检查比较严格,所以这里给大家一个比较通用的结构来避免这个错误

//引入react
import React from 'react';

//默认导出
export default function 组件/页面名  首字母大写驼峰式() {
   
   //返回组件结构
  return (
    < >       
    </>
  );
}

如果有样式需求,可以创建对应的index.module.less文件,啥都不写也会报错,就根据那个提示来操作就行

基本的结构就创建好了

路由配置

组件准备好后,可以把路由先配置好,这里的路由配置比较简单,看代码秒懂。

React-router 官网

先安装依赖

npm install --save react-router

在constant文件夹的index.ts中配置总路由

export const ROUTES = {
  Root: '/',
  Hero: {
    Root: '/hero-list',
    Heroes: '/hero-list/heroes',
    HeroDetails: '/hero-list/hero-details',
    HeroDashboard: '/hero-list/hero-dashboard'
  }
};

mian 路由配置好后,就要找到我们会在什么地方用到路由跳转了

我们可以直接hero-list这个页面的index中直接控制路由跳转,那么就可以在一个页面内对多个页面/组件进行部分模块的切换了

image.png

return (
    <div className={styles.container}>
      <h1>{title}</h1>
      <nav>
        <NavLink to='heroes'>Heroes</NavLink>
        <NavLink to='hero-dashboard'>dashboard</NavLink>
      </nav>

      <Routes>
        <Route element={<Navigate to={ROUTES.Hero.Root} />} />
        <Route path='heroes' element={<Heroes />} />
        <Route path='hero-dashboard' element={<HeroDashboard />} />
        <Route path='hero-details/:id' element={<HeroDetails />} />
      </Routes>
    </div>
  );

NavLink

NavLinkLink 功能相同,前者在触碰link时会自动改变样式

NavLink必须有to属性对应总配置中的路径name 比如to='heroes'就对应图中下方<Routes>中的path

Routes

在这里配置总路由,其中每一个<Route>对应每一个跳转的组件

比如

image.png 对应尖头所指的路径 image.png

 <Route path='hero-details/:id' element={<HeroDetails />} />

/:id 用来匹配获得的英雄id

Navigate

这个来做一个重定向,重定向到根路径,也就是主页的路径上

主页面还可以加上一些提示信息之类的,

主页面完整代码

import { ROUTES } from '../../constants/index';
import React from 'react';
import { Navigate, NavLink, Route, Routes } from 'react-router-dom';
import styles from './index.module.less';
import Heroes from './pages/heroes';
import HeroDashboard from './pages/hero-dashboard';
import HeroDetails from './pages/hero-details';

export default function HeroList() {
  const title: string = '欢迎来到英雄之旅';
  return (
    <div className={styles.container}>
      <h1>{title}</h1>
      <nav>
        <NavLink to='heroes'>Heroes</NavLink>
        <NavLink to='hero-dashboard'>dashboard</NavLink>
      </nav>

      <Routes>
        <Route element={<Navigate to={ROUTES.Hero.Root} />} />
        <Route path='heroes' element={<Heroes />} />
        <Route path='hero-dashboard' element={<HeroDashboard />} />
        <Route path='hero-details/:id' element={<HeroDetails />} />
      </Routes>
    </div>
  );
}

less

/* AppComponent's private CSS styles */
h1 {
  margin-bottom: 0;
}

nav a {
  display: inline-block;
  margin-top: 10px;
  margin-right: 10px;
  padding: 1rem;
  color: #3d3d3d;
  text-decoration: none;
  background-color: #e8e8e8;
  border-radius: 4px;
}

nav a:hover {
  color: white;
  background-color: #42545c;
}

nav a.active {
  background-color: black;
}

.container {
  margin-left: 10%;
}

数据准备和模拟接口

我用的json-server来模拟接口 参考我这篇文章,看json-server的使用 1分钟学会使用json-server - 掘金 (juejin.cn)

我在services文件夹中创建了json数据文件

{
  "data": [
    {
      "name": "MOAN",
      "id": 15
    },
    {
      "id": 16,
      "name": "RubberMan"
    },
    {
      "id": 17,
      "name": "Dynama"
    },
    {
      "id": 18,
      "name": "Dr. IQ"
    },
    {
      "id": 19,
      "name": "Magma"
    },
    {
      "id": 20,
      "name": "Tornado"
    },
    {
      "id": 21,
      "name": "nana"
    },
    {
      "id": 22,
      "name": "ww"
    }
  ]
}

❗启动服务时,一定要在json文件对应的终端下启动

image.png 这样就启动成功

还需要另外写一个hero-list.ts来模拟对接口进增删改查 就像⬇️

export function getHeroes() {
  return axios.get('http://localhost:3000/data');
}
export function getHero(id: string) {
  return axios.get('http://localhost:3000/data', { params: { id: id } });
}
export function addHero(id: number, name: string) {
  return axios.post('http://localhost:3000/data', { id: id, name: name });
}
export function deleteHero(id: number) {
  return axios.delete(`http://localhost:3000/data/${id}`);
}

可能会出现跨域的问题,我通过配置的proxy代理解决的

heroes组件

在这个组件中,我们展示英雄的列表和添加英雄 首先准备html代码的结构 在React中可以通过map的形式,来进行循环(必须有key) 所以我们写一次列表item的结构即可

其中的样式写到了index.module.less中,引入时命名为styles

❗1.我们需要判断获取到的herolist是否为空,不为空才继续渲染 ❗2.需要给叉叉删除按钮绑定对应的点击事件 ❗3.由于点击每个英雄可以跳转查看英雄详情,所以可以用Link来代替a标签,并且用query参数的形式传递id ❗4.key可以绑定为id,可以提高diff算法的效率

 <ul className={styles.heroes}>
        {heroes &&
          heroes.map((hero) => {
            return (
              <li key={hero.id}>
                <Link to={`/hero-list/hero-details/${hero.id}`}>
                  <span className={styles.badge}>{hero.id}</span>
                  {hero.name}
                </Link>
                <button
                  type='button'
                  className={styles.delete}
                  title='delete hero'
                  onClick={() => {
                    handleDeleteHero(hero.id);
                  }}
                >
                  x
                </button>
              </li>
            );
          })}
</ul>

添加英雄则需要给input绑定状态(value)来进行value的获取和数据双向绑定,react时单项数据流,所以需要通过useState来实现双向绑定

在button上绑定添加英雄的事件

 <div>
        <label htmlFor='new-hero'>Hero name:</label>
        <input id='new-hero' onChange={handleInputChange} value={inputName} />
        <button type='button' className={styles.addbutton} onClick={handleAddHero}>
          Add heroName
        </button>
</div>

接下来,来写对应的数据操作的方法和状态(函数式组件)

初始化获取数据一般放在useEffect中,🥚注意useEffct的第二个参数的变化会触发render重新渲染,所以可以写为空数组[] 意为 只在第一次渲染实现副作用

❗ 记得在每一次添加成功或删除成功,总之变化来heroes的值的时候重新调用getHeroes,获取最新的数据来渲染

❗1.状态heroes的类型IHero的定义可以放在modles下,因为在很多页面都需要用到heroes,接口只需要一个id和name

export interface IHero {
  id: number;
  name: string;
}

🥚在heroes状态这儿需要的是类型为IHero的一个数组形式

用js就不用考虑这个

❗2.状态inputName来控制添加英雄的input框的val

❗3.添加英雄前,先判断是否有重复的英雄名

❗❗4.axios的then方法时异步的,useState也可能是异步的,为了避免在数据未添加成功或删除成功就重新获取到未更新的数据,可以将getHeroes方法包裹在add或delete的then中,确保是在处理成功后执行

核心代码

  const [heroes, setHeroes] = useState<IHero[]>([]);
  const [inputName, setInputName] = useState<string>('');
  // 会在下一次渲染时才执行
  // 依赖于空数组,则只调用一次
  useEffect(() => {
    getHeroes().then((res) => {
      setHeroes(res.data);
    });
  }, []);


  function handleInputChange(e: ChangeEvent<HTMLInputElement>) {
    setInputName(e.target.value);
  }
  function handleAddHero() {
    const val: string = inputName.trim();
    if (val.length) {
      const isExist = heroes.find((hero) => hero.name === val);
      if (isExist) {
        return console.log('exsit hero name');
      }
      const id = heroes[heroes.length - 1].id + 1;

      addHero(id, val).then(() => {
        setInputName('');
        getHeroes().then((res) => {
          setHeroes(res.data);
        });
      });
    }
  }
  function handleDeleteHero(id: number) {
    deleteHero(id).then(() => {
      getHeroes().then((res) => {
        setHeroes(res.data);
      });
    });
  }

less

/* HeroesComponent's private CSS styles */
.heroes {
  width: 15em;
  margin: 0 0 2em;
  padding: 0;
  list-style-type: none;
}

input {
  display: block;
  box-sizing: border-box;
  width: 50%;
  margin: 1rem 0;
  padding: 0.5rem;
}

.heroes li {
  position: relative;
  cursor: pointer;
}

.heroes li:hover {
  left: 0.1em;
}

.heroes a {
  display: block;
  width: 100%;
  height: 1.9em;
  padding: 0.3em 7px;
  color: #333;
  text-decoration: none;
  background-color: #eee;
  border-radius: 4px;
  /* stylelint-disable-next-line order/properties-order */
  text-align: left;
}

.heroes a:hover {
  color: #2c3a41;
  background-color: #e6e6e6;
}

.heroes a:active {
  color: #fafafa;
  background-color: #525252;
}

.heroes .badge {
  position: relative;
  top: -5px;
  left: -1px;
  display: inline-block;
  min-width: 16px;
  height: 1.8em;
  margin-right: 0.8em;
  padding: 0.2em 0.7em 0;
  color: white;
  font-size: small;
  text-align: center;
  background-color: #405061;
  border-radius: 4px 0 0 4px;
}

.addbutton {
  margin-bottom: 2rem;
  padding: 0.5rem 1.5rem;
  font-size: 1rem;
}

.addbutton:hover {
  color: white;
  background-color: #42545c;
}

button.delete {
  position: absolute;
  top: -2px;
  left: 230px;
  margin: 0;
  padding: 0 10px;
  color: #525252;
  font-size: 1.1rem;
  background-color: white;
}

button.delete:hover {
  color: white;
  background-color: #525252;
}

hero-dashboard组件

理解了heroes页面的道理,仪表盘就很简单啦,只需要获得到数据,截取前n个来显示就行,获取的方式和heroes相同,在less样式文件做做改动就行

核心代码

  <div className={styles.heroesmenu}>
        {heroes &&
          heroes.slice(0, 5).map((hero) => {
            return (
              <NavLink to={`/hero-list/hero-details/${hero.id}`} key={hero.id}>
                {hero.name}{' '}
              </NavLink>
            );
          })}
</div>

less

/* DashboardComponent's private CSS styles */
h2 {
  text-align: center;
}

.heroesmenu {
  /* flexbox */
  display: flex;
  flex-flow: row wrap;
  align-content: flex-start;
  align-items: flex-start;
  justify-content: space-around;
  max-width: 1000px;
  margin: auto;
  padding: 0;
}

a {
  display: inline-block;
  flex: 0 1 auto;
  align-self: auto;

  /* flexbox */
  order: 0;
  box-sizing: border-box;
  width: 100%;
  min-width: 70px;
  margin: 0.5rem auto;
  padding: 1rem;
  color: #fff;
  font-size: 1.2rem;
  text-align: center;
  text-decoration: none;
  background-color: #3f525c;
  border-radius: 2px;
}

@media (min-width: 600px) {
  a {
    box-sizing: content-box;
    width: 18%;
  }
}

a:hover {
  background-color: #000;
}

hero-search组件

search组件需要我们时刻获取到input的输入内容,并在heroes中过滤判断是否有符合的值并渲染成列表

❗1.在项目中,涉及到搜索这种频繁操作的功能最好都加上性能优化比如防抖,不然你会输入一次,列表就变化一次,加上useState可能会重新触发渲染函数,导致一直获取到空列表 e ❗2.在搜索时,要把渲染的数据和原始数据分开,每次对数据的过滤(数组的indexOf和filter方法)都要在原始数据的基础上进行,不然你就会破坏掉原始的数据,得到越老来越少的内容

❗3.防抖的方法有很多,我用的是我mentor教我的useEffect处理inputValue的自定义hooks方法来进行防抖,自定义hooks可以重新加一个hooks的文件夹,写在里面

不想➕防抖可以用键盘事件

  // 通过enter键盘事件搜索
   function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
     if (e.code === 'Enter') {
       searchHeroes(inputVal:string);
     }
   }

核心代码

useDebounce

export const useDebounce = <V>(value: V, delay?: number) => {
  const [debounceValue, setDebounceValue] = useState(value);

  useEffect(() => {
    // 每次触发重新设置定时器,直到最后一次触发后delay秒再执行事件
    const timeID = setTimeout(() => {
      setDebounceValue(value);
    }, delay);
    // 在事件触发前,会先执行return中的操作,清除timeID,再执行useEfeect中的语句
    return () => {
      clearTimeout(timeID);
    };
  }, [delay, value]);
  return debounceValue;
};

index.tsx

export default function HeroSearch() {
  const [inputVal, setInputVal] = useState<string>('');
  const [heroes, setHeroes] = useState<IHero[]>([]);
  const [matchHero, setMatchHero] = useState<IHero[]>();

  function handleInputChange(e: ChangeEvent<HTMLInputElement>) {
    setInputVal(e.target.value);
  }
  const deValue = useDebounce(inputVal, 300);
  const searchHeroes = useCallback((inputVal: string) => {
    if (inputVal) {
      const matchArr: IHero[] = heroes.filter((hero) => hero.name.indexOf(inputVal) >= 0);
      setMatchHero(matchArr);
    } else {
      setMatchHero([]);
    }

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    searchHeroes(deValue);
  }, [deValue, searchHeroes]);

  useEffect(() => {
    getHeroes().then((res) => {
      setHeroes(res.data);
    });
  }, []);

  return (
    <div>
      <div id='search-component'>
        <label htmlFor='search-box'>Hero Search</label>
        <input id='search-box' onChange={handleInputChange} />

        <ul className={styles.searchresult}>
          {matchHero &&
            matchHero.map((hero) => {
              return (
                <li key={hero.id}>
                  <NavLink to={`/hero-list/hero-details/${hero.id}`}>{hero.name}</NavLink>
                </li>
              );
            })}
        </ul>
      </div>
    </div>
  );
}

less

/* HeroSearch private styles */

label {
  display: block;
  margin-top: 1rem;
  margin-bottom: 0.5rem;
  margin-left: 2.4rem;
  font-weight: bold;
  font-size: 1.2rem;
}

input {
  display: block;
  box-sizing: border-box;
  width: 100%;
  max-width: 600px;
  padding: 0.5rem;
}

input:focus {
  outline: #369 auto 1px;
}

li {
  list-style-type: none;
}

.searchresult li a {
  display: inline-block;
  box-sizing: border-box;
  width: 100%;
  max-width: 600px;
  padding: 0.5rem;
  color: black;
  text-decoration: none;
  border-right: 1px solid gray;
  border-bottom: 1px solid gray;
  border-left: 1px solid gray;
}

.searchresult li a:hover {
  color: white;
  background-color: #435a60;
}

ul.searchresult {
  margin-top: 0;
  padding-left: 0;
}

hero-details组件

这里组件需要获得到id,调用getHero方法,获得到数据进行渲染即可,修改hero的名字也是调用对应的接口方法即可

还加上了goBack的功能,对React router的API进行调用

❗1.获得url中的query参数可以直接用useParams方法获得(react-router-dom)

❗2.goback可以调用useNavigate这个hooks(react-router-dom)

核心代码

export default function HeroDetails() {
  const { id } = useParams<{ id: string }>();
  const [hero, setHero] = useState<IHero>();
  const [inputName, setInputName] = useState('');
  const navigate = useNavigate();

  useEffect(() => {
    if (id) return;
    getHero(id as string).then((res) => {
      console.log(res);
      setHero(res.data[0]);
    });
  }, [id]);

  function handleInputChange(e: ChangeEvent<HTMLInputElement>) {
    setInputName(e.target.value);
  }
  function saveName() {
    saveHero(hero!.id, inputName);
    if (!id) return;
    getHero(id).then((res) => {
      console.log(res);
      setHero(res.data[0]);
    });
    setInputName('');
  }
  const goBack = () => {
    navigate(-1);
  };

  if (!hero) {
    return <></>;
  }
  return (
    <div>
      {}
      <div>
        <h2>{hero.name} Details</h2>
        <div>id: {hero.id}</div>
        <div>
          <label htmlFor='hero-name'>Hero name:{hero.name}</label>
          <input id='hero-name' placeholder='input new name' onChange={handleInputChange} value={inputName} />
        </div>
      </div>
      <button type='button' onClick={goBack}>
        go back
      </button>
      <button type='button' onClick={saveName}>
        save
      </button>
    </div>
  );
}

⭐写在最后

希望你可以通过自己写一遍的方式来掌握react的简单使用,核心代码只是比较关键的一些地方,英雄之旅中需要注意的细节也有很多,可以改进的地方也很多,作为参考,希望能够帮到你

转载:欢迎转载,但未经作者同意,必须保留此段声明;

有问题可以在评论区踢我