【第2037期】React Hooks 实践指南
前言
现今还有好玩的东西么?今日早读文章由@繁星授权分享。
正文从这开始~~
在良好抽象的基础上,实现关注分离并合理地复用代码,这是编程的核心。
组件化开发可以帮助前端实现一定程度的关注分离,但其主要解决 UI 的复用,我们日常开发过程中还面临着 state 逻辑的关注分离与复用问题。
State 逻辑的复用
下面是两个纯组件,分别用来展示 users 和 posts 信息:
const Users = props => {
return (
<ul>
{props.data.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
};
const Posts = props => {
return (
<ul>
{props.data.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
);
};
users 和 posts 数据获取的方式是一样的,我们通过 HOC 来实现请求数据的 state 逻辑复用:
const withLoader = (BaseComponent, apiUrl) => {
class EnhancedComponent extends React.Component {
state = {
data: null,
};
componentDidMount() {
fetch(apiUrl)
.then(res => res.json())
.then(data => {
this.setState({ data });
});
}
render() {
if (!this.state.data) {
return 'Loading ...';
}
return <BaseComponent data={this.state.data}/>;
}
}
return EnhancedComponent;
};
最终使用如下:
import React, { Component } from "react";
import { render } from "react-dom";
const EnhancedUsers = withLoader(
Users,
"https://jsonplaceholder.typicode.com/users"
);
const EnhancedPosts = withLoader(
Posts,
"https://jsonplaceholder.typicode.com/posts/"
);
class App extends Component {
render() {
return (
<div>
<h2> users </h2>
<EnhancedUsers />
<h2> posts </h2>
<EnhancedPosts />
</div>
);
}
}
render(<App />, document.getElementById("root"));
上面的例子相对简单,如果遇到复杂的业务逻辑,HOC 的缺点很明显:比如属性不能完全一致导致覆盖,又或者遇到黑盒问题,必须到 BaseComponent 查看实现细节等。
另外一种实现 state 逻辑复用的方式是 Render Props:
class Loader extends React.Component {
constructor(props) {
super(props);
this.state = {
data: []
};
}
componentDidMount() {
fetch(this.props.apiUrl)
.then(res => res.json())
.then(data => {
this.setState({ data });
});
}
render() {
if (!this.state.data) {
return "Loading ...";
}
return this.props.children({data: this.state.data})
}
}
此时最终使用如下:
import React, { Component } from "react";
import { render } from "react-dom";
class App extends Component {
render() {
return (
<div>
<h2> users </h2>
<Loader apiUrl="https://jsonplaceholder.typicode.com/users">
{({ data }) => <Users data={data} />}
</Loader>
<h2> posts </h2>
<Loader apiUrl="https://jsonplaceholder.typicode.com/posts/">
{({ data }) => <Posts data={data} />}
</Loader>
</div>
);
}
}
render(<App />, document.getElementById("root"));
使用 Render Props 可以避免 HOC 所遇到的问题,但是很容易陷入标签嵌套地狱。
除了上面提及的问题之外,日常开发我们还经常面临:
代码写起来很复杂,不清爽,复杂业务很容易导致代码量剧增;
分割在不同生命周期中的 state 逻辑使代码难以理解;
this 问题所带来的困扰;
UI 与 可复用的 State 逻辑分离
Componet 在 pros 发生改变时会重新 render,这是 React 组件化设计的一个基础约定。
我们也见过其他形式,例如基于原生 JavaScript 的地图渲染引擎中常常可以看到类似这样的代码:
const map = L.map('map').setView([51.505, -0.09], 13);
如果将其改写为 React 的 Componet 形式,代码会是:
<Map id="map" zoom={13} position={[51.505, -0.09]} />
过去主流的前端架构体系均通过 this 将 state 与生命周期函数绑定,将 state 的逻辑分割在组件的不同生命周期中。在这个基础上, state 的逻辑的复用只能围绕 props 来开展。
HOC 和 Render Props 实现 state 逻辑复用均是建立在 props 传递之上的,所以显得十分笨拙。那么是否有更好的方式呢?
React 团队基于 Function Component 提出了 Hooks 的概念,包含了 useState、useEffect、useContext 等几个关键 API。
使用这些 API 我们可以将可复用的 state 逻辑与 UI 分离,这样我们无需基于 props 实现逻辑复用,而是通过灵活的组合将可复用的 state 逻辑使用在不同的组件中。这种方式不仅用起来非常简单,而且让 React 更 Reactive:
function useLoader (apiUrl) {
const [data, setData] = useState([]);
useEffect(() => {
fetch(apiUrl)
.then(res => res.json())
.then(data => {
setData(data);
});
}, []);
return data;
}
最终使用如下:
import React from "react";
import { render } from "react-dom";
const App = () => {
const users = useLoader('https://jsonplaceholder.typicode.com/users');
const posts = useLoader("https://jsonplaceholder.typicode.com/posts/");
return <>
<Users data={users} />
<Posts data={posts} />
</>
}
render(<App />, document.getElementById("root"));
Hooks 使用闭包来将 state 和处理 state 的方法关联起来,这种方式相比于使用 Class 能降低可观的代码量,且代码看起来十分清爽。
Hooks 的好处非常明显,且十分好用!
但好用并不等于上手快,这一点和 React 框架本身很像:语法和概念简单,API 少,但想很好的驾驭需要一定的内功,对于编程能力不足的人来说有一定的挑战。
Hooks 的使用
Hooks 是 Function,所以我们只要划分好职责,明确输入和输出便可以尽情享受 Hooks 带来的编程乐趣:
可复用的 Custom Hooks 常用于浏览器 API 调用、事件处理等副作用处理上,一般会使用 useState 和 useEffect,上文中用于数据获取的 useLoader 就是一个典型的场景。
下面是随时获取到浏览器窗口宽度的代码:
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return width;
}
我们也可以对常见的数据结构进行封装,用以返回 state 和对应的可触发页面更新的 setState ,比如:
function useArray(array) {
const [value, setValue] = useState(array);
const operators = useMemo(() => ({
push: item => {
setValue(v => [...v, item])
},
pop: () => setValue(v => v.slice(0, -1)),
removeIndex: index => {
setValue(v => {
const copy = v.slice();
copy.splice(index, 1);
return copy;
});
},
clear: () => setValue([])
}), []);
return [value, operators]
}
const TODOS = () => {
const [todos, operators] = useArray(["hi there", "sup", "world"]);
return (
<div>
<ul>
{todos.map((item, idx) => <li key={idx}>{item}</li>)}
</ul>
<button onClick={() => operators.push(Math.random())}> add </button>
<button onClick={operators.clear}> clear todos </button>
</div>
);
};
凡是可以跨组件复用的单一职责的 state 逻辑,这些逻辑无论简单与否,当相同的逻辑代码多次出现时,就可以考虑提取出来:
function useModalVisible() {
const [visible, setVisible] = useState(false);
const openModal = useCallback(() => setVisible(true), [])
const closeModal = useCallback(() => setVisible(false), []);
return [visible, openModal, closeModal]
}
Hooks 可以根据需要进行嵌套或组合使用,例如:
function useModalSize() {
const width = useWindowWidth();
const size = useMemo(() => {
if (width < 800) { return 'small' }
if (width >= 800 && width < 1366) { return 'middle'}
if (width > 1366) { return 'large' }
}, [width])
return size;
}
const MyModal = () => {
const [visible, openModal, closeModal] = useModalVisible();
const size = useModalSize();
return <>
<Button onClick={openModal} onCancel={closeModal}>Open Modal</Button>
<Modal visible={visible} size={size}>
<p> Modal Content </p>
</Modal>
</>
}
基于 useRef 存储实现的一些功能性 Hooks,例如:
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
const Counter = () => {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
useEffect(() => setTimeout(() => setCount(10), 2000), []);
return <h1> Now: {count}, before: {prevCount} </h1>
};
基于 useContext 更方便实现跨组件共享 state 的管理
import React, { createContext, useContext } from "react";
const createContainer = (useHook) => {
const Context = createContext();
const useContainer = () => {
return useContext(Context);
};
const Provider = ({ initialState, children }) => {
const value = useHook(initialState);
return <Context.Provider value={value}>{children}</Context.Provider>;
};
return { Provider, useContainer }
};
createContainer 的使用如下:
const useCounter = () => {
let [count, setCount] = useState(0)
let decrement = () => setCount(count - 1)
let increment = () => setCount(count + 1)
return { count, decrement, increment }
}
const Counter = createContainer(useCounter)
const CounterDisplay = () => {
let {decrement, count, increment} = Counter.useContainer()
return (
<div>
<button onClick={decrement}>-</button>
<p>You clicked {count} times</p>
<button onClick={increment}>+</button>
</div>
)
}
const APP = () => {
return (
<Counter.Provider>
<CounterDisplay />
<CounterDisplay />
<CounterDisplay />
</Counter.Provider>
)
}
Hooks 的设计缺陷
我们知道,在 Class 组件的设计中是通过 this 将 state 与对应的处理方法关联在一起,这样主要包含两个方面:
处理用户交互的回调里可以通过 this 访问 state 与 setState;
在组件初始化或者更新的过程中,各生命周期方法可以通过 this 访问 state 与 setState;
但 Function Component 不在有生命周期的概念:Hooks 是通过闭包实现 state 与对应的处理方法关联在一起,而且每一次更新时 Function Component 的所有部分都会执行。
我们把 Function Component 每一次更新后所对应的 state 称作一次快照,React 的 Hooks 会根据执行顺序在内部维护一个递增的 index 来将闭包里的变量映射到对应的 state,并且只在第一次 render 时接受 initState, 之后每次 render 都通过 index 从闭包里获取对应的 state 值。例如以下代码:
const [dataA, setDataA] = useState(0);
const [dataB, setDataB] = useState('string');
const [dataC, setDataC] = useState({});
每次快照:
副作用 useEffect 在每一次快照中会将其 Array Dependency 中的 state 和 返回的 cleanup 方法存储在自己的 hooks[index] 中。在下一次更新时会先执行 cleanup 方法,然后对比依赖的state 与上一次相比是否发生变化,进而决定副作用回调方法是否执行。
useEffect 的代码实现大致如下:
useEffect(cb, depArray) {
const hasNoDeps = !depArray;
hooks[idx] = hooks[idx] || {};
const {deps, cleanup} = hooks[idx]; // undefined when first render
const hasChanged = deps
? !depArray.every((el, i) => el === deps[i])
: true;
if (hasNoDeps || hasChanged) {
cleanup && cleanup();
hooks[idx].cleanup = cb();
hooks[idx].deps = depArray;
}
idx++;
}
useMemo 与 useCallback 原理与 useEffect 类似,会存储所依赖的 state 并在下一次更新时做对比,再根据依赖是否发生变化返回对应的结果。
这样 Hooks 就可以基于 Function Component 做到:
每次 render 通过递增的 index 访问闭包里所有的 state 与 setState,且它们可以被处理用户交互的回调方法或 useEffect 的回调方法所使用;
副作用代码不再被分割到生命周期方法中,而是在分离关注后形成单一职责的 effect 回调函数,并在每次更新时通过判断所依赖 state 是否发生变化而决定是否执行;
这样我们就可以让 Function Component 拥有与 Class Component 一样的能力。
但需要注意的是,这样的设计并不完美,缺陷非常明显:
由于通过递增的 index 访问, Hooks 的执行顺序要在每次 render 时必须保持一致,对于新手来说是一个大坑;
useEffect 解决了 Function Component 无生命周期时所面临问题,但 useEffect 并不是干掉了生命周期的概念,而是隐藏了生命周期概念,尤其是约定当依赖数组为 [] 时 返回的 cleanup 方法等价于 componentWillUnmount,这理解起来有些突兀,新手很容易在使用 useEffect 返回 cleanup 时踩坑;
useEffect(() => {
console.log("I'm mounted!");
return () => console.log("I'm going to unmount!");
}, []);
这些从设计根源上所带来的问题,需要我们在利用 React Hooks 优点简化代码,提高代码可读性和复用性时,努力避免踏入其缺陷误区。
令人困惑的 Dependency Array
从上文我们可以得知,Dependency Array 在 Hooks 中的作用主要有两点:
更新依赖,例如 useCallback、useMemo;
触发 useEffect 执行;
Dependency Array 在 useEffect 中的滥用比较多。新手往往会在 useEffect 的 Dependency Array 里放入许多本不应该放入的依赖变量,从而导致许多副作用回调被过多或异常触发。
错误的依赖变量对比
const data = {a: 1, b: 2};
// 改为 const data = useMemo(() => ({a: 1, b: 2}), [])
useEffect(() => {
// do something
}, [data]);
或者由于 props 的错误传入导致
const Component = ({arr}) => {
useEffect(() => {
// do something
}, [arr]);
return (...)
}
<Component arr={[1, 2]} frequent={frequent} />
// 改为 const arr = useMemo(() => [1, 2], []); <Component arr={arr} frequent={frequent} />
不遵循单一职责,没有使用多个 effect 来分离问题;
useEffect(() => {
solveProblem1(a);
solveProblem2(b);
}, [a, b]);
问题 1 依赖变量 a,问题 2 依赖变量 b,但如果放在同一个 useEffect 中,b 的变更也会导致问题 1 逻辑的执行。
未将 effect 触发执行的 action 与真实的回调执行逻辑解耦;
const [title, setTitle] = useState(null);
const [abstract, setAbstract] = useState(null);
const [content, setContent] = useState(null);
useEffect(() =>
window.addEventListener('beforeunload', () => {
save(title, abstract, content);
});
}, [title, content, content]);
<input value={title} />
<input value={abstract} />
<textarea value={content} /></textarea>
上面的例子中,每次 form 输入都会触发一次事件监听。下面这段更隐晦的代码是等效的:
const supSave = useCallback(() => {
save(title, abstract, content);
}, [title, abstract, content]);
useEffect(() =>
window.addEventListener('beforeunload', supSave);
}, [subSave]);
比较糙的解决方法是
const [article, setArticle] = useState({ title, abstract, content});
const supSave = useCallback(() => {
setArticle(article => {
const {title, abstract, content} = article;
save(title, abstract, content);
});
}, []);
useEffect(() =>
window.addEventListener('beforeunload', supSave);
}, [supSave]);
<input value={article.title} />
<input value={article.abstract} />
<textarea value={article.content} /></textarea>
如果希望代码更加优雅,可以使用 useReducer,可以达到上面代码相同的效果。他们解决问题的本质是:拒绝从 useEffect 的 Array dependency 中获取副作用回调执行所需要的 state!
我们知道,在每一次 render 时取到的 setState 或 useReducer 返回的 dispatch 都是第一次 render 生成并留在内存中的对象,所以 stateState 或 dispatch 是稳定不变的,我们可以放心使用。
我们可以利用 setState 的 callback 参数获取 state,甚至你可以通过以下代码实现类似 useReducer 的效果:
const [state, setState] = useState({});
function dispatch(type, value) {
if (type === 'type1') {
setState(state => ({
...state,
a: 'value'
}));
}
if (type === 'type2') {
setState(state => value)
}
}
我们在使用 useEffect 时应该优先思考的原则是:
在复杂的副作里,将逻辑拆分为 action 和 callback 两部分,这与 flux 思想类似:useEffect 中避免直接修改 state,只能触发 action。管理 state 的逻辑放在 callback 中,通过侦听 action 来执行具体的操作;
管理 state 逻辑的 callback 要么通过 setState 的参数获取所需 state,要么通过 useReducer,我们可以在 reducer 参数里获取到全部 state;
useEffect 的 Array Dependency 里只包含触发 action 的变量;
只要我们按照这三个原则去使用 useEffect,就一定可以避免绝大部分误区!
关于本文 作者:@繁星 原文:https://zhuanlan.zhihu.com/p/163381120
@繁星曾分享过
【第1508期】深入浅出 Javascript Decorators 和 AOP 编程
欢迎自荐投稿,前端早读课等你来