站长资讯网
最全最丰富的资讯网站

用hooks写个登录表单 – 前沿开发团队

最近尝试用React hooks相关api写一个登陆表单,目的就是加深一下对hooks的理解。本文不会讲解具体api的使用,只是针对要实现的功能,一步一步深入。所以阅读前要对 hooks有基本的认识。最终的样子有点像用hooks写一个简单的类似redux的状态管理模式。

细粒度的state

一个简单的登录表单,包含用户名、密码、验证码3个输入项,也代表着表单的3个数据状态,我们简单的针对username、password、capacha分别通过useState建立状态关系,就是所谓的比较细粒度的状态划分。代码也很简单:

// 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 className="login-form">       <input         placeholder="用户名"         value={username}         onChange={(e) => {           setUsername(e.target.value);         }}       />       <input         placeholder="密码"         value={password}         onChange={(e) => {           setPassword(e.target.value);         }}       />       <input         placeholder="验证码"         value={captcha}         onChange={(e) => {           setCaptcha(e.target.value);         }}       />       <button onClick={submit}>提交</button>     </p>   ); };  export default LoginForm;

这种细粒度的状态,很简单也很直观,但是状态一多的话,要针对每个状态写相同的逻辑,就挺麻烦的,且太过分散。

粗粒度

我们将username、password、capacha定义为一个state就是所谓粗粒度的状态划分:

const LoginForm = () => {   const [state, setState] = useState({     username: "",     password: "",     captcha: "",   });    const submit = useCallback(() => {     loginService.login(state);   }, [state]);    return (     <p className="login-form">       <input         placeholder="用户名"         value={state.username}         onChange={(e) => {           setState({             ...state,             username: e.target.value,           });         }}       />       ...       <button onClick={submit}>提交</button>     </p>   ); };

可以看到,setXXX 方法减少了,setState的命名也更贴切,只是这个setState不会自动合并状态项,需要我们手动合并。

加入表单校验

一个完整的表单当然不能缺少验证环节,为了能够在出现错误时,input下方显示错误信息,我们先抽出一个子组件Field:

const Filed = ({ placeholder, value, onChange, error }) => {   return (     <p className="form-field">       <input placeholder={placeholder} value={value} onChange={onChange} />       {error && <span>error</span>}     </p>   ); };

我们使用schema-typed这个库来做一些字段定义及验证。它的使用很简单,api用起来类似React的PropType,我们定义如下字段验证:

const model = SchemaModel({   username: StringType().isRequired("用户名不能为空"),   password: StringType().isRequired("密码不能为空"),   captcha: StringType()     .isRequired("验证码不能为空")     .rangeLength(4, 4, "验证码为4位字符"), });

然后在state中添加errors,并在submit方法中触发model.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 className="login-form">       <Field         placeholder="用户名"         value={state.username}         error={state.errors["username"].errorMessage}         onChange={(e) => {           setState({             ...state,             username: e.target.value,           });         }}       />         ...       <button onClick={submit}>提交</button>     </p>   ); };

然后我们在不输入任何内容的时候点击提交,就会触发错误提示:
用hooks写个登录表单 - 前沿开发团队

useReducer改写

到这一步,感觉我们的表单差不多了,功能好像完成了。但是这样就没问题了吗,我们在Field组件打印 console.log(placeholder, "rendering"),当我们在输入用户名时,发现所的Field组件都重新渲染了。这是可以试着优化的。
那要如何做呢?首先要让Field组件在props不变时能避免重新渲染,我们使用React.memo来包裹Filed组件。

React.memo 为高阶组件。它与 React.PureComponent 非常相似,但只适用于函数组件。如果你的函数组件在给定相同 props 的情况下渲染相同的结果,那么你可以通过将其包装在 React.memo 中调用,以此通过记忆组件渲染结果的方式来提高组件的性能表现

export default React.memo(Filed);

但是仅仅这样的话,Field组件还是全部重新渲染了。这是因为我们的onChange函数每次都会返回新的函数对象,导致memo失效了。
我们可以把Filed的onChange函数用useCallback包裹起来,这样就不用每次组件渲染都生产新的函数对象了。

const changeUserName = useCallback((e) => {   const value = e.target.value;   setState((prevState) => { // 注意因为我们设置useCallback的依赖为空,所以这里要使用函数的形式来获取最新的state(preState)     return {       ...prevState,       username: value,     };   }); }, []);

还有没有其他的方案呢,我们注意到了useReducer,

useReducer 是另一种可选方案,它更适合用于管理包含多个子值的 state 对象。它是useState 的替代方案。它接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。并且,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为你可以向子组件传递 dispatch 而不是回调函数

useReducer的一个重要特征是,其返回的dispatch 函数的标识是稳定的,并且不会在组件重新渲染时改变。那么我们就可以将dispatch放心传递给子组件而不用担心会导致子组件重新渲染。
我们首先定义好reducer函数,用来操作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;   } }

相应的在LoginForm中调用userReducer,传入我们的reducer函数和initialState

const LoginForm = () => {   const [state, dispatch] = useReducer(reducer, initialState);    const submit = ...    return (     <p className="login-form">       <Field         name="username"         placeholder="用户名"         value={state.username}         error={state.errors["username"].errorMessage}         dispatch={dispatch}       />       ...       <button onClick={submit}>提交</button>     </p>   ); };

在Field子组件中新增name属性标识更新的key,并传入dispatch方法

const Filed = ({ placeholder, value, dispatch, error, name }) => {   console.log(name, "rendering");   return (     <p className="form-field">       <input         placeholder={placeholder}         value={value}         onChange={(e) =>           dispatch({             type: "set",             payload: { key: name, value: e.target.value },           })         }       />       {error && <span>{error}</span>}     </p>   ); };  export default React.memo(Filed);

这样我们通过传入dispatch,让子组件内部去处理change事件,避免传入onChange函数。同时将表单的状态管理逻辑都迁移到了reducer中。

全局store

当我们的组件层级比较深的时候,想要使用dispatch方法时,需要通过props层层传递,这显然是不方便的。这时我们可以使用React提供的Context api来跨组件共享的状态和方法。

Context 提供了一个无需为每层组件手动添加 props,就能在组件树间进行数据传递的方法

函数式组件可以利用createContext和useContext来实现。

这里我们不再讲如何用这两个api,大家看看文档基本就可以写出来了。我们使用unstated-next来实现,它本质上是对上述api的封装,使用起来更方便。

我们首先新建一个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 />     </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包裹组件。

当组件上层最近的 <MyContext.Provider> 更新时,该 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 {...props} value={value} dispatch={dispatch} error={error} />   ); };  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           {...props}           {...mapStateProps(state, props)}           dispatch={dispatch}         />       );     };   }; };  export default connect((state, props) => {   return {     value: state[props.name],     error: state.errors[props.name].errorMessage,   }; })(Filed);

dispatch一个函数

使用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 className="login-form">     <Field       name="username"       placeholder="用户名"     />       ....     <button onClick={() => dispatch(login())}>提交</button>   </p> ); }

一个支持异步action的dispatch就完成了。

推荐教程:《JS教程》

赞(0)
分享到: 更多 (0)
网站地图   沪ICP备18035694号-2    沪公网安备31011702889846号