最近嘗試用React hooks相關api寫一個登陸表單,目的就是加深對hooks的理解。本文不會講解具體api的使用,只是針對要實現的功能,一步一步深入。所以閱讀前要對 hooks有基本的認識。最後的樣子有點像是用hooks寫一個簡單的類似redux的狀態管理模式。
一個簡單的登入表單,包含使用者名稱、密碼、驗證碼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> ); };
然後我們在不輸入任何內容的時候點擊提交,就會觸發錯誤提示:
到這一步,感覺我們的表單差不多了,功能好像完成了。但這樣就沒問題了嗎,我們在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中。
當我們的元件層級比較深的時候,想要使用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);
使用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中文網其他相關文章!