在上一篇小甜點 《我們或許不需要 classnames 這個庫》
) 中, 我們 簡單的使用了一些語法代替了 classnames 這個庫
現在我們調整一下難度, 移除 React 中相對比較復雜的組件: Form 組件
在移除 Form 組件之前, 我們現需要進行一些思考, 為什么會有 Form 組件及Form組件和 React 狀態管理的關系
注意, 接下來的內容非常容易讓 React 開發人員感到不適, 并且極具爭議性
何時不應該使用受控組件
Angular, Vue, 都有雙向綁定, 而 React 官方文檔也為一個 input 標簽的雙向綁定給了一個官方方案 - 受控組件:
https://reactjs.org/docs/forms.html#controlled-components
本文中提到的代碼都可以直接粘貼至項目中進行驗證.
// 以下是官方的受控組件例子:
class NameForm extends React.Component {
constructor(props) {
super(props);
this.state = {value: ''};
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(event) {
this.setState({value: event.target.value});
}
handleSubmit(event) {
alert('A name was submitted: ' + this.state.value);
event.preventDefault();
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
Name:
<input type="text" value={this.state.value} onChange={this.handleChange} />
</label>
<input type="submit" value="Submit" />
</form>
);
}
}
相信寫過 React 項目的人都已經非常熟練, 受控組件就是: 把一個 input 的 value 和 onChange 關聯到某一個狀態中.
很長一段時間, 使用受控組件, 我們都會受到以下幾個困惑:
- 針對較多表單內容的頁面, 編寫受控組件繁瑣
- 跨組件的受控組件需要使用 onChange 等 props 擊鼓傳花, 層層傳遞, 這種情況下做表單聯動就會變得麻煩
社區對以上的解決方案是提供一些表單組件, 比較常用的有:
- Ant Design Form 組件
- no-form 組件
- react-final-form 組件(有hooks版本)
包括我自己也編寫過 Form 組件
它們解決了以下幾個問題:
- 跨組件獲取表單內容
- 表單聯動
- 根據條件去執行或修改表單組件的某些行為, 如:
- 表單校驗
- props屬性控制
- ref獲取函數并執行
其實這些表單都是基于 React 官方受控組件的封裝, 其中 Antd Form 及 no-form 都是參考我們的先知
Dan Abramov 的理念:
單向數據流, 狀態管理至頂而下;
這樣可以確保整個架構數據的同步, 加強項目的穩定性;
它滿足以下 4 個特點:
- 不阻斷數據流
- 時刻準備渲染
- 沒有單例組件
- 隔離本地狀態
Dan Abramov 具體的文章在此處: 編寫有彈性的組件
我一直極力推崇單向數據流的方案, 在之前的項目中一直以 redux + immutable 作為項目管理, 項目也一直穩定運行, 直到 React-Hooks 的方案出現(這是另外的話題).
單向數據流的特點是用計算時間換開發人員的時間, 我們舉一個小例子說明:
如果當前組件樹中有 100 個 組件, 其中50個組件被connect注入了狀態, 那么當發起一個 dispatch 行為, 需要更新1個組件, 這50個組件會被更新, 我們需要使用 immutable 在 shouldComponentUpdate 中進行高效的判斷, 以攔截另外49個不必要更新的組件.
單向數據流的好處是我們永遠只需要維護最頂部的狀態, 減少了系統的混亂程度.
缺點也是明顯的: 我們需要額外的判斷是否更新的開銷
大部分 Form 表單獲取數據的思路也是一個內聚的單向數據流, 每次 onChange 就修改 Form 中的 state, 子組件通過注冊 context, 獲取及更新相應的值. 這是滿足 Dan Abramov 的設計理念的.
而 react-final-form 沒有使用以上模式, 而是通過發布訂閱, 把每個組件的更新加入訂閱, 根據行為進行相應的更新, 按照以上的例子, 它們是如此運作:
如果當前組件樹中有 100 個 組件, 其中50個組件被Form標記了, 那么當發起一個 input 行為, 需要更新1個組件, 會找到這一個組件, 在內部進行setState, 并把相應的值更新到 Form 中的 data 中.
這種設計有沒有違背 React 的初衷呢? 我認為是沒有的, 因為 Form 維護的內容是局部的, 而不是整體的, 我們只需要讓整個 Form 不脫離數據流的管理即可.
通過 react-final-form 這個組件的例子我想明白了一件事情:
單向數據流是幫我們更容易的管理, 但是并不是表示非單向數據流狀態就一定混亂, 就如 react-final-form 組件所管理的表單狀態.
既然 react-final-form 可以這么設計, 我們為什么不能設計局部的, 脫離受控組件的范疇的表單?
好的, 可以進入正題了:
表單內部的組件可以脫離受控組件存在, 只需要讓表單本身為受控組件
使用 form 標簽代替 React Form 組件
我們用一個簡單的例子實現最開始React官方的受控組件的示例代碼:
class App extends React.Component {
formDatas = {};
handleOnChange = event => {
// 在input事件中, 我們將dom元素的值存儲起來, 用于表單提交
this.formDatas[event.target.name] = event.target.value;
};
handleOnSubmit = event => {
console.log('formDatas: ', this.formDatas);
event.preventDefault();
};
render() {
return (
<form onChange={this.handleOnChange} onSubmit={this.handleOnSubmit}>
<input name="username" />
<input name="password" />
<button type="submit" />
</form>
);
}
}
這是最簡單的獲取值, 存儲到一個對象中, 我們會一步步描述如何脫離受控組件進行值和狀態管理, 但是為了后續的代碼更加簡潔, 我們使用 hooks 完成以上行為:
獲取表單內容
function App() {
// 使用 useRef 來存儲數據, 這樣可以防止函數每次被重新執行時無法存儲變量
const { current: formDatas } = React.useRef({});
// 使用 useCallback 來聲明函數, 減少組件重繪時重新聲明函數的開銷
const handleOnChange = React.useCallback(event => {
// 在input事件中, 我們將dom元素的值存儲起來, 用于表單提交
formDatas[event.target.name] = event.target.value;
}, []);
const handleOnSubmit = React.useCallback(event => {
// 提交表單
console.log('formDatas: ', formDatas);
event.preventDefault();
}, []);
return (
<form onChange={handleOnChange} onSubmit={handleOnSubmit}>
<input name="username" />
<input name="password" />
<button type="submit" />
</form>
);
}
接下來的代碼都會在此基礎上, 使用 hooks 語法編寫
跨組件獲取表單內容
我們不需要做任何處理, <form /> 標簽原本就可以獲取其內部的所有表單內容
// 子組件, form標簽一樣可以獲取相應的輸入
function PasswordInput(){
return <div>
<p>密碼:</p>
<input name="password" />
</div>
}
function App() {
const { current: formDatas } = React.useRef({});
const handleOnChange = React.useCallback(event => {
formDatas[event.target.name] = event.target.value;
}, []);
const handleOnSubmit = React.useCallback(event => {
console.log('formDatas: ', formDatas);
event.preventDefault();
}, []);
return (
<form onChange={handleOnChange} onSubmit={handleOnSubmit}>
<input name="username" />
<PasswordInput />
<button type="submit" />
</form>
);
}
表單聯動 \ 校驗
現在我們在之前的基礎上實現一個需求:
如果密碼長度大于8, 將用戶名和密碼重置為默認值
我們通過 form, 將input的dom元素存儲起來, 再在一些情況進行dom操作, 直接更新, 代碼如下:
function App() {
const { current: formDatas } = React.useRef({});
const { current: formTargets } = React.useRef({});
const handleOnChange = React.useCallback(event => {
// 在input事件中, 我們將dom元素的值存儲起來, 用于表單提交
formDatas[event.target.name] = event.target.value;
// 在input事件中, 我們將dom元素儲存起來, 接下來根據條件修改value
formTargets[event.target.name] = event.target;
// 如果密碼長度大于8, 將用戶名和密碼重置為默認值
if (formTargets.password && formDatas.password.length > 8) {
// 修改DOM元素的value, 更新視圖
formTargets.password.value = formTargets.password.defaultValue;
// 如果存儲過
if (formTargets.username) {
// 修改DOM元素的value, 更新視圖
formTargets.username.value = formTargets.username.defaultValue;
}
}
}, []);
const handleOnSubmit = React.useCallback(event => {
console.log('formDatas: ', formDatas);
event.preventDefault();
}, []);
return (
<form onChange={handleOnChange} onSubmit={handleOnSubmit}>
<input defaultValue="hello" name="username" />
<input defaultValue="" name="password" />
<button type="submit" />
</form>
);
}
ReactDOM.render(<App />, document.getElementById('root'));
如上述代碼, 我們很簡單的實現了表單的聯動, 因為直接操作 DOM, 所以整個組件并沒有重新執行 render, 這種更新方案的性能是極佳的(HTML的極限).
在寫 React 的時候我們都非常忌諱
直接操作DOM, 這是因為, 如果我們操作了 DOM, 但是通過React對Node的Diff之后, 又進行更新, 可能會覆蓋掉之前操作DOM的一些行為. 但是如果我們確保這些 DOM 并不是受控組件, 那么就不會發生以上情況.
它會有什么問題么? 當其他行為觸發 React 重繪時, 這些標簽內的值會被清空嗎?
明顯是不會的, 只要React的組件沒有被銷毀, 即便重繪, React也只是獲取到 dom對象修改其屬性:
function App() {
const { current: formDatas } = React.useRef({});
const { current: formTargets } = React.useRef({});
const [value, setValue] = React.useState(10);
// 我們這里每隔 500ms 自動更新, 并且重繪我們的輸入框的字號
React.useEffect(() => {
setInterval(() => {
setValue(v => v + 1);
}, 300);
}, []);
const handleOnChange = React.useCallback(event => {
formDatas[event.target.name] = event.target.value;
formTargets[event.target.name] = event.target;
if (formTargets.password && formDatas.password.length > 8) {
formTargets.password.value = formTargets.password.defaultValue;
if (formTargets.username) {
formTargets.username.value = formTargets.username.defaultValue;
}
}
}, []);
const handleOnSubmit = React.useCallback(event => {
console.log('formDatas: ', formDatas);
event.preventDefault();
}, []);
return (
<form onChange={handleOnChange} onSubmit={handleOnSubmit}>
<p>{value}</p>
<input defaultValue="hello" name="username" />
{/* p 標簽會一直被 setState 更新, 字號逐步增大, 我們輸入的值并沒有丟失 */}
<input defaultValue="" name="password" style={{ fontSize: value }} />
<button type="submit" />
</form>
);
}
但是, 如果標簽被銷毀了, 非受控組件的值就不會被保存
以下例子, input輸入了值之后, 被消耗再被重繪, 此時之前input的值已經丟失了
function App() {
const { current: formDatas } = React.useRef({});
const { current: formTargets } = React.useRef({});
const [value, setValue] = React.useState(0);
React.useEffect(() => {
setInterval(() => {
setValue(v => v + 1);
}, 500);
}, []);
const handleOnChange = React.useCallback(event => {
formDatas[event.target.name] = event.target.value;
formTargets[event.target.name] = event.target;
}, []);
const handleOnSubmit = React.useCallback(event => {
console.log('formDatas: ', formDatas);
event.preventDefault();
}, []);
return (
<form onChange={handleOnChange} onSubmit={handleOnSubmit}>
{/* 如果 value 是 5 的整數倍, input 會被銷毀, 已輸入的值會丟失 */}
{value % 5 !== 0 && <input name="username" />}
{/* 我們可以使用 defaultValue 去讀取歷史的值, 讓重繪時讀取之前輸入的值 */}
{value % 5 !== 0 && <input defaultValue={formDatas.password} name="password" />}
{/* 如果可能, 我們最好使用 display 代替條件渲染 */}
<input name="code" style={{ display: value % 5 !== 0 ? 'block' : 'none' }} />
<button type="submit" />
</form>
);
}
如代碼中的注釋所述:
- 如果 input 被銷毀, 已輸入的值會丟失
- 我們可以使用 defaultValue 去讀取歷史的值, 讓重繪時讀取之前輸入的
- 如果可能, 我們最好使用 display 代替條件渲
好了, 我們在了解了直接操作DOM的優點和弊端之后, 我們繼續實現表單常見的其他行為.
跨層級組件通信
根據條件執行某子組件的函數, 我們只需要獲取該組件的ref即可, 但是如果涉及到多層級的組件, 這就會很麻煩.
傳統 Form 組件會提供一個 FormItem, FormItem會獲取 context, 從而提供跨多級組件的通信
而我們如何既然已經獲取到dom了, 我們只需要在dom上捆綁事件, 就可以無痛的做到跨層級的通信. 這個行為完全違反我們平時編寫React的思路和常規操作, 但是通過之前我們對 "標簽銷毀" 的理解, 通常可以使它在可控的范圍內.
我們看看實現的代碼案例:
// 此為子子組件
function SubInput() {
const ref = React.useRef();
React.useEffect(() => {
if (ref.current) {
// 在DOM元素上捆綁一個函數, 此函數可以執行此組件的上下文事件
ref.current.saved = name => {
console.log('do saved by: ', name);
};
}
}, [ref]);
return (
<div>
{/* 獲取表單的DOM元素 */}
<input ref={ref} name="sub-input" />
</div>
);
}
// 此為子組件, 僅引用了子子組件
function Input() {
return (
<div>
<SubInput />
</div>
);
}
function App() {
const { current: formDatas } = React.useRef({});
const { current: formTargets } = React.useRef({});
const handleOnChange = React.useCallback(event => {
formDatas[event.target.name] = event.target.value;
formTargets[event.target.name] = event.target;
// 直接通過dom元素上的屬性, 獲取子子組件的事件
event.target.saved && event.target.saved(event.target.name);
}, []);
const handleOnSubmit = React.useCallback(event => {
console.log('formDatas: ', formDatas);
event.preventDefault();
}, []);
return (
<form onChange={handleOnChange} onSubmit={handleOnSubmit}>
{/* 我們應用了某個子子組件, 并且沒用傳遞任何 props, 也沒有捆綁任何 context, 沒有獲取ref */}
<Input />
</form>
);
}
根據此例子我們可以看到, 使用 html 的 form 標簽,就可以完成我們絕大部分的 Form 組件的場景, 而且開發效率和執行效率都更高.
爭議
通過操作DOM, 我們可以很天然解決一些 React 非常棘手才能解決的問題. 誠然這有點像在刀尖上跳舞, 但是此文中給出了一些會遇到的問題及解決方案.
我非常歡迎對此類問題的討論, 有哪些還會遇到的問題, 如果能清晰的將其原理及原因描述并回復到此文, 那是對所有閱讀者的幫助.
寫在最后
請不要被教條約束, 試試挑戰它.