Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React进阶开发 设计系统, 设计模式, 性能优化Advanced React Design System, Design Patterns, Performance/[进行中] #193

Open
WangShuXian6 opened this issue Jun 19, 2024 · 31 comments

Comments

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Jun 20, 2024

2. 设计模式布局组件 Design Patterns Layout Components

2. 介绍

图片
图片

3. 屏幕分割器 Screen Splitter

npm create vite@latest react-dp
react
typescript

pnpm i
pnpm i styled-components -S
pnpm run dev

src\components\split-screen.tsx

import React from "react";
import { styled } from "styled-components";

const Container = styled.div`
  display: flex;
`;

const Panel = styled.div`
  flex: 1;
`;

interface SplitScreenProps {
  Left: React.ComponentType;
  Right: React.ComponentType;
}
//使用 SplitScreenProps 作为 props 类型,并显式声明返回类型为 React.ReactElement。
export const SplitScreen = ({
  Left,
  Right,
}: SplitScreenProps): React.ReactElement => {
  return (
    <Container>
      <Panel>
        <Left />
      </Panel>
      <Panel>
        <Right />
      </Panel>
    </Container>
  );
};

src\App.tsx

import "./App.css";
import { SplitScreen } from "./components/split-screen";

const LeftSideComp = () => {
  return <h2 style={{ backgroundColor: "red" }}>left</h2>;
};

const RightSideComp = () => {
  return <h2 style={{ backgroundColor: "blue" }}>right</h2>;
};

function App() {
  return <SplitScreen Left={LeftSideComp} Right={RightSideComp} />;
}

export default App;

图片

4. 屏幕分割器增强 Screen Splitter Enhancement

src\components\split-screen.tsx

import React from 'react';
import { styled } from 'styled-components';

// 定义 styled-components 的类型
const Container = styled.div`
  display: flex;
`;

interface PanelProps {
  flex: number;
}

const Panel = styled.div<PanelProps>`
  flex: ${(p) => p.flex};
`;

// 定义 SplitScreen 组件的 props 类型
interface SplitScreenProps {
  children: [React.ReactNode, React.ReactNode];
  leftWidth?: number;
  rightWidth?: number;
}

export const SplitScreen = ({
  children,
  leftWidth = 1,
  rightWidth = 1,
}: SplitScreenProps): React.ReactElement => {
  const [left, right] = children;
  return (
    <Container>
      <Panel flex={leftWidth}>{left}</Panel>
      <Panel flex={rightWidth}>{right}</Panel>
    </Container>
  );
};

src\App.tsx

import React from 'react';
import './App.css';
import { SplitScreen } from './components/split-screen';

interface SideCompProps {
  title: string;
}

const LeftSideComp = ({ title }: SideCompProps): React.ReactElement => {
  return <h2 style={{ backgroundColor: 'crimson' }}>{title}</h2>;
};

const RightSideComp = ({ title }: SideCompProps): React.ReactElement => {
  return <h2 style={{ backgroundColor: 'burlywood' }}>{title}</h2>;
};

function App(): React.ReactElement {
  return (
    <SplitScreen leftWidth={1} rightWidth={3}>
      <LeftSideComp title="Left" />
      <RightSideComp title="Right" />
    </SplitScreen>
  );
}

export default App;

图片

5. 列表 Lists

src\components\authors\LargeListItems.tsx

import React from 'react';

export interface Author {
  name: string;
  age: number;
  country: string;
  books: string[];
}

interface LargeAuthorListItemProps {
  author: Author;
}

export const LargeAuthorListItem = ({ author }: LargeAuthorListItemProps): React.ReactElement => {
  const { name, age, country, books } = author;
  return (
    <>
      <h2>{name}</h2>
      <p>Age: {age}</p>
      <p>Country: {country}</p>
      <h2>Books</h2>
      <ul>
        {books.map((book) => (
          <li key={book}>{book}</li>
        ))}
      </ul>
    </>
  );
};

src\components\authors\SmallListItems.tsx

// components/authors/SmallAuthorListItem.tsx
import React from 'react';
import { Author } from './LargeListItems';

interface SmallAuthorListItemProps {
  author: Pick<Author, 'name' | 'age'>;
}

export const SmallAuthorListItem = ({ author }: SmallAuthorListItemProps): React.ReactElement => {
  const { name, age } = author;
  return (
    <p>Name: {name}, Age: {age}</p>
  );
};

src\components\lists\Regular.tsx

import React from 'react';

interface RegularListProps<T> {
  items: T[];
  sourceName: string;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  ItemComponent: any ;
}

export const RegularList = <T,>({ items, sourceName, ItemComponent }: RegularListProps<T>): React.ReactElement => {
  return (
    <>
      {items.map((item, i) => (
        <ItemComponent key={i} {...{ [sourceName]: item }} />
      ))}
    </>
  );
};

src\data\authors.ts

export const authors = [
  {
    name: "Sarah Waters",
    age: 55,
    country: "United Kingdom",
    books: ["Fingersmith", "The Night Watch"],
  },
  {
    name: "Haruki Murakami",
    age: 71,
    country: "Japan",
    books: ["Norwegian Wood", "Kafka on the Shore"],
  },
  {
    name: "Chimamanda Ngozi Adichie",
    age: 43,
    country: "Nigeria",
    books: ["Half of a Yellow Sun", "Americanah"],
  },
];

src\App.tsx

import { LargeAuthorListItem } from "./components/authors/LargeListItems";
import { SmallAuthorListItem } from "./components/authors/SmallListItems";
import { RegularList } from "./components/lists/Regular";
import { authors } from "./data/authors";


function App() {
  return (
    <>
      <RegularList
        items={authors}
        sourceName={"author"}
        ItemComponent={SmallAuthorListItem}
      />
      <RegularList
        items={authors}
        sourceName={"author"}
        ItemComponent={LargeAuthorListItem}
      />
    </>
  );
}

export default App;

6. 列表类型 Lists Types

src\components\books\LargeListItems.tsx

import React from 'react';

export interface Book {
  name: string;
  price: number;
  title: string;
  pages: number;
}

interface LargeBookListItemProps {
  book: Book;
}

export const LargeBookListItem = ({ book }: LargeBookListItemProps): React.ReactElement => {
  const { name, price, title, pages } = book;

  return (
    <>
      <h2>{name}</h2>
      <p>{price}</p>
      <h2>Title:</h2>
      <p>{title}</p>
      <p># of Pages: {pages}</p>
    </>
  );
};

src\components\books\SmallListItems.tsx

import React from 'react';
import { Book } from './LargeListItems';

interface SmallBookListItemProps {
  book: Pick<Book, 'name' | 'price'>;
}

export const SmallBookListItem = ({ book }: SmallBookListItemProps): React.ReactElement => {
  const { name, price } = book;
  return (
    <h2>{name} / {price}</h2>
  );
};

src\components\lists\Numbered.tsx

import React from "react";

interface NumberedListProps<T> {
  items: T[];
  sourceName: string;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  ItemComponent: any; //React.ComponentType<{ [key: string]: T }>;
}

export const NumberedList = <T,>({
  items,
  sourceName,
  ItemComponent,
}: NumberedListProps<T>): React.ReactElement => {
  return (
    <>
      {items.map((item, i) => {
        const props = { [sourceName]: item };
        return (
          <React.Fragment key={i}>
            <h3>{i + 1}</h3>
            <ItemComponent {...props} />
          </React.Fragment>
        );
      })}
    </>
  );
};

src\App.tsx

import { LargeAuthorListItem } from "./components/authors/LargeListItems";
import { SmallAuthorListItem } from "./components/authors/SmallListItems";
import { LargeBookListItem } from "./components/books/LargeListItems";
import { SmallBookListItem } from "./components/books/SmallListItems";
import { NumberedList } from "./components/lists/Numbered";
import { RegularList } from "./components/lists/Regular";
import { authors } from "./data/authors";
import { books } from "./data/books";

function App() {
  return (
    <>
      <RegularList
        items={authors}
        sourceName={"author"}
        ItemComponent={SmallAuthorListItem}
      />
      <NumberedList
        items={authors}
        sourceName={"author"}
        ItemComponent={LargeAuthorListItem}
      />

      <RegularList
        items={books}
        sourceName={"book"}
        ItemComponent={SmallBookListItem}
      />

      <NumberedList
        items={books}
        sourceName={"book"}
        ItemComponent={LargeBookListItem}
      />
    </>
  );
}

export default App;

7. 模态框 Modals

非受控,因为父级无法从模态框外部控制模态框的状态。

这个模型是非受控的,因为这个模型本身可以控制自己,比如显示和隐藏组件的 show 和 setShow。

我们说它是非受控的,因为外部组件无法直接访问它的特性。

因为我无法访问这个模型的状态,包括 show 和 setShow,这降低了模型的灵活性,因为它是非受控的。

src\components\Modal.tsx

import React, { useState, ReactNode } from 'react';
import { styled } from 'styled-components';

const ModalBackground = styled.div`
  position: absolute;
  left: 0;
  top: 0;
  overflow: auto;
  background-color: #00000067;
  width: 100%;
  height: 100%;
`;

const ModalContent = styled.div`
  margin: 12% auto;
  padding: 24px;
  background-color: wheat;
  width: 50%;
`;

interface ModalProps {
  children: ReactNode;
}

export const Modal = ({ children }: ModalProps): React.ReactElement => {
  const [show, setShow] = useState<boolean>(false);

  return (
    <>
      <button onClick={() => setShow(true)}>Show Modal</button>
      {show && (
        <ModalBackground onClick={() => setShow(false)}>
          <ModalContent onClick={(e) => e.stopPropagation()}>
            <button onClick={() => setShow(false)}>Hide Modal</button>
            {children}
          </ModalContent>
        </ModalBackground>
      )}
    </>
  );
};

src\App.tsx

import { Modal } from "./components/Modal";
import { LargeBookListItem } from "./components/books/LargeListItems";
import { books } from "./data/books";

function App() {
  return (
    <>
      <Modal>
        <LargeBookListItem book={books[0]} />
      </Modal>
    </>
  );
}

export default App;

图片

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Jun 22, 2024

3. 设计模式-容器组件 Design Patterns Container Components

1. 介绍 Introduction

图片

从某种意义上说,容器组件是负责数据加载和数据管理的React组件,它们为子组件处理这些任务。
这里显示的是容器组件包裹多个子组件的情况。

通常,如果你是一个初级或中级的React开发者,可能会让子组件自行加载数据并独立显示。

例如,你可能会使用Usestate和Useeffect钩子以及像Axios或Fetch这样的库来从服务器获取数据。

然而,当多个子组件需要共享相同的数据加载逻辑时,就会出现问题。

这时,容器组件就派上用场了。

它们通过将数据加载逻辑提取到一个专门的组件中来解决这个问题。

容器组件负责数据检索过程,并将数据自动传递给子组件。

很快我们将深入探讨容器组件如何实现这一点。

但在此之前,让我们先了解容器组件背后的核心概念,类似于布局组件,我们旨在让子组件不必了解它们所处的特定布局。

容器组件遵循类似的原则。

我们希望组件不知道其数据的来源或管理方式。

相反,它们只需接收props并显示相关内容,而无需了解底层的数据处理。

2. 服务器设置 Server Setup

pnpm i express -D

server.js

//const express = require("express");
import express from 'express';

const app = express();

app.use(express.json());

let currentUser = {
  name: "Sarah Waters",
  age: 55,
  country: "United Kingdom",
  books: ["Fingersmith", "The Night Watch"],
};

let users = [
  {
    name: "Sarah Waters",
    age: 55,
    country: "United Kingdom",
    books: ["Fingersmith", "The Night Watch"],
  },
  {
    name: "Haruki Murakami",
    age: 71,
    country: "Japan",
    books: ["Norwegian Wood", "Kafka on the Shore"],
  },
  {
    name: "Chimamanda Ngozi Adichie",
    age: 43,
    country: "Nigeria",
    books: ["Half of a Yellow Sun", "Americanah"],
  },
];

let books = [
  {
    name: "To Kill a Mockingbird",
    pages: 281,
    title: "Harper Lee",
    price: 12.99,
  },
  {
    name: "The Catcher in the Rye",
    pages: 224,
    title: "J.D. Salinger",
    price: 9.99,
  },
  {
    name: "The Little Prince",
    pages: 85,
    title: "Antoine de Saint-Exupéry",
    price: 7.99,
  },
];

app.get("/current-user", (req, res) => res.json(currentUser));

app.get("/users/:id", (req, res) => {
  const { id } = req.params;
  console.log(id);
  res.json(users.find((user) => user.id === id));
});

app.get("/users", (req, res) => res.json(users));

app.post("/users/:id", (req, res) => {
  const { id } = req.params;
  const { user: editedUser } = req.body;

  users = users.map((user) => (user.id === id ? editedUser : user));

  res.json(users.find((user) => user.id === id));
});

app.get("/books", (req, res) => res.json(books));

app.get("/books/:id", (req, res) => {
  const { id } = req.params;
  res.json(books.find((book) => book.id === id));
});

let SERVER_PORT = 9090;
app.listen(SERVER_PORT, () =>
  console.log(`Server is listening on port: ${SERVER_PORT}`)
);

配置 react 到服务器的代理

普通React 项目

package.json
"proxy": "http://localhost:9090",

{
  "name": "react-design-patterns",
  "version": "0.1.0",
  "private": true,
  "proxy": "http://localhost:9090",
  "dependencies": {
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^13.5.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-scripts": "5.0.1",
    "styled-components": "^6.0.0-rc.3",
    "web-vitals": "^2.1.4"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "files.watcherExclude": {
    "**/.git/objects/**": true,
    "**/node_modules/**": true
  },
  "devDependencies": {
    "express": "^4.19.2"
  }
}

请求

const response = await axios.get('/current-user');

Vite React 项目 [本项目使用该方式]

配置 vite.config.ts 以设置代理服务器,将前端请求代理到后端 Express 服务器。

在这里,/api 前缀会被代理到 http://localhost:9090,并且会去掉 /api 前缀。这意味着当你在前端发出 /api/current-user 请求时,它会被代理到 http://localhost:9090/current-user

通过配置 Vite 的代理设置和在前端使用相对路径来发送 API 请求,我们可以实现前端与后端的通信。这样可以避免跨域问题,并且使得开发环境配置更加简洁。

vite.config.ts

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:9090',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
});

请求

const response = await axios.get('/api/current-user');

运行服务器

node server.js

3. 当前用户数据加载组件 3. Loader Component for CurrentUser Data

pnpm i axios -S

子组件 user-info

src\components\user-info.tsx

import React from 'react';

// 定义 User 类型
type User = {
  name: string;
  age: number;
  country: string;
  books: string[];
};

// 定义组件的 Props 类型
type UserInfoProps = {
  user?: User;
};

// 使用 FC 和 Props 类型定义组件
export const UserInfo= ({ user }:UserInfoProps) : React.ReactElement=> {
  const { name, age, country, books } = user || {} as User;
  return user ? (
    <>
      <h2>{name}</h2>
      <p>Age: {age} years</p>
      <p>Country: {country}</p>
      <h2>Books</h2>
      <ul>
        {books.map((book) => (
          <li key={book}>{book}</li>
        ))}
      </ul>
    </>
  ) : (
    <h1>Loading...</h1>
  );
};

容器组件 current-user-loader

容器组件 current-user-loader 将数据传递给子组件 user-info

current-user-loader.tsx

import axios from "axios";
import React, { useEffect, useState, ReactElement } from "react";

// 定义 User 类型
type User = {
  name: string;
  age: number;
  country: string;
  books: string[];
};

// 定义组件的 Props 类型
type CurrentUserLoaderProps = {
  children: ReactElement<{ user: User | null }>;
};

// 使用 FC 和 Props 类型定义组件
export const CurrentUserLoader = ({
  children,
}: CurrentUserLoaderProps): React.ReactElement => {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    (async () => {
      const response = await axios.get("/api/current-user");
      setUser(response.data);
    })();
  }, []);

  return <>{React.cloneElement(children, { user })}</>;
};

App

src\App.tsx

import { CurrentUserLoader } from "./components/current-user-loader";
import { UserInfo } from "./components/user-info";

function App() {
  return (
    <>
      <CurrentUserLoader>
        <UserInfo />
      </CurrentUserLoader>
    </>
  );
}

export default App;

图片

4. 用户数据加载组件 Loader Component for User Data

之前的组件,只能获取当前用户的数据。
也许我们想根据 ID 获取用户的数据。使其更加通用。

服务器

server.js

//const express = require("express");
import express from 'express';

const app = express();

app.use(express.json());

let currentUser = {
  id: "1",
  name: "Sarah Waters",
  age: 55,
  country: "United Kingdom",
  books: ["Fingersmith", "The Night Watch"],
};

let users = [
  {
    id: "1",
    name: "Sarah Waters",
    age: 55,
    country: "United Kingdom",
    books: ["Fingersmith", "The Night Watch"],
  },
  {
    id: "2",
    name: "Haruki Murakami",
    age: 71,
    country: "Japan",
    books: ["Norwegian Wood", "Kafka on the Shore"],
  },
  {
    id: "3",
    name: "Chimamanda Ngozi Adichie",
    age: 43,
    country: "Nigeria",
    books: ["Half of a Yellow Sun", "Americanah"],
  },
];

let books = [
  {
    id: "1",
    name: "To Kill a Mockingbird",
    pages: 281,
    title: "Harper Lee",
    price: 12.99,
  },
  {
    id: "2",
    name: "The Catcher in the Rye",
    pages: 224,
    title: "J.D. Salinger",
    price: 9.99,
  },
  {
    id: "3",
    name: "The Little Prince",
    pages: 85,
    title: "Antoine de Saint-Exupéry",
    price: 7.99,
  },
];

app.get("/current-user", (req, res) => res.json(currentUser));

app.get("/users/:id", (req, res) => {
  const { id } = req.params;
  res.json(users.find((user) => user.id === id));
});

app.get("/users", (req, res) => res.json(users));

app.post("/users/:id", (req, res) => {
  const { id } = req.params;
  const { user: editedUser } = req.body;

  users = users.map((user) => (user.id === id ? editedUser : user));

  res.json(users.find((user) => user.id === id));
});

app.get("/books", (req, res) => res.json(books));

app.get("/books/:id", (req, res) => {
  const { id } = req.params;
  res.json(books.find((book) => book.id === id));
});

let SERVER_PORT = 9090;
app.listen(SERVER_PORT, () =>
  console.log(`Server is listening on port: ${SERVER_PORT}`)
);

通用用户信息容器组件 UserLoader

src\components\user-loader.tsx

import axios from "axios";
import React, { useEffect, useState, ReactElement } from "react";

// 定义 User 类型
type User = {
  name: string;
  age: number;
  country: string;
  books: string[];
};

// 定义 UserLoaderProps 类型
type UserLoaderProps = {
  userId: string;
  children: ReactElement<{ user: User | null }>;
};

// `UserLoader` 组件
export const UserLoader = ({
  userId,
  children,
}: UserLoaderProps): ReactElement => {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    (async () => {
      const response = await axios.get(`/api/users/${userId}`);
      setUser(response.data);
    })();
  }, [userId]);

  return (
    <>
      {React.Children.map(children, (child) => {
        if (React.isValidElement(child)) {
          return React.cloneElement(child, { user });
        }
        return child;
      })}
    </>
  );
};

App

import { UserInfo } from "./components/user-info";
import { UserLoader } from "./components/user-loader";

function App() {
  return (
    <>
      <UserLoader userId={"1"}>
        <UserInfo />
      </UserLoader>

      <UserLoader userId={"2"}>
        <UserInfo />
      </UserLoader>

      <UserLoader userId={"3"}>
        <UserInfo />
      </UserLoader>
    </>
  );
}

export default App;

图片

5. 资源数据加载组件 Loader Component for Resource Data

通用资源数据获取容器,通过动态api和动态子组件属性,为任意子组件获取数据

子组件 book-info

src\components\book-info.tsx

import React from 'react';

type Book = {
  name: string;
  price: number;
  title: string;
  pages: number;
};

type BookInfoProps = {
  book?: Book;
};

export const BookInfo = ({ book }: BookInfoProps): React.ReactElement => {
  const { name, price, title, pages } = book || {} as Book;

  return book ? (
    <>
      <h3>{name}</h3>
      <p>{price}</p>
      <h3>Title: {title}</h3>
      <p>Number of Pages: {pages}</p>
    </>
  ) : (
    <h1>Loading</h1>
  );
};

子组件

src\components\user-info.tsx

import React from 'react';

// 定义 User 类型
type User = {
  name: string;
  age: number;
  country: string;
  books: string[];
};

// 定义组件的 Props 类型
type UserInfoProps = {
  user?: User;
};

export const UserInfo= ({ user }:UserInfoProps) : React.ReactElement=> {
  const { name, age, country, books } = user || {} as User;
  return user ? (
    <>
      <h2>{name}</h2>
      <p>Age: {age} years</p>
      <p>Country: {country}</p>
      <h2>Books</h2>
      <ul>
        {books.map((book) => (
          <li key={book}>{book}</li>
        ))}
      </ul>
    </>
  ) : (
    <h1>Loading...</h1>
  );
};

通用资源容器组件 resource-loader.

src\components\resource-loader.tsx

import axios from "axios";
import React, {
  useEffect,
  useState,
  ReactNode,
  ReactElement,
  cloneElement,
} from "react";

type ResourceLoaderProps = {
  resourceUrl: string;
  resourceName: string;
  children: ReactNode;
};

export const ResourceLoader = ({
  resourceUrl,
  resourceName,
  children,
}: ResourceLoaderProps): ReactElement => {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const [resource, setResource] = useState<any>(null);

  useEffect(() => {
    (async () => {
      const response = await axios.get(resourceUrl);
      setResource(response.data);
    })();
  }, [resourceUrl]);

  return (
    <>
      {React.Children.map(children, (child) => {
        if (React.isValidElement(child)) {
          return cloneElement(child, { [resourceName]: resource });
        }
        return child;
      })}
    </>
  );
};

src\App.tsx

import { BookInfo } from "./components/book-info";
import { UserInfo } from "./components/user-info";
import { ResourceLoader } from "./components/resource-loader";

function App() {
  return (
    <>
      <ResourceLoader resourceUrl={"/api/users/1"} resourceName={"user"}>
        <UserInfo />
      </ResourceLoader>

      <ResourceLoader resourceUrl={"/api/books/1"} resourceName={"book"}>
        <BookInfo />
      </ResourceLoader>
    </>
  );
}

export default App;

图片

6. 数据源组件 DataSource Component

更通用的资源加载容器,无需关心是否有请求功能,无需关心数据源。只负责传递数据给子组件。

数据源容器组件,通过函数属性[替代内置的api请求]获取数据

src\components\data-source.tsx

import React, {
  useEffect,
  useState,
  ReactNode,
  ReactElement,
  cloneElement,
} from "react";

type DataSourceProps<T> = {
  getData: () => Promise<T>;
  resourceName: string;
  children: ReactNode;
};

export const DataSource = <T,>({
  getData,
  resourceName,
  children,
}: DataSourceProps<T>): ReactElement => {
  const [resource, setResource] = useState<T | null>(null);

  useEffect(() => {
    (async () => {
      const data = await getData();
      setResource(data);
    })();
  }, [getData]);

  return (
    <>
      {React.Children.map(children, (child) => {
        if (React.isValidElement(child)) {
          return cloneElement(child, { [resourceName]: resource });
        }
        return child;
      })}
    </>
  );
};

App

src\App.tsx

import axios from "axios";
import { DataSource } from "./components/data-source";
import { UserInfo, type User } from "./components/user-info";

const fetchData = async <T,>(url: string): Promise<T> => {
  const response = await axios.get(url);
  return response.data;
};

function App() {
  return (
    <>
      <DataSource
        getData={() => fetchData<User>("/api/users/1")}
        resourceName="user"
      >
        <UserInfo />
      </DataSource>
    </>
  );
}

export default App;

图片

7. 使用渲染属性模式的容器组件 Container Component with Render Props Pattern

注意,不应该在简单组件中使用 cloneElement 克隆元素传递数据,因为它们会导致可维护性降低。

所以使用渲染属性[render]模式的容器组件来传递数据,替代 cloneElement

带有渲染的数据源容器

src\components\data-source-with-render-props.tsx

import React, { useEffect, useState, ReactNode } from "react";

type DataSourceWithRenderProps<T> = {
  getData: () => Promise<T>;
  render: (resource?: T) => ReactNode;
};

export const DataSourceWithRenderProps = <T,>({
  getData,
  render,
}: DataSourceWithRenderProps<T>) => {
  const [resource, setResource] = useState<T>();

  useEffect(() => {
    (async () => {
      const data = await getData();
      setResource(data);
    })();
  }, [getData]);

  return <>{render(resource)}</>;
};

user-info

src\components\user-info.tsx

import React from 'react';

// 定义 User 类型
export type User = {
  name: string;
  age: number;
  country: string;
  books: string[];
};

// 定义组件的 Props 类型
type UserInfoProps = {
  user?: User;
};

export const UserInfo= ({ user }:UserInfoProps) : React.ReactElement=> {
  const { name, age, country, books } = user || {} as User;
  return user ? (
    <>
      <h2>{name}</h2>
      <p>Age: {age} years</p>
      <p>Country: {country}</p>
      <h2>Books</h2>
      <ul>
        {books.map((book) => (
          <li key={book}>{book}</li>
        ))}
      </ul>
    </>
  ) : (
    <h1>Loading...</h1>
  );
};

App

src\App.tsx

import axios from "axios";
import { DataSourceWithRenderProps } from "./components/data-source-with-render-props";

import { UserInfo, type User } from "./components/user-info";

const fetchData = async <T,>(url: string): Promise<T> => {
  const response = await axios.get(url);
  return response.data;
};

function App() {
  return (
    <>
      <DataSourceWithRenderProps<User>
        getData={() => fetchData<User>("/api/users/1")}
        render={(resource) => <UserInfo user={resource } />}
      />
    </>
  );
}

export default App;

图片

8. 本地存储数据加载组件 Local Storage Data Loader Component

src\App.tsx

import axios from "axios";
import { DataSource } from "./components/data-source";
import { UserInfo, type User } from "./components/user-info";

const fetchData = async <T,>(url: string): Promise<T> => {
  const response = await axios.get(url);
  return response.data;
};

const getDataFromLocalStorage = (key: string) => (): string | null => {
  return localStorage.getItem(key);
};

type MessageProps = {
  msg?: string ;
};

const Message = ({ msg }: MessageProps): React.ReactElement => <h1>{msg}</h1>;

function App() {
  return (
    <>
      <DataSource
        getData={() => fetchData("/api/users/1")}
        resourceName={"user"}
      >
        <UserInfo />
      </DataSource>

      <DataSource
        getData={async () => getDataFromLocalStorage("test")}
        resourceName={"msg"}
      >
        <Message />
      </DataSource>
    </>
  );
}

export default App;

图片
图片

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Jun 22, 2024

4. 设计模式-受控和非受控组件 Design Patterns Controlled and Uncontrolled Components

1. 介绍

在本章中,我们将探讨一个基本的 React 设计模式:受控和非受控组件。

这些模式在 React 中非常常见,因此理解它们的区别和使用场景是至关重要的。

非受控组件

让我们先了解一下 React 中的非受控组件。

非受控组件是指组件自身管理其内部状态,组件内的数据通常仅在特定事件发生时被访问。

一个常见的例子是非受控表单,表单输入的值只有在用户触发提交事件时才能被外部组件知道。

受控组件

另一方面,受控组件是指父组件负责管理状态,然后将状态传递给受控组件作为属性。

父组件处理状态并控制受控组件的行为。

这些是受控和非受控组件的基本定义。

现在让我们更仔细地看看这些概念在代码中的实现。

非受控组件

在非受控组件中,组件本身通常使用像 useState 这样的钩子来管理自己的状态。
图片

在这里提供的代码片段中,我们可以看到一个使用 useState 钩子的非受控组件。

传递给这个组件的唯一属性是 onSubmit,这是由父组件提供的一个函数,用于在提交事件发生时检索内部状态的值。

受控组件

在受控组件中,组件的状态不再由组件本身管理。

相反,状态是作为属性从父组件传递下来的。

在给出的示例中,你会注意到受控组件不再使用 useState 钩子。
图片

状态是作为属性从父组件接收的,并且相应地使用了额外的函数。

在本章中,我们将很快查看受控和非受控组件的具体示例。

现在一个常见的问题是,我们应该更倾向于使用哪种方式,受控组件还是非受控组件?

在大多数情况下,受控组件是首选。

这种偏好的原因有几个。

首先,受控组件更易用,也更易于测试。

使用受控组件,我们可以轻松设置所需状态的组件以进行测试。

这消除了手动操作组件和触发事件以检查其内部行为的需求。

2. 非受控组件 Uncontrolled Components

我们将创建一个非受控表单,所以我们称之为 UncontrolledForm.js。

正如我所说的,非受控组件或像这里的表单这样的元素是一种不会泄露其状态的元素或组件。

所以我们无法使用任何 useState 或钩子来访问这个表单的元素状态。

我们将使用实际的 DOM 来访问它们,例如使用 createRef 函数等。

由于这是一个非受控表单,我们必须使用 React.createRef 来访问这些元素。

为了防止表单提交时页面刷新,我们使用 e.preventDefault()。

由于这个表单是非受控的,它的状态和特性对外部组件是不可访问的。

因此,我们必须使用 createRef 这样的间接方法来访问它的特性,这就是所谓的非受控表单。

总之,只有当我们提交这个表单时,它的数据才会被组件外部所改变。

src\components\uncontrolled-form.tsx

import React, { FormEvent } from "react";

export const UncontrolledForm = (): React.ReactElement => {
  const nameInputRef = React.createRef<HTMLInputElement>();
  const ageInputRef = React.createRef<HTMLInputElement>();

  const SubmitForm = (e: FormEvent<HTMLFormElement>): void => {
    e.preventDefault();
    if (nameInputRef.current && ageInputRef.current) {
      console.log(nameInputRef.current.value);
      console.log(ageInputRef.current.value);
    }
  };

  return (
    <form onSubmit={SubmitForm}>
      <input name="name" type="text" placeholder="Name" ref={nameInputRef} />
      <input name="age" type="number" placeholder="Age" ref={ageInputRef} />
      <input type="submit" value="Submit" />
    </form>
  );
};

src\App.tsx

import { UncontrolledForm } from "./components/uncontrolled-form";

function App() {
  return (
    <>
      <UncontrolledForm />
    </>
  );
}

export default App;

图片

3. 受控组件 Controlled Components

可以为组件添加额外功能,例如验证。

要创建的这个受控表单,它的基本区别在于,我们将使用像 useState 和 useEffect 这样的钩子来跟踪用户在表单中输入的值。

为了跟踪表单的输入,我们需要为每个输入创建一个状态。

除了不再需要 ref 之外,其他都保持不变。

为了美观,我们添加一个按钮,因为我们不依赖于表单的 onSubmit 事件。

现在我们有了一个受控表单。

它的状态可以直接从外部跟踪。

其中一个好处是,例如,如果你需要在用户输入之前进行一些输入验证,你可以更容易地做到这一点。

为此,我们在其中添加一个 useEffect。

我们检查姓名长度是否小于1,也就是输入为空。

当添加一些功能时,受控表单比非受控表单更加灵活。

src\components\controlled-form.tsx

import React, { useEffect, useState, ChangeEvent, ReactElement } from "react";

// 定义 ControlledForm 组件
export const ControlledForm = (): ReactElement => {
  const [error, setError] = useState<string>("");
  const [name, setName] = useState<string>("");
  const [age, setAge] = useState<number | undefined>();

  useEffect(() => {
    if (name.length < 1) {
      setError("The name can not be empty");
    } else {
      setError("");
    }
  }, [name]);

  const handleNameChange = (e: ChangeEvent<HTMLInputElement>): void => {
    setName(e.target.value);
  };

  const handleAgeChange = (e: ChangeEvent<HTMLInputElement>): void => {
    const value = e.target.value;
    setAge(value === "" ? undefined : parseInt(value, 10));
  };

  return (
    <form>
      {error && <p>{error}</p>}
      <input
        name="name"
        type="text"
        placeholder="Name"
        value={name}
        onChange={handleNameChange}
      />
      <input
        name="age"
        type="number"
        placeholder="Age"
        value={age === undefined ? "" : age}
        onChange={handleAgeChange}
      />
      <button type="submit">Submit</button>
    </form>
  );
};

src\App.tsx

import { ControlledForm } from "./components/controlled-form";


function App() {
  return (
    <>
      <ControlledForm />
    </>
  );
}

export default App;

图片

4. 受控模态框 Controlled Modals

不再在内部更改它的状态(显示或隐藏),而是将其移到 App 组件中处理。

这是受控模型组件,因为它的状态将由外部控制。

onClose 不是在内部定义的,而是在外部。

一些特性如 shouldDisplay 也是从外部传入的。

它从 false 到 true,再从 true 到 false 的触发在父组件中进行,而不是在组件内部。

这就是为什么我们称它为受控组件。

它的状态 shouldDisplay 和 setShouldDisplay 将由 App 组件控制。

对于受控模型,我们要传递 shouldDisplay。

这样它可以用来显示自己 shouldDisplay。

还有用于关闭的 onClose,因为这是一个属性函数。

基本上,这就是受控模型,因为它不控制自己的状态。

相反,容器或父组件(即这里的 App 组件)控制它的状态。

src\components\controlled-modal.tsx

import React, { ReactNode } from "react";
import styled from "styled-components";

const ModalBackground = styled.div`
  position: absolute;
  left: 0;
  top: 0;
  overflow: auto;
  background-color: #00000067;
  width: 100%;
  height: 100%;
`;

const ModalContent = styled.div`
  margin: 12% auto;
  padding: 24px;
  background-color: wheat;
  width: 50%;
`;

type ControlledModalProps = {
  shouldShow: boolean;
  close: () => void;
  children: ReactNode;
};

export const ControlledModal = ({
  shouldShow,
  close,
  children
}: ControlledModalProps): React.ReactElement | null => {
  return (
    <>
      {shouldShow && (
        <ModalBackground onClick={close}>
          <ModalContent onClick={(e) => e.stopPropagation()}>
            <button onClick={close}>Hide Modal</button>
            {children}
          </ModalContent>
        </ModalBackground>
      )}
    </>
  );
};

src\App.tsx

import { useState } from "react";
import { ControlledModal } from "./components/controlled-modal";

function App() {
  const [showModal, setShowModal] = useState(false);
  return (
    <>
      <button onClick={() => setShowModal(!showModal)}>
        {" "}
        {showModal ? "Hide Modal" : "Show Modal"}{" "}
      </button>
      <ControlledModal shouldShow={showModal} close={() => setShowModal(false)}>
        <h1>I am the body of the modal!</h1>
      </ControlledModal>
    </>
  );
}

export default App;

图片

5. 非受控流程 Uncontrolled Flows

src\components\uncontrolled-flow.tsx

import React, { useState, ReactElement, ReactNode } from "react";

type UncontrolledFlowProps = {
  children: ReactNode;
  onDone?: () => void;
};

type StepProps = {
  next: () => void;
};

export const UncontrolledFlow = ({
  children,
  onDone,
}: UncontrolledFlowProps): ReactElement => {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const [data, setData] = useState<Record<string, any>>({});
  const [currentStepIndex, setCurrentStepIndex] = useState(0);

  const childrenArray = React.Children.toArray(children);
  const currentChild = childrenArray[currentStepIndex];

  const next = () => {
    if (currentStepIndex < childrenArray.length - 1) {
      setCurrentStepIndex(currentStepIndex + 1);
    } else if (onDone) {
      onDone();
    }
  };

  if (React.isValidElement<StepProps>(currentChild)) {
    return React.cloneElement(currentChild, { next });
  }

  return currentChild as ReactElement;
};

src\components\Steps.tsx

import React from "react";

type StepProps = {
  next?: () => void;
};

const StepOne = ({ next }: StepProps): React.ReactElement => {
  return (
    <>
      <h1>Step #1</h1>
      <button onClick={next}>Next</button>
    </>
  );
};

const StepTwo = ({ next }: StepProps): React.ReactElement => {
  return (
    <>
      <h1>Step #2</h1>
      <button onClick={next}>Next</button>
    </>
  );
};

const StepThree = ({ next }: StepProps): React.ReactElement => {
  return (
    <>
      <h1>Step #3</h1>
      <button onClick={next}>Next</button>
    </>
  );
};

export { StepOne, StepTwo, StepThree };

src\App.tsx

import React from "react";
import { UncontrolledFlow } from "./components/uncontrolled-flow";
import { StepOne, StepTwo, StepThree } from "./components/Steps";

function App(): React.ReactElement {
  return (
    <>
      <UncontrolledFlow>
        <StepOne />
        <StepTwo />
        <StepThree />
      </UncontrolledFlow>
    </>
  );
}

export default App;

图片

6. 数据收集 Collecting Data

src\components\uncontrolled-flow.tsx

import React, { useState, ReactElement, ReactNode } from "react";

type UncontrolledFlowProps = {
  children: ReactNode;
  onDone: (data: Record<string, any>) => void;
};

type StepProps = {
  next: (dataFromStep: Record<string, any>) => void;
};

export const UncontrolledFlow = ({
  children,
  onDone,
}: UncontrolledFlowProps): ReactElement => {
  const [data, setData] = useState<Record<string, any>>({});
  const [currentStepIndex, setCurrentStepIndex] = useState(0);

  const childrenArray = React.Children.toArray(children);
  const currentChild = childrenArray[currentStepIndex];

  const next = (dataFromStep: Record<string, any>) => {
    const nextIndex = currentStepIndex + 1;
    const updatedData = { ...data, ...dataFromStep };

    console.log(updatedData);

    if (nextIndex < childrenArray.length) {
      setCurrentStepIndex(nextIndex);
    } else {
      onDone(updatedData);
    }

    setData(updatedData);
  };

  if (React.isValidElement<StepProps>(currentChild)) {
    return React.cloneElement(currentChild, { next });
  }

  return currentChild as ReactElement;
};

src\components\Steps.tsx

import React from "react";

type StepProps = {
  next: (dataFromStep: Record<string, any>) => void;
};

const StepOne = ({ next }: StepProps): React.ReactElement => {
  return (
    <>
      <h1>Step #1: Enter your name</h1>
      <button onClick={() => next({ name: "TestName" })}>Next</button>
    </>
  );
};

const StepTwo = ({ next }: StepProps): React.ReactElement => {
  return (
    <>
      <h1>Step #2: Enter your age</h1>
      <button onClick={() => next({ age: 23 })}>Next</button>
    </>
  );
};

const StepThree = ({ next }: StepProps): React.ReactElement => {
  return (
    <>
      <h1>Step #3: Enter your country</h1>
      <button onClick={() => next({ country: "Poland" })}>Next</button>
    </>
  );
};

export { StepOne, StepTwo, StepThree };

src\App.tsx

import React from "react";
import { UncontrolledFlow } from "./components/uncontrolled-flow";
import { StepOne, StepTwo, StepThree } from "./components/Steps";

function App(): React.ReactElement {
  return (
    <>
      <UncontrolledFlow
        onDone={(data) => {
          console.log(data);
          alert("Onboarding Flow Done!");
        }}
      >
        <StepOne next={()=>{}}/>
        <StepTwo next={()=>{}}/>
        <StepThree next={()=>{}}/>
      </UncontrolledFlow>
    </>
  );
}

export default App;

7. 受控流程 Controlled Flows

src\components\controlled-flow.tsx

import React, { ReactElement, ReactNode } from "react";

type ControlledFlowProps = {
  children: ReactNode;
  onDone?: (data: Record<string, any>) => void;
  currentStepIndex: number;
  onNext: (data: Record<string, any>) => void;
};

type StepProps = {
  next: (data: Record<string, any>) => void;
};

export const ControlledFlow = ({
  children,
  onDone,
  currentStepIndex,
  onNext,
}: ControlledFlowProps): ReactElement => {
  const next = (data: Record<string, any>) => {
    onNext(data);
  };

  const currentChild = React.Children.toArray(children)[currentStepIndex];

  if (React.isValidElement<StepProps>(currentChild)) {
    return React.cloneElement(currentChild, { next });
  }

  return currentChild as ReactElement;
};

src\components\Steps.tsx

import React from "react";

type StepProps = {
  next: (data: Record<string, any>) => void;
};

const StepOne = ({ next }: StepProps): React.ReactElement => {
  return (
    <>
      <h1>Step #1: Enter your name</h1>
      <button onClick={() => next({ name: "TestName" })}>Next</button>
    </>
  );
};

const StepTwo = ({ next }: StepProps): React.ReactElement => {
  return (
    <>
      <h1>Step #2: Enter your age</h1>
      <button onClick={() => next({ age: 30 })}>Next</button>
    </>
  );
};

const StepThree = ({ next }: StepProps): React.ReactElement => {
  return (
    <>
      <h1>Step #3: You qualify!</h1>
      <button onClick={() => next({})}>Next</button>
    </>
  );
};

const StepFour = ({ next }: StepProps): React.ReactElement => {
  return (
    <>
      <h1>Step #4: Enter your country</h1>
      <button onClick={() => next({ country: "Poland" })}>Next</button>
    </>
  );
};

export { StepOne, StepTwo, StepThree, StepFour };

src\App.tsx

import React, { useState } from "react";
import { ControlledFlow } from "./components/controlled-flow";
import { StepOne, StepTwo, StepThree, StepFour } from "./components/Steps";

function App(): React.ReactElement {
  const [data, setData] = useState<Record<string, any>>({});
  const [currentStepIndex, setCurrentStepIndex] = useState(0);

  const next = (dataFromStep: Record<string, any>) => {
    setData((prevData) => ({ ...prevData, ...dataFromStep }));
    setCurrentStepIndex(currentStepIndex + 1);
  };

  return (
    <>
      <ControlledFlow currentStepIndex={currentStepIndex} onNext={next}>
        <StepOne next={()=>{}}/>
        <StepTwo next={()=>{}}/>
        {data.age > 25 && <StepThree next={()=>{}}/>}
        <StepFour next={()=>{}}/>
      </ControlledFlow>
    </>
  );
}

export default App;

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Jun 23, 2024

5. 设计模式-高阶组件 Design Patterns HOCs

1. 介绍 Introduction

React设计模式:高阶组件。

高阶组件(简称HOC)是一些组件,它们不是直接返回JSX,而是返回另一个组件。

大多数React组件只是返回JSX,这些JSX代表将要渲染的DOM元素。

然而,通过高阶组件,我们引入了一个额外的层次,HOC不会直接返回JSX,而是返回另一个组件,这个组件再返回JSX。

为了简化这个概念,记住高阶组件本质上是返回组件的函数。

你可以把它们看作是组件工厂,当这些函数被调用时,它们会生成新的组件。

这种思维模型将帮助你掌握HOC的本质。

那么,为什么要创建高阶组件呢?

原因有几个。首先,HOC使我们能够在多个组件之间共享行为。

这类似于我们在容器组件中看到的,不同的组件被包装在同一个容器中,并表现出相似的行为。

高阶组件提供了一种实现类似功能的方法,用于共享相关的逻辑。

此外,高阶组件允许我们为现有组件添加额外功能。

如果我们遇到一个现有的组件,比如由其他人开发的遗留代码,HOC提供了一种方法,可以在不修改原始代码的情况下,为该组件增加新的功能和特性。

在本章的示例中,我们将更详细地探讨这些情况,展示高阶组件如何增强代码重用性和扩展组件功能。

2. 使用高阶组件检查属性 Checking Props with HOC

src\components\check-props.tsx

import React from 'react';

export const checkProps = <P extends object>(Component: React.ComponentType<P>) => {
  return (props: P) => {
    console.log(props);
    return <Component {...props} />;
  };
};

src\components\user-info.tsx

import React from 'react';

// 定义 User 类型
export type User = {
  name: string;
  age: number;
  country: string;
  books: string[];
};

// 定义组件的 Props 类型
export type UserInfoProps = {
  user?: User;
};

export const UserInfo= ({ user }:UserInfoProps) : React.ReactElement=> {
  const { name, age, country, books } = user || {} as User;
  return user ? (
    <>
      <h2>{name}</h2>
      <p>Age: {age} years</p>
      <p>Country: {country}</p>
      <h2>Books</h2>
      <ul>
        {books.map((book) => (
          <li key={book}>{book}</li>
        ))}
      </ul>
    </>
  ) : (
    <h1>Loading...</h1>
  );
};

src\App.tsx

import React from 'react';
import { checkProps } from './components/check-props';
import { UserInfo, type UserInfoProps } from './components/user-info';

const UserInfoWrapper = checkProps<UserInfoProps>(UserInfo);

function App() {
  return (
    <>
      <UserInfoWrapper user={{
        name: "Sarah Waters",
        age: 55,
        country: "United Kingdom",
        books: ["Fingersmith", "The Night Watch"]
      }} />
    </>
  );
}

export default App;

图片

3. 使用高阶组件加载数据 Data Loading with HOC

src\components\include-user.tsx

import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { User } from './user-info';

export const includeUser = <P extends object>(Component: React.ComponentType<P & { user?: User }>, userId: string) => {
  return (props: P) => {
    const [user, setUser] = useState<User | undefined>(undefined);

    useEffect(() => {
      const fetchUser = async () => {
        try {
          const response = await axios.get(`/api/users/${userId}`);
          setUser(response.data);
        } catch (error) {
          console.error('获取用户数据时出错:', error);
          setUser(undefined); // 或者根据需要处理错误状态
        }
      };

      fetchUser();
    }, []); // 不依赖于 userId

    return <Component {...props} user={user} />;
  };
};

src\App.tsx

import { includeUser } from "./components/include-user";
import { UserInfo } from "./components/user-info";

const UserInfoWithUser = includeUser(UserInfo, "2");

function App() {
  return (
    <>
      <UserInfoWithUser />
    </>
  );
}

export default App;

4 使用高阶组件更新数据 Updating Data with HOC

src\components\include-updatable-user.tsx

import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { User } from './user-info';

type IncludeUpdatableUserProps = {
  updatableUser: User | null;
  changeHandler: (updates: Partial<User>) => void;
  userPostHandler: () => Promise<void>;
  resetUserHandler: () => void;
};

export const includeUpdatableUser = <P extends object>(Component: React.ComponentType<P & IncludeUpdatableUserProps>, userId: string) => {
  return (props: P) => {
    const [user, setUser] = useState<User | null>(null);
    const [updatableUser, setUpdatableUser] = useState<User | null>(null);

    useEffect(() => {
      (async () => {
        const response = await axios.get(`/api/users/${userId}`);
        setUser(response.data);
        setUpdatableUser(response.data);
      })();
    }, [userId]);

    const userChangeHandler = (updates: Partial<User>) => {
      setUpdatableUser((prev) => (prev ? { ...prev, ...updates } : null));
    };

    const userPostHandler = async () => {
      if (updatableUser) {
        const response = await axios.post(`/api/users/${userId}`, {
          user: updatableUser,
        });
        setUser(response.data);
        setUpdatableUser(response.data);
      }
    };

    const resetUserHandler = () => {
      setUpdatableUser(user);
    };

    return (
      <Component
        {...props}
        updatableUser={updatableUser}
        changeHandler={userChangeHandler}
        userPostHandler={userPostHandler}
        resetUserHandler={resetUserHandler}
      />
    );
  };
};

5. 使用高阶组件构建表单 Building Forms with HOC

src\components\user-form.tsx

import React from 'react';
import { includeUpdatableUser } from './include-updatable-user';
import { User } from './user-info';

type UserInfoFormProps = {
  updatableUser: User | null;
  changeHandler: (updates: Partial<User>) => void;
  userPostHandler: () => void;
  resetUserHandler: () => void;
};

export const UserInfoForm = includeUpdatableUser(
  ({ updatableUser, changeHandler, userPostHandler, resetUserHandler }: UserInfoFormProps) => {
    const { name, age } = updatableUser || {};

    return updatableUser ? (
      <>
        <label>
          Name:
          <input
            value={name}
            onChange={(e) => changeHandler({ name: e.target.value })}
          />
        </label>
        <label>
          Age:
          <input
            value={age}
            onChange={(e) => changeHandler({ age: Number(e.target.value) })}
          />
        </label>
        <button onClick={resetUserHandler}>Reset</button>
        <button onClick={userPostHandler}>Save</button>
      </>
    ) : (
      <h3>Loading...</h3>
    );
  },
  "3"
);

src\App.tsx

import { UserInfoForm } from "./components/user-form";

function App() {
  return (
    <>
      <UserInfoForm updatableUser={null} changeHandler={function (): void {
        throw new Error("Function not implemented.");
      } } userPostHandler={function (): void {
        throw new Error("Function not implemented.");
      } } resetUserHandler={function (): void {
        throw new Error("Function not implemented.");
      } } />
    </>
  );
}

export default App;

图片

6. 增强高阶组件模式 Enhancing HOC Pattern

不再局限于更新用户数据,而是通过资源api和资源名称,更新通用数据。

src\components\include-updatable-resouce.tsx

import React, { useEffect, useState } from 'react';
import axios from 'axios';

const toCapital = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);

type IncludeUpdatableResourceProps<T> = {
  [key: string]: T | ((updates: Partial<T>) => void) | (() => void);
};

export const includeUpdatableResouce = <T, P extends object>(
  Component: React.ComponentType<P & IncludeUpdatableResourceProps<T>>,
  resourceUrl: string,
  resourceName: string
) => {
  return (props: P) => {
    const [data, setData] = useState<T | null>(null);
    const [updatableData, setUpdatableData] = useState<T | null>(null);

    useEffect(() => {
      (async () => {
        const response = await axios.get(resourceUrl);
        setData(response.data);
        setUpdatableData(response.data);
      })();
    }, [resourceUrl]);

    const changeHandler = (updates: Partial<T>) => {
      setUpdatableData((prev) => (prev ? { ...prev, ...updates } : prev));
    };

    const dataPostHandler = async () => {
      if (updatableData) {
        const response = await axios.post(resourceUrl, {
          [resourceName]: updatableData,
        });
        setData(response.data);
        setUpdatableData(response.data);
      }
    };

    const resetHandler = () => {
      setUpdatableData(data);
    };

    const resourceProps = {
      [resourceName]: updatableData,
      [`onChange${toCapital(resourceName)}`]: changeHandler,
      [`onSave${toCapital(resourceName)}`]: dataPostHandler,
      [`onReset${toCapital(resourceName)}`]: resetHandler,
    } as IncludeUpdatableResourceProps<T>;

    return <Component {...props} {...resourceProps} />;
  };
};

src\components\user-form.tsx

import React from 'react';
import { includeUpdatableResouce } from './include-updatable-resouce';
import { User } from './user-info';

type UserInfoFormProps = {
  user: User | null;
  onChangeUser: (updates: Partial<User>) => void;
  onSaveUser: () => void;
  onResetUser: () => void;
};

export const UserInfoForm = includeUpdatableResouce<User, UserInfoFormProps>(
  ({ user, onChangeUser, onSaveUser, onResetUser }: UserInfoFormProps) => {
    const { name, age } = user || {};

    return user ? (
      <>
        <label>
          Name:
          <input
            value={name}
            onChange={(e) => onChangeUser({ name: e.target.value })}
          />
        </label>
        <label>
          Age:
          <input
            value={age}
            onChange={(e) => onChangeUser({ age: Number(e.target.value) })}
          />
        </label>
        <button onClick={onResetUser}>Reset</button>
        <button onClick={onSaveUser}>Save</button>
      </>
    ) : (
      <h3>Loading...</h3>
    );
  },
  '/api/users/2',
  'user'
);

src\App.tsx

import { UserInfoForm } from "./components/user-form";

function App() {
  return (
    <>
      <UserInfoForm user={null} onChangeUser={function (): void {
        throw new Error("Function not implemented.");
      } } onSaveUser={function (): void {
        throw new Error("Function not implemented.");
      } } onResetUser={function (): void {
        throw new Error("Function not implemented.");
      } } />
    </>
  );
}

export default App;

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Jun 30, 2024

6. 设计模式自定义钩子 Design Patterns Custom hooks

1. 介绍 Introduction

在本章中,我们将深入探讨自定义钩子这一强大的设计模式。

自定义钩子允许我们结合现有的 React 钩子,如 useStateuseEffect,创建可重用的钩子,以实现特定的功能。

那么,究竟什么是自定义钩子呢?

自定义钩子是我们通过结合 React 提供的基本钩子创建的钩子。与其在多个组件中重复相同的逻辑,不如将该逻辑封装到一个自定义钩子中。

这使我们能够将复杂的行为抽象为可重用的单元。

让我们考虑一个例子:我们希望组件从服务器获取用户信息。我们可以在组件内部加载用户信息,或者创建一个名为 useUsers 的自定义钩子来处理数据加载并封装相关功能。
图片

我们稍后将探讨自定义钩子的实现,但这大致是自定义钩子的样子。

在组件中使用自定义钩子时,我们只需调用自定义钩子并将其返回值赋给一个变量。

需要注意的是,自定义钩子必须以 use 作为开头,这是 React 规定的要求。

这种命名约定与钩子内部的工作方式有关,但我们暂时不深入探讨这些细节。

就像高阶组件和容器组件一样,自定义钩子也具有类似的目的。

它们允许我们在多个组件之间共享复杂的行为。

通过在自定义钩子中封装特定功能,我们可以轻松地在多个组件中重用这些逻辑。


在 React 前端开发中,custom hooks 一般翻译为“自定义钩子”或“自定义 Hook”。

自定义钩子 (Custom Hooks)

解释

自定义钩子是开发者通过组合 React 提供的基本钩子(如 useStateuseEffect 等)来创建的钩子函数,用于封装和重用组件逻辑。与在每个组件中重复相同的逻辑相比,自定义钩子使得代码更加模块化和易于维护。

用法和好处

  1. 封装逻辑
    自定义钩子允许将组件中通用的状态逻辑提取到一个独立的函数中,便于在多个组件中重用。例如,一个自定义钩子可以处理数据获取、表单处理、订阅等逻辑。

  2. 提高代码复用性
    通过将通用逻辑封装在自定义钩子中,开发者可以避免在多个组件中重复相同的代码,从而提高代码的复用性和可维护性。

  3. 清晰的代码结构
    自定义钩子使组件代码更加简洁和清晰,因为它们将复杂的逻辑封装在一个单独的函数中,组件本身只负责调用这个钩子并使用其返回值。

示例

以下是一个简单的自定义钩子示例,用于管理表单输入状态:

import { useState } from 'react';

// 自定义钩子:useFormInput
function useFormInput(initialValue) {
  const [value, setValue] = useState(initialValue);

  const handleChange = (event) => {
    setValue(event.target.value);
  };

  return {
    value,
    onChange: handleChange
  };
}

// 组件示例
function MyFormComponent() {
  const name = useFormInput('');
  const email = useFormInput('');

  const handleSubmit = (event) => {
    event.preventDefault();
    console.log('Name:', name.value);
    console.log('Email:', email.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Name: </label>
        <input type="text" {...name} />
      </div>
      <div>
        <label>Email: </label>
        <input type="email" {...email} />
      </div>
      <button type="submit">Submit</button>
    </form>
  );
}

总结

自定义钩子是 React 中非常强大的工具,通过将通用逻辑封装成钩子函数,可以提高代码的复用性和可维护性,使得组件代码更加简洁和清晰。使用自定义钩子,可以使开发者更好地管理状态逻辑,并在不同组件之间共享复杂的行为。

2. 使用自定义钩子获取用户 Fetching a user with Custom Hook

src/
|-- components/
|   |-- UserInfo.tsx
|   |-- current-user.hook.ts
|-- types/
|   |-- index.ts
|-- App.tsx
|-- main.tsx

src\types\index.ts

export type User = {
    name: string;
    age: number;
    country: string;
    books: string[];
  };
  

src\components\current-user.hook.tsx

import { useEffect, useState } from 'react';
import axios from 'axios';
import { User } from '../types';

export const useCurrentUser = (): User | null => {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    (async () => {
      const response = await axios.get('/api/current-user');
      setUser(response.data);
    })();
  }, []);

  return user;
};

src\components\user-info.tsx

import React from 'react';
import { useCurrentUser } from './current-user.hook';


export const UserInfo = (): React.ReactElement => {
  const user = useCurrentUser();

  if (!user) {
    return <h1>Loading...</h1>;
  }

  const { name, age, country, books } = user;

  return (
    <>
      <h2>{name}</h2>
      <p>Age: {age} years</p>
      <p>Country: {country}</p>
      <h2>Books</h2>
      <ul>
        {books.map((book) => (
          <li key={book}>{book}</li>
        ))}
      </ul>
    </>
  );
};

src\App.tsx

import { UserInfo } from "./components/user-info";

function App() {
  return (
    <>
      <UserInfo />
    </>
  );
}

export default App;

3. 使用自定义钩子获取多个用户 Fetching users with Custom Hook

src/
|-- components/
|   |-- UserInfo.tsx
|   |-- user.hook.ts
|-- types/
|   |-- index.ts
|-- App.tsx
|-- main.tsx

src\types\index.ts

export type User = {
  name: string;
  age: number;
  country: string;
  books: string[];
};

src\components\user.hook.tsx

import { useEffect, useState } from 'react';
import axios from 'axios';
import { User } from '../types';

export const useUser = (userId: string): User | null => {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    (async () => {
      const response = await axios.get(`/api/users/${userId}`);
      setUser(response.data);
    })();
  }, [userId]);

  return user;
};

src\components\user-info.tsx

import React from 'react';
import { useUser } from './user.hook';

type UserInfoProps = {
  userId: string;
};

export const UserInfo = ({ userId }: UserInfoProps): React.ReactElement => {
  const user = useUser(userId);
  const { name, age, country, books } = user || { name: '', age: 0, country: '', books: [] };

  return user ? (
    <>
      <h2>{name}</h2>
      <p>Age: {age} years</p>
      <p>Country: {country}</p>
      <h2>Books</h2>
      <ul>
        {books.map((book) => (
          <li key={book}>{book}</li>
        ))}
      </ul>
    </>
  ) : (
    <h1>Loading...</h1>
  );
};

src\App.tsx

import { UserInfo } from "./components/user-info";

function App() {
  return (
    <>
      <UserInfo userId={"1"}/>
      <UserInfo userId={"2"}/>
      <UserInfo userId={"3"}/>
    </>
  );
}

export default App;

4. 使用自定义钩子获取资源

src/
|-- components/
|   |-- UserInfo.tsx
|   |-- BookInfo.tsx
|   |-- resource.hook.ts
|-- types/
|   |-- index.ts
|-- App.tsx
|-- main.tsx

src\types\index.ts

export type User = {
  name: string;
  age: number;
  country: string;
  books: string[];
};

export type Book = {
  name: string;
  price: number;
  title: string;
  pages: number;
};

src\components\resource.hook.tsx

import { useEffect, useState } from 'react';
import axios from 'axios';

export const useResource = <T,>(resourceUrl: string): T | null => {
  const [resource, setResource] = useState<T | null>(null);

  useEffect(() => {
    (async () => {
      const response = await axios.get(resourceUrl);
      setResource(response.data);
    })();
  }, [resourceUrl]);

  return resource;
};

src\components\user-info.tsx

import React from 'react';
import { useResource } from './resource.hook';
import { User } from '../types';

type UserInfoProps = {
  userId: string;
};

export const UserInfo = ({ userId }: UserInfoProps): React.ReactElement => {
  const user = useResource<User>(`/api/users/${userId}`);
  const { name, age, country, books } = user || { name: '', age: 0, country: '', books: [] };

  return user ? (
    <>
      <h2>{name}</h2>
      <p>Age: {age} years</p>
      <p>Country: {country}</p>
      <h2>Books</h2>
      <ul>
        {books.map((book) => (
          <li key={book}>{book}</li>
        ))}
      </ul>
    </>
  ) : (
    <h1>Loading...</h1>
  );
};

src\components\book-info.tsx

import React from 'react';
import { useResource } from './resource.hook';
import { Book } from '../types';

type BookInfoProps = {
  bookId: string;
};

export const BookInfo = ({ bookId }: BookInfoProps): React.ReactElement => {
  const book = useResource<Book>(`/api/books/${bookId}`);
  const { name, price, title, pages } = book || { name: '', price: 0, title: '', pages: 0 };

  return book ? (
    <>
      <h3>{name}</h3>
      <p>{price}</p>
      <h3>Title: {title}</h3>
      <p>Number of Pages: {pages}</p>
    </>
  ) : (
    <h1>Loading...</h1>
  );
};

src\App.tsx

import { BookInfo } from "./components/book-info";
import { UserInfo } from "./components/user-info";

function App() {
  return (
    <>
      <UserInfo userId={"1"}/>
      <BookInfo bookId={"2"}/>
    </>
  );
}

export default App;

图片

5. 更通用的自定义钩子 a More Generic Custom Hook

从多个数据源获取数据

src/
|-- components/
|   |-- UserInfo.tsx
|   |-- data-source.hook.ts
|-- types/
|   |-- index.ts
|-- utils/
|   |-- data-utils.ts
|-- App.tsx
|-- main.tsx

src\types\index.ts

export type User = {
  name: string;
  age: number;
  country: string;
  books: string[];
};

src\components\data-source.hook.tsx

import { useEffect, useState } from 'react';

export const useDataSource = <T,>(getData: () => Promise<T> | T): T | null => {
  const [resource, setResource] = useState<T | null>(null);

  useEffect(() => {
    (async () => {
      const data = await getData();
      setResource(data);
    })();
  }, [getData]);

  return resource;
};

src\utils\data-utils.ts

import axios from 'axios';

export const fetchFromServer = <T,>(url: string) => async (): Promise<T> => {
  const response = await axios.get(url);
  return response.data;
};

export const getFromLocalStorage = (key: string) => (): string | null => {
  return localStorage.getItem(key);
};

src\components\user-info.tsx

import React from 'react';
import { useDataSource } from './data-source.hook';
import { User } from '../types';
import { fetchFromServer, getFromLocalStorage } from '../utils/data-utils';

type UserInfoProps = {
  userId: string;
};

export const UserInfo = ({ userId }: UserInfoProps): React.ReactElement => {
  const user = useDataSource<User>(fetchFromServer(`/api/users/${userId}`));
  const loginAttempts = useDataSource<string | null>(getFromLocalStorage('logins'));
  const { name, age, country, books } = user || { name: '', age: 0, country: '', books: [] };

  return user ? (
    <>
      <h2>{name}</h2>
      <p>Age: {age} years</p>
      <p>Country: {country}</p>
      <h2>Books</h2>
      <ul>
        {books.map((book) => (
          <li key={book}>{book}</li>
        ))}
      </ul>
      <p>Login Attempts: {loginAttempts}</p>
    </>
  ) : (
    <h1>Loading...</h1>
  );
};

src\App.tsx

import { UserInfo } from "./components/user-info";

function App() {
  return (
    <>
      <UserInfo userId={"1"}/>
      <UserInfo userId={"2"}/>
      <UserInfo userId={"3"}/>
    </>
  );
}

export default App;

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Jun 30, 2024

7. React中的函数式编程设计模式 Design Patterns Functional Programming in React

1. 介绍

函数式编程是一种组织代码的方法,它强调最小化变异和状态变化,利用独立于外部数据的纯函数,并将函数视为一等公民。

虽然这个定义最初可能看起来有点晦涩,但如果你是函数式编程的新手,请不要慌张。我建议你做一些相关研究,因为这可以在你的开发者职业生涯中对你有很大帮助。

现在让我们讨论一下函数式编程在 React 中的一些应用。
图片

一个常见的应用是在控制组件中,我们之前已经讨论过。控制组件允许我们通过传递必要的属性来管理组件状态,最小化组件对内部状态管理的依赖。

函数组件是 React 中函数式编程的另一个关键应用。与已经存在一段时间的类组件不同,函数组件体现了函数式编程范式,提供了一种简洁明了的定义组件的方法。

高阶组件(HOCs)是 React 中函数式编程的另一个例子,在本课程中我们已经探索过它们。HOCs 利用一等函数的概念,创建返回其他函数的可重用函数,提供强大的功能和组合能力。

接下来,我们将深入探讨另外三种设计模式,这些模式展示了函数式编程在 React 中的影响:递归组件、部分应用组件和组件组合。

递归组件依赖于递归来实现特定效果。它们可以非常强大,提供复杂问题的独特解决方案。请务必关注这一部分内容,它非常重要。

部分应用组件通过传递组件属性的一个子集来创建更具体的通用组件版本。这种技术允许代码重用和组件定制的灵活性。

最后但同样重要的是,组件组合涉及将多个组件组合成一个单一组件以实现所需效果。这种模式允许通过组合更简单的组件来创建更复杂的组件。

当我们探索这些设计模式时,我们看到函数式编程原则如何增强 React 应用程序的模块化、可重用性和可维护性。

2. 递归组件 Recursive Components

递归模式或者更准确地说,递归组件是一个调用自身的组件,它从内部调用自己。

src\components\recursive.tsx

const isValidObj = (data: string | object) =>
  typeof data === "object" && data !== null;

export const Recursive = ({ data }: { data: string | object }) => {
  if (!isValidObj(data)) {
    return <li>{data}</li>;
  }

  const pairs = Object.entries(data);
  console.log(data);
  return (
    <>
      {pairs.map(([key, value]) => {
        return (
          <li key={key}>
            {key}:
            <ul>
              <Recursive data={value} />
            </ul>
          </li>
        );
      })}
    </>
  );
};

src\App.tsx

import { Recursive } from "./components/recursive";
import "./App.css";

const myNestedObject = {
  key1: "value1",
  key2: {
    innerKey1: "innerValue1",
    innerKey2: {
      innerInnerKey1: "innerInnerValue1",
      innerInnerKey2: "innerInnerValue2",
    },
  },
  key3: "value3",
};

function App() {
  return (
    <>
      <Recursive data={myNestedObject} />
    </>
  );
}

export default App;

图片

3. Compositions 组合组件[类似继承]

src\components\composition.tsx

import React from "react";

type ButtonProps = {
  size?: "small" | "large";
  color?: string;
  text: string;
};

export const Button: React.FC<ButtonProps> = ({
  size,
  color,
  text,
  ...props
}) => {
  return (
    <button
      style={{
        fontSize: size === "large" ? "25px" : "16px",
        backgroundColor: color,
      }}
      {...props}
    >
      {text}
    </button>
  );
};

export const SmallButton: React.FC<ButtonProps> = (props) => {
  return <Button size="small" {...props} />;
};

export const SmallRedButton: React.FC<ButtonProps> = (props) => {
  return <SmallButton size={"large"} color="crimson" {...props} />;
};

src\App.tsx

import "./App.css";
import { SmallButton, SmallRedButton } from "./components/composition";

function App() {
  return (
    <>
      <SmallButton text={"I am small!"} />
      <SmallRedButton text={"I am small and Red"} />
    </>
  );
}

export default App;

图片

4. Partial Components 部分模式

只是用组件的一部分

src\components\partial.tsx

import React from "react";

type ButtonProps = {
  size?: "small" | "large";
  color?: string;
  text?: string;
};

// 定义高阶组件partial的类型
export function partial<T>(
  Component: React.ComponentType<T>,
  partialProps: Partial<T>
) {
  return (props: T): JSX.Element => {
    return <Component {...partialProps} {...props} />;
  };
}

export const Button: React.FC<ButtonProps> = ({
  size,
  color,
  text,
  ...props
}) => {
  return (
    <button
      style={{
        fontSize: size === "large" ? "25px" : "16px",
        backgroundColor: color || "initial",
      }}
      {...props}
    >
      {text}
    </button>
  );
};

// 使用partial创建SmallButton
export const SmallButton = partial(Button, { size: "small" });

// 使用partial创建LargeRedButton
export const LargeRedButton = partial(Button, {
  size: "large",
  color: "crimson",
});

src\App.tsx

import { LargeRedButton, SmallButton } from "./components/partial";

function App() {
  return (
    <>
      <SmallButton text={"I am small!"}/>
      <LargeRedButton text="I am large and Red"/>
    </>
  );
}

export default App;

图片

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Jul 29, 2024

8. Design Patterns More Patterns

1. Compound Components 复合组件

src\components\card.tsx

import React, { createContext, useContext } from "react";

// 定义Context的类型
interface ContextType {
  test?: string;
}

// 创建带有初始值的Context
const Context = createContext<ContextType | null>(null);

type Props = {
  children: React.ReactNode;
};

// Body组件
const Body: React.FC<Props> = ({ children }) => {
  return <div style={{ padding: ".5rem" }}>{children}</div>;
};

// Header组件
const Header: React.FC<Props> = ({ children }) => {
  const context = useContext(Context);
  return (
    <div
      style={{
        borderBottom: "1px solid black",
        padding: ".5rem",
        marginBottom: ".5rem",
      }}
    >
      {children}
      {/* 从context中安全地获取test值 */}
      {context?.test}
    </div>
  );
};

// Footer组件
const Footer: React.FC<Props> = ({ children }) => {
  return (
    <div
      style={{
        borderTop: "1px solid black",
        padding: ".5rem",
        marginTop: ".5rem",
      }}
    >
      {children}
    </div>
  );
};

type CardProps = {
  test?: string;
  children: React.ReactNode;
};

// Card组件
const Card: React.FC<CardProps> & {
  Header: typeof Header;
  Body: typeof Body;
  Footer: typeof Footer;
} = ({ test, children }) => {
  return (
    <Context.Provider value={{ test }}>
      <div style={{ border: "1px solid black" }}>{children}</div>
    </Context.Provider>
  );
};

Card.Header = Header;
Card.Body = Body;
Card.Footer = Footer;

export default Card;

src\App.tsx

import Card from "./components/card";

function App() {
  return (
    <Card test="Value">
      <Card.Header>
        <h1 style={{ margin: "0" }}>Header</h1>
      </Card.Header>
      <Card.Body>
        He hid under the covers hoping that nobody would notice him there. It
        really didn't make much sense since it would be obvious to anyone who
        walked into the room there was someone hiding there, but he still held
        out hope. He heard footsteps coming down the hall and stop in front in
        front of the bedroom door. He heard the squeak of the door hinges and
        someone opened the bedroom door. He held his breath waiting for whoever
        was about to discover him, but they never did.
      </Card.Body>
      <Card.Footer>
        <button>Ok</button>
        <button>Cancel</button>
      </Card.Footer>
    </Card>
  );
}

export default App;

图片

2. Observer Pattern 观察员模式

pnpm i mitt -S

src\components\buttons.tsx

import { emitter } from "../App";

const Buttons = (props) => {
  const onIncrementCounter = () => {
    emitter.emit("increment");
  };
  const onDecrementCounter = () => {
    emitter.emit("decrement");
  };
  return (
    <div>
      <button onClick={onIncrementCounter}></button>
      <button onClick={onDecrementCounter}></button>
    </div>
  );
};
export default Buttons;

src\components\counter.tsx

import { useEffect, useState } from "react";
import { emitter } from "../App";

const Counter = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const onIncrement = () => {
      setCount((count) => count + 1);
    };
    const onDecrement = () => {
      setCount((count) => count - 1);
    };
    emitter.on("increment", onIncrement);
    emitter.on("decrement", onDecrement);
    return () => {
      emitter.off("increment", onIncrement);
      emitter.off("decrement", onDecrement);
    };
  }, []);
  return <div>#: {count}</div>;
};
export default Counter;

src\components\parent.tsx

import Buttons from "./buttons";
import Counter from "./counter";

const ParentComponent = (props) => {
  return (
    <>
      <Buttons />
      <Counter />
    </>
  );
};
export default ParentComponent;

src\App.tsx

import ParentComponent from "./components/parent";
import mitt from "mitt";

export const emitter = mitt();

function App() {
  return (
    <>
      <ParentComponent />
    </>
  );
}

export default App;

图片

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Jul 29, 2024

9. Advanced Concepts and Hooks 高级概念和钩子

1. React Portals React 传送门/门户

独立的#alert-holder标签可以提高性能,防止非必要的Portals挂在同一标签上。
index.html

<!doctype html>
<html lang="zh-cn">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
  </head>
  <body>
    <div id="alert-holder"></div>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

src\App.tsx

import React, { useState, ReactNode } from "react";
import { createPortal } from "react-dom";
import "./App.css";

// App组件不需要额外的Props类型定义
function App() {
  const [show, setShow] = useState<boolean>(false); // 明确useState中状态的类型是boolean

  return (
    <div style={{ position: "absolute", marginTop: "200px" }}>
      <h1>Other Content</h1>
      <button onClick={() => setShow(true)}>Show Message</button>
      <Alert show={show} onClose={() => setShow(false)}>
        A sample message to show.
        <br />
        Click it to close.
      </Alert>
    </div>
  );
}

type AlertProps = {
  children: ReactNode; // ReactNode允许任何可以渲染的内容,包括字符串、数字、React元素等
  onClose: () => void; // onClose是一个不接受任何参数并且不返回任何内容的函数
  show: boolean; // show是一个布尔值,控制Alert组件是否渲染
};

const Alert: React.FC<AlertProps> = ({ children, onClose, show }) => {
  if (!show) return null; // 未显示时返回null

  return createPortal(
    <div className="alert" onClick={onClose}>
      {children}
    </div>,
    document.querySelector("#alert-holder")! // 使用非空断言操作符(!)来表明element一定存在
  );
};

export default App;

2. Forwarding Refs 转发引用

对自定义组件的引用

src\input.tsx

import React, { forwardRef, InputHTMLAttributes, Ref } from "react";

type CustomInputProps = InputHTMLAttributes<HTMLInputElement>;

const CustomInput = (props: CustomInputProps, ref: Ref<HTMLInputElement>) => {
  return <input {...props} ref={ref} className="text-input" />;
};

export const Input = forwardRef<HTMLInputElement, CustomInputProps>(
  CustomInput
);

src\App.tsx

import React, { useRef, FormEvent } from "react";
import "./App.css";
import { Input } from "./input";

function App() {
  const inputRef = useRef<HTMLInputElement>(null);

  function submitHandler(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    console.log(inputRef.current?.value);
  }

  return (
    <form onSubmit={submitHandler}>
      <Input ref={inputRef} />
      <button type="submit" className="button">
        Submit
      </button>
    </form>
  );
}

export default App;

图片

3. Error Boundaries 错误边界

在任何React项目中都会遇到的一件事,那就是错误。
现在假设由于某种原因,比如在你的子组件内部出现了拼写错误或其他错误,会发生什么呢?

整个应用程序会变成空白。这是一个糟糕的体验。

在该组件崩溃或遇到错误时应该显示与该组件相关的内容。

错误边界只是一些类组件,它们内部有一个回退组件。也许这是你现在在React应用程序中使用类组件的唯一情况。

因此,当你用错误边界包裹你的应用程序或任何组件时,如果该子组件崩溃或遇到错误,作为父组件的错误边界将显示该回退组件。

错误边界是高度可重用的。

你可以简单地使用错误边界包裹购物车组件,并提供你想要的回退组件。

我想重复的另一个建议是,应该有一个包裹整个应用程序的错误边界。

因此,如果应用程序的任何部分出错,不要向用户显示一个空白页面,你可以显示一个带有文字的图片,告诉他们我们遇到了一些问题,请稍后再试或几分钟后再试等。

现在我们在错误时显示了一些内容,但错误的详细信息呢?

也许你想将它记录到某个服务中?

在React类组件中有一个非常有用的函数叫做componentDidCatch,它接收错误。

这些错误边界只捕捉由React渲染步骤引起的错误。

注意:如果你有一些与异步代码相关的错误,例如从API获取数据,或在useEffect内部,或使用setTimeout,由于它是异步的,这些错误边界不会被触发。

简单举例,在useEffect中抛出错误,由于获取数据的问题,但它没有被错误边界捕捉。

因为这些错误与React渲染步骤无关。

如果你想捕捉这样的错误,你可能需要使用catch方法。

对于异步代码的错误,错误边界不会被触发,你需要用适当的方法根据场景捕捉这些错误,只使用错误边界处理与React渲染步骤相关的错误。

这非常合理,因为这些错误不会导致应用程序完全变白。【异步错误出现之前可能已经渲染了一些界面】

错误边界只处理那些让应用程序完全变白的可怕体验,这时候我们需要错误边界帮助我们。

其他情况,你需要使用相关技术来处理。

src\error-boundry.tsx

import React, { ReactNode } from "react";

interface ErrorBoundaryProps {
  fallback: ReactNode;
  children: ReactNode;
}

interface ErrorBoundaryState {
  hasError: boolean;
}

export class ErrorBoundary extends React.Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = { hasError: false };

  //错误状态接受
  static getDerivedStateFromError(_: Error): ErrorBoundaryState {
    return { hasError: true };
  }

  //捕获错误详细信息
  componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
    console.log("Error: ", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }

    //后备组件
    return this.props.children;
  }
}

src\child.tsx

import React, { useEffect } from "react";

export const Child: React.FC = () => {
  useEffect(() => {
    fetch("/")
      .then(() => {
        throw new Error("Fetch Error");
      })
      .catch((error) => {
        console.warn("catch fech error", error);
      });
    throw new Error("UI Error");
  }, []);

  return <h1>Child Component</h1>;
};

src\App.tsx

import React from "react";
import "./App.css";
import { Child } from "./child";
import { ErrorBoundary } from "./error-boundry";

function App(): JSX.Element {
  return (
    <>
      <h1>Parent Component</h1>
      <ErrorBoundary fallback={<h1>Error in child</h1>}>
        <Child />
      </ErrorBoundary>
    </>
  );
}

export default App;

4. Keys Explained 键和状态

React中的键和状态保留问题

在这个非常基础的应用程序中,我们只有两个组件。
其中一个是计数器组件,它只有一个状态,就是计数值,并有两个按钮,一个用于增加,一个用于减少计数值,当然还有显示它们的功能。

如果你点击它们,我们可以看到衬衫和鞋子。我可以增加鞋子的计数值,或者像这样增加衬衫的计数值。但这里的问题是,每当我在它们之间切换时,两个计数器的状态都是相同的。这是因为<Counter />的父级都是<></>,完全相同。所以切换时不会重新渲染。虽然状态没有持久化,但是因为没有重新渲染,所以状态也不会变更。

src\counter.tsx

import { useState } from "react";

const Counter = () => {
  const [count, setCount] = useState(0);

  return (
    <>
      <button onClick={() => setCount((c) => c - 1)}>-</button>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>+</button>
    </>
  );
};

export default Counter;

src\App.tsx

import { useState } from "react";
import "./App.css";
import Counter from "./counter";

function App() {
  const [changeShirts, setChangeShirts] = useState(false);
  return (
    <div>
      {changeShirts ? (
        <>
          <span>Shirts counts: </span> <Counter />{" "}
        </>
      ) : (
        <>
          <span>Shoes counts: </span> <Counter />{" "}
        </>
      )}
      <br />
      <button onClick={() => setChangeShirts((s) => !s)}>Switch</button>
    </div>
  );
}

export default App;

鞋子增加到6
图片
切换为体恤依然为6【应该为0】
图片

解决这个问题的一个简单方法是为每个计数器创建不同的父组件

所以当React查看两个状态的父组件时,它会发现它们在树中的位置不同。
例如,对于这个计数器,我们可以给它一个div,同样在这里。而对于另一个计数器,我们给它一个section而不是div,因为如果你再次给它一个div,React会查看计数器的父组件,并假设它们还是相同的,不会重新渲染。
所以我们简单地更改父组件,使React理解这两个组件虽然相同,但它们在不同的位置,请重新渲染它们。
src\App.tsx

import { useState } from "react";
import "./App.css";
import Counter from "./counter";

function App() {
  const [changeShirts, setChangeShirts] = useState(false);
  return (
    <div>
      {changeShirts ? (
        <div>
          <span>Shirts counts: </span> <Counter />{" "}
        </div>
      ) : (
        <section>
          <span>Shoes counts: </span> <Counter />{" "}
        </section>
      )}
      <br />
      <button onClick={() => setChangeShirts((s) => !s)}>Switch</button>
    </div>
  );
}

export default App;

现在,如果我增加衬衫的数量,然后切换,你可以看到状态被重置了。因为当你切换时,整个应用程序重新渲染,React DOM会查看这个状态。如果我们在这个状态中,它会获取这个div。当你切换到另一个状态时,它会查看父组件并期待div,但它看到的是section。它会说,“嘿,我们在不同的树中,所以让我们重新渲染计数器。”这就是为什么在这种情况下它会起作用。
图片
图片
图片

使用 key

通过使用键,你可以确定一个组件与其他实际相同的组件是独特的。通过在这里给一个键,比如说“shirt”,我们可以说这个键是“shirt”,而另一个键是“shoes”。

如果你保存它,现在增加衬衫的数量,切换,你会看到它现在重新渲染了。因为对于React来说,每次它查看计数器时,它会检查键。如果计数器的键是“shirt”,每当你切换时,它会说,“计数器还是那个计数器,但因为它有另一个键,我要重新渲染它。”所以这就是我们根据键区分组件的方法。
src\App.tsx

import { useState } from "react";
import "./App.css";
import Counter from "./counter";

function App() {
  const [changeShirts, setChangeShirts] = useState(false);
  return (
    <div>
      {changeShirts ? (
        <>
          <span>Shirts counts: </span> <Counter key="shirts" />{" "}
        </>
      ) : (
        <>
          <span>Shoes counts: </span> <Counter key="shoes" />{" "}
        </>
      )}
      <br />
      <button onClick={() => setChangeShirts((s) => !s)}>Switch</button>
    </div>
  );
}

export default App;

图片
切换后重新渲染,状态归0
图片
再切换重新渲染,状态归0,因为没有持久化状态
图片

如果你想知道为什么每次我们在计数器组件之间切换时,这个计数器会重置为零,那是因为我们没有在这里保留状态。每次React从头重新渲染一个组件时,它会将状态重置为默认值。如果你想保留状态,有很多方法可以做到,但现在我们不关注这个问题。

要记住的是,每当一个元素的键更改时,React会完全从头构建或重新渲染该组件或元素。这就是为什么我们在React中遍历或映射数组时必须使用键

5. Event Listeners 事件监听器

当你使用像onClick、onFocus这样的事件时,它们会使用冒泡阶段,这意味着它们从最初被点击的元素开始触发,即这个div,然后是其内部的警告,然后是第一个父元素,即这个div。

现在,在某些情况下,如果你想使用捕获阶段触发事件,也就是说从上到下触发,你只需要在事件末尾添加“Capture”。它可以是onClick、onFocus任何事件,只需添加“Capture”以获取捕获阶段。

现在保存,如果点击“显示消息”并点击,你会看到首先触发的是外部div,然后是内部div。这意味着在捕获阶段,事件从上到下开始触发,首先是父元素,然后一直到达实际被点击的元素,即这个div。

所以,基于你希望事件的触发顺序,你可以使用默认的冒泡事件,或者使用捕获事件。

值得一提的是,你可以对任何元素使用捕获事件,这不仅限于创建门户的示例。我只是使用创建门户的示例来演示这一点。你可以为每个元素添加onClick Capture,它们的行为将与此完全相同。
index.html

<!doctype html>
<html lang="zh-cn">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
  </head>
  <body>
    <div id="alert-holder"></div>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

src\App.css

.alert {
    position: absolute;
    top: 10px;
    left: 50%;
    translate: -50%;
    background-color: aquamarine;
    color: black;
    border-radius: 5px;
    padding: 10px;
    cursor: pointer;
  }
  

冒泡 onClick

由子组件开始触发事件,一直到父组件.
首先点击 Show Message 显示 Alert 组件。显示好父子组件。
图片

点击 Alert 组件,开始冒泡,首先打印 Alert 组件内的日志 inner div,
然后冒泡到Alert的父组件 ,打印 outer div

src\App.tsx

import { useState, ReactNode } from "react";
import { createPortal } from "react-dom";
import "./App.css";

interface AlertProps {
  children: ReactNode;
  onClose: () => void;
  show: boolean;
}

function App() {
  const [show, setShow] = useState(false);

  return (
    <div
      onClick={() => console.log("outer div")}
      style={{ position: "absolute", marginTop: "200px" }}
    >
      <h1>Other Content</h1>
      <button onClick={() => setShow(true)}>Show Message</button>
      <Alert show={show} onClose={() => setShow(false)}>
        A sample message to show.
        <br />
        Click it to close.
      </Alert>
    </div>
  );
}

const Alert: React.FC<AlertProps> = ({ children, onClose, show }) => {
  if (!show) return null;

  return createPortal(
    <div
      className="alert"
      onClick={() => {
        onClose();
        console.log("inner div");
      }}
    >
      {children}
    </div>,
    document.querySelector("#alert-holder") as Element
  );
};

export default App;

图片

捕获 onClickCapture

由父组件开始触发事件,一直到子组件.
首先点击 Show Message 显示 Alert 组件。显示好父子组件。
图片

点击 Alert 组件,开始捕获,首先打印 Alert 组件的父组件的日志 ``outer div, 然后到Alert组件 ,打印 inner div`

src\App.tsx

import { useState, ReactNode } from "react";
import { createPortal } from "react-dom";
import "./App.css";

interface AlertProps {
  children: ReactNode;
  onClose: () => void;
  show: boolean;
}

function App() {
  const [show, setShow] = useState(false);

  return (
    <div
      onClickCapture={() => console.log("outer div")}
      style={{ position: "absolute", marginTop: "200px" }}
    >
      <h1>Other Content</h1>
      <button onClick={() => setShow(true)}>Show Message</button>
      <Alert show={show} onClose={() => setShow(false)}>
        A sample message to show.
        <br />
        Click it to close.
      </Alert>
    </div>
  );
}

const Alert: React.FC<AlertProps> = ({ children, onClose, show }) => {
  if (!show) return null;

  return createPortal(
    <div
      className="alert"
      onClickCapture={() => {
        onClose();
        console.log("inner div");
      }}
    >
      {children}
    </div>,
    document.querySelector("#alert-holder") as Element
  );
};

export default App;

图片

6. useLayoutEffect

src\App.css

.tooltip {
    position: absolute;
    border: 2px solid black;
  }
  

普通的useEffect具有异步行为

src\App.tsx

import { useState, useRef, useEffect, MutableRefObject } from "react";
import "./App.css";

function App() {
  const [show, setShow] = useState<boolean>(false);
  const [top, setTop] = useState<number>(0);
  const buttonRef: MutableRefObject<HTMLButtonElement | null> = useRef(null);

  useEffect(() => {
    if (buttonRef.current === null || !show) {
      setTop(0);
      return;
    }
    const { bottom } = buttonRef.current.getBoundingClientRect();
    setTop(bottom + 30);
  }, [show]);

  const now = performance.now();
  while (now > performance.now() - 100) {
    // 模拟延迟操作
  }

  return (
    <>
      <button ref={buttonRef} onClick={() => setShow((s) => !s)}>
        Show
      </button>
      {show && (
        <div
          className="tooltip"
          style={{
            top: `${top}px`,
          }}
        >
          Some text ...
        </div>
      )}
    </>
  );
}

export default App;

如果你点击这个按钮,你会看到这里显示了文本。这段文本有一个样式,其中有一个top属性,这个top实际上是基于上面的某些useEffect计算出来的。

在这个useEffect内部,每当我们在这里切换文本的显示或隐藏时,就会进行计算。首先,我们检查文本是否不存在或者实际上是隐藏的,如果是这样,我们会将它的top设置为零,或者如果它正在显示在屏幕上,我们会将它的top设置为相对于这个按钮底部的某个值加上30像素。所以这个文本显示的位置是根据按钮的位置来计算的。

当我点击显示,你会看到它溢出按钮,有点从上到下移动,有100毫秒滞后。

点击按钮时,因为show为true,首先渲染文本,但位置在按钮上,因为此时top为0.
图片

100毫秒后,执行 useEffect,计算高度,文本位置下移到top 30处。
图片

这个问题的原因是,在这个useEffect中,我们看到文本的默认位置实际上是零。即使你隐藏它,它也会将其设置回零。

让我们回顾一下这里发生了什么。假设我们刚刚刷新了页面,这是组件的第一次渲染。首先,它运行这段代码,跳过useEffect,继续运行其他代码并渲染所有内容,包括文本,但默认的top位置是零。当我点击显示按钮时,这个show状态从false变为true。结果,这个useEffect监听到了show状态的变化,并将其触发。但在这个useEffect检测到show状态变化时,它不会先计算再渲染,而是告诉整个组件先渲染,然后再做任何计算。因此,应用程序或组件将运行这段代码,跳过useEffect,继续运行其他代码并渲染按钮,首先渲染的位置是默认的零。然后,当渲染完成后,useEffect会进行计算,设置新的top位置,即底部加上30像素,然后我们有第二次渲染,显示新位置的文本。

正如你所见,普通的useEffect具有异步行为。每次触发时,先告诉整个组件渲染,然后执行其任务。如果任务导致组件重新渲染,在某些情况下,包括我们这里的例子,会导致这种滞后。

也许并不是每次都会遇到这个问题,但有时当你在组件中有些元素需要基于useEffect中的计算结果进行渲染时,可能会导致不良的用户体验。

useLayoutEffect 会先执行任务然后再渲染组件和元素

为了解决这个问题,我们需要在渲染之前完成所有计算。所以我们希望在渲染之前计算底部位置,然后设置top位置。当top状态更新为新值加30像素后,useEffect会要求组件渲染,而不是先渲染再计算并更新状态然后再次渲染,这样就可以消除滞后。

为此,我们有一个叫做useLayoutEffect的钩子,它与useEffect完全相似。但我们说过,每当它触发时,首先执行其内部任务,然后如果需要渲染,组件将重新渲染。所以现在如果这样做,你会看到没有滞后。每次切换时,它会进行计算,然后我们看到组件根据位置渲染。

点击按钮,100毫秒后才渲染文本,虽然有延迟【卡顿】,但文本位置正确,没有闪烁。
src\App.tsx

import { useState, useRef, useEffect, MutableRefObject, useLayoutEffect } from "react";
import "./App.css";

function App() {
  const [show, setShow] = useState<boolean>(false);
  const [top, setTop] = useState<number>(0);
  const buttonRef: MutableRefObject<HTMLButtonElement | null> = useRef(null);

  useLayoutEffect(() => {
    if (buttonRef.current === null || !show) {
      setTop(0);
      return;
    }
    const { bottom } = buttonRef.current.getBoundingClientRect();
    setTop(bottom + 30);
  }, [show]);

  const now = performance.now();
  while (now > performance.now() - 100) {
    // 模拟延迟操作
  }

  return (
    <>
      <button ref={buttonRef} onClick={() => setShow((s) => !s)}>
        Show
      </button>
      {show && (
        <div
          className="tooltip"
          style={{
            top: `${top}px`,
          }}
        >
          Some text ...
        </div>
      )}
    </>
  );
}

export default App;

7. useId

图片

src\App.tsx

import Form from "./input";

function App() {
  return (
    <>
      <Form />
      <p>
        It is a long established fact that a reader will be distracted by the
        readable content of a page when looking at its layout.
      </p>
      <Form />
    </>
  );
}

export default App;

问题组件

src\input.tsx

import { useState } from "react";

const Form = () => {
  const [email, setEmail] = useState("");
  return (
    <div>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
    </div>
  );
};

export default Form;

但如果我点击这个 email,你会看到它仍然聚焦到另一个输入框,而不是与之对应的这个输入框。正如你猜测的那样,这个问题是因为我们硬编码了 ID 和 HTML for 属性。在 HTML 中,我们不能有重复的 ID。每当我们有多个元素使用相同的 ID,它总是会选择第一个具有该 ID 的元素。

初级解决方案 -随机 ID

const id = String(Math.random());

import { useState } from "react";

const Form = () => {
  const [email, setEmail] = useState("");
  const id = String(Math.random());
  return (
    <div>
      <label htmlFor={id}>Email</label>
      <input
        id={id}
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
    </div>
  );
};

export default Form;

问题:
我们点击它,可以聚焦,再点击另一个,也可以聚焦。如果你在检查看元素,你会看到这些 ID 是完全随机的。但这样做的问题在于,如果我们有服务器端渲染,这个页面在服务器上渲染并发送到客户端,每当客户端刷新时,它会有不同的 ID,这样就无法正常工作。因为服务器上生成的 ID 和客户端上新生成的 ID 不同,这会破坏应用程序的逻辑,导致混乱。所以,这在实际中并不可行。

useID

useID 钩子仅用于为 HTML 元素分配唯一标识符,不要用它来生成随机字符串,这样不安全。useID 的唯一用途是为 HTML 元素分配唯一标识符。

import { useId, useState } from "react";

const Form = () => {
  const [email, setEmail] = useState("");
  const id = useId();
  return (
    <div>
      <label htmlFor={`${id}-email`}>Email</label>
      <input
        id={`${id}-email`}
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <label htmlFor={`${id}-name`}>Name</label>
      <input id={`${id}-name`} />
    </div>
  );
};

export default Form;

8. useCallback As Ref

图片
实现在首次渲染时将焦点设置到这个输入框上。所以每次刷新页面,输入框都会自动获得焦点

错误示例

useEffect 会尝试运行这段代码,然而 inputRef.current 为 null,因为输入框尚未挂载。这样会导致一个错误,因为我们试图将焦点设置到一个不存在的元素上

src\App.tsx

import { useCallback, useEffect, useRef, useState, RefObject } from "react";

function App() {
  const [show, setShow] = useState<boolean>(false);
  const inputRef: React.MutableRefObject<HTMLInputElement | null> =
    useRef(null);

  useEffect(() => {
    inputRef.current.focus();
  }, []);

  return (
    <>
      <button onClick={() => setShow((s) => !s)}>Switch</button>
      {show && <input type="text" ref={inputRef} />}
    </>
  );
}

export default App;

使用 useCallback 而不是 useRef

这个问题的解决方案是,使用 useCallback 而不是 useRef。我们可以从顶部导入 useCallback。

当你传递一个 useCallback 给一个元素时,该元素会被传递给 useCallback 的第一个参数。因此,我们可以在 useCallback 中访问这个输入框,并对其执行任何操作。比如,我们可以设置输入框的焦点。因为 useCallback 类似于 useEffect,你需要传递一个依赖数组。让我们保存并切换按钮,你会看到输入框自动获得焦点。

刷新页面后,错误消失了。当点击切换按钮时,输入框被渲染并显示出来。useCallback 会在输入框渲染后立即执行并设置焦点。如果输入框被销毁,我们会遇到一个错误,因为 useCallback 会再次运行。因此,我们需要在回调函数中检查输入框是否为 null。如果为 null,直接返回,不执行任何操作。

现在,当你切换按钮时,输入框可以正确聚焦且没有错误。使用 useCallback 的想法是,当你想在元素渲染到实际 DOM 后执行某个操作时,可以传递一个 useCallback 给该元素,并在回调中执行操作。

需要注意的是,useCallback 传递的引用并不是实际的 useRef 钩子对象。你不能对其使用 inputRef.current。如果你需要一个实际的引用,可以创建一个新的 useRef 并手动设置它的 current 属性。这样你可以同时拥有一个实际的引用和一个自定义的引用。

import { useCallback, useEffect, useRef, useState, RefObject } from "react";

function App() {
  const [show, setShow] = useState<boolean>(false);
  const realInputRef: React.MutableRefObject<HTMLInputElement | null> =
    useRef(null);

  const inputRef = useCallback((input: HTMLInputElement | null) => {
    realInputRef.current = input;
    if (input === null) return;
    input.focus();
  }, []);

  return (
    <>
      <button onClick={() => setShow((s) => !s)}>Switch</button>
      {show && <input type="text" ref={inputRef} />}
    </>
  );
}

export default App;

要理解“如果输入框被销毁,我们会遇到一个错误,因为 useCallback 会再次运行”这句话,我们需要深入了解 React 中 useCallback 钩子的工作原理以及组件的生命周期。

组件生命周期与 useCallback

当 React 组件重新渲染时,所有在 JSX 中定义的回调函数(例如 ref 属性)都会再次运行。这意味着每次组件更新时,useCallback 钩子定义的回调函数也会重新执行。让我们看看这是如何与组件的挂载和卸载相关的。

useCallback 与 ref

在你的代码中,你定义了一个 useCallback 钩子来创建 inputRef

const inputRef = useCallback((input: HTMLInputElement | null) => {
  realInputRef.current = input;
  if (input === null) return;
  input.focus();
}, []);

这个回调函数会在以下两种情况下运行:

  1. 组件挂载时:当 input 元素第一次被添加到 DOM 中时,React 会调用这个回调函数,并将 input 元素作为参数传递。
  2. 组件卸载时:当 input 元素从 DOM 中移除时,React 会再次调用这个回调函数,并传递 null 作为参数。

销毁时的错误

如果不做任何检查,当 input 元素被销毁时(即 inputRef 被设置为 null),尝试访问或操作 input 会导致错误。这是因为此时 input 已经不存在。

在你的代码中,你通过以下方式防止了这种错误:

if (input === null) return;

这确保了当 input 被销毁时(即 inputnull),回调函数会立即返回,而不是尝试访问 input 的属性或方法,如 input.focus()

代码示例

让我们再看看完整的代码,并理解其工作原理:

import { useCallback, useEffect, useRef, useState, RefObject } from "react";

function App() {
  const [show, setShow] = useState<boolean>(false);
  const realInputRef: React.MutableRefObject<HTMLInputElement | null> = useRef(null);

  const inputRef = useCallback((input: HTMLInputElement | null) => {
    realInputRef.current = input;
    if (input === null) return; // 防止访问已销毁的元素
    input.focus();
  }, []);

  return (
    <>
      <button onClick={() => setShow((s) => !s)}>Switch</button>
      {show && <input type="text" ref={inputRef} />}
    </>
  );
}

export default App;

解释

  1. 初始渲染

    • show 状态为 false,不渲染 input 元素。
    • 按钮点击时,show 状态切换为 trueinput 元素被添加到 DOM 中,inputRef 回调函数执行,input 元素获得焦点。
  2. 状态切换

    • 再次点击按钮,show 状态切换为 falseinput 元素从 DOM 中移除,inputRef 回调函数执行,input 参数为 null
    • 回调函数检查 input 是否为 null,如果是,则立即返回,避免尝试访问已销毁的元素。

总结

input 元素被销毁时,React 会再次调用 useCallback 创建的回调函数,并传递 null 作为参数。通过在回调函数中检查 input 是否为 null,可以避免在 input 元素不存在时尝试访问其属性或方法,从而防止错误的发生。

9. useImperativeHandle

图片

原始 完全命令式

src\input.tsx

import { forwardRef, ForwardedRef, InputHTMLAttributes } from "react";

interface CustomInputProps extends InputHTMLAttributes<HTMLInputElement> {}

const CustomInput = (
  props: CustomInputProps,
  ref: ForwardedRef<HTMLInputElement>
) => {
  return <input {...props} ref={ref} className="text-input" />;
};

export const Input = forwardRef<HTMLInputElement, CustomInputProps>(
  CustomInput
);

src\App.tsx

import { useRef, FormEvent } from "react";
import "./App.css";
import { Input } from "./input";

function App() {
  const inputRef = useRef<HTMLInputElement>(null);

  function submitHandler(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    if (inputRef.current) {
      console.log(inputRef.current.value);
    }
  }

  return (
    <form onSubmit={submitHandler}>
      <Input ref={inputRef} />
      <button type="submit" className="button">
        Submit
      </button>
    </form>
  );
}

export default App;

在表单提交事件的上下文中,默认行为是提交表单并重新加载页面。使用 e.preventDefault() 可以防止这种默认行为,从而使你能够以编程方式处理表单提交,例如通过AJAX发送表单数据或在控制台中记录输入的值。

useImperativeHandle 暴漏必要的命令式

React 强调声明式编程,你应尽量避免命令式编程,但有时候必须使用。例如,当我们需要聚焦某个元素时,我们就需要使用 useRef。一个好的实践是,你可以限制传递给组件的 ref 的访问范围,比如你只希望它能够访问输入框的 focus 方法,而不是全部属性和方法。

为此,你可以使用一个名为 useImperativeHandle 的特定钩子。它可以帮助我们定义这个 ref 能够访问的具体内容。这个钩子接收两个参数,第一个是传递给组件的 ref,第二个是一个返回对象的函数,这个对象包含了我们希望暴露给外部的内容。

例如,我们可以定义一个简单的 sayHello 方法:

src\input.tsx

import {
  forwardRef,
  ForwardedRef,
  InputHTMLAttributes,
  useRef,
  useImperativeHandle,
} from "react";

interface CustomInputProps extends InputHTMLAttributes<HTMLInputElement> {}

interface CustomInputHandle {
  focus: () => void;
}

const CustomInput = (
  props: CustomInputProps,
  ref: ForwardedRef<CustomInputHandle>
) => {
  const inputRef = useRef<HTMLInputElement>(null);

  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current?.focus();
    },
  }));

  return <input {...props} ref={inputRef} className="text-input" />;
};

export const Input = forwardRef<CustomInputHandle, CustomInputProps>(
  CustomInput
);

传值

import {
  forwardRef,
  ForwardedRef,
  InputHTMLAttributes,
  useRef,
  useImperativeHandle,
  useState,
  ChangeEvent,
} from "react";

// 定义接口,扩展了 HTMLInputElement 的属性
interface CustomInputProps extends InputHTMLAttributes<HTMLInputElement> {}

// 定义接口,包含对外暴露的方法
interface CustomInputHandle {
  focus: () => void;
  value: string;
}

// 自定义输入组件,使用 forwardRef 转发 ref
const CustomInput = (
  props: CustomInputProps,
  ref: ForwardedRef<CustomInputHandle>
) => {
  const inputRef = useRef<HTMLInputElement>(null);
  const [value, setValue] = useState<string>("");

  // 处理输入变化的函数
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  };

  // 使用 useImperativeHandle 来定义暴露给父组件的属性和方法
  useImperativeHandle(
    ref,
    () => ({
      focus: () => {
        inputRef.current?.focus();
      },
      value,
    }),
    //如果是空依赖,那么只会渲染一次,每次获取的value都是最初的值,不会更新
    [value]
  );

  return (
    <input
      {...props}
      ref={inputRef}
      value={value}
      onChange={handleChange}
      className="text-input"
    />
  );
};

// 使用 forwardRef 包装组件,并导出
export const Input = forwardRef<CustomInputHandle, CustomInputProps>(
  CustomInput
);

10. useDeferredValue 延迟

它可以帮助我们将组件的渲染分为优先或即时更新和其他延迟或非即时更新。这些非即时更新将等待所有其他渲染完成后再获取状态并调用渲染。

消耗时间的重型组件

一个输入框,每当你输入某些内容时,它将使用 set keyword 更新这个状态。
但是我们有一个小组件,我称之为 heavy component。
这意味着它是一个在渲染时需要一些时间的组件。

在这里故意设置了一些延迟,好吧,仅用于教学目的。所以每次你在输入框中按键时,它将在 100 毫秒内渲染。
所以问题是,当我开始输入时,你会看到输入框中的值会有延迟更新。
这会导致输入卡顿。

关键词的变化和显示在输入框内之间有一个延迟。这是因为我们每次在键盘上按键时都试图渲染一个重的、慢的组件,这个 heavy component。

src\components\heavy-component.tsx

const HeavyComponent = ({ keyword }: { keyword: string }) => {
  const init = performance.now();
  while (init > performance.now() - 100) {
    //Slowing down the component on purpose.
  }
  return (
    <>
      <h2>I am a slow component</h2>
      {keyword}
    </>
  );
};

export default HeavyComponent;

src\App.tsx

import { useState } from "react";
import HeavyComponent from "./components/heavy-component";

function App() {
  const [keyword, setKeyword] = useState("");
  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <HeavyComponent keyword={keyword} />
    </>
  );
}

export default App;

React memo + useDeferredValue

memo 会监听传入的 props ,防止重复渲染。

react memo 本身并不能解决这个问题

src\components\heavy-component.tsx

import React from "react";

const Component = ({ keyword }: { keyword: string }) => {
  const init = performance.now();
  while (init > performance.now() - 100) {
    //Slowing down the component on purpose.
  }
  return (
    <>
      <h2>I am a slow component</h2>
      {keyword}
    </>
  );
};

export const HeavyComponent = React.memo(Component);

不再传递这个快速变化的关键词,当用户开始在输入框中输入时,

我们将传递它的一个延迟版本。称之为 deferred keyword.

延迟消失.

在幕后这个 heavy component 仍然很重。我们没有解决它。

但是我们已经在某种程度上断开了这个输入框的值与这里的 heavy component 的重渲染的联系,从而改善了我们应用程序的用户体验。

当我们使用这个 deferred value 时,究竟发生了什么?我们有一个称为渲染优先级的概念。当我们像这样说 set keyword 时,即将值传递给这个输入框,每当我们快速输入时,在几毫秒内,这个与 deferred value 无关的输入框的渲染将具有高优先级。所以渲染将在这个输入框上进行。但与这个延迟状态相关的组件,即我们这个 heavy component,将等到所有这些高优先级的渲染完成,然后才会渲染自己。所以每当我在这里快速输入时,比方说我们有十次重渲染。这个使用 hook 的 deferred keyword 将等到所有与这个快速输入字符串相关的渲染完成。然后它会抓取最终值,即这里的这个值,并将其传递给这里的 heavy component。所以我们会有多次这个组件的快速渲染,即这里的这个输入框。最后,当没有其他渲染时,这个 use deferred value 将抓取状态的值

在输入结束后,才会渲染重型组件。

src\App.tsx

import { useState, useDeferredValue } from "react";
import { HeavyComponent } from "./components/heavy-component";

function App() {
  const [keyword, setKeyword] = useState("");
  const deferredKeyword = useDeferredValue(keyword);

  console.log('keyword:',keyword)
  console.log('deferredKeyword',deferredKeyword)

  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <HeavyComponent keyword={deferredKeyword} />
    </>
  );
}

export default App;

图片
你可以将其用于不同的示例,比如 suspense 例如,以及拥有一个回退组件,诸如此类

11. useTransition

类似 useDeferredValue,但适用于不同的场景。

npm install styled-components

耗时组件

图片
切换时,部分组件会执行耗时操作,这会导致页面冻结,无法点击其他按钮。
点击这个书评时,需要一些时间来渲染这些内容。你可以假设我们有一个评论列表,例如,我们要从服务器获取这些评论,并且它们很多,可能需要一些时间。如果你看看这个特定的组件,即评论组件,你会看到我们有一个包含300个成员的数组。非常简单,我们试图遍历它们并渲染一个非常简单的组件,每个评论有三毫秒的延迟。这就是为什么当你点击这个书评时,需要一些时间来显示这些内容。

因为当前当我们在这里时,当你点击这个书评,它将是一个即时更新。它会通过任何其他渲染或调用渲染,直到它完成渲染为止。在这里点击这个书评时,如果我试图点击任何其他按钮,在它获取或渲染自己时,我无法点击。

假设我点击这个书评,你会看到所有这些按钮都冻结了,因为整个应用程序都在等待这个渲染完成

src\components\cover.tsx

import { CoverContainer, Emoji } from "./styled-elements";

const Cover = () => {
  return (
    <CoverContainer>
      <Emoji role="img" aria-label="Book Cover Emoji">
        📚
      </Emoji>
    </CoverContainer>
  );
};

export default Cover;

src\components\reviews.tsx

import React from "react";
import { ReviewsContainer } from "./styled-elements";

const Reviews = () => {
  return (
    <ReviewsContainer>
      <ul>
        {Array(300)
          .fill("")
          .map((_, i) => (
            <Review key={i} index={i} />
          ))}
      </ul>
    </ReviewsContainer>
  );
};

const Review = ({ index }: { index: number }) => {
  const init = performance.now();
  while (init > performance.now() - 3) {
    // Fake slow down.
  }
  return <li>Review #{index}</li>;
};

export default Reviews;

src\components\writer.tsx

import { WriterContainer } from "./styled-elements";

const Writer = () => {
  return <WriterContainer>Codelicks Academy</WriterContainer>;
};

export default Writer;

src\components\styled-elements.tsx

import styled from "styled-components";

export const StyledButton = styled.button`
  background-color: #f1f1f1;
  border: 1px solid #ccc;
  padding: 10px 15px;
  margin: 0 5px;
  cursor: pointer;
  border-radius: 5px;
  font-size: 16px;
  transition: background-color 0.3s ease;

  &:hover {
    background-color: #ddd;
  }

  &:focus {
    outline: none;
    border-color: #007bff;
  }
`;

export const ReviewsContainer = styled.div`
  ul {
    list-style-type: none;
    padding: 0;
  }

  li {
    border-bottom: 1px solid #ccc;
    padding: 10px;
    font-size: 1.2em;
    color: #333;
  }
`;

export const WriterContainer = styled.div`
  font-size: 1.5em;
  font-weight: bold;
  color: #333;
  text-align: center;
  margin: 20px 0;
`;

export const CoverContainer = styled.div`
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  font-size: 3em;
`;

export const Emoji = styled.span`
  font-size: 50px;
`;

src\App.tsx

import { useState } from "react";
import Cover from "./components/cover";
import Reviews from "./components/reviews";
import Writer from "./components/writer";
import { StyledButton } from "./components/styled-elements";

function App() {
  const [section, setSection] = useState("Cover");

  const sectionHandler = (sec: string) => {
    setSection(sec);
  };
  return (
    <>
      <StyledButton onClick={() => sectionHandler("Cover")}>
        Book Cover
      </StyledButton>
      <StyledButton onClick={() => sectionHandler("Reviews")}>
        Book Reviews
      </StyledButton>
      <StyledButton onClick={() => sectionHandler("Writer")}>
        Book's Writer
      </StyledButton>

      {section === "Cover" ? (
        <Cover />
      ) : section === "Reviews" ? (
        <Reviews />
      ) : (
        <Writer />
      )}
    </>
  );
}

export default App;

useTransitio

所以我想以某种方式使用一个 hook,这样当我在这个封面上并点击评论时,如果我改变主意,我可以立即转到这个作者,而不必等待它完成然后再回来。

解决这个问题的方法是,我想告诉 React 这个 set section 函数实际上是在更新 section 状态的值,在这种情况下是封面,它会显示封面,或者是评论时显示评论,等等。非常基本,非常简单。我想告诉 React,这个 set section 函数应该是可覆盖的。所以如果我说它将是评论,例如,我点击这个评论,但我改变主意,想点击这个封面,我应该能够覆盖之前的 set section,替换为封面。

useTransitio 返回一个数组或元组,包含两个值 isPending 和 start。这个是惯例,你可以给它起任何其他名字。

startTransition 需要一个工厂函数,你可以传递任何函数,特别是状态操作符,例如这里的 set section。它会将其标记为非即时更新,所以你可以在调用时简单地覆盖它。

通过在这里这样做,假设我点击书评,但我改变主意,可以转到作者,它不会冻结整个应用程序。因为现在感谢这个 useTransition hook,当我点击这个书评时调用的 set section,可以被覆盖并变成书作者。现在它被替换为另一个。

useTransition 和 useDeferred 的区别在于,如果你只是有一个快速变化的状态值,并且你想延迟读取那个值,你可以使用 useDeferred。但是如果你想延迟状态的更新,而不是读取状态,你可以使用 useTransition。

src\App.tsx

import { useState, useTransition } from "react";
import Cover from "./components/cover";
import Reviews from "./components/reviews";
import Writer from "./components/writer";
import { StyledButton } from "./components/styled-elements";

function App() {
  const [section, setSection] = useState("Cover");

  const sectionHandler = (sec: string) => {
    setSection(sec);
  };
  return (
    <>
      <Button onClick={() => sectionHandler("Cover")}>Cover</Button>
      <Button onClick={() => sectionHandler("Reviews")}>Book Reviews</Button>
      <Button onClick={() => sectionHandler("Writer")}>Book's Writer</Button>

      {section === "Cover" ? (
        <Cover />
      ) : section === "Reviews" ? (
        <Reviews />
      ) : (
        <Writer />
      )}
    </>
  );
}

const Button = ({ onClick, ...props }) => {
  const [isPending, startTransition] = useTransition();

  return (
    <StyledButton
      onClick={() => {
        startTransition(() => {
          onClick();
        });
      }}
      {...props}
    />
  );
};

export default App;

有 isPending,你可以利用它来提供更好的用户体验。例如,当 set section 正在等待时,你可以显示一段文字,比如“我正在加载”或“获取中”。如果你点击它,你会看到“我正在加载”,完成后它会消失。

import { useState, useTransition } from "react";
import Cover from "./components/cover";
import Reviews from "./components/reviews";
import Writer from "./components/writer";
import { StyledButton } from "./components/styled-elements";

function App() {
  const [section, setSection] = useState("Cover");

  const sectionHandler = (sec: string) => {
    setSection(sec);
  };
  return (
    <>
      <Button onClick={() => sectionHandler("Cover")}>Cover</Button>
      <Button onClick={() => sectionHandler("Reviews")}>Book Reviews</Button>
      <Button onClick={() => sectionHandler("Writer")}>Book's Writer</Button>

      {section === "Cover" ? (
        <Cover />
      ) : section === "Reviews" ? (
        <Reviews />
      ) : (
        <Writer />
      )}
    </>
  );
}

const Button = ({ onClick, ...props }) => {
  const [isPending, startTransition] = useTransition();

  return (
    <>
      <StyledButton
        onClick={() => {
          startTransition(() => {
            onClick();
          });
        }}
        {...props}
      />
      {isPending && "Loading..."}
    </>
  );
};

export default App;

不能在startTransition使用 setTimeout

状态函数需要直接在 startTransition 内部调用。所以如果我们在这里有一个 setTimeout,并且在这个超时内部调用 set section,比如给它一个十毫秒的延迟。现在,如果你保存这个并点击书评,你会看到它再次冻结了整个应用程序,因为 startTransition 无法访问这个 set section。所以你的状态函数需要直接在 startTransition 内部调用。

const Button = ({ onClick, ...props }) => {
  const [isPending, startTransition] = useTransition();

  return (
    <StyledButton
      onClick={() => {
        startTransition(() => {
          setTimeout(() => {
            onClick();
          }, 0);
        });
      }}
      {...props}
    />
  );
};

需要使用 setTimeout 的话,你可以将整个 startTransition 函数包装在里面,这样回到应用程序并点击它,你会看到它不会冻结。

这些代码段会立即从上到下运行,不像状态,有点异步行为。我的意思是,如果你在这里做一个 console log,比如在 useTransition 或 startTransition 之前和之后这样做,我们在 startTransition 内部说 console log 比如在这里。如果我打开 inspect 并点击这个书作者,你会看到在 startTransition 之前,内部和之后,完全按照顺序立即执行,没有任何延迟。唯一的延迟发生在这个 set section 上,在需要时延迟更新状态。

图片
图片

12. Async React Router 异步 React 路由器

pnpm i styled-components react-router -S

在 React Router 6 中使用 suspense 组件和功能

原始

src\main.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import Nav from "./components/nav";
import { mainRoute } from "./components/main";
import { booksRoute } from "./components/books";
import Club from "./components/club";

const router = createBrowserRouter([
  {
    element: <Nav />,
    children: [
      { index: true, ...mainRoute },
      { path: "/books", ...booksRoute },
      { path: "/club", element: <Club /> },
    ],
  },
]);

const root = ReactDOM.createRoot(document.getElementById("root")!);
root.render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

src\util\delay.ts

const delay = <T>(data: T, interval: number): Promise<T> => {
  return new Promise((res) => {
    setTimeout(() => {
      res(data);
    }, interval);
  });
};

export default delay;

src\components\books.tsx

import { useLoaderData } from "react-router";
import delay from "../util/delay";
import { MainHeading } from "./styled-elements";

interface BooksLoaderData {
  bookCount: number;
  authors: string;
}

const Books = () => {
  const { bookCount, authors } = useLoaderData() as BooksLoaderData;

  return (
    <div>
      <MainHeading>Books</MainHeading>
      <p>
        <strong>Available Books: </strong>
        {bookCount}
      </p>
      <p>
        <strong>Authors:</strong> {authors}
      </p>
    </div>
  );
};

async function loader() {
  const bookCount = delay(10, 1000);
  const authors = delay("Codelicks", 2000);

  return {
    bookCount: await bookCount,
    authors: await authors,
  };
}

export const booksRoute = { element: <Books />, loader };

src\components\club.tsx

import { MainHeading } from "./styled-elements";

const Club = () => {
  return <MainHeading>Club</MainHeading>;
};

export default Club;

src\components\main.tsx

import { useLoaderData } from "react-router-dom";
import delay from "../util/delay";
import { MainContainer, MainHeading } from "./styled-elements";

const Main = () => {
  const data = useLoaderData() as string;

  return (
    <MainContainer>
      <MainHeading>Main - {data}</MainHeading>
    </MainContainer>
  );
};

async function loader() {
  return await delay("Fetched Data", 1000);
}

export const mainRoute = { element: <Main />, loader };

src\components\nav.tsx

import { Outlet, useNavigation } from "react-router";
import { LoadingMessage, NavContainer, NavLink } from "./styled-elements";

const Nav = () => {
  const { state } = useNavigation();

  return (
    <NavContainer>
      <NavLink to={"/"}>Main</NavLink>
      <NavLink to={"/books"}>Books</NavLink>
      <NavLink to={"/club"}>Club</NavLink>
      {state === "loading" && <LoadingMessage>Loading...</LoadingMessage>}
      <Outlet />
    </NavContainer>
  );
};

export default Nav;

src\components\styled-elements.tsx

import { Link } from "react-router-dom";
import styled from "styled-components";

export const NavContainer = styled.div`
  background-color: #333;
  padding: 10px;
  color: white;
  text-align: center;
`;

export const NavLink = styled(Link)`
  text-decoration: none;
  color: white;
  font-size: 18px;
  margin-right: 10px;

  &:hover {
    text-decoration: underline;
  }
`;

export const LoadingMessage = styled.div`
  color: #ffcc00;
  font-size: 16px;
  margin-top: 10px;
`;

export const MainContainer = styled.div`
  padding: 20px;
  text-align: center;
`;

export const MainHeading = styled.h1`
  color: #aff003;
  font-size: 28px;
`;

图片

当你访问 books 页面时,会显示加载文本,加载完成后显示实际数据。
index 设置了主路由,
club 设置了 element club
并使用扩展运算符将 loader 函数传递给主路由和 books 路由。

定义了一个延迟函数,它接收数据和一个以毫秒为单位的间隔,并返回一个在指定时间后解析的数据的 Promise。
这用于模拟从后端延迟获取数据,以展示我们将在示例应用程序中解决的问题。

books 组件与 main 组件非常相似,但会显示更多数据。
我们使用 useLoaderData 钩子获取 bookCountauthors 数据,并在加载完成后显示。
这些数据具有不同的延迟,如 bookCount 为一秒,authors 为两秒。
但是,当你点击 books 页面时,会在两秒后同时显示所有数据,而不是分别显示。
这是我们需要解决的第一个问题。

分离组件的延迟部分和静态部分

我们首先要解决的问题是,当你点击 main 页面时,会显示加载文本,加载完成后显示主页面和动态数据。
我们希望分离组件的延迟部分和静态部分,先显示静态部分,然后再显示延迟加载的数据。

为了解决这个问题,我们使用 React Router 提供的 defer 函数。我们在 loader 函数中返回一个 Promise,而不是实际数据。这样组件就会等待 Promise 解析。

const { promise } = useLoaderData();return defer({ promise: delay("Fetched Data", 1000) }); 中的 promise 名字必须相同.

Suspense 包裹的地方异步展示。
父级的 Main -- 同步展示。
src\components\main.tsx

import { Await, defer, useLoaderData } from "react-router-dom";
import delay from "../util/delay";
import { MainContainer, MainHeading } from "./styled-elements";
import { Suspense } from "react";

const Main = () => {
  const { promise } = useLoaderData();

  return (
    <MainContainer>
      <MainHeading>
        Main -
        <Suspense fallback="Fetching...">
          <Await resolve={promise}>
            {(data) => {
              return <strong>{data}</strong>;
            }}
          </Await>
        </Suspense>
      </MainHeading>
    </MainContainer>
  );
};

function loader() {
  return defer({ promise: delay("Fetched Data", 1000) });
}

export const mainRoute = { element: <Main />, loader };

使用 defer 解决 bookCount,authors同时展示的问题

src\components\books.tsx

import { Await, defer, useLoaderData } from "react-router";
import delay from "../util/delay";
import { MainHeading } from "./styled-elements";
import { Suspense } from "react";

const Books = () => {
  const { bookCountPromise, authorsPromise } = useLoaderData();

  return (
    <div>
      <MainHeading>Books</MainHeading>
      <p>
        <strong>Available Books: </strong>
        <Suspense fallback="Fetching...">
          <Await resolve={bookCountPromise}>
            {(data) => {
              return <strong>{data}</strong>;
            }}
          </Await>
        </Suspense>
      </p>
      <p>
        <strong>Authors:</strong>
        <Suspense fallback="Fetching...">
          <Await resolve={authorsPromise}>
            {(data) => {
              return <strong>{data}</strong>;
            }}
          </Await>
        </Suspense>
      </p>
    </div>
  );
};

function loader() {
  const bookCountPromise = delay(10, 1000);
  const authorsPromise = delay("Codelicks", 2000);

  return defer({
    bookCountPromise,
    authorsPromise,
  });
}

export const booksRoute = { element: <Books />, loader };

使用 useAsyncValue 获取异步数据,抽离渲染组件以优化

src\components\books.tsx

import { Await, defer, useAsyncValue, useLoaderData } from "react-router";
import delay from "../util/delay";
import { MainHeading } from "./styled-elements";
import { Suspense } from "react";

const Books = () => {
  const { bookCountPromise, authorsPromise } = useLoaderData();

  return (
    <div>
      <MainHeading>Books</MainHeading>
      <p>
        <strong>Available Books: </strong>
        <Suspense fallback="Fetching...">
          <Await resolve={bookCountPromise}>
            {(data) => {
              return <strong>{data}</strong>;
            }}
          </Await>
        </Suspense>
      </p>
      <p>
        <strong>Authors:</strong>
        <Suspense fallback="Fetching...">
          <Await resolve={authorsPromise}>
            <Authors />
          </Await>
        </Suspense>
      </p>
    </div>
  );
};

const Authors=()=>{
  const authors=useAsyncValue()
  return <strong>{authors}</strong>;
}

function loader() {
  const bookCountPromise = delay(10, 1000);
  const authorsPromise = delay("Codelicks", 2000);

  return defer({
    bookCountPromise,
    authorsPromise,
  });
}

export const booksRoute = { element: <Books />, loader };

懒加载路由组件

src\main.tsx
const Club = lazy(() => import("./components/club"));

import React, { lazy } from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import Nav from "./components/nav";
import { mainRoute } from "./components/main";
import { booksRoute } from "./components/books";

const Club = lazy(() => import("./components/club"));

const router = createBrowserRouter([
  {
    element: <Nav />,
    children: [
      { index: true, ...mainRoute },
      { path: "/books", ...booksRoute },
      { path: "/club", element: <Club /> },
    ],
  },
]);

const root = ReactDOM.createRoot(document.getElementById("root")!);
root.render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

懒加载布局的一部分

点击club时才加载该组件

模拟延迟
src\main.tsx

import React, { lazy } from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import Nav from "./components/nav";
import { mainRoute } from "./components/main";
import { booksRoute } from "./components/books";
import delay from "./util/delay";

//const Club = lazy(() => import("./components/club"));
const Club = lazy(() => delay(import("./components/club"), 1000));

const router = createBrowserRouter([
  {
    element: <Nav />,
    children: [
      { index: true, ...mainRoute },
      { path: "/books", ...booksRoute },
      { path: "/club", element: <Club /> },
    ],
  },
]);

const root = ReactDOM.createRoot(document.getElementById("root")!);
root.render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

懒加载布局
src\components\nav.tsx

import { Outlet, useNavigation } from "react-router";
import { LoadingMessage, NavContainer, NavLink } from "./styled-elements";
import { Suspense } from "react";

const Nav = () => {
  const { state } = useNavigation();

  return (
    <>
      <NavContainer>
        <NavLink to={"/"}>Main</NavLink>
        <NavLink to={"/books"}>Books</NavLink>
        <NavLink to={"/club"}>Club</NavLink>
        {state === "loading" && <LoadingMessage>Loading...</LoadingMessage>}
      </NavContainer>
      <Suspense fallback={<NavContainer>Loading...</NavContainer>}>
        <NavContainer>
          <Outlet />
        </NavContainer>
      </Suspense>
    </>
  );
};

export default Nav;

分离loader和Suspense

src\components\main.tsx

import { Await, defer, useLoaderData } from "react-router-dom";
import delay from "../util/delay";
import { MainContainer, MainHeading } from "./styled-elements";
import { Suspense } from "react";

const Main = () => {
  const { promise } = useLoaderData();

  return (
    <MainContainer>
      <MainHeading>
        Main -
        <Suspense fallback="Fetching...">
          <Await resolve={promise}>
            {(data) => {
              return <strong>{data}</strong>;
            }}
          </Await>
        </Suspense>
      </MainHeading>
    </MainContainer>
  );
};

export default Main;

src\components\main-loader.ts

import { defer } from "react-router";
import delay from "../util/delay";

export function loader() {
  return defer({ promise: delay("Fetched Data", 1000) });
}

src\main.tsx

import React, { lazy } from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import Nav from "./components/nav";
import { booksRoute } from "./components/books";
import delay from "./util/delay";
import { loader } from "./components/main-loader";

//const Club = lazy(() => import("./components/club"));
const Club = lazy(() => delay(import("./components/club"), 1000));
const Main = lazy(() => delay(import("./components/main"), 1000));

const router = createBrowserRouter([
  {
    element: <Nav />,
    children: [
      { index: true, loader: loader, element: <Main /> },
      { path: "/books", ...booksRoute },
      { path: "/club", element: <Club /> },
    ],
  },
]);

const root = ReactDOM.createRoot(document.getElementById("root")!);
root.render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

10. Clean Code Tips 清洁代码技巧 //todo 此章以后都需要实际验证

1. Using Element Prop 使用元素属性


问题一:调整按钮样式和链接标签

我们有一组按钮,可以通过传递自定义属性(props)来设置大小,例如:小号、大号、超大号等。这里还有一个孤立的链接标签(anchor tag)。如果我们想让它看起来像这些按钮之一,使用当前代码并不容易实现。

  1. 包裹链接标签
    我们可以简单地把这个链接标签包裹在现有的按钮组件中。但这样做不会给它正确的样式,而且这个按钮本身无法直接访问链接标签的属性,例如 ref 属性。

  2. 目标
    我们的目标是让按钮组件具备链接标签的属性,让按钮能够理解并处理 ref 属性,同时保持按钮的样式。


解决方案:使用 as 属性

为了解决这个问题,我们可以在按钮组件中使用 as 属性(有时称为 element 属性或 component 属性)。as 属性的作用是让你将按钮组件渲染为某个特定的组件,在本例中是链接标签。

步骤:

  1. 在代码中添加 as 属性

    • buttons.js 文件中导入模块化样式。
    • 这个函数式组件接收 sizeclassName 等默认值以及其他属性。
    • 默认情况是渲染一个 button 标签。
  2. 替换默认按钮为 as

    • 在代码中加入 as 属性,并赋予默认值“button”。
    • 如果没有传递任何值,我们希望它默认渲染为一个按钮。
    • 然后将按钮标签替换为 as 属性传入的值。
    function Button({ size, className, ...props }) {
        const { as: Component = 'button' } = props;
        return <Component className={`${className} ${size}`} {...props} />;
    }
  3. 传递组件标签
    现在,无论传递的是 a 标签还是其他标签,它都会被渲染在组件中。


验证结果

  • 检查页面
    保存代码后,回到页面并检查。现在你可以看到它已经是一个链接标签了,且可以点击并刷新页面。

  • 设置大小
    例如,如果我们希望按钮看起来像一个大号按钮,只需指定大小为 large 即可。这时它会继承模块化样式,并且拥有 as 组件的所有属性。


总结

这段代码演示了使用 as 属性的一种方式,让你既可以访问模块化样式,还可以继承其他组件的属性,而不是默认的元素。在本例中,我们将按钮切换为链接标签,并且能够使用按钮的样式。

这样,通过 as 属性,你可以为组件提供更大的灵活性,使其不仅具备原始的按钮样式,还能添加链接标签的功能。


2. Optimizing Context API 优化上下文API


欢迎回来

在本视频中,首先要提到的是我们将处理非常基础的 TypeScript 文件。如果你不熟悉 TypeScript,我会在其他模块中详细讲解。暂时,你可以跳过这里的类型定义部分,直接使用 JavaScript 进行操作。


简单的购物车应用程序概述

这个应用程序非常简单,可以理解为一个购物车计数器。在 app.tsx 文件中,我们创建了一个上下文 CartContext,包含了状态 countdispatch 函数。

  • 我们定义了状态类型为 numbercount,还有不同的 action 类型和简单的 reducer,使用 switch 语句切换不同的 dispatch 类型。
  • App 组件中通过 useReducer 使用了这个上下文。

使用 Context API

整个应用被 Context.Provider 包裹,包括 DisplayButtons 组件。默认值为 count: 0。为了简化使用上下文,我们定义了一个 useCartContext 钩子导出上下文。

  1. 如果上下文的值为 null,我们抛出错误:必须在 Context.Provider 内使用。
  2. 通过这个钩子,我们确保能获取到上下文对象而不会为空。

Display 组件中,我们导入 useCartContext 并解构出 state 进行展示。在 Buttons 组件中,我们只使用了 dispatch 进行计数的增加和减少。


分离逻辑和优化性能

为了增强代码的可管理性和性能,我们将执行以下两步:

  1. 分离逻辑:将上下文相关的代码移到一个单独的文件 CartContext.tsx 中,便于未来的迁移(如迁移到 Redux)。
  2. 性能优化:通过创建两个上下文,一个专用于状态(StateContext),另一个用于分发(DispatchContext),从而使只关心 dispatch 的组件不会因为状态变化而重新渲染。

重构上下文并创建 CartProvider

  • 将与上下文 API 相关的逻辑分离到 CartContext.tsx 文件中,并创建 CartProvider 组件,专门处理 useReducer 和提供上下文。
  • 使用 useStateContextuseDispatchContext 钩子分别访问状态和分发,以确保只有依赖状态的组件会在状态变化时重新渲染,而非所有组件。

最终效果与性能验证

在开发者工具中的 Profiler 中可以看到,按钮组件不再因为状态变化而重新渲染,这显著提高了性能。通过这种方法,我们将上下文拆分成两个独立的上下文,使得仅关注 dispatch 的组件不会因为状态的变化而重新渲染,从而达到优化性能的目的。


3. Less useEffects 减少使用useEffect

React 最酷的特性之一是 useEffect。不过,由于其强大功能,很多人过度使用它,将其用于不适合的地方,从而导致应用程序出现一些难以预料的错误行为。

本视频将讨论 useEffect 的使用场景、避免使用的情况等。我们可以参考 React 官方文档获取更多建议。


示例:商品页面

文档中有一个示例是关于商品页面的。这里的 useEffect 依赖于 product 值,并在用户点击“加入购物车”时触发,显示通知提示商品已添加到购物车。尽管代码可以正常工作,但存在两个问题:

  1. 性能问题
    当用户点击加入购物车时,组件渲染两次,但其实只需要一次。这是因为 useEffect 监听 product 的变化,从而导致重复渲染。

  2. 逻辑问题
    如果 product 值因其他原因变化,useEffect 也会被触发,导致错误的通知。

更好的解决方法是在点击事件处理程序中直接显示通知,而不是在 useEffect 中触发它,这样就能避免性能问题和错误行为。


不适合使用 useEffect 的情况

另一个常见的误用是,在 useEffect 中发送基于用户交互的请求(例如提交表单)。如果数据依赖于用户的输入或点击事件,不应使用 useEffect,因为它会导致多余的重新渲染。useEffect 更适合处理不依赖用户交互的操作,例如数据获取。


过度使用 useEffect 的例子

有时,开发者还会过度依赖 useEffect,形成嵌套调用链,例如在某个值变化时触发一个 useEffect,再在此基础上触发下一个,导致组件多次重新渲染。正确的做法是将逻辑集中在一个条件判断中,从而简化渲染次数,提升性能。


总结

总之,useEffect 应该用于监听非用户交互的变化,而不是直接依赖用户事件。通过减少使用 useEffect,我们可以避免一些常见的性能和逻辑问题。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

11. Scalable Project Architecture 可扩展项目架构

1. General Architecture 一般架构

在本章中,我们将讨论 React 项目的目录结构,具体来说是 src 目录结构。这个结构是相对主观的,您可能会根据项目的需求选择不同的结构。不过,我会展示一个常用的目录结构,并解释每个目录的用途。此结构可作为您大型项目的起点。


目录结构解释

API 目录

用于存放应用程序的 API 层,主要负责 API 请求和与服务器的通信。

Assets 目录

包含项目的资源文件,如字体和图片。通常在 fonts 目录下存放字体文件,而 images 目录下存放图片。

Components 目录

包含常用组件目录 common,其中存放整个应用程序中重复使用的组件,例如按钮和表单组件。与 common 文件夹分开的组件则属于特定功能组件,例如 newsletter

Config 目录

存放应用程序的运行时配置文件和第三方服务配置,例如 Firebase 配置。请注意不要将环境变量与配置混淆。

Constants 目录

用于存放整个应用中使用的常量变量。建议常量命名时使用大写,以区分其他变量。

Context 目录

包含任何全局级别的 Context 状态提供者,用于实现全局状态管理。

Helpers 目录

存放实用函数和可重用的小型功能,例如日期格式化、货币格式化等。

Hooks 目录

存放自定义可重用的 Hook。注意,紧密耦合到特定功能的 Hook 应该放在该功能的目录下,而不放在全局 hooks 目录中。

Intel(可选)目录

如果应用程序需要国际化支持,可以在此处添加 Intel 目录。该目录用于存放不同语言的内容或特定于区域的格式设置。

Layout 目录

存放页面布局组件。例如,如果应用程序有登录用户和未登录用户的不同布局,可以在此目录管理这些布局。

Services 目录

较大的应用程序中可能会有复杂的业务逻辑代码,建议将其提取到 Services 目录中,以便于管理。

Store 目录

存放与全局状态管理相关的文件。例如,Redux 或 Zustand 等状态管理工具的配置可以放在此处。

Styles 目录

用于存放全局样式、变量、主题样式和覆盖样式。

Types 目录

在 TypeScript 项目中,可以将任何全局和可共享的类型定义放在此处。

Views 目录

通常用于存放根组件。例如,如果有一个用户可以查看产品的页面,可以在此目录下创建 Products.js 文件。


这个结构是一个大型代码库的起点,可以根据需求进行扩展。在接下来的视频中,我们还会讨论路由组件,并提供一些管理和扩展项目结构的建议。

2. Route Components 路由组件

在本节中,我们将继续探讨目录结构的最佳实践,重点是如何组织 views 文件夹中的路由组件。我们以一个电子商务应用的管理员仪表盘为例,用户可以浏览产品、查看详情、更新和删除产品,涉及增删改查(CRUD)操作。对于这些操作的组件,应如何进行文件夹组织呢?


路由组件组织

首先,可能会想到将所有与产品相关的组件放在 views 文件夹中:

  • AddProduct.js
  • DeleteProduct.js
  • EditProduct.js
  • ProductList.js
  • ViewProduct.js

然而,随着项目变大,这种平铺的文件组织很快会变得难以维护。因此,我们可以将产品相关的文件组织到一个名为 products 的文件夹中:

views/
  ├── products/
      ├── AddProduct.js
      ├── DeleteProduct.js
      ├── EditProduct.js
      ├── ProductList.js
      └── ViewProduct.js

这样,每个功能模块都可以有自己独立的文件夹,使得文件层次更加清晰,易于维护。

配置嵌套路由

我们可以进一步优化路由配置,将与产品相关的路由作为 products 路径的子路由。例如:

import { Routes, Route } from 'react-router-dom';
import Products from './views/products';

function App() {
  return (
    <Routes>
      <Route path="products" element={<Products />}>
        <Route path="add" element={<AddProduct />} />
        <Route path="edit/:id" element={<EditProduct />} />
        <Route path="delete/:id" element={<DeleteProduct />} />
        <Route path=":id" element={<ViewProduct />} />
        <Route index element={<ProductList />} />
      </Route>
    </Routes>
  );
}

这种配置方式使得路由结构更加直观清晰,并且将相同功能模块下的所有路由归类到一起,方便管理和扩展。

访问嵌套路由

可以通过 LinkNavLink 创建与这些路由的链接。例如,如果您想访问 ViewProduct,可以这样:

<Link to="/products/2">查看产品详情</Link>

使用动态参数(如 :id)可以访问不同产品的详情页。这样,路由路径变得更具描述性,同时利于后续的组件组织与开发。


通过这种目录结构和路由组织,您可以在项目中轻松维护不同功能模块的文件,使代码更具可读性和扩展性。这种模块化的组织方式尤其适合大型应用开发,并且在后续开发中也更易于定位和调整。

3. Encapsulating Components and Logics 封装组件和逻辑

在上一节视频中,我们讨论了如何更好地管理和组织根组件,使其更易于维护。在本节中,我们将探讨如何处理根组件所需的其他相关组件和服务,以确保结构清晰,便于团队合作。


组织组件和服务

假设在 views 文件夹中有用于产品操作的根组件(如编辑和添加产品),其中两个组件都会使用一个相同的表单组件 ProductForm。为此,我们可以为这些根组件提供一些辅助函数和服务文件。传统做法可能会将这些文件分散到不同的目录中,例如:

  • 表单组件放在 components 文件夹
  • 工具函数放在 helpers 文件夹
  • 服务逻辑放在 services 文件夹

这可能导致以下目录结构:

src/
  ├── components/
      └── ProductForm.js
  ├── helpers/
      └── productFormUtils.js
  ├── services/
      └── productFormService.js
  └── views/
      ├── AddProduct.js
      ├── EditProduct.js
      └── DeleteProduct.js

对于小项目,这种结构尚可接受。然而,随着项目规模的增大,这种分散的文件结构会使项目管理和维护变得复杂。尤其是在团队合作中,这种分散的结构会导致开发人员难以快速定位与某一特定功能相关的所有文件。

采用功能模块化的组织方式

为了解决以上问题,我们可以使用功能模块化的结构,将相关组件、工具函数和服务文件集中放置在一个文件夹中。这样可以确保每个功能模块的文件集中在一起,使其更具可维护性和扩展性。具体步骤如下:

  1. 创建产品模块文件夹:在 views 文件夹中,创建一个名为 products 的子文件夹,将所有与产品功能相关的文件放在其中。

  2. 集中组件、工具函数和服务:将 ProductFormproductFormUtils.jsproductFormService.js 文件放在 products 文件夹中,并创建子文件夹以便于组织,例如:

src/
  └── views/
      └── products/
          ├── components/
              └── ProductForm.js
          ├── helpers/
              └── productFormUtils.js
          ├── services/
              └── productFormService.js
          ├── AddProduct.js
          ├── EditProduct.js
          └── DeleteProduct.js

使用模块化的优势

采用这种基于功能模块的组织方式有以下几个优点:

  • 提升代码可读性:将相关文件集中在一起,减少在多个文件夹中寻找文件的需要。
  • 便于维护和扩展:随着项目的增大,可以在特定模块中进一步拆分文件,而不会影响其他模块。
  • 团队协作更高效:每个团队成员可以专注于某一功能模块,减少对其他模块的依赖和影响。

通过这种方式,ProductForm 组件以及其依赖的工具函数和服务可以被轻松定位到一个目录中,而不是分散在整个项目中。


总结来说,基于功能模块的项目组织方式可以提高结构的一致性,使项目组件和文件更加封装,方便维护和扩展。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

12. API Layer and Async Operations API层和异步操作

1.Building an API Layer 构建API层

为了提高代码的可维护性和清晰度,我们可以在组件和后端服务器之间建立一个API层。通过这种方式,组件不需要直接调用后端服务器,而是通过API层与后端通信。以下是具体实现步骤:

创建API层

  1. 安装Axios库:我们将使用Axios,一个流行的基于Promise的库来进行API请求。

    npm install axios
  2. 建立基本的API文件
    src目录下创建api文件夹,并在其中创建api.js文件。这将是API层的核心文件,负责与后端进行HTTP请求。

    // src/api/api.js
    import axios from 'axios';
    
    // 配置 Axios 实例
    const axiosInstance = axios.create({
        baseURL: process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : 'https://production.server.com',
    });
    
    const api = {
        get: (url, config = {}) => axiosInstance.get(url, config),
        post: (url, data, config = {}) => axiosInstance.post(url, data, config),
        put: (url, data, config = {}) => axiosInstance.put(url, data, config),
        delete: (url, config = {}) => axiosInstance.delete(url, config),
        patch: (url, data, config = {}) => axiosInstance.patch(url, data, config),
    };
    
    export default api;

为特定功能创建API文件

对于不同的功能(例如用户、产品等),我们可以为每个功能创建单独的API文件,使代码更具模块化。以下是一个用户API文件的示例:

// src/api/userApi.js
import api from './api';

const userApi = {
    fetchUsers: () => api.get('/users'),
};

export default userApi;

创建组件以使用API文件

接下来,我们将创建一个用户列表组件,使用自定义钩子来处理数据获取逻辑:

// src/components/Users.js
import React, { useEffect, useState } from 'react';
import userApi from '../api/userApi';

const useFetchUsers = () => {
    const [users, setUsers] = useState([]);

    const initFetchUsers = async () => {
        const response = await userApi.fetchUsers();
        setUsers(response.data);
    };

    return { users, initFetchUsers };
};

const Users = () => {
    const { users, initFetchUsers } = useFetchUsers();

    useEffect(() => {
        initFetchUsers();
    }, []);

    return (
        <div>
            <button onClick={initFetchUsers}>Fetch Users</button>
            <ul>
                {users.map((user) => (
                    <li key={user.id}>{user.name} - {user.email}</li>
                ))}
            </ul>
        </div>
    );
};

export default Users;

在应用中使用组件

Users组件导入并在应用主文件中使用:

// src/App.js
import React from 'react';
import Users from './components/Users';

function App() {
    return (
        <div className="App">
            <h1>User List</h1>
            <Users />
        </div>
    );
}

export default App;

这种API层结构的主要优点包括:

  • 减少代码冗余:可以集中管理HTTP请求和错误处理逻辑。
  • 模块化代码:API层通过功能模块进行拆分,便于维护和扩展。
  • 更好的测试性:API逻辑集中在一个位置,便于单元测试和集成测试。

通过这种方式,我们将API请求封装在特定的功能模块中,使得组件代码更简洁,项目结构更清晰。

2. API States API状态

在应用程序中处理异步 API 调用时,通常会遇到不同的状态——例如加载中、成功、失败等,以便在用户交互时提供反馈。为了编写更简洁的代码和提升用户体验,可以采用更加简化的方法来管理这些状态。以下是高效实现这些状态管理的步骤:

实现步骤

1. 定义 API 状态

可以使用一个单一的 status 状态变量来表示多个状态(例如 IDLEPENDINGSUCCESSERROR),而不是管理多个状态变量。定义好每个状态的常量,将初始状态设置为 IDLE

const API_STATUS = {
  IDLE: 'idle',
  PENDING: 'pending',
  SUCCESS: 'success',
  ERROR: 'error',
};

2. 创建 useFetchUsers Hook

设置一个自定义 hook useFetchUsers,用于管理 API 调用并处理不同的状态。这个 hook 包含:

  • 一个 status 状态变量来跟踪请求状态。
  • 一个 error 状态变量来捕获任何请求失败的信息。
  • 一个 users 状态变量来存储获取的数据。

以下是构建该 hook 的方法:

import { useState } from 'react';
import userApi from '../api/userApi';

const useFetchUsers = () => {
    const [status, setStatus] = useState(API_STATUS.IDLE);
    const [users, setUsers] = useState([]);
    const [error, setError] = useState(null);

    const initFetchUsers = async () => {
        setStatus(API_STATUS.PENDING);
        try {
            const response = await userApi.fetchUsers();
            setUsers(response.data);
            setStatus(API_STATUS.SUCCESS);
        } catch (err) {
            setError(err.message);
            setStatus(API_STATUS.ERROR);
        }
    };

    return { status, users, error, initFetchUsers };
};
export default useFetchUsers;

3. 更新 Users 组件

在组件中使用 status 状态来管理显示的内容。例如:

  • 数据获取时显示加载消息。
  • 请求失败时显示错误消息。
  • 请求成功时显示数据。
import React, { useEffect } from 'react';
import useFetchUsers from '../hooks/useFetchUsers';

const Users = () => {
    const { status, users, error, initFetchUsers } = useFetchUsers();

    useEffect(() => {
        initFetchUsers();
    }, []);

    if (status === API_STATUS.PENDING) return <p>加载中...</p>;
    if (status === API_STATUS.ERROR) return <p>错误: {error}</p>;
    if (status === API_STATUS.IDLE) return <p>欢迎访问我的网站!</p>;

    return (
        <div>
            <button onClick={initFetchUsers}>获取用户</button>
            <ul>
                {users.map((user) => (
                    <li key={user.id}>{user.name} - {user.email}</li>
                ))}
            </ul>
        </div>
    );
};

export default Users;

4. 使用异步辅助函数

为避免在每个 hook 中重复使用 try-catch,可以创建一个辅助函数,用于统一处理异步操作:

// src/helpers/withAsync.js
export const withAsync = async (asyncFunction) => {
    try {
        const response = await asyncFunction();
        return { response, error: null };
    } catch (error) {
        return { response: null, error };
    }
};

5. 修改 Hook 以使用 withAsync

您可以将 withAsync 集成到 hook 中,以简化异步处理:

import { withAsync } from '../helpers/withAsync';
import userApi from '../api/userApi';
import { useState } from 'react';

const useFetchUsers = () => {
    const [status, setStatus] = useState(API_STATUS.IDLE);
    const [users, setUsers] = useState([]);
    const [error, setError] = useState(null);

    const initFetchUsers = async () => {
        setStatus(API_STATUS.PENDING);
        const { response, error } = await withAsync(userApi.fetchUsers);

        if (error) {
            setError(error.message);
            setStatus(API_STATUS.ERROR);
        } else {
            setUsers(response.data);
            setStatus(API_STATUS.SUCCESS);
        }
    };

    return { status, users, error, initFetchUsers };
};
export default useFetchUsers;

这种方法的优点

  • 单一状态源:使用一个 status 变量来代替多个状态变量(如 isLoadingerror),简化了状态管理。
  • 减少冗余withAsync 辅助函数可以避免在每个 API hook 中重复 try-catch 代码。
  • 更好的可读性:将状态和逻辑集中处理,使组件更简洁、易于维护。

这种方法为您的 React 应用程序中的异步操作提供了更简洁且可扩展的管理方式。

3. Enhancing The API States 增强API状态

在上一个视频中提到过,为了避免使用字符串直接赋值而带来的潜在拼写错误问题,我们将状态值改为常量。使用字符串有时会导致命名不一致,例如用 successerror 或者 resolvedrejected。因此,定义一组常量以确保一致性会更易于维护。

1. 创建常量

首先,为 API 状态创建一个专门的常量文件,并将其置于 constants 文件夹中,以便所有状态都可以在同一地方进行管理:

// constants/apiStatus.js
export const API_STATUS = {
    IDLE: 'idle',
    PENDING: 'pending',
    SUCCESS: 'success',
    ERROR: 'error',
};

通过定义这些常量,我们可以在整个代码库中保持一致的命名,并且避免手动输入时的拼写错误。

2. 使用常量代替字符串

在使用这些常量时,可以通过引入这些状态常量并替代原始的字符串,使代码更加易于阅读:

import { API_STATUS } from '../constants/apiStatus';

// 例如在你的组件中使用
if (fetchStatus === API_STATUS.PENDING) {
    console.log('正在加载中...');
}

3. 创建一个自定义 Hook 用于检查状态

为了进一步简化对状态的管理,可以创建一个自定义的 Hook 来管理 API 状态,从而避免在多个地方重复 if 判断逻辑:

// hooks/useApiStatus.js
import { API_STATUS } from '../constants/apiStatus';
import { useMemo } from 'react';

const useApiStatus = (status) => {
    return useMemo(() => ({
        isIdle: status === API_STATUS.IDLE,
        isPending: status === API_STATUS.PENDING,
        isSuccess: status === API_STATUS.SUCCESS,
        isError: status === API_STATUS.ERROR,
    }), [status]);
};

export default useApiStatus;

此 Hook 接受一个当前状态作为参数,并返回包含各个状态布尔值的对象。这样你就可以在组件中轻松地使用这些状态:

import useApiStatus from '../hooks/useApiStatus';

const MyComponent = ({ fetchStatus }) => {
    const { isIdle, isPending, isSuccess, isError } = useApiStatus(fetchStatus);

    return (
        <div>
            {isPending && <p>加载中...</p>}
            {isError && <p>请求出错了!</p>}
            {isSuccess && <p>数据加载成功!</p>}
        </div>
    );
};

4. 使用 useMemo 优化性能

useApiStatus 中使用 useMemo,可以防止每次重新渲染时重新计算状态对象,从而提高性能。状态只有在 fetchStatus 变化时才会重新计算。

5. 添加一层状态管理的最终优化

如果需要进一步优化状态管理,可以在此基础上添加更多的定制化 Hook,以便在整个项目中重复使用这些状态管理逻辑。

通过这些改进,可以确保状态管理的一致性,同时使代码更加清晰和模块化。这样做也使得状态管理更简单、更易于维护。

4. Avoiding Flickering Loaders 避免闪烁的加载器

好的,那么在本视频中,我们将讨论避免加载器闪烁的问题。加载器闪烁是指加载器显示时间过短,使得用户体验不佳。在某些情况下,服务器响应很快,加载器只会显示不到半秒钟,这种闪烁会让用户感到不舒服。因此,我们可以通过延迟显示加载器来避免这个问题。

1. 创建 Lazy Loader 组件

为了解决这个问题,我们将创建一个“懒加载”组件,它将在特定的延迟时间后显示加载器。这意味着如果请求的处理时间很短,那么加载器可能根本不会显示,从而减少视觉上的闪烁。这个组件可以在 components 文件夹中创建,命名为 LazyLoader.js,并且它接受以下属性:

  • show: 一个布尔值,用于确定是否显示加载器。
  • delay: 延迟时间(以毫秒为单位)。

组件代码如下:

// components/LazyLoader.js
import React, { useEffect, useState } from 'react';

const LazyLoader = ({ show = false, delay = 0, defaultText = 'Fetch Users' }) => {
    const [showLoader, setShowLoader] = useState(false);

    useEffect(() => {
        let timeout;
        if (show) {
            if (delay === 0) {
                setShowLoader(true);
            } else {
                timeout = setTimeout(() => setShowLoader(true), delay);
            }
        } else {
            setShowLoader(false);
        }

        return () => clearTimeout(timeout);
    }, [show, delay]);

    return (
        <span>
            {showLoader ? 'Loading...' : defaultText}
        </span>
    );
};

export default LazyLoader;

2. 使用 Lazy Loader 组件

在使用这个 LazyLoader 组件时,你可以设置 show 属性为 isFetchPending(当前请求是否挂起)以及 delay 属性为一个延迟时间(如 500 毫秒),以实现较长请求时显示加载器的效果:

import LazyLoader from './components/LazyLoader';

const MyComponent = ({ isFetchPending }) => {
    return (
        <LazyLoader show={isFetchPending} delay={500} defaultText="Fetch Users" />
    );
};

3. 使用效果

这样,当用户点击请求时,如果请求时间较短(小于延迟时间),加载器将不会显示。然而,如果请求时间较长,加载器将会显示,以便用户知道后台正在进行操作。

通过这种方式,我们能够改善用户体验,减少视觉上的闪烁,并且根据请求时间动态调整加载器的显示。这种方法可以为所有异步操作创建一个统一的延迟加载器组件。

接下来的视频将继续优化和封装 API 请求的状态管理,借助自定义 Hook 实现更简洁的状态和错误处理。

5.Abstracting API States and Fetching Logic 抽象API状态和获取逻辑

好的,那么在本视频中,我们将优化 useFetchUsers 自定义 Hook,把其中的核心逻辑抽象到另一个自定义 Hook 中,以便复用。我们创建了 useAPI 自定义 Hook,这样我们可以将 API 请求的逻辑和状态管理集中起来,不必每次都重复相同的代码。

1. 创建 useAPI 自定义 Hook

首先在 hooks/api 目录下创建 useAPI.js 文件,该文件包含执行 API 请求的逻辑和状态管理。这个 Hook 接收两个参数:fn 表示执行 API 请求的函数,config 是一个可选配置对象,用于设置一些初始值或其他配置项。

// hooks/api/useAPI.js
import { useState } from 'react';
import { API_STATUS } from '../constants';

const useAPI = (fn, config = {}) => {
    const [data, setData] = useState(config.initialData || null);
    const [error, setError] = useState(null);
    const [status, setStatus] = useState(API_STATUS.IDLE);

    const execute = async (...args) => {
        setStatus(API_STATUS.PENDING);
        setError(null);

        try {
            const result = await fn(...args);
            setData(result);
            setStatus(API_STATUS.SUCCESS);
        } catch (err) {
            setError(err);
            setStatus(API_STATUS.ERROR);
        }
    };

    return {
        data,
        error,
        status,
        execute
    };
};

export default useAPI;

2. 使用 useAPI Hook

接下来,在使用 useAPI Hook 的组件中,你可以传入特定的 API 请求函数,例如 fetchUsers,以及一些可选的配置项(如初始数据)。下面是一个如何使用该 Hook 的示例:

// components/UsersComponent.js
import useAPI from '../hooks/api/useAPI';
import fetchUsers from '../api/fetchUsers';

const UsersComponent = () => {
    const { data: users, error, status, execute: fetchUsers } = useAPI(fetchUsers, {
        initialData: []
    });

    useEffect(() => {
        fetchUsers();
    }, [fetchUsers]);

    if (status === API_STATUS.PENDING) return <p>Loading...</p>;
    if (status === API_STATUS.ERROR) return <p>Error: {error.message}</p>;

    return (
        <div>
            {users.map(user => (
                <p key={user.id}>{user.name}</p>
            ))}
        </div>
    );
};

3. 解释 useAPI Hook 的实现

useAPI Hook 通过 execute 函数来执行 API 请求,先将状态设为 PENDING,在请求完成后更新 datastatus。如果请求成功,状态变为 SUCCESS,并将数据保存到 data 中;如果请求失败,将 error 状态更新为 ERROR 并保存错误信息。

4. 提升代码复用性

通过抽象出 useAPI Hook,我们可以在任何需要发起 API 请求的地方轻松使用它,并集中管理状态和错误处理逻辑,这样使得代码更简洁、复用性更强。

在接下来的部分,我们将进一步优化,可能会加入分页、无限滚动等功能,甚至是使用 React Query 来管理 API 请求和缓存,帮助我们实现更强大的数据请求管理。

6. Adding Request Abort Logic 添加请求中止逻辑

在本视频中,我们讨论了请求取消

在许多情况下,在发起新请求之前取消前一个请求是个好主意。例如,在自动完成功能中,如果有大量数据库查询,查询响应可能会有延迟。想象一个美食网站,用户可以输入内容进行搜索,每次输入字符时都会发起API请求。当收到响应后,立即显示配方列表。

假设用户在搜索字段中输入了几个字符并发起了两个API请求。第一个请求是“LA”,而第二个是“Lasagna”。由于延迟,第一个请求比第二个更晚完成,这会导致显示的是第一个请求的数据,而不是最新的搜索结果。这显然不是良好的用户体验。为了解决这个问题,可以在发起下一个请求之前确保第一个请求已取消。

使用Axios进行请求取消

我们可以使用Axios库来处理请求取消。取消请求需要创建一个取消令牌,并将其传递给Axios的方法中。为了避免在整个应用程序中直接创建取消令牌,我们可以通过封装函数的方式来实现,以便将来可以轻松替换HTTP客户端。

为了演示如何将请求取消与API层集成,我们将实现一个功能,允许用户搜索食物。这里我们将使用免费的API:Meal DB。首先,我们修改API.js文件,加入一个封装函数,名为withAbort

withAbort函数

withAbort函数中,我们先创建一个取消源,再在配置对象中加入取消令牌。然后我们定义了一些辅助函数:

  • isAPIError:用于检查错误是否是Axios错误;
  • getCancelSource:用于生成取消令牌;
  • handleError:处理错误,如果是取消请求导致的错误,会记录为“请求已取消”。

组件 SearchMeals

最后,我们创建组件SearchMeals,其中包含自定义Hook:useFetchMeals。在输入字段中,当用户输入内容时会触发请求。为了避免每次按键都发送请求,可以使用防抖(Debouncing)技术,让请求在用户停止输入后的短暂时间后才发送。这样可以减少请求数量,并提高应用性能。

使用示例

在输入搜索内容时,组件会自动取消之前的请求,并只保留最新的请求以确保显示最新的搜索结果。输入快速变化时,会显示“请求已取消”的消息,提示用户已取消先前请求。

在实际应用中,还可以使用防抖来进一步优化请求频率,使体验更加流畅。

7. Logging Errors 记录错误

在接下来的部分之前,我们最后要讨论的是错误日志记录。简单来说,如果API层没有任何错误日志记录,当出问题时会非常难以排查。基于我的经验,这是非常重要的,因此添加一些基础的错误日志记录是个好主意。

每个API请求都加上错误日志记录可能比较繁琐,而且容易遗漏。因此,我们可以利用之前创建的API层,将日志记录功能添加到其中。

实现日志记录功能

我们在API.js文件中创建了一个新函数,比如withLogger,将其包装在每个Axios请求的外层。该函数接受一个Promise作为输入,并在发生错误时记录日志。

async function withLogger(promise) {
  return promise.catch((error) => {
    if (process.env.REACT_APP_DEBUG_API) {
      // 基本的错误处理逻辑
      if (error.response) {
        // 请求已发送且服务器响应了错误代码
        console.log("Response Error:", error.response.data, error.response.status, error.response.headers);
      } else if (error.request) {
        // 请求已发送但没有收到响应
        console.log("Request Error:", error.request);
      } else {
        // 请求配置错误
        console.log("General Error:", error.message);
      }
      console.log("Error Config:", error.config);
    }
    throw error;
  });
}

我们可以将withLogger函数用于所有Axios请求,比如将axios.getaxios.post等方法包装起来。这样,每当请求发生错误时,都会自动记录日志,帮助我们了解出错的原因。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

13. API Layer with React-Query 使用React-Query的API层

1. Server Setup and a Quick Fix to withLogger Function 服务器设置和withLogger函数的快速修复

欢迎回来!

在上一节中,我们讨论了如何处理API请求,以及如何使用API层和我们创建的自定义钩子(如useAPIStatususeAPI)来管理API状态。在本节中,我们将把API层与React Query库结合起来。正如您所知,React Query功能丰富且性能优越,可以用于数据的获取、更新、缓存和后台重新获取等。

在继续之前,先提两点

首先,在上一节视频中,我们在API文件中创建了一个withLogger函数。在编写过程中,我犯了一个语法错误。您可以将其简单地定义为接受一个Promise,如果该Promise被拒绝,就会执行记录代码,否则继续执行原有流程。

第二,为了演示,我们还搭建了一个用Express.js构建的小型服务器。虽然涉及一些后端内容,但它相对简单,可以帮助您更好地理解整个流程。这个代码可以在server文件夹中找到,只需导航到该目录并运行npm start即可启动服务器,默认端口为localhost的9000。服务器提供了一些引人深思的名言作为接口数据,这些数据将作为本节的API来使用。

如果您在设置过程中遇到问题或有疑问,请随时与我联系,我会非常乐意回答您的问题。

2. Fetching Data with React-Query 使用React-Query获取数据

好的,那么我们开始用React Query来与我们的API层进行整合,首先创建一个简单的组件来展示一些名言。


第一步

API文件夹下创建一个新的API文件,用于获取名言。可以称它为quoteAPI.js,并在其中定义一个fetchTopQuotes函数。我们将会使用API.get方法来调用我们的名言端点(假设在后端已经有了相关的路由和端点),然后返回数据。

// quoteAPI.js
import API from './API';

export const fetchTopQuotes = () => {
  return API.get('/top_quotes').then((res) => res.data.quotes);
};

第二步

创建一个名为TopQuotes.jsx的组件,负责显示获取到的名言。组件将使用React Query的useQuery钩子来管理数据请求,并展示加载状态、成功状态和错误状态。

// TopQuotes.jsx
import React from 'react';
import { useQuery } from 'react-query';
import { fetchTopQuotes } from './quoteAPI';

const TopQuotes = () => {
  const { data: quotes, isLoading, isError } = useQuery('topQuotes', fetchTopQuotes);

  if (isLoading) return <p>Loading...</p>;
  if (isError) return <p>Error fetching quotes</p>;

  return (
    <div>
      <h2>Top Quotes</h2>
      <ul>
        {quotes.map((quote, index) => (
          <li key={index}>{quote}</li>
        ))}
      </ul>
    </div>
  );
};

export default TopQuotes;

第三步

将该组件集成到主App组件中。在App.js中使用QueryClientProvider来包裹应用,这样React Query就可以管理数据缓存和状态。

// App.js
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import TopQuotes from './TopQuotes';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div className="App">
        <TopQuotes />
      </div>
    </QueryClientProvider>
  );
}

export default App;

额外功能

若有需要,也可以添加错误提示和加载状态的样式。将加载提示、错误提示等通过条件渲染展示出来,提升用户体验。

至此,我们已经成功完成了React Query与API层的结合!这样可以更方便地处理数据获取、缓存和状态管理。

3. Updating Data with React-Query 使用React-Query更新数据

要更新数据到服务器,可以使用React Query提供的useMutation钩子。这个钩子和useQuery类似,可以传递API请求方法以及配置对象。以下是如何实现和使用useMutation进行数据更新的示例:


第一步

quoteAPI.js中新增两个功能函数:

  1. postQuote:用于发送新的名言。
  2. resetQuotes:用于重置名言列表,将其恢复为原始状态。
// quoteAPI.js
import API from './API';

export const postQuote = (quote) => {
  return API.post('/add_quote', quote);
};

export const resetQuotes = () => {
  return API.post('/reset', {});
};

第二步

创建一个名为UpdateQuote.jsx的组件,包含表单,用于提交和重置名言。该组件会使用useMutation来管理创建和重置操作,并使用useQueryClient来无效化缓存,从而刷新列表。

// UpdateQuote.jsx
import React, { useState } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import { postQuote, resetQuotes } from './quoteAPI';

const UpdateQuote = () => {
  const queryClient = useQueryClient();
  const [form, setForm] = useState({ author: '', quote: '' });

  const createQuoteMutation = useMutation(postQuote, {
    onSuccess: () => {
      queryClient.invalidateQueries('topQuotes');
      setForm({ author: '', quote: '' });
      alert('Quote created successfully');
    },
  });

  const resetQuotesMutation = useMutation(resetQuotes, {
    onSuccess: () => {
      queryClient.invalidateQueries('topQuotes');
      alert('Quotes reset successfully');
    },
  });

  const handleChange = (e) => {
    const { name, value } = e.target;
    setForm((prev) => ({ ...prev, [name]: value }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    if (form.author && form.quote) {
      createQuoteMutation.mutate(form);
    } else {
      alert('Please fill in all fields');
    }
  };

  const handleReset = () => {
    resetQuotesMutation.mutate();
  };

  return (
    <div>
      <h2>Update Quotes</h2>
      <form onSubmit={handleSubmit}>
        <div>
          <label>Author:</label>
          <input type="text" name="author" value={form.author} onChange={handleChange} />
        </div>
        <div>
          <label>Quote:</label>
          <input type="text" name="quote" value={form.quote} onChange={handleChange} />
        </div>
        <button type="submit">Add Quote</button>
        <button type="button" onClick={handleReset}>Reset Quotes</button>
      </form>
    </div>
  );
};

export default UpdateQuote;

第三步

App.js中导入并使用新组件UpdateQuote,放在现有的TopQuotes组件之前。

// App.js
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import TopQuotes from './TopQuotes';
import UpdateQuote from './UpdateQuote';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div className="App">
        <UpdateQuote />
        <TopQuotes />
      </div>
    </QueryClientProvider>
  );
}

export default App;

总结

这样我们实现了通过React Query的useMutation来创建和重置数据,并使用useQueryClient来无效化缓存,确保数据更新后自动刷新显示。通过这种方式,我们可以更轻松地管理和更新客户端和服务器的数据同步。

4. Pagination with React-Query 使用React-Query分页

要实现分页功能,可以使用React Query中的useQuery钩子,同时传递查询键和页码。这样我们可以轻松获取不同页的数据,同时保留上次成功请求的数据。以下是如何用React Query实现分页的示例。


第一步:在API文件中添加分页功能

quoteAPI.js文件中添加一个新函数,用于按页获取数据。

// quoteAPI.js
import API from './API';

export const fetchQuotesByPage = (page) => {
  return API.get('/quotes', {
    params: { page },
  }).then((res) => res.data);
};

第二步:创建分页组件

接下来,在项目中创建一个新的组件PaginatedQuotes.jsx,用来显示分页的名言列表。该组件将使用useQuery钩子,通过查询键和页码来获取不同页的数据。

// PaginatedQuotes.jsx
import React, { useState } from 'react';
import { useQuery } from 'react-query';
import { fetchQuotesByPage } from './quoteAPI';

const PaginatedQuotes = () => {
  const [page, setPage] = useState(1);
  
  const { data, isLoading, isError, isPreviousData } = useQuery(
    ['quotes', page],
    () => fetchQuotesByPage(page),
    { keepPreviousData: true }
  );

  return (
    <div>
      <h2>Quotes - Page {page}</h2>

      {isLoading ? (
        <p>Loading quotes...</p>
      ) : isError ? (
        <p>Error fetching quotes.</p>
      ) : (
        <div>
          {data.quotes.map((quote) => (
            <p key={quote.id}>{quote.text} - {quote.author}</p>
          ))}

          <button
            onClick={() => setPage((old) => Math.max(old - 1, 1))}
            disabled={page === 1}
          >
            Previous
          </button>

          <button
            onClick={() => {
              if (!isPreviousData && data.hasMore) {
                setPage((old) => old + 1);
              }
            }}
            disabled={isPreviousData || !data?.hasMore}
          >
            Next
          </button>
        </div>
      )}
    </div>
  );
};

export default PaginatedQuotes;

代码说明:

  1. 分页状态:通过useState钩子来管理当前页码。
  2. 使用查询键管理分页请求:使用useQuery时,我们传递了一个包含查询键和页码的数组,以便根据页码动态获取数据。
  3. 保留先前数据keepPreviousData设置为true时,数据加载过程中仍然会显示上次成功的请求数据,以便提供更流畅的用户体验。
  4. 按钮:创建“上一步”和“下一步”按钮,以便用户可以浏览不同页的数据。

第三步:在App.js中导入并使用分页组件

将新的PaginatedQuotes组件添加到应用的主文件中。

// App.js
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import PaginatedQuotes from './PaginatedQuotes';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div className="App">
        <PaginatedQuotes />
      </div>
    </QueryClientProvider>
  );
}

export default App;

运行效果

页面加载时,PaginatedQuotes组件会默认显示第一页的数据。用户可以通过“下一步”和“上一步”按钮来翻页,React Query会在后台加载新的数据,同时保留上次成功请求的数据,确保用户体验顺畅。

5. Infinite scroll with React-Query 使用React-Query无限滚动

为了实现无限滚动,你可以结合React Query的useInfiniteQuery钩子和Intersection Observer API来监测用户是否滚动到列表底部。以下是如何在React Query中使用无限滚动的详细步骤:


第一步:创建一个按游标分页获取数据的函数

在你的API文件quoteAPI.js中添加一个新函数,用于按游标分页获取数据。

// quoteAPI.js
import API from './API';

export const fetchQuotesByCursor = (cursor) => {
  return API.get('/quotes', {
    params: { cursor },
  }).then((res) => res.data);
};

第二步:创建无限滚动组件

接下来,创建一个新的组件InfiniteScrollQuotes.jsx,使用useInfiniteQuery钩子来支持无限滚动。该组件将在用户滚动到页面底部时自动加载更多数据。

// InfiniteScrollQuotes.jsx
import React, { useEffect } from 'react';
import { useInfiniteQuery } from 'react-query';
import { fetchQuotesByCursor } from './quoteAPI';
import { useInView } from 'react-intersection-observer';

const InfiniteScrollQuotes = () => {
  const { ref, inView } = useInView();

  const {
    data,
    isLoading,
    isError,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery(
    'quotes',
    ({ pageParam = null }) => fetchQuotesByCursor(pageParam),
    {
      getNextPageParam: (lastPage) => lastPage.nextCursor ?? false,
    }
  );

  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

  return (
    <div>
      <h2>Infinite Scroll Quotes</h2>

      {isLoading ? (
        <p>Loading quotes...</p>
      ) : isError ? (
        <p>Error fetching quotes.</p>
      ) : (
        <div>
          {data.pages.map((page, index) => (
            <React.Fragment key={index}>
              {page.quotes.map((quote) => (
                <p key={quote.id}>{quote.text} - {quote.author}</p>
              ))}
            </React.Fragment>
          ))}
          <div ref={ref} style={{ height: '20px' }}>
            {isFetchingNextPage ? 'Loading more...' : 'Load more'}
          </div>
        </div>
      )}
    </div>
  );
};

export default InfiniteScrollQuotes;

代码说明:

  1. Intersection ObserveruseInView钩子用于监测用户是否滚动到组件底部。当底部元素进入视图时,inView的值为true
  2. 无限滚动钩子useInfiniteQuery允许我们定义一个查询键(quotes)和一个查询函数(fetchQuotesByCursor)。在配置对象中,getNextPageParam用于指示下一页的游标。
  3. 自动加载更多数据:使用useEffect钩子,当inViewtrue并且存在下一页时,调用fetchNextPage来加载更多数据。

第三步:在App.js中使用无限滚动组件

将新的InfiniteScrollQuotes组件导入到应用的主文件中。

// App.js
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import InfiniteScrollQuotes from './InfiniteScrollQuotes';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div className="App">
        <InfiniteScrollQuotes />
      </div>
    </QueryClientProvider>
  );
}

export default App;

运行效果

此时,当用户滚动到底部时,组件将自动加载更多数据。随着用户不断向下滚动,新的数据将被自动加载,实现了无限滚动效果。

6. Query Cancellation with React-Query 使用React-Query取消查询

要在React Query中实现请求取消,可以利用Axios中的abort signal功能。这种方式允许你在请求未完成时将其取消,例如,当组件卸载或需要取消多个请求时。下面是使用abort signal取消请求的详细步骤:


第一步:在API文件中使用abort signal

首先,你需要确保Axios实例配置支持signal参数。下面是在API层实现的一个简单请求取消功能:

// quoteAPI.js
import API from './API';

export const fetchQuotesWithSignal = (signal) => {
  return API.get('/quotes', { signal }).then((res) => res.data);
};

第二步:创建请求取消组件

创建一个新的组件QueryCancellation.jsx,用React Query的useQuery钩子来支持请求取消,并使用Abort Signal功能。

// QueryCancellation.jsx
import React, { useState } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { fetchQuotesWithSignal } from './quoteAPI';

const QueryCancellation = () => {
  const [shouldAbort, setShouldAbort] = useState(true);
  const queryClient = useQueryClient();

  const { data, error, isLoading } = useQuery(
    'quotes',
    ({ signal }) => fetchQuotesWithSignal(signal),
    {
      enabled: !shouldAbort,
      retry: false,
      onError: (err) => {
        if (err.message === 'canceled') {
          console.log('Request was cancelled');
        }
      },
    }
  );

  const handleFetchQuotes = () => {
    if (shouldAbort) {
      queryClient.cancelQueries('quotes');
    } else {
      queryClient.refetchQueries('quotes');
    }
  };

  return (
    <div>
      <h2>Quote Fetcher with Cancellation</h2>
      <label>
        <input
          type="checkbox"
          checked={shouldAbort}
          onChange={() => setShouldAbort(!shouldAbort)}
        />
        Cancel Request
      </label>
      <button onClick={handleFetchQuotes}>
        Fetch Quotes
      </button>
      {isLoading && <p>Loading quotes...</p>}
      {error && <p>Error: {error.message}</p>}
      {data && (
        <ul>
          {data.map((quote) => (
            <li key={quote.id}>{quote.text} - {quote.author}</li>
          ))}
        </ul>
      )}
    </div>
  );
};

export default QueryCancellation;

代码说明:

  1. 设置取消状态shouldAbort状态控制请求是否应取消。勾选复选框可以动态更改请求的取消状态。
  2. 请求配置:通过传递{ signal }对象,Axios在检测到取消信号时会取消请求。此配置在React Query的useQuery钩子中使用。
  3. 请求取消和重新触发handleFetchQuotes函数检查shouldAbort,并使用queryClient.cancelQueries取消请求。若未启用取消请求,则使用queryClient.refetchQueries重新触发请求。

第三步:在主应用文件中使用组件

App.js文件中引入并渲染新创建的QueryCancellation组件。

// App.js
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import QueryCancellation from './QueryCancellation';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div className="App">
        <QueryCancellation />
      </div>
    </QueryClientProvider>
  );
}

export default App;

运行效果

通过这种方式,你可以在组件中动态取消API请求。勾选复选框后,触发Fetch Quotes按钮将显示“请求被取消”。这种技术在需要频繁请求时非常有用,因为它允许你在未完成请求前安全地终止该请求。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

14. State Management Patterns 状态管理模式

1. Immutable updates with useImmer 使用useImmer进行不可变更新

欢迎回来!正如你所知,在 React 代码库中,大多数状态可以通过 useState 钩子来处理。然而,有些情况下可以通过其他语法来优化状态管理。


使用 useImmer 优化状态管理

在本节中,我们将介绍如何使用 useImmer 库中的 useImmer 钩子和 useImmerReducer 钩子来优化状态管理。
让我们打开 app.js 文件,假设我们有一个基本状态变量 person,初始属性是 name,例如“Superman”或“Batman”——你可以随意设置。

状态更新的常规方法

如果想要更新状态,例如在 onChange 函数中,尽管代码看起来很干净,但它实际上是不可行的。
原因是你在直接修改状态对象,而 React 不允许这样做。为了更新 person 变量,你必须创建一个新对象,使用扩展运算符保持其他属性不变,并只修改你想要更改的属性:

const onChange = () => {
    setPerson({
        ...person,
        name: 'New Name'
    });
};

React 鼓励函数式编程范式和对象不可变性,这意味着状态不应被直接修改,而是通过创建新对象来更新状态。这样可以避免因对象突变导致的问题,虽然这种方法更易于理解,但也带来了额外的复杂性,导致我们不得不编写冗长的代码来满足 React 的这种特性。

示例:复杂状态对象的更新

让我们举个例子,假设我们有一个类似 Trello 或 Jira 的小组件,允许用户创建带有列和任务的看板。首先,我们会加载一些假数据,然后创建 tasks-board.jsx 组件。基本组件结构如下:

const TasksBoard = () => {
    const [board, setBoard] = useState(dummyBoardData);
    const [selectedTask, setSelectedTask] = useState(null);

    const onSelectTask = (columnIndex, taskIndex) => {
        setSelectedTask({ columnIndex, taskIndex });
    };

    const onTaskNameChange = (e) => {
        const updatedBoard = {
            ...board,
            columns: board.columns.map((col, colIndex) => {
                if (colIndex === selectedTask.columnIndex) {
                    return {
                        ...col,
                        tasks: col.tasks.map((task, taskIndex) => 
                            taskIndex === selectedTask.taskIndex ? { ...task, name: e.target.value } : task
                        )
                    };
                }
                return col;
            })
        };
        setBoard(updatedBoard);
    };

    return (
        // JSX代码,渲染看板及任务卡片
    );
};

在这种结构下,我们需要迭代列和任务数组,创建更新后的对象,以满足 React 的不可变性要求。这种方法虽然有效,但代码编写和理解都较为繁琐。

使用 useImmer 简化状态更新

为了简化代码,我们可以使用 useImmer。安装 useImmer,并将状态声明更改为:

import { useImmer } from 'use-immer';

const [board, setBoard] = useImmer(dummyBoardData);

const onTaskNameChange = (e) => {
    setBoard(draft => {
        draft.columns[selectedTask.columnIndex].tasks[selectedTask.taskIndex].name = e.target.value;
    });
};

useImmer 允许你直接修改状态的草稿版本,它会在后台自动处理不可变性,使代码更简洁和易读。
只需在 setBoard 中传入一个函数,直接修改草稿对象,而不必手动创建新的对象。


总结

通过 useImmeruseImmerReducer,你可以更轻松地管理复杂的状态对象和嵌套结构,使代码更加简洁清晰。

2. Cleaner reducer with useImmerReducer 使用useImmerReducer清理reducer

欢迎回来!大家都知道,useState 钩子是 React 代码中最常用的状态管理钩子。然而,React 还提供了另一个钩子,叫做 useReducer,尽管使用频率不高,但在处理更复杂的数据集时,它可能是个不错的选择。


使用 useReducer 实现简单购物清单

我们将创建一个简单的购物清单功能来演示 useReducer 的用法。在 App.jsx 中,我们将使用一个 ShoppingList 组件,并且这个组件包含了一些基本样式。

ShoppingList 组件结构

  1. 生成ID

    • 使用 getUniqueId() 函数为每个新添加的购物清单项生成唯一 ID。
  2. 定义初始状态

    • 初始状态包含三个购物清单项。
  3. 创建 Reducer

    • 我们的 reducer 具有四种操作类型:添加、删除、更新和更改购物清单项的名称。
    • 通过 switch 语句和扩展运算符实现 reducer 逻辑。
const initialState = [...];
const reducer = (state, action) => {
    switch (action.type) {
        case 'addItem':
            return [...];
        case 'deleteItem':
            return [...];
        case 'updateItem':
            return [...];
        default:
            return state;
    }
};
  1. 实现基本功能
    • 添加、删除和更新购物清单项的功能通过 dispatch 方法触发相应的 reducer 动作。
const [state, dispatch] = useReducer(reducer, initialState);
const addItem = (item) => dispatch({ type: 'addItem', item });
const deleteItem = (id) => dispatch({ type: 'deleteItem', id });
  1. 渲染 UI
    • ShoppingListHeader 显示标题和购物清单长度。
    • ShoppingListRow 显示每个购物清单项,包含编辑和删除功能。

组件分解

ShoppingListRow 接收四个属性:item(带 ID 和名称的对象)、index(索引)、updateItemdeleteItem(函数)。通过这些属性,该组件允许用户编辑或删除当前购物清单项。

使用 useReducer 优化

useReducer 可以管理复杂的状态变更,但它需要我们为每种操作类型显式地返回一个新对象,这种方式虽然清晰,但代码容易冗长。为了简化代码结构,我们可以改用 useImmerReducer

import { useImmerReducer } from 'use-immer';

const [state, dispatch] = useImmerReducer(reducer, initialState);

useImmerReduceruseReducer 非常相似,但它处理不可变性,使得我们可以直接修改状态而不用手动返回新的对象或数组。这可以让我们的代码更为简洁:

const reducer = (state, action) => {
    switch (action.type) {
        case 'addItem':
            state.push(action.item);
            break;
        case 'deleteItem':
            return state.filter((item) => item.id !== action.id);
        case 'updateItem':
            const item = state.find((i) => i.id === action.id);
            item.name = action.name;
            break;
        default:
            break;
    }
};

现在,你可以轻松实现购物清单的添加、更新和删除功能,使用 useImmerReducer 让代码更清晰、更易于维护。

总结

useImmerReducer 提供了处理不可变性的新方法,可以帮助我们简化代码结构,减少冗长的对象复制操作。如果你在管理复杂状态对象时遇到挑战,不妨考虑使用 useImmerReducer 来优化代码!

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

15. Performance Optimization 性能优化

1. Code-Splitting and Lazy-Loading 代码分割和懒加载

在提升应用程序加载性能方面,有一个非常有效的技术:代码分割延迟加载


代码分割与延迟加载的好处

过去,我们通常会将整个应用程序的 JavaScript 代码打包成一个大文件,但这种方式会导致用户在访问某个页面时,必须下载整个网站的代码,即便他们不需要访问所有页面。这种情况在应用程序包含多个页面和复杂功能时尤为明显。用户可能在使用网站之前被迫下载大量代码,这不仅增加了等待时间,也提高了用户离开网站的概率。

现代应用程序通常将代码分成多个小块,这样用户在访问某个页面时,只会下载与该页面相关的代码。访问其他页面时,所需代码才会按需加载,从而缩短加载时间。

React 中的代码分割与延迟加载

在 React 应用中,我们可以使用 React.lazySuspense 来实现代码分割和延迟加载。以下是使用 React Router 实现这些功能的步骤:

基本设置

  1. App.js 文件中引入 React Router,并创建简单的路由。我们设置了三个页面组件,分别是 HomeAboutContact,这些组件的内容都很简单,只是演示目的。
  2. 初始状态下,所有页面代码会被一起打包成一个文件,无论用户访问哪个页面都会加载整个应用程序。

使用 React.lazySuspense 实现延迟加载

  1. 将页面组件的导入修改为懒加载形式:

    const Home = React.lazy(() => import('./Home'));
    const About = React.lazy(() => import('./About'));
    const Contact = React.lazy(() => import('./Contact'));

    React.lazy 接收一个函数,该函数需返回一个默认导出的模块,并且必须包含 React 组件。

  2. 使用 Suspense 组件包裹路由,并设置 fallback 属性作为加载中的指示器:

    <Suspense fallback={<h3>加载中...</h3>}>
        <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/about" element={<About />} />
            <Route path="/contact" element={<Contact />} />
        </Routes>
    </Suspense>

    fallback 中可以设置简单的加载文本,也可以使用加载动画。Suspense 会在组件加载完成前显示 fallback 内容,加载完成后自动替换为目标组件。

添加延迟显示

为了防止快速加载时出现闪烁,可以设置一个延迟显示逻辑。使用 useStateuseEffect 实现一个简单的 LazyLoader 组件:

function LazyLoader({ show, delay = 0 }) {
   const [showLoader, setShowLoader] = useState(false);

   useEffect(() => {
       if (!show) {
           setShowLoader(false);
           return;
       }

       const timeoutId = setTimeout(() => setShowLoader(true), delay);

       return () => clearTimeout(timeoutId);
   }, [show, delay]);

   return showLoader ? <h3>加载中...</h3> : null;
}

使用这个组件包裹延迟加载的内容,并设置适当的延迟:

<Suspense fallback={<LazyLoader show={true} delay={500} />}>
    <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/contact" element={<Contact />} />
    </Routes>
</Suspense>

这样可以有效地减少快速加载时的闪烁现象,提升用户体验。

总结

通过代码分割和延迟加载技术,我们可以在 React 应用中更高效地管理资源加载,从而提高应用的加载速度和用户体验。在复杂应用中,这种优化手段尤其重要!

2. useCallback hook to preserve referential integrity 使用useCallback钩子保持引用完整性

优化性能:减少组件重复渲染

我们创建了一个简单的应用程序,但发现它存在一些不必要的重复渲染。在一些菜谱网站中,用户可以像购物车一样添加和移除食材。我们接下来会优化这个应用,减少这些浪费的渲染。

应用组件结构

app.jsx 文件中,我们引入了一个 Ingredients 组件,并在其中处理食材列表的渲染和管理。我们有以下主要组件:

  1. Ingredients:包含食材管理功能。
  2. Ingredients Info Helper:简单的按钮显示。
  3. Ingredients List:显示食材列表。
  4. Add Ingredient:提供一个表单,用户可以在其中添加新的食材。

发现不必要的渲染

在每个组件的开始处添加 console.log 语句,打开开发者控制台观察渲染情况。我们会发现,无论在表单中做了什么操作,Ingredients List 组件都被重新渲染,即便列表数据没有发生变化。

使用 React.memo 优化渲染

为避免不必要的渲染,我们使用 React.memo 对组件进行缓存。React.memo 接收两个参数:

  1. 要缓存的组件。
  2. 可选的对比函数,用于比较前后两个 props,判断是否需要重新渲染。

Ingredients List 组件中导入 React.memo 并包裹组件:

import { memo } from 'react';
export default memo(IngredientsList);

若仅这样设置,仍然会重复渲染,因为 deleteIngredient 函数在每次父组件更新时都会重新创建。我们可以传入一个对比函数以解决此问题:

export default memo(IngredientsList, (prevProps, nextProps) => {
    return prevProps.ingredients === nextProps.ingredients;
});

使用 useCallback 优化函数

直接传递的函数会导致组件重新渲染,我们可以使用 useCallback 确保 deleteIngredient 函数的引用在组件未发生变化时保持一致:

const deleteIngredient = useCallback(() => {
    // 删除逻辑
}, []);

这样可以确保 deleteIngredient 函数不会在组件更新时被重新创建,从而避免 Ingredients List 的重复渲染。

测试优化结果

在控制台中,我们可以看到无关的组件不再因输入框中的变化而重新渲染。通过这些优化,应用在性能和用户体验方面得到了显著提升。


以上是优化组件渲染的主要方法和步骤。通过使用 React.memouseCallback,可以显著减少重复渲染,提升应用性能。

3. Avoiding re-renders with useMemo 使用useMemo避免重新渲染

使用 useMemo 优化组件渲染

useMemo 不仅可以用来缓存状态值,还可以缓存 React 元素。我们来看一个示例,假设有一个 CreateIngredientsHeaderText 函数,用于生成标题文本。尽管这个函数很简单,但在每次 Ingredients 组件重新渲染时,它也会被重新调用。

在这种情况下,我们可以通过将内容移到一个新的组件中并用 React.memo 包裹,但 useMemo 也可以实现同样的效果,并且更加简洁。useMemo 允许我们仅在依赖项发生变化时重新计算值,从而避免不必要的重新渲染。

实现步骤

  1. 首先,导入 useMemo 并定义新的变量,将 CreateIngredientsHeaderText 的内容用 useMemo 包裹:

    import { useMemo } from 'react';
    
    const ingredientsHeader = useMemo(() => {
        return <h1>Create Ingredients Header Text</h1>;
    }, [ingredients.length]);
  2. 然后将页面中的标题内容替换为 ingredientsHeader,使其只在 ingredients 数组的长度发生变化时重新计算。

  3. 检查代码是否生效。在控制台中输入内容,可以看到 CreateIngredientsHeaderText 不会因为输入框的更新而被重新渲染,只有当添加或移除食材时才会重新调用它。

通过这种方式,我们有效地减少了无关的重新渲染,从而提高了应用的性能。

4. State Collocation 状态位置集中化

在这个优化中,我们使用了状态归位(State Collocation)技术来避免不必要的重新渲染,并使状态流更容易理解。具体实现步骤如下:

  1. 识别问题:我们发现在输入框中键入内容时,Ingredients 组件、IngredientsInfoHelper 按钮和 AddIngredients 组件都会重新渲染。事实上,只有 AddIngredients 需要在输入时重新渲染,其他组件不需要。

  2. 应用状态归位:问题的关键在于 IngredientsIngredientsInfoHelper 不应该接收 ingredient 状态,因为它们不直接使用它。

    • Ingredients 组件中,我们将 ingredient 状态移出,避免该状态的改变导致整个组件重新渲染。

    • 我们将 ingredient 状态移到 AddIngredients 组件内部,因为它是唯一真正依赖此状态的组件。

  3. 调整组件结构:移除不必要的状态传递。在 Ingredients 主组件中,不再向 AddIngredients 组件传递 ingredientsetIngredient 状态。

  4. 验证优化:清空控制台日志,开始在输入框中键入内容。可以看到,只有 AddIngredients 组件被重新渲染,而其他组件保持不变。

总结

通过这种状态归位的方法,我们不仅减少了不必要的重新渲染,还让状态管理更简洁,使得状态更接近其使用的位置,从而提高了代码的可读性。

5. Preventing re-renders by lifting components up 提升组件以防止重新渲染

在这个优化中,我们应用了“提升状态”的技巧,以减少不必要的重新渲染。具体步骤如下:

  1. 识别问题:在删除一个配料项时,IngredientsInfoHelper 组件(一个简单的按钮)没有接收任何 props,却仍被重新渲染。原因是 Ingredients 组件的状态更新后,React 的协调过程需要重新检查整个组件树。

  2. 应用状态提升:为了避免不必要的重新渲染,我们可以将 IngredientsInfoHelper 提升为一个 prop:

    • 先将 IngredientsInfoHelperIngredients 组件中移除,并修改 Ingredients 组件,使其接收 IngredientsInfoHelper 作为一个 prop。
    • App.jsx 中,将 IngredientsInfoHelper 作为 prop 传递给 Ingredients
  3. 验证优化:在页面上删除一个配料项,可以看到 IngredientsInfoHelper 组件没有再因状态更新而重新渲染。

总结

我们探讨了五种优化和防止不必要渲染的方法:memouseCallbackuseMemo、状态归位(State Collocation)、和状态提升。React 本身速度很快,不必对每个组件都使用这些优化技术。优化通常适用于需要频繁渲染的较大组件。

6. Throttling 节流

欢迎回来,大家好。

在某些情况下,限制回调函数的初始化次数是一个好主意。常用的两种技术是节流防抖

节流

顾名思义,节流是限制函数执行频率的技术。举个例子,假设我们有一个跟踪用户光标位置的分析功能。鼠标的微小移动可能会触发几十甚至几百次的鼠标移动事件,但我们不需要追踪每一个像素变化。通过节流,可以设定事件回调函数的执行间隔,例如每隔一段特定时间只执行一次。

防抖

与节流不同,防抖是延迟函数的执行,直到一段特定时间内没有新的事件触发。例如在搜索框中输入内容时,只有在停止输入后的特定时间内不再触发输入事件,才执行搜索操作。

让我们先看看如何实现节流。在这个示例中,当你在应用中移动鼠标时,每一个像素的移动都会被显示出来。我们将在 useMousePosition 钩子中加入节流逻辑,以减少更新的频率。

实现步骤

  1. 创建一个 helpers 文件夹,在其中创建 throttle.js 文件,定义一个节流函数。节流函数接受一个回调函数和一个时间间隔(以毫秒为单位),并返回一个新的函数。

  2. throttle 函数中,定义变量 timerIDinThrottlelastTimetimerID 存储 setTimeout 的引用,inThrottle 是一个布尔值,表示是否正在节流中,而 lastTime 记录上次节流的时间。

  3. 在返回的函数内部,检查是否在节流中。如果不是,则执行回调并更新 lastTime;如果在节流中,则使用 clearTimeout 清除前一个定时器。

  4. 最后,通过设置合适的 wait 时间,让函数在指定的间隔时间内执行。

然后我们在 useMousePosition 钩子中将鼠标位置更新逻辑用节流函数包裹,使其在指定时间间隔内只更新一次。例如,将节流时间设置为200毫秒,可以减少更新频率,提高性能。

示例代码

节流函数示例:

export const throttle = (func, wait) => {
    let inThrottle, lastTime;
    return function(...args) {
        if (!inThrottle) {
            func.apply(this, args);
            lastTime = Date.now();
            inThrottle = true;
        } else {
            clearTimeout(timerID);
            timerID = setTimeout(() => {
                if (Date.now() - lastTime >= wait) {
                    func.apply(this, args);
                    lastTime = Date.now();
                }
            }, Math.max(wait - (Date.now() - lastTime), 0));
        }
    };
};

将鼠标位置更新节流处理:

const [position, setPosition] = useState({ x: 0, y: 0 });
const throttledUpdate = throttle((newPosition) => {
    setPosition(newPosition);
}, 200);

效果

现在,鼠标移动会在指定的间隔内更新位置,而不会因为每一个像素的移动而频繁触发更新,从而提升应用性能。

以上就是节流的实现,接下来我们会讨论防抖的实现方法。

7. Debouncing 防抖

防抖的一个很好的例子就是自动补全的搜索框。假设一个网站有搜索功能,如果用户输入了“鸡肉”或“肉”,这会产生多次 API 请求。显然,不需要每个键击都向服务器发送请求。相反,我们可以等用户停止输入一小段时间后再发起请求。

实现步骤

  1. 在帮助程序中创建一个新的 debounce.js 文件。Debounce 函数接受两个参数:回调函数和延迟时间。类似于节流函数,防抖函数也是一个高阶函数,返回一个新的函数。

  2. debounce 函数中定义 timerID 变量。如果 debounce 返回的函数被调用,则清除上一次的 timerID,并初始化新的 setTimeout,使用提供的延迟时间。

防抖代码示例:

export const debounce = (func, delay) => {
    let timerID;
    return function(...args) {
        if (timerID) clearTimeout(timerID);
        timerID = setTimeout(() => {
            func(...args);
        }, delay);
    };
};

search 组件中,我们可以用 debounce 函数来包裹搜索请求,避免在用户输入时频繁调用 API。

const searchMeals = useMemo(() => debounce(async (query) => {
    setMeals(await fetchMeals(query));
}, 500), []);

这样,当用户在 500 毫秒内不再输入时,才会发出请求,避免不必要的 API 调用。这显著地提升了性能,尤其在有频繁输入的场景中。

效果

通过使用防抖函数,当用户在搜索框输入时,不再每个字符输入都发送请求,而是等待最后一个字符输入结束的 500 毫秒后再进行搜索。这使得应用更高效,也减少了对服务器的压力。

总结

节流和防抖是控制回调执行次数的有效技术。可以借助诸如 React Hooks 库中的 useThrottleuseDebounce 钩子,进一步简化代码。如果你频繁使用这些技术,考虑使用这些钩子会使代码更整洁。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

16. Design System Core Concepts 设计系统核心概念

1. What is a design system 什么是设计系统

其实,并没有一个确切的定义来描述什么是设计系统。那么,它到底意味着什么呢?

如果是 UI/UX 设计师谈论设计系统,他们很可能指的是设计语言,比如色彩搭配、排版以及 UI 工具包,比如 Figma 等工具。而如果是开发人员谈论设计系统,他们通常指的是像 React、Angular 等 Web 框架中的组件库,或通过 Gatsby、Storybook 等工具实现的样式指南。

实际上,设计系统包括所有这些组成部分:设计语言、UI 工具包、组件库和样式指南。在本课程中,我会带你深入了解所有这些主题,帮助你掌握如何设计和构建一个整洁而稳固的设计系统。

2. The importance of having a design system 拥有设计系统的重要性

设计系统帮助公司定义品牌身份,并将其转化为可访问、一致且可重用的组件,这带来了许多好处。

首先,设计系统确保所有用户,无论其状况如何,都能像其他人一样使用你的产品。设计系统确保应用程序有足够的颜色对比度,适当的文字比例和较大的字号,使内容易于浏览。此外,它确保应用程序的各个部分可以通过键盘访问,且重要的提示能被屏幕阅读器检测到,以帮助需要这些功能的用户。

在为企业公司工作时,通常会有一系列产品,像是“兄弟姐妹”一般。一个常见的例子就是 Google,他们有一整套看起来像同一家族的产品。无论你使用 Google Drive 还是 Google Maps,界面元素总是保持一致,比如按钮等。这就是设计系统中的一致性,所有产品都应展示公司的品牌。设计系统的首要任务之一就是确保这种一致性。

设计系统的另一个重要好处是支持渐进式更新。有了设计系统,不再需要频繁的小规模更新,也不需要跨团队沟通来通知每个团队关于新样式更新的详细信息。你只需一次更改,整个设计系统就会根据更改自动更新。

此外,设计系统对于新成员的入职也非常有帮助,它提供了一个中心样式指南,作为一站式参考,帮助团队中新加入的前端开发人员迅速找到开始工作所需的信息。这对于跨团队协作也十分有利,因为所有团队都在使用相同的系统,开发人员可以轻松地参与到其他团队的项目中,比如 A 团队的开发人员可以轻松地帮助 B 团队修复一个 bug,因为他们使用的是同一个设计系统。

当设计系统稳定运行时,设计师可以利用 UI 工具包快速创建新组件的原型,随后开发人员也可以使用组件库,以极快的速度开发这些新组件。因为设计师和开发人员都已经有了所需的组件和设计,只需调用这些资源即可,以快速实现产品中的新功能。

3. Down sides of design systems 设计系统的缺点

好的,在我们刚才讨论了设计系统的诸多优点之后,也需要考虑这些系统可能带来的一些缺点。

首先是时间问题。从零开始构建一个稳固的设计系统可能需要较长时间,有时甚至需要数年。而对公司来说,时间是一种非常宝贵的资源。要解决这个问题,需要设立合适的设计系统团队结构。别担心,我们将在后续的讲解中详细讨论不同的团队结构,帮助你选择合适的团队来应对时间问题。

设计系统的开发过程是持续的。它的生命周期与产品的存在时间一样长,并会随时间不断进化。这是因为设计系统本质上是服务于其他产品的产品。

接下来是维护问题。每个产品都需要维护,设计系统也不例外。它作为服务于其他产品的产品,需要一个团队来持续维护。向利益相关者说明设计系统需要大量初期资源,他们可能不会太乐意接受,但这是确保成功的必要条件。你将需要设计师、工程师,甚至产品经理来维护设计系统。

考虑到上述内容,如果问我什么问题可能会导致设计系统失败,我的答案并不是时间或维护,而是适应性。要确保设计系统成功,你需要一个适当的团队结构,以便能够说服所有产品团队使用并遵循设计系统。因此,若你想让所有相关产品团队采纳你的设计系统,就需要制定最优的团队结构策略。在下一节视频中,我们将深入探讨这个话题。

4. Team Structure 团队结构

我们刚才讨论了设计系统的一些缺点,并提到了解决部分问题需要适当的团队结构。例如,设计系统是否被广泛接受对其成功至关重要。要推广设计系统,必须向公司内的所有团队成员和产品团队推销它。为此,你可以通过三种不同的方式来组建团队:


1. 集中团队

在这种模式下,有一个核心团队负责一切。他们从零开始构建和维护设计系统,包括定义系统的基础、创建UI工具包、开发组件库和风格指南。他们是唯一的负责人,外部人员无法参与其中。这个团队通常由设计师、工程师和产品经理组成。


2. 分布式团队

这种模式与集中团队相反,设计系统的消费者团队负责开发和维护系统。人们通常更喜欢参与自己帮助构建的系统,因此这种分布式模式可能会提高设计系统的受欢迎程度,进而促进更广泛的采用。此外,系统会从组织的各个角落获得创意和贡献,带来更多创新。由于多个团队共同维护系统,一个成员的缺席不会影响系统的整体进展,其他团队可以继续贡献。


3. 混合模式

这种模式结合了前两种模式的优点。它既有一个专注于设计系统的核心团队,能够加速开发和交付,还欢迎产品团队的贡献,从而带来更多创新。因此,这种模式既具备速度,也具备创新能力。

5. Audience of design systems 设计系统的受众

本节课的重点是:不要直接复制粘贴开源设计系统,因为设计系统的主要受众是其最初创建的团队和公司。它是为了体现他们的品牌和身份。

当一个设计系统开源或与公司外的成员共享时,其目的是欢迎外部成员的贡献,同时也为他人提供学习和灵感。因此,你需要自己动手,根据产品的特点,从头开始构建专属于你的设计系统。

目前大多数资源会教你如何在 Figma 中创建 UI 工具包,或在 React 中基于某些预构建的设计模板开发组件库,但这种方法是不对的。首先,你需要了解如何根据特定品牌构建和发展设计语言与基础,接下来,Figma、React、Gatsby.js 和 Storybook 等工具的使用将变得相对简单。

因此,我特别为这些概念专门设计了两个独立的部分来教你缺失的关键知识。请不要跳过这些内容,尽你所能认真学习。

6. A real-life example 现实生活中的例子

这一课的目的,是通过一个案例分析,让你了解设计系统的重要性,以及它如何节省时间、精力和金钱。

我们都熟悉按钮。它们看起来简单,易于开发,对吧?但事实是,开发一个按钮组件其实具有相当的复杂性和挑战。按钮是应用中最常用的组件之一,因此它们能够很好地展示你的品牌。

首先,你需要考虑按钮的属性,例如:内边距、字体、大小、颜色、字体类型等等。然后,还要考虑不同的状态,如悬停、激活、点击和禁用。通常,我们在代码中至少会有两种类型的按钮:主按钮和次按钮,甚至还可能有第三种类型的按钮。也许还需要带图标的按钮,有文字的或无文字的。每一种类型的按钮也可能会有主要和次要的两种样式。另外,不要忘了按钮的大小可能不同,比如小号和大号。除此之外,别忘了主题!如果有浅色主题和深色主题,那么所有按钮都需要相应的双份设计。

这是一项庞大的清单,对吧?现在,我们来算一算成本。假设我们有一个产品团队,包括一位设计师、一位工程师和一位质量保证工程师,他们共同负责按钮组件的设计、开发和测试。每人时薪100美元,每人完成150小时工作量,也就是每人平均花费约50小时在按钮上,这将使团队的成本总计约15,000美元。

15,000美元,仅用于开发按钮组件?听起来很夸张,但这只是一个团队的成本。假设在一家大型公司中,有50个这样的团队都在为各自的产品开发按钮,那么总成本可能会超过80万美元,甚至达到100万美元。而每个团队的按钮设计风格和质量可能都不尽相同。

这时,设计系统就派上了用场。设计系统能够将所有产品团队的设计统一起来,提供一个设计的单一来源,极大地降低成本。

7. The key concepts of design systems 设计系统的关键概念

所谓的设计语言,是一组标准和元素,通过产品来定义品牌的身份。可以将设计语言视为组件、品牌以及相关设计元素的“个性”。

设计语言包括两个部分:基础和组件。基础部分包含一些原则,用来展示品牌的个性,包括颜色、字体、网格布局、图标和一些图形动画等元素。而组件库则是一些在 React.js 或 Vue.js 等框架和库中开发的组件,它们将你的设计语言和 UI 工具包转化为实际的成品。

风格指南是设计系统的文档化内容,涵盖从设计语言和 UI 工具包到组件库的所有内容。构建风格指南的工具有多种,其中 Gatsby.js 和 Storybook 是最为著名的。

8. A practical checklist 实用检查清单

在将组件标记为完全交付之前,您需要进行设计和开发检查,以确保该组件满足所有要求。

设计阶段清单

  1. 无障碍性:所有用户,无论其条件如何,都应能够以相同的体验导航您的产品。设计系统应考虑到视觉障碍用户的需求,并确保他们可以轻松使用产品。
  2. 交互性:当用户与特定组件交互时,应该发生什么?是否有反馈需要展示给用户?定义所有可能的交互。
  3. 上下文使用:定义组件的使用场景。例如,如果有次要按钮和链接组件,应该在什么情况下选择使用哪一个。
  4. 状态:确保定义了所有状态,例如悬停、点击和禁用状态。
  5. 内容展示:该组件应有效地展示品牌形象,确保其展示内容准确。
  6. 自定义能力:该组件是否可自定义?如果可以,具体如何?例如,如果按钮组件被多个产品使用,则可能在不同产品中会有不同颜色。需要明确定义这些可自定义参数。
  7. 响应性:该组件在不同屏幕分辨率下的显示效果如何?布局是如何变化的?

开发阶段清单

  1. 无障碍性:使用语义化 HTML 开发产品,以确保兼容辅助技术,并实现键盘导航等功能。
  2. 响应性:确保组件能够对屏幕大小和分辨率变化作出正确响应。
  3. 自定义属性:检查是否正确实现了所有可定制属性。
  4. 错误处理:在组件出现错误时,它的响应是怎样的?
  5. 类型检查:确保组件接收到正确的属性(props)。
  6. 兼容性:确保所有依赖项在所有浏览器上表现一致,必要时使用 polyfill。

通过上述检查,确保组件满足设计和开发的质量标准。

9. Mistakes to avoid 避免的错误

在构建设计系统的过程中应避免的错误

  1. 勿过早考虑大规模扩展:在任务初期,不要立即考虑大规模实现,这样会让系统变得复杂。应先交付一个小规模可行的版本,然后再逐步扩展。这是一个需要时刻牢记的规则。

  2. 不要空手与团队讨论设计系统:首先设计并开发一些组件,然后再与他人讨论和合作。这样,团队成员可以在了解系统后立即着手工作。否则,他们会感到无聊,而你期望的系统采纳度也难以实现。

  3. 为协作制定明确的流程:当你想要与他人合作时,设立一个清晰的工作流程或程序非常重要。确保协作的路径明确,所有成员都能理解,以确保高效沟通和执行。

  4. 记录决策,避免重复解释:在构建设计系统时,资源丰富,关注度高,因此记录每一个决策是关键。这不仅能节省你和团队解释的时间,还能减少与利益相关者之间的沟通负担。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

17. Design System Building Components Using Figma 使用Figma构建组件的设计系统

1. Section Overview 部分概述

课程章节概述:使用 Figma 构建设计基础

在本章节中,我将演示 Figma 的使用,这是目前最受欢迎的设计工具,因其具备协作设计等多项功能。在这部分课程中,我们将学习如何为项目构建设计基础,从定义色彩方案到设计组件和复杂的用户界面(UI)。

  • 首先,我们会定义颜色样式。
  • 接下来,构建项目中按钮的各个状态。
  • 最后,我们将使用所有组件构建一个用于身份验证表单的模态窗口。

完成本章节后,您将能够熟练使用 Figma 来进行任何前端项目和设计系统的开发。

2. Hands-on Color Palette in Figma 在Figma中实际操作颜色调色板

使用 Figma 构建颜色样式和调色板

大家好,欢迎回来!本视频将展示如何在 Figma 网站上创建颜色调色板,并了解颜色样式的作用。

  1. 创建新文件

    • 登录 figma.com,并点击页面上的“新建设计文件”。
    • 在新文件中,我们将创建一个页面并命名为“Colors”,在其中定义我们的颜色调色板和框架。
  2. 颜色调色板

    • 在页面中创建框架或形状,以容纳不同的颜色。
    • 使用颜色标签显示每种颜色的十六进制代码,以便开发者轻松查阅和使用。
  3. 使用 Colors.co 获取颜色灵感

    • 访问 colors.co 网站获取调色板创意。
    • 选择你喜欢的颜色并复制它的十六进制代码,在 Figma 中将其应用到你的调色板中。
  4. 创建颜色样式

    • 选择框架中的颜色块并点击颜色填充旁边的四个点。
    • 选择“创建样式”,为样式命名(如 Primary 100),并保存该样式。
    • 以后可以在其他组件中直接应用此样式,以便于管理和修改。更改样式时,所有使用该颜色的组件都会自动更新。

通过这些步骤,你已经学会了如何在 Figma 中创建页面、使用 Colors.co 获取配色灵感,并创建颜色样式以便在其他组件中使用。谢谢观看!

3. Hands-on Button Building Practice 按钮构建练习

使用 Figma 创建按钮

大家好,欢迎回来!在本节课中,我们将使用 Figma 创建应用程序的按钮样式。

  1. 创建按钮页面

    • 在 Figma 中新建一个页面,并命名为“Buttons”。
    • 此页面将存放所有按钮样式、模板以及应用程序所需的按钮设计。
  2. 准备工作

    • 我已经为你准备了一些基础文件,你可以在 Udemy 平台上下载并导入到你的 Figma 项目中。
    • 我们将构建的按钮包括默认按钮、悬停样式、聚焦样式、点击样式和禁用样式等。
  3. 创建默认按钮

    • 首先,选择矩形工具来创建按钮的背景。
    • 将按钮背景颜色设定为主色,选择之前创建的颜色样式进行填充。
    • 设置按钮的圆角值,可以使用 Figma 的属性面板调整,例如将圆角设为 5。
  4. 添加按钮文本

    • 使用文本工具在按钮上添加文本,例如 "Save"。
    • 调整文本的大小和字体,使其符合应用程序的风格。
  5. 转换为组件

    • 选中按钮的背景和文本,右键选择“组群选项”将它们组合。
    • 再次右键选择“创建组件”,这样按钮就成为可复用的组件。
    • 可以在“Assets”选项卡中找到此按钮,方便在其他页面中拖拽使用。
  6. 复用与更新组件

    • 当你在其他页面(如“Colors”页面)中需要该按钮时,直接从“Assets”拖动并放置按钮即可。
    • 修改主组件时,所有使用该组件的实例将自动同步更新。
  7. 完成所有按钮

    • 请返回到按钮页面,并根据你创建的颜色和文本样式完成整个按钮集,包含警告、风险、提交按钮等。
    • 完成后,所有按钮样式将具备一致的风格,方便应用于你的项目中。

祝你设计愉快!

4. Hands-on Designing a Modal 设计模态框练习

在 Figma 中创建注册弹窗

欢迎回来!在本节课中,我们将使用 Figma 来创建一个注册弹窗,为页面设计添加一个有用的弹窗组件。

1. 创建页面

  • 在 Figma 中新建一个页面,命名为“Pop ups”。
  • 创建一个新页面后,准备添加一个框架来显示弹窗。

2. 创建弹窗框架

  • 在页面中拖拽一个矩形,设置大小为 700 x 400(具体尺寸可根据需求调整)。
  • 为弹窗框架选择合适的颜色,确保它与整体设计的色彩平衡相符。
  • 锁定框架以防止误移动。

3. 添加按钮

  • 从“Assets”中拖入我们之前创建的按钮组件,放置在弹窗内合适的位置。
  • 根据需求调整按钮的大小,确保它与弹窗整体视觉平衡。

4. 加入图片或插图

  • 打开 OnDraw 网站(或其他图标、插图资源网站),下载相关的 SVG 文件。
  • 拖拽插图到 Figma 中,调整大小并放置在弹窗内,确保它与整体设计一致。

5. 添加文本和标题

  • 使用文本工具添加标题,例如“Register”,并设置文本样式为 H2。
  • 添加描述文本,例如“Register and unlock all features”,并设置为 H5 样式。
  • 选中所有文本和元素,通过居中对齐工具确保所有内容居中对齐。

6. 添加关闭按钮

  • 在 OnDraw(或其他资源网站)上找到“关闭”图标,下载并导入 Figma。
  • 将关闭按钮放置在弹窗的右上角,调整大小以适配弹窗设计。

7. 最终检查和调整

  • 确保所有元素正确对齐且锁定。
  • 如果需要,可以添加阴影效果(如 Drop Shadow)来提高视觉层次。
  • 最后检查弹窗的整体效果,确保所有组件符合设计需求。

完成后,你将拥有一个功能齐全、视觉一致的注册弹窗组件,便于在项目中复用!

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

18. Design System Developing Components in React 在React中开发组件的设计系统

1. Extensible Foundations 可扩展基础

将 Figma 组件转换为 React JS 组件

欢迎回来!本节课将展示如何将设计的 Figma 组件转化为 React 组件,重点关注代码的可复用性和清洁性。我们将从创建一个按钮组件开始,最终将其纳入到一个模态框中。

1. 初始化 React 项目

  • 使用以下命令创建一个新的 React 项目:
    npx create-react-app my-app
  • 进入项目目录,并安装所需的 npm 包。
    cd my-app
    npm install styled-components polished

2. 设置全局样式

  • src 目录下创建一个 utils 文件夹。在此文件夹内创建一个 global.style.js 文件,用于定义全局 CSS 样式。
  • 使用 styled-components 库导入 createGlobalStyle,编写全局样式,包含常见的 CSS 重置。
  • 例如:
    import { createGlobalStyle } from 'styled-components';
    
    export const GlobalStyle = createGlobalStyle`
      *, *::before, *::after {
        box-sizing: border-box;
      }
      body {
        margin: 0;
        padding: 0;
        font-family: Arial, sans-serif;
      }
    `;

3. 创建颜色和字体比例的实用工具

  • utils 文件夹中创建 colors.util.jstypeScales.util.js 文件,用于存储颜色和字体比例。
  • 颜色示例:
    export const colors = {
      primary: "#1A73E8",
      secondary: "#9E9E9E",
      warning: "#FFC107",
      success: "#4CAF50"
    };
  • 字体比例示例:
    export const typeScale = {
      header1: "1.8rem",
      header2: "1.6rem",
      paragraph: "1rem"
    };

4. 创建并导入 Index 文件

  • utils 文件夹内创建一个 index.js 文件,并在其中集中导出所有样式配置。
  • 这样做的好处是可以通过一个导入语句访问所有的样式:
    export * from './global.style';
    export * from './colors.util';
    export * from './typeScales.util';

5. 创建按钮组件

  • src 目录下创建一个 components 文件夹,并在其中创建 Button.js
  • 使用 styled-components 创建一个带有样式的按钮组件,样式依赖于之前定义的颜色和字体比例。
  • 例如:
    import styled from 'styled-components';
    import { colors, typeScale } from '../utils';
    
    const Button = styled.button`
      background-color: ${colors.primary};
      color: white;
      font-size: ${typeScale.paragraph};
      padding: 8px 16px;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      &:hover {
        background-color: ${colors.secondary};
      }
    `;
    
    export default Button;

6. 在 App 组件中应用全局样式和按钮组件

  • 打开 App.js 文件,导入 GlobalStyleButton 组件。
  • GlobalStyle 添加到应用程序中,并在其中使用按钮组件:
    import React from 'react';
    import { GlobalStyle } from './utils';
    import Button from './components/Button';
    
    function App() {
      return (
        <div>
          <GlobalStyle />
          <h1>Hello, React!</h1>
          <Button>Click Me</Button>
        </div>
      );
    }
    
    export default App;

通过这些步骤,我们完成了项目的基本设置,并创建了一个带有全局样式和实用工具的基础 React 应用。接下来,我们可以继续开发其他组件,如模态框,并进一步扩展我们的设计系统。

2. Creating Button Component 创建按钮组件

使用 Styled Components 创建按钮组件

在本视频中,我们将使用 styled-components 库来构建一个按钮组件,并为其创建不同的状态和样式变体,例如主按钮、次按钮、禁用状态等。以下是实现过程的详细步骤:

1. 创建按钮组件文件

  • src 目录下创建 components 文件夹,然后在其中创建 Button.jsx 文件。

  • Button.jsx 文件中引入 styled-components 库:

    import styled from 'styled-components';
    import { colors, typeScale } from '../utils';

2. 创建基础按钮样式

  • 使用 styled-components 创建一个基本按钮组件,并定义其样式:

    export const Button = styled.button`
      font-size: ${typeScale.paragraph};
      padding: 8px 16px;
      border-radius: 4px;
      cursor: pointer;
      border: none;
      transition: all 0.3s ease;
      
      &:hover {
        background-color: ${colors.primaryHover};
      }
      
      &:focus {
        outline: 2px solid ${colors.primaryFocus};
      }
      
      &:disabled {
        cursor: not-allowed;
        background-color: ${colors.disabled};
        color: ${colors.disabledText};
      }
    `;

3. 为不同类型的按钮创建样式继承

  • 使用样式继承来创建其他类型的按钮(如主按钮和次按钮)。将主按钮样式继承基础按钮样式:

    export const PrimaryButton = styled(Button)`
      background-color: ${colors.primary};
      color: ${colors.textOnPrimary};
      
      &:hover {
        background-color: ${colors.primaryHover};
      }
    `;
    
    export const SecondaryButton = styled(Button)`
      background-color: ${colors.secondary};
      color: ${colors.textOnSecondary};
      
      &:hover {
        background-color: ${colors.secondaryHover};
      }
    `;

4. 添加不同的状态

  • 为按钮组件添加不同的状态,例如 :hover:active:disabled 状态:

    export const DangerButton = styled(Button)`
      background-color: ${colors.danger};
      color: ${colors.textOnDanger};
    
      &:hover {
        background-color: ${colors.dangerHover};
      }
    
      &:active {
        background-color: ${colors.dangerActive};
      }
    `;

5. 在应用程序中使用按钮组件

  • 打开 App.js 文件并导入 PrimaryButtonSecondaryButton、和 DangerButton,然后在应用程序中使用它们:

    import React from 'react';
    import { PrimaryButton, SecondaryButton, DangerButton } from './components/Button';
    
    function App() {
      return (
        <div>
          <h1>Button Component Demo</h1>
          <PrimaryButton>Primary Button</PrimaryButton>
          <SecondaryButton>Secondary Button</SecondaryButton>
          <DangerButton disabled>Danger Button</DangerButton>
        </div>
      );
    }
    
    export default App;

通过使用 styled-components 和样式继承,我们创建了一个易于扩展且结构良好的按钮组件。

3. Building a Modal 构建模态框

创建模态框组件:从 Figma 到 React.js

在此视频中,我们将通过使用 styled-components 创建一个模态框组件,逐步将设计转换为 React.js 组件,并实现模态框的布局、样式和关闭功能。以下是实现步骤:

1. 创建模态框组件文件

  • components 文件夹中创建 Modal.jsx 文件。

  • 引入 React 和 styled-components,并开始定义模态框的基本结构:

    import React from 'react';
    import styled from 'styled-components';
    import { PrimaryButton } from './Button';
    import { colors, typeScale } from '../utils';
    import closeIcon from '../assets/close.svg';
    import registerImage from '../assets/register.svg';
    
    const ModalContainer = styled.div`
      width: 700px;
      height: 400px;
      background-color: ${colors.white};
      box-shadow: 0px 5px 15px rgba(0, 0, 0, 0.1);
      position: absolute;
      top: 100px;
      left: 50%;
      transform: translateX(-50%);
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
    `;

2. 定义模态框内容容器

  • 创建一个内容容器,以便稍后将图像、标题和按钮布局在模态框内:

    const ModalHeader = styled.h3`
      font-size: ${typeScale.header3};
      color: ${colors.primaryText};
      margin-bottom: 16px;
    `;
    
    const ModalText = styled.p`
      font-size: ${typeScale.paragraph};
      color: ${colors.secondaryText};
      margin-bottom: 24px;
    `;

3. 引入关闭按钮和图片

  • 添加关闭按钮并将其样式化以在模态框的右上角显示。

  • 添加用于展示的图片。

    const CloseIcon = styled.img`
      width: 24px;
      position: absolute;
      top: 16px;
      right: 16px;
      cursor: pointer;
    `;
    
    const ModalImage = styled.img`
      width: 300px;
      margin-bottom: 16px;
    `;

4. 创建模态框的 React 组件

  • 定义 Modal 组件结构,并引入上述组件:

    const Modal = () => {
      return (
        <ModalContainer>
          <CloseIcon src={closeIcon} alt="close icon" />
          <ModalImage src={registerImage} alt="register illustration" />
          <ModalHeader>Register</ModalHeader>
          <ModalText>Unlock all features by registering now!</ModalText>
          <PrimaryButton onClick={() => alert('Register clicked!')}>Register</PrimaryButton>
        </ModalContainer>
      );
    };
    
    export default Modal;

5. App.js 中引入并使用模态框组件

  • 打开 App.js,引入并使用 Modal 组件:

    import React from 'react';
    import Modal from './components/Modal';
    
    function App() {
      return (
        <div>
          <Modal />
        </div>
      );
    }
    
    export default App;

6. 检查并完善样式

  • 运行应用程序,检查模态框组件的布局和样式是否符合预期。
  • 如果需要微调,例如调整对齐方式、增加间距或更改字体大小,可以在 ModalContainer 和其他样式组件中进行修改。

这样就完成了模态框组件的创建,并实现了从 Figma 设计到 React.js 组件的转换。未来视频将探讨如何进一步优化代码,增加组件的可重用性和维护性。

4. Reusability and Encapsulating Styles 重用和封装样式

封装样式:提高组件复用性的实践

在本视频中,我们探讨了“封装样式”的概念,这是构建高复用性组件的关键。封装样式的基本原则是:每个组件只为自身定义样式,而不直接影响外部环境。这意味着组件的样式不应包括影响布局的属性(如 marginwidthheightposition)。以下是封装样式的要点和未来章节的展望:

1. 避免布局样式

  • 组件应当专注于其内部样式,而不去影响外部布局。例如,我们之前在模态框中设置了 widthheightposition 等样式。这些样式会影响模态框组件的父元素或页面布局,使其在其他环境中难以复用。

  • 如果组件的样式包含了 marginabsolute 定位等布局样式,那么在嵌入其他组件时可能会遇到适配问题,导致需要重复构建组件。

2. 拆解低级样式为复用模式组件

  • 我们可以将常用的样式抽象成小的“模式组件”(Pattern Components)。例如:

    • Padding 和 Margin:定义组件时可以通过模式组件应用不同的填充和间距。
    • 水平和垂直居中:创建可复用的居中模式,避免每个组件都重新实现这些样式。
  • 通过组合这些小的模式组件,可以构建出更加复杂且灵活的布局。这样在创建新组件时,我们只需复用这些模式组件,无需重新定义基础样式。

3. 未来章节内容

  • 封装样式理论:我们将更深入地探讨封装样式的理论,了解其原则及应用。
  • 构建通用样式模式组件:接下来,我们将学习如何使用 styled-components 构建复用性强的样式模式,例如栅格布局、对齐方式等。
  • 重构模态框组件:之后,我们会使用这些模式组件重构模态框,以使其更加模块化和复用。
  • 更大型的项目练习:在章节的最后,我们将实践一个更大型的项目,以全面展示模式组件的复用优势,并巩固所学内容。

通过这些步骤,我们将最终掌握如何构建不仅美观,还具备高复用性和可维护性的组件。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

19. Design System Encapsulating Styles 封装样式的设计系统

1. Style Compositions 样式组合

样式组合:分解与复用的艺术

在本视频中,我们探讨了样式组合(Style Composition)的概念,并展示了如何使用这种方法来构建更具复用性和模块化的组件库。以下是关键要点:

1. 什么是样式组合?

  • 样式组合意味着将复杂的组件拆分为更小、更易管理的小组件。这些小组件被称为模式组件(Pattern Components),每个组件专注于实现某一种样式功能,例如排列、对齐、覆盖等。
  • 组件不仅存在于 React.js 应用中,也适用于其他框架,如 Angular 和 Vue.js。
  • 通过将常见样式(如 paddingbackground-coloraspect-ratio 等)封装在模式组件中,我们可以使这些样式高度复用。

2. 传统方法的局限性

  • 以往构建页面布局的常见做法是为页面的各个部分编写特定的 CSS 样式,比如顶部区域(top section)、底部区域(bottom section)等。
  • 虽然这种方法有效,但它通常会导致重复的代码,难以管理。当项目变大时,这种方法不利于复用和维护。
  • 样式组合旨在解决这一问题,使得页面或组件能够通过组合简单的模式组件来实现,而不是为每个新部分重新编写样式。

3. 实现样式组合的示例

  • 以一个 hero 页为例。它可能包含菜单栏、按钮、图标和主图像等元素。通过创建如下模式组件,可以有效管理样式:
    • Layers:用于垂直堆叠子元素,例如将菜单和 hero 部分层叠。
    • Inline:用于水平排列子元素,例如将菜单项和按钮排列成行。
    • Split:用于将屏幕分割为左右两部分,例如将 hero 图像和 CTA 部分分成左右。
    • Cover:用于居中布局组件,例如将 hero 内容垂直居中。
  • 利用这些模式组件,开发者可以简单地将这些组件包裹在页面各部分中,实现不同的布局效果,同时保持代码简洁和高复用性。

4. 未来的学习内容

  • 在接下来的内容中,我们将深入探讨如何实现这些模式组件,并以此构建更复杂的布局。
  • 我们还将展示如何用这些模式组件重构页面,从而最大限度地利用样式组合的优势来构建可维护且易于扩展的代码库。

总结

样式组合通过将样式功能抽象为小组件并加以组合,开发者可以更简洁地构建复杂布局。这种方法不仅可以减少代码重复,还能提高组件的复用性和维护性,是现代前端开发中的一种高效实践。

2. Encapsulating Styles 封装样式

封装样式:提高复用性和一致性

在本视频中,我们讨论了封装样式(Encapsulating Styles)的概念,并介绍了两条基本原则,帮助我们创建更具复用性和一致性的设计系统。以下是主要内容:

1. 封装的概念

  • 封装指的是控制对代码内部的访问权限,仅通过特定通道(例如方法或函数)暴露某些内容。
  • 样式封装的目标是将样式限制在组件内部,以避免与其他组件发生冲突。例如,组件中的 marginposition 样式不应影响外部环境。
  • 通过封装,组件能够独立管理其内部样式,确保其在不同环境中复用时不会出现样式冲突。

2. 封装样式的两大原则

原则一:组件不应该设置布局相关样式
  • 组件应仅负责管理其内部样式,而不是布局或影响外部环境的样式。
  • 属性如 positionsize(宽高)、margin 等,不应由组件自身设置,而应由其父组件控制。因为这些属性影响到组件的外部空间。
  • 将这些样式责任交给父组件,可以确保组件自身的样式只对其内部内容生效,不会影响外部环境。
原则二:组件应仅设置其自身及直接子组件的样式
  • 每个组件只应管理自身样式,以及直接子组件(即紧接在它之下的子元素)的样式。
  • 样式属性如 borderpaddingfont-familybackground-color 等,是可以安全地在组件中设置的,因为它们仅影响组件的内部。
  • 组件不应控制深层次子组件的样式,以保持良好的模块化和可维护性。

3. 例外情况

  • 在一些特定条件下,可能需要打破上述规则,但必须清楚为什么需要这么做,并在特殊情况下做出合理的取舍。
  • 了解何时以及为何破例,有助于在设计系统中灵活地应用封装样式,而不会影响整体代码的可维护性和一致性。

总结

通过应用封装样式的概念,我们可以创建更具复用性和一致性的组件,减少样式冲突,使得组件在不同环境中表现一致。这两条基本原则不仅能帮助我们构建稳健的设计系统,还能在日常开发中避免很多样式冲突问题。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

20. Design System Patterns for Spacing 间距模式的设计系统

1. Overview 概述

间距模式:实现简单化和复用性

在本节中,我们将深入探讨如何通过创建间距模式组件(Spacing Patterns Components),来简化布局中的间距处理。以下是主要内容:

1. 间距模式组件的作用

  • 我们将创建一组模式组件,帮助我们在不同组件中实现各种间距策略。
  • 通过这些模式组件,我们可以轻松地在代码中添加通用的间距和布局样式,提高代码的简洁性和复用性。
  • 使用这些组件,可以让我们的布局更灵活、样式一致性更高。

2. 使用 Style Components

  • 我们将在接下来的课程中使用 Styled Components 库,这个库在 React.js 中非常流行,用于构建样式化的组件。
  • Styled Components 允许我们直接在 JavaScript 中书写 CSS,使样式与组件紧密耦合,有助于实现样式的模块化管理。

3. 实现方式

  • 我们将通过构建不同的间距模式组件(例如 StackInlineSplit 等),来实现不同的布局需求。
  • 通过这种方式,我们可以在布局中灵活应用间距,而无需重复手动定义样式。
  • 这些组件不仅提高了样式管理的效率,还能提升项目的代码可读性和一致性。

总结

本节课是关于实现和应用间距模式的入门课程,通过利用 Styled Components 库,我们将构建一系列用于控制间距的模式组件。准备好了吗?让我们开始动手实现代码吧!

2. Layers Pattern 层次模式

层叠模式组件

在这个视频中,我们将介绍如何构建一个称为“层叠模式”的组件,它将帮助我们轻松实现垂直布局和间距管理。以下是关键步骤和内容:

1. 什么是层叠模式组件?

  • 层叠模式组件(Layers Pattern Component)是一种将多个元素或组件垂直堆叠在一起的方式。
  • 通过这种模式,我们可以设置各个元素之间的一致间距,同时保持代码简洁。
  • 这种布局在网页上非常常见,比如多个段落、社交媒体动态等,它们通常在垂直方向上排列,彼此间有一定间距。

2. 创建层叠模式组件

  • 我们将使用 Styled Components 库创建一个新的样式化组件。
  • 使用 display: grid;,使所有子元素按单列显示,每个子元素占据一个独立的行。
  • 我们还将添加 gap 属性,允许自定义每个子元素之间的间距。

3. 通过 props 自定义间距

  • 为了提高组件的灵活性,我们将使用 props 动态设置 gap 属性。
  • 如果用户没有传递间距值,组件将使用默认值。
  • 示例代码如下:
    const Layers = styled.div`
      display: grid;
      gap: ${props => props.gutter || '1rem'};
    `;
  • 通过这种方式,gutter 属性可控,并在没有指定时使用默认间距。

4. 空间方案(Space Scheme)

  • 为了标准化组件间距,我们创建了一个空间方案(Space Scheme),其中定义了不同的间距大小(如 smallmediumlarge 等)。
  • 这个方案可以帮助我们避免手动设置具体数值,而是使用预定义的尺寸:
    const spaceScheme = {
      small: '0.5rem',
      medium: '1rem',
      large: '2rem',
      // 其他间距定义...
    };
  • 使用 props 中传递的键值,可以从 spaceScheme 中动态选择相应的间距。

5. 示例:在订阅表单中使用

  • 我们使用 Layers 组件将表单中的输入项(例如姓名、电子邮件、提交按钮)垂直堆叠,并为每个部分设置所需的间距。
  • 例如:
    <Layers gutter="medium">
      <label htmlFor="name">Name</label>
      <input type="text" id="name" />
    </Layers>

6. 模块化并提高复用性

  • 最后,我们将 spaceScheme 单独存储到一个文件中,以便在项目的其他部分重用。
  • 通过导出该空间方案,任何需要标准化间距的组件都可以轻松导入和使用。

总结

通过这种层叠模式组件,我们可以将元素和组件垂直堆叠在一起,并控制它们之间的间距。这种模式不仅提高了代码的可读性,还确保了布局的一致性。接下来的视频中,我们将进一步探索如何构建其他实用的布局模式组件,帮助我们更好地组织页面布局。

3. Split Pattern 分割模式

分割模式组件

在本节中,我们将介绍如何创建“分割模式”组件,该组件通过将页面分为左右两侧,使布局更加灵活。以下是分割模式的基本内容和实现步骤:

1. 什么是分割模式?

  • 分割模式(Split Pattern)将布局水平分割为左右两部分,每一部分可以放置不同的内容。
  • 这种模式常用于在一个页面上显示对比内容,例如左侧展示说明文字,右侧放置一个表单。
  • 通过设置左右两列的比例,确保内容能够按照设计意图显示。

2. 创建分割模式组件

  • 使用 Styled Components 库创建分割模式组件,并设置 display: grid; 以实现网格布局。
  • 为了定义左右两列的宽度比例,我们使用 grid-template-columns 属性。
  • 默认设置为两列,每列占一个等分单位(1fr),相当于 50% 左右对半分。

示例代码如下:

const Split = styled.div`
  display: grid;
  gap: ${props => spaceScheme[props.gutter] || spaceScheme.large};
  grid-template-columns: 1fr 1fr;
`;

3. 自定义列比例

  • 使用 grid-template-columns 属性,我们可以通过传递比例来动态调整列宽。
  • 使用 fr 单位指定每列所占比例,例如 1fr 2fr 表示将宽度分为 3 个单位,左侧占 1 个单位,右侧占 2 个单位。
  • 示例代码:
    grid-template-columns: ${props => props.fractions ? fractions[props.fractions] : '1fr 1fr'};

4. 定义预设比例方案

  • 为了简化代码和确保布局一致性,我们定义了预设比例方案,类似于 spaceScheme 用于间距的方案。
  • 预设比例方案示例:
    const fractions = {
      oneToOne: '1fr 1fr',
      oneToTwo: '1fr 2fr',
      autoStart: 'auto 1fr',
      autoEnd: '1fr auto',
      // 可添加更多比例...
    };
  • 通过传递 fractions prop,用户可以指定所需的列宽比例。默认值可以设置为 oneToOne 或其他常用比例。

5. 示例:使用分割模式创建表单布局

  • 在一个简单表单中,我们可以使用 Split 组件将表单的标题放在左侧,而表单内容放在右侧。
  • 代码示例:
    <Split fractions="oneToTwo" gutter="medium">
      <div>Title</div>
      <div>Form Content Here</div>
    </Split>
  • 上述代码将页面分成两个部分,左侧为标题区域,占据总宽度的 1/3,右侧为表单内容,占据总宽度的 2/3。间距设置为 medium

6. 添加间距(Gap)和比例

  • 使用 gap 属性为左右部分设置间距,通过定义 gutter prop 来控制具体间距。
  • 在预设比例方案的帮助下,组件用户可以通过 gutterfractions 参数来控制左右列的间距和宽度。

总结

分割模式组件提供了一种方便的方式来实现左右分栏布局,并允许灵活设置列宽比例和间距。通过这种模式组件,我们可以创建高度可复用的布局元素,特别适用于表单、导航等场景。在接下来的课程中,我们将继续探索其他模式组件,帮助大家掌握构建复杂布局的技巧。

4. Column Pattern 列模式

列模式组件

在本节中,我们将创建“列模式”组件,使布局更加灵活。该组件允许你将内容划分为多个列,并且可以控制每列的宽度和列之间的间距。

1. 什么是列模式?

  • 列模式(Columns Pattern)可以将页面内容划分为多列。
  • 适用于表单或其他需要多列布局的场景。
  • 你可以自由设置列的数量以及列之间的间距。

2. 创建列模式组件

  • 使用 Styled Components 库创建列模式组件,并设置 display: grid; 以实现网格布局。
  • grid-template-columns 属性提供可重复的列模板,来定义列数和每列宽度。
  • 代码示例如下:
    const Columns = styled.div`
      display: grid;
      gap: ${props => spaceScheme[props.gutter] || spaceScheme.large};
      grid-template-columns: repeat(${props => props.columns || 2}, 1fr);
    `;

3. 动态调整列宽和数量

  • 使用 repeat 函数将列的数量和每列的宽度设置为可配置项。
  • 例如,可以传递 columns={3} 来定义三列布局。
  • 代码示例:
    <Columns columns={3} gutter="medium">
      <Column>Content 1</Column>
      <Column>Content 2</Column>
      <Column>Content 3</Column>
    </Columns>

4. 定义子组件:Column

  • 为了更灵活地控制列的占比,可以使用子组件 Column 并设置 grid-column 属性,允许其跨多列。
  • 示例代码:
    const Column = styled.div`
      grid-column: span ${props => props.span || 1};
    `;
  • 使用示例:
    <Columns columns={4}>
      <Column span={2}>Content 1</Column>
      <Column span={2}>Content 2</Column>
    </Columns>

5. 自适应列的最小占比

  • 可以通过 CSS 中的 min() 函数为列设置最小占比,以确保列不会超过可用的列数。
  • grid-column 属性中的 span 限制在一个合理的范围内,例如:
    grid-column: span ${props => `min(${props.span}, var(--columns))`};
  • 这样,可以防止由于设置过多的列数导致布局问题。

6. 示例:创建表单布局

  • 在一个表单中使用 ColumnsColumn 组件,将表单字段划分为多列布局。
  • 示例:
    <Columns columns={3} gutter="large">
      <Column span={1}>First Name</Column>
      <Column span={1}>Last Name</Column>
      <Column span={2}>Address</Column>
    </Columns>

总结

列模式组件为实现多列布局提供了灵活性,允许你动态设置列宽和列数,并且能够在各种布局需求中广泛应用。通过这种模式组件,我们可以创建高度可复用的布局元素,在不同屏幕尺寸和复杂布局中都能保持一致性。

5. Grid Pattern 网格模式

网格模式组件

在本节中,我们将创建一个灵活的**网格模式(Grid Pattern)**组件,这个组件可以帮助我们构建网格布局,并且可以轻松调整网格项之间的间距,支持根据屏幕宽度动态调整列的数量和项目宽度。

1. 网格模式的特点

  • 支持多列布局。
  • 可以指定每个项目的最小宽度,同时允许其在有足够空间时自动扩展。
  • 简单易用,适合展示卡片列表、联系人列表等布局。

2. 创建网格模式组件

  • 使用 Styled Components 库创建组件,并设置 display: grid 以实现网格布局。
  • grid-template-columns 属性使用 auto-fitminmax 函数,这样可以根据屏幕宽度动态调整列数和项目宽度。
  • 代码示例如下:
    const Grid = styled.div`
      display: grid;
      gap: ${props => spaceScheme[props.gutter] || spaceScheme.large};
      grid-template-columns: repeat(auto-fit, minmax(${props => props.minItemWidth || '310px'}, 1fr));
    `;
  • minmax() 中,第一个参数设置每个网格项的最小宽度,第二个参数为最大宽度(这里是 1fr,即自动填充剩余空间)。

3. 自动适应屏幕大小

  • 使用 auto-fitminmax 组合,可以使网格在屏幕较小时减少列数,并在有足够宽度时自动增加列数。
  • 例如,当屏幕较小时,项目宽度保持 24rem,但是当有更多空间时,列数会自动增加。

4. 设置自定义宽度和间距

  • 可以将最小宽度设置为可配置项,使组件更灵活。例如,通过传递 minItemWidth 属性来调整每项的最小宽度:
    <Grid minItemWidth="24rem" gutter="x-large">
      {cards.map(card => <Card key={card.id}>{card.content}</Card>)}
    </Grid>
  • gutter 属性允许你设置列之间的间距。可以使用 spaceScheme 预设的值,或根据需要定义新的间距。

5. 避免布局溢出

  • 如果屏幕宽度小于最小项宽度,可以使用 min() 函数来确保内容在屏幕内显示。更新后的代码如下:
    grid-template-columns: repeat(auto-fit, minmax(min(100%, ${props => props.minItemWidth || '310px'}), 1fr));
  • 使用 min(100%, 310px) 表示在宽度不足时,将项目的最小宽度设为屏幕宽度,从而避免内容超出屏幕。

6. 示例代码

  • 创建一个简单的联系人列表示例:
    const ContactList = () => (
      <Grid minItemWidth="24rem" gutter="large">
        <Card>Contact 1</Card>
        <Card>Contact 2</Card>
        <Card>Contact 3</Card>
        {/* Add more cards as needed */}
      </Grid>
    );

总结

通过这种网格模式组件,我们可以灵活地构建响应式网格布局,不需要手动调整媒体查询或管理各个屏幕尺寸下的显示效果。该组件简洁、实用,适用于各种应用场景中的卡片列表、商品展示等布局需求。

6. Inline-Bundle Pattern 内联捆绑模式

内联组合模式组件

在本节中,我们将创建一个**内联组合模式(Inline Bundle Pattern)**组件,这个组件的作用是在屏幕宽度不足时,将内联元素重新排列成多行,同时保持行内元素的布局样式。此外,我们还将允许自定义对齐和元素间的间距,以便适应不同的设计需求。

1. 内联组合模式的特点

  • 当可用宽度不足时,将内联元素换行排列,同时保持每行内元素之间的间距一致。
  • 支持对齐选项,使组件内容可以左对齐、右对齐或居中对齐。
  • 允许自定义元素之间的间距(gutter)。

2. 创建内联组合模式组件

  • 使用 Styled Components 库创建组件,设置 display: flex 并启用 flex-wrap 属性以便元素换行。
  • 为了实现对齐功能,我们将 justify-content 设置为 flex-endflex-startcenter,以控制内容的水平对齐方式。
  • 代码示例如下:
    const InlineBundle = styled.div`
      display: flex;
      flex-wrap: wrap;
      justify-content: ${props => justifyScheme[props.justify] || justifyScheme.start};
      align-items: ${props => alignScheme[props.align] || alignScheme.center};
      gap: ${props => props.gutter || '1rem'};
    `;
  • justifySchemealignScheme 是两个对象,分别包含了 flex-startflex-endcenter 等值,以根据传递的属性来控制内容的对齐。

3. 对齐和间距设置

  • 创建对齐设置对象 justifySchemealignScheme,其中包含用于控制主轴和交叉轴对齐方式的设置:
    const justifyScheme = {
      start: 'flex-start',
      end: 'flex-end',
      center: 'center',
    };
    const alignScheme = {
      start: 'flex-start',
      end: 'flex-end',
      center: 'center',
    };
  • 允许组件使用传入的 justifyalign 属性来动态调整布局。

4. 自定义间距

  • 使用 gap 属性来设置元素之间的间距,并提供一个默认值:
    gap: ${props => spaceScheme[props.gutter] || spaceScheme.large};
  • spaceScheme 是一个预定义的对象,用于提供不同大小的间距选项,如 smallmediumlarge 等。

5. 示例代码

  • 创建一个包含菜单项的示例,展示内联组合模式的使用:
    const Menu = () => (
      <InlineBundle justify="center" align="start" gutter="large">
        <div>Product</div>
        <div>Features</div>
        <div>Marketplace</div>
        <div>Company</div>
        <div>Login</div>
      </InlineBundle>
    );
  • 在这个示例中,菜单项在可用空间不足时会自动换行排列,并保持水平居中对齐和上对齐。

总结

内联组合模式组件提供了一种简洁的解决方案来排列内容,同时保证对齐和间距的一致性。它的设计灵活,适用于菜单栏、标签列表等内容动态换行的场景。通过可配置的对齐方式和间距选项,这个组件不仅易于使用,也便于重用和扩展。

7. Inline Pattern 内联模式

内联模式组件

在本节中,我们将创建一个**内联模式(Inline Pattern)**组件,它的主要作用是保持子元素在同一行水平排列,同时在空间不足时,将它们垂直堆叠。相比之前的内联组合模式,这种模式还增加了组件在有限空间内自适应的功能,使其更具灵活性。

1. 内联模式的主要功能

  • 子元素在水平空间足够时保持水平排列(inline)。
  • 当空间不足时,将子元素在垂直方向上堆叠排列。
  • 可以根据需要设置对齐方式,使特定元素根据空间变化自动调整位置。
  • 支持自定义元素之间的间距。

2. 创建内联模式组件

  • 使用 Styled Components 扩展内联组合模式,继承其布局特点,并在此基础上实现新增功能。
  • 代码如下:
    const Inline = styled(InlineBundle)`
      flex-wrap: nowrap;
      & > * {
        flex: 1;
        min-width: fit-content;
        flex-basis: calc(40rem - ${props => props.gutter || '1rem'});
      }
    `;
  • 使用 flex-basis 属性来调整每个子元素的宽度,使其在可用空间内适应。

3. 对齐和间距设置

  • 提供 alignjustify 属性,控制子元素的对齐方式。
  • 创建 stretchSchema 对象来管理元素在不同情况下的拉伸模式,如 startendcenter
  • 使用示例:
    const Menu = () => (
      <Inline justify="center" align="center" gutter="large">
        <div>Home</div>
        <div>About</div>
        <div>Contact</div>
      </Inline>
    );

4. 响应式调整

  • 使用 calc() 函数动态计算 flex-basis 的值,以便根据空间大小调整元素宽度。
  • 在宽度不足时,子元素将自动堆叠,以适应屏幕的大小。
  • 使用条件 CSS 确保布局在不同设备上的一致性。

5. 示例代码

  • 创建一个菜单示例,展示内联模式的使用:
    const Navbar = () => (
      <Inline justify="end" align="center" gutter="large">
        <div>Products</div>
        <div>Features</div>
        <div>Marketplace</div>
        <div>Company</div>
        <Inline justify="end" align="center">
          <button>Login</button>
          <button>Register</button>
        </Inline>
      </Inline>
    );
  • 在此示例中,当空间不足时,菜单项将垂直堆叠排列,而按钮部分始终右对齐。

总结

内联模式组件提供了一种灵活的方式来处理水平和垂直布局的切换,使得内容在空间充足时可以水平排列,而在空间不足时自动堆叠。通过使用 flex-basiscalc() 函数,该组件能够智能地适应不同屏幕大小。这种设计适合导航栏、菜单、标签等需要响应式布局的元素。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

21. Design System Patterns for More Complex Styles 更复杂样式的设计系统模式

1. Overview 概述

Wrapper组件模式

在本节中,我们将讨论在React中创建Wrapper组件模式,即用于包装其他元素并帮助构建特定布局的组件。这些Wrapper组件不仅可以帮助我们处理空间间距,还可以用于更全面的布局功能,从而轻松实现复杂的样式。

1. Wrapper组件的目的

  • Wrapper组件用于封装布局行为。
  • 这些组件允许我们包裹其他元素或组件,以实现特定的布局样式,例如居中、分层或定位。
  • 典型用例包括创建处理对齐、间距和嵌套布局的组件。

2. 引入as属性模式(多态组件)

  • as属性模式(也称为多态组件)允许Wrapper组件渲染成不同的HTML元素或其他组件。
  • 这种模式增强了灵活性,使单个组件可以根据具体的用例承担不同的角色。
// `as`属性模式示例
const Wrapper = ({ as: Component = 'div', ...props }) => {
  return <Component {...props} />;
};

// 使用示例
<Wrapper as="section">这是一个section</Wrapper>
<Wrapper as="button">这是一个button</Wrapper>

3. 组合Wrapper组件模式

  • 通过组合CenterLayer等Wrapper模式,我们可以创建结合多种布局功能的组件。例如:
<Center>
  <Layer>
    <p>您的内容在此...</p>
  </Layer>
</Center>
  • 借助as属性,我们可以在不失去已应用样式和行为的情况下切换组件:
<Wrapper as={Center}>
  <Wrapper as={Layer}>
    <p>居中和分层的内容</p>
  </Wrapper>
</Wrapper>

4. 多态Wrapper组件的示例代码

  • 以下代码片段展示了使用as属性模式创建多态Wrapper组件的基础实现:

    const Wrapper = ({ as: Component = 'div', children, ...props }) => (
      <Component {...props}>{children}</Component>
    );
    
    const App = () => (
      <>
        <Wrapper as="button" onClick={() => alert('按钮点击!')}>
          点击我
        </Wrapper>
        <Wrapper as="header">
          <h1>这是一个标题</h1>
        </Wrapper>
      </>
    );

总结

Wrapper组件通过封装特定的设计模式提高了布局样式的可重用性。借助as属性,我们可以让这些组件多态化,使其适应布局中的不同角色。这样,我们能够实现更模块化、更灵活且易于维护的代码库。在下一节中,我们将深入构建独立的Wrapper组件,以便在不进行手动样式化的情况下实现复杂且响应式的布局。

2. Pad Pattern 填充模式

填充组件模式

在本节中,我们将讨论填充组件模式(Pad Pattern),这是一个为任意组件或元素添加填充空间的模式。填充空间是元素的一部分,有助于确保布局中的一致性,例如让填充空间和网格间距(gutter)相匹配。

1. 填充组件的目标

  • 填充组件用于向其他组件或元素添加内边距。
  • 通过添加填充,组件能更好地控制布局中的空间,例如将内容与边框分隔开。
  • 可以设置统一的填充规则,使得间距和填充保持一致。

2. 创建 Pad 组件模式

  • 我们将创建一个称为 Pad 的组件,用于在包裹的内容上应用填充。
  • Pad 组件可以使用不同大小的填充空间(例如 small, medium, large),并确保这些填充空间与其他样式(例如间距)保持一致性。
import styled from 'styled-components';

const Pad = styled.div`
  padding: ${(props) => props.padding || '1rem'};
`;

export default Pad;

3. 多值填充的实现

  • 有时候,我们需要在不同方向上设置不同的填充,例如垂直方向小的填充,水平方向大的填充。

  • CSS中的填充属性支持多个值(最多四个),用于控制上、右、下、左方向的填充。

  • 根据这些知识,我们可以使用数组来指定每个方向的填充,并通过 map 方法将其转换为样式字符串。

    const Pad = styled.div`
      padding: ${(props) => {
        const paddingValues = Array.isArray(props.padding) ? props.padding : [props.padding];
        return paddingValues.map((p) => spaceScheme[p]).join(' ');
      }};
    `;

4. 填充组件的用法

  • Pad 组件用于某个元素时,我们可以指定不同的填充大小,以实现符合设计需求的布局。
// 使用 Pad 组件
<Pad padding={['small', 'large']}>
  <Button>点击我</Button>
</Pad>

5. 使用as属性简化代码

  • 为了更灵活地使用组件,可以通过 React 的 as 属性让 Pad 组件渲染成不同类型的元素,例如 <button>
  • 此外,可以使用多态性特性,为 Pad 添加不同的属性以适应不同的使用场景。
const CustomButton = styled(Pad).attrs({ as: 'button' })`
  background-color: crimson;
  color: white;
  padding: small large;
`;

// 使用 CustomButton
<CustomButton>自定义按钮</CustomButton>

总结

填充组件模式(Pad Pattern)允许我们轻松地为组件添加一致的填充空间,并可以灵活地通过as属性设置不同的渲染类型。在开发复杂布局时,填充组件能提高代码的可维护性和复用性,同时通过统一的填充策略,保持视觉的一致性。

3. Center Pattern 居中模式

居中组件模式

在本节视频中,我们将创建一个称为居中组件模式(Center Pattern)的组件。该组件的作用非常简单:将其包裹的所有子元素在父级组件中水平居中。以下是如何实现和使用该模式的详细介绍。

1. 居中组件的作用

  • 居中组件模式主要用于水平居中对齐。
  • 包裹的子元素也可以通过添加相应的属性来进行子元素的垂直居中。
  • 可以让特定区域的内容更具结构性和中心对齐的视觉效果。

2. 构建 Center 组件模式

  • 使用 styled-components 库创建一个 Center 组件。
  • 组件通过设置 margin-inline-startmargin-inline-endauto 来实现水平居中。
  • 我们使用 content-box 作为 box-sizing,这确保外边距成为组件本身的组成部分,而不会影响其布局原则。
import styled from 'styled-components';

export const Center = styled.div`
  box-sizing: content-box;
  margin-inline-start: auto;
  margin-inline-end: auto;
  max-inline-size: ${(props) => props.maxWidth || '60ch'};
`;

3. 实现条件 CSS 以居中子元素

  • 我们还可以通过传递 centerTextcenterChildren 属性,使子元素居中。
  • centerText 用于文本的水平居中,centerChildren 用于将所有子元素在垂直方向上居中。
  • 通过检查属性的存在与否,返回特定的 CSS 样式,以实现这些功能。
${props => props.centerText && `
  text-align: center;
`}

${props => props.centerChildren && `
  display: flex;
  flex-direction: column;
  align-items: center;
`}

4. 组件的使用

  • 可以将 Center 组件包裹在任何需要居中对齐的元素上。
  • 使用属性来指定文本或子元素是否需要在垂直方向上对齐。
<Center centerText centerChildren maxWidth="60ch">
  <h1>标题</h1>
  <p>这是一段描述文本。</p>
  <button>点击我</button>
</Center>

5. 组合使用其他模式

  • 可以将 Center 与其他组件模式结合使用。例如,我们可以用 Layers 模式为子元素之间增加间距。
  • 通过 as 属性,可以将 Center 渲染成不同的组件,例如使用 Layers 来控制子元素之间的间距。
<Center as={Layers} gutter="small" centerText centerChildren maxWidth="60ch">
  <h1>标题</h1>
  <p>这是一段描述文本。</p>
  <button>点击我</button>
</Center>

总结

居中组件模式(Center Pattern)让我们能够轻松地将内容居中对齐,并且可以选择性地控制文本和子元素的居中方式。结合使用 Layers 等其他模式,还可以更好地控制布局中的空间。这个模式的好处是代码更简洁,布局更规范,能够在不同的使用场景中保持一致的视觉体验。

4. Media-Wrapper Pattern 媒体包装器模式

媒体包装器组件模式

在本节视频中,我们将探讨如何构建一个媒体包装器模式,它用于处理网页上图像和视频的显示。图像和视频的显示可能会面临尺寸和纵横比的挑战。媒体包装器组件模式帮助我们在不同屏幕大小和视图中保持图像和视频的响应性。

1. 媒体包装器的作用

  • 确保图像和视频在网页上的纵横比保持一致。
  • 根据可用的空间自动调整图像和视频的尺寸。
  • 使得组件代码更加模块化,可重用,只需简单地将图像和视频包裹在媒体包装器组件中,即可应用设置的效果。

2. 构建 Media Wrapper 组件模式

  • 使用 styled-components 库创建一个名为 MediaWrapper 的组件。
  • 设置 MediaWrapperpositionrelative,以便将图像或视频在容器内定位。
  • 使用 widthheight 来定义默认的纵横比(例如 1:1)。
import styled from 'styled-components';

export const MediaWrapper = styled.div`
  position: relative;
  width: 100%;
  height: 0;
  padding-bottom: ${(props) => (props.ratio ? (props.ratio[1] / props.ratio[0]) * 100 : 100)}%;
  overflow: hidden;

  img, video {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
`;

3. 实现响应性纵横比

  • padding-bottom 用于定义高度,以保持纵横比。
  • 如果浏览器支持 aspect-ratio,我们可以直接使用它,但为了兼容性,使用 padding-bottom 的方式来计算纵横比。
  • 为 Safari 等不支持 aspect-ratio 的浏览器提供了一个替代方案。
aspect-ratio: ${(props) => props.ratio ? props.ratio[0] + '/' + props.ratio[1] : '1/1'};
@supports not (aspect-ratio: 1 / 1) {
  padding-bottom: ${(props) => (props.ratio ? (props.ratio[1] / props.ratio[0]) * 100 : 100)}%;
}

4. 使用 MediaWrapper 组件

  • MediaWrapper 组件用作包裹图像和视频元素的容器。
  • 通过 ratio 属性传递所需的纵横比,以适应不同的内容需求。
<MediaWrapper ratio={[16, 9]}>
  <img src="image-url.jpg" alt="描述" />
</MediaWrapper>

5. 为不同的元素添加样式

  • 为了适应不同的情况,可以将其他类型的内容(如 div 元素)也应用到该模式中。
  • 使用 flex 布局来确保这些内容在容器内正确对齐,并且不受溢出影响。
.wrapper {
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}

总结

媒体包装器组件模式为网页开发提供了一个简单而强大的方法来处理不同的媒体显示需求。通过此模式,开发者能够在多种设备上实现一致的图像和视频布局,保持纵横比,并且简化代码。这个组件模式不仅提高了可重用性,还可以帮助创建更具响应性和可控性的用户界面。

5. Cover Pattern 封面模式

垂直居中组件模式

在本节课程中,我们将探讨一种组件模式,该模式可以帮助我们在网页上实现垂直居中布局。该模式的组件可以垂直居中其内部的元素,同时允许您在顶部和底部添加额外的组件或内容(例如导航条或页脚)。

1. Cover Pattern 的作用

  • 垂直居中:将内容居中显示,通常用于英雄页面或大型内容块。
  • 顶部和底部区块:可以选择性地添加顶部和底部内容区域,以方便增加附加内容。
  • 灵活布局:利用 CSS Grid 设置行布局,使得中间内容能够根据空间进行自适应扩展,同时确保顶部和底部区域不影响中心内容。

2. 构建 Cover Pattern 组件模式

  • 使用 styled-components 库创建 CoverPattern 组件。
  • 设置 display: gridgrid-template-rows,确保内容按行排列并居中。
  • 使用 min-height: 100vh 使内容占满整个视口高度。
import styled from 'styled-components';

export const CoverPattern = styled.div`
  display: grid;
  min-height: 100vh;
  grid-template-rows: ${(props) => 
    props.top && props.bottom ? 'auto 1fr auto' : 
    props.top ? 'auto 1fr' : 
    props.bottom ? '1fr auto' : '1fr'
  };
  align-items: center;
`;

3. 实现条件渲染顶部和底部内容

  • 使用属性 topbottom 检查是否传入顶部或底部内容。
  • topbottom 被传入时,自动调整行模板,以便为顶部和底部内容分配适当的空间。
const Top = () => <div>顶部内容</div>;
const Bottom = () => <div>底部内容</div>;

<CoverPattern top={<Top />} bottom={<Bottom />}>
  <h1>主内容区块</h1>
</CoverPattern>

4. 使用其他组件模式(如 Padding)增加间距

  • 可以将 CoverPattern 组件与其他模式结合,例如 Pad 模式,以确保顶部和底部与中间内容的间距一致。
  • 使用 Pad 模式为整个布局增加外边距。
import { Pad } from './PadPattern';

<CoverPattern as={Pad} padding="large" top={<Top />} bottom={<Bottom />}>
  <h1>主内容区块</h1>
</CoverPattern>

总结

通过构建和使用 CoverPattern 组件,我们能够更轻松地管理页面布局,并确保垂直方向的内容居中。这种模式非常适用于英雄页面布局或中心化的内容显示,可以与其他组件模式组合使用,以提高布局的灵活性和可维护性。

6. Revisiting the Modal 重新审视模态框

使用样式模式重新构建弹出框

在这个章节,我们将使用之前创建的样式模式(pattern)来重新构建一个弹出框组件。我们会利用不同的模式来控制布局、居中、间距等样式,以减少代码冗余并增强组件的可重用性。

1. 构建弹出框基本结构

  • 创建基本的弹出框组件,并导出。
  • 使用 CoverCenter 模式来控制弹出框的垂直和水平居中。
import styled from 'styled-components';
import { Cover, Center } from './patterns';

const Modal = () => (
  <Cover as={Center} maxWidth="50rem">
    <ContentArea>
      {/* 内容区块 */}
    </ContentArea>
  </Cover>
);

export default Modal;

2. 设计内容区块

  • 定义 ContentArea 组件来包裹所有内容,利用 LayersPad 模式来实现层叠和内边距。
  • 设定阴影效果来模拟边框。
const ContentArea = styled(Layers).attrs({
  as: Pad,
  padding: 'large',
  gutter: 'small',
})`
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  background-color: #fff;
`;

3. 中心图像样式

  • 创建 StyledImage 组件来处理图片样式,通过 Center 模式使其水平居中。
  • 使用 maxWidth 属性控制图片大小。
const StyledImage = styled(Center).attrs({
  as: 'img',
})`
  max-width: ${(props) => props.maxWidth || '30rem'};
`;

4. 文本和按钮组件

  • 创建通用 TextStyledButton 组件来管理文本和按钮样式。
  • 利用 props 使组件具有可配置性。
const Text = styled(Center).attrs({ as: 'span' })`
  font-size: ${(props) => props.fontSize || '1rem'};
`;

const StyledButton = styled(Center).attrs({ as: 'button' })`
  background-color: #007bff;
  color: white;
  border-radius: 4px;
  border: none;
  cursor: pointer;
  font-size: 1rem;
`;

5. 构建完整的弹出框布局

  • 使用嵌套的 Layers 模式来管理文本、按钮和其他内容间的间距。
  • 使用 Pad 模式来添加外边距,并确保弹出框的内容层叠有序。
const Modal = () => (
  <Cover as={Center} maxWidth="50rem">
    <ContentArea>
      <StyledImage src="register.png" alt="Register" />
      <Layers gutter="large">
        <Layers gutter="small">
          <Text fontSize="2rem">Register</Text>
          <Text fontSize="1.2rem">Unlock all features</Text>
        </Layers>
        <StyledButton>Get Started</StyledButton>
      </Layers>
    </ContentArea>
  </Cover>
);

总结

通过将样式模式封装到可重用的组件中,我们可以大幅减少样式代码的冗余,同时提升组件的灵活性和可维护性。利用 CoverCenterPadLayers 等模式,可以轻松实现复杂布局。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

22. Design System Final Project 设计系统最终项目

1. Project Assignment 项目任务

最终项目:用样式模式构建设置页面

在这个最终项目中,我们将使用前面课程中创建的样式模式(patterns)来构建一个实际的网页。我们的目标是通过重用这些模式来提高代码的可维护性和开发效率,同时体验如何利用这些模式简化页面设计。

项目目标

创建一个类似用户配置文件或设置页面的网页。页面将包含标题区域、主内容区以及其他可能的内容块。我们会使用以下模式来实现不同区域的布局和样式:

  1. Layers 模式:用于分层内容。
  2. Inline 模式:用于将元素水平排列。
  3. Pad 模式:用于添加内边距以创建适当的间距。
  4. Cover 和 Center 模式:用于垂直和水平居中对齐内容。

任务说明

  1. 构建标题区域:从页面顶部开始,使用 Layers 模式来创建标题区域。使用 Inline 模式对齐标题中的图标、文本和操作按钮。

  2. 实现内边距:根据需求使用 Pad 模式来添加内边距。例如,可以为左右两侧设置较大的内边距,使得内容显得更有层次感和排版合理。

  3. 主内容布局:为内容区创建分块布局,使用 Layers 和 Inline 组合来合理安排内容的排列。将内容按照需要分组并对齐。

  4. 调整与优化:在布局的基础上进一步优化,例如调整字体大小、颜色等,使页面更具吸引力和易读性。

实践练习

在接下来的课程中,我们将逐步完成这个页面构建过程,你可以在每一步后对照自己的实现与我们的解决方案,看看如何利用这些模式来实现更简洁和灵活的代码。

现在可以开始动手,尝试用课程中的模式来创建页面的各个部分。完成后再继续观看下一个视频,我们将逐步展示如何实现这些部分。

2. Solution Building a Navbar with Menu and Header 解决方案:构建带菜单和标题的导航栏

菜单栏和搜索框实现:分步构建设置页面头部

在这一部分,我们继续完善前端项目,重点构建页面的头部区域。通过分步讲解,我们实现了菜单栏的样式、搜索框设计,并且准备好下一步要构建的标题部分。

项目进展

  1. 创建 Menu 组件:我们首先新建了 menu.js 文件,将菜单栏的结构和样式与主页面代码分离,保证项目结构的清晰。
  2. 使用 Inline 和 Pad 模式:使用 Inline 模式将菜单项和搜索框对齐,并用 Pad 模式为菜单栏整体添加了内边距,使内容在视觉上更加集中和协调。
  3. 添加 Logo 和图标:通过 Logo 组件展示用户头像、网站图标等。我们在 Logo 组件中使用样式来控制 logo 尺寸和形状(圆形或方形),并根据传递的 props 来设置颜色。
  4. 设置菜单项样式:创建了 Item 组件,用于显示菜单项,并为选中的菜单项设置了高亮效果。通过灵活使用样式和 props,我们成功地为菜单项添加了 hover 和 active 状态。
  5. 实现搜索框设计:创建了 SearchBar 组件,该组件使用 Pad 和样式来实现必要的背景色、内边距和圆角效果,使得搜索框在视觉上与菜单保持一致。

下一步

在接下来的课程中,我们将集中精力完善页面的其他区域,包括标题部分和左侧导航栏。通过将各部分代码分离并模块化处理,我们最终将构建一个功能完整的设置页面,进一步展示如何高效地使用样式模式来快速实现页面布局和样式。

接下来,尝试在你的项目中复现这些步骤,并将代码与课程内容进行对照,以加深对每个步骤的理解。

3. Solution Building a Sidebar Menu 解决方案:构建侧边栏菜单

实现左侧导航栏和侧边栏样式

在这个视频中,我们进一步完善了页面的左侧导航栏,为页面布局添加了更多样式和组件。我们通过几个步骤实现了样式设计并且创建了基本布局,为下一步的右侧内容区域打下了基础。

项目进展

  1. 创建 Content 组件:我们首先新建了 content.jsx 文件,将内容区从主页面代码中分离出来,并定义了基础布局以支持未来的扩展。
  2. 设计 Header 区域:为 Header 区域创建了 ContentArea 组件,使用了渐变背景、内边距等样式,使其看起来更具层次感和视觉美感。为使背景颜色在页面主体中保持一致,我们更新了 index.css 文件,设置了与页面背景一致的颜色。
  3. 实现 Sidebar 组件:创建了 sidebar.jsx 文件,将侧边栏从主页面中分离。通过使用 LayerPadSplit 等模式,完成了左侧菜单项的样式设计,并确保每个菜单项水平排列,具有间距。
  4. 为导航栏菜单项添加图标:通过 Logo 组件和 Inline 模式添加图标,为每个菜单项引入了图标,并调整了图标与文本的水平对齐,使视觉效果更加清晰。
  5. 添加激活状态样式:我们为左侧导航栏的菜单项增加了 Active 状态,通过自定义边框样式和背景色,使得当前激活的菜单项更为突出。

下一步

下一部分将集中在实现右侧内容区域的布局,进一步完善页面的整体设计。通过继续使用模式化组件和样式,我们将确保整个页面的布局一致性,同时提升代码的可读性和可维护性。

请继续尝试构建该项目,将代码与视频中的步骤对照,以更好地理解每个步骤的实现过程。

4. Solution Building the Form 解决方案:构建表单

实现右侧表单内容区域

在这部分视频中,我们完成了页面右侧的内容区域,该区域包括了一个个人信息表单和控制按钮。

项目进展

  1. 创建 RightSide 组件:新建了 right_side.jsx 文件,定义了 RightSide 组件并为整个表单内容区域设定了基础布局。
  2. 表单区域样式:为表单区域应用了边框样式,以使其与背景区分开来。使用 styled-components 创建了表单的外层容器组件,应用了左右边框,使视觉效果更佳。
  3. 分段布局:我们使用了分层和拆分组件来实现不同元素的堆叠与对齐。例如,用户名、简介和图片部分使用 LayerSplit 模式实现了纵向和横向的分布,使页面看起来更简洁且层次分明。
  4. 定义通用组件:为了复用表单中标签和输入框组合的样式,我们创建了 Input 组件,将标签和输入框封装在一起,以保持代码简洁并提高可维护性。
  5. 样式调整:为表单输入框和按钮添加了自定义样式,使它们更符合整体设计风格。增加了 paddingmargin 以提供适当的空间,并设置了文本和边框颜色,以提升可读性。
  6. 实现多列布局:在两个输入框的行上应用了 Columns 模式,将表单中的输入项按列排列。使用 ColumnsColumn 模式实现双列布局,使其更符合表单设计规范。

下一步

  1. 完成右侧表单区域的控制按钮样式。
  2. 根据设计需求调整按钮的布局和风格,使整个表单功能区显得更为协调。
  3. 继续对页面进行调试和细节优化,以确保用户在交互时获得流畅的体验。

通过本次视频的构建,我们已经构建了右侧表单区域的整体布局与样式设计。尝试进一步完善这一区域,并调整样式以匹配预期的用户界面设计。

5. Solution Finishing Buttons 解决方案:完成按钮

最终步骤:实现表单的 Save 和 Cancel 按钮

在这一部分,我们为表单添加了最终的控制元素,即 Save 和 Cancel 按钮。以下是实现细节:

实现过程

  1. 创建 Buttons 组件:我们创建了一个新的 buttons.jsx 文件,用于封装保存和取消按钮。这可以帮助我们保持代码模块化,并使主表单组件更简洁。

  2. 布局按钮:将按钮使用 Inline 包装,使它们水平排列在一起。通过 justify: end 将它们定位到右侧,并为按钮之间添加了较大的间隔,以确保良好的视觉效果。

  3. 添加 Padding:为整个按钮区域添加了 padding,确保按钮组有足够的边距,使其与表单的其他部分保持一致。

  4. 按钮样式

    • 使用 styled-components 创建了一个通用的 Button 组件,具有基础的填充和圆角样式。
    • 添加了条件样式,以实现两种按钮风格:一种为主按钮(深色背景和浅色文本),另一种为次要按钮(浅色背景和深色边框)。
    • 通过传递 primary 属性控制按钮的样式变化,例如 Save 按钮设置为主要按钮,而 Cancel 按钮保持次要样式。
  5. 验证按钮:在页面中检查按钮样式和布局,确保其符合设计要求。我们还通过设置不同的属性来验证条件样式,最终完成了按钮的实现。

总结

至此,我们已完整构建了该页面的所有布局和样式。在整个过程中,使用了模块化组件和封装样式的设计理念,保持代码简洁且易于维护。建议进一步实践,通过模仿其他模板来加强这些布局和样式技术的应用。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

23. Advanced Typescript Introduction 高级Typescript介绍

1. Requirements 要求

好的,在开始课程之前,我们需要准备什么?

首先,你需要一台可以正常工作的电脑。显然,大家都有这个条件。

但更重要的是,你需要在电脑上安装 Node.js,因为在课程中的一个演示项目里,我们将有一个很小的 server.js 文件,它包含一个由 Node.js 和 Express 编写的后端 API。

别担心,你不需要了解 Node.js 的相关内容,只需要安装好它,这样你就可以运行 server.js 文件。

接下来,我们需要安装 NPM。如果你想使用我在每个讲座中附上的资源,例如运行 NPM install,你需要 NPM,当然,这对于创建自己的新项目也很有用。

至于编辑器,我会选择 VS Code。大家都知道,它是 React 项目中最常用且广泛使用的编辑器。我们将使用 VS Code,因为它具有一些功能,特别是在使用 TypeScript 和 React 应用程序时,非常方便。在后面的课程中,你会看到 VS Code 对 TypeScript 的支持有多好。

此外,提到网络连接是因为在一些讲座中,我们将使用 TypeScript 的一些基本功能,这些功能不一定是 React 组件,而是一些通用的 TypeScript 概念。为此,我会使用 TypeScript playground.org 这样的网站。所以,如果你有网络连接,自己尝试这些内容也会更方便。

如果你具备了以上这些条件,就可以开始了!

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

24. Advanced Typescript Typing Hooks 高级Typescript钩子类型

1. useState 使用useState

使用 useState 的技巧和练习:在 TypeScript 中管理状态

在此视频中,我们深入探讨了如何在 TypeScript 中使用 useState 来管理组件状态,并特别关注设置默认值和处理输入类型的技巧。

主要内容概述:

  1. 介绍示例组件

    • 我们有一个简单的购物车组件,允许用户增加、重置、或减少物品数量。
    • 使用了 useState 创建了 items 状态,并且每个按钮都通过 setItems 来更新状态。
  2. TypeScript 类型推断

    • TypeScript 能够智能地推断类型。例如,在使用 useState 初始化为数字 0 时,TypeScript 自动将 items 推断为 number 类型,setItems 则接受一个 number 类型的值。
  3. 练习:表单交互与状态更新

    • 为了扩展功能,我们在组件中添加了一个输入框和一个按钮,要求用户输入新值以更新 items 的数量。
    • 该练习要求监听输入框的变化(onChange),并在点击更新按钮时触发 setItems
  4. 输入处理和类型转换

    • 在 TypeScript 中,HTML 表单元素的值通常为字符串类型。如果期望输入的值为数字,则需要转换。
    • 使用 parseIntNumber() 函数来处理字符串到数字的转换,确保输入框的值可以正确存储和使用。
    • 如果不希望在 useState 中进行显式类型转换,可以通过定义状态为 string | number 类型,从而避免类型错误。
  5. useEffect 处理自动更新

    • items 状态更新后,我们使用 useEffect 将输入框的值同步到 items 上。
    • useEffect 监听 items 的变化,并在每次更新时重新设置输入框的值为 items 的值。
  6. 简单的输入类型转换技巧

    • 通过 e.target.valueAsNumber 可以直接将输入值转换为数字。这种方法避免了手动转换的步骤,更加简洁高效。

代码实现摘要:

以下是用于创建输入框和更新按钮的核心代码:

const [items, setItems] = useState<number>(0);
const [inputItems, setInputItems] = useState<string | number>(0);

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setInputItems(e.target.valueAsNumber);
};

const handleUpdateItems = () => {
  setItems(Number(inputItems));
};

// 在 JSX 中
<input type="number" value={inputItems} onChange={handleInputChange} />
<button onClick={handleUpdateItems}>Update Counter</button>

总结:

这个视频展示了如何使用 useStateuseEffect 在 TypeScript 中管理复杂的状态更新流程,特别是当输入类型多样化时的处理方式。通过对不同类型的处理方法以及 TypeScript 的类型推断和转换,能够实现更健壮和可维护的代码。

2. State without initial state 无初始状态的状态

在 TypeScript 中使用 useState 和接口处理后端数据

在这个视频中,我们探讨了使用 TypeScript 和 React Hooks 的 useState 钩子从后端 API 获取数据并处理异步状态的情况。

主要内容概述:

  1. 示例项目结构

    • 项目包含一些基本组件,如显示书籍列表的 Books.tsx 和显示单个书籍的 Book.tsx
    • 使用了 Loader 组件显示数据加载前的动画效果。
  2. 使用简单的后端服务器

    • 使用 Node.js 和 Express 创建一个基本的后端,提供包含书籍信息的 books.json 文件,并在 Server.js 中用 Express 处理这些数据。
    • 后端服务在本地端口 4000 上运行,提供一个 /api/books 路由用于获取书籍列表。
    • 在本地开发环境中,我们可以通过在前端的配置中设置 proxy(代理)来解决跨域请求问题,例如设置代理到 http://localhost:4000
  3. 设置 useState 和初始值

    • 在组件加载时,通过 useEffect 调用后端 API 获取数据。在请求完成前,状态为 undefined,并显示 Loader
    • 通过 TypeScript,设置 useState 钩子的初始类型为 Book | undefined,这样在数据加载前不会导致类型错误。
  4. 状态初始化的挑战和解决方法

    • 为了解决初始化问题,可能想到的第一种方法是为 useState 设置默认值。然而,这样会导致不必要的默认内容并影响显示逻辑。
    • 使用联合类型 (Book | undefined) 可以在数据加载之前将状态设为 undefined,以便正确控制加载逻辑,并在 useEffect 中更新状态。

代码实现摘要:

interface Book {
  id: number;
  title: string;
  author?: string;
}

const [book, setBook] = useState<Book | undefined>(undefined);

useEffect(() => {
  const fetchRandomBook = async () => {
    const response = await fetch('/api/books/random');
    const data: Book = await response.json();
    setBook(data);
  };
  
  fetchRandomBook();
}, []);

return (
  <div>
    {book ? <BookComponent book={book} /> : <Loader />}
  </div>
);

总结:

这个视频展示了如何在 TypeScript 中处理异步请求以及如何管理初始状态。通过设置 useState 钩子的联合类型(如 Book | undefined),不仅可以避免初始值问题,还能在数据加载完成之前显示合适的加载动画。使用 TypeScript 的类型推断功能可以在数据请求和状态管理中获得更高的类型安全性,从而减少错误,提高代码的可维护性。

3. Passing States and Events Part1 传递状态和事件Part1

使用 TypeScript 和 React 实现动态书籍列表加载

在这个练习中,我们将学习如何使用 TypeScript 和 React 创建动态书籍列表加载功能。当用户输入一个数字并点击加载按钮时,前端会请求后端 API,并返回对应数量的书籍信息。

练习目标:

  • 创建一个 useState 状态以存储书籍列表。
  • 在点击按钮时,使用 onChangeonSubmit 事件处理用户输入和表单提交。
  • 使用 TypeScript 明确定义状态和属性的类型。
  • 在组件中渲染书籍列表。

实现步骤:

  1. 设置状态:

    • 我们将使用 useState 钩子创建 bookssetBooks 状态变量,用于存储返回的书籍列表。
    • books 状态将是一个 Book 对象数组,因此在 useState 中指定类型为 Book[]
    const [books, setBooks] = useState<Book[]>([]);
  2. 表单输入处理:

    • 使用 onChange 事件来获取用户输入的数量。
    • count 的值将作为参数传递给 API 请求,以限制返回的书籍数量。
  3. 获取书籍数据:

    • 点击按钮触发 onSubmit 事件并调用 API。然后更新 books 状态以渲染书籍列表。
    const loadBooks = async () => {
        const response = await fetch(`/api/books?count=${count}`);
        const data = await response.json();
        setBooks(data);
    };
  4. 渲染书籍列表:

    • 使用 map 方法遍历 books 数组,并为每个 Book 渲染一个组件。
    return (
        <div>
            {books.map(book => (
                <BookComponent key={book.id} title={book.title} author={book.author} />
            ))}
        </div>
    );
  5. 定义属性类型:

    • 使用 TypeScript 确保所有传递的数据和函数具有明确的类型,例如:
    interface BookProps {
        title: string;
        author: string;
    }

完整代码示例:

import React, { useState } from 'react';

interface Book {
    id: number;
    title: string;
    author: string;
}

const BookComponent: React.FC<Book> = ({ title, author }) => (
    <div>
        <h2>{title}</h2>
        <p>{author}</p>
    </div>
);

const App: React.FC = () => {
    const [books, setBooks] = useState<Book[]>([]);
    const [count, setCount] = useState<number>(0);

    const loadBooks = async () => {
        const response = await fetch(`/api/books?count=${count}`);
        const data: Book[] = await response.json();
        setBooks(data);
    };

    return (
        <div>
            <input
                type="number"
                value={count}
                onChange={(e) => setCount(parseInt(e.target.value))}
            />
            <button onClick={loadBooks}>Load Books</button>

            <div>
                {books.map((book) => (
                    <BookComponent key={book.id} title={book.title} author={book.author} />
                ))}
            </div>
        </div>
    );
};

export default App;

总结:

该练习展示了如何在 TypeScript 中使用 React 处理 API 数据加载和状态管理。在实际开发中,通过这种方法可以确保类型安全,并便于调试和维护应用逻辑。

4. Passing States and Events Part2 传递状态和事件Part2

TypeScript和React中的事件处理及状态传递

在这个练习中,我们扩展了对TypeScript和React中事件处理和状态传递的理解,通过一个书籍加载项目来展示如何在父组件和子组件之间共享状态与事件处理函数。

实现步骤概述:

  1. 定义count状态:

    • 首先在父组件中创建一个状态变量count,以便追踪用户想要加载的书籍数量。
    const [count, setCount] = useState<number>(10); // 默认值为10
  2. 传递count状态和事件处理器:

    • 通过props将count值和onChangeonSubmit事件处理器传递到子组件,以便管理用户输入和表单提交行为。
    • 在表单的onSubmit事件中调用fetchBooks函数以获取书籍列表,并将其存储在books状态中。
    const handleSubmit = (event: React.FormEvent) => {
        event.preventDefault();
        fetchBooks(count).then(setBooks);
    };
  3. 子组件中的类型定义:

    • 在子组件中定义props的类型,以确保我们传递的数据符合预期类型。
    • 使用React.FormEventHandleronSubmitonChange事件处理器指定类型。
    interface BooksProps {
        count: number;
        onSubmit: React.FormEventHandler;
        onChange: React.ChangeEventHandler<HTMLInputElement>;
    }
  4. 在子组件中使用props:

    • props中的countonChangeonSubmit事件处理器绑定到表单元素上,以响应用户输入。
    • 通过确保正确的类型,可以避免许多潜在的错误。
    <form onSubmit={onSubmit}>
        <input
            type="number"
            value={count}
            onChange={onChange}
        />
        <button type="submit">Load Books</button>
    </form>
  5. 验证功能:

    • 启动应用程序,输入希望加载的书籍数量,点击提交按钮,然后通过调用API加载相应数量的书籍。

完整代码示例

父组件:

import React, { useState } from 'react';
import Books from './Books';

const App: React.FC = () => {
    const [books, setBooks] = useState<Book[]>([]);
    const [count, setCount] = useState<number>(5);

    const fetchBooks = async (limit: number) => {
        const response = await fetch(`/api/books?count=${limit}`);
        const data = await response.json();
        setBooks(data);
    };

    const handleSubmit: React.FormEventHandler = (event) => {
        event.preventDefault();
        fetchBooks(count);
    };

    const handleChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
        setCount(Number(event.target.value));
    };

    return (
        <div>
            <Books count={count} onSubmit={handleSubmit} onChange={handleChange} />
            <div>
                {books.map((book) => (
                    <div key={book.id}>
                        <h2>{book.title}</h2>
                        <p>{book.author}</p>
                    </div>
                ))}
            </div>
        </div>
    );
};

export default App;

子组件:

import React from 'react';

interface BooksProps {
    count: number;
    onSubmit: React.FormEventHandler;
    onChange: React.ChangeEventHandler<HTMLInputElement>;
}

const Books: React.FC<BooksProps> = ({ count, onSubmit, onChange }) => (
    <form onSubmit={onSubmit}>
        <label>
            Number of Books:
            <input type="number" value={count} onChange={onChange} />
        </label>
        <button type="submit">Load Books</button>
    </form>
);

export default Books;

总结

该练习展示了如何使用 TypeScript 确保组件间的状态和事件处理传递符合类型要求,提高代码的可靠性和可维护性。

5. Refactoring Passing States and Events 重构传递状态和事件

优化React中状态更新的性能:将状态从父组件移至子组件

在本节中,我们优化了之前的视频中的代码,使其在React应用中更具性能。通过将count状态移动到子组件中,我们避免了每次count更新时整个父组件的重新渲染。

优化步骤概述:

  1. count状态移至子组件:

    • 从父组件中移除count状态,并将其添加到子组件中,以便只在子组件内进行状态管理,避免整个父组件的重新渲染。
    // 从父组件移除
    const [count, setCount] = useState<number>(10);
    
    // 添加到子组件
    const [count, setCount] = useState<number>(10);
  2. 清理无用的props:

    • 移除父组件中传递给子组件的无用props,例如countonChangeonSubmit
    • 仅将setBooks传递到子组件,以在子组件内进行状态更新。
    <Books setBooks={setBooks} />
  3. 在子组件中定义事件处理器:

    • 在子组件内部实现onChangeonSubmit处理函数,并通过回调更新父组件的books状态。
    • 使用 fetchBooks(count).then(setBooks); 来在提交表单时获取书籍数据并更新。
    const handleSubmit = (event: React.FormEvent) => {
        event.preventDefault();
        fetchBooks(count).then(setBooks);
    };
  4. 使用TypeScript的类型推导:

    • 通过在父组件中悬停setBooks,可以使用VSCode自动推导出setBooks的函数签名。
    • 在子组件中粘贴此类型,以确保类型一致性并避免手动查找。
    // 复制父组件中`setBooks`类型
    setBooks: React.Dispatch<React.SetStateAction<Book[]>>
  5. 在子组件中处理输入更改:

    • 在子组件内的输入框中使用onChange事件更新count
    • 使用value as number简化类型转换,从而确保输入值被正确解析为数字。
    const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
        setCount(event.target.value as number);
    };

完整代码示例

父组件:

import React, { useState } from 'react';
import Books from './Books';

const App: React.FC = () => {
    const [books, setBooks] = useState<Book[]>([]);

    return (
        <div>
            <Books setBooks={setBooks} />
            <div>
                {books.map((book) => (
                    <div key={book.id}>
                        <h2>{book.title}</h2>
                        <p>{book.author}</p>
                    </div>
                ))}
            </div>
        </div>
    );
};

export default App;

子组件:

import React, { useState } from 'react';

interface BooksProps {
    setBooks: React.Dispatch<React.SetStateAction<Book[]>>;
}

const Books: React.FC<BooksProps> = ({ setBooks }) => {
    const [count, setCount] = useState<number>(5);

    const handleSubmit = (event: React.FormEvent) => {
        event.preventDefault();
        fetchBooks(count).then(setBooks);
    };

    const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
        setCount(event.target.value as number);
    };

    return (
        <form onSubmit={handleSubmit}>
            <label>
                Number of Books:
                <input type="number" value={count} onChange={handleChange} />
            </label>
            <button type="submit">Load Books</button>
        </form>
    );
};

export default Books;

总结

通过将count状态从父组件移到子组件,我们提高了代码的性能。优化后的代码避免了父组件的多次重新渲染,并且更好地展示了如何利用TypeScript的类型推导来提高代码的准确性和可维护性。

6. Typing useRef 使用useRef

使用 useRef Hook 的类型处理

在这节视频中,我们讨论了 useRef 钩子在 React 中的使用,并探讨了如何在 TypeScript 中正确地为 useRef 添加类型。

useRef 的基本概念:

  1. useRef 钩子的用途

    • useRef 用于获取和存储不可变值,通常用于直接访问 DOM 元素或保存组件生命周期内的状态而不会触发重新渲染。
    • useRef 返回一个带有 .current 属性的对象,你可以使用 .current 来存储值。
  2. useRef 指定类型

    • useRef 用于访问 DOM 元素时(例如 input),你可以指定类型为 HTMLInputElement,这样 TypeScript 会知道 .current 的类型,并为你提供自动完成。

具体实现步骤:

  1. 访问 DOM 元素的 Ref 类型

    • 为了访问 input DOM 元素,我们可以将 useRef 初始化为 null,并指定它的类型为 HTMLInputElementnull,这样 .current 便可以被识别为该类型。
    • 在使用 .current 时,可以使用可选链操作符(?.)以避免可能出现的 null 值。
    const inputRef = useRef<HTMLInputElement | null>(null);
    
    // 聚焦到 input 元素
    inputRef.current?.focus();
  2. useRef 的类型推断

    • 如果使用 useRef 来保存一个可变的状态值(例如计数器),可以初始化为数字值,如 0。这会让 TypeScript 推断出 .current 是一个可变的数字。
    • 同样可以在 useRef 的泛型中指定类型:
    const countRef = useRef<number>(0);
    
    // 更新计数值
    countRef.current += 1;
  3. 避免常见的类型错误

    • useRef 的值不能初始化为 undefined,它只能是 null 或一个特定的值。
    • 如果 .current 需要初始化为 null,可以将其类型指定为 HTMLInputElement | null
    • useRef 在 TypeScript 中使用泛型以确保 .current 的类型安全,避免出现意外的类型问题。

代码示例:

// 获取 input 元素的引用
const inputRef = useRef<HTMLInputElement | null>(null);
const countRef = useRef<number>(0);

// 在 componentDidMount 中自动聚焦到 input
useEffect(() => {
    inputRef.current?.focus();
}, []);

// 点击按钮时更新计数
const handleClick = () => {
    countRef.current += 1;
    console.log(`Count is now ${countRef.current}`);
};

总结

通过正确地为 useRef 指定类型,我们可以确保在访问 .current 时获得类型检查的支持,从而提高代码的健壮性和可维护性。特别是在复杂的组件中,这种类型安全性可以帮助我们更容易地发现错误。

7. Typing Returned Values of a Custom Hook 自定义钩子返回值类型

Custom Hook useURL 的类型改进

在这段视频中,我们讨论了如何使用 TypeScript 为自定义 Hook useURL 提供正确的类型,以确保代码的类型检查更精确。让我们一起看看如何使用 useStateas const 来解决这个问题。

问题描述:

在创建自定义 Hook 时,useURL 返回一个字符串和一个更新该字符串的函数(类似于 useState 的返回值)。然而,当我们在此 Hook 中使用 useState 时,TypeScript 将其推断为数组,并认为它可能会变化或被更新,这导致我们无法获得字符串特有的方法的自动补全。

解决方案步骤:

  1. 使用 as const 声明为元组

    • 默认情况下,useState 返回一个数组 [state, setState]。当我们不指定类型时,TypeScript 将其视为一个通用数组。
    • 通过在返回值后添加 as const,可以告知 TypeScript 将其视为元组,使其不可变。这将确保 TypeScript 不会认为我们会修改数组结构,而只会访问其内容。
    import { useState } from 'react';
    
    const useURL = () => {
        const [url, setUrl] = useState<string>('');
        return [url, setUrl] as const; // 告诉 TypeScript 将其视为元组
    };
  2. 测试返回值的类型和自动补全

    • 在使用此自定义 Hook 时,例如:

      const [url, setUrl] = useURL();
    • 现在当你输入 url. 时,将会获得字符串的方法补全,如 .toLowerCase().match(),这表明 TypeScript 已正确识别 url 为字符串类型。

  3. 理解 as const 的作用

    • as const 可以保证数组在结构上是不可变的,这意味着 TypeScript 会确保返回值的类型是 [string, Dispatch<SetStateAction<string>>],而不是一个普通数组 (string | Dispatch<SetStateAction<string>>)[]
    • 这种方式也可以用于确保字面量类型的数组或对象是只读的,防止意外更改。

完整示例代码:

import { useState } from 'react';

const useURL = () => {
    const [url, setUrl] = useState<string>('');
    return [url, setUrl] as const;
};

// 使用该 Hook
const [url, setUrl] = useURL();
console.log(url.toLowerCase()); // 自动补全功能可用

总结

使用 as const 可以显著提升 TypeScript 对于元组返回值的推断能力,使代码在自定义 Hook 中更具类型安全性。此外,它还增强了开发体验,使我们能够在编写代码时更方便地获得自动补全提示。

8. Typing Complex States 复杂状态类型

Custom Hook useUser 的强类型设置

在此视频中,我们探索了如何通过使用 TypeScript 强类型 useUser 自定义 Hook,以便在处理状态时获得更准确的类型检查和自动补全。这样可以减少错误并提高代码的可读性和维护性。

问题描述:

useUser Hook 中使用 useState 设置了一个字符串作为状态(如 "fetching"),该状态用于跟踪用户数据请求的状态。然而,直接将状态设置为字符串会导致一些问题:

  1. 代码中没有自动补全,也不能确定状态只能是预定义的几个值。
  2. 程序员可能会拼错字符串值,导致逻辑错误。

解决方案步骤:

  1. 定义状态的联合类型

    • 使用 TypeScript 的联合类型,为状态定义一个可以接受的字符串值集合(如 "fetching" | "fetched" | "error")。
    • useState 中使用该联合类型来限制状态值,这样 TypeScript 会提示和限制状态的可能值,并提供自动补全。
    import { useState, useEffect } from 'react';
    
    type UserFetchState = 'fetching' | 'fetched' | 'error';
    
    const useUser = (src: string) => {
        const [state, setState] = useState<UserFetchState>('fetching'); // 使用联合类型
    
        useEffect(() => {
            let aborted = false;
    
            const fetchData = async () => {
                try {
                    // 模拟请求
                    const response = await fetch(src);
                    if (aborted) return;
                    if (response.ok) {
                        setState('fetched');
                    } else {
                        setState('error');
                    }
                } catch (error) {
                    if (!aborted) setState('error');
                }
            };
    
            fetchData();
    
            return () => {
                aborted = true;
            };
        }, [src]);
    
        return state;
    };
  2. 验证联合类型和自动补全

    • 使用该 Hook 时,例如:

      const userState = useUser('/api/user');
    • 现在,当你比较 userState 的值时,如:

      if (userState === 'fetching') {
          // 显示加载动画
      }
    • TypeScript 会提供 "fetching" | "fetched" | "error" 的自动补全。此外,任何不在该联合类型中的字符串都会引发类型错误,确保只能使用预定义的状态值。

  3. 调整代码中的状态使用

    • 强类型后,整个代码都将受益于明确的状态值,可以防止拼写错误和不匹配的状态比较。

示例代码:

import { useState, useEffect } from 'react';

type UserFetchState = 'fetching' | 'fetched' | 'error';

const useUser = (src: string) => {
    const [state, setState] = useState<UserFetchState>('fetching');

    useEffect(() => {
        let aborted = false;

        const fetchData = async () => {
            try {
                const response = await fetch(src);
                if (aborted) return;
                if (response.ok) {
                    setState('fetched');
                } else {
                    setState('error');
                }
            } catch (error) {
                if (!aborted) setState('error');
            }
        };

        fetchData();

        return () => {
            aborted = true;
        };
    }, [src]);

    return state;
};

// 使用该 Hook
const userState = useUser('/api/user');
if (userState === 'fetching') {
    console.log('Loading...');
}

总结

通过为状态设置联合类型 UserFetchState,不仅获得了更好的代码提示和自动补全,还能确保状态值的准确性。无论何时处理复杂状态或需要限定状态值时,使用联合类型都是一种最佳实践。

9. Typing Complex States Part2 复杂状态类型Part2

TypeScript 联合类型处理更复杂的状态对象

在这个视频中,我们继续改进了 useUser 自定义 Hook,添加了一个更复杂的状态对象。之前,状态仅仅是一个字符串,现在我们改为包含 statusvalue 的对象,使得状态可以包含更多的信息(如加载时显示错误消息)。

目标:

将状态对象设置为一个具有不同状态分支(如 "fetching"、"fetched"、"error")的联合类型,使得每个状态具有适当的数据属性。

解决方案步骤:

  1. 定义联合类型

    • 使用 TypeScript 联合类型为状态定义分支,以便状态可以包含特定数据属性。
    • 我们定义 State 类型,使得状态对象在不同分支中有不同的结构。例如,"fetching" 仅包含 status 属性,而 "error" 状态还包含 error 对象。
    type UserFetchState = 
        | { status: 'fetching' }
        | { status: 'fetched' }
        | { status: 'error', error: Error };
  2. 应用联合类型

    • useState 中使用 UserFetchState 类型初始化状态。
    • 这种方式允许状态对象根据 status 属性动态显示不同的内容,同时提供更精确的类型检查。
    import { useState, useEffect } from 'react';
    
    const useUser = (src: string) => {
        const [state, setState] = useState<UserFetchState>({ status: 'fetching' });
    
        useEffect(() => {
            let aborted = false;
    
            const fetchData = async () => {
                try {
                    const response = await fetch(src);
                    if (aborted) return;
                    if (response.ok) {
                        setState({ status: 'fetched' });
                    } else {
                        setState({ status: 'error', error: new Error('Fetch failed') });
                    }
                } catch (error) {
                    if (!aborted) setState({ status: 'error', error });
                }
            };
    
            fetchData();
    
            return () => {
                aborted = true;
            };
        }, [src]);
    
        return state;
    };
  3. 使用联合类型的好处

    • 在组件中使用此 Hook 时,可以安全地根据 status 属性检查状态:
      const userState = useUser('/api/user');
      if (userState.status === 'fetching') {
          // 显示加载动画
      } else if (userState.status === 'error') {
          console.error(userState.error.message);
      }
    • TypeScript 会基于 status 的值进行类型推导,例如当 status"error" 时,可以直接访问 error 属性。

总结

使用联合类型为状态对象增加了灵活性和类型安全性,使状态管理更加清晰和可维护。这种方法特别适合处理复杂状态,如多状态异步请求中的错误处理和加载显示。

10. Tuples with Custom Hooks 使用元组自定义钩子

Using TypeScript Union Types for Tuple-Based Custom Hook States

In this session, we're working with the useUser custom hook again, which fetches data and returns it alongside a status indicator. Instead of a simple status string, we have a tuple that pairs the status with a specific data type, T, for success, or an Error object for error scenarios.

Objective:

The goal is to create a TypeScript union type for this tuple that will:

  1. Return a status of "fetching" and an undefined value while data is being fetched.
  2. Return a status of "success" with a data type T upon successful data retrieval.
  3. Return a status of "error" paired with an Error object when there's an error.

Solution Steps:

  1. Define the Union Type for the Tuple:
    We’ll create a union type that uses three tuples, each corresponding to a different state. By leveraging union types, we ensure that the tuple's second value aligns with the status:

    type UserFetchState<T> =
        | ['fetching', undefined?]
        | ['success', T]
        | ['error', Error];
  2. Update the Hook to Use the New Type:
    In our useUser hook, this new UserFetchState<T> type is applied to the state to provide strict type checking. Now, TypeScript will enforce that the second element in the tuple is correct for each status value.

    import { useState, useEffect } from 'react';
    
    function useUser<T>(url: string): UserFetchState<T> {
        const [state, setState] = useState<UserFetchState<T>>(['fetching']);
    
        useEffect(() => {
            fetch(url)
                .then((response) => response.json())
                .then((data: T) => {
                    setState(['success', data]);
                })
                .catch((error: Error) => {
                    setState(['error', error]);
                });
        }, [url]);
    
        return state;
    }
  3. Using the Hook and TypeScript Inference:
    When using this hook, TypeScript will now automatically infer the type based on the status, making it easy to manage and work with different states in a TypeScript-aware way.

    const [status, value] = useUser<UserType>('/api/user');
    
    if (status === 'fetching') {
        // Handle fetching state
    } else if (status === 'error') {
        console.error(value.message); // Here, value is inferred as Error
    } else if (status === 'success') {
        console.log(value); // Here, value is inferred as UserType
    }
  4. Optionality in Tuple:
    For added flexibility, we make the undefined value optional when in the "fetching" state by adding ?. This allows us to only pass one value in the "fetching" tuple without TypeScript errors.

    type UserFetchState<T> =
        | ['fetching', undefined?]
        | ['success', T]
        | ['error', Error];
  5. TypeScript’s Smart Inference:
    TypeScript is capable of narrowing down the type of value based on the status. When status is "error," value is recognized as Error; when it is "success," value is inferred as T.

Summary

Using union types in combination with tuples, TypeScript provides a robust way to handle state and associated data types, enabling more precise and error-free development. This pattern allows for better control over state handling and ensures that each state can only contain the appropriate type of data.

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

25. Advanced Typescript Typing Reducers 高级Typescript类型Reducer

1. Typing Reducers 类型Reducer

使用 TypeScript 和 useReducer 增强类型安全

在这个示例中,我们使用 useReducer 钩子来构建一个购物车项目,并借助 TypeScript 提高代码的类型安全性。通过定义具体的动作类型和状态结构,这种方法能够更快速地识别拼写错误和类型不匹配等常见问题。

为什么在 Reducer 中使用 TypeScript?

在 Reducer 中引入 TypeScript 有以下几个好处:

  1. 即时错误反馈:TypeScript 会标记错误的动作类型和数据载荷,节省调试时间。
  2. 自动补全和类型推断:通过定义类型,TypeScript 能够提供动作类型和载荷的智能建议,减少错误并提升开发体验。
  3. 提升可读性和可维护性:清晰的类型定义使代码自带文档性质,更易于维护。

定义动作类型的步骤

  1. 定义状态结构
    首先为 reducer 管理的状态定义接口或类型。在这个购物车示例中,状态有两个主要属性 itemsinputItems,它们都属于 number 类型:

    interface CartState {
        items: number;
        inputItems: number;
    }
  2. 定义动作类型
    创建一个联合类型来区分是否有载荷的动作,这在大型应用程序中尤其有用,可以帮助追踪载荷的需求:

    // 无需载荷的动作类型
    type CartActionWithoutPayload = 
        | { type: 'INCREASE' }
        | { type: 'DECREASE' }
        | { type: 'RESET' };
    
    // 需要载荷的动作类型
    type CartActionWithPayload = 
        | { type: 'UPDATE_INPUT_ITEMS'; payload: number }
        | { type: 'UPDATE_ITEMS_FROM_INPUT' };
    
    // 动作的联合类型
    type CartAction = CartActionWithoutPayload | CartActionWithPayload;
  3. 使用类型安全性实现 Reducer
    现在可以在 reducer 中使用 CartAction 类型,使 TypeScript 强制进行类型检查。我们在 reducer 中使用 switch 语句来处理不同的动作类型:

    function cartReducer(state: CartState, action: CartAction): CartState {
        switch (action.type) {
            case 'INCREASE':
                return { ...state, items: state.items + 1 };
            case 'DECREASE':
                return { ...state, items: Math.max(state.items - 1, 0) };
            case 'RESET':
                return { ...state, items: 0, inputItems: 0 };
            case 'UPDATE_INPUT_ITEMS':
                return { ...state, inputItems: action.payload };
            case 'UPDATE_ITEMS_FROM_INPUT':
                return { ...state, items: state.inputItems };
            default:
                return state;
        }
    }
  4. 在组件中使用 useReducer
    现在 TypeScript 会验证所有派发的动作是否符合 CartAction 类型。它将检查 INCREASEDECREASERESET 不应包含 payload,而 UPDATE_INPUT_ITEMS 应包含:

    const [state, dispatch] = useReducer(cartReducer, { items: 0, inputItems: 0 });
    
    // 派发动作
    dispatch({ type: 'INCREASE' });
    dispatch({ type: 'UPDATE_INPUT_ITEMS', payload: 5 });
  5. 调试常见问题
    如果尝试派发一个类型或载荷不正确的动作,TypeScript 将会捕捉到错误:

    dispatch({ type: 'RESET', payload: 5 });  // 错误:'RESET' 不应有载荷
    dispatch({ type: 'UPDATE_INPUT_ITEMS' }); // 错误:缺少必要的 'payload'

核心优势:

  • 动作类型的自动补全:只有有效的动作类型会出现在建议列表中。
  • 错误检查:拼写错误和缺失的载荷会触发 TypeScript 错误,从而更容易修复。
  • 代码维护:类型本身就是文档,能明确标示动作的载荷需求以及状态更改。

这种设置创建了一个可靠且类型安全的 reducer,展示了 TypeScript 如何改进依赖 useReducer 或 Redux 风格状态管理的应用程序的可维护性和稳健性。

2. Passing Dispatch as a Prop Part1 作为属性传递的Dispatch Part1

在这个视频中,我们讨论了如何在使用 TypeScript 时将 dispatch 函数作为 prop 传递,并演示了一个颜色选择器的示例项目。在这个示例中,我们使用 useReducer 钩子来管理应用的状态,具体包括颜色的十六进制值(Hex)和 RGB 值的更新操作。以下是项目的主要内容和步骤:

1. 项目介绍与需求分析

该项目的主要功能是:

  • 使用颜色选择器选择颜色。
  • 选择颜色后,将颜色的 Hex 码和 RGB 值作为状态保存在应用中。
  • 当颜色被选择时,触发 dispatch 操作,更新状态。

2. 创建 Reducer 和 Action

我们首先在 src 文件夹中创建一个名为 colorReducer.ts 的文件,用于定义颜色选择器的状态和操作。

定义 Action

我们定义了两个简单的动作类型:

  1. 更新 Hex 颜色值:包含类型为 UPDATE_HEX 的动作和一个包含 hexColor 字符串的载荷。
  2. 更新 RGB 颜色值:包含类型为 UPDATE_RGB 的动作和一个包含 RGB 数组的载荷。
export type UpdateHexAction = {
    type: 'UPDATE_HEX',
    payload: { hexColor: string }
};

export type UpdateRGBAction = {
    type: 'UPDATE_RGB',
    payload: { rgb: [number, number, number] }
};

定义 State

状态 ColorState 包含了一个 hexColor 属性,该属性用于存储选定的颜色 Hex 值:

type ColorState = {
    hexColor: string;
};

定义初始状态和 Reducer

创建一个 initialState 对象作为初始状态。定义 colorReducer 函数,根据不同的动作类型更新状态:

const initialState: ColorState = {
    hexColor: '#ffffff', // 你可以使用你喜欢的颜色
};

function colorReducer(state: ColorState, action: UpdateHexAction | UpdateRGBAction): ColorState {
    switch (action.type) {
        case 'UPDATE_HEX':
            return { ...state, hexColor: action.payload.hexColor };
        case 'UPDATE_RGB':
            const hexColor = rgbToHex(action.payload.rgb); // 使用 RGB 转 Hex 的转换函数
            return { ...state, hexColor };
        default:
            return state;
    }
}

3. 在应用中使用 useReducer

我们在 App.tsx 中使用 useReducer 管理应用状态。通过调用 useReducer(colorReducer, initialState) 来获取 statedispatch 函数。

const [state, dispatch] = useReducer(colorReducer, initialState);

然后,我们将 dispatch 函数作为 prop 传递给子组件,并在组件中使用它来更新颜色状态。例如,当颜色选择器的值发生变化时,我们通过调用 dispatch 来更新 Hex 颜色值:

onChange={(e) => {
    dispatch({
        type: 'UPDATE_HEX',
        payload: { hexColor: e.target.value }
    });
}}

4. 验证功能

我们在浏览器中刷新应用并测试颜色选择器,验证通过 dispatch 操作能够正确地更新应用的状态。

总结

通过 useReducer 和 TypeScript 的结合,我们能够更安全和高效地管理复杂的应用状态。借助类型定义和自动补全功能,减少了拼写错误和类型错误的发生,使开发过程更加顺畅。

3. Passing Dispatch as a Prop Part2 作为属性传递的Dispatch Part2

在本视频中,我们进一步探讨了如何将 dispatch 函数作为 prop 传递到子组件中,并通过颜色选择器项目的实例展示了如何更新颜色的 RGB 值。

主要内容和步骤:

  1. 将 Dispatch 传递到子组件
    我们首先在 App.tsx 中,将 dispatch 作为 prop 传递给 SetColor 组件,然后在 SetColor 组件中继续将它传递到其他子组件(如 HexToRGB)。

    <SetColor dispatch={dispatch} />

    这种将 dispatch 从上层组件逐层传递到下层组件的方式是一种常见的 prop drilling(逐层传递)方式。虽然在这个示例中可以用 Context API 进行优化,但我们先演示这个过程以更好地理解 dispatch 的使用。

  2. 在子组件中定义 Dispatch 函数
    SetColor 组件和 HexToRGB 组件中,我们分别添加了 dispatch 属性。然后,在 HexToRGB 组件中,我们创建了一个 updateRGB 辅助函数,当颜色的输入值发生变化时,会触发 dispatch 操作。

    const updateRGB = (r: number, g: number, b: number) => {
        dispatch({
            type: 'UPDATE_RGB',
            payload: { rgb: [r, g, b] }
        });
    };
  3. 实现 Input 的 OnChange 事件
    在每个 RGB 输入框上添加 onChange 事件,并调用 updateRGB 函数。我们通过 e.target.value 来获取输入框的值,并将它们作为参数传递给 updateRGB 函数以更新相应的颜色值。

    <input 
        type="number" 
        value={r} 
        onChange={(e) => updateRGB(Number(e.target.value), g, b)} 
    />
  4. 添加颜色的 Hex 码前缀
    colorReducer.ts 中,我们确保返回的 hexColor 带有 # 前缀,以确保浏览器能够正确识别颜色。

    const hexColor = `#${rgbToHex(action.payload.rgb)}`;
  5. 测试功能
    在浏览器中测试应用,选择不同的 RGB 颜色,验证 dispatch 操作能够正确更新颜色值。输入 RGB 数值,确认 Hex 值和颜色展示同步更新。

通过这个项目,我们展示了如何在 React 应用中通过 prop drilling 方式传递 dispatch 函数,实现组件之间的状态管理。这种方法非常适用于小型项目,能够帮助开发者熟悉 Redux 和 TypeScript 结合使用时的一些基本概念。

4. Template Literal Types 模板文字类型

在本视频中,我们讨论了 TypeScript 中的模板字面量(Template Literals),以及如何在类型定义中利用这种特性来实现更严格和灵活的类型约束。这种方法可以使代码更具可读性,并减少硬编码,从而更容易维护和理解。

主要内容和步骤:

  1. 模板字面量的概念
    模板字面量允许我们通过将变量和字符串嵌入到双反引号(``)之间,并在变量前加上 $ 符号,从而构建字符串。在 TypeScript 中,我们可以使用这种方式创建特定模式的类型。例如:

    type HexColor = `#${string}`;

    上述代码定义了一个类型 HexColor,该类型仅接受以 # 开头的字符串。这使得类型变得更加明确,限制了只能传入特定格式的字符串。

  2. 创建特定的字符串模式
    我们还可以使用模板字面量来定义其他特定格式的字符串。例如,可以创建一个只接受 RGB 格式字符串的类型:

    type RGBString = `rgb(${number}, ${number}, ${number})`;

    这个类型定义了 RGB 格式的颜色字符串,要求有三个数字值。这种类型约束在处理颜色代码时非常有用,可以有效减少错误输入。

  3. 利用模板字面量创建动态类型
    模板字面量类型还可以与其他类型结合,以创建动态的类型约束。例如:

    type ColorFormat = "hex" | "rgb";
    type ActionType = `update-${ColorFormat}`;

    这将生成一个类型 ActionType,允许 update-hexupdate-rgb。通过这种方式,我们可以减少重复代码,并使代码更加简洁。

  4. 更具信息性的类型检查
    我们可以在函数中利用模板字面量类型来提供更精确的返回类型。例如,定义一个判断字符串是否为 Hex 颜色的函数:

    const isHexColor = (str: string): str is HexColor => str.startsWith("#");

    使用这种方法,返回类型不再只是布尔值,而是包含类型信息的布尔值,可以使类型推断更准确。这样可以更清晰地表明 str 是否符合 Hex 颜色格式。

  5. 实际应用场景
    模板字面量类型在处理从 API 获取的数据时特别有用。如果你从 API 获取数据,且不确定其类型,可以使用这种方法进行类型检查,从而根据检查结果将数据转换为适当的类型。

模板字面量在 TypeScript 中是一个非常强大的工具,使我们可以创建更具灵活性和信息性的类型。它可以帮助我们捕获和处理特定格式的数据,有效减少错误并提升代码的可读性。

5. Action and Reducer Types Action和Reducer类型

在本视频开头,我们做了一个小练习,旨在帮助大家理解如何通过点击不同颜色来更新页面中的颜色。这个练习鼓励你在代码中添加事件监听器,让点击颜色时自动更新显示颜色。在这里我们具体操作如下:

主要内容和步骤:

  1. 事件监听器的添加
    这个练习的核心是添加点击事件。通过为颜色元素添加 onClick 事件监听器,当用户点击某个颜色时触发 dispatch 操作,将颜色更新到展示区域中。

  2. 传递 dispatch 到子组件
    我们首先在 App.tsx 中,将 dispatch 作为 props 传递给 SavedColors 组件,这样在该组件内可以使用 dispatch 来触发颜色更新。代码如下:

    <SavedColors dispatch={dispatch} />
  3. 在子组件中定义 dispatch 类型
    接下来在 SavedColors 组件中,我们定义了 dispatch 的类型,确保接收到的 dispatch 方法符合预期。然后在组件中使用该 dispatch 方法,当点击颜色时,将颜色数据作为 payload 发送出去:

    const handleClick = (hexColor: string) => {
        dispatch({
            type: "update-hex",
            payload: { hexColor }
        });
    };
  4. 使用内联样式说明
    这里讲解了示例代码中使用了大量的内联样式。虽然在实际项目中我们可能不推荐使用内联样式,但在这个示例中为了简洁和演示目的,我们仍然使用了它们。

  5. 点击事件的实现
    我们将点击事件添加到每个颜色块上,以便在点击时触发 handleClick 函数,从而更新当前展示的颜色。在实际项目中,这样的点击事件会动态更新页面的颜色,使其响应更加灵活:

    <div onClick={() => handleClick(color.hexColor)} style={{ backgroundColor: color.hexColor }}>
        {/* 其他颜色块的内容 */}
    </div>
  6. 测试和验证
    最后,切换到浏览器,点击颜色块,以验证功能是否正常。你会看到每次点击颜色块时,页面的展示颜色都会相应地改变,表明 dispatch 事件成功触发并更新了应用的状态。

这个练习展示了如何将 dispatch 传递给子组件并在事件监听器中使用它,以便在用户交互时触发特定的状态更新。通过这种方式,你可以在应用中实现灵活、动态的交互效果。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

26. Advanced Typescript Typing Context API 高级Typescript Context API类型

1. Context API with Types 使用类型的Context API

在本视频中,我们探讨了如何使用 React 的 Context API 来简化 dispatch 的传递,从而避免多层组件间的 prop drilling。具体实现过程如下:

主要步骤

  1. 创建 Context
    我们在 src 文件夹内新建了 Context 文件夹,并在其中创建了 colorContext.tsx 文件,用于存放全局颜色和 dispatch 函数:

    import React, { createContext } from 'react';
    
    export const ColorContext = createContext({ hexColor: '#000000', dispatch: () => {} });
  2. 设置 Context Provider
    在同一文件中,定义了 ColorProvider 组件,将 useReducer 放置其中,并将 hexColordispatch 作为 Context 的值暴露出去:

    export const ColorProvider = ({ children }) => {
        const [state, dispatch] = useReducer(colorReducer, initialState);
    
        return (
            <ColorContext.Provider value={{ hexColor: state.hexColor, dispatch }}>
                {children}
            </ColorContext.Provider>
        );
    };
  3. 在顶层应用中引入 Provider
    index.tsx 文件中,用 ColorProvider 包裹应用组件,使其内的所有组件都可以访问 Context 的值:

    import { ColorProvider } from './Context/colorContext';
    
    ReactDOM.render(
        <ColorProvider>
            <App />
        </ColorProvider>,
        document.getElementById('root')
    );
  4. 组件中使用 Context
    在需要使用颜色值和 dispatch 函数的组件中,使用 useContext 钩子来引用 Context 值:

    import React, { useContext } from 'react';
    import { ColorContext } from '../Context/colorContext';
    
    const SomeComponent = () => {
        const { hexColor, dispatch } = useContext(ColorContext);
    
        const handleClick = () => {
            dispatch({ type: 'update-hex', payload: { hexColor: '#FF0000' } });
        };
    
        return (
            <div onClick={handleClick} style={{ backgroundColor: hexColor }}>
                Click to change color
            </div>
        );
    };
  5. 测试效果
    将组件 dispatch 操作后的效果在浏览器中测试,确认点击事件是否能够更新颜色值。

使用 Context API 的优势

通过 Context API,可以让 dispatchhexColor 等全局状态在应用的任何部分轻松访问到,而不需要在每一层组件中通过 props 进行传递,从而使代码更简洁、易于维护。这种方式特别适用于需要跨越多层组件访问数据的场景。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

27. Advanced Typescript Using Generics 高级Typescript使用泛型

1. Utility types 实用类型

在此视频中,我们探讨了多种 TypeScript 工具函数,这些函数可以简化代码并增强类型安全性,特别是对于 React 应用程序。以下是关键概念的摘要:

关键的 TypeScript 工具函数

  1. keyof

    • 获取给定对象类型的键,作为联合类型。
    • 示例:
      type Keys = keyof MyObject; // Keys 可以是 "key1" | "key2"(取决于 MyObject 的键)
  2. 从键中获取值类型

    • 根据对象中特定键的值类型来定义类型。
    • 示例:
      type ValueType = MyObject['key1']; // key1 对应的值的类型
  3. 联合类型和交叉类型

    • 联合类型(|:将多个类型组合,以允许使用其中任一类型。
    • 交叉类型(&:将多个类型组合,以包含所有重叠类型或属性。
    • 示例:
      type Combined = Type1 | Type2; // 联合类型
      type Common = Type1 & Type2;   // 交叉类型
  4. 条件类型

    • 一个强大的特性,可根据条件创建类型。
    • 示例:
      type Conditional<T> = T extends SomeType ? TrueType : FalseType;
  5. 映射类型

    • 可用于遍历键的联合以生成新类型。
    • 示例:
      type Mapped<T> = { [K in keyof T]: boolean }; // 对象键的布尔映射
  6. PickOmit

    • Pick:通过选择现有类型中的特定键来创建新类型。
    • Omit:通过排除现有类型中的特定键来创建新类型。
    • 示例:
      type Picked = Pick<MyObject, 'key1' | 'key3'>;
      type Omitted = Omit<MyObject, 'key2'>;

这些 TypeScript 工具可以显著帮助您在 TypeScript 项目中控制和精炼类型定义。

2. Generics with Template Literals 带模板文字的泛型

大家好,欢迎回来!在这一节课中,我们将讨论 TypeScript 中的泛型。在接下来的几节课中,我们会尝试解决一个在上下文中遇到的问题。之前我们使用了 as 关键字来处理一个问题,在这里我们将通过泛型来看看是否有更好的方法。

首先,我决定先来聊聊泛型,并看看我们可以用 TypeScript 的泛型实现哪些强大功能。为此,我们将使用一个在线编辑器。您可以在 Google 搜索 "TypeScript Playground",然后点击 TypeScript 的官网。进去之后,您会看到一个编辑器页面。为了节省时间,我会粘贴一些简单的 TypeScript 代码,并逐步讲解。


接下来,代码中定义了一个名为 Book 的类型,其中包含 authortitleprice 三个键。前两个键的类型是字符串,而 price 的类型是数字。然后,我们定义了 actionTypes 类型,并使用模板字面量。可能您在之前的视频中已经见过,这很简单,主要是生成类似 update-author 这样的字符串。

actions 类型中,接收了两个参数 TKT 可以是任何类型,在这里我们用 Book 类型来表示。而 K 必须是 T 类型的一个键。换句话说,这里 K 必须是 Book 类型的键之一,比如 authortitleprice,并且 K 的类型必须是字符串。

您可能会觉得这些定义有些复杂,但在大型代码库中,您会经常遇到类似的声明,它们可以节省您很多时间。例如,当您在代码中悬停查看类型信息时,可以看到这些类型是动态设置的,并且不需要多余的代码。


在示例中,我们通过泛型来定义 TK,这样我们可以为每个 update 动作动态生成不同的类型。例如,updateTitleAction 会自动生成适合更新 title 的类型,而 updatePriceAction 则会适应 price 的类型。

这就是为什么我们要学习泛型,它在代码复用方面非常有帮助。当我们为 K 指定 price 时,因为 price 是字符串键,您会看到它的类型显示为字符串。实际上,我们在这里处理的是键而不是键对应的值,因此它会显示为字符串类型。

3. More on Generics 更多泛型内容

大家好,欢迎回来!在我们开始使用 React 之前,今天我们先深入探讨一下泛型,并通过一些例子来看看它们能带来的具体好处。

首先,大家可能都熟悉一种叫做链表的数据结构。链表包含一个元素,同时还包含指向下一个节点的引用。接下来,我们将创建一个类型来表示链表。让我们称之为 Linked,然后我们给它一个泛型参数 T,用来表示元素的类型。链表中包含一个 value,它的类型是 T,并且可以选择包含一个 next,这个 next 也是一个 Linked 类型的节点:

type Linked<T> = {
    value: T;
    next?: Linked<T>;
};

这意味着,如果我们创建一个链表类型的实例,实例的 value 必须是 T 类型,同时它可以选择性地指向下一个相同类型的节点。

我们可以创建一个具体的链表实例。例如,如果我们想要一个 string 类型的链表,那么我们可以写成:

let textLinked: Linked<string> = {
    value: "Hello",
    next: { value: "World" }
};

如果我们给 value 一个错误的类型,例如一个数字,TypeScript 会提示错误,因为我们指定了它是 string 类型。TypeScript 自动推断类型,帮助我们检测代码中的错误,使代码更具健壮性。


为了进一步展示 TypeScript 自动处理类型的强大功能,我们可以创建一个函数,帮助我们构建链表。这次,我们用泛型来处理不同的类型,让同一个函数可以生成任何类型的链表。

function buildLink<T>(value: T): Linked<T> {
    return { value };
}

通过这个泛型函数,我们可以传递不同的类型,例如 stringnumber,而不需要为每种类型写多个函数。这样,我们就可以使用同一个 buildLink 函数生成不同类型的链表:

let stringLinkedList = buildLink("Hello");
let numberLinkedList = buildLink(123);

在以上代码中,TypeScript 自动推断出了 stringLinkedList 是一个 Linked<string> 类型,而 numberLinkedListLinked<number> 类型。我们无需手动指定每次使用的具体类型,TypeScript 会根据传入的参数类型来推断泛型的类型。这种方式不仅减少了代码重复,还让代码更加简洁易维护。

因此,TypeScript 泛型不仅提升了代码的灵活性,还大大减少了冗余代码的量,使代码更加干净、易于维护。记住:当你发现代码中有大量重复时,考虑使用泛型来帮助简化代码并提升可读性。

4. Building a Context with Generics 使用泛型构建Context

大家好,欢迎回来!在本视频中,我们将使用泛型来修复一个问题。如果你还记得我们之前在颜色演示项目中创建的上下文时遇到的问题,具体来说,在我们的颜色上下文中,我们需要提供两个值:一个是颜色值(hex color,字符串类型),另一个是 dispatch 方法。

问题在于,当我们使用 createContext 创建上下文时,无法将 dispatch 设置为默认值。因为 dispatch 需要通过 useReducer 生成,而 useReducer 只能在组件内部使用,比如在颜色提供者(ColorProvider)这样的函数组件内。因此,我们无法在上下文的初始值中直接使用 dispatch

当时,我们使用了 as 关键字强制类型转换,告诉 TypeScript 程序“尽管我现在没有 dispatch,但我稍后会初始化它”。然而,这种做法不安全,如果我们忘了给它添加 dispatch,项目后期会出现各种调试问题。

为了找到更好的解决方案,我们决定使用泛型来确保类型安全。首先,我们在上下文目录下创建了一个新文件,命名为 createContext.tsx。我们在这个文件中创建了一个泛型函数 createContext,并确保上下文在创建时使用泛型 T,且默认情况下 T 必须是一个对象。

接下来,我们使用 React.createContext 创建了上下文,并将类型指定为 T | undefined,这样我们可以检查这个值是否为 undefined。我们还创建了一个 useContext 钩子来访问上下文值,如果上下文值为 undefined,我们就抛出一个错误。

例如:

function createContext<T extends {}>() {
    const context = React.createContext<T | undefined>(undefined);
    
    function useContext() {
        const colorContext = React.useContext(context);
        if (!colorContext) throw new Error("使用上下文时必须提供值。");
        return colorContext;
    }

    return [useContext, context.Provider] as const;
}

在这个 createContext 函数中,我们首先创建了泛型 T,确保它至少是一个对象。接着,创建了一个新的上下文实例,并为其提供 T | undefined 类型。接下来,我们定义了一个 useContext 钩子函数,来检查上下文值是否为 undefined,如果是则抛出错误。

这个泛型 createContext 函数的优势在于:它保证了在使用上下文时总是会有一个值,并且通过抛出错误确保类型的安全性。这样一来,无论何时在项目中使用这个上下文,我们都可以确保上下文值的完整性和一致性。

使用泛型使代码更加健壮,并且在创建上下文时大大提高了类型安全性,避免了潜在的调试问题。这种方式使得代码更具可维护性,同时确保了项目的一致性。

5. Consuming a Custom Context 使用自定义Context

好了,接下来我们来应用这个解决方案。首先回到 context.tsx 文件,这是我们遇到问题的地方。我们已经创建了一个 createContext,现在要替换所有内容。

首先,我们不再需要 createContext,因为在另一个文件中已经有了,我们可以从那里导入它。所以我们导入刚才创建的自定义 createContext。导入完成后,就可以去掉旧的内容,并用新的内容替换它。

现在,当我们查看 colorContext,它会返回一个包含 colorContextStateProvider 的数组。我们可以使用 useContextContextProvider 分别引用这些内容,然后将应用程序中的旧 Provider 替换为我们自定义的 Provider

接着,我们在 index.tsx 文件中的 ColorProvider 保持不变,然后在 App.tsx 文件中移除旧的 useContext,改为导入我们自定义的 useContext,并不需要传递任何参数。这样就修复了 App.tsx 中的问题。

接下来,我们还要检查在其他文件中是否使用了 useContext,并按相同方式进行替换。例如,在 commonColorChange 文件中使用了 useContext,我们可以移除原来的 useContext,然后从我们自己的上下文文件中导入它。

这正是为什么创建自己的钩子是有好处的。因为这样一来,其他组件就不需要关心上下文是如何创建的,也不需要关心默认值的设置,只需要简单地使用这些钩子函数。这样即使以后我们决定从上下文 API 切换到 Redux,也不需要更改整个应用程序中的代码,只需要修改自定义的 useContext 钩子的代码就行了。

这个例子展示了如何通过封装来简化应用程序的逻辑维护。这样我们不需要直接从 React 中导入 useContextcreateContext,只需从我们自己的文件中导入即可。

最后,我们在浏览器中测试了新逻辑,发现点击颜色按钮后颜色能够成功改变,证明新的 dispatch 逻辑正常工作。整个问题的核心是使用 TypeScript 的泛型功能来控制 createContext 的类型,并确保上下文有合适的默认值。

这展示了如何创建自定义 createContext,并通过泛型来增强类型安全性。这样不仅省去使用 as 关键字的麻烦,还能更好地控制上下文的初始化。此外,这种封装方式让代码更具可维护性,并且在未来需要切换到 Redux 时,只需修改封装的上下文钩子即可,无需在应用中修改其他代码。

为了进一步实现封装,可以为不同的状态值创建单独的自定义钩子。例如,我们可以创建一个 useHexColor 钩子来获取 hexColor 值,或者创建一个 useDispatch 钩子来处理 dispatch。这样应用程序的其他组件只需调用这些钩子,而不需要直接接触上下文 API,从而实现更好的模块化和维护性。

6. Building a Type Helper 构建类型助手

大家好。我们来看看这里的内容吧。主要思想是关于这个字符串或这种灵活的语法。基本上我们在这里使用它,以便在使用此类型(灵活菜单)时可以有自动补全功能,同时还可以输入其他值。

例如,如果你在这里查看,你会看到我们有自动补全功能,也可以输入任何其他字符串。这正是我们在这里使用它的主要原因。这里我们也有相同的功能,如果移除它,你会看到我们有主要和次要的自动补全选项,一切都运作良好。我相信你知道这里的工作原理。

不过,还有改进的空间。正如你所看到的,这个语句或语法被重复使用,因此并不十分简洁。而且对于一些新手来说,可能会觉得难以理解。所以我们希望使其更具描述性、更清晰并更容易理解。

你的任务是创建一个 TypeScript 助手函数。如果你对 TypeScript 助手函数不了解,可以去阅读文档,比如 TypeScript 的文档,并了解什么是类型函数。

你要创建的类型助手的主要责任是包含这个语法,从而使我们能够提取它,并在后续使用时只需在一个地方进行更改。我们可以称之为 FlexibleAutocomplete 类型助手,该类型接受一个泛型参数,比如 T 或任何你想命名的东西。这个类型助手的作用是,它可以接收任何类型的输入,然后返回类似于常量的值。

现在,让我们看看如何使用这个助手函数。假设我们有一个 example 变量,它的类型设置为 FlexibleAutocomplete,并传入 menu。这样 T 就包含了 menu,并且最终类型仍然是我们定义的输出类型。

我们要做的是,让这个助手函数返回包含这种表达式的组合。让我们用这个助手函数来包装 menu,并应用相同的操作到 buttonVariants 上。最终你会发现它们的类型仍然和之前一样。

通过这种方式,你可以捕捉到代码中重复的部分,并可以在单一位置添加注释和说明,使代码更清晰、更易于维护。

7. Another Type Helper 另一个类型助手


大家好,欢迎回来。

今天我们将再次回顾一个之前的练习。我相信你们还记得这个。我们有一个输入框组件,这个输入框的 props 非常有趣,正如你们记得的,我在这个应用组件中使用了它。

在这里,你必须传递 valueonChange 两个 props,或者什么都不传递。所以,这里要强调的是:要么不传递任何值,因为这是可选的,即使传递了,也必须都设置为 undefined。也就是说,所有 props 要么全部传递,要么都不传递。

正如你们看到的,我们需要加上这些括号,还需要在每次需要这种 props 模式时重复这个繁琐的代码。这个视频的练习是要你再次使用类型辅助工具(type helpers)来捕捉这种变体。

将这个类型作为泛型参数传递,稍微调整它,并将这个部分添加进去。你可以稍微思考一下,如果有问题,可以回来找我,我们可以一起做。

好了,欢迎回来。接下来,我们将一起完成它。我们将创建一个叫做 TightProps 的类型。意思是,当你想要一些“严格”的 props 时,要么全部传递,要么一个都不传递。

你可以重命名,以免混淆。我们将传递一个泛型类型 T,暂时设为空对象。这意味着,如果我们将泛型 T 设为必须的 props 部分,那么我们可以说 T 的另一侧也必须是这样的结构。我们需要将这个结构插入空对象中,以便在 TightProps 中获得完整的表达式。

我们可以创建另一个类型助手,叫做 OptionalProps。我们会传递 T 作为泛型参数。现在,我们需要弄清楚如何基于这个表达式转换,因为这个表达式中的所有 props 都是可选的,且值为 undefined。我们可以使用 Record,这样我们就可以创建一个新的对象类型,具有键和值。

对于键,我们知道这里的值叫做 valueonChange。我们希望这些键与泛型 T 中的键相同。所以,我们将使用 key of T。这样传递后,键都将作为 Record 的键,而值都设为 undefined

让我们创建一个示例类型,使其更清晰。我们将传递 OptionalProps,并传递 T。例如,如果你悬停在这里,可以看到我们成功地将其转换为可选的、undefinedprops

接下来,我们要用 Partial 包装它,使 props 可选。Partial 会使传递的所有项都变成可选的。如果你悬停在这里,你可以看到现在的 props 都变成了可选的。接下来,我们只需要在这里使用它。

代替之前的代码,我们只需编写 OptionalProps 并传递 T。我们可以去掉这些括号,直接使用 TightProps,并传递这个泛型类型。现在一切正常,你将看到预期的错误信息。

最后,不要让这些名字混淆了你。可以随意更改,例如将它们命名为 OptionalAndUndefinedProps 或其他。希望这能帮助你将复杂类型分解为简单的类型助手,使代码更简洁。

8. Generic Constrains 泛型约束


好的,我们仍在同一个示例中,继续使用这个漂亮的输入框组件。通过类型助手,我们已经将一个相对复杂的类型简化成更简单的语法,但这里存在一个小问题。

假设我想创建一个类型 test,并传入 type props,对于泛型 T,我想传递一个数字,没有报错。传入字符串,也没有报错。即使是传入 null,它也不会报错。我们并不希望允许传入任何类型的值。T 应该只接收对象类型,如果传递数字、字符串等其他值,它应该报错,并提示我们需要一个对象类型。

你的任务是找到一种方法来约束这个 T 泛型,使它仅接受对象类型,其他类型则会报错。

好了,欢迎回来。接下来让我给你展示一些内容。假设我们有一个函数,暂时命名为 blah,它接收 T 并返回一个空对象。现在,如果你将 T 指定为 number 类型,并在调用函数时传递一个字符串,它会报错,提示期望的是数字而不是字符串。我们希望对泛型 T 进行类似的类型约束,但如何实现呢?

在 TypeScript 中,约束类型的关键字是 extends。所以,我们可以使用 extends 来约束传入的 T,让它只能扩展指定类型。我们可以约束 T 为对象类型,但直接使用 object 并不是最好的选择,因为在 TypeScript 中,对象更常用的是 Record 类型。

Record 类型要求键是字符串,并且值可以是任意类型。因此,这里我们指定 TRecord<string, any>,表示 T 将是一个对象类型,键为字符串,值为任意类型。现在,如果 T 不符合这个约束,例如传递数字或字符串,编译器就会报错。

接下来,我们在泛型中使用相同的约束,使代码正常工作。如果你创建一个类型,例如 test,并为 type props 传递一个数字类型,它会提示错误。同样,如果传递字符串,也会提示错误。这样,我们就实现了在类型助手中使用 extends 进行类型约束。

9. Typing a Hook with Generics 使用泛型类型钩子


好的,欢迎回来。

在这个有趣的练习中,我们将实现一个类似于自定义 Hook 的 useLocalStorage,它接收一个标识符作为字符串。这个 useLocalStorage 提供两个函数:setget

  • set 函数接收一个字符串类型的键和任意类型的值,并将其设置到本地存储中,键将被组合成 key + identifier,值则被存储为 JSON 格式。
  • get 函数接收本地存储中的键并返回对应的值或 null

在使用 useLocalStorage 时,我们创建了一个客户端实例,但目前无法传递泛型类型,并且在调用 get 函数时,它返回的类型是 any,而我们期望返回具体的类型。你需要解决以下两个问题:

  1. 使 useLocalStorage 成为泛型,这样可以传递类型。
  2. 修改 getset 函数的返回值,使其能够使用泛型类型而不是 any

你可以先暂停视频,试着解决这些问题,再回来查看。


首先,我们要在 useLocalStorage 函数定义前面加上一个泛型参数。因为我们使用的是 TypeScript,所以我们可以在函数括号前定义一个泛型 T。这样一来,错误提示就会消失。

接下来,我们要利用这个 T 来修复其他问题。get 函数需要根据存储的值是否存在返回相应的类型:

  • 如果键存在,返回类型 T
  • 如果键不存在,返回 null

我们将返回类型设置为 T | null,这样当你悬停在客户端实例的 level 上时,便可以看到它现在返回的类型是 string | null,这正是我们需要的。

此外,我们还需要确保 set 函数的值类型符合我们传递的泛型 T。因此,将 any 替换为 T,即可确保设置的值类型与泛型一致。

10. Inferring Generic Types 推断泛型类型


好的,我们在这里使用 useState 包装状态管理,但与直接返回元组不同,我们将其作为对象返回。有时在代码中你可能会使用这种模式。然而,存在一个问题:当我们在这里使用这个状态时,例如我们传递 nameCatholics,但悬停在例子上时,会看到 valueany 类型,而 set 的类型也为 Dispatch<any>。这是因为 TypeScript 无法根据传递的参数推断出类型。

我们传递了一个字符串 name,理论上 TypeScript 应该知道这个值是字符串,且 set 也应接收字符串类型,但它没有做到这一点。你需要修复这个问题。代码量不大,但可以尝试自己解决,完成后回来,我们再一起做。


解决这个问题其实很简单。首先,我们需要在 userStateObject 中接收一个泛型 T。然后,将 initial 的类型从 any 修改为泛型 T。这样,如果你悬停在示例上,就会看到一切正常工作了。

虽然我们没有显式传递 T,TypeScript 可以根据运行时传递的参数(例如 name 是字符串)推断出泛型 T。因此,即使没有显式地指定 T 的类型,TypeScript 也能通过传递的参数推断出泛型类型,并在函数体中使用。

例如,如果你在这里传递 name 为字符串,并添加 keyTwo 为数字,悬停在示例上,会看到 name 的类型是 string,而 keyTwonumber。如果你手动指定 T 的类型,那么运行时的参数也会根据类型推断进行约束。这种模式很智能且干净,无需显式指定返回类型。TypeScript 会自动根据泛型推断出返回值类型,让你的代码更简洁。

11. Generic Components 泛型组件


我们在本视频中使用的组件名为 ProductList,它接收两种类型的 propsrowsrenderRow

  • rows 是包含若干对象的数组,每个对象代表一行的数据。
  • renderRow 是一个函数,接收每行的数据并返回一个用于显示该行的 React 节点,这里通过 map 方法在一个无序列表中渲染。

这是一个常见的模式,尤其是当你希望对不同的行应用不同样式时,使用这种模式很方便。在示例中,我们有一组虚拟产品,每个产品只包含 IDtitle 属性。我们将这些产品传递给 ProductList,并使用 renderRow 来渲染它们。

但是,悬停在 rows 上时,会看到它的类型是 any,而我们传入的是具体的 products 类型。TypeScript 没有自动推断出具体类型。此外,我们还可以访问不存在的属性,并不会报错。我们的任务是通过泛型修复这些问题,让 ProductListrowsrenderRow 能自动推断出具体的类型。


首先,我们将 ProductListProps 类型声明为泛型,接收一个类型参数 T。然后,我们将 rows 定义为 T 类型的数组,renderRow 函数接收类型为 T 的参数。这确保了 ProductList 组件接收到的每个 row 的类型一致。

ProductList 的函数定义前添加泛型参数 T,将 rows 定义为 T[] 类型,将 renderRow 定义为 (row: T) => React.ReactNode 类型,这样 TypeScript 就能够推断出类型。

更新后,如果你悬停在 rows 上,会看到具体的类型信息,如 IDtitle。现在,如果尝试访问不存在的属性,TypeScript 会报错,提示该属性不存在。

这种方法展示了如何创建一个泛型组件。当两个或多个 props 之间存在类型依赖时,例如 rows 数组中的每个元素类型与 renderRow 函数参数的类型一致,可以考虑使用泛型组件。这不仅简化了代码,也使得 TypeScript 可以自动推断类型,在代码提示和类型检查上更加智能。

12. Passing Types to Components 传递类型到组件


我们再次回到之前的泛型函数组件,这次要看看如何在 ProductList 组件中使用类型推断。

在这里,我们传递了一个随机内容的数组,它会报错,因为没有找到 title 属性。这是即时的错误反馈。然而,如果我们查看 rows,你会发现我们传递了一个数组,其元素的结构是 IDnumber,而 title 应该为 string。但实际上,title 这里是 number,所以出现了类型不匹配的问题。

你的任务是将 Product 接口作为类型传递给 ProductList 组件,这样它就知道应该期待 rows 是什么类型的数据。


欢迎回来!要实现这一点,你可以像在泛型函数中传递参数类型一样为泛型组件传递类型参数。例如,对于 Product 接口类型,可以在组件名称后传入:

<ProductList<Product> />

这样,TypeScript 就会检测到传入的类型是否与 Product 匹配。现在,如果我们传入的 ID 不是 numbertitle 不是 string,TypeScript 将会报错,提示类型不匹配。这是一种整洁的方式,可以在泛型组件中使用显式类型推断,确保代码的类型安全。

13. Reconsidering Generics 重新考虑泛型


在本视频中,我们将探讨如何简化一个带有泛型的组件 Popup。这个组件允许传递两种变体:withControlsnoControls

  • props 包含 isOpen 作为布尔值,以及 variant,它可以是 withControlsnoControls
  • variantwithControls 时,组件还允许传递 labelonClick 属性。
  • 否则,当 variantnoControls 时,不允许传递这些额外的属性。

这是通过泛型的方式实现的,TypeScript 会根据传入的 variant 值,确定是否允许 labelonClick 这些属性。


你的任务是:找到一种更简单的方法来实现同样的功能。虽然泛型在很多情况下有效,但我们是否可以通过更简单的方式,比如联合类型,来解决这个问题?


欢迎回来!我们可以通过使用联合类型来简化代码。

步骤如下:

  1. 我们首先保留 isOpen 属性,它是布尔值。
  2. 然后,我们定义两个联合类型分支:
    • 第一个分支是 variantwithControls,它要求 label 是字符串,onClick 是一个函数。
    • 第二个分支是 variantnoControls,它不允许传递 labelonClick 属性。

最终代码结构如下:

interface PopupProps {
  isOpen: boolean;
  variant: "withControls" | "noControls";
}

interface WithControlsProps extends PopupProps {
  variant: "withControls";
  label: string;
  onClick: () => void;
}

interface NoControlsProps extends PopupProps {
  variant: "noControls";
}

type Props = WithControlsProps | NoControlsProps;

通过这种方式,我们不再需要复杂的泛型,直接使用联合类型来约束传递的属性。


这样,当你选择 withControls 时,可以正确传递 labelonClick。如果选择 noControls,则 TypeScript 会阻止你传递额外的属性,并给出错误提示。

结论:虽然泛型非常强大,但在某些情况下,联合类型提供了更简单的解决方案。在这个例子中,使用联合类型不仅简化了代码,还提升了可读性。选择尽量简单的方案,往往是更好的做法。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

28. Advanced Typescript More on Typescript 高级Typescript更多内容

1. Types vs interfaces 类型 vs 接口

在 TypeScript 中,typesinterfaces 都用于定义数据的结构,但它们在行为和用法上有所不同。

接口(Interfaces)

接口通常用于定义对象和类的结构,帮助指定对象应具备的属性和方法。它们特别适合用于定义对象的“合同”或“蓝图”,使不同的对象共享相同的属性和方法。此外,接口是可扩展的,你可以在需要时为接口添加更多的属性或方法,这使得它们更加灵活。

类型(Types)

类型同样用于定义数据结构,但不局限于对象和类。类型可以用于定义函数的类型,或为复杂的类型创建别名,这在提升可读性和复用性方面很有用。与接口不同,类型在定义后不可重新打开或扩展,无法在后续添加新的属性或方法。

选择何种方式?

如果你在定义一个公共 API,并希望其他人能够扩展它,接口是不错的选择。接口允许 API 的使用者根据需要添加更多属性或方法。

在 React 组件中,尤其是用于定义 propsstate 的结构时,推荐使用 types。这样可以保持代码的一致性,同时对组件中使用的类型施加更多约束。

2. Function overloads 函数重载


在这段视频中,我们讨论了 TypeScript 的函数重载。函数重载允许你为一个函数定义多个类型签名,从而根据不同的参数组合实现不同的行为。

函数重载的基本示例

add 函数为例,假设它接收两个数字并返回它们的和。为了实现不同的调用方式,我们可以通过函数重载来定义不同的签名,例如:

  • 如果传入两个数字,直接返回它们的和。
  • 如果传入一个数字,则返回一个接收第二个数字的函数。

实现步骤

  1. 定义函数签名:

    • 使用 add(a: number, b: number): number 作为第一个签名。
    • 使用 add(a: number): (b: number) => number 作为第二个签名。
  2. 编写函数体:
    在函数体中,根据传入的参数决定返回值:

    • 如果提供了 ab,直接返回它们的和。
    • 如果仅提供了 a,则返回一个接收 b 的新函数,并在该函数中返回 a + b 的结果。

代码示例

function add(a: number, b: number): number;
function add(a: number): (b: number) => number;
function add(a: number, b?: number) {
  if (b === undefined) {
    return (b: number) => a + b;
  }
  return a + b;
}

// 测试代码
console.log(add(5)(4)); // 输出 9
console.log(add(3, 3)); // 输出 6

测试运行

  1. add(5)(4) 调用返回 9,因为它返回一个函数,接着传入 4 计算 5 + 4
  2. add(3, 3) 直接调用并返回 6

这种实现方式展示了函数重载如何提供灵活的函数调用方式,使代码更具可读性和扩展性。TypeScript 中的函数重载特别适用于不同参数组合的多态实现,可以帮助开发者在编写复杂应用时更有效地管理函数行为。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

29. Advanced Typescript Component Patterns 高级Typescript组件模式

1. Higher Order components Part1 高阶组件 Part1


在这一部分视频中,我们讨论了 React 中的组件模式,特别是高阶组件(HOC)的使用。我们通过一个简单的示例项目来展示如何将逻辑从组件中分离出来,创建一个“展示组件” (presentational component) 和一个负责逻辑的高阶组件。下面是具体步骤和示例。

1. 现有代码概述

  • 我们有两个文件:

    • Position.tsx:接收鼠标事件并返回鼠标当前位置的 X 和 Y 坐标。
    • GetPos.tsx:该文件包含了鼠标位置检测的逻辑。
  • Position.tsx 文件中,我们定义了一个简单的函数式组件用于显示鼠标位置,但它包含了逻辑代码来计算 X 和 Y 坐标。这使得组件逻辑和显示逻辑紧密耦合,不利于维护。

2. 任务说明

目标: 将鼠标位置检测逻辑从 Position.tsx 中分离,使其成为无状态的展示组件,并创建一个高阶组件 withMouseMove 来处理逻辑。

3. 解决方案

步骤:

  1. 创建展示组件: 创建一个 DisplayMousePosition 组件,它是无状态的,只负责接收并显示 X 和 Y 坐标。
  2. 创建高阶组件 withMouseMove 创建一个高阶组件,它包含了鼠标位置计算的逻辑,并将 X 和 Y 值作为 props 传递给 DisplayMousePosition 组件。
  3. 应用高阶组件:App.tsx 中,将 DisplayMousePosition 包裹在 withMouseMove 中,并展示鼠标移动效果。
// DisplayMousePosition.tsx
import React from 'react';

interface MouseProps {
  x: number;
  y: number;
}

const DisplayMousePosition: React.FC<MouseProps> = ({ x, y }) => (
  <div>
    X: {x}, Y: {y}
  </div>
);

export default DisplayMousePosition;
// withMouseMove.tsx
import React, { useState, useEffect } from 'react';

interface Position {
  x: number;
  y: number;
}

const withMouseMove = <P extends Position>(Component: React.ComponentType<P>) => {
  return (props: Omit<P, keyof Position>) => {
    const [position, setPosition] = useState<Position>({ x: 0, y: 0 });

    const handleMouseMove = (event: MouseEvent) => {
      setPosition({
        x: event.clientX,
        y: event.clientY,
      });
    };

    useEffect(() => {
      window.addEventListener('mousemove', handleMouseMove);
      return () => window.removeEventListener('mousemove', handleMouseMove);
    }, []);

    return <Component {...(props as P)} x={position.x} y={position.y} />;
  };
};

export default withMouseMove;
// App.tsx
import React from 'react';
import DisplayMousePosition from './DisplayMousePosition';
import withMouseMove from './withMouseMove';

const EnhancedMouseComponent = withMouseMove(DisplayMousePosition);

const App = () => (
  <div>
    <h1>Mouse Position Tracker</h1>
    <EnhancedMouseComponent />
  </div>
);

export default App;

4. 代码解析

  • DisplayMousePosition:这是一个展示组件,仅接收并展示鼠标位置。
  • withMouseMove:这是一个高阶组件,负责捕获鼠标事件并计算位置。它通过 useStateuseEffect 将鼠标位置的状态管理与展示组件分离。
  • App.tsx:最终我们使用高阶组件 withMouseMove 包裹展示组件,实现分离逻辑与展示的目的。

通过这种模式,高阶组件将位置逻辑与 DisplayMousePosition 的展示逻辑完全解耦,便于测试和复用,同时也提升了代码的可读性。

2. Higher Order components Part2 高阶组件 Part2

首先,我们要在组件文件夹中创建一个名为 h o C 的目录。在这个目录中,我们将创建两个文件:一个是 displayMouseMove.tsx(用于显示鼠标位置),另一个是 withMousePosition.ts

withMousePosition.ts 文件将包含所有计算 X 和 Y 坐标的逻辑,并将这些值传递给 displayMouseMove,这是展示层的功能。展示层非常简单。我们来实现它。

displayMouseMove.tsx 中定义 displayMouseMove 组件,这个组件将接收一些属性。返回时,我们需要传递 X 和 Y 位置以及更新位置的函数。首先,属性需要包括 X 和 Y 坐标。接下来,我们创建一个 updatePosition 函数用于更新位置数据。

对于展示部分,我们使用 TypeScript 定义类型。首先定义一个 DisplayMouseMoveProps 类型,包括 X、Y 和一个 onMouseMove 事件处理器。确保将 MouseEventHandler 从 React 中导入。

然后我们创建 withMousePosition 高阶组件。它是一个返回 JSX 的函数,会接收一个组件作为参数。高阶组件的本质是返回另一个函数,同时在 TypeScript 中,我们使用泛型确保组件的类型正确。对于 onMouseMove 事件,我们会更新组件的状态并使用 useState 来保存位置。

接下来,我们定义一个辅助函数 updatePosition,这个函数会从事件对象中提取 X 和 Y 值,并更新到状态中。

最后,返回被包装的组件,并传递 X 和 Y 以及 onMouseMove 处理函数。我们可以在主应用文件中导入这个高阶组件并进行测试。

在浏览器中进行测试时,可以看到组件已经正常工作。使用高阶组件的好处在于它增强了组件的可测试性和可复用性。通过将逻辑拆分为独立的部分,组件更易于进行单元测试。

3. Render Props 渲染属性

欢迎回来!我们今天要使用一种名为 render props 的模式来解决与鼠标位置相关的问题。虽然这种模式比高阶组件(HOC)稍旧,但它在某些场景下依然非常强大。

什么是 Render Props?

Render Props 的核心思想是创建一个包装组件,该组件可以进行一些计算或从 API 获取数据,然后将结果作为 props 传递给它的子组件。这种方式简单直接。

为了实现它,我们首先在组件文件夹中新建一个目录,命名为 renderProps。然后在其中创建一个文件,例如 RenderMousePosition.tsx,它将作为我们的包装组件。

首先,我们将其定义为一个常量,命名为 RenderMousePosition,这个组件看起来与普通的函数组件相似。它会接收子组件作为 props,因此我们定义子组件的类型为 React.ReactNode,这样 TypeScript 就会知道子组件的类型。

接着,我们定义一个状态 position,用于存储 X 和 Y 坐标,并将初始值设为 { x: 0, y: 0 }

为了处理鼠标移动事件,我们创建一个 updatePosition 函数。当鼠标移动时,函数会被触发,并更新 X 和 Y 坐标。我们通过事件对象的 clientXclientY 属性获取这些值。

最后,我们在 RenderMousePosition 组件中返回一个 div,并为其添加一个 onMouseMove 事件处理器,将 updatePosition 绑定到该事件。在 div 内,我们通过 children 属性将 X 和 Y 值传递给子组件,这样子组件就能接收到这些坐标值。

使用 Render Props

要在应用中使用这个组件,我们可以在 App.tsx 文件中导入 RenderMousePosition 组件,并将子组件作为其子元素。例如,定义一个 DisplayMousePosition 组件,它接收 X 和 Y 值并展示在页面上。

为了保持代码整洁,我们可以将 DisplayMousePosition 定义在单独的文件中,并在 App.tsx 中导入并使用它。这样就能清晰地分离逻辑部分和展示部分。

总结

Render Props 模式的主要优点在于它可以很好地将逻辑和展示分离,使代码更加模块化和可复用。通过这种模式,我们不仅能更灵活地控制组件内部的逻辑,也更方便地对其进行测试和扩展。希望这次的讲解让你了解了 render props 的应用场景和优势。

4. Custom Hooks 自定义钩子

亲爱的同学们,大家好!欢迎回来。

在本视频中,我们将优化鼠标位置的示例,使其更加简洁、易用,并提高复用性和测试性。这次我们会使用另一种常见模式:自定义 Hook(Custom Hook)。相信大家都熟悉它,并知道如何使用,不过我们今天会用 TypeScript 来实现它,看看是否能学到一些 TypeScript 的知识。

首先,我们创建一个名为 Hook 的目录。在里面新建一个文件,命名为 useMousePosition.ts。在这个文件中,我们将创建自定义 Hook useMousePosition,不需要任何参数。

useMousePosition 中,首先我们会定义一个 useState,用于存储 X 和 Y 坐标的状态,初始值设为 { x: 0, y: 0 }

接下来,我们定义一个 updatePosition 函数,用来处理鼠标移动事件。为了避免不必要的渲染,我们使用 useCallback 钩子函数,使得 updatePosition 只在依赖项发生变化时重新创建。该函数使用 clientXclientY 更新鼠标位置。

然后,我们返回一个包含 X、Y 和 onMouseMove 的对象,便于外部调用。在 onMouseMove 中,我们传递 updatePosition 函数。

在应用中使用自定义 Hook

我们可以在 App.tsx 中使用这个自定义 Hook。例如,定义一个常量,并通过解构获取 xyonMouseMove。然后使用 useMousePosition 函数将它们赋值。

接着,我们可以将这些属性传递给任意组件,如一个展示鼠标位置的组件。这种方式使得代码更加简洁和清晰,也有助于提高组件的复用性和测试性。

在浏览器中验证效果后,您就可以看到自定义 Hook 的实际效果。

总结

现在你已经掌握了如何使用 React 的一些主要组件模式,并通过 TypeScript 进一步增强了代码的可读性和类型安全性。如果有任何问题,欢迎随时在问答区提问。

5. Limiting Prop Composition 限制属性组合

大家好,欢迎回来!

在本视频中,我们将介绍一种在 React 和 TypeScript 中实现组件功能的模式。比如,在这个小项目中,我们有两个按钮:一个是主按钮(Primary Button),另一个是次按钮(Secondary Button)。当然,你也可以有其他按钮类型,但为了简单起见,我们这里只用这两个按钮。

目前我们有一个简单的按钮组件,你可以传递子元素,比如这里我们传入的是字符串。我们也可以通过 type 属性传入按钮的类型(例如 primarysecondary),并根据它来设置 className。然而这里有个问题:如果传入了两个类型(例如同时是 primarysecondary),它会导致不明确的样式。所以我们希望限制 props 的组合,使得每个按钮只能有一个类型,不能同时是 primarysecondary

为此,我们可以在 Button.tsx 文件中创建一个 buildClassName 函数。这个函数接收一个对象,该对象的键是按钮类型(如 primarysecondary),值是布尔值(truefalse)。如果传入了 true,那么相应的按钮类型将被应用。这样我们就能确保每次只有一个按钮类型会被应用。

使用 Build Class Names 函数

我们在 buildClassName 函数中定义一个变量 className(初始值为空字符串)。然后我们遍历传入的 classes 对象(包括键和值),如果值为 true,则将键加入到 className 中。遍历完成后,我们返回最终生成的 className

然后在 Button.tsx 中使用这个函数。我们定义 Button 组件的 props,其中 children 为字符串,primarysecondary 为布尔值,并且在组件类型定义中确保两者不能同时为 true

限制 Props 组合

我们创建两个类型:PrimaryButtonPropsSecondaryButtonProps。在 PrimaryButtonProps 中,primary 是必需的,而 secondarynever,反之亦然。在 Button 组件的 props 类型定义中,我们用联合类型确保传入的 props 只能是其中一种。

在应用中使用

在使用组件时,你可以指定按钮类型(如 primarysecondary)。如果误传了两个类型,TypeScript 会立即报错,提醒只能设置一个类型。这就是限制 props 组合的效果。

总结

这种方法可以让你通过 TypeScript 限制组件 props 的组合,在开发阶段帮助开发者避免误用。此外,buildClassName 函数通过遍历 props,实现了对样式类名的动态控制。希望这个示例能让你更好地了解如何使用 TypeScript 来增强组件的类型安全性。

6. Requiring Prop Composition 需要属性组合

大家好,欢迎回来!

今天我们将讨论如何在组件中要求传入的 props 进行组合。首先来看这个简单的演示项目。我们有一个简单的文本组件,可以称之为 TextBand,它是一个函数式组件,包含一段较长的文本字符串,并接收两个状态(也就是两个 props):shortexpanded

如果 shorttrue,组件将只显示前 50 个字符,并在末尾添加 ...。如果 expandedtrueshortfalse,则会显示完整的文本。我们将演示如何在 TypeScript 中要求这些 props 必须以一定组合传入。

App.tsx 文件中,我们有一个简单的状态 expanded,初始值为 false,并且包含一段长文本。这个状态用于控制文本是否显示完整或部分。返回的 JSX 包含一个 TextBand 函数组件,以及一个按钮,用于切换 expanded 的值。

TextBand 组件

TextBand 组件接收两个 propsshortexpanded。如果仅传入 short,则显示部分文本;如果传入 expanded,则显示完整文本。然而,我们想要求,如果使用 expanded,必须同时传入 short。这种功能可以通过 TypeScript 实现。

使用 TypeScript 进行函数重载

为了实现这种功能,我们将使用函数重载。我们为 TextBand 组件定义多个 props 类型,这样就可以实现对 props 的不同组合要求。

  • NotShortTextProps:表示文本未缩短的情况。我们将 short 定义为可选并默认为 false
  • ShortTextProps:表示文本已缩短的情况。当 expandedtrue 时,要求同时传入 short,以确保文本显示的状态一致。

我们使用 TypeScript 的重载功能来创建这两个不同版本的 props 类型,并将它们应用到 TextBand 组件中。这样就能确保在代码中如果没有传入 short 而传入了 expanded,TypeScript 会发出错误提示。

使用示例

现在我们可以测试这个功能。在 App.tsx 文件中,如果只传入 short 或不传任何 props,组件会正常工作;但如果没有 short 而只传入 expanded,TypeScript 会报错,提醒用户必须同时传入 short

这种方式不仅在项目规模变大时非常实用,而且在多人合作中也能减少错误,提高代码的可维护性。

额外的 Props 支持

另外,如果我们希望将额外的原生 div 元素的 props 传递给 TextBand 中的 div,可以使用 React.ComponentPropsWithoutRef 结合特定的 HTML 元素类型。例如,可以传入 id 或其他属性,并通过扩展运算符 ... 传递给 div 元素。这使组件在保留原有功能的同时,具备了更高的灵活性。

希望你们喜欢这个功能扩展的介绍,并能在项目中加以应用!

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

30. Bonus 额外

1. Render Props 渲染属性

好的,接下来我们要讨论的模式是渲染 props(render props)模式。

渲染 props 模式在你需要共享一些有状态的逻辑,并希望将某些功能抽象出来的情况下非常有用。这样做可以让该组件的使用者提供他们自己的 JSX 元素。在这个模式的示例中,我们将创建一个非常简单的 ListManager 组件,用于渲染一组列表项。

首先,我们有一个 books.json 文件,其中包含了一些示例数据。接下来我们会用这个文件作为示例数据,用来展示我们的列表。第一个要创建的组件我们可以称为 ListHandler

创建 ListHandler 组件

ListHandler 是一个简单的函数式组件。它将接收一些 props

  1. items:要渲染的列表项;
  2. keyExtractor:在 React 中,每个项都需要一个唯一的 key,这个函数负责从 items 中提取 key
  3. renderItem:渲染项的函数。

代码如下:

const ListHandler = ({ items, keyExtractor, renderItem }) => {
    return (
        <div>
            {items.map((item, index) => (
                <div key={keyExtractor(item)}>
                    {renderItem(item, index)}
                </div>
            ))}
        </div>
    );
};

我们将导出 ListHandler

export default ListHandler;

创建 DisplayBooks 组件

接下来,我们创建一个名为 DisplayBooks 的组件,用它来消费 ListHandler 组件并显示 books.json 文件中的五本书。

DisplayBooks 组件中,我们可以通过使用样式组件库(styled-components)来美化输出。首先,我们创建一些样式化的容器:

import styled from 'styled-components';

const Container = styled.div`
    padding: 20px;
`;

const BookTitle = styled.h3`
    font-size: 1.5rem;
    margin: 10px 0;
`;

DisplayBooks 中使用这些组件:

const DisplayBooks = () => {
    const booksData = books.slice(0, 5); // 只获取前五项

    return (
        <Container>
            <BookTitle>Book List</BookTitle>
            <ListHandler
                items={booksData}
                keyExtractor={book => book.id}
                renderItem={(item) => (
                    <div>{item.title}</div>
                )}
            />
        </Container>
    );
};
export default DisplayBooks;

总结

通过渲染 props 模式,ListHandler 组件能够让使用者传递自定义的 JSX 片段(renderItem)。这不仅为样式和内容提供了更高的灵活性,也减少了代码重复性。此外,可以在 ListHandler 中设置默认的渲染内容,以便在没有传递 renderItem 时,依然能提供默认的展示内容。


希望这个示例能够帮助你理解渲染 props 模式的强大和灵活性!如果有任何问题,欢迎随时提问。

2. Wrapper Component 包装组件

欢迎回来,接下来我们要讨论的模式称为“包装器组件” (Wrapper Component)。

包装器组件的作用正如其名,是一种包装其他组件并向其传递 props 的组件。你可以将其视为 props 转发组件。包装器组件在以下情况中特别有用:当你使用一些第三方库,这些库中可能包含一些组件(例如日期选择器组件),你希望在这些组件之上添加一些额外的功能。比如说,有一个流行的日期选择器库 React Date Picker,可以通过 npm 安装并使用它。

示例:创建一个 DatePicker 包装器组件

假设你想在应用的多个地方使用这个日期选择器,并且在其上方添加一个标签 (label),例如 “选择日期” 或 “选择生日”。为此,我们可以创建一个 DatePicker 包装器组件。

首先,在 DatePicker.js 文件中创建一个函数式组件:

import React from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';

const CustomDatePicker = ({ label, ...props }) => {
    return (
        <div>
            {label && <label>{label}</label>}
            <DatePicker {...props} />
        </div>
    );
};

export default CustomDatePicker;

在这个组件中,label 是一个可选的 prop,用于显示标签。如果 label 存在,则会渲染一个标签元素。此外,我们使用扩展运算符 ...props 将其他 props 转发给 DatePicker 组件。

使用 CustomDatePicker 组件

现在,我们可以在另一个组件中使用 CustomDatePicker 并传递一些自定义的 props。例如,创建一个 WrapperComponent.js 文件,内容如下:

import React, { useState } from 'react';
import CustomDatePicker from './CustomDatePicker';

const WrapperComponent = () => {
    const [date, setDate] = useState(null);

    return (
        <CustomDatePicker
            label="选择生日"
            selected={date}
            onChange={setDate}
        />
    );
};

export default WrapperComponent;

在这个组件中,我们通过 useState 创建一个状态 date 来存储选择的日期。CustomDatePicker 组件接收一个标签和两个日期选择器的内置 propsselectedonChange,用于设置和更新日期值。

总结

通过使用包装器组件模式,可以轻松地在第三方组件上添加额外的功能和样式。这样可以提高组件的可重用性、扩展性和可替换性。如果需要更换日期选择器库,只需在包装器组件中替换实现,而不影响其他使用了 CustomDatePicker 组件的地方。

希望这个示例能帮助你理解包装器组件的使用场景和优势!

3. Polymorphic Component 多态组件

大家好,欢迎收看本视频。本视频将讨论“多态化的 React 组件”。

什么是多态化组件?

多态化组件模式为组件使用者提供灵活性,允许他们指定子组件渲染的元素类型。例如,假设你有一个按钮组件,具备不同的样式和变体。有时可能希望能够渲染其他元素,比如链接,而不是按钮。

示例:创建多态化按钮组件

假设我们有一个按钮组件,但希望根据需要渲染成 <a> 标签或其他元素。实现这种多态性可以使用 React 中的 as 属性,通过它我们可以指定组件应渲染的 HTML 元素。

实现步骤

  1. 创建 Button 组件,它接受一个 as 属性:
import React from 'react';

const PolymorphicButton = ({ as: Component = 'button', children, ...props }) => {
    return (
        <Component {...props}>
            {children}
        </Component>
    );
};

export default PolymorphicButton;

在这个例子中,我们使用 as 属性。如果未指定 as,则默认渲染为 <button> 标签。组件内容是 children,其他属性通过扩展运算符 ...props 转发到指定的元素中。

  1. 使用 PolymorphicButton 组件:
import React from 'react';
import PolymorphicButton from './PolymorphicButton';

const App = () => {
    return (
        <div>
            {/* 使用默认按钮 */}
            <PolymorphicButton>普通按钮</PolymorphicButton>

            {/* 使用 <a> 标签样式 */}
            <PolymorphicButton as="a" href="https://example.com">链接按钮</PolymorphicButton>
        </div>
    );
};

export default App;

在这个示例中,我们创建了两个按钮实例,一个默认渲染为 <button> 标签,另一个使用 <a> 标签并带有 href 属性。

总结

多态化组件是一个强大的设计模式,它允许使用者根据需求在同一个组件上使用不同的 HTML 元素,同时保留其样式和行为。这个模式不仅可以应用于 HTML 基本元素,还可以与自定义组件一起使用,使代码更具灵活性和可重用性。尝试在项目中使用这个模式吧!

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Aug 1, 2024

31. Appendix A - Typescript Basics 附录 A - Typescript基础

1. Typescript via Intellisense 通过Intellisense使用Typescript

以下是翻译和简化后的内容:


大家好,欢迎回来。今天我们开始介绍 TypeScript。

TypeScript 简介

在我们的示例代码中,components 文件夹中有一个子文件夹叫 ClassInfo。在 class.info 文件中,有一个小问题:这个代码是 TypeScript 还是 JavaScript?

如果你的答案是 TypeScript,那你答对了。因为这个文件扩展名为 .tsx,这是 TypeScript 的标志。尽管代码看起来和 JavaScript 差不多,但因为文件格式,它就是 TypeScript 文件。

TypeScript 自动推断类型

TypeScript 强大的地方在于它会自动推断代码中的类型。除非它无法识别类型,否则一般情况下你不需要手动指定类型。比如,如果你在 VS Code 中悬停在 ClassInfo 函数上,编辑器会告诉你这个函数返回了一个 JSX 元素的类型。

让我们看看一个简单的示例:

function testFunction() {
    return 5 - 3;
}

在这个函数中,TypeScript 能自动识别 testFunction 的返回类型为 number。如果我们把返回值改成字符串,TypeScript 也会自动识别为 string。TypeScript 的类型推断能力可以帮助你在编写代码时减少类型错误。

结论

TypeScript 是一个强大的工具,可以帮助我们自动推断类型。它通常可以准确地理解代码的类型信息,只有在它无法自动识别时,才需要开发者手动指定类型。这使得 TypeScript 成为一个非常实用的伙伴。

2. Defining Type of Props 定义属性类型

大家好,欢迎回来!今天我们来谈谈如何在 TypeScript 中为函数组件的 props 指定类型。

设置 props 类型

首先,在 app.tsx 中使用 ClassInfo 组件,例如传递一个 name 属性。在 TypeScript 中,如果没有明确指定组件的 props 类型,TypeScript 会给出一个错误提示。

要解决这个问题,我们需要在组件的定义中明确指出 name 的类型。例如:

function ClassInfo({ name }: { name: string }) {
  return <div>{name}</div>;
}

通过这种方式,我们明确了 name 的类型为 string,TypeScript 就不会再报错了。

定义更复杂的 props 类型

如果组件有多个 props,可以将这些类型集中定义在一个类型接口中,例如:

type ClassInfoProps = {
  name: string;
  course: string;
};

function ClassInfo({ name, course }: ClassInfoProps) {
  return (
    <div>
      <p>Instructor: {name}</p>
      <p>Course: {course}</p>
    </div>
  );
}

通过定义一个 ClassInfoProps 类型接口并在组件定义中引用它,可以让代码更清晰。如果将来需要添加更多的 props,也只需在 ClassInfoProps 中更新即可。

总结

在 TypeScript 中,使用类型接口来定义 props 是一种良好的实践,它不仅可以避免类型错误,还能让代码结构更简洁易读。

3. Migrating From JS to TS Exercise 从JS迁移到TS练习

大家好,今天我们来实践如何将 JavaScript 组件转换为 TypeScript 组件,并且为其添加类型。

练习步骤

我们将进行以下几个步骤:

  1. 文件扩展名更改:将文件扩展名从 .js 改为 .tsx
  2. 转换 PropTypes:将组件的 PropTypes 转换为 TypeScript 类型。
  3. 定义事件处理器类型:为 handleChange 事件处理器指定类型。
  4. 添加新属性:在之前的 ClassInfo 组件中,添加一个新属性并指定类型,同时设置默认值和可选标记。

步骤 1:更改文件扩展名

首先,重命名文件,将 settings.js 改为 settings.tsx。TypeScript 会自动检查文件,提示任何类型错误。

步骤 2:定义 props 类型

在 TypeScript 中为组件定义类型的一种标准方法是通过 type 关键字:

type DashboardProps = {
  inputName: string;
  handleChange: React.ChangeEventHandler<HTMLInputElement>;
};

步骤 3:为 handleChange 添加类型

使用 VS Code 的悬停功能来确定 onChange 的类型。一般来说,输入事件类型为 React.ChangeEventHandler<HTMLInputElement>,因此我们可以指定:

const Dashboard: React.FC<DashboardProps> = ({ inputName, handleChange }) => {
  return <input name={inputName} onChange={handleChange} />;
};

步骤 4:在 ClassInfo 中添加新属性并设置默认值

如果我们要在 ClassInfo 组件中添加一个新属性 course,并使其具有默认值和可选标记,我们可以这样做:

type ClassInfoProps = {
  name: string;
  course?: string;
};

const ClassInfo: React.FC<ClassInfoProps> = ({ name, course = "默认课程" }) => {
  return (
    <div>
      <p>Instructor: {name}</p>
      <p>Course: {course}</p>
    </div>
  );
};

通过这种方式,我们实现了属性类型定义、事件处理器类型定义和设置默认值。TypeScript 的类型检查和 VS Code 的智能提示可以帮助我们确保代码的类型安全。

4. Defining Types for Children 定义子组件类型

下面是如何在 TypeScript 中为 React 组件正确地定义 children 属性类型,并添加一个自定义属性的示例。

第一步:定义 children 属性的类型

在 React 中使用 TypeScript 时,如果 children 属性可以包含多种类型(如字符串、React 元素或其他组件),可以使用 React.ReactNode 来定义类型:

import React, { ReactNode } from 'react';

type CardProps = {
  children: ReactNode;
};

const Card: React.FC<CardProps> = ({ children }) => {
  return <div>{children}</div>;
};

使用 ReactNode 可以支持:

  • 字符串、数字和其他原始数据类型
  • JSX 元素
  • 元素数组或混合内容

第二步:添加一个可选的自定义属性

可以通过定义一个类型并添加新属性及其可能的值,然后在组件的 props 中扩展此类型来添加属性。例如,添加一个 color 属性:

type CardProps = {
  children: ReactNode;
  color?: 'blue' | 'green' | 'crimson';
};

const Card: React.FC<CardProps> = ({ children, color = 'blue' }) => {
  return <div style={{ color }}>{children}</div>;
};

第三步:使用 PropsWithChildren

当组件既有 children 属性又有自定义属性时,可以使用 PropsWithChildren 来简化类型定义:

import React, { PropsWithChildren } from 'react';

type CardProps = PropsWithChildren<{
  color?: 'blue' | 'green' | 'crimson';
}>;

const Card: React.FC<CardProps> = ({ children, color = 'blue' }) => {
  return <div style={{ color }}>{children}</div>;
};

总结

  • ReactNode 可以定义灵活的 children 类型,支持多种内容。
  • PropsWithChildren 可以轻松地将 children 与自定义属性结合起来使用。
  • 定义自定义属性的类型,并结合 PropsWithChildren 以创建类型安全且可重用的组件。

这种方式确保 TypeScript 正确推断类型,从而提升代码的清晰度和可靠性。

5. Extending Props with Helpers 使用助手扩展属性

以下是如何在 TypeScript 中使用 ComponentPropsWithoutRef 工具类型,为 React 组件定义类型,以接收标准 HTML 元素的属性,同时还能扩展自定义的属性。

第一步:定义包含 childrenonClick 的类型

通过使用 ComponentPropsWithoutRef,可以定义一个组件来接收标准 HTML 元素(如 <button>)的所有属性,而无需逐一定义。例如,我们定义一个类型为 ButtonProps 的对象,它既包含 children,也可以指定 onClick

import React, { ComponentPropsWithoutRef } from 'react';

type ButtonProps = ComponentPropsWithoutRef<'button'> & {
  onClick: () => void;
};

const CustomButton: React.FC<ButtonProps> = ({ children, onClick, ...props }) => {
  return (
    <button onClick={onClick} {...props}>
      {children}
    </button>
  );
};

在这里,ComponentPropsWithoutRef<'button'> 会自动包含标准 <button> 的所有原生属性,例如 typeonClick 等,这样就不必手动定义每一个。

第二步:扩展其他元素的属性

如果组件需要动态扩展不同 HTML 元素的属性(比如可以根据情况渲染为 <button><select>),可以对 ComponentPropsWithoutRef 传入元素类型以动态获取其原生属性。

例如,定义一个可以扩展 <select> 元素的组件:

type SelectProps = ComponentPropsWithoutRef<'select'> & {
  onChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
};

const CustomSelect: React.FC<SelectProps> = ({ children, onChange, ...props }) => {
  return (
    <select onChange={onChange} {...props}>
      {children}
    </select>
  );
};

在这里,ComponentPropsWithoutRef<'select'> 会自动包含 <select> 元素的所有原生属性,同时我们可以添加自定义的 onChange 属性。

第三步:进一步扩展并使用通用属性

通过 ComponentPropsWithoutRef,可以让组件灵活地继承 HTML 元素的原生属性,并根据需求扩展其他自定义属性,无需手动为每个属性指定类型。这不仅减少了冗余代码,还确保了类型安全,增强了 TypeScript 的类型推断能力。

使用这个方法,可以轻松定义一个支持标准 HTML 元素属性的组件,还能为其添加自定义属性,从而增强代码的复用性和灵活性。

6. Props with Variant Types 带变体类型的属性

以下是如何使用联合类型来更精确地定义 React 组件的 props 类型,从而确保传递了正确的值,解决了 variantcode 属性的限制。

背景

这个练习的目标是确保在传递 variantwith code 的情况下,code 是必需的,而当 variantno code 时,不允许传递 code。通过使用联合类型可以实现这种逻辑控制。

解决方案步骤

首先,通过 TypeScript 的联合类型(Union Types),可以为 props 创建两个分支,每个分支对应一种情况:

  1. variantno code 时,不允许传递 code 属性。
  2. variantwith code 时,code 属性是必需的。

在代码中实现如下:

import React from 'react';

type AlertProps = 
  | { variant: 'no code'; code?: never }
  | { variant: 'with code'; code: string };

const Alert: React.FC<AlertProps> = ({ variant, code }) => {
  return (
    <div>
      {variant === 'with code' ? (
        <p>Code: {code}</p>
      ) : (
        <p>No code provided.</p>
      )}
    </div>
  );
};

// 使用示例
const App: React.FC = () => {
  return (
    <div>
      {/* 正确用法 */}
      <Alert variant="with code" code="12345" />
      <Alert variant="no code" />

      {/* 错误用法,将会导致 TypeScript 错误提示 */}
      <Alert variant="no code" code="12345" />
      <Alert variant="with code" />
    </div>
  );
};

解释

  • variantno code 时,code 被声明为 never 类型,表示不允许有 code 值。
  • variantwith code 时,code 被声明为 string 类型,这是必需的属性。
  • TypeScript 将会在编译时检查代码,并确保在不符合规则时显示错误。

这样可以确保:

  • 如果 variantwith code,则必须传递 code
  • 如果 variantno code,则不允许传递 code

总结

通过联合类型,TypeScript 能够更灵活、精确地约束组件的 props,确保了在组件使用时的正确性,避免了无效的属性传递,提高了代码的安全性和可维护性。

7. Requiring Props 需要的属性

以下是如何使用 TypeScript 交叉类型(Intersection Types)将 BTN color 属性设置为两个分支都需要的必填属性,并保持代码的简洁和可读性。

背景

我们有一个 Alert 组件,其中包括两个分支:

  1. variantno code 时,不需要 code 属性。
  2. variantwith code 时,code 属性是必需的。

此外,无论 variant 是什么,组件都需要 BTN color 属性。

解决方案步骤

  1. 使用交叉类型:通过交叉类型,可以确保 BTN color 属性在每个分支中都是必需的。
  2. 简化代码:将 BTN color 的类型提取出来,使代码更具可读性。

在代码中实现如下:

import React from 'react';

// 定义带有特定的分支类型
type AlertVariantProps =
  | { variant: 'no code'; code?: never }
  | { variant: 'with code'; code: string };

// 定义需要的公共属性
type CommonAlertProps = {
  BTNColor: string;
};

// 使用交叉类型确保所有分支都需要 BTN color
type AlertProps = AlertVariantProps & CommonAlertProps;

const Alert: React.FC<AlertProps> = ({ variant, code, BTNColor }) => {
  return (
    <div style={{ backgroundColor: BTNColor }}>
      <h3>Alert Component</h3>
      {variant === 'with code' ? (
        <p>Code: {code}</p>
      ) : (
        <p>No code provided.</p>
      )}
    </div>
  );
};

// 使用示例
const App: React.FC = () => {
  return (
    <div>
      {/* 正确用法 */}
      <Alert variant="with code" code="12345" BTNColor="blue" />
      <Alert variant="no code" BTNColor="green" />

      {/* 错误用法,将会导致 TypeScript 错误提示 */}
      <Alert variant="no code" code="12345" BTNColor="red" />
      <Alert variant="with code" BTNColor="yellow" />
    </div>
  );
};

解释

  • AlertVariantProps 定义了两种不同的 variant 分支,每种分支对应的 code 属性限制。
  • CommonAlertProps 用于定义所有分支共享的属性(即 BTN color)。
  • AlertProps 通过交叉类型将 AlertVariantPropsCommonAlertProps 合并,确保 BTN color 属性在每个分支中都是必需的。

结果

这样就能确保:

  • 不论 variantno code 还是 with codeBTN color 属性始终是必填的。
  • variantwith code 时,必须提供 code 属性。
  • variantno code 时,不能提供 code 属性。

使用交叉类型不仅实现了更严格的类型约束,还使代码更简洁和清晰。

8. Differentiating Props 区分属性

下面是如何使用 TypeScript 来重构 Profile 组件的 props 类型,以实现更加严格的类型检查。这样可以确保在不同的条件下传递合适的参数。

背景

我们有一个 Profile 组件,包含以下条件:

  1. showLinkedIntrue 时,必须传递 LinkedInID,并显示 LinkedIn 链接。
  2. showLinkedIn 未传递或为 false 时,必须传递 GitHubID,并显示 GitHub 链接。

解决方案步骤

  1. 使用联合类型(Union Types):通过联合类型,可以将条件和必需属性结合起来,实现对不同 props 组合的严格约束。
  2. 定义两种分支:一个分支用于显示 LinkedIn 链接,另一个分支用于显示 GitHub 链接。

示例代码如下:

import React from 'react';

// 定义两种类型,分别对应不同的条件
type ProfileProps =
  | { showLinkedIn: true; linkedInID: string; githubID?: never }
  | { showLinkedIn?: false; linkedInID?: never; githubID: string };

const Profile: React.FC<ProfileProps> = (props) => {
  return (
    <div>
      {props.showLinkedIn ? (
        <a href={`https://linkedin.com/in/${props.linkedInID}`}>LinkedIn Profile</a>
      ) : (
        <a href={`https:/${props.githubID}`}>GitHub Profile</a>
      )}
    </div>
  );
};

// 使用示例
const App: React.FC = () => {
  return (
    <div>
      {/* 正确示例 */}
      <Profile showLinkedIn={true} linkedInID="linkedinUser" />
      <Profile githubID="githubUser" />

      {/* 错误示例:这些会导致 TypeScript 报错 */}
      <Profile showLinkedIn={true} githubID="githubUser" />
      <Profile linkedInID="linkedinUser" />
    </div>
  );
};

解释

  • ProfileProps 定义了两种不同的 props 组合:
    • showLinkedIntrue 时,必须传递 linkedInID,且不能传递 githubID
    • showLinkedIn 未传递或为 false 时,必须传递 githubID,且不能传递 linkedInID
  • 使用该类型后,TypeScript 将在不满足上述条件时报错,确保了传递给 Profile 组件的 props 总是满足逻辑需求。

总结

通过联合类型,我们可以对组件 props 的条件组合进行严格的类型检查,减少潜在的错误,并让代码更具可读性。

9. Empty Object as Type 空对象作为类型

在这个视频中,我们将了解如何在 TypeScript 中定义一个空对象类型。

空对象的默认行为

如果你定义一个变量 data 为空对象 {},TypeScript 默认允许这个变量接收任意类型的值——从数字、字符串到嵌套对象都可以传递。然而,这样的定义并不包括 nullundefined,如果你尝试赋予这两个值,TypeScript 会报错。

let data: {} = {};
data = 123;  // 没有报错
data = "hello";  // 没有报错
data = { key: "value" };  // 没有报错
data = null;  // 报错
data = undefined;  // 报错

严格的空对象类型

如果你希望 data 只能是一个严格的空对象,即不允许任何属性和值,可以使用 Record<string, never> 类型。这种类型只允许传递完全为空的对象,而其他情况都会导致 TypeScript 报错。

let data: Record<string, never> = {};  // 合法
data = { key: "value" };  // 报错
data = 123;  // 报错
data = { };  // 只有完全空对象才合法

在这里,我们使用 Record<string, never>,表示键必须是字符串类型,而值的类型是 never,即没有值是被允许的。这样 data 就只能是 {}

总结

  1. 使用 {} 表示可以有任意键值对,但不包括 nullundefined
  2. 使用 Record<string, never> 来严格限定为空对象,确保不包含任何键值对。

这种方法非常有用,尤其在我们希望数据结构非常严格时。

10. Empty Object and Requiring Props 空对象和需要的属性

在这个视频中,我们将练习如何更精确地定义一个输入组件的 prop 类型,使得传递的值符合预期。

问题描述

在这个组件中,我们有两种状态:

  1. 完全受控状态 - 我们传递了两个 props:value(字符串类型)和 onChange(事件处理函数)。
  2. 非受控状态 - 不传递 valueonChange,组件将自己管理输入状态。

当前 TypeScript 没有对这两种情况进行限制,导致了我们可以只传 value 或只传 onChange,这是不合理的。

解决方法

为了确保 valueonChange 要么同时存在,要么完全不出现,我们可以定义两个分支的类型:

  • 第一个分支类型要求 valueonChange 必须都传递。
  • 第二个分支类型则定义 valueonChange 为可选且为 undefined,表示它们不应被传递。
type InputProps =
  | {
      label: string;
      value: string;
      onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
    }
  | {
      label: string;
      value?: undefined;
      onChange?: undefined;
    };

应用

InputProps 应用于组件上:

const Input: React.FC<InputProps> = (props) => {
  return (
    <div>
      <label>{props.label}</label>
      <input value={props.value} onChange={props.onChange} />
    </div>
  );
};

结果

这种类型定义方式确保了:

  • 如果 value 存在,则 onChange 也必须存在。
  • 如果 valueonChange 之一不存在,TypeScript 会报错。

现在在使用组件时:

<Input label="Name" value="John" onChange={handleChange} />  // 合法
<Input label="Age" />  // 合法
<Input label="Email" value="[email protected]" />  // 报错,缺少 `onChange`

总结

这种通过联合类型的方式定义了组件的类型分支,使得输入组件可以灵活使用,同时确保了类型安全。

11. Understanding ReactNode 理解ReactNode

在本视频中,我们讨论如何正确地为 renderRow 函数类型定义 props,以解决 React 报错问题。

问题描述

在这个 Rows 组件中,renderRow prop 被期望为一个函数,而我们当前将其类型定义为 ReactNode,这导致了错误。因为 ReactNode 代表可以渲染的 React 元素(如字符串、数字、null 等),而不是一个函数。因此,当我们尝试像函数一样调用 renderRow 时,TypeScript 报错。

解决方案

我们需要将 renderRow 的类型改为函数,以便它可以接收参数并返回 JSX 元素。

方法一:使用箭头函数

定义 renderRow 为一个箭头函数类型,以指定它接受一个 number 类型参数,并返回 ReactNode 类型:

interface RowsProps {
  renderRow: (index: number) => React.ReactNode;
}

这样一来,TypeScript 便理解 renderRow 是一个函数,可以被调用。

方法二:使用 React.FC

你也可以使用 React.FC,这是一个内建的功能组件类型,它可以简化类型声明:

const Rows: React.FC<{ renderRow: (index: number) => React.ReactNode }> = ({ renderRow }) => {
  return (
    <div>
      {[1, 2, 3].map((num) => (
        <div key={num}>{renderRow(num)}</div>
      ))}
    </div>
  );
};

在此代码中,我们使用 React.FC 来声明 renderRow 参数,使其更简洁和直观。

总结

这两种方法都允许我们正确地定义 renderRow 作为函数类型,从而使 TypeScript 知道它会接收一个参数,并返回一个可以渲染的节点。

12. Linking Types 链接类型

在本视频中,我们学习了如何改进 Button 组件的 props 类型定义,使其更加简洁和灵活。

问题描述

Button 组件的 props 包含一个 variant 属性,它可以接受 "primary""secondary""tertiary" 作为值。我们还定义了一个 classNames 对象,根据 variant 的值来映射对应的 CSS 类名。然而,这种写法在 variantclassNames 对象之间造成了重复定义,使代码变得冗长且容易出错。

解决方案

我们可以使用 TypeScript 中的 typeofkeyof 操作符来改进代码,让 variant 的类型自动与 classNames 对象的键匹配。这样,如果以后需要添加新的 variant,只需要更新 classNames 对象,variant 的类型会自动更新。

第一步:使用 typeof

typeof 可以获取变量的类型:

type ClassNamesType = typeof classNames;

这样 ClassNamesType 就表示 classNames 对象的类型。

第二步:使用 keyof

接下来,我们使用 keyof 来获取 ClassNamesType 的键:

type Variant = keyof ClassNamesType;

这表示 Variant 类型只允许 classNames 的键值(即 primarysecondarytertiary)。

应用在 props

现在我们可以在 Button 组件的 props 中应用 Variant 类型:

interface ButtonProps {
  variant: Variant;
}

这样,variant 属性的类型会自动与 classNames 对象的键匹配,使代码更具扩展性和安全性。

完整示例

最终代码如下:

const classNames = {
  primary: 'bg-blue-500 text-white',
  secondary: 'bg-gray-500 text-black',
  tertiary: 'bg-green-500 text-white',
};

type Variant = keyof typeof classNames;

interface ButtonProps {
  variant: Variant;
}

const Button: React.FC<ButtonProps> = ({ variant }) => {
  const className = classNames[variant];
  return <button className={className}>Button</button>;
};

结论

通过这种方式,当我们向 classNames 添加新键时,variant 的类型会自动更新。这使得代码更具维护性,并减少了重复。

13. Partial Autocomplete 部分自动完成

在本视频中,我们讲解了如何在 TypeScript 中通过一个技巧实现更智能的自动补全功能,使 string 类型不会妨碍我们在联合类型中的补全提示。

问题描述

我们有一个 Label 组件,它接受一个 space 属性,这个属性可以是特定字符串 "s", "m", "l" 或任何其他字符串。由于 TypeScript 推断 space 的类型为 string,我们没有得到需要的 sml 的自动补全提示。

解决方案

要实现自动补全,我们可以使用一个不常见但实用的技巧:将字符串类型用括号包裹,并与一个空对象类型交集。这个方法会使 TypeScript 在推断时保留特定字符串类型,同时支持其他字符串。

具体步骤:

  1. 用括号将 string 包裹起来。
  2. 将结果类型与 {} 交集。

最终的类型定义:

type SpaceType = "s" | "m" | "l" | (string & {});

效果演示

应用了上述定义后,当您在 Label 组件中键入 space 属性值时,您将获得 "s""m""l" 的自动补全,同时仍然可以传递其他字符串值。这种用法也出现在 TypeScript 源码中,是一种在联合类型中启用特定值的智能提示而不完全限制为 string 的方式。

完整代码示例

type SpaceType = "s" | "m" | "l" | (string & {});

interface LabelProps {
  space: SpaceType;
}

const Label: React.FC<LabelProps> = ({ space }) => {
  const margin = space === "s" ? "4px" : space === "m" ? "8px" : "16px";
  return <div style={{ marginTop: margin }}>Label</div>;
};

总结

这种技巧在 TypeScript 中可以改善代码的可读性与自动补全体验。尽管它的内部原理并不完全明确,但在代码补全优化方面非常有效。

14. Extracting Types with as const 使用as const提取类型

在本例中,我们通过 TypeScript 的 keyoftypeof 类型操作符来提取对象 buttonTypes 的键和值的类型。这种方法可用于动态生成联合类型,从而避免硬编码,保持代码的简洁和灵活性。

问题描述

我们有一个对象 buttonTypes,其结构如下:

const buttonTypes = {
  0: "warning",
  1: "success",
  2: "error"
} as const;

目标是提取 buttonTypes 的键类型(0 | 1 | 2)和值类型("warning" | "success" | "error")。

解决步骤

  1. 提取键类型
    使用 keyoftypeof 提取键的类型。typeof 提取 buttonTypes 的类型,keyof 操作符则返回该对象类型的键组成的联合类型:

    type TypeKeys = keyof typeof buttonTypes; // 结果为 0 | 1 | 2
  2. 提取值类型
    使用索引访问操作符提取键对应的值类型。为了动态处理,我们可以将 TypeKeys 传入索引位置,获取所有值类型:

    type TypeValues = (typeof buttonTypes)[TypeKeys]; // 结果为 "warning" | "success" | "error"

使用 as const 的原因

as const 将对象转换为只读(readonly)类型,确保 buttonTypes 的值不可变,使得 TypeScript 能正确推断每个键的具体值类型(即 "warning", "success", "error")。若不使用 as const,TypeScript 只会将每个值视为 string 类型,而不是具体的字符串字面量类型。

最终代码示例

const buttonTypes = {
  0: "warning",
  1: "success",
  2: "error"
} as const;

type TypeKeys = keyof typeof buttonTypes; // 0 | 1 | 2
type TypeValues = (typeof buttonTypes)[TypeKeys]; // "warning" | "success" | "error"

效果

此代码在 buttonTypes 的键和值发生变更时,TypeKeysTypeValues 类型也会自动更新,不需手动更改。这样不仅使代码更具动态性,还减少了维护成本。

这种技巧在处理大量键值对或希望根据对象自动生成类型时尤其有用,展示了 TypeScript 的类型操作和推断功能的强大之处。

15. Dynamic Props 动态属性

在这个示例中,我们使用了 TypeScript 中的 satisfies 关键字来确保 buttonPropsMap 满足特定类型的要求,而不会丢失其原始类型信息。这种方法可以帮助我们动态生成键值映射,同时保持代码的简洁性和维护性。

问题描述

我们有一个 buttonPropsMap 对象,其中包含了三个键值对:

const buttonPropsMap = {
  submit: { ... },
  reset: { ... },
  skip: { ... }
};

我们希望:

  1. buttonPropsMap 中只能接受键为 submit, reset, skip 的属性。
  2. 保持 TypeScript 对于键的自动补全功能,防止手动重复声明键。

解决步骤

  1. 定义键和值的类型
    使用 satisfiesRecord 创建类型 ButtonTypes,表示键是字符串,但键名必须满足 submit, reset, skip,而值则是按钮组件允许的属性类型。

    const buttonPropsMap = {
      submit: { /* 属性定义 */ },
      reset: { /* 属性定义 */ },
      skip: { /* 属性定义 */ }
    } satisfies Record<"submit" | "reset" | "skip", ButtonPropsType>;
  2. 使用 satisfies 关键字
    通过 satisfies 关键字,TypeScript 会检查 buttonPropsMap 的键是否符合预定义的类型。它确保在 buttonPropsMap 中的所有键和类型定义一致,但不会影响 buttonPropsMap 的原始类型,使得代码能够享受自动补全和错误提示等类型支持。

  3. 自动生成类型以防止重复声明
    使用 keyof typeof buttonPropsMap 来动态提取键类型,使得在代码中只需声明一次键值,即可自动生成所需的键类型。这样可以避免手动硬编码键值。

    type ButtonVariant = keyof typeof buttonPropsMap; // "submit" | "reset" | "skip"

最终代码示例

const buttonPropsMap = {
  submit: { /* 组件的相关属性 */ },
  reset: { /* 组件的相关属性 */ },
  skip: { /* 组件的相关属性 */ }
} satisfies Record<"submit" | "reset" | "skip", ButtonPropsType>;

// 提取 ButtonVariant 类型
type ButtonVariant = keyof typeof buttonPropsMap; // "submit" | "reset" | "skip"

效果

  1. 类型检查和自动补全:当键名或值类型不符合要求时,TypeScript 将会报错,并且在 buttonPropsMap 上输入键时,会出现自动补全。
  2. 减少代码重复:无需手动定义和重复键名,确保更高的维护性和代码可读性。

使用 satisfies 关键字结合 keyof 提供了一个灵活而强大的方法来保持代码的一致性,帮助开发者以一种动态、可靠的方式创建和操作键值映射。

@WangShuXian6
Copy link
Owner Author

32. ---LEGACY--- Performance Optimization 旧版- 性能优化

1. The demo project 演示项目

2. Getting up and running with the demo codes 使用示例代码启动和运行

3. Introduction to the React Profiler React Profiler介绍

4. Introduction to React Rendering React渲染介绍

5. The Virtual DOM 虚拟DOM

6. Preventing Wasted Renders in a Simple Component 在简单组件中防止浪费渲染

7. Preventing Wasted Renders in Functional Components 在函数组件中防止浪费渲染

8. Preventing Wasted Renders When Dealing With Complex Props 处理复杂属性时防止浪费渲染

9. Using Immutable Data in Order to Allow for Comparisons 使用不可变数据进行比较

10. Preventing Wasted Renders in Repeated Components 在重复组件中防止浪费渲染

11. Resources 资源

12. Catching Expensive Operations 捕捉昂贵操作

13. Reducing Bundle Sizes 减小包大小

14. Lazy Loading Components 懒加载组件

15. Resources 资源

@WangShuXian6 WangShuXian6 changed the title React进阶开发 设计系统, 设计模式, 性能优化Advanced React Design System, Design Patterns, Performance React进阶开发 设计系统, 设计模式, 性能优化Advanced React Design System, Design Patterns, Performance/[进行中] Oct 13, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant