阅读 2079

制定专属自己的 React Hooks

Hooks 是 16.7.0-alpha 新加入的新特性,目的解决状态逻辑复用,使无状态组件拥有了许多状态组件的能力,如自更新能力(useState)、访问ref(useRef)、上下文context(useContext)、更高级的setState(useReducer)及周期方法(useEffect/useLayoutEffect)及缓存(useMemo、useCallback)。其底层实现没有太多变动,整体更接近函数式语法,逻辑内聚,高阶封装这两大特点,让你同时领悟到 Hooks 的强大与优雅。

如果你已经厌倦写诸如修改网页标题,判断用户离线状态,监听页面大小,用户手机状态(电池、螺旋仪...),说明你已经不甘心做一个重复劳动的开发者 ,那么自定义hooks非常适合你。只想关注Custom Hooks,F 传送!!!!

在阅读本文之前,建议unLearning,也就是忘记你之前学会的“React” ,它已经不是那个“它”了,否则只会给你带来“误导”。

ps: 为了更好的阅读体验,- 表示删减代码, + 表示新增代码,* 表示修改行

本文custom Hooks repo

个人Blog

useState

看这篇解析之前,我们已经知道自己的水平,岂能像新手一样先看api? 当然是要先从源码入手。

alt

function useState<S>(initialState: (() => S) | S) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}
复制代码

WTF ???

alt

回到正题,做人怎么能手高眼低呢?

高手,当然是要先从源码入手。

我们先看下官方的api

useState 用来定义组件数据变量。传入一个初始值,得到一个数组,前者为值,后者是一个dispatch函数,通过dispatch函数可以去更新该值。

const [value, updateVal] = useState(0)
value // 0
updateVal(1)
value // 1
复制代码

也可以传入一个同步函数。

// ⚠️ Math.random() 每次 render 都会调用Math.random()
const [value, updateVal] = useState(Math.random());
// 只会执行一次
const [value, updateVal] = useState(() => Math.random());
复制代码

useState仅在函数组件第一次执行初始化。在组件存在期间始终返回最新的值。不会再次去执行初始化函数。看到这,是不是觉得和闭包一样?

指向问题

// Index.js
class Index extends Component {
  componentDidMount() {
    setTimeout(() => console.log('classComponent', this.state.value), 1000);
    this.setState({ value: 5 });
  }

  render() {
    return null;
  }
}

// App.js
function App () {
  const [value, updateVal] = useState(0);
  useEffect(() => {
    setTimeout(() => console.log('FunctionComponent', value), 1000);
    updateVal(5);
  }, []);
  return (
    <Index />
  );
}

// classComponent 5
// FunctionComponent 0

复制代码

如果你还不了解 useEffect,可以暂时把上面 useEffect 暂时看成是 componentWillMount 。目的是一秒钟后打印当前的value值。

前者通过 this 可以访问到最新的值,而函数组件由于闭包的原因,打印的时候访问的还是更新前的值。这种情况可以通过useRef解决,如果你还不了解useRef

const num = useRef(null);
useEffect(() => {
    setTimeout(() => console.log(num.current), 1000); // 2
    updateVal((pre) => {
      const newPre = pre * 2;
      num.current = newPre;
      return newPre;
    });
}, []);
复制代码

但是updateVal 执行的时机无法保证(毕竟在整个周期的最后);还有个比较low的方案 —— 就是 useStatedispatch 函数 。

 useEffect(() => {
    setTimeout(() => updateVal((pre) => {
      console.log('FunctionComponent', pre);
      return pre;
    }), 1000);
    updateVal(5);
  }, []);
// classComponent 5
// FunctionComponent 5
复制代码

效果是能实现,但是由于 dispatch set 新值会触发一次 re-render。 所以这个方案不建议使用。后面会有封装的hooks达到目的。

根作用域顺序声明

不能嵌套在 if 或者 for 中声明

之前看过不少hooks的文章,都说hooks是以数组的形式存储的,所以才会出现指向问题。但在后来实践发现并非如此(连官方也这么误导我).

React 如何将 Hook 调用与组件相关联?
React 跟踪当前渲染组件。 由于 Hooks 规则,我们知道 Hook 只能从 React 组件调用(或自定义 Hooks 也只能从 React 组件中调用)。
每个组件都有一个 “内存单元” 的内部列表。它们只是 JavaScript 对象,我们可以在其中放置一些数据。当调用 useState() 这样的Hook 时,它读取当前单元格(或在第一次呈现时初始化它),然后将指针移动到下一个单元格。这就是多个 useState() 调用各自获取独立本地状态的方式。

实际上是以一种单向循环链表。类似A.next === B => B.next === C 。

alt
剖析 引用

const [state1,setState1] = useState(1)
const [state2,setState2] = useState(2)
const [state3,setState3] = useState(3)
复制代码

每个FunctionalComponent都会有个对应的Fiber对象,

function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // Instance
  this.tag = tag;
  this.key = key;
  this.elementType = null;  // ReactElement[$$typeof]
  this.type = null;         // ReactElement.type
  this.stateNode = null;

  // ...others
  this.ref = null;
  this.memoizedState = null;
  // ...others
}
复制代码

在其中调用的useState 会有个 Hook 对象。

export type Hook = {
  memoizedState: any,

  baseState: any,
  baseUpdate: Update<any, any> | null,
  queue: UpdateQueue<any, any> | null,

  next: Hook | null, // 指向下一个hook节点
};

复制代码

其他的问题不用太关注,只需要知道当在第一次执行到useState的时候,会对应 Fiber 对象上的 memoizedState,这个属性原来设计来是用来存储 ClassComponentstate 的,因为在 ClassComponentstate 是一整个对象,所以可以和memoizedState 一一对应。

但是在 Hooks 中,React并不知道我们调用了几次 useState,所以在保存 state 这件事情上,React 想出了一个比较有意思的方案,

Fiber.memoizedState === hook1
state1 === hook1.memoizedState
hook1.next === hook2
state2 === hook2.memoizedState
hook2.next === hook3
state3 === hook2.memoizedState
复制代码

每个在 FunctionalComponent 中调用的 useState 都会有一个对应的 Hook 对象,他们按照执行的顺序以类似单向循环链表的数据格式存放在 Fiber.memoizedState 上。

如果出现下面这种逻辑

if(false) {
    const [status,setStatus] = useState(false)
}
// or
let times = 10 // 某次逻辑修改了times
for(let i = 0;i < times; i++) {
    const status = useState(false)
}
复制代码

会导致某次 re-render 后,少了某个 hook ,next 指向错误,比如 hook1.next 指向了 hook3 造成数据混乱,无法达到预想效果。

useEffect

生命周期的阶段性方法,类似setState(state, cb)中的cb,执行时机位于整个更新周期的最后。

话不多少,先上源码。

///////// useEffect
export function useEffect(
  create: () => (() => void) | void,
  inputs: Array<mixed> | void | null,
) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useEffect(create, inputs);
}
//...省略
function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
 return mountEffectImpl(
    UpdateEffect | PassiveEffect,
    UnmountPassive | MountPassive,
    create,
    deps,
  );
}

复制代码

alt

咳,这个方法可谓是hooks里最重要的一个hooks。如果把useState看成 HTML+CSS,那 useEffect 就是 JS

useEffect(fn, deps?:any[])fn 执行函数,deps 依赖。与 useState 类似,fn 在初始化时执行一次。而后续执行则依赖于 deps 的变化,如果 re-render 后执行该 effects 发现此次 deps 与上次不同,就会触发执行。

ps: React 内部使用 Object.is 对 deps 进行浅比较。

刚开始脱离 classComponent 转而使用 hooks 时曾以为它在 render 前执行,其实不然。

默认情况下,效果在每次完成渲染后运行

useEffect(() => {
    // 仅在初始化时(首次render后)执行
}, []);

useEffect(() => {
    // 每次render后执行
});
复制代码

fn 可返回一个清理函数,大多数运用于 addEventListenerremoveEventListener

useEffect(() => {
    // 首次render后执行
    return () => {
        // 组件卸载前执行
    }
},[]);

useEffect(() => {
    (function fn() => { /*dst...*/ })()
    // 每次render后执行
    return () => {
        // 从第二次开始,先运行此清理函数,再执行fn
    }
});

复制代码

清理函数的执行时机可以理解为如果该 Effect 句柄执行过,则下次优先执行清理函数,以防止内存泄漏,最后一次执行时机在组件卸载后。

如果非要形容对应哪个生命周期,我更觉得像 componentDidUpdate

不要在 useEffect 中操作DOM。比如使用 requestAnimationFrame 添加几万个节点。会有意想不到的惊喜。

eg:

const Message = ({ boxRef, children }) => {
  const msgRef = React.useRef(null);
  React.useEffect(() => {
    const rect = boxRef.current.getBoundingClientRect(); // 获取尺寸
    msgRef.current.style.top = `${rect.height + rect.top}px`; // 放到盒子下方
  }, [boxRef]);

  return (
    <span ref={msgRef} className="msgA">
      {children}
    </span>
  );
};
const App = () => {
  const [show, setShow] = React.useState(false);
  const boxRef = React.useRef(null);

  return (
    <div>
        <div ref={boxRef} className="box" onClick={() => setShow(prev => !prev)}>
          Click me A
        </div>
        {show && <Message boxRef={boxRef}>useEffect</Message>}
    </div>
  );
};

复制代码

目的很简单,将 Message 组件 显示时放置到div下,但实际运行结果时会发现有一瞬间跳动效果。

alt

当把Message组件内的 useEffect 换成useLayoutEffect就正常了。

Edit charming-surf-wz9fk

原因是虽然useEffect在浏览器绘制后执行,也代表着它会在新渲染之前触发。需要执行新的渲染之前它会先刷新现有的effects。

什么?你不信?

alt
e.g:

const [val, updateVal] = useState(0)
useEffect(() => { // hooks1
    updateVal(2);
}, []);
useEffect(() => { // hooks2
    console.log(val);// ---- 0
});
复制代码

在 render后,先执行hook1 updateVal(2) 触发了 re-render,但在此之前需要先刷新现有的 effects,所以hooks2 val 打印出来的还是 0 ,然后再次触发 render 渲染后的 effects hooks2才打印出 2

依赖于闭包

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => {
      console.log(`You clicked ${count} times`);
    }, 3000);
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}
复制代码

你觉得快速的连续点击5次,弹出来的会是什么?

alt
与classComponent不同,它访问的而是this。而不是闭包。

componentDidUpdate() {
    setTimeout(() => {
      console.log(`You clicked ${this.state.count} times`);
    }, 3000);
  }
复制代码

alt

在定时器里执行的事件,完全依赖于闭包。可能你不认同,但是事实确是如此。

关于依赖项不要对React撒谎

function App(){
   const [tagType, setTagType] = useState()
    async function request () {
        const result = await Api.getShopTag({
            tagType
        })
    }
    useEffect(()=>{
        setTagType('hot')
        request()
    },[])
    return null
}
复制代码

request 函数依赖于 tagType,但是Effects没有依赖于tagType,当tagType改变时,requesttagType 的值仍然是 hot。 你可能只是想挂载的请求它,但是 现在只需要记住:如果你设置了依赖项,effect中用到的所有组件内的值都要包含在依赖中。这包括 props,state,函数 — 组件内的任何东西。

后话

在组件年内请求数据时,经常会这么写

function App(){
    async function request () {
        // ...
        setList(result)
        setLoaded(false)
    }
    useEffect(()=>{
        request()
    },[])
    return null
}
复制代码

在正常情况下访问当然没问题,当组件体积庞大或者请求速度慢时,你会收到“惊喜”。

alt
意思是还没请求完毕你就去到别的页面,导致effects内的 setList/setLoaded 无从下手送温暖。 这也是闭包的弊端 —— 无法及时销毁。还有一个解决方案是 AbortController

其实搞定这两个api就能完成80%的业务了。符合二八定律,即20%的功能完成80%的业务。封装自定义hooks大多数也需要它们。

useLayoutEffect

useLayoutEffect 名字与 useEffect 相差了一个 Layout。 顾名思义,它们的区别就是执行时机不一样,表示在 Layout 后触发。即 render 后。

源码如下:

alt

签名与 useEffect 相同,但在所有 DOM 变化后同步触发。 使用它来从 DOM 读取布局并同步重新渲染。 在浏览器有机会绘制之前,将在 useLayoutEffect 内部计划的更新将同步刷新。

官方解释它会阻塞渲染,所以在不操作dom的情况用 useEffect ,以避免阻止视觉更新。

执行时机 render > useLayoutEffect > useEffect > setState(useState) > 清理effects > render(第二遍) > ...

而useLayoutEffect内setState的执行机制和useEffect不一样。虽然最后都执行了合并策略。在mount和update的阶段也是不一样的。甚至函数组件顶部申明useState的顺序都会导致执行结果不一致。

相对于组件 mount,在 update 触发 Hooks 的顺序更让人容易理解一些。

requestAnimationFrame将任务“打碎”,执行的时机在于重绘后,也就是useLayoutEffect执行过后。

ps:如果你只是改变数据,首选useEffect,因为它不会阻塞渲染。这是优点也是缺点,不阻塞(代表异步),当然也保证不了顺序。而涉及到 DOM 操作的建议使用useLayoutEffect

useReducer

内置hook,看名字就知道和redux有关。使用方法和redux相同。

const reducer = (state,action) => {
    let backups = { ...state }
    switch(action.type){
        case 'any': // ... ; break;
    }
    return backups
}
const initial = { nowTimes: Date.now() }
function App () {
    const [val, dispatch] = useReducer(reducer,initial);
    return null
}
复制代码

通过 useState 手动一个实现 useReducer

function useMyReducer(reducer, initialState, init) {
  const compatible = init ? init(initialState) : initialState;
  const [state, setState] = useState(compatible);
  function dispatch(action) {
    const nextState = reducer(state, action);
    setState(nextState);
  }

  return [state, dispatch];
}
复制代码

第三个参数类似于 reduxinitialState,用于设置初始State。无论是否命中 reducer,每次 dispatch 都将触发 re-render。

如果你想用它代替 Redux 可能还是缺少点什么。有一个明显的问题,这里定义的state是和组件绑定的,和 useState 一样,无法和其他组件共享数据。但是通过 useContext 可以达到目的。

useContext

useContext(context: React.createContext)

// Context/index.js
const ContextStore = React.createContext()

// App.js

function Todo() {
  const value = React.useContext(ContextStore);
  return (
    <React.Fragment>
      {
        JSON.stringify(value, null, 4)
      }
    </React.Fragment>
  );
}

function App() {
  return (
    <ContextStore.Provider value={store}>
      <Todo />
    </ContextStore.Provider>
  );
}
复制代码

通过使用方法发现,配合 useReducer 可以在组件树顶层使用 Context.Provider 生产/改变数据,在子组件使用 useContext 消费数据。

const myContext = React.createContext();
const ContextProvider = () => {
    const products = useReducer(productsReducer, { count: 0 });
    const order = useReducer(orderReducer, { order: [] });
    const store = {
        product: products,
        order: order // [ order,deOrder ]
    }
    return (
        <myContext.Provider value={store}>
          <Todo />
        </myContext.Provider>
    );
};

const Todo = () => {
    const { product, order } = useContext(myContext)
    return (
        <React.Fragment>
            {
                JSON.stringify(state, null, 4)
            }
            <button onClick={product.dispatch}> product dispatch </button>
        </React.Fragment>
    )
}
复制代码

弊端是当数据量变大时,整个应用会变得“十分臃肿”并且性能差劲。这有个很不错的实现 iostore

useMemo

译文备忘录,如果更贴切点我想应该叫缓存,useCache? 但后来想想也对,叫备忘录也没错,毕竟是状态逻辑复用。

useMemoreselect库功能相同,都是依赖于传入的值,有固定的输入就一定有固定的输出。不必重新去计算,优化性能。在依赖不改变的情况下避免重新去计算浪费性能。

但是reselect用起来太繁琐了。useMemo相对简单的多

const memoDate = useMemo(()=>{
   return new Date().toLocalString() 
},[])
复制代码

useMemo的第二个参数与useEffect功能相同,当依赖发生变化才会进行重新计算。memoDate在组件内将始终不变。

依赖项

可能你写过这样的代码

const [count, setCount] = useState(0);

const request = useMemo(() => {
    return aysnc () => {
        let result = await Api.getMainShopTag({
            startNum: count,
            size: 10
        })
        setCount(result.count)
    }
}, []);
return <button onClick={request} type="button"> request </button>
复制代码

简单的一个分页标签请求,开始一切正常,当请求第二次的时候发现 count 仍为0。useMemo 缓存了函数,自然也缓存了函数内变量的指向。所以需要在deps内添加函数内需要依赖的参数。

如果你对这一切还不熟悉,react-hooks 针对 eslint 推出一款插件 eslint-plugin-react-hooks,它可以自动帮你修复依赖项,并且提供优化支持。在制定自定义hooks的时候,严格遵守准则。

npm i eslint-plugin-react-hooks -D

复制代码
// .eslintrc
{
    // other ...
    "plugins": [
        "html","react","react-hooks"
    ],
    "rules":{
        "react-hooks/rules-of-hooks": "error",
        "react-hooks/exhaustive-deps": "warn"
    }
}
复制代码

useCallback

useCallback 是 useMemo的变体。两者作用相同,你可以理解为前者更偏向于函数缓存。在定义一些不依赖于当前组件的属性变量方法时,可以尽量采用 useCallback 缓存。避免组件每次render前再次申明。

useCallback(fn, deps) === useMemo(() => fn, deps))
复制代码

比如上面的代码你可以简化成

const request = useCallback(aysnc () => {
        let result = await Api.getMainShopTag({
            startNum: count,
            size: 10
        })
        setCount(result.count)
    }, [count]);
复制代码

关于memo与callback

useCallback与useMemo需要慎重使用。很多人以为两者是为了解决创建函数带来的性能问题,其实不然。

上菜:

const forgetPwd = () => {
    const sendSmsCode = () => { /*...*/ }
}

const forgetPwd = () => {
    const sendSmsCode = useMemo(()=>{ /*...*/ }, [A,B])
}

复制代码

上面的例子中两者效果是一样的,无论如何 sendSmsCode 都会被创建。只不因为后者需要比对依赖而耗费了稍微一点点的性能(蚊子再小也是肉),那可能会有疑问,为什么使用了缓存性能反而越来越差。

<button onClick={() => {}}>Search</button>
// 第二次render
<button onClick={() => {}}>Search</button>
复制代码

两次渲染 inline 函数永远不会相等,与memo的概念背道而驰。这是没有意义的diff。只会浪费时间,而组件也绝对不会被memo优化。

useCallback 实际上缓存的是 inline callback 的实例,配合React.memo能够起到避免不必要的渲染。两者缺一个性能都会变差。当你的函数组件 UI 容器内有任何一个 inline 函数,任何优化都是没有意义的。

useRef

解决的问题是组件数据状态无法保存,有如下代码

function Interval() {
  const [time, setTime] = useState(Date.now());
  let intervalId;
  const start = () => {
    intervalId = setInterval(() => {
      setTime(Date.now());
    }, 500);
  };
  const clear = () => {
    clearInterval(intervalId);
  };
  return (
    <div>
      <button onClick={start} type="button">start</button>
      <button onClick={clear} type="button">clear</button>
    </div>
  );
}
复制代码

看起来很正常的一段逻辑,但是启动定时器后,发现无法关闭定时器了。

这是因为启动定时器后,setTime(Date.now()) 更新值后,函数组件被 re-render。此时intervalId已经被重新声明了。所以清除不了之前的定时器。

函数组件没有被实例化,意味着无法使用this、没有内部的组件属性变量。需要避免其每次被重新声明。

const [intervalId, setIntervalId] = useState(null)
const start = () => {
    setIntervalId(
        setInterval(() => setTime(Date.now()), 500)
    )
};
复制代码

难道必须全部使用 useState 储存状态么?前面提到过,每次执行 setIntervalId 句柄都会触发一次 re-render,即使没有在视图里没有用到。

可以用 useRef 处理组件属性。改造组件

// ... other
let intervalId = useRef(null);
const start = () => {
    intervalId.current = setInterval(() => {
        setTime(Date.now());
    }, 500);
};
const clear = () => {
    clearInterval(intervalId.current);
};

复制代码

使用 useRef 最好的理由是不会触发 re-render 。源码:

function mountRef<T>(initialValue: T): {current: T} {
  const hook = mountWorkInProgressHook();
  const ref = {current: initialValue};
  if (__DEV__) {
    Object.seal(ref);
  }
  hook.memoizedState = ref;
  return ref;
}

复制代码

那怎么理解useRef?

你可以把它看成是一个盒子。可以放任何数据(甚至组件) —— 海纳百川有容乃大。在盒子中的东西(current)会被隔离,且值将会被深拷贝,不会被外界所干扰、同时也不会响应。你可以重新通过 ref.current 去赋值。并且不会触发 re-render 与 useEffect 。通过 .current 获取的值始终都是最新的。

const info = useRef({ status: false });

const focus = () => {
    // 始终都是最新的
    setTimeout(() => console.log(info.current), 1000); // {status: true}
    info.current.status = true;
}

const input = useRef();
useEffect(() => {
    //可以访问元素上的方法
    input.current.focus()
}, [])

useEffect(() => {
    // info改变不会触发
}, [info.current.status])

return <input ref={input} type="text" onFocus={focus} />

复制代码

useImperativeHandle

虽然通过useRef可以访问本组件属性。但如果父元素想操作子组件就显得较无能无力。在 classComponent 你可以使用this.chilren去访问子组件方法。函数组件就没有这项特权了,毕竟没有被实例化,官方提供useImperativeHandle(原useImperativeMethods)向父组件暴露组件方法。额外的是需要配合 forwardRef 转发该节点使用,官方的例子已经极为清楚了:

// FancyInput.js
function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    },
    blurs: () =>{
        // dst...
    }
  }));
  return <input ref={inputRef} ... />;
}
FancyInput = forwardRef(FancyInput);

//App.js
function App(){
    const fancyInputRef = useRef()

    useEffect(()=>{
        fancyInputRef.current.focus()
    },[])
    return (
        <React.Fragment>
            <FancyInput ref={fancyInputRef} />
        </React.Fragment>
    )
}

复制代码

useDebugValue

这个就属于辅助标识了。在某个custom Hook 使用,标识该钩子是 custom hook。 比如申明一个 useMount

const useMount = (mount) => {
  useEffect(() => {
    mount && mount();
  }, []);
  useDebugValue('it"s my Custom hook',fit => `${fit} !` );
};
// App.js
const App = () => {
  useMount(() => { /*dst...*/});
  return (
    <Provider>
      <Count />
      <IncrementButton />
    </Provider>
  );
};

复制代码

alt
该Api只会在ReactDevTools启用状况下才会加载,蓝色代表 Custom Hook 名称,比如useMount。红色为描述。

自定义钩子

实现 setState 回调

首先实现一个简陋版本

// App.js
function App() {
  const [value, updateVal] = useState(0);
  const init = useRef(0);

  const doSomething = () => {
    updateVal(pre => pre + 1);
  };

  useEffect(() => {
    init.current += 1
    if (init.current > 1) {
      // callback
      console.log('has changed', value);
    }
  }, [value]);

  return <button onClick={doSomething}>{value}</button>;
}
复制代码

这或许不能称为callback,监听可能更符合一点,可以封装成hooks

// hooks.js
const useStateWithCb = (initialState, callback) => {
  const [state, setState] = useState(()=>initialState);
  
  useEffect(() => callback(state), [state, callback]); //每次state更新的时候都会去执行该回调
  return [state, setState];
}

function App () {
    const [value, updataVal ] = useStateWithCb(0, () => {
        console.log(value)
    })
}
复制代码

这样初始化也会执行,相应可以参照上文给useStateWithCb加上 useRef 来避免初始化执行。这里就不再演示了。还一种方法是利用 useLayoutEffect 也能达成相同的效果。

不过这样做的弊端是不能动态的传入回调。叔可忍婶婶不能忍,既然返回的updateVal不能改,那就劫持它。于是就用到 Proxyapply 拦截函数调用。

apply 函数有3个参数

  • target 调用的目标对象(函数)。
  • thisArg 调用上下文对象。
  • argumentsList 被调用时的参数数组。
// hooks.js
const useStateWithCb = (initialVal) => {
  const [state, setState] = useState(()=>initialVal);
  const hijackSetState = useMemo(()=>new Proxy(setState, { // 这里做一层缓存,确保不会再次创建
    apply(target, thisArg, argumentsList) { // 拦截
      const args = Array.prototype.slice.call(argumentsList);
      if (args.length > 1 && typeof args.slice(-1)[0] === 'function') { // 如果尾参数为函数
        fn.current = args.pop(); // 赋值给 fn.current
      }
      return target.apply(thisArg, args);
    }
  }),[]);
  useEffect(() => {
      typeof fn.current === 'function' && fn.current(state);
  }, [state]);
  return [state, hijackSetState];
};

// App.js
import { useStateWithCb } from 'hooks'
function App() {
  const [value, updateVal] = useStateWithCb(0);

  const doSomething = () => {
    updateVal.current(pre => pre + 1, (newVal) => {
      console.log(newVal); // 1
    });
  };

  return <button onClick={doSomething} type="button">{value}</button>;
}
复制代码

设置相同值

效果是能实现,但设置相同值,Object.is 会将其判定 unChanged

//App.js
const [value, updateVal] = useStateWithCb(2);

const doSomething = () => {
    updateVal(2, (newVal) => {
      console.log(newVal); // 值未改变,触发不了newVal
    });
};
复制代码

由于Effect 依赖于 state ,对于基本类型数据直接比较的值,所以触发不了 Effect 的回调函数,而Object,Array,Map,Symbol等直接比较内存地址的却可以。可以对基本类型做特别处理。

// utils.js

// 判断原始类型数据 undefined,number,boolean,string, null,NaN
const isOriginal = o => typeof o === 'object' ? !o : typeof o !== 'function';
// 创建随机数
const random = () => Math.random().toString(36).split('').join('.')
// 函数判断
const isFunction = fn => Object.prototype.toString.call(fn) === "[object Function]"

const useStateWithCb = (initialVal) => {
  const [state, setState] = useState(()=>initialVal);
  const fn = useRef(null);
+ const [r, sr] = useState(()=>random()); // 创建随机数句柄
  const hijackSetState = useMemo(() => new Proxy(setState, {
    apply(target, thisArg, argumentsList) {
      const args = Array.prototype.slice.call(argumentsList);
+     if (isOriginal(args[0]) && args[0] === state) { // 原始类型 && 等与自身
+       sr(random()); // 如果是原始类型则更新随机数
+     }
      if (args.length > 1 && typeof args.slice(-1)[0] === 'function') {
        fn.current = args.pop();
      }
      return target(...args);
    }
  }),[]);

  useEffect(() => {
    isFunction(fn.current) && fn.current(state);
+ }, [state, r]); // 增加 随机数 作为依赖

  return [state, hijackSetState];
};
复制代码

代码简洁,说明思路即可。

回调内再次赋值

// App.js
const doSomething = () => {
    updateVal(pre => `${pre}2`, (firstVal) => {
      console.log(firstVal); // 1
      updateVal(pre => `${pre}3`, (secondVal) => {
        console.log(secondVal); // 循环调用
      });
    });
};
复制代码

这种情况也能想得到是由于每次调用的都是上一次的更新器导致死循环。

那就把最新的更新器传给callback。

// hooks.js
function useStateWithCb (initialVal){
    //...other
    useEffect(() => {
-    isFunction(fn.current) && fn.current(state);
+    isFunction(fn.current) && fn.current(state, hijackSetState);
    }, [state, r, hijackSetState]);
   // ...other
}

// index.js
const doSomething = () => {
    const [value, updateVal] = useStateWithCb(1);
    updateVal(pre => `${pre}2`, (firstVal, firstSetter) => {
      console.log(firstVal); // 12
      firstSetter(pre => `${pre}3`, (secondVal, secondSetter) => {
        console.log(secondVal); // 123
        secondSetter(pre => `${pre}4`, (thirdVal, thirdSetter) => {
          console.log(thirdVal); // 1234
        });
      });
    });
};
复制代码
完整版
const useStateWithCb = (initialVal) => {
  const [state, setState] = useState(()=>initialVal);
  const fn = useRef(null);
  const [r, sr] = useState(()=>random());
  const hijackSetState = useMemo(() => new Proxy(setState, {
    apply(target, thisArg, argumentsList) {
      const args = Array.prototype.slice.call(argumentsList);
      if (isOriginal(args[0]) && args[0] === state) {
        sr(random());
      }
      if (
        args.length > 1
          && typeof args.slice(-1)[0] === 'function'
      ) {
        fn.current = args.pop();
      }
      return target(...args);
    },
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }), []);

  useEffect(() => {
    typeof fn.current === 'function' && fn.current(state, hijackSetState);
  }, [state, r, hijackSetState]);

  return [state, hijackSetState];
};
复制代码
有兴趣的可以安装 `npm i like-hooks -S`。使用方法和上文一致。

Edit serverless-morning-r2svr

useLifeCycles

或许不需要单独拿出来。但还是忍不住凑字数。

alt

const useLifeCycles = (mount, unMount) => {
  useEffect(() => {
    mount && mount();
    return () => {
      unMount && unMount();
    };
  }, []);
};
复制代码

useRequestAnimationFrame

RequestAnimationFrame使用的频率很高,理所当然将其封装成一个hooks;名字太长可不是个好事。

/**
 * useRaf useRequestAnimationFrame
 * @param callback 回调函数
 * @param startRun 立即执行
 */
const useRaf = (callback, startRun = true) => {
  const requestRef = useRef(); // 储存RequestAnimationFrame返回的id
  const previousTimeRef = useRef(null); // 每次耗时间隔

  const animate = useCallback((time) => {
    if (previousTimeRef.current !== undefined) {
      const deltaTime = time - previousTimeRef.current; // 耗时间隔
      callback(deltaTime);
    }
    previousTimeRef.current = time;
    requestRef.current = requestAnimationFrame(animate);
  }, [callback]);

  useEffect(() => {
    requestRef.current = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(requestRef.current);
  }, []);

  const stopRaf = useCallback(() => {
    if(startRun) cancelAnimationFrame(requestRef.current);
    requestRef.current = null;
  }, [animate]);

  const restartRaf = useCallback(() => {
    if (requestRef.current === null) {
      requestAnimationFrame(animate);
    }
  }, [animate]);

  return [restartRaf, stopRaf];
};

// App.js
const App = () => {
  const [count, setCounter] = useState(0);
  const run = () => {
    setCounter(pre => pre + 1);
  };
  const [start, stop] = useRaf(() => run());
  return (
    <div>
      <button type="button" onClick={start}>开始</button>
      <button type="button" onClick={stop}>暂停</button>
      <h1>{count}</h1>
    </div>
  );
};
复制代码

Edit serverless-morning-r2svr

该hook接受一个函数作为帧变动的callback,callback接受一个参数,作为距离上次performance.now() 的间隔耗时,通常为16ms上下(意义不大,但可为低配置用户启用优化方案)。hook返回两个控制器,一个用来重启,当然不会将数据重置,另一个用来暂停。

ps:不要用来操作DOM,如果非得操作,建议改成useLayoutEffect

有了这个hook,相信你就能够轻轻松松做出秒表、倒计时、数字逐帧变动等酷炫组件了。

usePrevious

利用 useRef 保存上一次的值,在Effect里第一次取值会拿到 undefined 的情况。有时候还需要去判断,这里利用Symbol 判断,首次返回该值。当然你也可以不考虑这种情况(第二个参数为false)。

export const usePrevious = (value) => {
  const r = useRef(Math.random().toString(36)) // 利用随机数创建全局唯一的id
  const ref = useRef(Symbol.for(r.current));

  useEffect(() => {
    ref.current = value;
  });
  return Symbol.for(r.current) === ref.current ? value : ref.current;
};
复制代码

Edit serverless-morning-r2svr

useEventListener

不想去频繁写原生event事件,将其封装成hooks。

export function useEventListener(eventName, handler, target = window) {
  const memoHandler = useRef();
  useEffect(() => {
    memoHandler.current = handler;
  }, [handler]);

  useEffect(() => {
    const eventListener = event => memoHandler.current(event);
    const targetEl =
      "current" in target && typeof target.current === "object"
        ? target.current
        : target;
    targetEl.addEventListener(eventName, eventListener);
    return () => {
      targetEl.removeEventListener(eventName, eventListener);
    };
  }, [eventName, target]);
}
复制代码

由于React-DOM为IE9+,不考虑 attachEvent。函数组件只能通过ref访问元素,增加.current判断防止报错。

Edit serverless-morning-r2svr

useDebounce

防抖都不会陌生

/**
 * 防抖函数执行
 * @param {*} fn 被防抖函数
 * @param {number} [ms=300] 间隔
 */
export const useDebounce = (fn, args, ms = 300 ) => {
  
  const pendingInput = useRef(true);
  
  useEffect(() => {
    let savedHandlerId;
    if (pendingInput.current) {
      pendingInput.current = false;
    } else {
      savedHandlerId = setTimeout(fn, ms);
    }
    return () => clearTimeout(savedHandlerId);
  }, [fn, ms, args]);
};
复制代码

Edit serverless-morning-r2svr

useThrottle

节流有更加简单的第三方实现

const throttled = useRef(throttle((newValue) => {
    // dst...
}, 1000))
useEffect(() => throttled.current(value), [value])
复制代码

但是入乡随俗,还是要实现一个。

/*
 * 节流函数,等电梯,电梯15秒一轮,进人不重置。
 * @param {*} fn 被节流函数
 * @param {*} args 依赖更新参数
 * @param {number} [timing=300] 节流阀时间
 * @returns 节流值
 */
const useThrottle = (fn, args, timing = 300) => {
  const [state, setState] = useState(() => fn(...args));
  const timeout = useRef(null);
  const lastArgs = useRef(null); // 最近一次参数
  const hasChanged = useRef(false); // 是否有更新
  useEffect(() => {
    if (!timeout.current) {
      const timeoutHandler = () => {
        if (hasChanged.current) { // 有更新,立即更新并再启动一次,否则放弃更新
          hasChanged.current = false;
          setState(() => fn(...lastArgs.current));
          timeout.current = setTimeout(timeoutHandler, timing);
        } else {
          timeout.current = undefined;
        }
      };
      timeout.current = setTimeout(timeoutHandler, timing);
    } else {
      lastArgs.current = args; // 更新最新参数
      hasChanged.current = true; // 有更新任务
    }
  }, [...args, fn, timing]);
  return state;
};
复制代码

使用方法

const throttledValue = useThrottle(value => value, [val], 1000);
复制代码

Edit serverless-morning-r2svr

useImtArray

制作一个 ImmutableArray

/**
 * 通过二次封装数组,达到类似ImmutableArray效果
 * @param {*} initial 
 * @returns
 */
const useImtArray = (initial = []) => {
  const [value, setValue] = useState(()=>{
    if(!Array.isArray(initial)) {
      throw new Error('useImtArray argument Expectations are arrays. Actually, they are' + Object.prototype.toString.call(initial))
    }
    return initial
  });
  return {
    value,
    push: useCallback(val => setValue(v => [...v, val]), []),
    pop: useCallback(() => setValue(arr => arr.slice(0, arr.length - 1)), []),
    shift: useCallback(() => setValue(arr => arr.slice(1, arr.length)),[]),
    unshift: useCallback(val => setValue(v => [val, ...v]), []),
    clear: useCallback(() => setValue(() => []), []),
    removeByVal: useCallback(val => setValue(arr => arr.filter(v => v !== val)),[]),
    removeByIdx: useCallback(index => setValue(arr =>
          arr.filter((v, idx) => parseInt(index, 10) !== idx),
        ), []),
  };
};
复制代码

Edit serverless-morning-r2svr

usePromise

Promise当然也少不了。

/**
 * 简化Promise
 * @param {*} fn Promise函数
 * @param {*} [args=[]] 依赖更新参数
 * @returns loading:加载状态,value:成功状态的值,error:失败状态的值
 */
const usePromise = (fn, args = []) => {
  const [state, setState] = useState({});
  // eslint-disable-next-line react-hooks/exhaustive-deps
  const memoPromise = useCallback(() => fn(), args);

  useEffect(() => {
    let pending = true; // 防止多次触发
    setState(newestState => ({ ...newestState, loading: true }));
    Promise.resolve(memoPromise())
      .then(value => {
        if (pending) {
          setState({
            loading: false,
            value,
          });
        }
      })
      .catch(error => {
        if (pending) {
          setState({
            loading: false,
            error,
          });
        }
      });

    return () => {
      pending = false;
    };
  }, [memoPromise]);

  return state;
};


// App.js
const request = () => new Promise((resolve,reject)=>{
  setTimeout(()=>{
    if(Math.random() > 0.5 ){
      resolve('Success')
    }else{
      reject('Fail')
    }
  },2000)
})
function App(){
  const { value, loading, error} = usePromise(request)
  return (
    <div>{loading? <span>Loading...</span> : result:<span>{error||value}</span>}</div>
  )
}
复制代码

Edit serverless-morning-r2svr

useGetter

通过 Object.definedProperty 能够简单的去监听读取属性

import { clone, isPlainObject } from '../utils';
/**
 * 监听对象属性被读取
 * @param {*} watcher 监听对象
 * @param {*} fn 回调
 */
const useGetter = (watcher, fn) => {
  if (!isPlainObject(watcher)) {
    throw new Error(
      `Expectation is the object, the actual result ${Object.prototype.toString.call(
        watcher,
      )}`,
    );
  }
  const value = useMemo(() => watcher, [watcher]);
  const cloneVal = useMemo(() => clone(watcher), [watcher]);
  const cb = useRef(fn);

  Object.keys(cloneVal).forEach(name => {
    Object.defineProperty(value, name, {
      get() {
        if (typeof cb.current === 'function')
          cb.current(name, cloneVal);
        return cloneVal[name];
      },
    });
  });
};
复制代码

Edit serverless-morning-r2svr

useLockBodyScroll

这个钩子偶然看到的,针对防止遮罩滚动穿透。原地址

/**
 * 锁定body滚动条,多用于modal,后台
 */
const useLockBodyScroll = () => {
  useLayoutEffect(() => {
    const originalStyle = window.getComputedStyle(document.body)
      .overflow;
    document.body.style.overflow = 'hidden';
    return () => {
      document.body.style.overflow = originalStyle;
    };
  }, []);
};
复制代码

Edit serverless-morning-r2svr

useTheme

你甚至可以自己切换主题配色,就像这样

/**
 * 更换主题
 * @param {*} theme 主题数据
 */
const useTheme = theme => {
  useLayoutEffect(() => {
    for (const key in theme) {
      document.documentElement.style.setProperty(
        `--${key}`,
        theme[key],
      );
    }
  }, [theme]);
};
复制代码

Edit serverless-morning-r2svr

useInput

写input的时候你还在手动 onChange 么?

/**
 * auto Input Hooks
 * @param {*} initial Input初始值
 * @returns InputProps clear清空  replace(arg:any|Function) bind 绑定Input
 */
function useInput(initial) {
  const [value, setValue] = useState(initial);
  function onChange(event) {
    setValue(event.currentTarget.value);
  }
  const clear = () => {
    setValue('');
  };
  const replace = arg => {
    setValue(pre => (typeof arg === 'function' ? arg(pre) : arg));
  };
  return {
    bind: {
      value,
      onChange,
    },
    value,
    clear,
    replace,
  };
}

function Input() {
  let userName = useInput("Seven"); // {clear,replace,bind:{value,onChange}}
  return <input {...userName.bind} />;
}
复制代码

Edit serverless-morning-r2svr

useDragger

一个极简的拖拽hook,稍加改造。

/**
 * 拖拽元素
 * @param {*} el 目标元素
 * @returns x,y偏移量 pageX,pageY 元素左上角位置
 */
function useDraggable(el) {
  const [{ dx, dy }, setOffset] = useState({ dx: 0, dy: 0 });
  const [{ pageX, pageY }, setPageOffset] = useState({
    pageX: 0,
    pageY: 0,
  });
  useEffect(() => {
   const { top, left } = el.current.getBoundingClientRect();
    setPageOffset({ pageX: top, pageY: left });
    const handleMouseDown = event => {
      const startX = event.pageX - dx;
      const startY = event.pageY - dy;
      const handleMouseMove = e => {
        const newDx = e.pageX - startX;
        const newDy = e.pageY - startY;
        setOffset({ dx: newDx, dy: newDy });
      };
      document.addEventListener('mousemove', handleMouseMove);
      document.addEventListener('mouseup', () => {
          document.removeEventListener('mousemove', handleMouseMove);
        },{ once: true });
    };
    el.current.addEventListener('mousedown', handleMouseDown);
    return () => {
      el.current.removeEventListener('mousedown', handleMouseDown);
    };
  }, [dx, dy, el]);

  useEffect(() => {
    el.current.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
  }, [dx, dy, el]);

  return { x: dx, y: dy, pageX, pageY };
}
复制代码

Edit serverless-morning-r2svr

生命周期:从类组件到函数组件的过渡

截至目前 react 最新版本为 16.9 ,从图例中,探索各生命周期的实现方案。虽然没有理由再去使用 LifyCycle 了,但是了解下还是可以的。

alt

componentDidMount 与 componentWillUnmount

由于是函数组件,没有被实例化,就没有一套完整的 LifeCycle 。componentWillMountcomponentDidMount 只有顺序之分,放在组件顶部。

function App (){
    // 函数组件顶部
    const [value, setValue] = useState(0)
    useEffect(() => {
        console.log('componentDidMount');
        return () => {
          console.log('componentWillUnMount');
        };
    }, []);
    // other
}
复制代码

forceUpdate

通过更新一个无关的state闭包变量强制更新

const [updateDep,setUpdateDep] = useState(0)
function forceUpdate() {
    setUpdateDep((updateDep) => updateDep + 1 )
}
复制代码

getSnapshotBeforeUpdate

render后渲染dom之前调用,当然是 useLayoutEffect。效果有待验证。

const SnapshotRef = useRef()
useLayoutEffect(()=>{
    SnapshotRef.current = //...
})
复制代码

componentDidUpdate

利用上文setState回调的例子,不同的是 componentDidUpdate 依赖的是所有值,所以没有deps。结合 useRefuseEffect 实现,componentDidUpdate 执行时机为组件第二次开始render,只需要判断执行render次数是否大于1即可。时机晚于 useLayoutEffect。以便可以拿到最新的 SnapshotRef.current

let updateRender = useRef(0)

useEffect(() => {
  updateRender.current++ 
  if(updateRender.current > 1){
      // componentDidUpdate
      // get SnapshotRef.current do some thing
  }
})
复制代码

shouldComponentUpdate

在class里,PureComponent 替代自动shouldComponentUpdate,而在函数组件里,当然是memo,能将一个组件完美优化工作量可不会小。

但是有时候我们就单单想控制某个组件不更新。也是可以做到的

const ShouldUpdateCertainCpt = useMemo(() => (
    <div>Never updated</div>
), [])
    
return (
    <ShouldUpdateCertainCpt />
)
复制代码

后话

还是那句话,入乡随俗,React hooks 确实是革命性的变动,不能把 hooks 看成是 ClassComponent LifeCycle 的进化版,应该称之为重做版,于前者来说对新手也不太友好。把底层机制通过 effects 暴露给开发者确实是个明智之举。如果仍然想着用Hooks去实现LifeCycle 那么为什么不用 react 的“老版本”呢?

更有有意思的Hooks

本文有部分Hook都出自react-usehook-guide 思路去开发的。相信掌握了hooks,你离成功剩下的只差一个Idea了。

Hooks 描述
React Use hooks 工具库
useHistory 管理历史记录栈
useScript 动态添加脚本
useAuth 用户状态
useWhyDidYouUpdate hook版Why-Did-You-Update
useDarkMode 切换夜间模式

参考文献

如果你还觉得不错,star一下也是不错的like-hooks

关注下面的标签,发现更多相似文章
评论