Recently I tried to write a login form using React hooks related API, the purpose is to deepen the understanding of hooks. This article will not explain the use of specific APIs, but will provide a step-by-step in-depth look at the functions to be implemented. So you need to have a basic understanding of hooks before reading. The final look is a bit like using hooks to write a simple redux-like state management model.
A simple login form contains three input items: username, password, and verification code, which also represent the three data states of the form. We simply focus on username , password, and capacha establish state relationships through useState
respectively, which is the so-called relatively fine-grained state division. The code is also very simple:
// LoginForm.js const LoginForm = () => { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [captcha, setCaptcha] = useState(""); const submit = useCallback(() => { loginService.login({ username, password, captcha, }); }, [username, password, captcha]); return ( <p> <input> { setUsername(e.target.value); }} /> <input> { setPassword(e.target.value); }} /> <input> { setCaptcha(e.target.value); }} /> <button>提交</button> </p> ); }; export default LoginForm;
This kind of fine-grained state is very simple and intuitive, but if there are too many states, it will be quite troublesome to write the same logic for each state, and it will be too scattered. .
We define username, password, and capacha as a state, which is the so-called coarse-grained state division:
const LoginForm = () => { const [state, setState] = useState({ username: "", password: "", captcha: "", }); const submit = useCallback(() => { loginService.login(state); }, [state]); return ( <p> <input> { setState({ ...state, username: e.target.value, }); }} /> ... <button>提交</button> </p> ); };
As you can see, the setXXX method is reduced, and the setState The naming is also more appropriate, but this setState will not automatically merge the status items, and we need to merge them manually.
Of course a complete form cannot lack the verification link. In order to display the error message below the input when an error occurs, we first extract a sub-component Field:
const Filed = ({ placeholder, value, onChange, error }) => { return ( <p> <input> {error && <span>error</span>} </p> ); };
We use the schema-typed library to do some field definition and verification. Its use is very simple. The API is similar to React's PropType. We define the following field verification:
const model = SchemaModel({ username: StringType().isRequired("用户名不能为空"), password: StringType().isRequired("密码不能为空"), captcha: StringType() .isRequired("验证码不能为空") .rangeLength(4, 4, "验证码为4位字符"), });
Then add errors to the state and trigger model.check
in the submit method. check.
const LoginForm = () => { const [state, setState] = useState({ username: "", password: "", captcha: "", // ++++ errors: { username: {}, password: {}, captcha: {}, }, }); const submit = useCallback(() => { const errors = model.check({ username: state.username, password: state.password, captcha: state.captcha, }); setState({ ...state, errors: errors, }); const hasErrors = Object.values(errors).filter((error) => error.hasError).length > 0; if (hasErrors) return; loginService.login(state); }, [state]); return ( <p> <field> { setState({ ...state, username: e.target.value, }); }} /> ... <button>提交</button> </field></p> ); };
Then when we click submit without entering anything, an error message will be triggered:
At this point, it feels like our form is almost complete and the function seems to be completed. But is this okay? We print console.log(placeholder, "rendering")
in the Field component. When we enter the user name, we find that all the Field components are re-rendered. This can be optimized.
How to do it? First, let the Field component avoid re-rendering when the props remain unchanged. We use React.memo to wrap the Field component.
React.memo is a high-order component. It's very similar to React.PureComponent, but only works with functional components. If your function component renders the same result given the same props, then you can improve the performance of the component by remembering the component's rendering result by wrapping it in React.memo and calling it
export default React.memo(Filed);
But if this is the case, all the Field components will still be re-rendered. This is because our onChange function returns a new function object every time, causing the memo to become invalid.
We can wrap Filed's onChange function with useCallback
, so that we don't have to generate a new function object every time the component is rendered.
const changeUserName = useCallback((e) => { const value = e.target.value; setState((prevState) => { // 注意因为我们设置useCallback的依赖为空,所以这里要使用函数的形式来获取最新的state(preState) return { ...prevState, username: value, }; }); }, []);
Is there any other solution? We noticed useReducer,
useReducer is another alternative, which is more suitable for managing state objects containing multiple sub-values. It is an alternative to useState. It receives a reducer of the form (state, action) => newState and returns the current state and its matching dispatch method. Moreover, using useReducer can also optimize the performance of components that trigger deep updates, because you can pass dispatch to sub-components instead of callback functions.
An important feature of useReducer is that the dispatch it returns The function's identity is stable and does not change when the component is re-rendered. Then we can safely pass dispatch to the sub-component without worrying about causing the sub-component to re-render.
We first define the reducer function to operate state:
const initialState = { username: "", ... errors: ..., }; // dispatch({type: 'set', payload: {key: 'username', value: 123}}) function reducer(state, action) { switch (action.type) { case "set": return { ...state, [action.payload.key]: action.payload.value, }; default: return state; } }
const LoginForm = () => { const [state, dispatch] = useReducer(reducer, initialState); const submit = ... return ( <p> <field></field> ... <button>提交</button> </p> ); };
const Filed = ({ placeholder, value, dispatch, error, name }) => { console.log(name, "rendering"); return ( <p> <input> dispatch({ type: "set", payload: { key: name, value: e.target.value }, }) } /> {error && <span>{error}</span>} </p> ); }; export default React.memo(Filed);
我们首先新建一个store.js文件,放置我们的reducer函数,并新建一个useStore hook,返回我们关注的state和dispatch,然后调用createContainer并将返回值Store暴露给外部文件使用。
// store.js import { createContainer } from "unstated-next"; import { useReducer } from "react"; const initialState = { ... }; function reducer(state, action) { switch (action.type) { case "set": ... default: return state; } } function useStore() { const [state, dispatch] = useReducer(reducer, initialState); return { state, dispatch }; } export const Store = createContainer(useStore);
接着我们将LoginForm包裹一层Provider
// LoginForm.js import { Store } from "./store"; const LoginFormContainer = () => { return ( <store.provider> <loginform></loginform> </store.provider> ); };
这样在子组件中就可以通过useContainer随意的访问到state和dispatch了
// Field.js import React from "react"; import { Store } from "./store"; const Filed = ({ placeholder, name }) => { const { state, dispatch } = Store.useContainer(); return ( ... ); }; export default React.memo(Filed);
可以看到不用考虑组件层级就能轻易访问到state和dispatch。但是这样一来每次调用dispatch之后state都会变化,导致Context变化,那么子组件也会重新render了,即使我只更新username, 并且使用了memo包裹组件。
当组件上层最近的更新时,该 Hook 会触发重渲染,并使用最新传递给 MyContext provider 的 context value 值。即使祖先使用 React.memo 或 shouldComponentUpdate,也会在组件本身使用 useContext 时重新渲染
那么怎么避免这种情况呢,回想一下使用redux时,我们并不是直接在组件内部使用state,而是使用connect高阶函数来注入我们需要的state和dispatch。我们也可以为Field组件创建一个FieldContainer组件来注入state和dispatch。
// Field.js const Filed = ({ placeholder, error, name, dispatch, value }) => { // 我们的Filed组件,仍然是从props中获取需要的方法和state } const FiledInner = React.memo(Filed); // 保证props不变,组件就不重新渲染 const FiledContainer = (props) => { const { state, dispatch } = Store.useContainer(); const value = state[props.name]; const error = state.errors[props.name].errorMessage; return ( <filedinner></filedinner> ); }; export default FiledContainer;
这样一来在value值不变的情况下,Field组件就不会重新渲染了,当然这里我们也可以抽象出一个类似connect高阶组件来做这个事情:
// Field.js const connect = (mapStateProps) => { return (comp) => { const Inner = React.memo(comp); return (props) => { const { state, dispatch } = Store.useContainer(); return ( <inner></inner> ); }; }; }; export default connect((state, props) => { return { value: state[props.name], error: state.errors[props.name].errorMessage, }; })(Filed);
使用redux时,我习惯将一些逻辑写到函数中,如dispatch(login()),
也就是使dispatch支持异步action。这个功能也很容易实现,只需要装饰一下useReducer返回的dispatch方法即可。
// store.js function useStore() { const [state, _dispatch] = useReducer(reducer, initialState); const dispatch = useCallback( (action) => { if (typeof action === "function") { return action(state, _dispatch); } else { return _dispatch(action); } }, [state] ); return { state, dispatch }; }
如上我们在调用_dispatch方法之前,判断一下传来的action,如果action是函数的话,就调用之并将state、_dispatch作为参数传入,最终我们返回修饰后的dispatch方法。
不知道你有没有发现这里的dispatch函数是不稳定,因为它将state作为依赖,每次state变化,dispatch就会变化。这会导致以dispatch为props的组件,每次都会重新render。这不是我们想要的,但是如果不写入state依赖,那么useCallback内部就拿不到最新的state
。
那有没有不将state写入deps,依然能拿到最新state的方法呢,其实hook也提供了解决方案,那就是useRef
useRef返回的 ref 对象在组件的整个生命周期内保持不变,并且变更 ref的current 属性不会引发组件重新渲染
通过这个特性,我们可以声明一个ref对象,并且在useEffect
中将current
赋值为最新的state对象。那么在我们装饰的dispatch函数中就可以通过ref.current拿到最新的state。
// store.js function useStore() { const [state, _dispatch] = useReducer(reducer, initialState); const refs = useRef(state); useEffect(() => { refs.current = state; }); const dispatch = useCallback( (action) => { if (typeof action === "function") { return action(refs.current, _dispatch); //refs.current拿到最新的state } else { return _dispatch(action); } }, [_dispatch] // _dispatch本身是稳定的,所以我们的dispatch也能保持稳定 ); return { state, dispatch }; }
这样我们就可以定义一个login方法作为action,如下
// store.js export const login = () => { return (state, dispatch) => { const errors = model.check({ username: state.username, password: state.password, captcha: state.captcha, }); const hasErrors = Object.values(errors).filter((error) => error.hasError).length > 0; dispatch({ type: "set", payload: { key: "errors", value: errors } }); if (hasErrors) return; loginService.login(state); }; };
在LoginForm中,我们提交表单时就可以直接调用dispatch(login())
了。
const LoginForm = () => { const { state, dispatch } = Store.useContainer(); ..... return ( <p> <field></field> .... <button> dispatch(login())}>提交</button> </p> ); }
一个支持异步action的dispatch就完成了。
推荐教程:《JS教程》
The above is the detailed content of Use hooks to write a login form - Frontier Development Team. For more information, please follow other related articles on the PHP Chinese website!