精读《怎么用 React Hooks 造轮子》

1 引言

上周的 精读《React Hooks》 已经实现了对 React Hooks 的基本认知,也许你也看了 React Hooks 基本实现剖析(就是数组),但理解实现原理就能够用好了吗?学的是知识,而用的是技能,看别人的用法就像刷抖音同样(哇,饭还能够这样吃?),你总会有新的收获。css

这篇文章将这些知识实践起来,看看广大程序劳动人民是如何发掘 React Hooks 的潜力的(造什么轮子)。html

首先,站在使用角度,要理解 React Hooks 的特色是 “很是方便的 Connect 一切”,因此不管是数据流、Network,或者是定时器均可以监听,有一点 RXJS 的意味,也就是你能够利用 React Hooks,将 React 组件打形成:任何事物的变化都是输入源,当这些源变化时会从新触发 React 组件的 render,你只须要挑选组件绑定哪些数据源(use 哪些 Hooks),而后只管写 render 函数就好了!前端

2 精读

参考了部分 React Hooks 组件后,笔者按照功能进行了一些分类。react

因为 React Hooks 并非很是复杂,因此就不按照技术实现方式去分类了,毕竟技术总有一天会熟练,并且按照功能分类才有持久的参考价值。

DOM 反作用修改 / 监听

作一个网页,总有一些看上去和组件关系不大的麻烦事,好比修改页面标题(切换页面记得改为默认标题)、监听页面大小变化(组件销毁记得取消监听)、断网时提示(一层层装饰器要堆成小山了)。而 React Hooks 特别擅长作这些事,造这种轮子,大小皆宜。git

因为 React Hooks 下降了高阶组件使用成本,那么一套生命周期才能完成的 “杂耍” 将变得很是简单。

下面举几个例子:github

修改页面 title

效果:在组件里调用 useDocumentTitle 函数便可设置页面标题,且切换页面时,页面标题重置为默认标题 “前端精读”。spring

useDocumentTitle("我的中心");

实现:直接用 document.title 赋值,不能再简单。在销毁时再次给一个默认标题便可,这个简单的函数能够抽象在项目工具函数里,每一个页面组件都须要调用。json

function useDocumentTitle(title) {
  useEffect(
    () => {
      document.title = title;
      return () => (document.title = "前端精读");
    },
    [title]
  );
}

在线 Demoredux

监听页面大小变化,网络是否断开

效果:在组件调用 useWindowSize 时,能够拿到页面大小,而且在浏览器缩放时自动触发组件更新。数组

const windowSize = useWindowSize();
return <div>页面高度:{windowSize.innerWidth}</div>;

实现:和标题思路基本一致,此次从 window.innerHeight 等 API 直接拿到页面宽高便可,注意此时能够用 window.addEventListener('resize') 监听页面大小变化,此时调用 setValue 将会触发调用自身的 UI 组件 rerender,就是这么简单!

最后注意在销毁时,removeEventListener 注销监听。

function getSize() {
  return {
    innerHeight: window.innerHeight,
    innerWidth: window.innerWidth,
    outerHeight: window.outerHeight,
    outerWidth: window.outerWidth
  };
}

function useWindowSize() {
  let [windowSize, setWindowSize] = useState(getSize());

  function handleResize() {
    setWindowSize(getSize());
  }

  useEffect(() => {
    window.addEventListener("resize", handleResize);
    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []);

  return windowSize;
}

在线 Demo

动态注入 css

效果:在页面注入一段 class,而且当组件销毁时,移除这个 class。

const className = useCss({
  color: "red"
});

return <div className={className}>Text.</div>;

实现:能够看到,Hooks 方便的地方是在组件销毁时移除反作用,因此咱们能够安心的利用 Hooks 作一些反作用。注入 css 天然没必要说了,而销毁 css 只要找到注入的那段引用进行销毁便可,具体能够看这个 代码片断

DOM 反作用修改 / 监听场景有一些现成的库了,从名字上就能看出来用法: document-visibilitynetwork-statusonline-statuswindow-scroll-positionwindow-sizedocument-title

组件辅助

Hooks 还能够加强组件能力,好比拿到并监听组件运行时宽高等。

获取组件宽高

效果:经过调用 useComponentSize 拿到某个组件 ref 实例的宽高,而且在宽高变化时,rerender 并拿到最新的宽高。

const ref = useRef(null);
let componentSize = useComponentSize(ref);

return (
  <>
    {componentSize.width}
    <textArea ref={ref} />
  </>
);

实现:和 DOM 监听相似,此次换成了利用 ResizeObserver 对组件 ref 进行监听,同时在组件销毁时,销毁监听。

其本质仍是监听一些反作用,但经过 ref 的传递,咱们能够对组件粒度进行监听和操做了。

useLayoutEffect(() => {
  handleResize();

  let resizeObserver = new ResizeObserver(() => handleResize());
  resizeObserver.observe(ref.current);

  return () => {
    resizeObserver.disconnect(ref.current);
    resizeObserver = null;
  };
}, []);

在线 Demo,对应组件 component-size

拿到组件 onChange 抛出的值

效果:经过 useInputValue() 拿到 Input 框当前用户输入的值,而不是手动监听 onChange 再腾一个 otherInputValue 和一个回调函数把这一堆逻辑写在无关的地方。

let name = useInputValue("Jamie");
// name = { value: 'Jamie', onChange: [Function] }
return <input {...name} />;

能够看到,这样不只没有占用组件本身的 state,也不须要手写 onChange 回调函数进行处理,这些处理都压缩成了一行 use hook。

实现:读到这里应该大体能够猜到了,利用 useState 存储组件的值,并抛出 valueonChange,监听 onChange 并经过 setValue 修改 value, 就能够在每次 onChange 时触发调用组件的 rerender 了。

function useInputValue(initialValue) {
  let [value, setValue] = useState(initialValue);
  let onChange = useCallback(function(event) {
    setValue(event.currentTarget.value);
  }, []);

  return {
    value,
    onChange
  };
}

这里要注意的是,咱们对组件加强时,组件的回调通常不须要销毁监听,并且仅需监听一次,这与 DOM 监听不一样,所以大部分场景,咱们须要利用 useCallback 包裹,并传一个空数组,来保证永远只监听一次,并且不须要在组件销毁时注销这个 callback。

在线 Demo,对应组件 input-value

作动画

利用 React Hooks 作动画,通常是拿到一些具备弹性变化的值,咱们能够将值赋给进度条之类的组件,这样其进度变化就符合某种动画曲线。

在某个时间段内获取 0-1 之间的值

这个是动画最基本的概念,某个时间内拿到一个线性增加的值。

效果:经过 useRaf(t) 拿到 t 毫秒内不断刷新的 0-1 之间的数字,期间组件会不断刷新,但刷新频率由 requestAnimationFrame 控制(不会卡顿 UI)。

const value = useRaf(1000);

实现:写起来比较冗长,这里简单描述一下。利用 requestAnimationFrame 在给定时间内给出 0-1 之间的值,那每次刷新时,只要判断当前刷新的时间点占总时间的比例是多少,而后作分母,分子是 1 便可。

在线 Demo,对应组件 use-raf

弹性动画

效果:经过 useSpring 拿到动画值,组件以固定频率刷新,而这个动画值以弹性函数进行增减。

实际调用方式通常是,先经过 useState 拿到一个值,再经过动画函数包住这个值,这样组件就会从本来的刷新一次,变成刷新 N 次,拿到的值也随着动画函数的规则变化,最后这个值会稳定到最终的输入值(如例子中的 50)。

const [target, setTarget] = useState(50);
const value = useSpring(target);

return <div onClick={() => setTarget(100)}>{value}</div>;

实现:为了实现动画效果,须要依赖 rebound 库,它能够实现将一个目标值拆解为符合弹性动画函数过程的功能,那咱们须要利用 React Hooks 作的就是在第一次接收到目标值是,调用 spring.setEndValue 来触发动画事件,并在 useEffect 里作一次性监听,再值变时从新 setValue 便可。

最神奇的 setTarget 联动 useSpring 从新计算弹性动画部分,是经过 useEffect 第二个参数实现的:

useEffect(
  () => {
    if (spring) {
      spring.setEndValue(targetValue);
    }
  },
  [targetValue]
);

也就是当目标值变化后,才会进行新的一轮 rerender,因此 useSpring 并不须要监听调用处的 setTarget,它只须要监听 target 的变化便可,而巧妙利用 useEffect 的第二个参数能够事半功倍。

在线 Demo

Tween 动画

明白了弹性动画原理,Tween 动画就更简单了。

效果:经过 useTween 拿到一个从 0 变化到 1 的值,这个值的动画曲线是 tween。能够看到,因为取值范围是固定的,因此咱们不须要给初始值了。

const value = useTween();

实现:经过 useRaf 拿到一个线性增加的值(区间也是 0 ~ 1),再经过 easing 库将其映射到 0 ~ 1 到值便可。这里用到了 hook 调用 hook 的联动(经过 useRaf 驱动 useTween),还能够在其余地方触类旁通。

const fn: Easing = easing[easingName];
const t = useRaf(ms, delay);

return fn(t);

发请求

利用 Hooks,能够将任意请求 Promise 封装为带有标准状态的对象:loading、error、result。

通用 Http 封装

效果:经过 useAsync 将一个 Promise 拆解为 loading、error、result 三个对象。

const { loading, error, result } = useAsync(fetchUser, [id]);

实现:在 Promise 的初期设置 loading,结束后设置 result,若是出错则设置 error,这里能够将请求对象包装成 useAsyncState 来处理,这里就不放出来了。

export function useAsync(asyncFunction) {
  const asyncState = useAsyncState(options);

  useEffect(() => {
    const promise = asyncFunction();
    asyncState.setLoading();
    promise.then(
      result => asyncState.setResult(result);,
      error => asyncState.setError(error);
    );
  }, params);
}

具体代码能够参考 react-async-hook,这个功能建议仅了解原理,具体实现由于有一些边界状况须要考虑,好比组件 isMounted 后才能相应请求结果。

Request Service

业务层通常会抽象一个 request service 作统一取数的抽象(好比统一 url,或者能够统一换 socket 实现等等)。假如之前比较 low 的作法是:

async componentDidMount() {
  // setState: 改 isLoading state
  try {
    const data = await fetchUser()
    // setState: 改 isLoading、error、data
  } catch (error) {
    // setState: 改 isLoading、error
  }
}

后来把请求放在 redux 里,经过 connect 注入的方式会稍微有些改观:

@Connect(...)
class App extends React.PureComponent {
  public componentDidMount() {
    this.props.fetchUser()
  }

  public render() {
    // this.props.userData.isLoading | error | data
  }
}

最后会发现仍是 Hooks 简洁明了:

function App() {
  const { isLoading, error, data } = useFetchUser();
}

useFetchUser 利用上面封装的 useAsync 能够很容易编写:

const fetchUser = id =>
  fetch(`xxx`).then(result => {
    if (result.status !== 200) {
      throw new Error("bad status = " + result.status);
    }
    return result.json();
  });

function useFetchUser(id) {
  const asyncFetchUser = useAsync(fetchUser, id);
  return asyncUser;
}

填表单

React Hooks 特别适合作表单,尤为是 antd form 若是支持 Hooks 版,那用起来会方便许多:

function App() {
  const { getFieldDecorator } = useAntdForm();

  return (
    <Form onSubmit={this.handleSubmit} className="login-form">
      <FormItem>
        {getFieldDecorator("userName", {
          rules: [{ required: true, message: "Please input your username!" }]
        })(
          <Input
            prefix={<Icon type="user" style={{ color: "rgba(0,0,0,.25)" }} />}
            placeholder="Username"
          />
        )}
      </FormItem>
      <FormItem>
        <Button type="primary" htmlType="submit" className="login-form-button">
          Log in
        </Button>
        Or <a href="">register now!</a>
      </FormItem>
    </Form>
  );
}

不过虽然如此,getFieldDecorator 仍是基于 RenderProps 思路的,完全的 Hooks 思路是利用以前说的 组件辅助方式,提供一个组件方法集,用解构方式传给组件

Hooks 思惟的表单组件

效果:经过 useFormState 拿到表单值,而且提供一系列 组件辅助 方法控制组件状态。

const [formState, { text, password }] = useFormState();
return (
  <form>
    <input {...text("username")} required />
    <input {...password("password")} required minLength={8} />
  </form>
);

上面能够经过 formState 随时拿到表单值,和一些校验信息,经过 password("pwd") 传给 input 组件,让这个组件达到受控状态,且输入类型是 password 类型,表单 key 是 pwd。并且能够看到使用的 form 是原生标签,这种表单加强是至关解耦的。

实现:仔细观察一下结构,不难发现,咱们只要结合 组件辅助 小节说的 “拿到组件 onChange 抛出的值” 一节的思路,就能轻松理解 textpassword 是如何做用于 input 组件,并拿到其输入状态

往简单的来讲,只要把这些状态 Merge 起来,经过 useReducer 聚合到 formState 就能够实现了。

为了简化,咱们只考虑对 input 的加强,源码仅需 30 几行:

export function useFormState(initialState) {
  const [state, setState] = useReducer(stateReducer, initialState || {});

  const createPropsGetter = type => (name, ownValue) => {
    const hasOwnValue = !!ownValue;
    const hasValueInState = state[name] !== undefined;

    function setInitialValue() {
      let value = "";
      setState({ [name]: value });
    }

    const inputProps = {
      name, // 给 input 添加 type: text or password
      get value() {
        if (!hasValueInState) {
          setInitialValue(); // 给初始化值
        }
        return hasValueInState ? state[name] : ""; // 赋值
      },
      onChange(e) {
        let { value } = e.target;
        setState({ [name]: value }); // 修改对应 Key 的值
      }
    };

    return inputProps;
  };

  const inputPropsCreators = ["text", "password"].reduce(
    (methods, type) => ({ ...methods, [type]: createPropsGetter(type) }),
    {}
  );

  return [
    { values: state }, // formState
    inputPropsCreators
  ];
}

上面 30 行代码实现了对 input 标签类型的设置,监听 value onChange,最终聚合到大的 values 做为 formState 返回。读到这里应该发现对 React Hooks 的应用都是万变不离其宗的,特别是对组件信息的获取,经过解构方式来作,Hooks 内部再作一下聚合,就完成表单组件基本功能了。

实际上一个完整的轮子还须要考虑 checkbox radio 的兼容,以及校验问题,这些思路大同小异,具体源码能够看 react-use-form-state

模拟生命周期

有的时候 React15 的 API 仍是挺有用的,利用 React Hooks 几乎能够模拟出全套。

componentDidMount

效果:经过 useMount 拿到 mount 周期才执行的回调函数。

useMount(() => {
  // quite similar to `componentDidMount`
});

实现:componentDidMount 等价于 useEffect 的回调(仅执行一次时),所以直接把回调函数抛出来便可。

useEffect(() => void fn(), []);

componentWillUnmount

效果:经过 useUnmount 拿到 unmount 周期才执行的回调函数。

useUnmount(() => {
  // quite similar to `componentWillUnmount`
});

实现:componentWillUnmount 等价于 useEffect 的回调函数返回值(仅执行一次时),所以直接把回调函数返回值抛出来便可。

useEffect(() => fn, []);

componentDidUpdate

效果:经过 useUpdate 拿到 didUpdate 周期才执行的回调函数。

useUpdate(() => {
  // quite similar to `componentDidUpdate`
});

实现:componentDidUpdate 等价于 useMount 的逻辑每次执行,除了初始化第一次。所以采用 mouting flag(判断初始状态)+ 不加限制参数确保每次 rerender 都会执行便可。

const mounting = useRef(true);
useEffect(() => {
  if (mounting.current) {
    mounting.current = false;
  } else {
    fn();
  }
});

Force Update

效果:这个最有意思了,我但愿拿到一个函数 update,每次调用就强制刷新当前组件。

const update = useUpdate();

实现:咱们知道 useState 下标为 1 的项是用来更新数据的,并且就算数据没有变化,调用了也会刷新组件,因此咱们能够把返回一个没有修改数值的 setValue,这样它的功能就仅剩下刷新组件了。

const useUpdate = () => useState(0)[1];
对于 getSnapshotBeforeUpdate, getDerivedStateFromError, componentDidCatch 目前 Hooks 是没法模拟的。

isMounted

好久之前 React 是提供过这个 API 的,后来移除了,缘由是能够经过 componentWillMountcomponentWillUnmount 推导。自从有了 React Hooks,支持 isMount 简直是分分钟的事。

效果:经过 useIsMounted 拿到 isMounted 状态。

const isMounted = useIsMounted();

实现:看到这里的话,应该已经很熟悉这个套路了,useEffect 第一次调用时赋值为 true,组件销毁时返回 false,注意这里能够加第二个参数为空数组来优化性能。

const [isMount, setIsMount] = useState(false);
useEffect(() => {
  if (!isMount) {
    setIsMount(true);
  }
  return () => setIsMount(false);
}, []);
return isMount;

在线 Demo

存数据

上一篇提到过 React Hooks 内置的 useReducer 能够模拟 Redux 的 reducer 行为,那惟一须要补充的就是将数据持久化。咱们考虑最小实现,也就是全局 Store + Provider 部分。

全局 Store

效果:经过 createStore 建立一个全局 Store,再经过 StoreProviderstore 注入到子组件的 context 中,最终经过两个 Hooks 进行获取与操做:useStoreuseAction

const store = createStore({
  user: {
    name: "小明",
    setName: (state, payload) => {
      state.name = payload;
    }
  }
});

const App = () => (
  <StoreProvider store={store}>
    <YourApp />
  </StoreProvider>
);

function YourApp() {
  const userName = useStore(state => state.user.name);
  const setName = userAction(dispatch => dispatch.user.setName);
}

实现:这个例子的实现能够单独拎出一篇文章了,因此笔者从存数据的角度剖析一下 StoreProvider 的实现。

对,Hooks 并不解决 Provider 的问题,因此全局状态必须有 Provider,但这个 Provider 能够利用 React 内置的 createContext 简单搞定:

const StoreContext = createContext();

const StoreProvider = ({ children, store }) => (
  <StoreContext.Provider value={store}>{children}</StoreContext.Provider>
);

剩下就是 useStore 怎么取到持久化 Store 的问题了,这里利用 useContext 和刚才建立的 Context 对象:

const store = useContext(StoreContext);
return store;

更多源码能够参考 easy-peasy,这个库基于 redux 编写,提供了一套 Hooks API。

封装原有库

是否是 React Hooks 出现后,全部的库都要重写一次?固然不是,咱们看看其余库如何作改造。

RenderProps to Hooks

这里拿 react-powerplug 举例。

好比有一个 renderProps 库,但愿改形成 Hooks 的用法:

import { Toggle } from 'react-powerplug'

function App() {
  return (
    <Toggle initial={true}>
      {({ on, toggle }) => (
        <Checkbox checked={on} onChange={toggle} />
      )}
    </Toggle>
  )
}
↓ ↓ ↓ ↓ ↓ ↓
import { useToggle } from 'react-powerhooks'

function App() {
  const [on, toggle] = useToggle()
  return <Checkbox checked={on} onChange={toggle} />
}

效果:假如我是 react-powerplug 的维护者,怎么样最小成本支持 React Hook? 说实话这个没办法一步作到,但能够经过两步实现。

export function Toggle() {
  // 这是 Toggle 的源码
  // balabalabala..
}

const App = wrap(() => {
  // 第一步:包 wrap
  const [on, toggle] = useRenderProps(Toggle); // 第二步:包 useRenderProps
});

实现:首先解释一下为何要包两层,首先 Hooks 必须遵循 React 的规范,咱们必须写一个 useRenderProps 函数以符合 Hooks 的格式,那问题是如何拿到 Toggle 给 render 的 ontoggle正常方式应该拿不到,因此退而求其次,将 useRenderProps 拿到的 Toggle 传给 wrapwrap 构造 RenderProps 执行环境拿到 ontoggle 后,调用 useRenderProps 内部的 setArgs 函数,让 const [on, toggle] = useRenderProps(Toggle) 实现曲线救国。

const wrappers = []; // 全局存储 wrappers

export const useRenderProps = (WrapperComponent, wrapperProps) => {
  const [args, setArgs] = useState([]);
  const ref = useRef({});
  if (!ref.current.initialized) {
    wrappers.push({
      WrapperComponent,
      wrapperProps,
      setArgs
    });
  }
  useEffect(() => {
    ref.current.initialized = true;
  }, []);
  return args; // 经过下面 wrap 调用 setArgs 获取值。
};

因为 useRenderProps 会先于 wrap 执行,因此 wrappers 会先拿到 Toggle,wrap 执行时直接调用 wrappers.pop() 便可拿到 Toggle 对象。而后构造出 RenderProps 的执行环境便可:

export const wrap = FunctionComponent => props => {
  const element = FunctionComponent(props);
  const ref = useRef({ wrapper: wrappers.pop() }); // 拿到 useRenderProps 提供的 Toggle
  const { WrapperComponent, wrapperProps } = ref.current.wrapper;
  return createElement(WrapperComponent, wrapperProps, (...args) => {
    // WrapperComponent => Toggle,这一步是在构造 RenderProps 执行环境
    if (!ref.current.processed) {
      ref.current.wrapper.setArgs(args); // 拿到 on、toggle 后,经过 setArgs 传给上面的 args。
      ref.current.processed = true;
    } else {
      ref.current.processed = false;
    }
    return element;
  });
};

以上实现方案参考 react-hooks-render-props,有需求要能够拿过来直接用,不过实现思路能够参考,做者的脑洞挺大。

Hooks to RenderProps

好吧,若是但愿 Hooks 支持 RenderProps,那必定是但愿同时支持这两套语法。

效果:一套代码同时支持 Hooks 和 RenderProps。

实现:其实 Hooks 封装为 RenderProps 最方便,所以咱们使用 Hooks 写核心的代码,假设咱们写一个最简单的 Toggle

const useToggle = initialValue => {
  const [on, setOn] = useState(initialValue);
  return {
    on,
    toggle: () => setOn(!on)
  };
};

在线 Demo

而后经过 render-props 这个库能够轻松封装出 RenderProps 组件:

const Toggle = ({ initialValue, children, render = children }) =>
  renderProps(render, useToggle(initialValue));

在线 Demo

其实 renderProps 这个组件的第二个参数,在 Class 形式 React 组件时,接收的是 this.state,如今咱们改为 useToggle 返回的对象,也能够理解为 state,利用 Hooks 机制驱动 Toggle 组件 rerender,从而让子组件 rerender。

封装本来对 setState 加强的库

Hooks 也特别适合封装本来就做用于 setState 的库,好比 immer

useState 虽然不是 setState,但却能够理解为控制高阶组件的 setState,咱们彻底能够封装一个自定义的 useState,而后内置对 setState 的优化。

好比 immer 的语法是经过 produce 包装,将 mutable 代码经过 Proxy 代理为 immutable:

const nextState = produce(baseState, draftState => {
  draftState.push({ todo: "Tweet about it" });
  draftState[1].done = true;
});

那这个 produce 就能够经过封装一个 useImmer 来隐藏掉:

function useImmer(initialValue) {
  const [val, updateValue] = React.useState(initialValue);
  return [
    val,
    updater => {
      updateValue(produce(updater));
    }
  ];
}

使用方式:

const [value, setValue] = useImmer({ a: 1 });

value(obj => (obj.a = 2)); // immutable

3 总结

本文列出了 React Hooks 的如下几种使用方式以及实现思路:

  • DOM 反作用修改 / 监听。
  • 组件辅助。
  • 作动画。
  • 发请求。
  • 填表单。
  • 模拟生命周期。
  • 存数据。
  • 封装原有库。

欢迎你们的持续补充。

4 更多讨论

讨论地址是: 精读《怎么用 React Hooks 造轮子》 · Issue #112 · dt-fe/weekly

若是你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

相关文章
相关标签/搜索