翻译 | 《JavaScript Everywhere》第15章 Web身份验证和状态
写在最前面
大家好呀,我是毛小悠,是一位前端开发工程师。正在翻译一本英文技术书籍。
为了提高大家的阅读体验,对语句的结构和内容略有调整。如果发现本文中有存在瑕疵的地方,或者你有任何意见或者建议,可以在评论区留言,或者加我的微信:code_maomao,欢迎相互沟通交流学习。
(σ゚∀゚)σ..:*☆哎哟不错哦
第15章 Web身份验证和状态
最近我和我的家人搬家了。填写并签署了几种表格(我的手仍然很累)后,我们就用钥匙进了前门。每次我们回到家,我们都可以使用这些钥匙来解锁并进入门。我很高兴我每次回家都不需要填写表格,但也感谢拥有一把锁,这样我们就不会有任何不速之客到来了。
客户端Web
身份验证的工作方式几乎与这相同。
我们的用户将填写表格,并以密码和存储在他们的浏览器中的令牌的形式交给网站。当他们返回站点时,他们将使用令牌自动进行身份验证,或者能够使用其密码重新登录。
在本章中,我们将使用GraphQL API
构建一个Web
身份验证系统。
为此,我们将构建表单,将JWT
存储在浏览器中,随每个请求发送令牌,并跟踪应用程序的状态。
创建注册表格
开始使用我们的应用程序的客户端身份验证,我们可以创建一个用户注册React
组件。在这样做之前,让我们先确定组件的工作方式。
首先,用户将导航到我们应用程序中的/signup
路由。在此页面上,他们将看到一个表单,可以在其中输入电子邮件地址、用户名和密码。提交表单将执行我们API
的 signUp
请求 。如果请求成功,将创建一个新的用户帐户,API
将返回一个JWT
。如果有错误,我们可以通知用户。我们将显示一条通用错误消息,但我们可以更新API
以返回特定的错误消息,例如预先存在的用户名或重复的电子邮件地址。
让我们开始创建新路由。首先,我们将在src/pages/signup.js
创建一个新的React
组件 。
import React, { useEffect } from 'react';
// include the props passed to the component for later use
const SignUp = props => {
useEffect(() => {
// update the document title
document.title = 'Sign Up — Notedly';
});
return (
Sign Up
);
};
export default SignUp;
现在,我们将在src/pages/index.js
中更新路由列表,包括注册路由:
// import the signup route
import SignUp from './signup';
// within the Pages component add the route
通过添加路由,我们将能够导航到 http:// localhost:1234/signup
来查看(大部分为空)注册页面。现在,让我们为表单添加标记:
import React, { useEffect } from 'react';
const SignUp = props => {
useEffect(() => {
// update the document title
document.title = 'Sign Up — Notedly';
});
return (
);
};
export default SignUp;
htmlFor
如果你只是在学习React
,那么常见的陷阱之一就是与HTML
对应的JSX
属性的不同。在这种情况下,我们使用JSX htmlFor
代替HTML
的 for
属性来避免任何JavaScript
冲突。你可以在以下页面中看到这些属性的完整列表(虽然简短)
React DOM Elements
文档。
现在,我们可以通过导入Button
组件并将样式设置为样式化组件来添加某种样式 :
import React, { useEffect } from 'react';
import styled from 'styled-components';
import Button from '../components/Button';
const Wrapper = styled.div` border: 1px solid #f5f4f0;
max-width: 500px;
padding: 1em;
margin: 0 auto; `;
const Form = styled.form` label,
input { display: block;
line-height: 2em;
}
input { width: 100%;
margin-bottom: 1em;
} `;
const SignUp = props => {
useEffect(() => {
// update the document title
document.title = 'Sign Up — Notedly';
});
return (
Sign Up
);
};
export default SignUp;
React
表单和状态
在应用程序中会有事情的改变。数据输入到表单中,用户将点击按钮,发送消息。在React
中,我们可以通过分配state
来在组件级别跟踪这些请求。在我们的表单中,我们需要跟踪每个表单元素的状态,以便在后面可以提交它。
React Hooks
在本书中,我们将使用功能组件和React
的较新Hooks API
。如果你使用了其他使用React
的类组件的学习资源 ,则可能看起来有些不同。你可以在React
文档中阅读有关钩子的更多信息。
要开始使用状态,我们首先将src/pages/signup.js
文件顶部的React
导入更新为useState
:
import React, { useEffect, useState } from 'react';
接下来,在我们的 SignUp
组件中,我们将设置默认表单值状态:
const SignUp = props => {
// set the default state of the form
const [values, setValues] = useState();
// rest of component goes here
};
现在,我们将更新组件在输入表单字段时更改状态,并在用户提交表单时执行操作。首先,我们将创建一个onChange
函数,该函数将在更新表单时更新组件的状态。
当用户做了改变后,通过调用这个函数的onChange属性来更新每个表单元素的标记。
然后,我们在onSubmit
处理程序更新表单元素。现在,我们仅将表单数据输出到控制台。
在/src/pages/sigunp.js
:
const SignUp = () => {
// set the default state of the form
const [values, setValues] = useState();
// update the state when a user types in the form
const onChange = event => {
setValues({
...values,
[event.target.name]: event.target.value
});
};
useEffect(() => {
// update the document title
document.title = 'Sign Up — Notedly';
});
return (
Sign Up
);
};
使用此表单标记后,我们就可以请求具有GraphQL
修改的数据了。
修改注册
要注册用户,我们将使用API
的 signUp
请求。如果注册成功,此请求将接受电子邮件、用户名和密码作为变量,并返回JWT
。让我们写出我们的请求并将其集成到我们的注册表单中。
首先,我们需要导入我们的Apollo
库。我们将利用useMutation
和useApolloClient
挂钩以及 Apollo Client
的 gql
语法。
在 src/pages/signUp
中,在其他库import
语句旁边添加以下内容:
import { useMutation, useApolloClient, gql } from '@apollo/client';
现在编写GraphQL
修改,如下所示:
const SIGNUP_USER = gql`
mutation signUp($email: String!, $username: String!, $password: String!) {
signUp(email: $email, username: $username, password: $password)
}
`;
编写了请求后,我们可以更新React
组件标记以在用户提交表单时将表单元素作为变量传递来执行修改。现在,我们将响应(如果成功,应该是JWT
)输出到控制台:
const SignUp = props => {
// useState, onChange, and useEffect all remain the same here
//add the mutation hook
const [signUp, { loading, error }] = useMutation(SIGNUP_USER, {
onCompleted: data => {
// console.log the JSON Web Token when the mutation is complete
console.log(data.signUp);
}
});
// render our form
return (
Sign Up
{/* pass the form data to the mutation when a user submits the form */}
);
};
现在,如果你完成并提交表单,你应该会看到一个JWT
输出到控制台(图15-1
)。
另外,如果你在GraphQLPlayground
( http
:// localhost
:4000/api
)中执行用户查询,你将看到新帐户(图15-2
)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6crer0sJ-1606432851137)(http://vipkshttp0.wiz.cn/ks/s...]
图15-1
。如果成功,当我们提交表单时,JSON Web
令牌将打印到我们的控制台
图15-2
。我们还可以通过在GraphQL Playground
中执行用户查询来查看用户列表
设置好修改并返回期望的数据后,接下来我们要存储收到的响应。
JSON Web令牌和本地存储
成功完成我们的 signUp
请求后,它会返回JSON Web
令牌(JWT
)。你可能会从本书的API
部分回忆起JWT
允许我们在用户设备上安全存储用户ID
。为了在用户的Web
浏览器中实现此目的,我们将令牌存储在浏览器的 localStorage
中。 localStorage
是一个简单的键值存储,可在浏览器会话之间保留,直到更新或清除该存储为止。让我们更新请求以将令牌存储在 localStorage
中。
在 src/pages/signup.js
,更新 useMutation
钩子以将令牌存储在本地存储中 ( 见图15-3
):
const [signUp, { loading, error }] = useMutation(SIGNUP_USER, {
onCompleted: data => {
// store the JWT in localStorage
localStorage.setItem('token', data.signUp);
}
});
图15-3
。我们的Web
令牌现在存储在浏览器的localStorage
中
JWT和安全性
当令牌存储在 localStorage
中时,可以在页面上运行的任何JavaScript
都可以访问该令牌,然后容易受到跨站点脚本(XSS
)攻击。因此,在使用 localStorage
存储令牌凭证时,需要格外小心以限制(或避免)CDN
托管脚本。如果第三方脚本被盗用,它将有权访问JWT
。
随着我们的JWT
存储在本地,我们准备在GraphQL
请求和查询中使用它。
重导向
当前,当用户完成注册表单时,该表单会重新呈现为空白表单。这不会给用户很多视觉提示,表明他们的帐户注册成功。相反,我们可以将用户重定向到应用程序的主页。另一种选择是创建一个“成功”页面,该页面感谢用户注册并将其注册到应用程序中。
你可能会在本章前面接触到,我们可以将属性传递到组件中。我们可以使用React Router
的历史记录重定向路由,这将通过props.history.push
实现。为了实现这一点,我们将更新我们的修改的 onCompleted
事件,包括如下所示的重定向:
const [signUp, { loading, error }] = useMutation(SIGNUP_USER, {
onCompleted: data => {
// store the token
localStorage.setItem('token', data.signUp);
// redirect the user to the homepage
props.history.push('/');
}
});
进行此更改后,现在用户在注册帐户后将被重定向到我们应用程序的主页。
发送附加标头的请求
尽管我们将令牌存储在 localStorage
中,但我们的API
尚未访问它。这意味着即使用户创建了帐户,API
也无法识别该用户。如果你回想我们的API
开发,每个API
调用都会在请求的标头中收到一个令牌。我们将修改客户端以将JWT
作为每个请求的标头发送。
在 src/App.js
中, 我们将更新依赖项,包括来自Apollo Client
的createHttpLink
以及来自Apollo
的Link Context
包的setContext
。然后,我们将更新Apollo
的配置,以在每个请求的标头中发送令牌:
// import the Apollo dependencies
import {
ApolloClient,
ApolloProvider,
createHttpLink,
InMemoryCache
} from '@apollo/client';
import { setContext } from 'apollo-link-context';
// configure our API URI & cache
const uri = process.env.API_URI;
const httpLink = createHttpLink({ uri });
const cache = new InMemoryCache();
// check for a token and return the headers to the context
const authLink = setContext((_, { headers }) => {
return {
headers: {
...headers,
authorization: localStorage.getItem('token') || ''
}
};
});
// create the Apollo client
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache,
resolvers: {},
connectToDevTools: true
});
进行此更改后,我们现在可以将已登录用户的信息传递给我们的API
。
本地状态管理
我们已经研究了如何在组件中管理状态,但是整个应用程序呢?有时在许多组件之间共享一些信息很有用。我们可以在整个应用程序中从基本组件传递组件,但是一旦我们经过几个子组件级别,就会变得混乱。一些库如Redux
和 MobX
试图解决状态管理的挑战,并已证明对许多开发人员和团队非常有用。
在我们的案例中,我们已经在使用Apollo
客户端库,该库包括使用GraphQL
查询进行本地状态管理的功能。让我们实现一个本地状态属性,该属性将存储用户是否已登录,而不是引入另一个依赖关系。
Apollo React
库将ApolloClient
实例放入 React
的上下文中,但有时我们可能需要直接访问它。我们可以通过useApolloClient
挂钩,这将使我们能够执行诸如直接更新或重置缓存存储区或写入本地数据之类的操作。
当前,我们有两种方法来确定用户是否登录到我们的应用程序。首先,如果他们成功提交了注册表单,我们知道他们是当前用户。其次,我们知道,如果访问者使用存储在localStorage
中的令牌访问该站点 ,那么他们已经登录。让我们从用户填写注册表单时添加到我们的状态开始。
为此,我们将使用client.writeData
和useApolloClient
挂钩直接将其写入Apollo
客户的本地仓库。
在 src/pages/signup.js
中,我们首先需要更新 @apollo/client
库导入以包含 useApolloClient
:
import { useMutation, useApolloClient } from '@apollo/client';
在 src/pages/signup.js
中, 我们将调用 useApolloClient
函数,并在完成后使用writeData
更新该修改以添加到本地存储中 :
// Apollo Client
const client = useApolloClient();
// Mutation Hook
const [signUp, { loading, error }] = useMutation(SIGNUP_USER, {
onCompleted: data => {
// store the token
localStorage.setItem('token', data.signUp);
// update the local cache
client.writeData({ data: { isLoggedIn: true } });
// redirect the user to the homepage
props.history.push('/');
}
});
现在,让我们更新应用程序,以在页面加载时检查预先存在的令牌,并在找到令牌时更新状态。在 src/App.js
,首先将ApolloClient
配置更新为一个空的 resolvers
对象。这将使我们能够在本地缓存上执行GraphQL
查询。
// create the Apollo client
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache,
resolvers: {},
connectToDevTools: true
});
接下来,我们可以对应用程序的初始页面加载执行检查:
// check for a local token
const data = {
isLoggedIn: !!localStorage.getItem('token')
};
// write the cache data on initial load
cache.writeData({ data });
这里很酷:我们现在可以使用@client
指令在应用程序中的任何位置以GraphQL
查询形式访问 isLoggedIn
。
为了证明这一点,让我们更新我们的应用程序,
如果isLoggedIn
是false
,显示“注册”和“登录”链接。
如果 isLoggedIn
是 true
就显示“注销”链接。
在 src/components/Header.js
,导入必要的依赖项并像下面这样编写查询:
// new dependencies
import { useQuery, gql } from '@apollo/client';
import { Link } from 'react-router-dom';
// local query
const IS_LOGGED_IN = gql`
{
isLoggedIn @client
}
`;
现在,在我们的React
组件中,我们可以包括一个简单的查询来检索状态,以及一个三级运算符,该运算符显示注销或登录的选项:
const UserState = styled.div`
margin-left: auto;
`;
const Header = props => {
// query hook for user logged in state
const { data } = useQuery(IS_LOGGED_IN);
return (
Notedly {/* If logged in display a logout link, else display sign-in options */} {data.isLoggedIn ? ( Log Out
) : ( Sign In or{' '} Sign Up
)} );
};
这样,当用户登录时,他们将看到“注销”选项。否则,将由于本地状态来为他们提供用于登录或注册的选项。我们也不限于简单的布尔逻辑。Apollo
使我们能够编写本地解析器和类型定义,从而使我们能够利用GraphQL
在本地状态下必须提供的一切。
注销
目前,一旦用户登录,他们将无法退出我们的应用程序。让我们将标题中的“注销”变成一个按钮,单击该按钮将注销用户。为此,当单击按钮时,我们将删除存储在localStorage
中的令牌 。我们将使用一个元素来实现其内置的可访问性,因为当用户使用键盘导航应用程序时,它既充当用户动作的语义表示,又可以获得焦点(如链接)。
在编写代码之前,让我们编写一个样式化的组件,该组件将呈现一个类似于链接的按钮。在src/Components/ButtonAsLink.js
中创建一个新文件,并添加以下内容:
import styled from 'styled-components';
const ButtonAsLink = styled.button`
background: none;
color: #0077cc;
border: none;
padding: 0;
font: inherit;
text-decoration: underline;
cursor: pointer;
:hover,
:active {
color: #004499;
}
`;
export default ButtonAsLink;
现在在 src/components/Header.js
, 我们可以实现我们的注销功能。我们需要使用React Router
的withRouter
高阶组件来处理重定向,因为Header.js
文件是UI
组件,而不是已定义的路由。首先导入 ButtonAsLink
组件以及 withRouter
:
// import both Link and withRouter from React Router
import { Link, withRouter } from 'react-router-dom';
// import the ButtonAsLink component
import ButtonAsLink from './ButtonAsLink';
现在,在我们的JSX
中,我们将更新组件以包括 props
参数,并将注销标记更新为一个按钮:
const Header = props => {
// query hook for user logged-in state,
// including the client for referencing the Apollo store
const { data, client } = useQuery(IS_LOGGED_IN);
return (
Notedly {/* If logged in display a logout link, else display sign-in options */} {data.isLoggedIn ? ( Logout ) : ( Sign In or{' '} Sign Up
)} );
};
// we wrap our component in the withRouter higher-order component
export default withRouter(Header);
路由器
当我们想在本身不能直接路由的组件中使用路由时,我们需要使用React Router
的 withRouter
高阶组件。当用户注销我们的应用程序时,我们希望重置缓存存储区,以防止任何不需要的数据出现在会话外部。Apollo
可以调用resetStore
函数,它将完全清除缓存。
让我们在组件的按钮上添加一个 onClick
处理函数,以删除用户的令牌,重置Apollo
仓库,更新本地状态并将用户重定向到首页。为此,我们将更新 useQuery
挂钩,以包括对客户端的引用,并将组件包装 在export
语句的 withRouter
高阶组件中 。
const Header = props => {
// query hook for user logged in state
const { data, client } = useQuery(IS_LOGGED_IN);
return (
Notedly {/* If logged in display a logout link, else display sign-in options */} {data.isLoggedIn ? ( {
// remove the token
localStorage.removeItem('token');
// clear the application's cache
client.resetStore();
// update local state
client.writeData({ data: { isLoggedIn: false } });
// redirect the user to the home page
props.history.push('/');
}}
>
Logout ) : ( Sign In or{' '} Sign Up
)} );
};
export default withRouter(Header);
最后,重置存储后,我们需要Apollo
将用户状态添加回我们的缓存状态。在 src/App.js
将缓存设置更新为包括 onResetStore
:
// check for a local token
const data = {
isLoggedIn: !!localStorage.getItem('token')
};
// write the cache data on initial load
cache.writeData({ data });
// write the cache data after cache is reset
client.onResetStore(() => cache.writeData({ data }));
这样,登录用户可以轻松注销我们的应用程序。我们已经将此功能直接集成到了 Header
组件中,但是将来我们可以将其重构为一个独立的组件。
创建登录表单
当前,我们的用户可以注册并注销我们的应用程序,但是他们无法重新登录。让我们创建一个登录表单,并在此过程中进行一些重构,以便我们可以重用许多代码在我们的注册组件中找到。
我们的第一步将是创建一个新的页面组件,该组件将位于/signin
。在src/pages/signin.js
的新文件中 ,添加以下内容:
import React, { useEffect } from 'react';
const SignIn = props => {
useEffect(() => {
// update the document title
document.title = 'Sign In — Notedly';
});
return (
Sign up page
);
};
export default SignIn;
现在我们可以使页面可路由,以便用户可以导航到该页面。在 src/pages/index.js
导入路由页面并添加新的路由路径:
// import the sign-in page component
import SignIn from './signin';
const Pages = () => {
return (
// ... our other routes
// add a signin route to our routes list );
};
在实施登录表单之前,让我们暂停一下,考虑我们的选项。我们可以重新实现一个表单,就像我们在“注册”页面上写的那样,但这听起来很乏味,并且需要我们维护两个相似的表单。当一个更改时,我们需要确保更新另一个。另一个选择是将表单隔离到其自己的组件中,这将使我们能够重复使用通用代码并在单个位置进行更新。让我们继续使用共享表单组件方法。
我们将首先在src/components/UserForm.js
中创建一个新组件,介绍我们的标记和样式。
我们将对该表单进行一些小的但值得注意的更改,使用它从父组件接收的属性。首先,我们将onSubmit
请求重命名为props.action
,这将使我们能够通过组件的属性将修改传递给表单。其次,我们将添加一些条件语句,我们知道我们的两种形式将有所不同。我们将使用第二个名为formType
的属性,该属性将传递一个字符串。我们可以根据字符串的值更改模板的渲染。
我们会通过逻辑运算符&&或三元运算符。
import React, { useState } from 'react';
import styled from 'styled-components';
import Button from './Button';
const Wrapper = styled.div`
border: 1px solid #f5f4f0;
max-width: 500px;
padding: 1em;
margin: 0 auto;
`;
const Form = styled.form`
label,
input {
display: block;
line-height: 2em;
}
input {
width: 100%;
margin-bottom: 1em;
}
`;
const UserForm = props => {
// set the default state of the form
const [values, setValues] = useState();
// update the state when a user types in the form
const onChange = event => {
setValues({
...values,
[event.target.name]: event.target.value
});
};
return (
{/* Display the appropriate form header */}
{props.formType === 'signup' ? Sign Up
: Sign In
}
{/* perform the mutation when a user submits the form */}
);
};
export default UserForm;
现在,我们可以简化 src/pages/signup.js
组件以利用共享表单组件:
import React, { useEffect } from 'react';
import { useMutation, useApolloClient, gql } from '@apollo/client';
import UserForm from '../components/UserForm';
const SIGNUP_USER = gql`
mutation signUp($email: String!, $username: String!, $password: String!) {
signUp(email: $email, username: $username, password: $password)
}
`;
const SignUp = props => {
useEffect(() => {
// update the document title
document.title = 'Sign Up — Notedly';
});
const client = useApolloClient();
const [signUp, { loading, error }] = useMutation(SIGNUP_USER, {
onCompleted: data => {
// store the token
localStorage.setItem('token', data.signUp);
// update the local cache
client.writeData({ data: { isLoggedIn: true } });
// redirect the user to the homepage
props.history.push('/');
}
});
return (
{/* if the data is loading, display a loading message*/}
{loading && Loading...
}
{/* if there is an error, display a error message*/}
{error && Error creating an account!
}
);
};
export default SignUp;
最后,我们可以使用 signIn
请求和 UserForm
组件编写 SignIn
组件。
在 src/pages/signin.js
:
import React, { useEffect } from 'react';
import { useMutation, useApolloClient, gql } from '@apollo/client';
import UserForm from '../components/UserForm';
const SIGNIN_USER = gql`
mutation signIn($email: String, $password: String!) {
signIn(email: $email, password: $password)
}
`;
const SignIn = props => {
useEffect(() => {
// update the document title
document.title = 'Sign In — Notedly';
});
const client = useApolloClient();
const [signIn, { loading, error }] = useMutation(SIGNIN_USER, {
onCompleted: data => {
// store the token
localStorage.setItem('token', data.signIn);
// update the local cache
client.writeData({ data: { isLoggedIn: true } });
// redirect the user to the homepage
props.history.push('/');
}
});
return (
{/* if the data is loading, display a loading message*/}
{loading && Loading...
}
{/* if there is an error, display a error message*/}
{error && Error signing in!
}
);
};
export default SignIn;
这样,我们现在有了一个易于管理的表单组件,并使用户能够注册和登录我们的应用程序。
保护路由
常见的应用程序模式是将对特定页面或网站部分的访问权限限制为经过身份验证的用户。在我们的情况下,未经身份验证的用户将无法使用“我的笔记”或“收藏夹”页面。我们可以在路由器中实现此模式,当未经身份验证的用户尝试访问那些路由时,会将他们自动导航到应用程序的“登录”页面。
在 src/pages/index.js
中, 我们将首先导入必要的依赖项并添加我们的 isLoggedIn
查询:
import { useQuery, gql } from '@apollo/client';
const IS_LOGGED_IN = gql`
{
isLoggedIn @client
}
`;
现在,我们将导入React Router
的 Redirect
库并编写一个 PrivateRoute
组件,如果用户未登录,它将对用户进行重定向:
// update our react-router import to include Redirect
import { BrowserRouter as Router, Route, Redirect } from 'react-router-dom';
// add the PrivateRoute component below our `Pages` component
const PrivateRoute = ({ component: Component, ...rest }) => {
const { loading, error, data } = useQuery(IS_LOGGED_IN);
// if the data is loading, display a loading message
if (loading) return Loading...
;
// if there is an error fetching the data, display an error message
if (error) return Error!
;
// if the user is logged in, route them to the requested component
// else redirect them to the sign-in page
return (
data.isLoggedIn === true ? (
) : (
)
}
/>
);
};
export default Pages;
最后,我们可以更新用于登录用户的任何路由以使用 PrivateRoute
组件:
const Pages = () => {
return (
);
};
重定向状态
当我们重定向私有路由时,我们也将存储URL
作为状态。这使我们能够将用户重定向到他们最初试图导航到的页面。我们可以更新登录页面的重定向,可以选择使用props.state.
' ' location.from
来启用这个功能。
现在,当用户试图导航到为已登录用户准备的页面时,他们将被重定向到我们的登录页面。
结论
在本章中,我们介绍了构建客户端JavaScript
应用程序的两个关键概念:身份验证和状态。通过构建完整的身份验证流程,你已洞悉用户帐户如何与客户端应用程序一起使用。从这里开始,我希望你探索OAuth
等替代选项以及Auth0
,Okta
和Firebase
等身份验证服务。此外,你已经学会了使用React Hooks API
在组件级别管理应用程序中的状态,以及使用Apollo
的本地状态管理整个应用程序中的状态。
有了这些关键概念,你现在就可以构建强大的用户界面应用程序。
如果有理解不到位的地方,欢迎大家纠错。如果觉得还可以,麻烦您点赞收藏或者分享一下,希望可以帮到更多人。