前端实战|React18极客园——登陆模块(token持久化路由拦截mobx封装axios)

Posted codeMak1r.小新

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了前端实战|React18极客园——登陆模块(token持久化路由拦截mobx封装axios)相关的知识,希望对你有一定的参考价值。

欢迎来到我的博客
📔博主是一名大学在读本科生,主要学习方向是前端。
🍭目前已经更新了【Vue】、【React–从基础到实战】、【TypeScript】等等系列专栏
🛠目前正在学习的是🔥 R e a c t 框架 React框架 React框架🔥,中间穿插了一些基础知识的回顾
🌈博客主页👉codeMak1r.小新的博客

😇本文目录😇

本文被专栏【React–从基础到实战】收录
🕹坚持创作✏️,一起学习📖,码出未来👨🏻‍💻!

最近在学习React过程中,找到了一个实战小项目,在这里与大家分享。
本文遵循项目开发流程,逐步完善各个需求
前文——《项目前置准备》

登陆模块

1.基本结构模块

本节目标: 能够使用antd搭建基础布局

实现步骤

  1. 在 Login/index.js 中创建登录页面基本结构
  2. 在 Login 目录中创建 index.scss 文件,指定组件样式
  3. 将 logo.png 和 login.png 拷贝到 assets 目录中

代码实现

pages/Login/index.js

import  Card  from 'antd'
import logo from '@/assets/logo.png'
import './index.scss'

const Login = () => 
  return (
    <div className="login">
      <Card className="login-container">
        <img className="login-logo" src=logo alt="" />
        /* 登录表单 */
      </Card>
    </div>
  )


export default Login

pages/Login/index.scss

.login 
  width: 100%;
  height: 100%;
  position: absolute;
  left: 0;
  top: 0;
  background: center/cover url('~@/assets/login.png');
  
  .login-logo 
    width: 200px;
    height: 60px;
    display: block;
    margin: 0 auto 20px;
  
  
  .login-container 
    width: 440px;
    height: 360px;
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    box-shadow: 0 0 50px rgb(0 0 0 / 10%);
  
  
  .login-checkbox-label 
    color: #1890ff;
  

2. 创建表单结构

本节目标: 能够使用antd的Form组件创建登录表单

实现步骤

  1. 打开 antd Form 组件文档
  2. 找到代码演示的第一个示例(基本使用),点击<>(显示代码),并拷贝代码到组件中
  3. 分析 Form 组件基本结构
  4. 调整 Form 组件结构和样式

代码实现

pages/Login/index.js

import  Form, Input, Button, Checkbox  from 'antd'
const Login = () => 
  return (
    <Form>
      <Form.Item>
        <Input size="large" placeholder="请输入手机号" />
      </Form.Item>
      <Form.Item>
        <Input size="large" placeholder="请输入验证码" />
      </Form.Item>
      <Form.Item>
        <Checkbox className="login-checkbox-label">
          我已阅读并同意「用户协议」和「隐私条款」
        </Checkbox>
      </Form.Item>

      <Form.Item>
        <!-- 渲染Button组件为submit按钮 -->
        <Button type="primary" htmlType="submit" size="large" block>
          登录
        </Button>
      </Form.Item>
    </Form>
  )

3. 表单校验实现

本节目标: 能够为手机号和密码添加表单校验

实现步骤

  1. 为 Form 组件添加 validateTrigger 属性,指定校验触发时机的集合
  2. 为 Form.Item 组件添加 name 属性,这样表单校验才会生效
  3. 为 Form.Item 组件添加 rules 属性,用来添加表单校验

代码实现

page/Login/index.js

const Login = () => 
  return (
    <Form validateTrigger=['onBlur', 'onChange']>
      <Form.Item
        name="mobile"
        rules=[
          
            pattern: /^1[3-9]\\d9$/,
            message: '手机号码格式不对',
            validateTrigger: 'onBlur'
          ,
           required: true, message: '请输入手机号' 
        ]
      >
        <Input size="large" placeholder="请输入手机号" />
      </Form.Item>
      <Form.Item
        name="code"
        rules=[
           len: 6, message: '验证码6个字符', validateTrigger: 'onBlur' ,
           required: true, message: '请输入验证码' 
        ]
      >
        <Input size="large" placeholder="请输入验证码" maxLength=6 />
      </Form.Item>
      <Form.Item name="remember" valuePropName="checked">
        <Checkbox className="login-checkbox-label">
          我已阅读并同意「用户协议」和「隐私条款」
        </Checkbox>
      </Form.Item>

      <Form.Item>
        <Button type="primary" htmlType="submit" size="large" block>
          登录
        </Button>
      </Form.Item>
    </Form>
  )

4. 获取登录表单数据

本节目标: 能够拿到登录表单中用户的手机号码和验证码

实现步骤

  1. 为 Form 组件添加 onFinish 属性,该事件会在点击登录按钮时触发
  2. 创建 onFinish 函数,通过函数参数 values 拿到表单值
  3. Form 组件添加 initialValues 属性,来初始化表单值

代码实现

pages/Login/index.js

// 点击登录按钮时触发 参数values即是表单输入数据
const onFinish = values => 
  console.log(values)


<Form
  onFinish= onFinish 
  initialValues=
    mobile: '13911111111',
    code: '246810',
    remember: true
  
>...</Form>

5. 封装http工具模块

本节目标: 封装axios,简化操作

实现步骤

  1. 创建 utils/http.js 文件
  2. 创建 axios 实例,配置 baseURL,请求拦截器,响应拦截器
  3. 在 utils/index.js 中,统一导出 http

代码实现

utils/http.js

import axios from 'axios'

const http = axios.create(
  baseURL: 'http://geek.itheima.net/v1_0',
  timeout: 5000
)
// 添加请求拦截器
http.interceptors.request.use((config)=> 
    return config
  , (error)=> 
    return Promise.reject(error)
)

// 添加响应拦截器
http.interceptors.response.use((response)=> 
    // 2xx 范围内的状态码都会触发该函数。
    // 对响应数据做点什么
    return response
  , (error)=> 
    // 超出 2xx 范围的状态码都会触发该函数。
    // 对响应错误做点什么
    return Promise.reject(error)
)

export  http 

utils/index.js

import  http  from './http'
export   http 

6. 配置登录Mobx

本节目标: 基于mobx封装管理用户登录的store

store/login.Store.js

// 登录模块
import  makeAutoObservable  from "mobx"
import  http  from '@/utils'

class LoginStore 
  token = ''
  constructor() 
    makeAutoObservable(this)
  
  // 登录
  login = async ( mobile, code ) => 
    const res = await http.post('http://geek.itheima.net/v1_0/authorizations', 
      mobile,
      code
    )
    this.token = res.data.token
  

export default LoginStore

store/index.js

import React from "react"
import LoginStore from './login.Store'

class RootStore 
  // 组合模块
  constructor() 
    this.loginStore = new LoginStore()
  

// 导入useStore方法供组件使用数据
const StoresContext = React.createContext(new RootStore())
export const useStore = () => React.useContext(StoresContext)

7. 实现登录逻辑

本节目标: 在表单校验通过之后通过封装好的store调用登录接口

实现步骤

  1. 使用useStore方法得到loginStore实例对象
  2. 在校验通过之后,调用loginStore中的login函数
  3. 登录成功之后跳转到首页

代码实现

import  useStore  from '@/store'
const onFinish = async (values) => 
    // 存储登录成功的token
    try 
      await loginStore.setToken(values)
      navigate('/',  replace: true )
      message.success('At Your Service, Sir!', 2)
     catch (error) 
      message.error(error.response?.data?.message || '登录失败')
    
  ;
  const onFinishFailed = (errorInfo) => 
    const [name] = errorInfo.errorFields[0].name
    if (name === "captcha") message.error('登录失败,请检查验证码是否有误!', 2);
    if (name === "tel") message.error('登录失败,请检查手机号是否有误!', 2);
  
  return (...)

8. token持久化

封装工具函数

本节目标: 能够统一处理 token 的持久化相关操作,确保刷新后 token 不丢失。

实现步骤

  1. 创建 utils/token.js 文件
  2. 分别提供 getToken/setToken/clearToken/isAuth 四个工具函数并导出
  3. 创建 utils/index.js 文件,统一导出 token.js 中的所有内容,来简化工具函数的导入
  4. 将登录操作中用到 token 的地方,替换为该工具函数

代码实现

utils/token.js

const TOKEN_KEY = 'geek_pc'

const getToken = () => localStorage.getItem(TOKEN_KEY)
const setToken = token => localStorage.setItem(TOKEN_KEY, token)
const clearToken = () => localStorage.removeItem(TOKEN_KEY)

export  getToken, setToken, clearToken 

持久化设置

本节目标: 使用token函数持久化配置

实现步骤

  1. 拿到token的时候一式两份,存本地一份
  2. 初始化的时候优先从本地取,取不到再初始化为控制

代码实现

store/login.Store.js

// 登录模块
import  makeAutoObservable  from "mobx"
import  setToken, getToken, clearToken, http  from '@/utils'

class LoginStore 
  // 这里哦!!
  token = getToken() || ''
  constructor() 
    makeAutoObservable(this)
  
  // 登录
  login = async ( mobile, code ) => 
    const res = await http.post('http://geek.itheima.net/v1_0/authorizations', 
      mobile,
      code
    )
    this.token = res.data.token
    // 还有这里哦!!
    setToken(res.data.token)
  
 

export default LoginStore

9. axios请求拦截器注入token

《Vue/React项目实现axios请求拦截器注入token》

本节目标: 把token通过请求拦截器注入到请求头中

拼接方式:config.headers.Authorization = Bearer $token

utils/http.js

http.interceptors.request.use(config => 
  const token = getToken('pc-key')
  if (token) 
    config.headers.Authorization = `Bearer $token`
  
  return config
)

第一次发起请求,是登录请求,此时,localStorage中没有token,getToken获取不到,不走下面这个if函数体,直接return config;

后面再发请求时,由于已经登录了,此时,localStorage中有token,getToken获取到了,走if中的函数体,在发起请求前自动进行预处理,追加一个token,以便于访问需要权限的页面

为请求头对象(headers)中添加token验证的自定义字段(Authorization),作用是为了让需要验证才能使用的API能够使用(请求头中携带了token值则可通过验证)

在最后必须return config

10. 路由导航守卫

【Vue/React实现路由鉴权/导航守卫/路由拦截(react-router v6)】

本节目标: 能够实现未登录时访问拦截并跳转到登录页面(路由鉴权实现)

实现思路

自己封装 AuthRoute 路由鉴权高阶组件,实现未登录拦截,并跳转到登录页面

思路为:判断本地是否有token,如果有,就返回子组件,否则就重定向到登录Login

实现步骤

  1. 在 components 目录中,创建 AuthRoute/index.js 文件
  2. 判断是否登录
  3. 登录时,直接渲染相应页面组件
  4. 未登录时,重定向到登录页面
  5. 将需要鉴权的页面路由配置,替换为 AuthRoute 组件渲染

代码实现

components/AuthRoute/index.js

// 路由鉴权
// 1. 判断token是否存在
// 2. 如果存在 直接正常渲染
// 3. 如果不存在 重定向到登录路由

import  Navigate  from "react-router-dom";
import  getToken  from "@/utils";
// 高阶组件:把一个组件当成另外一个组件的参数传入 然后通过一定的判断 返回新的组件
// 这里的AuthRoute就是一个高阶组件

function AuthRoute( children ) 
  // 获取token
  const tokenStr = getToken()
  // 如果token存在 直接正常渲染
  if (tokenStr) 
    return <>children</>
  
  // 如果token不存在,重定向到登录路由
  else 
    return <Navigate to='/login' replace />
  

/*
 <AuthRoute> <Layout /> </AuthRoute> 
 登录:<> <Layout /> </>
 非登录:<Navigate to="/login" replace />
*/ 
export  AuthRoute 

注:utils工具函数getToken如下

// 从localstorage中取token
const getToken = () => 
return window.localStorage.getItem(key)

src/routes/index.js路由表文件

import Layout from "@/pages/Layout";
import Login from "@/pages/Login";
import  AuthRoute  from "@/components/AuthRoute";

// eslint-disable-next-line
export default [
  // 不需要鉴权的组件Login
  
    path: "/login",
    element: <Login />
  ,
  // 需要鉴权的组件Layout
  
    path: "/",
    element: <AuthRoute>
      <Layout />
    </AuthRoute>
  
]

下篇文章:Layout布局模块的实现
专栏订阅入口【React–从基础到实战】

前端实战|React18极客园——布局模块(useRoutes路由配置处理Token失效退出登录)

欢迎来到我的博客
📔博主是一名大学在读本科生,主要学习方向是前端。
🍭目前已经更新了【Vue】、【React–从基础到实战】、【TypeScript】等等系列专栏
🛠目前正在学习的是🔥 R e a c t / 小程序 React/小程序 React/小程序🔥,中间穿插了一些基础知识的回顾
🌈博客主页👉codeMak1r.小新的博客

😇本文目录😇

本文被专栏【React–从基础到实战】收录
🕹坚持创作✏️,一起学习📖,码出未来👨🏻‍💻!

最近在学习React过程中,找到了一个实战小项目,在这里与大家分享。
本文遵循项目开发流程,逐步完善各个需求
gitee完整项目地址:极客园完整代码

Layout模块

1. 基本结构搭建

本节目标: 能够使用antd搭建基础布局

实现步骤

  1. 打开 antd/Layout 布局组件文档,找到示例:顶部-侧边布局-通栏
  2. 拷贝示例代码到我们的 Layout 页面中
  3. 分析并调整页面布局

代码实现

pages/Layout/index.js

import  Layout, Menu, Popconfirm  from 'antd'
import 
  HomeOutlined,
  DiffOutlined,
  EditOutlined,
  LogoutOutlined
 from '@ant-design/icons'
import './index.scss'

const  Header, Sider  = Layout

const GeekLayout = () => 
  return (
    <Layout>
      <Header className="header">
        <div className="logo" />
        <div className="user-info">
          <span className="user-name">user.name</span>
          <span className="user-logout">
            <Popconfirm title="是否确认退出?" okText="退出" cancelText="取消">
              <LogoutOutlined /> 退出
            </Popconfirm>
          </span>
        </div>
      </Header>
      <Layout>
        <Sider width=200 className="site-layout-background">
          <Menu
            mode="inline"
            theme="dark"
            defaultSelectedKeys=['1']
            style= height: '100%', borderRight: 0 
          >
            <Menu.Item icon=<HomeOutlined /> key="1">
              数据概览
            </Menu.Item>
            <Menu.Item icon=<DiffOutlined /> key="2">
              内容管理
            </Menu.Item>
            <Menu.Item icon=<EditOutlined /> key="3">
              发布文章
            </Menu.Item>
          </Menu>
        </Sider>
        <Layout className="layout-content" style= padding: 20 >内容</Layout>
      </Layout>
    </Layout>
  )


export default GeekLayout

pages/Layout/index.scss

.ant-layout 
  height: 100%;


.header 
  padding: 0;


.logo 
  width: 200px;
  height: 60px;
  background: url('~@/assets/logo.png') no-repeat center / 160px auto;


.layout-content 
  overflow-y: auto;


.user-info 
  position: absolute;
  right: 0;
  top: 0;
  padding-right: 20px;
  color: #fff;
  
  .user-name 
    margin-right: 20px;
  
  
  .user-logout 
    display: inline-block;
    cursor: pointer;
  

.ant-layout-header 
  padding: 0 !important;

2. 二级路由配置

本节目标: 能够在右侧内容区域展示左侧菜单对应的页面内容

使用步骤

  1. 在 pages 目录中,分别创建:Home(数据概览)/Article(内容管理)/Publish(发布文章)页面文件夹
  2. 分别在三个文件夹中创建 index.js 并创建基础组件后导出
  3. 在app.js中配置嵌套子路由,在layout.js中配置二级路由出口
  4. 使用 Link 修改左侧菜单内容,与子路由规则匹配实现路由切换

代码实现

pages/Home/index.js

const Home = () => 
  return <div>Home</div>

export default Home

pages/Article/index.js

const Article = () => 
  return <div>Article</div>

export default Article

pages/Publish/index.js

const Publish = () => 
  return <div>Publish</div>

export default Publish

src/routes/index.js

export default [
  // 不需要鉴权的组件Login
  
    path: "/login",
    element: <Login />
  ,
  // 需要鉴权的组件Layout
  
    path: "/",
    element: <AuthRoute>
      <Layout />
    </AuthRoute>,
    children: [
      
        path: "home",
        element: <AuthRoute>
          <Home />
        </AuthRoute>
      ,
      
        path: "article",
        element: <AuthRoute>
          <Article />
        </AuthRoute>
      ,
      
        path: "publish",
        element: <AuthRoute>
          <Publish />
        </AuthRoute>
      ,
      
        path: "",
        element: <Navigate to="home" replace />
      
    ]
  
]

pages/Layout/index.js

// 配置Link组件
<Menu
 mode="inline"
 theme="dark"
 defaultSelectedKeys=['1']
 style= height: '100%', borderRight: 0 
>
  <Menu.Item icon=<HomeOutlined /> key="1" onClick=() => navigate('home')>
    数据概览
	</Menu.Item>
	<Menu.Item icon=<DiffOutlined /> key="2" onClick=() => navigate('article')>
    内容管理
  </Menu.Item>
  <Menu.Item icon=<EditOutlined /> key="3" onClick=() => navigate('publish')>
		发布文章
  </Menu.Item>
</Menu>
<Layout className="layout-content" style= padding: 20 ><Outlet /></Layout>

3. 菜单高亮显示

本节目标: 能够在页面刷新的时候保持对应菜单高亮

思路

  1. Menu组件的selectedKeys属性与Menu.Item组件的key属性发生匹配的时候,Item组件即可高亮
  2. 页面刷新时,将当前访问页面的路由地址作为 Menu 选中项的值(selectedKeys)即可

实现步骤

  1. 将 Menu 的key 属性修改为与其对应的路由地址
  2. 获取到当前正在访问页面的路由地址
  3. 将当前路由地址设置为 selectedKeys 属性的值

代码实现

pages/Layout/index.js

import  useLocation  from 'react-router-dom'

const GeekLayout = () => 
  const  pathname: selectedKey  = useLocation()
  console.log(selectedKey)

  return (
    // ...
    <Menu
      mode="inline"
      theme="dark"
      selectedKeys=[selectedKey]
      style= height: '100%', borderRight: 0 
    >
      <Menu.Item icon=<HomeOutlined /> key="/home" onClick=() => navigate('home')>
				  数据概览
			</Menu.Item>
			<Menu.Item icon=<DiffOutlined /> key="/article" onClick=() => navigate('article')>
				  内容管理
			</Menu.Item>
			<Menu.Item icon=<EditOutlined /> key="/publish" onClick=() => navigate('publish')>
				  发布文章
			</Menu.Item>
    </Menu>
  )

4. 展示个人信息

本节目标: 能够在页面右上角展示登录用户名

实现步骤

  1. 在store中新增user.Store.js模块,在其中定义获取用户信息的mobx代码
  2. 在store的入口文件中组合新增的userStore模块
  3. 在Layout组件中调用action函数获取用户数据
  4. 在Layout组件中获取个人信息并展示

代码实现

store/user.Store.js

// 用户模块
import  makeAutoObservable  from "mobx"
import  http  from '@/utils'

class UserStore 
  userInfo = 
  constructor() 
    makeAutoObservable(this)
  
  async getUserInfo() 
    const res = await http.get('/user/profile')
    this.userInfo = res.data
  


export default UserStore

store/index.js

import React from "react"
import LoginStore from './login.Store'
import UserStore from './user.Store'

class RootStore 
  // 组合模块
  constructor() 
    this.loginStore = new LoginStore()
    this.userStore = new UserStore()
  


const StoresContext = React.createContext(new RootStore())
export const useStore = () => React.useContext(StoresContext)

pages/Layout/index.js

import  useEffect  from 'react'
import  observer  from 'mobx-react-lite'

const GeekLayout = () => 
  const  userStore  = useStore()
  // 获取用户数据
  useEffect(() => 
    try 
      userStore.getUserInfo()
     catch  
  , [userStore])
    
  return (
    <Layout>
      <Header className="header">
        <div className="logo" />
        <div className="user-info">
          <span className="user-name">userStore.userInfo.name</span>
        </div>
      </Header>
      /* 省略无关代码 */
    </Layout>
  )


export default observer(GeekLayout)

5. 退出登录实现

本节目标: 能够实现退出登录功能

实现步骤

  1. 为气泡确认框添加确认回调事件
  2. store/login.Store.js 中新增退出登录的action函数,在其中删除token
  3. 在回调事件中,调用loginStore中的退出action
  4. 退出后,返回登录页面

代码实现

store/login.Store.js

class LoginStore 
  // 退出登录
  loginOut = () => 
    this.token = ''
    clearToken()
  


export default LoginStore

clearToken()是utils/token.js中定义好的清除token的工具函数。

pages/Layout/index.js

// login out
const navigate = useNavigate()
const onLogout = () => 
    loginStore.loginOut()
    navigate('/login')


<span className="user-logout">
    <Popconfirm title="是否确认退出?" okText="退出" cancelText="取消" onConfirm=onLogout>
      <LogoutOutlined /> 退出
    </Popconfirm>
</span>

6. 处理Token失效

本节目标: 能够在响应拦截器中处理token失效

说明:为了能够在非组件环境下拿到路由信息,需要我们安装一个history包

实现步骤

  1. 安装history包:yarn add history
  2. 创建 utils/history.js文件
  3. 在app.js中使用我们新建的路由并配置history参数
  4. 通过响应拦截器处理 token 失效,如果发现是401跳回到登录页

代码实现

utils/history.js

// https://github.com/remix-run/react-router/issues/8264
import  createBrowserHistory  from 'history'
const history = createBrowserHistory()
export  history 

index.js入口文件

...省略无关代码
import  unstable_HistoryRouter as HistoryRouter  from "react-router-dom";
import  history  from "./utils/history";

const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
  <HistoryRouter history=history>
    <App />
  </HistoryRouter>
)

utils/http.js

import  history  from './history'

http.interceptors.response.use(
  response => 
    return response.data
  ,
  error => 
    if (error.response.status === 401) 
      // 清除失效的token
      removeToken()
      // 跳转到登录页
      history.push('/login')
    
    return Promise.reject(error)
  
)

7. 首页Home图表展示

本节目标: 实现首页echart图表封装展示

需求描述:

  1. 使用eharts配合react封装柱状图组件Bar
  2. 要求组件的标题title,横向数据xData,纵向数据yData,样式style可定制

代码实现

components/Bar/index.js

// 封装图表bar组件
// 思路:
// 1. 看官方文档 把echarts加入项目
// 如何在react中获取dom => useRef
// 在什么地方获取dom节点  => useEffect
// 2. 不抽离定制化参数 先把最小化的demo跑起来
// 3. 按照需求:哪些参数需要自定义 抽象出来
import  useRef, useEffect  from 'react';
import * as echarts from 'echarts'

export default function Bar( title, xData, yData, style ) 
  const domRef = useRef()
  // 执行这个初始化的函数
  useEffect(() => 
    const chartInit = () => 
      // 基于准备好的dom,初始化echarts实例
      const myChart = echarts.init(domRef.current);
      // 绘制图表
      myChart.setOption(
        title: 
          text: title
        ,
        tooltip: ,
        xAxis: 
          data: xData
        ,
        yAxis: ,
        series: [
          
            name: '框架',
            type: 'bar',
            data: yData
          
        ]
      );
    
    chartInit()
  , [title, xData, yData])
  return (
    <div>
      /* 为echart准备一个dom节点 */
      <div ref=domRef style=style></div>
    </div>
  )

pages/Home/index.js

import React from 'react'
import Bar from '@/components/Bar'

export default function Home() 
  return (
    <div>
      <Bar
        title='主流框架使用满意度'
        xData=['React', 'Vue', 'Angular']
        yData=[40, 50, 30]
        style= width: '500px', height: '400px' 
      />
      <Bar
        title='主流框架使用满意度2'
        xData=['React', 'Vue', 'Angular']
        yData=[70, 80, 40]
        style= width: '300px', height: '200px' 
      />
    </div>
  )

pages/Home/index.scss

.home 
  width: 100%;
  height: 100%;
  align-items: center;

下篇文章:内容管理模块的实现
专栏订阅入口【React–从基础到实战】

以上是关于前端实战|React18极客园——登陆模块(token持久化路由拦截mobx封装axios)的主要内容,如果未能解决你的问题,请参考以下文章

前端工程化实战:React 模块化开发性能优化和组件化实践

Weex实战分享|Weex在极客时间APP中的实践

2019大前端热门技术流之React服务器端渲染NextJS实战

这篇只需要你有一些前端基础就可以上手开发HarmonyOS应用

极客Go云监工 — 基于Ant Design的Web React实现

极客Go云监工 — 基于Ant Design的Web React实现