首頁  >  文章  >  web前端  >  用hooks寫個登入表單 - 前沿開發團隊

用hooks寫個登入表單 - 前沿開發團隊

hzc
hzc轉載
2020-06-22 18:02:372409瀏覽

最近嘗試用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>
      <input> {
          setUsername(e.target.value);
        }}
      />
      <input> {
          setPassword(e.target.value);
        }}
      />
      <input> {
          setCaptcha(e.target.value);
        }}
      />
      <button>提交</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>
      <input> {
          setState({
            ...state,
            username: e.target.value,
          });
        }}
      />
      ...
      <button>提交</button>
    </p>
  );
};

可以看到,setXXX 方法減少了,setState的命名也比較貼切,只是這個setState不會自動合併狀態項,需要我們手動合併。

加入表單校驗

一個完整的表單當然不能缺少驗證環節,為了能夠在出現錯誤時,input下方顯示錯誤訊息,我們先抽出一個子元件Field:

const Filed = ({ placeholder, value, onChange, error }) => {
  return (
    <p>
      <input>
      {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>
      <field> {
          setState({
            ...state,
            username: e.target.value,
          });
        }}
      />
        ...
      <button>提交</button>
    </field></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>
      <field></field>
      ...
      <button>提交</button>
    </p>
  );
};

在Field子元件中新增name屬性識別更新的key,並傳入dispatch方法

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

這樣我們透過傳入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></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);

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>
    <field></field>
      ....
    <button> dispatch(login())}>提交</button>
  </p>
);
}

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

推荐教程:《JS教程

以上是用hooks寫個登入表單 - 前沿開發團隊的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:segmentfault.com。如有侵權,請聯絡admin@php.cn刪除