React Hook + TypeScript + styled-component 建站

Posted 刻刻帝丶

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了React Hook + TypeScript + styled-component 建站相关的知识,希望对你有一定的参考价值。

技术选型

Vue 与 React 的对比

  • 组件化
    Vue 的组件化是将 UI 结构(template)、UI 样式(style)、数据与业务逻辑(script)都放在一个 .vue 的文件中,运行前 .vue 文件会被编译成真正的组件;
    React 的组件化是直接通过 JS 代码的形式实现组件
  • 模板引擎
    Vue的视图模板使用类 html 的写法加上属性与指令,多数情况下要比 React 的 JSX 写法清晰且开发效率高,但是在复杂场景下,Vue 的写法有时会比 React 写起来更麻烦
  • 数据监听
    Vue 使用代理/拦截的方式使得我们直接修改 data 就可以,但 React 需要使用 setState API 改变数据

项目构建

目录结构

├─ mock     #数据模拟
├─ public   #静态
├─ scripts  #脚本
└─ src
    ├─ common      #工具库
    ├─ components  #组件
    ├─ hooks       #钩子
    ├─ pages       #页面
    ├─ styles      #样式
    └─ router      #路由

技术栈

  • 开发框架:React
  • 构建工具:Webpack
  • 类型检查:TypeScript
  • 日志埋点:@baidu/bend-sdk
  • 视图样式:styled-components
  • 状态管理:React-hook/useReducer
  • 数据请求:Umi-hook/useRequest + axios
  • 规范检测:Eslint + prettier + husky + lint-staged + commitlint

代码规范化提交

  • husky 注册 git 的钩子函数保证在 git 执行 commit 时调用代码扫描的动作
  • lint-staged 保证只对当前 add 到 stage 区的文件进行扫描
  • prettier自动格式化代码
  • eslint按照配置扫描代码
  • @commitlint/cli 规范 commit 提交
  • @commitlint/config-conventional commtlint 通用配置

工作流

  • 待提交的代码git add添加到暂存区
  • 执行git commit
  • husky注册在git pre-commit的钩子函数被调用,执行lint-staged
  • lint-staged 取得所有被提交的文件依次执行写好的任务(ESLint 和 Prettier)
  • 如果有错误(没通过ESlint检查)则停止任务,同时打印错误信息,等待修复后再执行git commit
  • 成功 commit,可 push 到远程

package.json配置如下:


  ...
  "husky": 
    "hooks": 
      "pre-commit": "lint-staged",
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    
  ,
  "lint-staged": 
    "src/**/*.jsx,js,tsx,ts": [
      "prettier --write",
      "eslint --fix",
      "git add"
    ]
  


.prettierrc.js

module.exports = 
    "printWidth": 100, // 一行的字符数,如果超过会进行换行,默认为80
    "tabWidth": 4,
    "useTabs": false, // 注意:makefile文件必须使用tab
    "singleQuote": true,
    "semi": true,
    "trailingComma": "es5", //是否使用尾逗号,有三个可选值"<none|es5|all>"
    "bracketSpacing": true, //对象大括号之间是否有空格,默认为true,效果: foo: bar 
    "endOfLine": "auto",
    "arrowParens": "avoid"
;

.eslintrc.js

module.exports = 
  "root": true,
  "env": 
    "browser": true,
    "node": true,
    "es6": true,
    "jest": true,
    "jsx-control-statements/jsx-control-statements": true
  ,
  "parser": "@typescript-eslint/parser",
  "parserOptions": 
    "sourceType": 'module',
    "ecmaFeatures": 
      "jsx": true,
      "experimentalObjectRestSpread": true
    
  ,
  "globals": 
    // "wx": "readonly",
  ,
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:jsx-control-statements/recommended", // 需要另外配合babel插件使用
    "prettier"
  ],
  "overrides": [
    
      "files": ["**/*.tsx"],
      "rules": 
          "react/prop-types": "off"
      
    
  ],
  "settings": 
    "react": 
      "version": "detect"
    
  ,
  "plugins": ["@typescript-eslint", "react", "react-hooks", "jsx-control-statements", "prettier"],
  "rules": 
    "prettier/prettier": 1,
    "no-extra-semi": 2, // 禁止不必要的分号
    "quotes": ['error', 'single'], // 强制使用单引号
    "no-unused-vars": 0, // 不允许未定义的变量
    "jsx-control-statements/jsx-use-if-tag": 0,
    "react-hooks/rules-of-hooks": "error", // 检查 Hook 的规则
    "react-hooks/exhaustive-deps": "warn" // 检查 effect 的依赖
  
;

.eslintignore/.prettierignore

**/*.js
!src/**/*.js

.commitlintrc.js

module.exports = 
    extends: ['@commitlint/config-conventional']
;

提交需遵循 conventional commit 格式,即:

type(scope?): subject

e.g. feat: 教培PC框架搭建(cvi-3000)

type 可以是:

  • feat:新功能(feature)
  • upd:更新某功能
  • fix:修补bug
  • docs:文档(documentation)
  • style: 格式(不影响代码运行的变动)
  • refactor:重构(即不是新增功能,也不是修改bug的代码变动)
  • test:增加测试
  • chore:构建过程或辅助工具的变动

样式方案

styled-components

styled-comonents官方文档

是一个 CSS in JS 的类库,就是可以在 JS 中写 CSS 语法
使用 Sass/Less 等预处理语言需要在 Webpack 中进行各种 loader 的配置
而 styled-components 只需直接引用

import styled from 'styled-components';
  • 样式化组件,主要作用是编写实际 CSS 代码来设计组件样式,无需组件和样式之间的映射,创建后实际就是一个 React 组件
  • 使用后不再需要使用 className 来控制样式,而是写成更具语义化的组件
  • 编译后的节点会随机生成 class,可以避免全局污染,但会增加维护难度

解决方案:在 babel 配置中加入 styled-components 插件

babel-plugin-styled-components

use: [
 
    loader: 'babel-loader',
    options: 
      ...
      plugins: [
        'babel-plugin-styled-components',
        ...
      ]
    
  ,
  ...
]

编译后:


基本使用

// src/components/styles/index.ts
...
interface ICardProps 
    type?: string;

...
const typeMap = (type: string | undefined) => 
    switch (type) 
        case 'b':
            return 'block';
        case 'i':
            return 'inline-block';
        case 'f':
            return 'flex';
        case 'n':
            return 'none';
        default:
            return 'block';
    
;
/**
 * 卡片容器
 */
const Card = styled.div`
    display: $(props: ICardProps) => typeMap(props.type);
    padding: 24px 24px 15px;
    background-color: #fff;
`;
Card.defaultProps = 
    type: 'f',
;
...
// src/pages/shop/index.tsx
import styled from 'styled-components';
...
const Shop = () => 
	...
	return (
		<Card type="b">
			...
		</Card>
	);


全局样式

// src/style.ts
import  createGlobalStyle  from 'styled-components';

export const GlobalStyle = createGlobalStyle`
    html,body,div,span,applet,object,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video,button 
   margin: 0;
    padding: 0;
    border: 0;
    font-size: 100%;
    font: inherit;
    font-weight: normal;
    vertical-align: baseline;

/* HTML5 display-role reset for older browsers */
article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section 
    display: block;

ol,ul,li 
    list-style:none;

...
`;
...
import  GlobalStyle  from '@/style';
...

const App: React.FC = () => 
	return (
        <Router>
            <GlobalStyle />
            ...
        </Router>
    );
;

export default App;

代码片段

// src/components/styles/snippet.ts
import  css  from 'styled-components';

const mask = css`
    &::after 
        content: '';
        position: absolute;
        left: 0;
        right: 0;
        top: 0;
        bottom: 0;
        background-image: radial-gradient(50% 129%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0.1) 100%);
    
`;
...
export default 
    mask,
    ...
;
// src/components/styles/index.ts
import styled from 'styled-components';
import s from './snippet';
...
const Img = styled.div`
	...
	$(props: ICardImgProps) => (props.mask ? s.mask : '')
	...
`

类型系统

TypeScript 微软开发的开源编程语言

TypeScript官方文档

是 JS 的一个超集,主要提供类型系统以及对尚未正式发布的 ECMAScript 新特性的支持,最终会被编译成纯 javascript 代码

优点:

  • 编译阶段就能发现大部分错误
  • 增加代码的可读性和可维护性,大部分函数看看类型定义就知道如何使用
  • VSCode 对 TS 提供代码补全、接口提示、跳转定义等功能

缺点:

  • 开发时要写很多类型定义,短期来看会增加开发成本,但对长期维护项目来看可以降低维护成本
  • 集成到构建流程有一定工作量
  • 与部分第三方库的结合使用可能不是很完美(比如styled-components.)
// tsconfig.json

    "compilerOptions": 
        "target": "es5",
        "lib": [
            "dom",
            "dom.iterable",
            "esnext" 
        ],
        "allowJs": true,
        "skipLibCheck": true,
        "esModuleInterop": true,
        "experimentalDecorators": true,
        "allowSyntheticDefaultImports": true,
        "strict": true,
        "forceConsistentCasingInFileNames": true,
        "module": "esnext",
        "moduleResolution": "node",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "noEmit": true,
        "jsx": "react",
        "downlevelIteration": true,
        "baseUrl": ".",
        "paths": 
            "@/*": ["src/*"]
        ,
        "plugins": [
            
                "transform": "typescript-plugin-styled-components",
                "type": "config"
            
        ]
    ,
    "include": [
        "src/**/*"
    ],
    "exclude": [
        "node_modules"
    ]

React Hook

Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其它的 React 特性。

  • Hook 强化了 React 函数组件的能力,使得函数组件可以做到类组件中的 state 和生命周期。Hook 让类组件能实现的在函数组件中也都可以实现
  • 语法更加简洁,解决了高阶组件使用困难以及难以理解的问题
  • 向后兼容,类组件不会被舍弃

类组件与函数组件

类组件

import React,  Component  from 'react';

export default class Button extends Component 
  constructor() 
    super();
    this.state =  buttonText: 'Click' ;
    this.handleClick = this.handleClick.bind(this);
  
  handleClick() 
    this.setState(() => 
      return  buttonText: 'Click Done' ;
    );
  
  render() 
    const  buttonText  = this.state;
    return <button onClick=this.handleClick>buttonText</button>;
  

函数组件

import React,  useState  from 'react';

export default function Button() 
  const [buttonText, setButtonText] = useState('Click');
  function handleClick() 
    return setButtonText('Click Done');
  
  return <button onClick=handleClick>buttonText</button>;

类组件的缺点:

  • 需手动绑定this指向
  • 大型组件难以拆分和重构
  • 业务逻辑分散在各生命周期函数中,会导致逻辑重复(函数组件 useEffect 解决)
  • 引入了复杂编程模式,例如渲染属性(Render Props)、高阶组件(HOC)(函数组件自定义 Hook 解决)

Render Props

使用一个值为函数的prop来传递需要动态渲染的组件

import UIDemo from 'components/demo';
class DataProvider extends React.Component 
	constructor(props) 
		super(props);
		this.state = target: 'Payen';
	
	render() 
		return (
			<div>
				this.props.render(this.state)
			</div>
		)
	


<DataProvider render=data => (
	<UIDemo target=data.target />
)/>

<DataProvider>
	data => (
		<UIDemo target=data.target/>
	)
</DataProvider>

HOC

函数接收一个组件作为参数,经过一系列加工,返回一个新组件

const withUser = WrappedComponent => 
	const user = sessionStorage.getItem('user');
	return props => <WrappedComponent user=user ...props/>;

const UserPage = props => (
	<div>
		<p>name: props.user</p>
	</div>
);

内置 Hook API

Hook API 名字以 use 开头


基础Hook:

  • useState
  • useEffect
  • useContext

附加Hook:

  • useReducer
  • useCallback
  • useMemo
  • useRef
  • useImperativeHandle
  • useLayoutEffect
  • useDebugValue

基础 Hook

useState

// 传入初始state值
const [state, setState] = useState(initState);

// 传入函数,函数返回值作为初始state,函数只会在初始渲染时被调用
const [state, setState] = useState(() => 
	const initState = Func();
	return initState;
);

useState 唯一参数是初始状态值,初始状态参数仅在第一次渲染时使用
返回当前的状态值(state)和用来变更状态的函数(setState)
类似于类组件中的this.setState
解构赋值语法允许我们将声明的状态赋予不同的名称

useEffect

useEffect(() => 
	Func(state);
	return () => 
		// 组件卸载时以及后续渲染重新运行效果之前执行
	;
, [state]);

useEffect 接收一个包含命令式且可能有副作用代码的函数
React渲染阶段在函数组件主体内改变 DOM、设置定时器等包含副作用的操作是不允许的,因为会影响其它组件
useEffect 和类组件中的componentDidMountcomponentDidUpdatecomponentWillUnmount 有相同用途

import  useState, useEffect  from 'react';

function Example() 
  const [count, setCount] = useState(0);

  useEffect(() => 
    document.title = `You clicked $count times`;
  );

  return (
    <div>
      <p>You clicked count times</p>
      <button onClick=() => setCount(count + 1)>
        Click me
      </button>
    </div>
  );

使用规则

  • 只能在顶层调用 Hooks。不能在循环,条件和嵌套函数中使用 Hook API
  • 仅在 React 函数组件中和自定义 Hooks 函数中使用 Hooks API

React 会根据调用 hook 的顺序依次将值存入数组
如果存在条件判断等可能会导致更新时不能获取对应的值,导致取值混乱

自定义Hook

为了与普通函数区分,自定义 Hook 命名以 use 开头
用来解决逻辑复用问题

// src/hooks/index.tsx
import React,  useState, useEffect, ...  from 'react';
...
// 元素可拖拽hook
function useDraggable(ref: React.RefObject<HTMLElement>) 
    const [ dx, dy , setOffset] = useState( dx: 0, dy: 0 );

    useEffect(() => 
        if (ref.current == null) 
            throw new Error('[useDraggable] ref未注册到组件中');
        
        const el = ref.current;

        const handleMouseDown = (event: MouseEvent) => 
            const startX = event.pageX - dx;
            const startY = event.pageY - dy;

            const handleMouseMove = (event: MouseEvent) => 
                const newDx = event.pageX - startX;
                const newDy = event.pageY - startY;
                setOffset( dx: newDx, dy: newDy );
            ;

            document.addEventListener('mousemove', handleMouseMove);
            document.addEventListener(
                'mouseup',
                () => 
                    document.removeEventListener('mousemove', handleMouseMove);
                ,
                 once: true 
            );
        ;

        el.addEventListener('mousedown', handleMouseDown);

        return () => 
            el.removeEventListener('mousedown', handleMouseDown);
        ;
    , [dx, dy, ref]);

    useEffect(() => 
        if (ref.current) 
            ref.current.style.transform = `translate3d($dxpx, $dypx, 0)`;
        
    , [dx, dy, ref]);

// src/components/Usual/ButtonGroup.tsx
import  useDraggable, ...  from '@/hooks';
...
const ButtonGroup = (props: IButtonGroupProps) => 
	...
	// 预约弹窗可拖拽
	const el = useRef<HTMLDivElement>(null);
    useDraggable(el);
    return (
	    <Space gap="b" nowrap>
		    ...
		    <PopupWrapper ref=el>
                <AppointPopup
                    show=showAppointBox
                    switchAppointBox=switchAppointBox
                    formid=formid
                />
            </PopupWrapper>
		</Space>
    )
;

Hook 对比 HOC

import React from 'react';

function hocMatch(Component) 
  return class Match React.Component 
    componentDidMount() 
      this.getMatchInfo(this.props.matchId)
    
    componentDidUpdate(prevProps) 
      if (prevProps.matchId !== this.props.matchId) 
        this.getMatchInfo(this.props.matchId)
      
    
    getMatchInfo = (matchId) => 
      // 请求后台接口获取赛事信息
    
    render () 
      return (
        <Component ...this.props />
      )
    
  


const MatchDiv=hocMatch(DivUIComponent)
const MatchSpan=hocMatch(SpanUIComponent)

<MatchDiv matchId=1 matchInfo=matchInfo />
<MatchSpan matchId=1 matchInfo=matchInfo />
function useMatch(matchId) 
  const [ matchInfo, setMatchInfo ] = useState('');
  useEffect(() => 
    // 请求后台接口获取赛事信息
    // ...
    setMatchInfo(serverResult) // serverResult后端返回数据
  , [matchId]);
  return [matchInfo];

...
export default function Match(matchId) 
  const [matchInfo] = useMatch(matchId);
  return <div>matchInfo</div>;

其它问题

地图跳转

const jumpMap = (e: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => 
    // coord_type坐标类型选择国测局坐标(火星坐标系)
    window.open(
        `http://api.map.baidu.com/marker?location=$pos?.lat,$pos?.lng&title=$name ||
            '我的位置'&content=$children&output=html&coord_type=gcj02`
    );
    e.stopPropagation();
;

百度地图调起接口

二维码

import QRCode from 'qrcode.react';
...
<QRCode
    value=qrCodeUrl
    size=300
    fgColor="#000"
    imageSettings=
        src: logo,
        height: 60,
        width: 60,
        excavate: false,
    
/>

屏幕适配

CSS 适配

直接使用 CSS 媒体查询对窄屏下单独设置样式

const PageContent = styled.div`
    width: 1200px;
    padding-top: 16px;
    margin: 0 auto;
    @media (max-width: 900px) 
        width: 740px;
    
`;

JS 适配

实现一个自定义 Hook

// src/hooks/index.ts
...
function useNarrowScreen() 
    const isNarrow = () => window.innerWidth <= 900;
    const [narrow, setNarrow] = useState(isNarrow);
    useEffect(() => 
        const resizeHandler = () => setNarrow(isNarrow());
        window.addEventListener('resize', resizeHandler);
        return () => window.removeEventListener('resize', resizeHandler);
    );
    return narrow;

// src/components/Base/PhotoAlbum.tsx
...
import  useNarrowScreen  from '@/hooks';
...
const PhotoAlbum = (props: IPhotoAlbum) => 
	...
	const [baseLen, setBaseLen] = useState(0);
	const isNarrow = useNarrowScreen();
	useEffect(() => 
		setBaseLen(isNarrow ? 3 : 5);
		...
	, [isNarrow, ...]);
;

日志相关

click日志

import React,  useEffect  from 'react';
import  sendLog  from '@/common/log';
...
useEffect(() => 
    const listenedEles = document.querySelectorAll('[data-mod]') || [];
    listenedEles.forEach((ele: HTMLElement) => 
        ele.onclick = function() 
            const mod = ele.getAttribute('data-mod');
            ...
        ;
    );
, []);

// src/hooks/useLog.ts
function useClickLog(ref: React.RefObject<HTMLElement>) 
    useEffect(() => 
        const el: any = ref.current;

        el.onclick = function() 
            sendLog(el);
        ;
    , [ref]);

const jumpMapRef = useRef(null);
useClickLog(jumpMapRef);
...
<MapHref
	ref=jumpMapRef
	onClick=jumpMap
	data-mod="map_click"
>
   查看
</MapHref>

show日志

页面进入埋点

在路由组件渲染回调函数中添加

// src/router/RouterWithSubRoutes.tsx
import React from 'react';
import  Route, Redirect, RouteComponentProps  from 'react-router-dom';
import  RouteInterface  from '@/types/route';
import  sendLog  from '@/common/log';
import  routers  from '@/common/router-config';

export const RouteWithSubRoutes = (
    route: RouteInterface,
    i: number,
    authed: boolean,
    authPath: string
) => 
    return (
        <Route
            key=i
            path=route.path
            exact=route.exact
            render=(props: RouteComponentProps) => 
                const  match  = props;
                const location = props.location;
                if (routers[location.pathname]) 
                    sendLog(
                        mod: 'detail_show',
                        s_type: 'show',
                    );
                
                if (!route.auth || authed || route.path === authPath) 
                    return <route.component ...props routes=route.routes />;
                
                return <Redirect to= pathname: authPath, state:  from: props.location   />;
            
        />
    );
;


页面离开埋点

利用react-router 提供的离开确认组件Prompt

// src/App.tsx
import React from 'react';
import  BrowserRouter as Router  from 'react-router-dom';
import  routes  from '@/router/router';
import  RenderRoutes  from '@/router/RenderRoutes';
import  GlobalStyle  from '@/style';
import  Prompt  from 'react-router';
import  sendLog  from '@/common/log';
import  routers  from '@/common/router-config';
...

const App: React.FC = () => 
	...
    return (
        <Router>
            <GlobalStyle />
            RenderRoutes(routes, authed, authPath)
            <Prompt
                message=location => 
                    // 退出页面前的逻辑
                    window.scrollTo(0, 0);

                    const  pathname, search  = window.location;
                    if (routers[pathname]) 
                        // sendLog
                    
                    return true;
                
            />
        </Router>
    );
;

export default App;

页面状态缓存


Vue 中有keep-alive的组件功能,但是 React 官方没有提供支持
使用<Route>时,路由对应的组件在前进和后退无法被缓存,数据和行为会丢失

例如:列表页滚动到底后,点击跳转到详情页,返回后会回到列表顶部,数据以及滚动位置重置

<Route> 中配置的组件在路径不匹配时就会被卸载,对应真实节点也会从 DOM 树中移除

三种解决方案:

  • 手动实现类似 Vue 的 keep-alive 功能
  • 迁移其它第三方可以实现状态缓存的 Route 库
  • 页面状态存入 sessionStorage

通过实现自定义钩子 useStorage,可以将数据代理到其它数据源
LocalStorage / SessionStorage

// src/pages/search/index.tsx
...
import  useStorage  from '@/hooks';
...
const Search = () => 
	const location = useLocation();
    const params = new URLSearchParams(location.search);
    const tabSearch = params.get('tab') || 'shop';
	...
	// const [currentTab, changeCurrentTabState] = useState(tabSearch);
	const [currentTab, changeCurrentTabState] = useStorage('zlhx_home_tab', tabSearch);
	...

// src/hooks/index.ts
import React,  useState, useEffect, useCallback, Dispatch, SetStateAction  from 'react';
...
function useStorage<T>(
    key: string,
    defaultValue?: T | (() => T), // 默认值
    keepOnWindowClosed: boolean = false // 是否在窗口关闭后保持数据
): [T | undefined, Dispatch<SetStateAction<T>>, () => void] 
    const storage = keepOnWindowClosed ? localStorage : sessionStorage;

    // 尝试从Storage恢复值
    const getStorageValue = () => 
        try 
            const storageValue = storage.getItem(key);
            if (storageValue != null) 
                return JSON.parse(storageValue);
             else if (defaultValue != null) 
                // 设置默认值
                const value =
                    typeof defaultValue === 'function' ? (defaultValue as () => T)() : defaultValue;
                storage.setItem(key, JSON.stringify(value));
                return value;
            
         catch (err) 
            console.warn(`useStorage 无法获取$key: `, err);
        

        return undefined;
    ;

    const [value, setValue] = useState<T | undefined>(getStorageValue);

    // 更新组件状态并保存到Storage
    const save = useCallback<Dispatch<SetStateAction<T>>>(
        value => 
            setValue(prev => 
                const finalValue =
                    typeof value === 'function'
                        ? (value as (prev: T | undefined) => T)(prev)
                        : value;
                storage.setItem(key, JSON.stringify(finalValue));
                return finalValue;
            );
        ,
        [storage, key]
    );

    // 移除状态
    const clear = useCallback(() => 
        storage.removeItem(key);
        setValue(undefined);
    , [storage, key]);

    return [value, save, clear];

以上是关于React Hook + TypeScript + styled-component 建站的主要内容,如果未能解决你的问题,请参考以下文章

React Hook + TypeScript 手把手打造类型安全的应用。

React:react-router-dom Typescript 中不存在 useLocation Hook

如何测试使用自定义 TypeScript React Hook 的组件?

如何测试使用自定义TypeScript React Hook的组件?

我应该如何测试使用 Typescript 进行 api 调用的 React Hook “useEffect”?

如何为 React Action Hook Store 定义 Typescript 属性和类型