用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 (

<div 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>

</div>

);

};

export default LoginForm;

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

粗粒度

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

const LoginForm = () => {

const [state, setState] = useState({

username: "",

password: "",

captcha: "",

});

const submit = useCallback(() => {

loginService.login(state);

}, [state]);

return (

<div className="login-form">

<input

placeholder="用户名"

value={state.username}

onChange={(e) => {

setState({

...state,

username: e.target.value,

});

}}

/>

...

<button onClick={submit}>提交</button>

</div>

);

};

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

加入表单校验

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

const Filed = ({ placeholder, value, onChange, error }) => {

return (

<div className="form-field">

<input placeholder={placeholder} value={value} onChange={onChange} />

{error && <span>error</span>}

</div>

);

};

我们使用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 (

<div 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>

</div>

);

};

然后我们在不输入任何内容的时候点击提交,就会触发错误提示:
Jietu20200530-150144.jpg

useReducer改写

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

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的一个重要特征是,其返回的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 (

<div className="login-form">

<Field

name="username"

placeholder="用户名"

value={state.username}

error={state.errors["username"].errorMessage}

dispatch={dispatch}

/>

...

<button onClick={submit}>提交</button>

</div>

);

};

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

const Filed = ({ placeholder, value, dispatch, error, name }) => {

console.log(name, "rendering");

return (

<div className="form-field">

<input

placeholder={placeholder}

value={value}

onChange={(e) =>

dispatch({

type: "set",

payload: { key: name, value: e.target.value },

})

}

/>

{error && <span>{error}</span>}

</div>

);

};

export default React.memo(Filed);

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

全局store

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

函数式组件可以利用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包裹组件。

Jietu20200531-104643.jpg
那么怎么避免这种情况呢,回想一下使用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

通过这个特性,我们可以声明一个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 (

<div className="login-form">

<Field

name="username"

placeholder="用户名"

/>

....

<button onClick={() => dispatch(login())}>提交</button>

</div>

);

}

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

结语

看到这里你会发现,我们使用hooks的能力,实现了一个简单的类似redux的状态管理模式。目前hooks状态管理还没有出现一个被普遍接受的模式,还有折腾的空间。最近Facebook新出的recoil,有空可以研究研究。
上面很多时候,我们为了避免子组件重新渲染,多写了很多逻辑,包括使用useCallback、memeo、useRef。这些函数本身是会消耗一定的内存和计算资源的。事实上render对现代浏览器来说成本很低,所以有时候我们没必要做提前做这些优化,当然本文只是以学习探讨为目的才这么做的。
大家有空可以多看看阿里hooks这个库,能够学到很多hooks的用法,同时惊叹hooks居然可以抽象出这么多业务无关的通用逻辑。

本文完整代码

参考:

React Hooks 你真的用对了吗?
精读《React Hooks 数据流》
10个案例让你彻底理解React hooks的渲染逻辑

以上是 用hooks写个登录表单 的全部内容, 来源链接: utcz.com/a/26646.html

回到顶部