用hooks写个登陆表单

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

细粒度的state

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

// 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;

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

粗粒度

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

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不会自动合并状态项,须要咱们手动合并。redux

加入表单校验

一个完整的表单固然不能缺乏验证环节,为了可以在出现错误时,input下方显示错误信息,咱们先抽出一个子组件Field:api

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组件。异步

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 (
    <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来跨组件共享的状态和方法。

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

函数式组件能够利用createContextuseContext来实现。

这里咱们再也不讲如何用这两个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 时从新渲染

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

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 (
  <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的渲染逻辑

相关文章
相关标签/搜索