最近嘗試用React hooks相關api寫一個登錄表單,目的就是加深一下對hooks的理解。本文不會講解具體api的使用,只是針對要實現的功能,一步一步深刻。因此閱讀前要對 hooks有基本的認識。最終的樣子有點像用hooks寫一個簡單的相似redux的狀態管理模式。javascript
一個簡單的登陸表單,包含用戶名、密碼、驗證碼3個輸入項,也表明着表單的3個數據狀態,咱們簡單的針對username、password、capacha分別經過useState
創建狀態關係,就是所謂的比較細粒度的狀態劃分。代碼也很簡單:html
// 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;
複製代碼
這種細粒度的狀態,很簡單也很直觀,可是狀態一多的話,要針對每一個狀態寫相同的邏輯,就挺麻煩的,且太過度散。java
咱們將username、password、capacha定義爲一個state就是所謂粗粒度的狀態劃分:react
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不會自動合併狀態項,須要咱們手動合併。git
一個完整的表單固然不能缺乏驗證環節,爲了可以在出現錯誤時,input下方顯示錯誤信息,咱們先抽出一個子組件Field:github
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,咱們定義以下字段驗證:redux
const model = SchemaModel({
username: StringType().isRequired("用戶名不能爲空"),
password: StringType().isRequired("密碼不能爲空"),
captcha: StringType()
.isRequired("驗證碼不能爲空")
.rangeLength(4, 4, "驗證碼爲4位字符"),
});
複製代碼
而後在state中添加errors,並在submit方法中觸發model.check
進行校驗。api
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> ); }; 複製代碼
而後咱們在不輸入任何內容的時候點擊提交,就會觸發錯誤提示: 數組
到這一步,感受咱們的表單差很少了,功能好像完成了。可是這樣就沒問題了嗎,咱們在Field組件打印console.log(placeholder, "rendering")
,當咱們在輸入用戶名時,發現所的Field組件都從新渲染了。這是能夠試着優化的。 瀏覽器
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中。
當咱們的組件層級比較深的時候,想要使用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 時從新渲染
// 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);
複製代碼
使用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的渲染邏輯