# React18仿问卷星低代码 **Repository Path**: szxio/react-wenjaunxing ## Basic Information - **Project Name**: React18仿问卷星低代码 - **Description**: 使用React实现仿问卷星低代码平台 - **Primary Language**: Unknown - **License**: MulanPSL-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 1 - **Created**: 2025-02-28 - **Last Updated**: 2025-07-25 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # React实战项目 - 仿问卷星低代码平台,已完结。完整源码+教程 ## 项目截图 ![image-20250514115335614](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250514115335614.png) ![image-20250514114456745](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250514114456745.png) ![image-20250514114805647](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250514114805647.png) ![image-20250514115103804](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250514115103804.png) ![image-20250514115112997](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250514115112997.png) ![image-20250514115140407](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250514115140407.png) ## 接口文档 https://doc.apipost.net/docs/464fe54acc73000?locale=zh-cn ![image-20250514115735715](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250514115735715.png) ## 技术栈 | 环境 | 技术栈 | | ------ | ---------------------------- | | 后端 | Node、Nest、Mongodb、JWT | | PC端 | React18、Ant Design、dnd-kit | | 移动端 | Next、Ant Design | ## 目录说明 ![image-20250514130537762](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250514130537762.png) ## 完整实现代码 [点此查看](https://gitee.com/szxio/react-wenjaunxing) ## 使用脚手架搭建项目 > 前置条件 > > Node版本 >= 18 ### 方式一 使用 `create-react-app`,进入[官方网站](https://create-react-app.bootcss.com/),执行下面一行命令即可快速新建一个React项目 ```sh ## 使用 npx npx create-react-app my-app --template typescript ## 使用 npm npm init react-app my-app --template typescript ## 使用 yarn yarn create react-app my-app --template typescript ``` 执行完毕后,执行 `npm start` 运行 ### 方式二 使用 vite 脚手架创建,[进入官网](https://vitejs.cn/vite5-cn/guide/),执行下面命令 ```sh npm create vite@latest my-react-app -- --template react-ts ``` 创建好后进入项目依次执行 ```sh npm install npm run dev ``` ## 代码规范 ### Eslint9安装和使用 安装插件 ```shell yarn add eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin ``` 初始化配置 ```shell npx eslint --init ## 然后根据引导一步一步走 ``` 按照步骤执行完毕后会自动生成`eslint.config.mjs` 文件,打开并修改,新增 rules 规则 ```js import globals from 'globals' import pluginJs from '@eslint/js' import tseslint from 'typescript-eslint' import pluginReact from 'eslint-plugin-react' /** @type {import('eslint').Linter.Config[]} */ export default [ { files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'], rules: { 'no-console': 'warn', // console,警告级别 'react/jsx-uses-react': 'off', // 关闭 React 相关规则 'react/react-in-jsx-scope': 'off' // 关闭 React 相关规则 } }, { languageOptions: { globals: globals.browser } }, pluginJs.configs.recommended, ...tseslint.configs.recommended, pluginReact.configs.flat.recommended ] ``` 我们可以在第一项中新增rules增加额外的eslint配置 安装 vscode 插件 `eslint` ,此时就可以看到代码 `App.txs` 中的错误提示(如定义一个未使用的变量) 在 `package.json` 中增加 scripts "lint": "eslint src --ext .ts,.tsx" 控制台运行 `npm run lint` 也可以看到错误提示。如果要自动修复,可以加 `--fix` 参数 ![image-20250303100625015](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250303100625015.png) ### Prettier安装 安装 ``` yarn add prettier eslint-config-prettier ``` 然后在根目录新建 `.prettierrc` 文件 ```js { "printWidth": 100, "tabWidth": 2, "useTabs": false, "singleQuote": true, "semi": false, "trailingComma": "none", "arrowParens": "avoid", "bracketSpacing": true, "singleAttributePerLine": false, "endOfLine": "auto" } ``` 然后修改 `eslint.config.mjs` 文件,引入 eslint-config-prettier ```js import globals from 'globals' import pluginJs from '@eslint/js' import tseslint from 'typescript-eslint' import pluginReact from 'eslint-plugin-react' import eslintPrettier from 'eslint-config-prettier' // 新增 /** @type {import('eslint').Linter.Config[]} */ export default [ { files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] }, { languageOptions: { globals: globals.browser } }, pluginJs.configs.recommended, ...tseslint.configs.recommended, pluginReact.configs.flat.recommended, eslintPrettier // 新增 ] ``` 然后安装 Prettier 插件 ![image-20250303113946503](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250303113946503.png) 点击设置 ![image-20250303114004388](C:/Users/yikonsh/AppData/Roaming/Typora/typora-user-images/image-20250303114004388.png) 配置文件路径 ![image-20250303114024340](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250303114024340.png) 然后打开vscode的设置,修改下面的配置 ![image-20250303114120066](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250303114120066.png) 现在我们保存代码时,就会自动的根据 `prettierrc` 文件中的配置进行代码格式化 ### husky控制提交规范 [官网文档]() 安装 husky,我安装的版本是 9.1.7 版本 ```sh yarn add --dev husky # 如果你的项目不是私有的,那么只需要安装 pinst yarn add --dev pinst ``` 安装好之后初始化配置,注意要使用 Git Bash 窗口执行下面这个命令 ```sh npx husky init ``` 完成后会生成下面的文件夹 ![image-20250303134450919](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250303134450919.png) 修改 pre-commit 文件夹 ```sh yarn lint yarn format ``` 在commit时就会依次运行这两行命令,首先是要保证 package.json 添加了这两个命令 ```json "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "lint": "eslint src --ext .ts,.tsx", "format": "prettier --write src/**/*.{ts,tsx,js,jsx,json,css,scss,md}", "prepare": "husky" }, ``` 现在我们添加下面的错误代码 ![image-20250303134658043](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250303134658043.png) 执行提交就会报错 ![image-20250303134738607](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250303134738607.png) 修改正确后就可以正常提交了 ## 浏览器调试插件下载 https://chromewebstore.google.com/detail/fmkadmapgofadopljbjfkapdkoienihi?utm_source=item-share-cp 安装完成后可以很方便的帮我们调试代码 ![image-20250303153330683](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250303153330683.png) ## useState数据的不可变性 我们改变useState中的数据时,必须传入一个新的对象或者数组来完成对原数据的修改,例如下面代码 ```tsx import React, { FC } from 'react' const UseStateDemo: FC = () => { const [arr, setArr] = React.useState([1, 2]) const [useInfo, setUseInfo] = React.useState({ name: '张三', age: 18 }) function addItem() { setArr([...arr, 3]) } function changeUserAge() { setUseInfo({ ...useInfo, age: 20 }) } return (

useState Demo

{JSON.stringify(arr)}
{JSON.stringify(useInfo)}
) } export default UseStateDemo ``` ## 使用immer简化数据操作 安装 ```sh yarn add immer ``` 我们使用immer来改写上面的写法 ```tsx import React, { FC } from 'react' import { produce } from 'immer' const UseStateDemo: FC = () => { const [arr, setArr] = React.useState([1, 2]) const [useInfo, setUseInfo] = React.useState({ name: '张三', age: 18 }) function addItem() { setArr( produce(draft => { draft.push(3) }) ) } function changeUserAge() { setUseInfo( produce(draft => { draft.age = 20 }) ) } return (

useState Demo

{JSON.stringify(arr)}
{JSON.stringify(useInfo)}
) } export default UseStateDemo ``` ## useEffect监听组件生命周期 ### 组件创建完成后触发 ```tsx useEffect(() => { console.log('组件创建完成') }, []) ``` ### 数据更新触发 ```tsx const [questionData, setQuestionData] = useState([ { id: '1', title: '调查问卷1', public: false }, { id: '2', title: '调查问卷2', public: true }, { id: '3', title: '调查问卷3', public: false } ]) // 监听questionData变化 useEffect(() => { console.log('questionData变化', questionData) }, [questionData]) ``` ### 组件销毁触发 ```tsx // 组件销毁触发 useEffect(() => { return () => { console.log('组件销毁', title) } }, []) ``` ### useEffect执行两次的问题 从 React18 开始,使用 useEffect 时,为了更早的暴露代码问题,会模拟一遍组件从创建到销毁再到创建的完整流程,目的是有助于开发者在开发过程中更早的发现问题,因此 useEffect 会执行两次。但是在生产环境不会执行两次。 ## 使用useRef操作DOM ```tsx import React, { useRef } from 'react' export default function UseRefDemo() { const inputRef = useRef(null) function setBlur() { const current = inputRef.current if (current) { current.focus() current.style.color = 'red' } } return ( <> ) } ``` ## 使用useMemo缓存数据 [`useMemo`](https://zh-hans.react.dev/reference/react/useMemo) 会从函数调用中创建/重新访问记忆化值,只有在第二个参数中传入的依赖项发生变化时,才会重新运行该函数。函数的类型是根据第一个参数中函数的返回值进行推断的,如果希望明确指定,可以为该 Hook 提供一个类型参数以指定函数类型。 ```tsx import React, { useEffect, useMemo, useState } from 'react' export default function UseMemoDemo() { useEffect(() => { console.log('create') }, []) const [num1, setNum1] = useState(1) const [num2, setNum2] = useState(2) const [count, setCount] = useState(12) const sum = useMemo(() => { return num1 + num2 }, [num1, num2]) function updateCount(e: React.ChangeEvent) { setCount(Number(e.target.value)) } return ( <>
sum:{sum}
num1: {num1}
num2: {num2}
updateCount(e)} />
) } ``` ## 使用useCallback缓存函数 [官方解释](https://zh-hans.react.dev/reference/react/useCallback#skipping-re-rendering-of-components) `useCallback` 是一个允许你在多次渲染中缓存函数的 React Hook。 ```tsx import React, { useState, useCallback } from 'react' export default function UseCallbackDemo() { const [text, setText] = useState('hello') function fn1() { console.log('fn1', text) } const fn2 = useCallback((): void => { console.log('fn2', text) }, [text]) return (

UseCallbackDemo

setText(e.target?.value)} />
) } ``` ## 自定义Hook ```tsx import { useEffect, useState } from 'react' // 设置页面标题 export function useTitle(title: string) { useEffect(() => { document.title = title }, []) } // 获取数据实时位置 export function useMousePosition() { // 初始化鼠标位置为当前鼠标位置 const [x, setX] = useState(0) const [y, setY] = useState(0) useEffect(() => { function updateMousePosition(e: MouseEvent) { setX(e.clientX) setY(e.clientY) } document.addEventListener('mousemove', updateMousePosition) return () => { document.removeEventListener('mousemove', updateMousePosition) } }, []) return { x, y } } function getInfo(): Promise { return new Promise(resolve => { setTimeout(() => { resolve(Date.now().toString()) }, 1500) }) } // 模拟异步获取数据 export function useGetInfo() { const [loading, setLoading] = useState(true) const [userInfo, setUserInfo] = useState(null) useEffect(() => { getInfo().then(info => { setLoading(false) setUserInfo(info) }) }, []) return { loading, userInfo } } ``` 使用方法 ```tsx import React, { useState, useCallback } from 'react' import { useMousePosition, useGetInfo } from './HookUtils' export default function UseCallbackDemo() { const [text, setText] = useState('hello') const { x, y } = useMousePosition() const { loading, userInfo } = useGetInfo() function fn1() { console.log('fn1', text) } const fn2 = useCallback((): void => { console.log('fn2', text) }, [text]) return (

UseCallbackDemo

setText(e.target?.value)} />
鼠标位置:{x} {y}
{loading ? '加载中...' : userInfo}
) } ``` ## 使用第三方Hooks 使用 [aHools](https://ahooks.js.org/zh-CN) 安装 ```sh yarn add ahooks ``` 函数节流实现 ```tsx import { useLockFn } from 'ahooks' export default function UseCallbackDemo() { const [count, setCount] = useState(0) function timeOutCount() { return new Promise(resolve => { setTimeout(() => { resolve() }, 1000) }) } const submit = useLockFn(async () => { await timeOutCount() setCount(count + 1) }) return ( <>
count: {count}
) } ``` ## Hooks中的闭包陷阱 首先看下面的代码 ```tsx import React from 'react' export default function ClosureTrap() { const [count, setCount] = React.useState(0) function addCount() { setCount(count + 1) } function alertCount() { setTimeout(() => { alert(count) }, 3000) } return ( <>
{count}
) } ``` 现在当我点击添加按钮吧count添加到5,然后点击alert按钮,这时在倒计时的等待过程中快速的点击添加按钮,倒计时结束后,弹出的count的值不是最新的值,这是因为点击alert按钮时,由于count是一个值类型,会暂存点击时的count的值到缓存中,然后倒计时结束后,自然弹出的是缓存时也就是之前的值 可以使用 useRef 配合 useState 来使用,下面是改进后的代码 ```tsx import React, { useState, useRef, useEffect } from 'react' export default function ClosureTrap() { const [count, setCount] = useState(0) const countRef = useRef(count) // 监听count变化,更新countRef useEffect(() => { countRef.current = count }, [count]) function addCount() { setCount(count + 1) } function alertCount() { setTimeout(() => { alert(countRef.current) }, 3000) } return ( <>
{count}
) } ``` 我们监听count变化,更新countRef,由于countRef是一个引用类型,指向的是同一个引用地址,所以我们改变count的值的时候,改变的是同一个引用地址的值,因此倒计时结束后,弹出的就是最新的值 ## React中CSS处理 ### 使用classnames处理动态样式 classnames [文档](https://www.npmjs.com/package/classnames) 一个简单的 JavaScript 实用程序,用于有条件地将 classNames 连接在一起。 安装 ```sh yarn add classnames ``` 使用 classnames 进行类名拼接 ```tsx import React, { FC, useEffect } from 'react' import './question.css' import classnames from 'classnames' const QuestionCar: FC = props => { const { id, title, isPublic, editQuestion, removeQuestion } = props const questionClass = classnames({ 'question-card': true, 'question-public': isPublic }) return ( <>
....
) } export default QuestionCar ``` ### CssModule解决样式重复问题 使用组件过程中,如果有组件之间存在重复的class类名,则会发生样式覆盖问题,create react app 中原生支持了 css module 的方式来解决样式覆盖问题 只需要把css文件命名成 ` xxx.module.css` 即可 ```tsx import React, { FC, useEffect } from 'react' import style from './question.module.css' import classnames from 'classnames' const QuestionCar: FC = props => { const { id, title, isPublic, editQuestion, removeQuestion } = props const questionClass = classnames({ [style['question-card']]: true, [style['question-public']]: isPublic }) return ( <>
...
) } export default QuestionCar ``` 经过上面的修改后,观察生成的代码,发现 React 自动的吧 class 变成 组件名+样式名+随机字符串 ![image-20250306163104399](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250306163104399.png) ### 使用SassModule简化CSS语法 原生css不支持嵌套循环等,我们使用sass来简化css语法 安装 ```sh yarn add sass ``` > "sass": "^1.85.1", 使用方式也很简答,只需要把css文件改成 `xxx.module.scss` 即可,**注意后缀是 scss** question.module.scss ```scss .question-card { background-color: #f8f9fa; border: 1px solid #e9ecef; border-radius: 5px; margin-bottom: 20px; padding: 20px; .public-span{ font-size: 20px; font-weight: 700; color: green; } } .question-public { color: #007bff; border: 1px solid #007bff; } button { margin: 0 15px; } ``` 使用 ```tsx import React, { FC, useEffect } from 'react' import style from './question.module.scss' import classnames from 'classnames' const QuestionCar: FC = props => { const { id, title, isPublic, editQuestion, removeQuestion } = props const questionClass = classnames({ [style['question-card']]: true, [style['question-public']]: isPublic }) return ( <>
{title} {isPublic ? 已发布 : 未发布}
) } export default QuestionCar ``` ![image-20250306163741750](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250306163741750.png) ## Router路由配置 ### 安装React路由 ```sh yarn add react-router-dom ``` ### 页面框架搭建 新建 src\roters\index.tsx,并且按照下面的结构搭建页面 ```tsx import React from 'react' import { createBrowserRouter } from 'react-router-dom' import MainLayout from '../layouts/MainLayout' import ManageLayout from '../layouts/ManageLayout' import QuestionLayout from '../layouts/QuestionLayout' import Home from '../pages/Home' import Login from '../pages/Login' import Register from '../pages/Register' import List from '../pages/manage/List' import Star from '../pages/manage/Star' import Trash from '../pages/manage/Trash' import Edit from '../pages/question/Edit' import Stat from '../pages/question/Stat' import NotFound from '../pages/404' const Router = createBrowserRouter([ { path: '/login', element: }, { path: '/register', element: }, { path: '/', element: , children: [ { path: '/', element: }, { path: 'manage', element: , children: [ { path: 'list', element: }, { path: 'star', element: }, { path: 'trash', element: } ] }, ] }, { path: '/question', element: , children: [ { path: 'edit/:id', element: }, { path: 'stat', element: } ] }, { path: '*', element: } ]) export default Router ``` 修改 App.tsx ```tsx import React from 'react' import { RouterProvider } from 'react-router-dom' import routerConfig from './roters' function App() { return } export default App ``` ### 路由API使用跳转 #### 路由跳转 ```tsx import React from 'react' import { Outlet, useNavigate } from 'react-router-dom' import style from './MainLayout.module.scss' export default function MainLayout() { const navigate = useNavigate() const goLogin = () => { // 携带参数跳转 navigate({ pathname: '/login', search: 'a=1&b=2' }) // 普通跳转 // navigate("/login") } return ( <>
问卷大师
footer
) } ``` #### 获取路由参数 引入 `useSearchParams` 获取路由参数,`searchParams.get('xxx')` 获取参数值 ```tsx import React, { useState } from 'react' import style from './Login.module.scss' import { useNavigate, useSearchParams } from 'react-router-dom' export default function Login() { const navigate = useNavigate() // 获取路由参数 const [searchParams] = useSearchParams() console.log(searchParams.get('a')) console.log(searchParams.get('b')) const handleLogin = () => { navigate('/') } const handleRegister = () => { navigate('/register') } return (
) } ``` #### 获取路径参数 使用 `useParams` 获取动态路由的参数 ```tsx import React from 'react' import { useParams } from 'react-router-dom' export default function Edit() { // 获取路径参数 question/edit/4545 const { id } = useParams() console.log(id) return
Edit
} ``` #### 返回 ```tsx import React from 'react' import { useNavigate } from 'react-router-dom' export default function Register() { const navigate = useNavigate() const back = () => { navigate(-1) } return (
Register
) } ``` ## Ant Design使用 ### 安装 [官方文档](https://ant-design.antgroup.com/index-cn) ```sh yarn add antd ``` index.tsx引入中文包,并设置默认中文 ```tsx import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' import reportWebVitals from './reportWebVitals' import './index.css' import zh_CN from 'antd/es/locale/zh_CN' import ConfigProvider from 'antd/es/config-provider' const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement) root.render( // // // ) // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals reportWebVitals() ``` 修改 `src/app/page.tsx`,引入 antd 的按钮组件。 ```tsx import React from 'react' import { Button } from 'antd' export default function Home() { return (
) } ``` 好了,现在你应该能看到页面上已经有了 `antd` 的蓝色按钮组件,接下来就可以继续选用其他组件开发应用了 安装图标库 ```sh yarn add @ant-design/icons ``` ### React 19 兼容问题 [官方文档](https://ant-design.antgroup.com/docs/react/v5-for-19-cn) 由于 React 19 调整了 `react-dom` 的导出方式,导致 antd 无法直接使用 `ReactDOM.render` 方法。因而使用 antd 会遇到以下问题: - 波纹特效无法正常工作 - `Modal`、`Notification`、`Message` 等组件的静态方法无效 因而需要通过兼容配置,使 antd 在 React 19 中正常工作。 ```sh yarn add @ant-design/v5-patch-for-react-19 ``` 在应用入口处引入兼容包 ```tsx import '@ant-design/v5-patch-for-react-19'; ``` ### 改造MainLayout 使用 Layout,Header,Content,Footer等组件 ```tsx import React from 'react' import { Outlet, useNavigate } from 'react-router-dom' import style from './MainLayout.module.scss' import { Layout, Button, Space } from 'antd' import { FormOutlined } from '@ant-design/icons' import { LOGIN_PATH } from '../roters' const { Header, Content, Footer } = Layout export default function MainLayout() { const navigate = useNavigate() const goHome = () => { navigate('/') } const goLogin = () => { // 携带参数跳转 navigate({ pathname: LOGIN_PATH, search: 'a=1&b=2' }) } return (

问卷大师

问卷大师 ©{new Date().getFullYear()} Created by Ant UED
) } ``` MainLayout.module.scss 样式 ```scss .header { display: flex; color: white; align-items: center; justify-content: space-between; } .container { height: var(--content-height); width: 100%; background-color: #f5f5f5; position: relative; } .footer { text-align: center; height: 66px; border-top: 1px solid #e9ecef; } ``` 全局定义 --content-height CSS变量 index.css ```css :root { --content-height: calc(100vh - 64px - 67px); } ``` ![image-20250318101024860](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250318101024860.png) ## 表单交互组件 ### 搜索组件 ```tsx import React, { useEffect, useState } from 'react' import style from './question.module.scss' import { Flex, Input, Space } from 'antd' import { useNavigate, useLocation, useSearchParams } from 'react-router-dom' import { LIST_SEARCH_PARMA_KEY } from '../constant' const { Search } = Input type propsType = { title: string showAdd?: boolean addQuestion?: () => void } export default function QuestionHeader(props: propsType) { const { title } = props const nav = useNavigate() const { pathname } = useLocation() const [searchParam] = useSearchParams() const [value, setValue] = useState('') const handleChange = (e: React.ChangeEvent) => { setValue(e.target.value) } const handleSearch = (value: string) => { nav({ pathname, search: `${LIST_SEARCH_PARMA_KEY}=${value}` }) } useEffect(() => { const search = searchParam.get(LIST_SEARCH_PARMA_KEY) setValue(search || '') }, [searchParam]) return ( <>
{title}
) } ``` 点击搜索时,动态改变当前路由的参数,并且将参数定义为常量,其他组件根据路由参数实现查询逻辑,可搜索组件解耦,并且通过 useEffect 监听路由参数变化,实现页面刷新时重新赋值输入框的值 常量定义如下 constant/index.ts ```ts export const LIST_SEARCH_PARMA_KEY = 'searchName' export const PAGE_SIZE_KEY = 'pageSize' export const PAGE_NUMBER_KEY = 'pageNum' export const LIST_PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50] export const LIST_PAGE_DEFAULT_CURRENT = 1 export const LIST_PAGE_DEFAULT_SIZE = 10 ``` ### 注册表单 ```tsx import React from 'react' import style from '../styles/Login.module.scss' import { Link } from 'react-router-dom' import type { FormProps } from 'antd' import { Button, Flex, Form, Input, Space, Typography } from 'antd' import { LOGIN_PATH } from '../roters' const { Title } = Typography type FieldType = { username?: string password?: string confirmPassword?: string } export default function Login() { // 创建 Form 实例,用于管理所有数据状态 const [form] = Form.useForm() const onFinish: FormProps['onFinish'] = values => { console.log(values) } return (
注册
label="用户名" name="username" rules={[ { required: true, message: '请输入用户名' }, { pattern: /^[a-zA-Z0-9]{6,20}$/, message: '请输入6-20位字母、数字' } ]} > label="密码" name="password" rules={[ { required: true, message: '密码不能为空' }, { min: 6, max: 20, message: '密码长度在6-20位之间' } ]} > label="确认密码" name="confirmPassword" dependencies={['password']} rules={[ { required: true, message: '密码不能为空' }, ({ getFieldValue }) => ({ validator(_, value) { if (!value || getFieldValue('password') === value) { return Promise.resolve() } return Promise.reject(new Error('两次密码不一致')) } }) ]} > 已有账号,去登录
) } ``` ### 登录表单 ```tsx import React, { useEffect } from 'react' import style from '../styles/Login.module.scss' import { Link, useNavigate } from 'react-router-dom' import type { FormProps } from 'antd' import { Button, Checkbox, Flex, Form, Input, Space, Typography } from 'antd' import { HOME_PATH, REGISTER_PATH } from '../roters' const { Title } = Typography type FieldType = { username?: string password?: string remember?: string } const USERNAME = 'username' const PASSWORD = 'password' const setUserInfo = (username: string, password: string) => { localStorage.setItem(USERNAME, username) localStorage.setItem(PASSWORD, password) } const delUserInfo = () => { localStorage.removeItem(USERNAME) localStorage.removeItem(PASSWORD) } const getUserInfo = () => { const username = localStorage.getItem(USERNAME) const password = localStorage.getItem(PASSWORD) return { username, password } } export default function Login() { // 创建 Form 实例,用于管理所有数据状态 const [form] = Form.useForm() const nav = useNavigate() // 页面加载完成后获取本地存储的用户名密码 useEffect(() => { const { username, password } = getUserInfo() if (username && password) { form.setFieldsValue({ username, password, remember: true }) } }, []) const onFinish: FormProps['onFinish'] = values => { if (values.remember) { // 后面加!表示一定有值 setUserInfo(values.username!, values.password!) } else { delUserInfo() } nav(HOME_PATH) } return (
登录
label="用户名" name="username" rules={[ { required: true, message: '请输入用户名' } ]} > label="密码" name="password" rules={[{ required: true, message: '密码不能为空' }]} > name="remember" valuePropName="checked" label={null}> 记住我 没有账号,去注册
) } ``` ## Ajax网络请求 ### 使用Node搭建Mock服务 新建文件夹 wenjun-node,然后进入到项目中,打开终端执行 ```sh npm init -y ``` 安装依赖 ```sh npm install koa koa-router koa-bodyparser mockjs nodemon ``` 1. **koa**: - 作用:Koa 是一个用于构建 Web 应用程序和 API 的 Node.js 框架。它由 Express 的原始团队开发,旨在提供更强大和更优雅的中间件机制。 - 版本:^2.16.0 2. **koa-router**: - 作用:Koa Router 是 Koa 框架的路由中间件,用于定义应用程序的路由规则。它允许你将请求路径和处理函数关联起来,从而处理不同的 HTTP 请求。 - 版本:^13.0.1 3. **koa-bodyparser** - 作用:koa-bodyparser 是 Koa 框架的一个中间件,可以帮我我们获取post请求发送过来的json数据。 - 版本:^4.4.1 4. **mockjs**: - 作用:Mock.js 是一个用于生成随机数据和模拟 AJAX 请求的库。它可以帮助前端开发人员在没有后端 API 的情况下进行开发和测试。 - 版本:^1.1.0 5. **nodemon**: - 作用:Nodemon 是一个用于 Node.js 应用程序的工具,它会在检测到文件变化时自动重启应用程序。它非常适合在开发过程中使用,以提高开发效率。 - 版本:^3.1.9 然后再 packages.json 的 scripts 中新增 dev 启动命令 ```json { "name": "wenjuan-node", "version": "1.0.0", "description": "", "main": "index.js", "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "dev": "nodemon index.js" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "koa": "^2.16.0", "koa-bodyparser": "^4.4.1", "koa-router": "^13.0.1", "mockjs": "^1.1.0", "nodemon": "^3.1.9" } } ``` ### 创建Mock数据 新建 mock/question.js 文件 ```js import mock from 'mockjs'; const random = mock.Random export default [ { url: '/api/question/list', method:"get", response:(ctx)=>{ // 获取参数 console.log(ctx.query.name); return { code:200, data:mock.mock({ 'list|10':[ { 'id|+1':1, 'title':'@ctitle', "createTime":random.date('yyyy-MM-dd HH:mm:ss'), "publish": '@boolean', "star": '@boolean', } ] }) } } }, { url: '/api/question/add', method:"post", response:(ctx)=>{ // 获取post参数 console.log(ctx.request.body); return { code:200, data:{ id:1 } } } } ] ``` 然后新建 mock/index.js 文件,导入question.js 并导出 ```js import question from './question.js' export default [...question] ``` ### Koa整合Mock路由 根目录新建 index.js ```js import Koa from 'koa' import KoaRouter from 'koa-router' import bodyParser from 'koa-bodyparser'; import mockList from './mock/index.js' const app = new Koa() const router = new KoaRouter() app.use(bodyParser()); router.get('/', async ctx => { ctx.body = 'mock server' }) const getRes = async fn => { return new Promise((resolve, reject) => { setTimeout(() => { resolve(fn()) }, 1000) }) } mockList.forEach(item => { router[item.method](item.url, async ctx => { ctx.body = await getRes(item.response.bind(null, ctx)) }) }) app.use(router.routes()) app.listen(5001, () => { console.log('mock server start at http://localhost:5001') }) ``` ### 测试 ![image-20250318172209038](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250318172209038.png) ![image-20250318172228669](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250318172228669.png) ### 扩展webpack处理前端请求跨域问题 现在我们的Mock服务地址是5001端口,但是前端是5000端口,直接请求数据会出现跨域问题 我们安装 craco 来实现跨域处理,[文档地址](https://www.npmjs.com/package/@craco/craco) 1. 从 npm 安装最新版本的包作为开发依赖项 ``` yarn add -D @craco/craco ``` 2. 在项目的根目录中创建一个 CRACO 配置文件并[进行配置](https://craco.js.org/docs/) ``` my-app ├── node_modules + ├── craco.config.js └── package.json ``` 编写配置代码 ```js module.exports = { devServer: { client: { overlay: false // 解决开发环境下axios返回Promise.reject()时,全屏显示错误的问题 }, proxy: { '/api': { target: 'http://localhost:5001', changeOrigin: true } } } } ``` 3. 更新部分`react-scripts`中现有的调用以使用CLI:`scripts``package.json``craco` ``` "scripts": { - "start": "react-scripts start" + "start": "craco start" - "build": "react-scripts build" + "build": "craco build" - "test": "react-scripts test" + "test": "craco test" } ``` 访问[craco.js.org](https://craco.js.org/)了解更多信息。 ### 封装axios 新建配置文件,配置请求前缀和启动端口 .env ```sh PORT=5000 REACT_APP_API_BASE_URL="/api" ``` 创建http.ts来二次封装axios ```ts // src/utils/http.ts import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' import { message } from 'antd' // 类型定义 interface ResponseData { code: number data?: T msg?: string } interface CustomRequestConfig extends AxiosRequestConfig { hideErrorMessage?: boolean } // 创建axios实例 const createHttpInstance = () => { const instance = axios.create({ baseURL: process.env.REACT_APP_API_BASE_URL, timeout: 10000, headers: { 'Content-Type': 'application/json' } }) // 请求拦截 instance.interceptors.request.use(config => { const token = localStorage.getItem('access_token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截 instance.interceptors.response.use( (response: AxiosResponse) => handleResponse(response), error => handleHttpError(error) ) return instance } // 处理响应 const handleResponse = (response: AxiosResponse) => { const res = response.data const config = response.config as CustomRequestConfig if (res.code === 200) { return res.data } const errorMsg = res.msg || '请求处理失败' if (!config.hideErrorMessage) { message.error(errorMsg) } return Promise.reject(new Error(errorMsg)) } // 处理HTTP错误 const handleHttpError = (error: any) => { const errorMessage = getHttpErrorMsg(error) const config = error.config as CustomRequestConfig | undefined if (!config?.hideErrorMessage) { message.error(errorMessage) } return Promise.reject(new Error(errorMessage)) } // 获取HTTP错误信息 const getHttpErrorMsg = (error: any): string => { if (error.response) { switch (error.response.status) { case 401: localStorage.removeItem('access_token') setTimeout(() => window.location.assign('/login'), 1000) return '登录已过期,请重新登录' case 403: return '拒绝访问' case 500: return '服务器内部错误' default: return error.response.data?.msg || '请求错误' } } return error.request ? '网络连接异常' : '未知错误' } // 创建实例并导出方法 const httpInstance = createHttpInstance() export default (config: CustomRequestConfig) => httpInstance.request(config) ``` 使用 ```ts import http from '../utils/http' type QuestionData = { id: string title: string createTime: string star: boolean publish: boolean } // 修正服务函数类型 export const getQuestionListFun = (): Promise => { return http({ url: '/question/list', method: 'get' }) } ``` 因为我们使用了泛型,所以访问接口拿到返回值的时候就会有提示 ![image-20250319131636525](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250319131636525.png) ### 使用useRequest优化请求 [ahooks](https://ahooks.js.org/zh-CN/hooks/use-request/index#index-default) 中 `useRequest` 是一个强大的异步数据管理的 Hooks,React 项目中的网络请求场景使用 `useRequest` 就够了。 `useRequest` 通过插件式组织代码,核心代码极其简单,并且可以很方便的扩展出更高级的功能。目前已有能力包括: - 自动请求/手动请求 - 轮询 - 防抖 - 节流 - 屏幕聚焦重新请求 - 错误重试 - loading delay - SWR(stale-while-revalidate) - 缓存 我们使用useRequest并封装hooks实现一个通用的查询问卷详情接口 后端添加接口 ```js { url: '/api/question/add', method:"post", response:(ctx)=>{ // 获取post参数 console.log(ctx.request.body); return { code:200, msg:"添加成功", data:{ // 随机8为数字 id: random.id(8) } } } }, ``` 前端定义接口api ```ts import http from '../utils/http' type QuestionData = { id: string title: string createTime: string star: boolean publish: boolean isDelete: boolean } // 查询问卷 export const getQuestionInfoFun = (id: string): Promise => { return http({ url: '/question/getInfo', method: 'get', params: { id } }) } ``` 然后新建自定义hooks src\hooks\useLoadQuestionData.ts ```ts import { useRequest } from 'ahooks' import { getQuestionInfoFun } from '../api/list' import { useParams } from 'react-router-dom' // 将查询问卷的功能单独抽离成一个单独的hooks,方便复用 export function useLoadQuestionData() { const { id } = useParams() const { data, loading } = useRequest(() => getQuestionInfoFun(id as string)) return { data, loading } } ``` 使用useLoadQuestionData ```tsx import React from 'react' import { useLoadQuestionData } from '../../hooks/useLoadQuestionData' export default function Edit() { const { data, loading } = useLoadQuestionData() return ( <>
编辑页面
{loading ?
loading...
:
{JSON.stringify(data)}
} ) } ``` 这里拿到的data就是接口返回的数据,并且页面可以根据loading状态显示加载 ![image-20250319174128060](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250319174128060.png) ## MongoDB ### 使用Docker安装Mongodb 拉取镜像 ```sh docker pull mongo ``` 运行 ```sh // linux docker run -d -p 27017:27017 --name mongodb \ -e MONGO_INITDB_ROOT_USERNAME=admin \ -e MONGO_INITDB_ROOT_PASSWORD=abc123 \ -v D:\docker\mongo\conf:/etc/mongo \ -v D:\docker\mongo\data:/data/db \ mongo // Windows docker run -d -p 27017:27017 --name mongodb -e MONGO_INITDB_ROOT_USERNAME=admin -e MONGO_INITDB_ROOT_PASSWORD=abc123 -v D:\docker\mongo\conf\mongod.conf:/etc/mongod.conf -v D:\docker\mongo\data:/data/db mongo ``` 查看运行 ```sh docker ps ``` ![image-20250321150713861](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250321150713861.png) ### Node连接MongoDB 安装所需依赖 ```sh yarn add mongodb ``` 创建常量配置文件 ```js export const DB_URL = "mongodb://admin:abc123@127.0.0.1:27017"; export const DB_NAME = "wenjuan"; export const COLLECTION_QUESTION = 'question'; ``` 创建连接 ```js import { MongoClient } from 'mongodb'; import {DB_NAME, DB_URL} from "../constant/dbConfig.js"; const client = new MongoClient(DB_URL); let dbInstance = null; export async function connectToDatabase() { if (!dbInstance) { await client.connect(); dbInstance = client.db(DB_NAME); } return dbInstance; } ``` 新增 ```js import {connectToDatabase} from "../data/db.js"; const db = await connectToDatabase(); const result = await db.collection(COLLECTION_QUESTION).insertOne({ title:ctx.request.body.title, createTime:formatDate(), publish:false, star:false, isDelete:0 }) return { code:200, msg:"添加成功", data:result } ``` 调用接口后接口返回成功 ![image-20250321151450934](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250321151450934.png) 查看数据库情况 ![image-20250321151437968](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250321151437968.png) ### 多条件查询 ```js response: async ctx => { const { title, isStar, isDelete = 0 } = ctx.query const result = await questionCollection .find({ // 多条件查询 isDelete: isDelete === '1' ? 1 : 0, title: new RegExp(title || '', 'i'), star: isStar === undefined ? { $exists: true } : isStar === 'true' }) .toArray() return Result.success(result) } ``` ### 分页查询 ```js response: async ctx => { const { questionId, pageNum = 1, pageSize = 10 } = ctx.query if (!questionId) { return Result.error('Question ID is required') } try { const query = { questionId: questionId } const skip = (pageNum - 1) * pageSize // 计算跳过的文档数量 const limit = parseInt(pageSize, 10) // 每页文档数量 // 从答案表中分页获取数据 const total = await answerCollection.countDocuments(query) // 获取总记录数 const answers = await answerCollection .find(query) .skip(skip) .limit(limit) .toArray() // 分页查询 const rows = answers.map(answer => { const { _id, questionId, answers } = answer return { _id, questionId, ...answers } }) return Result.success({ total, // 总记录数 pageNum: parseInt(pageNum, 10), // 当前页码 pageSize: limit, // 每页数量 rows // 当前页数据 }) } catch (error) { return Result.error('Failed to fetch answer list').setMsg( JSON.stringify(error) ) } } ``` ### 根据ID查询单个数据 ```js response: async ctx => { const { _id } = ctx.query const result = await questionCollection.findOne({ _id: new ObjectId(_id) }) // 获取post参数 return Result.success(result) } ``` ### 根据ID修改数据 ```js response: async ctx => { const { _id, title, publish, star, isDelete } = ctx.request.body const result = await questionCollection.updateOne( { _id: new ObjectId(_id) }, { $set: { title, publish, star, isDelete } } ) return Result.success(result) } ``` ### 删除多个数据 ```js response: async ctx => { const { ids } = ctx.request.body // 得到要删除的id数组 const result = await questionCollection.deleteMany({ _id: { $in: ids.map(id => new ObjectId(id)) } }) return Result.success(result) } ``` ## 问卷相关功能 ### 封装通用的查询Hooks src/api/list.ts 添加获取列表api接口 ```js import { QuestionType } from '../types/QuestionType' import http from '../utils/http' /** * 问卷搜索参数 * @param title 问卷标题 * @param isDelete 是否删除 * @param star 是否标星 */ type QuestionSearchProp = { title: string isDelete: number star: boolean } // 获取问卷列表 export const getQuestionListFun = ( params: Partial ): Promise => { return http({ url: '/question/list', method: 'get', params: params }) } ``` 添加自定义hooks,实现页面加载时自动获取问卷列表,同时配合搜索组件,改变页面路由参数时,自动重新调用接口 src/hooks/useLoadQuestionListData*.*ts ```ts import { useRequest } from 'ahooks' import { getQuestionListFun } from '../api/list' import { useSearchParams } from 'react-router-dom' import { LIST_SEARCH_PARMA_KEY } from '../constant' /** * 问卷搜索参数 * @param title 问卷标题 * @param isDelete 是否删除 * @param star 是否标星 */ type QuestionSearchProp = { isDelete: number isStar: boolean } // 将查询问卷列表的功能单独抽离成一个单独的hooks,方便复用 export function useLoadQuestionListData( params: Partial = { isDelete: 0 } ) { const [searchParam] = useSearchParams() const { data, loading } = useRequest( async () => { const data = await getQuestionListFun({ title: searchParam.get(LIST_SEARCH_PARMA_KEY) || '', ...params }) return data }, { refreshDeps: [searchParam] // 监听搜索参数的变化,重新请求数据 } ) return { data, loading } } ``` 页面工作原理 ![image-20250325091953603](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250325091953603.png) ### 搜索组件 ```tsx import React, { useEffect, useState } from 'react' import style from './question.module.scss' import { Flex, Input, Space } from 'antd' import { useNavigate, useLocation, useSearchParams } from 'react-router-dom' import { LIST_SEARCH_PARMA_KEY, PAGE_NUMBER_KEY } from '../constant' const { Search } = Input type propsType = { title: string } export default function QuestionHeader(props: propsType) { const { title } = props const nav = useNavigate() const { pathname } = useLocation() const [searchParam] = useSearchParams() const [value, setValue] = useState('') const handleChange = (e: React.ChangeEvent) => { setValue(e.target.value) } const handleSearch = (value: string) => { searchParam.set(LIST_SEARCH_PARMA_KEY, value) searchParam.set(PAGE_NUMBER_KEY, '1') nav({ pathname, search: searchParam.toString() }) } useEffect(() => { const search = searchParam.get(LIST_SEARCH_PARMA_KEY) setValue(search || '') }, [searchParam]) return ( <>
{title}
) } ``` ### 分页组件 ```tsx import React, { useState, useEffect } from 'react' import { Flex, Pagination } from 'antd' import { useNavigate, useLocation, useSearchParams } from 'react-router-dom' import { PAGE_NUMBER_KEY, PAGE_SIZE_KEY } from '../constant' type ListPageProps = { total: number } export default function ListPage(props: ListPageProps) { const nav = useNavigate() const location = useLocation() const [searchParams] = useSearchParams() const [pageNum, setPageNum] = useState(1) const [pageSize, setPageSize] = useState(10) const { total } = props useEffect(() => { // 从url中获取当前页码和每页显示条数 setPageNum(parseInt(searchParams.get(PAGE_NUMBER_KEY) || '1')) setPageSize(parseInt(searchParams.get(PAGE_SIZE_KEY) || '5')) }, [searchParams]) function handlePageChange(page: number, pageSize: number) { // 改变url中的查询参数,触发useLoadQuestionListData重新请求数据 searchParams.set(PAGE_NUMBER_KEY, page.toString() || '1') searchParams.set(PAGE_SIZE_KEY, pageSize.toString() || '5') nav({ pathname: location.pathname, search: searchParams.toString() }) } return (
`共 ${total} 条`} locale={{ items_per_page: '/页' }} showSizeChanger showLessItems current={pageNum} pageSize={pageSize} onChange={handlePageChange} />
) } ``` ### 实现星标页面 我的问卷页面使用 ```ts import React, { FC, useEffect, useState } from 'react' import QuestionCar from '../../components/QuestionCar' import QuestionHeader from '../../components/QuestionHeader' import { Flex, Spin } from 'antd' import { QuestionType } from '../../types/QuestionType' import { useLoadQuestionListData } from '../../hooks/useLoadQuestionListData' import ListPage from '../../components/ListPage' const List: FC = () => { const [questionData, setQuestionData] = useState([]) const [total, setTotal] = useState(0) const { data, loading } = useLoadQuestionListData({ isStar: true }) useEffect(() => { if (data) { setQuestionData(data.list) setTotal(data.total) } }, [data]) function editQuestion() {} // 设置标星 function starQuestion() {} function removeQuestion() {} return ( <> {loading && ( )} {!loading && (
{questionData.map(question => { const _id = question._id return ( ) })}
)} ) } export default List ``` ![image-20250325092247361](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250325092247361.png) ### 回收站页面 ```ts import React, { FC, useEffect, useState } from 'react' import QuestionHeader from '../../components/QuestionHeader' import { Space, Table, Tag, Button, message } from 'antd' import type { TableProps } from 'antd' import { QuestionType } from '../../types/QuestionType' import { useLoadQuestionListData } from '../../hooks/useLoadQuestionListData' import ListPage from '../../components/ListPage' import { useRequest } from 'ahooks' import { removeQuestionFun, restoreQuestionFun } from '../../api/list' type TableRowSelection = TableProps['rowSelection'] interface DataType { _id: string title: string createTime: string publish: boolean star: boolean } const columns: TableProps['columns'] = [ { title: '问卷名称', dataIndex: 'title' }, { title: '创建时间', dataIndex: 'createTime' }, { title: '是否标星', dataIndex: 'star', render: (_, { star }) => <>{star ? 已标星 : 未标星} }, { title: '问卷状态', dataIndex: 'publish', render: (_, { publish }) => <>{publish ? 已发布 : 未发布} } ] const List: FC = () => { const [questionData, setQuestionData] = useState([]) const [total, setTotal] = useState(0) const { data, loading, refresh } = useLoadQuestionListData({ isDelete: 1 }) useEffect(() => { if (data) { setQuestionData(data.data.list) setTotal(data.data.total) } }, [data]) const [selectedRowKeys, setSelectedRowKeys] = useState([]) // 当选择项发生变化时的回调函数 const onSelectChange = (newSelectedRowKeys: React.Key[]) => { setSelectedRowKeys(newSelectedRowKeys) } const rowSelection: TableRowSelection = { columnWidth: '50px', selectedRowKeys, onChange: onSelectChange } // 根据选择的id数组,进行批量还原操作 const { run: restoreFun, loading: restoreLoading } = useRequest( async ids => await restoreQuestionFun(ids), { manual: true, onSuccess(res) { message.success(res.message) setSelectedRowKeys([]) refresh() } } ) const restore = () => { restoreFun(selectedRowKeys as string[]) } // 彻底删除 const { run: removeFun, loading: removeLaoding } = useRequest( async ids => await removeQuestionFun(ids), { manual: true, onSuccess(res) { message.success(res.message) setSelectedRowKeys([]) refresh() } } ) const remove = () => { removeFun(selectedRowKeys as string[]) } return ( <> loading={loading} rowKey={'_id'} rowSelection={rowSelection} columns={columns} dataSource={questionData} pagination={false} />
) } export default List ``` ![image-20250325092339955](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250325092339955.png) ### 实现上滑加载更多 ```tsx import React, { FC, useState, useEffect, useRef } from 'react' import QuestionCar from '../../components/QuestionCar' import QuestionHeader from '../../components/QuestionHeader' import { produce } from 'immer' import { QuestionType } from '../../types/QuestionType' import { Flex, message, Spin } from 'antd' import { LIST_SEARCH_PARMA_KEY } from '../../constant' import { useSearchParams } from 'react-router-dom' import { deleteQuestionFun, getQuestionListFun, updateQuestionInfoFun } from '../../api/list' import { useRequest } from 'ahooks' const List: FC = () => { const [loading, setLoading] = useState(false) const [questionData, setQuestionData] = useState([]) const loadMoreRef = useRef(null) const [searchParams] = useSearchParams() const [total, setTotal] = useState(0) const keyWord = searchParams.get(LIST_SEARCH_PARMA_KEY) || '' const [pages, setPages] = useState({ pageNum: 1, pageSize: 10, title: keyWord }) function getListData() { setLoading(true) getQuestionListFun(pages) .then(res => { setQuestionData( produce(draft => { draft.push(...res.data.list) }) ) setTotal(res.data.total) }) .finally(() => { setLoading(false) }) } useEffect(() => { // 从url中获取搜索的关键词 const searchTitle = searchParams.get(LIST_SEARCH_PARMA_KEY) || '' setQuestionData([]) setPages( produce(draft => { draft.title = searchTitle draft.pageNum = 1 }) ) setTotal(0) }, [keyWord]) // 观察者模式,监听loadMoreRef是否出现在视口中 useEffect(() => { const observer = new IntersectionObserver(entries => { entries.forEach(entry => { if (entry.isIntersecting && questionData.length < total && !loading) { console.log('触发加载更多') setPages( produce(draft => { draft.pageNum += 1 }) ) } }) }) if (loadMoreRef.current) { observer.observe(loadMoreRef.current) } return () => { observer.disconnect() } }, [questionData, total, loading]) // 获取问卷列表 useEffect(() => { getListData() }, [pages]) // 设置标星 const { run: updateData, loading: updateLoading } = useRequest( async data => { return await updateQuestionInfoFun(data) }, { manual: true, onSuccess: res => { message.success(res.message) setQuestionData( produce(draft => { const find = draft.find(item => item._id === res.data._id) if (find) { find.star = res.data.star } }) ) } } ) function starQuestion(_id: string, star: boolean) { updateData({ _id, star }) } // 删除问卷 const { run: deleteOne, loading: deleteLoading } = useRequest( async _id => await deleteQuestionFun(_id), { manual: true, onSuccess(res) { setQuestionData([]) getListData() message.success(res.message) } } ) function removeQuestion(_id: string) { deleteOne(_id) } return ( <>
{questionData.map(question => { const _id = question._id return ( ) })} {loading && ( )}
共 {total} 个问卷,已显示 {questionData.length} 个
{questionData.length === total ? '已显示全部' : '上滑加载更多...'}
) } export default List ``` 核心逻辑,使用 IntersectionObserver Api函数监听元素是否可见,当上滑加载更多的元素出现在屏幕可视窗口中时,判断当前已经显示的数量和总数,如果没有大于总数,则让页面加1,同时监听pages变化,重新触发getListData方法,获取下一页数据 ## Redux ### 快速上手 #### 安装 [官方文档](https://www.redux.org.cn/) ```sh yarn add @reduxjs/toolkit react-redux ``` #### 创建 Redux Store 创建 `src/store/index.ts 文件。从 Redux Toolkit 引入 `configureStore` API。我们从创建一个空的 Redux store 开始,并且导出它: js ```ts import { configureStore } from "@reduxjs/toolkit"; export default configureStore({ reducer: {}, }); ``` 上面代码创建了 Redux store ,并且自动配置了 Redux DevTools 扩展 ,这样你就可以在开发时调试 store。 #### 为 React 提供 Redux Store 创建 store 后,便可以在 React 组件中使用它。 在 src/index.js 中引入我们刚刚创建的 store , 通过 React-Redux 的 ``将 `` 包裹起来,并将 store 作为 prop 传入。 ```tsx import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' import zh_CN from 'antd/es/locale/zh_CN' import ConfigProvider from 'antd/es/config-provider' import store from './store/index' import { Provider } from 'react-redux' const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement) root.render( ) ``` #### 创建 Redux State Slice 创建 `src/store/counterSlice.ts` 文件。在该文件中从 Redux Toolkit 引入 `createSlice` API。 创建 slice 需要一个字符串名称来标识切片、一个初始 state 以及一个或多个定义了该如何更新 state 的 reducer 函数。slice 创建后 ,我们可以导出 slice 中生成的 Redux action creators 和 reducer 函数。 Redux 要求[我们通过创建数据副本和更新数据副本,来实现不可变地写入所有状态更新](https://redux.js.org/tutorials/fundamentals/part-2-concepts-data-flow#immutability)。不过 Redux Toolkit `createSlice` 和 `createReducer` 在内部使用 Immer 允许我们[编写“可变”的更新逻辑,变成正确的不可变更新](https://redux.js.org/tutorials/fundamentals/part-8-modern-redux#immutable-updates-with-immer)。 js ```ts import { createSlice } from "@reduxjs/toolkit"; export const counterSlice = createSlice({ name: "counter", initialState: { value: 0, }, reducers: { increment: (state) => { // Redux Toolkit 允许我们在 reducers 写 "可变" 逻辑。它 // 并不是真正的改变状态值,因为它使用了 Immer 库 // 可以检测到“草稿状态“ 的变化并且基于这些变化生产全新的 // 不可变的状态 state.value += 1; }, decrement: (state) => { state.value -= 1; }, incrementByAmount: (state, action) => { state.value += action.payload; }, }, }); // 每个 case reducer 函数会生成对应的 Action creators export const { increment, decrement, incrementByAmount } = counterSlice.actions; export default counterSlice.reducer; ``` #### 将 Slice Reducers 添加到 Store 中 下一步,我们需要从计数切片中引入 reducer 函数,并将它添加到我们的 store 中。通过在 reducer 参数中定义一个字段,我们告诉 store 使用这个 slice reducer 函数来处理对该状态的所有更新。 js ```ts import { configureStore } from '@reduxjs/toolkit' import counterReducer from './counterSlice' const store = configureStore({ reducer: { counter: counterReducer } }) type GetStateFunType = typeof store.getState export type IRoorState = ReturnType // 定义TS类型,使用时出现提示 export default store ``` #### 在 React 组件中使用 Redux 状态和操作 现在我们可以使用 React-Redux 钩子让 React 组件与 Redux store 交互。我们可以使用 `useSelector` 从 store 中读取数据,使用 `useDispatch` dispatch actions。创建包含 `` 组件的 `src/features/counter/Counter.js` 文件,然后将该组件导入 `App.js` 并在 `` 中渲染它。 jsx ```tsx import React from 'react' import { useSelector, useDispatch } from 'react-redux' import { increment, decrement, incrementByAmount } from '../store/counterSlice' import { IRoorState } from '../store' import { Button } from 'antd' export default function ReduxDemo() { const count = useSelector((state: IRoorState) => state.counter.value) const dispatch = useDispatch() return (
{count}
) } ``` 现在,每当你点击”递增“和“递减”按钮。 - 会 dispatch 对应的 Redux action 到 store - 在计数器切片对应的 reducer 中将看到 action 并更新其状态 - ``组件将从 store 中看到新的状态,并使用新数据重新渲染组件 ## JWT ### 使用JWT实现登录鉴权 #### 安装JWT 再node中安装依赖 ```sh npm install jsonwebtoken ``` 使用下面的方法可以生成一个包含指定信息的token ```ts import jwt from 'jsonwebtoken' jwt.sign({ userName, _id: user._id }, SECRET_KEY, { expiresIn: '12h' } ``` #### Node开发登录接口并生成token 开发登录接口,登录成功后,根据用户名和用户id生成token ```js { // 登录用户 url: '/api/system/login', method: 'post', response: async ctx => { const { userName, passWord } = ctx.request.body const user = await questionCollection.findOne({ userName, passWord }) if (user) { // 生成 JWT 令牌 const token = jwt.sign({ userName, _id: user._id }, SECRET_KEY, { expiresIn: '12h' }) return Result.success({ token }) } else { return Result.error().setMsg('用户名或密码错误') } } }, { // 根据用户id查询用户信息 url: '/api/system/userInfo', method: 'get', response: async ctx => { const { _id } = ctx.state.user const user = await questionCollection.findOne({ _id: new ObjectId(_id) }) delete user.passWord return Result.success(user) } } ``` #### 前端调用登录接口获取token 新建一个 userInfoSlice 用于保存登录的用户信息 ```ts import { createSlice } from '@reduxjs/toolkit' export type UserInfoType = { userId: string userName: string roles: string[] } const initialState: UserInfoType = { userId: '', userName: '', roles: [] } const userInfoSlice = createSlice({ name: 'userInfo', initialState, reducers: { setUserInfo(state, action) { state.userName = action.payload.userName localStorage.setItem('userName', action.payload.userName) state.userId = action.payload._id localStorage.setItem('userId', action.payload._id) state.roles = action.payload.roles || [] localStorage.setItem('roles', JSON.stringify(action.payload.roles)) localStorage.setItem('userInfo', JSON.stringify(action.payload)) }, clearUserInfo(state) { state.userName = '' state.userId = '' state.roles = [] // 删除所有缓存 localStorage.clear() // 跳转登录页面 window.location.href = '/login' } } }) export const { setUserInfo, clearUserInfo } = userInfoSlice.actions export default userInfoSlice.reducer ``` 封装一个 userUserInfoStore 方便获取 userInfoSlice 中的数据 ```tsx import { useDispatch, useSelector } from 'react-redux' import { setUserInfo, clearUserInfo, UserInfoType } from '../store/userInfoSlice' import store, { RootState } from '../store' const useUserInfoStore = () => { const dispatch = useDispatch() const userName = localStorage.getItem('userName') || useSelector((state: RootState) => state.userInfo.userName) const userId = localStorage.getItem('userId') || useSelector((state: RootState) => state.userInfo.userId) const roles = localStorage.getItem('roles') || useSelector((state: RootState) => state.userInfo.roles) const setInfo = (info: UserInfoType) => dispatch(setUserInfo(info)) const clearInfo = () => dispatch(clearUserInfo()) return { userName, userId, roles, setInfo, clearInfo } } // 新增一个非 Hook 函数,用于获取 token export const getToken = () => { return localStorage.getItem('token') || '' } export const loginOut = () => { store.dispatch(clearUserInfo()) } export const setToken = (token: string) => { localStorage.setItem('token', token) } export default useUserInfoStore ``` 调用登录接口获取 token 并缓存到本地中,之后每次请求都写到这个token放在请求头中,发送给后端 ```tsx import React from 'react' import style from '../styles/Login.module.scss' import { Link, useNavigate } from 'react-router-dom' import type { FormProps } from 'antd' import { Button, Checkbox, Flex, Form, Input, Space, Typography } from 'antd' import { HOME_PATH, REGISTER_PATH } from '../roters' import { systemLoginFun, systemGetUserInfoFun } from '../api/system' const { Title } = Typography import { setToken } from '../hooks/userUserInfoStore' import { useRequest } from 'ahooks' import useUserInfoStore from '../hooks/userUserInfoStore' type FieldType = { userName?: string passWord?: string remember?: string } export default function Login() { // 创建 Form 实例,用于管理所有数据状态 const [form] = Form.useForm() const nav = useNavigate() const { setInfo } = useUserInfoStore() // 获取个人信息方法 const { run: getUserInfoFun } = useRequest(async () => await systemGetUserInfoFun(), { manual: true, onSuccess(res) { setInfo(res.data) nav(HOME_PATH) } }) // 登录方法 const { run: loginFun, loading: loginLoading } = useRequest( async data => await systemLoginFun(data), { manual: true, onSuccess(res) { // 登录完成后保存token setToken(res.data.token) // 获取用户信息保存到状态和缓存中 getUserInfoFun() } } ) const onFinish: FormProps['onFinish'] = values => { // 调用登录接口 loginFun(values) } return (
登录
label="用户名" name="userName" rules={[{ required: true, message: '请输入用户名' }]} > label="密码" name="passWord" rules={[{ required: true, message: '密码不能为空' }]} > name="remember" valuePropName="checked" label={null}> 记住我 没有账号,去注册
) } ``` #### 前端请求拦截器中添加token请求头 在请求拦截器中添加token请求头 ```ts // 创建axios实例 const createHttpInstance = () => { const instance = axios.create({ baseURL: process.env.REACT_APP_API_BASE_URL, timeout: 10000, headers: { 'Content-Type': 'application/json' } }) // 请求拦截 instance.interceptors.request.use(config => { const token = getToken() if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截 instance.interceptors.response.use( (response: AxiosResponse) => handleResponse(response), error => handleHttpError(error) ) return instance } ``` #### 添加Node中间件实现token鉴权 新建 `interceptor/loginInterceptor.js` ```js import { SECRET_KEY } from '../constant/dbConfig.js' import jwt from 'jsonwebtoken' // 设置白名单接口 const whiteList = ['/api/system/register', '/api/system/login'] // 编写一个中间件,拦截所有请求验证token const loginInterceptor = async (ctx, next) => { // 判断是否在白名单中 if (whiteList.includes(ctx.path)) { await next() return } const token = ctx.headers.authorization && ctx.headers.authorization.split(' ')[1] if (!token) { ctx.status = 401 ctx.body = { message: '用户未登录' } return } try { const decoded = jwt.verify(token, SECRET_KEY) // 验证并解析令牌 ctx.state.user = decoded // 将解码后的用户信息存储在 ctx.state 中,后续接口可以直接使用ctx.state.user获取当前操作的用户信息 await next() } catch (err) { if (err.name === 'TokenExpiredError') { ctx.status = 401 ctx.body = { message: '令牌已过期', error: err } return } else if (err.name === 'JsonWebTokenError') { ctx.status = 401 ctx.body = { message: '无效的令牌', error: err } return } else { ctx.status = 500 ctx.body = { message: '服务器错误', error: err } return } } } export default loginInterceptor ``` 注册中间件 ```js import Koa from 'koa' import KoaRouter from 'koa-router' import bodyParser from 'koa-bodyparser' // 引入 koa-bodyparser import mockList from './mock/index.js' import loginInterceptor from './interceptor/loginInterceptor.js' const app = new Koa() const router = new KoaRouter() app.use(bodyParser()) // 使用 koa-bodyparser 中间件处理body请求参数 router.get('/', async ctx => { ctx.body = 'mock server' }) app.use(loginInterceptor) // 注册登录拦截器,位置必须在路由之前注册 const getRes = async fn => { return new Promise((resolve, reject) => { resolve(fn()) }) } mockList.forEach(item => { router[item.method](item.url, async ctx => { ctx.body = await getRes(item.response.bind(null, ctx)) }) }) app.use(router.routes()) app.listen(5001, () => { console.log('mock server start at http://localhost:5001') }) ``` 中间件的注册时机必须在所有路由之前,这样才能在接口请求之前触发鉴权机制 ## componentsSlice完整代码 src/store/componentsReducer/index.ts 这个代码将我们操作的组件数据都保存在Redux中 ```ts import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { ComponentPropsType } from '../../components/QuestionComponents' import { produce } from 'immer' import { getNextSelectedId, insertNewComponent } from './utils' import { cloneDeep } from 'lodash' import { nanoid } from 'nanoid' // 每个组件的信息 export type componentInfoType = { // 组件的唯一标识 _id: string // 组件的名称 title: string // 组件的类型 type: string // 组件的属性 props: ComponentPropsType // 是否显示 isHidden?: boolean // 是否锁定 isLock?: boolean } export type ComponentsStateType = { // 当前选中的id selectedId: string // 问卷组件列表 componentsList: Array // 复制的组件信息 copiedComponent?: componentInfoType } // 数据的默认值 const INIT_STATE: ComponentsStateType = { selectedId: '', componentsList: [], copiedComponent: undefined } export const componentsSlice = createSlice({ name: 'components', initialState: INIT_STATE, reducers: { // 重置所有组件 resertComponents(state: ComponentsStateType, action: PayloadAction) { return action.payload }, // 设置当前选中的组件ID changeSelectedId: produce((draft: ComponentsStateType, action: PayloadAction) => { draft.selectedId = action.payload }), // 添加组件 addComponent: produce( (draft: ComponentsStateType, action: PayloadAction) => { const newComponent = action.payload insertNewComponent(draft, newComponent) } ), // 根据id修改组件属性 updateComponentProps: produce( ( draft: ComponentsStateType, action: PayloadAction<{ _id: string newProps: ComponentPropsType }> ) => { const { _id, newProps } = action.payload // 根据id找到对应的属性 const target = draft.componentsList.find(item => item._id === _id) if (target) { target.props = newProps } } ), // 删除某个题目 deleteComponent: produce((draft: ComponentsStateType) => { // 删除 const removeIndex = draft.componentsList.findIndex(item => item._id === draft.selectedId) // 自动选中下一个组件 draft.selectedId = getNextSelectedId(draft.selectedId, draft.componentsList) draft.componentsList.splice(removeIndex, 1) }), // 隐藏某个组件 hiddenComponent: produce( (draft: ComponentsStateType, action: PayloadAction<{ _id: string; isHidden: boolean }>) => { const { _id, isHidden } = action.payload const target = draft.componentsList.find(item => item._id === _id) if (target) { if (isHidden) { draft.selectedId = getNextSelectedId(_id, draft.componentsList) } else { // 显示组件的话,自动选中当前组件 draft.selectedId = _id } target.isHidden = isHidden } } ), // 切换锁定和解锁 toogleLock: produce((draft: ComponentsStateType, action: PayloadAction<{ _id: string }>) => { const { _id } = action.payload const target = draft.componentsList.find(item => item._id === _id) if (target) { target.isLock = !target.isLock } }), // 复制组件到剪切板 copyComponent: produce((draft: ComponentsStateType, action: PayloadAction<{ _id: string }>) => { // 获取当前的组件 const { _id } = action.payload const target = draft.componentsList.find(item => item._id === _id) if (target) { // 深拷贝 draft.copiedComponent = cloneDeep(target) } }), // 粘贴组件到当前选中组件下面 pasteComponent: produce((draft: ComponentsStateType) => { // 更新组件Id if (draft.copiedComponent) { draft.copiedComponent._id = nanoid() insertNewComponent(draft, draft.copiedComponent) } }), // 选中上一个 selectPrevComponent: produce((draft: ComponentsStateType) => { const { selectedId, componentsList } = draft const index = componentsList.findIndex(item => item._id === selectedId) // 如果已经是第一个了,则return if (index <= 0) return draft.selectedId = componentsList[index - 1]._id }), // 选中下一个 selectNextComponent: produce((draft: ComponentsStateType) => { const { selectedId, componentsList } = draft const index = componentsList.findIndex(item => item._id === selectedId) // 如果已经是最后一个了,则return if (index === componentsList.length - 1) return draft.selectedId = componentsList[index + 1]._id }), // 修改组件标题 changeComponentTitle: produce( ( draft: ComponentsStateType, action: PayloadAction<{ _id: string title: string }> ) => { const { _id, title } = action.payload const target = draft.componentsList.find(item => item._id === _id) if (target) { target.title = title } } ), // 获取排序后的组件列表 reorderComponents: produce( (draft: ComponentsStateType, action: PayloadAction>) => { draft.componentsList = action.payload } ) } }) export const { resertComponents, changeSelectedId, addComponent, updateComponentProps, deleteComponent, hiddenComponent, toogleLock, copyComponent, pasteComponent, selectPrevComponent, selectNextComponent, changeComponentTitle, reorderComponents } = componentsSlice.actions export default componentsSlice.reducer ``` src/store/componentsReducer/utils.ts ```ts import { componentInfoType, ComponentsStateType } from '.' export function getNextSelectedId(selectedId: string, componentsList: componentInfoType[]) { // 不计算被隐藏的 const visibleComponents = componentsList.filter(item => !item.isHidden) // 找到当前选中组件的索引 const index = visibleComponents.findIndex(item => item._id === selectedId) if (index >= 0) { // 如果只有一个组件,则清空选中组件 if (visibleComponents.length <= 1) return '' // 如果选中的是最后一个组件,选中上一个 if (index === visibleComponents.length - 1) return visibleComponents[index - 1]._id // 否则选中下一个组件 return visibleComponents[index + 1]._id } else { return '' } } export function insertNewComponent(draft: ComponentsStateType, newComponent: componentInfoType) { if (draft.selectedId) { const index = draft.componentsList.findIndex(item => item._id === draft.selectedId) draft.componentsList.splice(index + 1, 0, newComponent) } else { draft.componentsList.push(newComponent) } // 设置选中最新加进来的组件 draft.selectedId = newComponent._id } ``` ## 使用dnd-kit实现拖拽排序 ### 效果展示 ![gifimg4](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/gifimg4.gif) [实现源码](https://gitee.com/szxio/react-wenjaunxing/blob/master/src/pages/question/Layerlib.tsx) ### 安装依赖 dad-kit [github地址](https://github.com/clauderic/dnd-kit) ```sh yarn add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @dnd-kit/modifiers ``` 这几个包的作用 - @dnd-kit/core:核心库,提供基本的拖拽功能。 - @dnd-kit/sortable:扩展库,提供排序功能和工具。 - @dnd-kit/modifiers:修饰库,提供拖拽行为的限制和修饰功能。 - @dnd-kit/utilities:工具库,提供 CSS 和实用工具函数。上述演示的平滑移动的样式就是来源于这个包。 ### 实现代码 最外层使用 DndContext 组件,然后内层使用 SortableContext,并且传入列表信息,SortableContext 内层正常显示循环出来的DOM元素 ```tsx import React, { ChangeEvent, FC, useState } from 'react' import useGetComponentInfo from '../../hooks/useGetComponentInfo' import { Button, Flex, Input, message, Space } from 'antd' import style from './Layerlib.module.scss' import { EyeInvisibleOutlined, LockOutlined, DragOutlined } from '@ant-design/icons' import classNames from 'classnames' import { useDispatch } from 'react-redux' import { changeSelectedId, changeComponentTitle, componentInfoType, hiddenComponent, toogleLock, reorderComponents } from '../../store/componentsReducer' import type { DragEndEvent, DragMoveEvent } from '@dnd-kit/core' import { DndContext } from '@dnd-kit/core' import { arrayMove, SortableContext, rectSortingStrategy, useSortable } from '@dnd-kit/sortable' import { restrictToParentElement } from '@dnd-kit/modifiers' import { CSS } from '@dnd-kit/utilities' export default function Layerlib() { // 记录当前正在修改的id const [changingId, setChangingId] = useState('') const { componentsList, selectedId } = useGetComponentInfo() const dispatch = useDispatch() // 点击组件 function handleClick(component: componentInfoType) { const { _id, isHidden } = component if (isHidden) { message.error('不能选中被隐藏的组件') return } if (_id !== selectedId) { dispatch(changeSelectedId(_id)) setChangingId('') } else { setChangingId(_id) } } // 修改组件标题 function handleChangeTitle(event: ChangeEvent, id: string) { const value = event.target.value.trim() if (!value) return dispatch(changeComponentTitle({ _id: id, title: value })) } // 切换组件隐藏和显示 function handleToggleHidden(component: componentInfoType) { const { _id, isHidden } = component dispatch(hiddenComponent({ _id, isHidden: !isHidden })) } // 切换锁定 function handleToggleLock(component: componentInfoType) { const { _id } = component dispatch(toogleLock({ _id })) } // 拖动排序 const getMoveIndex = (array: componentInfoType[], dragItem: DragMoveEvent) => { const { active, over } = dragItem const activeIndex = array.findIndex(item => item._id === active.id) const overIndex = array.findIndex(item => item._id === over?.id) // 处理未找到索引的情况 return { activeIndex: activeIndex !== -1 ? activeIndex : 0, overIndex: overIndex !== -1 ? overIndex : activeIndex } } // 拖动结束 const dragEndEvent = (dragItem: DragEndEvent) => { const { active, over } = dragItem if (!active || !over) return // 处理边界情况 const moveDataList = [...componentsList] const { activeIndex, overIndex } = getMoveIndex(moveDataList, dragItem) if (activeIndex !== overIndex) { const newDataList = arrayMove(moveDataList, activeIndex, overIndex) // 关键:更新一下最新的组件数据 dispatch(reorderComponents(newDataList)) } } type DraggableItemProps = { component: componentInfoType } // 拖拽的子组件 const DraggableItem: FC = ({ component }) => { const { _id, isLock, isHidden } = component const { setNodeRef, attributes, listeners, transform, transition } = useSortable({ id: _id, transition: { duration: 500, easing: 'cubic-bezier(0.25, 1, 0.5, 1)' } }) const styles = { transform: CSS.Transform.toString(transform), transition } // 动态样式 const componentClass = classNames({ [style.check]: _id === selectedId, [style.hidden]: isHidden }) return (
handleClick(component)}> {changingId === _id ? ( handleChangeTitle(event, component._id)} onPressEnter={() => setChangingId('')} onBlur={() => setChangingId('')} /> ) : ( component.title )}
{/* 拖动排序 */} ) } ``` ### 动态路由 [官方文档](https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes) 新建 `src\app\question\[id]\page.tsx` 文件,使用方括号包裹一个变量名作为文件夹,这个变量名要和代码中使用的名字对应 ```tsx type propsType = { params: Promise<{ id: string }> } export default async function Page({ params }: propsType) { // 这里使用的名字需要和[id]名保持一致 const { id } = await params return
QuestionId: {id}
} ``` 访问 http://localhost:3000/question/123456 ![image-20250415103941990](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250415103941990.png) ### Next中使用Antd示例 https://github.com/ant-design/ant-design-examples/tree/main/examples/with-nextjs-app-router-inline-style) ## React.mome优化子组件渲染 ### React.memo 的含义 `React.memo` 的作用是:如果子组件的 props 没有变化,则跳过该组件的重新渲染,直接复用之前的渲染结果。 语法如下: ```js const MemoizedComponent = React.memo(function MyComponent(props) { // 组件逻辑 }); ``` - 如果父组件重新渲染,但传递给子组件的 props 没有变化,`React.memo` 会阻止子组件的重新渲染。 - 默认情况下,`React.memo` 使用浅比较(shallow comparison)来判断 props 是否发生变化。 ------ ### 使用场景 `React.memo` 主要用于以下场景: 1. **性能优化** :当子组件接收的 props 没有变化时,避免不必要的重新渲染。 2. **复杂组件树** :在大型组件树中,某些子组件可能不需要频繁更新,使用 `React.memo` 可以减少渲染开销。 ------ ### 示例代码 #### 未使用 `React.memo` 的情况 假设我们有一个父组件和一个子组件,每次父组件重新渲染时,子组件也会重新渲染,即使子组件的 props 没有变化。 ```tsx import React, { useState } from 'react'; function ChildComponent({ text }) { console.log('Child re-rendered'); return
{text}
; } function ParentComponent() { const [count, setCount] = useState(0); return (

Parent Component

Count: {count}

); } export default ParentComponent; ``` 在这个例子中,即使 `ChildComponent` 的 `text` 属性没有变化,每次点击按钮时,`ChildComponent` 都会重新渲染。 ![image-20250417145923978](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250417145923978.png) ------ #### 使用 `React.memo` 的情况 通过将 `ChildComponent` 包裹在 `React.memo` 中,可以避免不必要的重新渲染。 ```tsx import React, { useState, memo } from 'react' // 使用 React.memo 包裹子组件 const ChildComponent = memo(({ text }: { text: string }) => { console.log('Child re-rendered') // Uncomment for debugging return
{text}
}) ChildComponent.displayName = 'ChildComponent' function ParentComponent() { const [count, setCount] = useState(0) return (

Parent Component

Count: {count}

) } export default ParentComponent ``` 在这个例子中,点击按钮时,`ChildComponent` 不会重新渲染,因为它的 `text` 属性没有变化。 ![image-20250417150352557](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250417150352557.png) ------ ### 自定义比较逻辑 默认情况下,`React.memo` 使用浅比较来判断 props 是否发生变化。如果你需要自定义比较逻辑,可以通过第二个参数提供一个比较函数。 示例: ```tsx import React, { useState, memo } from 'react'; // 自定义比较逻辑 const ChildComponent = memo(({ text }) => { console.log('Child re-rendered'); return
{text}
; }, (prevProps, nextProps) => { // 如果前后两次的 text 相同,则认为 props 没有变化 return prevProps.text === nextProps.text; }); function ParentComponent() { const [count, setCount] = useState(0); return (

Parent Component

Count: {count}

); } export default ParentComponent; ``` 在这个例子中,我们通过自定义比较函数确保只有在 `text` 发生变化时,`ChildComponent` 才会重新渲染。 ------ ### 注意事项 1. **浅比较的限制** : - `React.memo` 默认只进行浅比较。如果 props 是对象或数组,即使内容相同,但由于引用不同,仍然会被认为是不同的 props。 - 解决方法:确保传递的是不可变数据,或者使用自定义比较函数。 2. **不适合所有场景** : - 如果子组件的渲染成本很低,使用 `React.memo` 可能会导致额外的性能开销(例如执行浅比较)。 - 在简单组件中,通常不需要使用 `React.memo`。 3. **与 `useMemo` 和 `useCallback` 的配合** : - 如果子组件依赖于父组件中的函数或对象,可以使用 `useCallback` 或 `useMemo` 来确保这些值不会频繁变化。 ------ ### 实际应用案例-列表渲染优化 假设我们有一个包含大量子组件的列表,每次父组件重新渲染时,都会导致所有子组件重新渲染。通过使用 `React.memo`,可以避免不必要的渲染。 ```tsx import React, { useState, memo } from 'react'; // 子组件 const ListItem = memo(({ item }) => { console.log(`Rendering ${item}`); return
  • {item}
  • ; }); function App() { const [list, setList] = useState(['Item 1', 'Item 2', 'Item 3']); const [count, setCount] = useState(0); return (

    Count: {count}

      {list.map((item, index) => ( ))}
    ); } export default App; ``` 在这个例子中,点击按钮时,`ListItem` 不会重新渲染,因为它的 `item` 属性没有变化。 ------ ### 总结 - `React.memo` 是一个强大的工具,用于优化函数式组件的性能。 - 它通过浅比较 props 来决定是否重新渲染子组件。 - 在实际开发中,合理使用 `React.memo` 可以显著提升应用性能,尤其是在复杂的组件树中。 - 但需要注意浅比较的限制,并根据具体场景选择是否使用 `React.memo`。 ## 打包体积分析和优化 ### source-map-explorer [官网文档](https://create-react-app.bootcss.com/docs/analyzing-the-bundle-size) [源映射浏览器](https://www.npmjs.com/package/source-map-explorer)使用源映射分析 JavaScript 包。这有助于您了解代码膨胀的来源。 ```sh yarn add source-map-explorer ``` 然后在 中`package.json`添加以下行`scripts`: ```sh "scripts": { + "analyze": "source-map-explorer 'build/static/js/*.js'", "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", ``` 然后分析捆绑包,运行生产构建,然后运行分析脚本。 ```sh yarn build yarn analyze ``` ![image-20250417152530804](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250417152530804.png) 通过分析后发现main文件有1.98M,我们加载首屏时,有很多不需要的包也被加载了进来 浏览器加载main.js 文件,经过 gzip 压缩后仍有600多KB的大小 ![image-20250417160037088](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250417160037088.png) ### 路由懒加载 修改部分页面的路由导入方式 ```js // import Edit from '../pages/question/Edit' // import Stat from '../pages/question/Stat' // 路由懒加载 const Edit = lazy(() => import(/* webpackChunkName: "question-edit" */ '../pages/question/Edit')); const Stat = lazy(() => import(/* webpackChunkName: "question-stat" */'../pages/question/Stat')); ``` 再次观察打包结果 ![image-20250417153849003](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250417153849003.png) 可以看到main文件减少到了1.3M ### 抽离公共组件 修改 `craco.config.js` 文件,添加打包配置 ```js module.exports = { webpack: { configure(webpackConfig) { // 判断环境 if (webpackConfig.mode === 'production') { webpackConfig.optimization.splitChunks = { chunks: 'all', cacheGroups: { antd: { name: 'antd-chunk', test: /antd/, priority: 10 // 权重 }, reactDom: { name: 'reactDom-chunk', test: /react-dom/, priority: 9 // 权重 }, vendors: { name: 'vendors-chunk', test: /node_modules/, priority: 8 // 权重 } } } } return webpackConfig } }, devServer: { client: { overlay: false // 解决开发环境下axios返回Promise.reject()时,全屏显示错误的问题 }, proxy: { '/api': { target: 'http://localhost:5001', changeOrigin: true } } } } ``` 分析打包结果 ![image-20250417155709709](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250417155709709.png) 现在查看优化后的main函数只有10KB左右,我们从最初的 600KB 直接优化成 10KB,效果还是很明显的 ![image-20250417160006920](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250417160006920.png) ## Nest框架快速上手 ### 介绍 Nest 是一个用于构建高效,可扩展的 [Node.js](http://nodejs.cn/) 服务器端应用程序的框架。它使用渐进式 JavaScript,内置并完全支持 [TypeScript](https://www.tslang.cn/)(但仍然允许开发人员使用纯 JavaScript 编写代码)并结合了 OOP(面向对象编程),FP(函数式编程)和 FRP(函数式响应编程)的元素。 在底层,Nest 使用强大的 HTTP Server 框架,如 Express(默认)和 Fastify。Nest 在这些框架之上提供了一定程度的抽象,同时也将其 API 直接暴露给开发人员。这样可以轻松使用每个平台的无数第三方模块。 ### 安装 [官网地址](https://github.com/nestjs/nest) [中文文档地址](https://docs.nestjs.cn/11/introduction) ```sh npm i -g @nestjs/cli nest new project-name ``` 启动项目 ```sh yarn start:dev ``` 访问默认的地址 http://localhost:3000 ![image-20250418112000178](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250418112000178.png) 我们找到 src\app.service.ts 文件,修改一下代码 ```ts import { Injectable } from '@nestjs/common' @Injectable() export class AppService { getHello(): string { return 'Hello Nest!' } } ``` 然后刷新地址,可以看到返回值已经改变了 ![image-20250418112123767](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250418112123767.png) ### 修改默认端口 方式一: 找到 main.ts 文件,直接修改默认的启动端口,如图 ![image-20250418112220857](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250418112220857.png) 方式二: 通过配置文件的方式来修改 安装 ```sh yanr add @nestjs/config ``` 然后修改 app.module.ts 文件 ```ts import { Module } from '@nestjs/common' import { AppController } from './app.controller' import { AppService } from './app.service' import { ConfigModule } from '@nestjs/config' @Module({ imports: [ // 配置模块,用于读取配置文件 ConfigModule.forRoot({ isGlobal: true // 全局可用 }) ], controllers: [AppController], providers: [AppService] }) export class AppModule {} ``` ![image-20250418112531868](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250418112531868.png) 接着在根目录新建 .env 文件 ``` PORT=3003 ``` 重启项目,此时的默认端口就变成了3003 ### 创建question模块和路由 在项目目录下依次执行如下命令 ```sh nest g module question nest g controller question --no-spec nest g service question --no-spec ``` - `--no-spec` 的意思是不要生成测试文件 执行完毕后,可以看到自动生成了这三个文件 ![image-20250418113403532](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250418113403532.png) 然后 app.module.ts 中也自动帮我们引入了 QuestionModule ```ts import { Module } from '@nestjs/common' import { AppController } from './app.controller' import { AppService } from './app.service' import { ConfigModule } from '@nestjs/config' import { QuestionModule } from './question/question.module' @Module({ imports: [ // 配置模块,用于读取配置文件 ConfigModule.forRoot({ isGlobal: true // 全局可用 }), QuestionModule ], controllers: [AppController], providers: [AppService] }) export class AppModule {} ``` 接下来我们来创建一个 question/list 接口 先在 question.service.ts 文件中新增一个 findAll 方法,模拟返回一些数据 ```ts import { Injectable } from '@nestjs/common' @Injectable() export class QuestionService { findAll() { return [ { id: 1, title: '输入框', type: 'QuestionInput', props: { text: '姓名', placeholder: '请输入姓名' } } ] } } ``` 然后在 question.controller.ts 文件中引入 service 并定义 get 请求,返回 findAll 方法的返回值 ```ts import { Controller, Get } from '@nestjs/common' import { QuestionService } from './question.service' @Controller('question') export class QuestionController { constructor(private readonly questionService: QuestionService) {} @Get('findAll') findAll() { return this.questionService.findAll() } } ``` 做完这些后,来访问一下这个接口 http://localhost:3003/question/findAll ![image-20250418114002850](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250418114002850.png) 可以看到,已经正常返回了 ### 获取请求参数 #### Param路径参数 question.controller.ts 文件新增接口 ```ts @Get('getQuestionInfo/:id') getQuestionInfo(@Param('id') id: string) { console.log('路径参数:', id) return id } ``` 接口格式:question/getQuestionInfo/123 ![image-20250418115348152](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250418115348152.png) #### Query路由参数 ```ts @Get('getQuestionList') getQuestionList( @Query('pageNum') pageNum: number, @Query('pageSize') pageSize: number ) { console.log('pageNum:', pageNum) console.log('pageSize', pageSize) return { pageNum, pageSize } } ``` 接口格式:question/getQuestionList?pageNum=1&pageSize=10 ![image-20250418115716585](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250418115716585.png) #### Body请求体参数 首先定义DTO,新建 src\question\dto\Question.ts ```tsx export class Question { readonly title: string readonly type: string } ``` 然后定义接口 ```ts @Post('updateQuestion') updateQuestion(@Body() question: Question) { console.log('body参数' + JSON.stringify(question)) return question } ``` 接口格式:POST /question/updateQuestion ![image-20250418132646206](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250418132646206.png) ### 连接Mongodb数据库 首先需要安装所需依赖 ```sh yarn add @nestjs/mongoose mongoose ``` 然后在 AppModule 中引入 MongooseModule ```ts import { Module } from '@nestjs/common' import { AppController } from './app.controller' import { AppService } from './app.service' import { ConfigModule } from '@nestjs/config' import { QuestionModule } from './question/question.module' import { MongooseModule } from '@nestjs/mongoose' @Module({ imports: [ // 配置模块,用于读取配置文件 ConfigModule.forRoot({ isGlobal: true // 全局可用 }), // mongodb模块 MongooseModule.forRoot(`${process.env.MONGO_URL}`), // 自定义的Model模块 QuestionModule ], controllers: [AppController], providers: [AppService] }) export class AppModule {} ``` 在 .env 文件中添加配置 ```conf PORT=3003 MONGO_URL=mongodb://admin:abc123@127.0.0.1:27017/wenjuan?authSource=admin ``` > 要想正常使用 process.env,必须安装 @nestjs/config 然后启动项目查看是否连接成功 ![image-20250418144142907](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250418144142907.png) ### 定义Question的Schema模块 #### 步骤一:新建schema 新建 `src\question\question.schema.ts` ```ts import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' import { Document } from 'mongoose' export type QuestionDocument = Question & Document @Schema() export class Question extends Document { @Prop({ required: true }) userId: string @Prop() title: string @Prop() publish: boolean @Prop() star: boolean @Prop() createTime: string @Prop() isDelete: number @Prop() description: string @Prop() jsCode: string @Prop() cssCode: string } export const QuestionSchema = SchemaFactory.createForClass(Question) // 显式指定 MongoDB 中的集合名为 'question' QuestionSchema.set('collection', 'question') ``` 我们在最后添加了 `QuestionSchema.set` 表示指定 question 集合,否则根据 Nest 的规则,会自动连接到 **小写 + 复数** 对应的集合中 例如我们定义的类名是 Question ,如果没有写 QuestionSchema.set 时,会自动帮我们连接到 questions 这个集合中 #### 步骤二:注入Question 在 Model 中通过 MongooseModule 注入Question `src\question\question.module.ts` ```ts import { Module } from '@nestjs/common' import { QuestionController } from './question.controller' import { QuestionService } from './question.service' import { MongooseModule } from '@nestjs/mongoose' import { Question, QuestionSchema } from './question.schema' @Module({ imports: [ MongooseModule.forFeature([{ name: Question.name, schema: QuestionSchema }]) ], controllers: [QuestionController], providers: [QuestionService] }) export class QuestionModule {} ``` #### 步骤三:在Service中注入 在service中注入 `src\question\question.service.ts` ```ts import { Injectable } from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { Model } from 'mongoose' import { Question, QuestionDocument } from './question.schema' @Injectable() export class QuestionService { constructor( @InjectModel(Question.name) private questionModel: Model ) {} // 查询所有问题 async findAll(): Promise { return await this.questionModel.find() } } ``` 我们操作数据的逻辑都写到 service 文件中,然后吧得到的数据返回,在 Controller 中通过调用 Service 中的方法,把数据返回给浏览器 #### 步骤四:Controller中使用 在Controller中引入Service ```ts import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common' import { QuestionService } from './question.service' import { Question } from './dto/Question' @Controller('question') export class QuestionController { constructor(private readonly questionService: QuestionService) {} @Get('findAll') findAll() { return this.questionService.findAll() } } ``` 现在访问 http://localhost:3003/question/findAll ,可以看到已经把数据库中数据查出并返回了 ![image-20250418162541928](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250418162541928.png) ### 统一错误过滤器 当我们编写的代码发生错误时,我们希望有一个统一的错误返回结果 新建 `src\exception\AllExceptionsFilter.ts` ```ts import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common' import { Request, Response } from 'express' @Catch() export class AllExceptionsFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp() const response = ctx.getResponse() const request = ctx.getRequest() let status = HttpStatus.INTERNAL_SERVER_ERROR let message = '服务器内部错误' if (exception instanceof HttpException) { status = exception.getStatus() const res = exception.getResponse() as { message?: string } | string message = typeof res === 'string' ? res : res?.message || message } response.status(status).json({ code: status, message, path: request.url, timestamp: new Date().toISOString() }) } } ``` 注册全局过滤器 修改 `src\main.ts` 添加 ` app.useGlobalFilters(new AllExceptionsFilter())` ```ts import { NestFactory } from '@nestjs/core' import { AppModule } from './app.module' import { TokenGuard } from './guards/TokenGuard' import { AllExceptionsFilter } from './exception/AllExceptionsFilter' async function bootstrap() { const app = await NestFactory.create(AppModule) // 添加全局Token守卫 app.useGlobalGuards(new TokenGuard()) app.useGlobalFilters(new AllExceptionsFilter()) await app.listen(process.env.PORT ?? 3000, () => { console.log(`Server is running on port ${process.env.PORT ?? 3000}`) }) } bootstrap() ``` 此时我们故意编写一个错误的代码 ```ts @Get('findAll') findAll() { return a + b } ``` 然后访问一下 ![image-20250418141247144](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250418141247144.png) ### 实现JWT验证和全局路由守卫 #### 安装JWT和目录创建 [官方文档](https://docs.nestjs.com/security/authentication#creating-an-authentication-module) 首先安装 jwt 相关包 ```sh yarn add @nestjs/jwt ``` 接着新建一个 user 模块 ```sh nest g module users nest g controller users nest g service users ``` users模块目录结构如下 ![image-20250423170330117](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250423170330117.png) #### 实现登录接口 首先编写 `users.schema.ts`,定义字段结构 ```ts import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' import { Document } from 'mongoose' export type UserDocument = User & Document @Schema() export class User extends Document { @Prop({ required: true }) userName: string @Prop() passWord: string @Prop() createTime: string } export const UserSchema = SchemaFactory.createForClass(User) // 显式指定 MongoDB 中的集合名为 'userlist' UserSchema.set('collection', 'userlist') ``` 编写 `dto/User.ts` ```ts export class User { readonly userName: string readonly passWord: string readonly createTime: string } ``` 然后在 `users.service.ts` 文件中编写登录逻辑 ```ts import { HttpException, HttpStatus, Injectable } from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { User, UserDocument } from './users.schema' import { Model } from 'mongoose' import { JwtService } from '@nestjs/jwt' @Injectable() export class UsersService { constructor( @InjectModel(User.name) private readonly userModel: Model, private jwtService: JwtService ) {} /** * 登录方法 */ async login(User: User): Promise { // 1.先查询用户名是否存在 const user = await this.userModel.findOne({ userName: User.userName }) if (!user) { throw new HttpException('用户不存在', HttpStatus.BAD_REQUEST) } // 2.判断密码是否正确 if (user.passWord !== User.passWord) { throw new HttpException('密码错误', HttpStatus.BAD_REQUEST) } // 3.生成tokne const payload = { userName: user.userName, sub: user._id?.toString() } return this.jwtService.signAsync(payload) } } ``` 接着在 `users.controller.ts` 中使用 service 提供的登录方法 ```ts import { Body, Controller, Post } from '@nestjs/common' import { UsersService } from './users.service' import { Result } from 'src/utils/result' import { User } from './users.schema' import { Public } from 'src/metadata/isPublicMetaData' @Controller('system') export class UsersController { constructor(private readonly userService: UsersService) {} @Public() @Post('login') async login(@Body() userInfo: User): Promise> { const token = await this.userService.login(userInfo) return Result.success(token) } } ``` 然后在 `users.module.ts` 中注册 controller 和 service ```ts import { Module } from '@nestjs/common' import { MongooseModule } from '@nestjs/mongoose' import { UsersService } from './users.service' import { User, UserSchema } from './users.schema' import { UsersController } from './users.controller' @Module({ imports: [ MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]) ], controllers: [UsersController], providers: [UsersService] }) export class UsersModule {} ``` #### 全局路由守卫 新增 `src\guards\TokenGuard.ts` 文件 ```ts import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common' import { Request } from 'express' import { JwtService } from '@nestjs/jwt' import { IS_PUBLIC_KEY } from 'src/metadata/isPublicMetaData' import { Reflector } from '@nestjs/core' import { User } from 'src/users/dto/User' @Injectable() export class TokenGuard implements CanActivate { constructor( private jwtService: JwtService, private reflector: Reflector ) {} async canActivate(context: ExecutionContext): Promise { // 判断是否是公开接口,根据自定义的元数据判断 const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ context.getHandler(), context.getClass() ]) if (isPublic) { return true } const request: Request = context.switchToHttp().getRequest() const token: string | undefined = (request.headers['token'] as string) || (request.headers['authorization'] as string) if (!token) { throw new UnauthorizedException('请求头缺少 Token') } try { // 验证 token const payload = await this.jwtService.verifyAsync(token, { secret: process.env.SECRET }) console.log(payload) // 将解码后的用户信息添加到请求对象中 request['user'] = payload } catch { throw new UnauthorizedException('无效的 Token') } return true } } ``` 用到了自定义注解 新建 `src/metadata/isPublicMetaData` 实现 Public 注解 ```ts import { SetMetadata } from '@nestjs/common' export const IS_PUBLIC_KEY = 'isPublic' export const Public = () => SetMetadata(IS_PUBLIC_KEY, true) ``` 在配置文件 `.env` 中添加 SECRET 配置 ```sh SECRET=abc123 ``` #### 注册路由守卫到全局 修改 `app.module.ts` ,将 JwtModule,TokenGuard,AllExceptionsFilter 都通过providers的方法注册进来 ```ts import { Module } from '@nestjs/common' import { JwtModule } from '@nestjs/jwt' import { AppController } from './app.controller' import { AppService } from './app.service' import { ConfigModule } from '@nestjs/config' import { QuestionModule } from './question/question.module' import { MongooseModule } from '@nestjs/mongoose' import { UsersModule } from './users/users.module' import { TokenGuard } from './guards/TokenGuard' import { APP_FILTER, APP_GUARD } from '@nestjs/core' import { AllExceptionsFilter } from './exception/AllExceptionsFilter' @Module({ imports: [ // 配置模块,用于读取配置文件 ConfigModule.forRoot({ isGlobal: true // 全局可用 }), // mongodb模块 MongooseModule.forRoot(`${process.env.MONGO_URL}`), // jwt模块 JwtModule.register({ global: true, // 全局可用 secret: process.env.SECRET, signOptions: { expiresIn: '72h' } // token 过期时间 }), // 自定义的Model模块 QuestionModule, UsersModule ], controllers: [AppController], providers: [ AppService, // 添加全局守卫 { provide: APP_GUARD, useClass: TokenGuard }, // 全局异常处理 { provide: APP_FILTER, useClass: AllExceptionsFilter } ] }) export class AppModule {} ``` 然后修改原本的全局注册守卫的方法 修改 `main.ts`,删掉原本的 app.useGloba ```ts import { NestFactory } from '@nestjs/core' import { AppModule } from './app.module' async function bootstrap() { const app = await NestFactory.create(AppModule) await app.listen(process.env.PORT ?? 3000, () => { console.log(`Server is running on port ${process.env.PORT ?? 3000}`) }) } bootstrap() ``` #### 测试一下 首先输入错误的账户名和密码 ![image-20250423171842053](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250423171842053.png) ![image-20250423171914024](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250423171914024.png) 然后输入正确的密码 ![image-20250423171940163](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250423171940163.png) 获取token后请求其他接口 ![image-20250423172008881](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250423172008881.png) 这时如果手动吧token改掉 ![image-20250423172044643](https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20250423172044643.png) ### 完成注册和查询接口 修改 `src\users\users.service.ts` 添加注册和查询逻辑 ```ts import { HttpException, HttpStatus, Injectable } from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { User, UserDocument } from './users.schema' import { Model } from 'mongoose' import { JwtService } from '@nestjs/jwt' import { formatDate } from 'src/utils' @Injectable() export class UsersService { constructor( @InjectModel(User.name) private readonly userModel: Model, private jwtService: JwtService ) {} /** * 登录方法 */ async login(User: User): Promise { // 1.先查询用户名是否存在 const user = await this.userModel.findOne({ userName: User.userName }) if (!user) { throw new HttpException('用户不存在', HttpStatus.BAD_REQUEST) } // 2.判断密码是否正确 if (user.passWord !== User.passWord) { throw new HttpException('密码错误', HttpStatus.BAD_REQUEST) } // 3.生成tokne const payload = { userName: user.userName, sub: user._id?.toString() } return this.jwtService.signAsync(payload) } /** * 注册方法 */ async register(User: User) { // 1.先查询用户名是否存在 const user = await this.userModel.findOne({ userName: User.userName }) if (user) { throw new HttpException('用户已存在', HttpStatus.BAD_REQUEST) } User.createTime = formatDate() // 2.创建用户 await this.userModel.insertOne(User) } /** * 查询用户信息 */ async getUserInfo(_id: string) { const userInfo = await this.userModel.findOne({ _id }) return userInfo } } ``` 修改 `src\users\users.controller.ts` ,添加接口地址 ```ts import { Body, Controller, Get, Post, Query } from '@nestjs/common' import { UsersService } from './users.service' import { Result } from 'src/utils/result' import { User } from './users.schema' import { Public } from 'src/metadata/isPublicMetaData' @Controller('system') export class UsersController { constructor(private readonly userService: UsersService) {} /** * 登录 */ @Public() @Post('login') async login(@Body() userInfo: User): Promise> { const token = await this.userService.login(userInfo) return Result.success({ token }) } /** * 注册 */ @Public() @Post('register') async register(@Body() userInfo: User): Promise> { await this.userService.register(userInfo) return Result.success(null) } /** * 根据用户id查询用户信息 */ @Get('userInfo') async getUserInfo(@Query() _id: string): Promise> { const userInfo = await this.userService.getUserInfo(_id) return Result.success(userInfo) } } ```