Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HOC 和 Hooks —— React组件复用 #51

Open
wuyanqian0503 opened this issue Jun 22, 2021 · 2 comments
Open

HOC 和 Hooks —— React组件复用 #51

wuyanqian0503 opened this issue Jun 22, 2021 · 2 comments

Comments

@wuyanqian0503
Copy link
Owner

wuyanqian0503 commented Jun 22, 2021

Mixin

Mixin(混入)是一种通过扩展收集功能的方式,它本质上是将一个对象的属性拷贝到另一个对象上面去,不过你可以拷贝任意多个对象的任意个方法到一个新对象上去,这是继承所不能实现的。它的出现主要就是为了解决代码复用问题。

React中应用Mixin

React也提供了Mixin的实现,如果完全不同的组件有相似的功能,我们可以引入来实现代码复用,当然只有在使用createClass来创建React组件时才可以使用,因为在React组件的es6写法中它已经被废弃掉了。

Mixin存在一些缺陷:

  • Mixin 可能会相互依赖,相互耦合,不利于代码维护
  • 不同的Mixin中的方法可能会相互冲突
  • Mixin非常多时,组件是可以感知到的,甚至还要为其做相关处理,这样会给代码造成滚雪球式的复杂性

装饰模式

装饰者(decorator)模式能够在不改变对象自身的基础上,在程序运行期间给对像动态的添加职责。与继承相比,装饰者是一种更轻便灵活的做法。

高阶组件HOC(Higher-Order-Components)

高阶组件可以看作React对装饰模式的一种实现,高阶组件就是一个函数,且该函数接受一个组件作为参数,并返回一个新的组件。

高阶组件的实现方式有属性代理反向继承

// 属性代理
function visible(WrappedComponent) {
  return class extends Component {
    render() {
      const { visible, ...props } = this.props;
      if (visible === false) return null;
      return <WrappedComponent {...props} />;
    }
  }
}

// 反向继承
function inheritHOC(WrappedComponent) {
  return class extends WrappedComponent {
    render() {
      return super.render();
    }
  }
}

高阶组件可以实现什么功能

  • 组合渲染
  • 条件渲染
  • 操作props
  • 获取refs
  • 状态管理
  • 操作state
  • 渲染劫持

高阶函数的应用场景有:

  • 页面埋点
  • 记录日志
  • 权限控制
  • 表单校验

React-Redux中的connect

React-Redux中的connect其实就是一个高阶函数,通过代理组件的props来实现将state和dispatch方法作为props注入原组件中。

// 使用provider(注意这是react-redux提供的Provider,区别于React.createContext提供的context.Provider)
import {Provider} from "react-redux";
<Provider store={store}>
            <App/>
 </Provider>

使用HOC的一些注意事项,也可以说是缺陷

  • 原组件的静态属性和方法、生命周期函数无法访问到,需要在高阶函数中做拷贝
  • ref访问到的是HOC的ref,可以通过回调函数配合获取
  • HOC需要在原组件上进行包裹或者嵌套,不建议大量使用HOC,将会产生非常多的嵌套,这让调试变得非常困难。
  • HOC可以劫持props,在不遵守约定的情况下也可能造成冲突。
@wuyanqian0503
Copy link
Owner Author

wuyanqian0503 commented Jun 23, 2021

Hook

Hook是React v16.7.0-alpha中加入的新特性。它可以让你在class以外使用state和其他React特性。

渐进策略,React 没有计划移除 class。最重要的是,Hook 和现有代码可以同时工作,你可以渐进式地使用他们。 不用急着迁移到 Hook。只要是在函数中就可以使用 Hook。

Hook的动机

React官方文档中详细阐述了Hook的动机

概括如下:

1、组件之间复用状态逻辑很难

通常我们使用renderProps或者HOC来做逻辑复用,但是这也需要你重新组织组件结构。Hook 使你在无需修改组件结构的情况下复用状态逻辑

2、复杂组件变得难以理解

在业务逻辑复杂时,同一个生命周期周会包含很多不想关的逻辑,使得开发者在理解组件时很困难。
Hook 将组件中相互关联的部分拆分成更小的函数,而并非强制按照生命周期划分。你还可以使用 reducer 来管理组件的内部状态,使其更加可预测。

3、难以理解的 class

在编写类组件时,你必须去理解 JavaScript 中 this 的工作方式,这与其他语言存在巨大差异。还不能忘记绑定事件处理器。

Hook 使你在非 class 的情况下可以使用更多的 React 特性。

官方 Hook 有哪些

基础 Hook

  • useState
  • useEffect
  • useContext

额外的 Hook

  • useReducer
  • useCallback
  • useMemo
  • useRef
  • useImperativeHandle
  • useLayoutEffect
  • useDebugValue

基础Hook

useState

在初始渲染期间,返回的状态 (state) 与传入的第一个参数 (initialState) 值相同。

setState 函数用于更新 state。它接收一个新的 state 值并将组件的一次重新渲染加入队列。

setState也可以使用函数方式来更新state,函数将接收一个prevState,可以用来解决闭包中陈旧值的问题。

const [state, setState] = useState({});
setState(prevState => {
  // 也可以使用 Object.assign
  return {...prevState, ...updatedValues};
});

另外,由于hook 的 setState 不像 class中的setState会自动进行属性合并,所以可以自己通过扩展操作符来实现合并。

useEffect

Effect Hook 可以让你在函数组件中执行一些具有 side effect(副作用)的操作。

默认情况下,effect 将在每轮组件渲染到屏幕之后执行,但你可以选择让它 在只有某些值改变的时候 才执行。

useEffect接收两个参数:

  • effect函数:可以用来执行一些副作用操作的函数,并且 effect 可以返回一个函数,用来清除副作用。
  • 依赖项数组:当设置了依赖项数组后,可以控制在数组中的依赖项发生变化后才需要执行。当设置依赖项为空数组,则表示只在第一次渲染后执行 effect。

useContext

接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值。当前的 context 值由上层组件中距离当前组件最近的 <MyContext.Provider> 的 value prop 决定。

当组件上层最近的 <MyContext.Provider> 更新时,该 Hook 会触发重渲染,并使用最新传递给 MyContext provider 的 context value 值。即使祖先使用 React.memo 或 shouldComponentUpdate,也会在组件本身使用 useContext 时重新渲染。所以要注意不要滥用useContext。

如何避免 useContext造成的不必要的渲染

1、拆分context,将不会同时发生变更的数据拆分到不同的context中。
2、拆分组件,将不需要依赖context来更新的内容拆分成单独的组件,并且使用 React.memo 来优化性能。

类组件中的表示

useContext(MyContext) 相当于 class 组件中的 static contextType = MyContext 或者 <MyContext.Consumer>

额外的 Hook

useLayoutEffect

其函数签名与 useEffect 相同,但它会在所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。

useReducer

useState 的替代方案。 它接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。(如果你熟悉 Redux 的话,就已经知道它如何工作了。)

在某些场景下,useReducer 会比 useState 更适用,例如 state 逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的 state 等。

并且,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为你可以向子组件传递 dispatch 而不是回调函数,回调函数在不使用 useCalback 进行优化的情况下,组件刷新的时候会重新创建,从而导致子组件重新渲染 。

需要注意的是,React内部使用Object.is 比较算法 来比较state前后是否发生变更,来判断是否需要更新组件,所以和React-Redux一样,reducer的返回值必须是一个新的对象,而不应该在原有state的基础上修改属性值。

另外,由于reducer中总是能够拿到最新的state,所以也可以避免掉闭包导致的 state 陈旧值的问题。

用法:

const [state, dispatch] = useReducer(reducer, initialArg, init);

使用案例(计数器):

const initialState = {count: 0};

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

useCalback

通常用来保存函数的 memoized 版本。
内联回调函数依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。

应用场景:
当组件发生更新时,整个组件函数将被重新执行,其中,传递给子组件的回调函数也会被重新创建,这就会导致子组件不必要的重新渲染,所以我们可以通过useCalback保存回调函数,只有在依赖项发生变更时,才会使用第一个参数中的内联函数来作为新的函数。

useCalback的实现(借助 useMemo)

由于 **useCallback(fn, deps) 相当于 useMemo(() => fn, deps)。**我们可以很简单地通过 useMemo 来实现useCallback:

function useCalback (fn, deps) {
   let memoizedFn = useMemo(() => fn, deps)
   return memoizedFn
}

useMemo

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

返回一个memoized值,当依赖项没有发生变更时,就不会重新执行创建函数,减少不必要的计算开销。

如果不提供依赖项,则每次都会重新计算。

你可以把 useMemo 作为性能优化的手段,但不要把它当成语义上的保证。

useRef

const refContainer = useRef(initialValue);

useRef() 比 ref 属性更有用。它可以很方便地保存任何可变值,其类似于在 class 中使用实例字段的方式。

这是因为它创建的是一个普通 Javascript 对象。而 useRef() 和自建一个 {current: ...} 对象的唯一区别是,useRef 会在每次渲染时返回同一个 ref 对象。

需要注意的是,修改ref对象的current属性时,不会触发组件的重新渲染。

useImperativeHandle

useImperativeHandle 可以让你在使用 ref 时自定义暴露给父组件的实例值。在大多数情况下,应当避免使用 ref 这样的命令式代码。useImperativeHandle 应当与 forwardRef 一起使用

function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => {
      // 将输入框的focus方法暴露给父组件
      inputRef.current.focus();
    }
  }));
  return <input ref={inputRef} ... />;
}
FancyInput = forwardRef(FancyInput);

在本例中,渲染 的父组件可以调用 inputRef.current.focus()。

useDebugValue

useDebugValue 可用于在 React 开发者工具中显示自定义 hook 的标签。

例如,“自定义 Hook” 章节中描述的名为 useFriendStatus 的自定义 Hook:

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  // ...

  // 在开发者工具中的这个 Hook 旁边显示标签
  // e.g. "FriendStatus: Online"
  useDebugValue(isOnline ? 'Online' : 'Offline');

  return isOnline;
}

我们不推荐你向每个自定义 Hook 添加 debug 值。当它作为共享库的一部分时才最有价值。

使用 Hook 的注意事项

  • 只能在函数组件或自定义 Hook 中使用 Hook
  • 不要在条件或嵌套函数中使用 Hook

Hook 是通过数组实现的,React需要利用 Hook 的调用顺序来正确更新相应的状态,如果 useState 被包裹循环或条件语句中,那每就可能会引起调用顺序的错乱,从而造成意想不到的错误。

我们可以安装一个eslint插件来帮助我们避免这些问题。

// 安装
npm install eslint-plugin-react-hooks --save-dev
// 配置
{
  "plugins": [
    // ...
    "react-hooks"
  ],
  "rules": {
    // ...
    "react-hooks/rules-of-hooks": "error"
  }
}

自定义Hook

像上面介绍的HOC和mixin一样,我们同样可以通过自定义的Hook将组件中类似的状态逻辑抽取出来。
自定义Hook非常简单,我们只需要定义一个函数,并且把相应需要的状态和effect封装进去,同时,Hook之间也是可以相互引用的。使用use开头命名自定义Hook,这样可以方便eslint进行检查。

例举自定义 Hook 的封装:

修改页面title:

function useTitle(title) {
  useEffect(
    () => {
      document.title = title;
      return () => (document.title = "主页");
    },
    [title]
  );
}

function Page1(props){
  useTitle('Page1');
  return (<div>...</div>)
}

页面埋点:

function useTrack(title) {
  useEffect(
    () => {
     track.pageTrack(title)
    },
    []
  );
}

禁止页面滚动:

useBlockScroll(title) {
  const originOverflow = document.body.style.overflow
  useEffect(
    () => {
     document.body.style.height = "100%"
     document.body.style.overflow = "hidden"
         return () => {
             document.body.style.height = "auto"
              document.body.style.overflow  = originOverflow
         }
    },
    []
  );
}

Hook中闭包陷阱的原理,如何解决

@wuyanqian0503
Copy link
Owner Author

wuyanqian0503 commented Jun 23, 2021

Hook中闭包陷阱的原理,如何解决

来看一个典型的使用 useEffect 时导致的闭包陷阱:

const btn = useRef();
const [v, setV] = useState('');
useEffect(() => {
    let clickHandle = () => {
        console.log('v:', v);
    }
    btn.current.addEventListener('click', clickHandle)
    
    return () => {
        btn.removeEventListener('click', clickHandle)
    }
}, []);
    
const inputHandle = e => {
    setV(e.target.value)
}

return (
        <>
            <input value={v} onChange={inputHandle} />
            <button ref={btn} >测试</button>
        </>
    )

在函数组件更新时,是会重新执行函数的,也就会形成一个新的上下文。

那么我们根据作用域的访问规则可以知道,effect 中的事件监听器访问了 state,会形成闭包,并且一直指向的都是第一轮更新时上下文中的 state 。后续就算state发生了变更,也不会对上一轮更新的state产生修改,所以回调函数中访问到的state一直都是旧值。

总结原因:
effect 函数中的代码通过闭包的形式保存了本轮组件更新的执行上下文中声明的 变量(不一定是state)

还有一点是,通过声明依赖只是可以更新effect函数,在下轮更新时执行新的effect,但是老的effect函数中产生的闭包并不一定能够被清除,比如说通过setInterval注册的回调,需要在下一轮的effect执行时主动去清除,否则这个闭包会在执行时不断产生。

解决方法

1、通过useRef来获取最新的值
2、声明依赖项来更新副作用函数,并且必要时需要清除之前产生的副作用,比如清除定时器
3、如果有更新 state 的需要,可以通过setState 的函数方式更新 state,这样回调函数中总是能拿到最新的state
4、同样我们还可以通过 useReducer 来拿到最新的state,因为 reducer 中总是能拿到最新的 state。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant