精读《Function Component 入门》

1. 引言

若是你在使用 React 16,能够尝试 Function Component 风格,享受更大的灵活性。但在尝试以前,最好先阅读本文,对 Function Component 的思惟模式有一个初步认识,防止因思惟模式不一样步形成的困扰。html

2. 精读

什么是 Function Component?

Function Component 就是以 Function 的形式建立的 React 组件:前端

function App() {
  return (
    <div>
      <p>App</p>
    </div>
  );
}

也就是,一个返回了 JSX 或 createElement 的 Function 就能够看成 React 组件,这种形式的组件就是 Function Component。react

因此我已经学会 Function Component 了吗?ios

别急,故事才刚刚开始。git

什么是 Hooks?

Hooks 是辅助 Function Component 的工具。好比 useState 就是一种 Hook,它能够用来管理状态:es6

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

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

useState 返回的结果是数组,数组的第一项是 ,第二项是 赋值函数useState 函数的第一个参数就是 默认值,也支持回调函数。更详细的介绍能够参考 Hooks 规则解读github

先赋值再 setTimeout 打印

咱们再将 useStatesetTimeout 结合使用,看看有什么发现。npm

建立一个按钮,点击后让计数器自增,可是延时 3 秒后再打印出来redux

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

  const log = () => {
    setCount(count + 1);
    setTimeout(() => {
      console.log(count);
    }, 3000);
  };

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={log}>Click me</button>
    </div>
  );
}

若是咱们 在三秒内连续点击三次,那么 count 的值最终会变成 3,而随之而来的输出结果是。。?axios

0
1
2

嗯,好像对,但总以为有点怪?

使用 Class Component 方式实现一遍呢?

敲黑板了,回到咱们熟悉的 Class Component 模式,实现一遍上面的功能:

class Counter extends Component {
  state = { count: 0 };

  log = () => {
    this.setState({
      count: this.state.count + 1
    });
    setTimeout(() => {
      console.log(this.state.count);
    }, 3000);
  };

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={this.log}>Click me</button>
      </div>
    );
  }
}

嗯,结果应该等价吧?3 秒内快速点击三次按钮,此次的结果是:

3
3
3

怎么和 Function Component 结果不同?

这是用好 Function Component 必须迈过的第一道坎,请确认彻底理解下面这段话:

首先对 Class Component 进行解释:

  1. 首先 state 是 Immutable 的,setState 后必定会生成一个全新的 state 引用。
  2. 但 Class Component 经过 this.state 方式读取 state,这致使了每次代码执行都会拿到最新的 state 引用,因此快速点击三次的结果是 3 3 3

那么对 Function Component 而言:

  1. useState 产生的数据也是 Immutable 的,经过数组第二个参数 Set 一个新值后,原来的值会造成一个新的引用在下次渲染时。
  2. 但因为对 state 的读取没有经过 this. 的方式,使得 每次 setTimeout 都读取了当时渲染闭包环境的数据,虽然最新的值跟着最新的渲染变了,但旧的渲染里,状态依然是旧值。

为了更容易理解,咱们来模拟三次 Function Component 模式下点击按钮时的状态:

第一次点击,共渲染了 2 次,setTimeout 生效在第 1 次渲染,此时状态为:

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

  const log = () => {
    setCount(0 + 1);
    setTimeout(() => {
      console.log(0);
    }, 3000);
  };

  return ...
}

第二次点击,共渲染了 3 次,setTimeout 生效在第 2 次渲染,此时状态为:

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

  const log = () => {
    setCount(1 + 1);
    setTimeout(() => {
      console.log(1);
    }, 3000);
  };

  return ...
}

第三次点击,共渲染了 4 次,setTimeout 生效在第 3 次渲染,此时状态为:

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

  const log = () => {
    setCount(2 + 1);
    setTimeout(() => {
      console.log(2);
    }, 3000);
  };

  return ...
}

能够看到,每个渲染都是一个独立的闭包,在独立的三次渲染中,count 在每次渲染中的值分别是 0 1 2,因此不管 setTimeout 延时多久,打印出来的结果永远是 0 1 2

理解了这一点,咱们就能继续了。

如何让 Function Component 也打印 3 3 3

因此这是否是表明 Function Component 没法覆盖 Class Component 的功能呢?彻底不是,我但愿你读完本文后,不只能解决这个问题,更能理解为何用 Function Component 实现的代码更佳合理、优雅

第一种方案是借助一个新 Hook - useRef 的能力:

function Counter() {
  const count = useRef(0);

  const log = () => {
    count.current++;
    setTimeout(() => {
      console.log(count.current);
    }, 3000);
  };

  return (
    <div>
      <p>You clicked {count.current} times</p>
      <button onClick={log}>Click me</button>
    </div>
  );
}

这种方案的打印结果就是 3 3 3

想要理解为何,首先要理解 useRef 的功能:经过 useRef 建立的对象,其值只有一份,并且在全部 Rerender 之间共享

因此咱们对 count.current 赋值或读取,读到的永远是其最新值,而与渲染闭包无关,所以若是快速点击三下,一定会返回 3 3 3 的结果。

但这种方案有个问题,就是使用 useRef 替代了 useState 建立值,那么很天然的问题就是,如何不改变原始值的写法,达到一样的效果呢?

如何不改造原始值也打印 3 3 3

一种最简单的作法,就是新建一个 useRef 的值给 setTimeout 使用,而程序其他部分仍是用原始的 count:

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

  useEffect(() => {
    currentCount.current = count;
  });

  const log = () => {
    setCount(count + 1);
    setTimeout(() => {
      console.log(currentCount.current);
    }, 3000);
  };

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={log}>Click me</button>
    </div>
  );
}

经过这个例子,咱们引出了一个新的,也是 最重要的 Hook - useEffect,请务必深刻理解这个函数。

useEffect 是处理反作用的,其执行时机在 每次 Render 渲染完毕后,换句话说就是每次渲染都会执行,只是实际在真实 DOM 操做完毕后。

咱们能够利用这个特性,在每次渲染完毕后,将 count 此时最新的值赋给 currentCount.current,这样就使 currentCount 的值自动同步了 count 的最新值。

为了确保你们准确理解 useEffect,笔者再啰嗦一下,将其执行周期拆解到每次渲染中。假设你在三秒内快速点击了三次按钮,那么你须要在大脑中模拟出下面这三次渲染都发生了什么:

第一次点击,共渲染了 2 次,useEffect 生效在第 2 次渲染:

function Counter() {
  const [1, setCount] = useState(0);
  const currentCount = useRef(0);

  useEffect(() => {
    currentCount.current = 1; // 第二次渲染完毕后执行一次
  });

  const log = () => {
    setCount(1 + 1);
    setTimeout(() => {
      console.log(currentCount.current);
    }, 3000);
  };

  return ...
}

第二次点击,共渲染了 3 次,useEffect 生效在第 3 次渲染:

function Counter() {
  const [2, setCount] = useState(0);
  const currentCount = useRef(0);

  useEffect(() => {
    currentCount.current = 2; // 第三次渲染完毕后执行一次
  });

  const log = () => {
    setCount(2 + 1);
    setTimeout(() => {
      console.log(currentCount.current);
    }, 3000);
  };

  return ...
}

第三次点击,共渲染了 4 次,useEffect 生效在第 4 次渲染:

function Counter() {
  const [3, setCount] = useState(0);
  const currentCount = useRef(0);

  useEffect(() => {
    currentCount.current = 3; // 第四次渲染完毕后执行一次
  });

  const log = () => {
    setCount(3 + 1);
    setTimeout(() => {
      console.log(currentCount.current);
    }, 3000);
  };

  return ...
}

注意对比与上面章节展开的 setTimeout 渲染时有什么不一样。

要注意的是,useEffect 也随着每次渲染而不一样的,同一个组件不一样渲染之间,useEffect 内闭包环境彻底独立。对于本次的例子,useEffect 共执行了 四次,经历了以下四次赋值最终变成 3:

currentCount.current = 0; // 第 1 次渲染
currentCount.current = 1; // 第 2 次渲染
currentCount.current = 2; // 第 3 次渲染
currentCount.current = 3; // 第 4 次渲染

请确保理解了这句话再继续往下阅读:

  • setTimeout 的例子,三次点击触发了四次渲染,但 setTimeout 分别生效在第 一、二、3 次渲染中,所以值是 0 1 2
  • useEffect 的例子中,三次点击也触发了四次渲染,但 useEffect 分别生效在第 一、二、三、4 次渲染中,最终使 currentCount 的值变成 3

用自定义 Hook 包装 useRef

是否是以为每次都写一堆 useEffect 同步数据到 useRef 很烦?是的,想要简化,就须要引出一个新的概念:自定义 Hooks

首先介绍一下,自定义 Hooks 容许建立自定义 Hook,只要函数名遵循以 use 开头,且返回非 JSX 元素,就是 Hooks 啦!自定义 Hooks 内还能够调用包括内置 Hooks 在内的全部自定义 Hooks

也就是咱们能够将 useEffect 写到自定义 Hook 里:

function useCurrentValue(value) {
  const ref = useRef(0);

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref;
}

这里又引出一个新的概念,就是 useEffect 的第二个参数,dependences。dependences 这个参数定义了 useEffect 的依赖,在新的渲染中,只要全部依赖项的引用都不发生变化,useEffect 就不会被执行,且当依赖项为 [] 时,useEffect 仅在初始化执行一次,后续的 Rerender 永远也不会被执行。

这个例子中,咱们告诉 React:仅当 value 的值变化了,再将其最新值同步给 ref.current

那么这个自定义 Hook 就能够在任何 Function Component 调用了:

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

  const log = () => {
    setCount(count + 1);
    setTimeout(() => {
      console.log(currentCount.current);
    }, 3000);
  };

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={log}>Click me</button>
    </div>
  );
}

封装之后代码清爽了不少,并且最重要的是将逻辑封装起来,咱们只要理解 useCurrentValue 这个 Hook 能够产生一个值,其最新值永远与入参同步。

看到这里,也许有的小伙伴已经按捺不住迸发的灵感了:useEffect 第二个参数设置为空数组,这个自定义 Hook 就表明了 didMount 生命周期!

是的,但笔者建议你们 不要再想生命周期的事情,这样会阻碍你更好的理解 Function Component。由于下一个话题,就是要告诉你:永远要对 useEffect 的依赖诚实,被依赖的参数必定要填上去,不然会产生很是难以察觉与修复的 BUG。

setTimeout 换成 setInterval 会怎样

咱们回到起点,将第一个 setTimeout Demo 中换成 setInterval,看看会如何:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

这个例子将引起学习 Function Component 的第二个拦路虎,理解了它,才深刻理解了 Function Component 的渲染原理。

首先介绍一下引入的新概念,useEffect 函数的返回值。它的返回值是一个函数,这个函数在 useEffect 即将从新执行时,会先执行上一次 Rerender useEffect 第一个回调的返回函数,再执行下一次渲染的 useEffect 第一个回调。

以两次连续渲染为例介绍,展开后的效果是这样的:

第一次渲染:

function Counter() {
  useEffect(() => {
    // 第一次渲染完毕后执行
    // 最终执行顺序:1
    return () => {
      // 因为没有填写依赖项,因此第二次渲染 useEffect 会再次执行,在执行前,第一次渲染中这个地方的回调函数会首先被调用
      // 最终执行顺序:2
    }
  });

  return ...
}

第二次渲染:

function Counter() {
  useEffect(() => {
    // 第二次渲染完毕后执行
    // 最终执行顺序:3
    return () => {
      // 依此类推
    }
  });

  return ...
}

然而本 Demo 将 useEffect 的第二个参数设置为了 [],那么其返回函数只会在这个组件被销毁时执行

读懂了前面的例子,应该能想到,这个 Demo 但愿利用 [] 依赖,将 useEffect 看成 didMount 使用,再结合 setInterval 每次时 count 自增,这样指望将 count 的值每秒自增 1。

然而结果是:

1
1
1
...

理解了 setTimeout 例子的读者应该能够自行推导出缘由:setInterval 永远在第一次 Render 的闭包中,count 的值永远是 0,也就是等价于:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(0 + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

然而罪魁祸首就是 没有对依赖诚实 致使的。例子中 useEffect 明明依赖了 count,依赖项却非要写 [],因此产生了很难理解的错误。

因此改正的办法就是 对依赖诚实

永远对依赖项诚实

一旦咱们对依赖诚实了,就能够获得正确的效果:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, [count]);

  return <h1>{count}</h1>;
}

咱们将 count 做为了 useEffect 的依赖项,就获得了正确的结果:

1
2
3
...

既然漏写依赖的风险这么大,天然也有保护措施,那就是 eslint-plugin-react-hooks 这个插件,会自动订正你的代码中的依赖,想不对依赖诚实都不行!

然而对这个例子而言,代码依然存在 BUG:每次计数器都会从新实例化,若是换成其余费事操做,性能成本将不可接受。

如何不在每次渲染时从新实例化 setInterval?

最简单的办法,就是利用 useState 的第二种赋值用法,不直接依赖 count,而是以函数回调方式进行赋值:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

这这写法真正作到了:

  1. 不依赖 count,因此对依赖诚实。
  2. 依赖项为 [],只有初始化会对 setInterval 进行实例化。

而之因此输出仍是正确的 1 2 3 ...,缘由是 setCount 的回调函数中,c 值永远指向最新的 count 值,所以没有逻辑漏洞。

可是聪明的同窗仔细一想,就会发现一个新问题:若是存在两个以上变量须要使用时,这招就没有用武之地了。

同时使用两个以上变量时?

若是同时须要对 countstep 两个变量作累加,那 useEffect 的依赖必然要写上一种某一个值,频繁实例化的问题就又出现了:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step);
    }, 1000);
    return () => clearInterval(id);
  }, [step]);

  return <h1>{count}</h1>;
}

这个例子中,因为 setCount 只能拿到最新的 count 值,而为了每次都拿到最新的 step 值,就必须将 step 申明到 useEffect 依赖中,致使 setInterval 被频繁实例化。

这个问题天然也困扰了 React 团队,因此他们拿出了一个新的 Hook 解决问题:useReducer

什么是 useReducer

先别联想到 Redux。只考虑上面的场景,看看为何 React 团队要将 useReducer 列为内置 Hooks 之一。

先介绍一下 useReducer 的用法:

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

useReducer 返回的结构与 useState 很像,只是数组第二项是 dispatch,而接收的参数也有两个,初始值放在第二位,第一位就是 reducer

reducer 定义了如何对数据进行变换,好比一个简单的 reducer 以下:

function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return {
        ...state,
        count: state.count + 1
      };
    default:
      return state;
  }
}

这样就能够经过调用 dispatch({ type: 'increment' }) 的方式实现 count 自增了。

那么回到这个例子,咱们只须要稍微改写一下用法便可:

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { count, step } = state;

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: "tick" });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);

  return <h1>{count}</h1>;
}

function reducer(state, action) {
  switch (action.type) {
    case "tick":
      return {
        ...state,
        count: state.count + state.step
      };
  }
}

能够看到,咱们经过 reducertick 类型完成了对 count 的累加,而在 useEffect 的函数中,居然彻底绕过了 countstep 这两个变量。因此 useReducer 也被称为解决此类问题的 “黑魔法”。

其实无论被怎么称呼也好,其本质是让函数与数据解耦,函数只管发出指令,而不须要关心使用的数据被更新时,须要从新初始化自身。

仔细的读者会发现这个例子仍是有一个依赖的,那就是 dispatch,然而 dispatch 引用永远也不会变,所以能够忽略它的影响。这也体现了不管如何都要对依赖保持诚实。

这也引起了另外一个注意项:尽可能将函数写在 useEffect 内部

将函数写在 useEffect 内部

为了不遗漏依赖,必须将函数写在 useEffect 内部,这样 eslint-plugin-react-hooks 才能经过静态分析补齐依赖项:

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

  useEffect(() => {
    function getFetchUrl() {
      return "https://v?query=" + count;
    }

    getFetchUrl();
  }, [count]);

  return <h1>{count}</h1>;
}

getFetchUrl 这个函数依赖了 count,而若是将这个函数定义在 useEffect 外部,不管是机器仍是人眼都难以看出 useEffect 的依赖项包含 count

然而这就引起了一个新问题:将全部函数都写在 useEffect 内部岂不是很是难以维护?

如何将函数抽到 useEffect 外部?

为了解决这个问题,咱们要引入一个新的 Hook:useCallback,它就是解决将函数抽到 useEffect 外部的问题。

咱们先看 useCallback 的用法:

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

  const getFetchUrl = useCallback(() => {
    return "https://v?query=" + count;
  }, [count]);

  useEffect(() => {
    getFetchUrl();
  }, [getFetchUrl]);

  return <h1>{count}</h1>;
}

能够看到,useCallback 也有第二个参数 - 依赖项,咱们将 getFetchUrl 函数的依赖项经过 useCallback 打包到新的 getFetchUrl 函数中,那么 useEffect 就只须要依赖 getFetchUrl 这个函数,就实现了对 count 的间接依赖。

换句话说,咱们利用了 useCallbackgetFetchUrl 函数抽到了 useEffect 外部。

为何 useCallbackcomponentDidUpdate 更好用

回忆一下 Class Component 的模式,咱们是如何在函数参数变化时进行从新取数的:

class Parent extends Component {
  state = {
    count: 0,
    step: 0
  };
  fetchData = () => {
    const url =
      "https://v?query=" + this.state.count + "&step=" + this.state.step;
  };
  render() {
    return <Child fetchData={this.fetchData} count={count} step={step} />;
  }
}

class Child extends Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.props.fetchData();
  }
  componentDidUpdate(prevProps) {
    if (
      this.props.count !== prevProps.count &&
      this.props.step !== prevProps.step // 别漏了!
    ) {
      this.props.fetchData();
    }
  }
  render() {
    // ...
  }
}

上面的代码常常用 Class Component 的人应该很熟悉,然而暴露的问题可不小。

咱们须要理解 props.count props.stepprops.fetchData 函数使用了,所以在 componentDidUpdate 时,判断这两个参数发生了变化就触发从新取数。

然而问题是,这种理解成本是否是太高了?若是父级函数 fetchData 不是我写的,在不读源码的状况下,我怎么知道它依赖了 props.countprops.step 呢?更严重的是,若是某一天 fetchData 多依赖了 params 这个参数,下游函数将须要所有在 componentDidUpdate 覆盖到这个逻辑,不然 params 变化时将不会从新取数。能够想象,这种方式维护成本巨大,甚至能够说几乎没法维护。

换成 Function Component 的思惟吧!试着用上刚才提到的 useCallback 解决问题:

function Parent() {
  const [ count, setCount ] = useState(0);
  const [ step, setStep ] = useState(0);

  const fetchData = useCallback(() => {
    const url = 'https://v/search?query=' + count + "&step=" + step;
  }, [count, step])

  return (
    <Child fetchData={fetchData} />
  )
}

function Child(props) {
  useEffect(() => {
    props.fetchData()
  }, [props.fetchData])

  return (
    // ...
  )
}

能够看出来,当 fetchData 的依赖变化后,按下保存键,eslint-plugin-react-hooks 会自动补上更新后的依赖,而下游的代码不须要作任何改变,下游只须要关心依赖了 fetchData 这个函数便可,至于这个函数依赖了什么,已经封装在 useCallback 后打包透传下来了。

不只解决了维护性问题,并且对于 只要参数变化,就从新执行某逻辑,是特别适合用 useEffect 作的,使用这种思惟思考问题会让你的代码更 “智能”,而使用分裂的生命周期进行思考,会让你的代码四分五裂,并且容易漏掉各类时机。

useEffect 对业务的抽象很是方便,笔者举几个例子:

  1. 依赖项是查询参数,那么 useEffect 内能够进行取数请求,那么只要查询参数变化了,列表就会自动取数刷新。注意咱们将取数时机从触发端改为了接收端。
  2. 当列表更新后,从新注册一遍拖拽响应事件。也是同理,依赖参数是列表,只要列表变化,拖拽响应就会从新初始化,这样咱们能够放心的修改列表,而不用担忧拖拽事件失效。
  3. 只要数据流某个数据变化,页面标题就同步修改。同理,也不须要在每次数据变化时修改标题,而是经过 useEffect “监听” 数据的变化,这是一种 “控制反转” 的思惟。

说了这么多,其本质仍是利用了 useCallback 将函数独立抽离到 useEffect 外部。

那么进一步思考,能够将函数抽离到整个组件的外部吗?

这也是能够的,须要灵活运用自定义 Hooks 实现。

将函数抽到组件外部

以上面的 fetchData 函数为例,若是要抽到整个组件的外部,就不是利用 useCallback 作到了,而是利用自定义 Hooks 来作:

function useFetch(count, step) {
  return useCallback(() => {
    const url = "https://v/search?query=" + count + "&step=" + step;
  }, [count, step]);
}

能够看到,咱们将 useCallback 打包搬到了自定义 Hook useFetch 中,那么函数中只须要一行代码就能实现同样的效果了:

function Parent() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(0);
  const [other, setOther] = useState(0);
  const fetch = useFetch(count, step); // 封装了 useFetch

  useEffect(() => {
    fetch();
  }, [fetch]);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>setCount {count}</button>
      <button onClick={() => setStep(c => c + 1)}>setStep {step}</button>
      <button onClick={() => setOther(c => c + 1)}>setOther {other}</button>
    </div>
  );
}

随着使用愈来愈方便,咱们能够将精力放到性能上。观察能够发现,countstep 都会频繁变化,每次变化就会致使 useFetchuseCallback 依赖的变化,进而致使从新生成函数。然而实际上这种函数是不必每次都从新生成的,反复生成函数会形成大量性能损耗。

换一个例子就能够看得更清楚:

function Parent() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(0);
  const [other, setOther] = useState(0);
  const drag = useDraggable(count, step); // 封装了拖拽函数
}

假设咱们使用 Sortablejs 对某个区域进行拖拽监听,这个函数每次都重复执行的性能损耗很是大,然而这个函数内部可能由于仅仅要上报一些日志,因此依赖了没有实际被使用的 count step 变量:

function useDraggable(count, step) {
  return useCallback(() => {
    // 上报日志
    report(count, step);

    // 对区域进行初始化,很是耗时
    // ... 省略耗时代码
  }, [count, step]);
}

这种状况,函数的依赖就特别不合理。虽然依赖变化应该触发函数从新执行,但若是函数从新执行的成本很是高,而依赖只是无关紧要的点缀,得不偿失。

利用 Ref 保证耗时函数依赖不变

一种办法是经过将依赖转化为 Ref:

function useFetch(count, step) {
  const countRef = useRef(count);
  const stepRef = useRef(step);

  useEffect(() => {
    countRef.current = count;
    stepRef.current = step;
  });

  return useCallback(() => {
    const url =
      "https://v/search?query=" + countRef.current + "&step=" + stepRef.current;
  }, [countRef, stepRef]); // 依赖不会变,却能每次拿到最新的值
}

这种方式比较取巧,将须要更新的区域与耗时区域分离,再将需更新的内容经过 Ref 提供给耗时的区域,实现性能优化。

然而这样作对函数的改动成本比较高,有一种更通用的作法解决此类问题。

通用的自定义 Hooks 解决函数从新实例化问题

咱们能够利用 useRef 创造一个自定义 Hook 代替 useCallback使其依赖的值变化时,回调不会从新执行,却能拿到最新的值!

这个神奇的 Hook 写法以下:

function useEventCallback(fn, dependencies) {
  const ref = useRef(null);

  useEffect(() => {
    ref.current = fn;
  }, [fn, ...dependencies]);

  return useCallback(() => {
    const fn = ref.current;
    return fn();
  }, [ref]);
}

再次体会到自定义 Hook 的无所不能。

首先看这一段:

useEffect(() => {
  ref.current = fn;
}, [fn, ...dependencies]);

fn 回调函数变化时, ref.current 从新指向最新的 fn 这个逻辑中规中矩。重点是,当依赖 dependencies 变化时,也从新为 ref.current 赋值,此时 fn 内部的 dependencies 值是最新的,而下一段代码:

return useCallback(() => {
  const fn = ref.current;
  return fn();
}, [ref]);

又仅执行一次(ref 引用不会改变),因此每次均可以返回 dependencies 是最新的 fn,而且 fn 还不会从新执行。

假设咱们对 useEventCallback 传入的回调函数称为 X,则这段代码的含义,就是使每次渲染的闭包中,回调函数 X 老是拿到的老是最新 Rerender 闭包中的那个,因此依赖的值永远是最新的,并且函数不会从新初始化。

React 官方不推荐使用此范式,所以对于这种场景,利用 useReducer,将函数经过 dispatch 中调用。 还记得吗?dispatch 是一种能够绕过依赖的黑魔法,咱们在 “什么是 useReducer” 小节提到过。

随着对 Function Component 的使用,你也渐渐关心到函数的性能了,这很棒。那么下一个重点天然是关注 Render 的性能。

用 memo 作 PureRender

在 Fucntion Component 中,Class Component 的 PureComponent 等价的概念是 React.memo,咱们介绍一下 memo 的用法:

const Child = memo((props) => {
  useEffect(() => {
    props.fetchData()
  }, [props.fetchData])

  return (
    // ...
  )
})

使用 memo 包裹的组件,会在自身重渲染时,对每个 props 项进行浅对比,若是引用没有变化,就不会触发重渲染。因此 memo 是一种很棒的性能优化工具。

下面就介绍一个看似比 memo 难用,但真正理解后会发现,其实比 memo 更好用的渲染优化函数:useMemo

用 useMemo 作局部 PureRender

相比 React.memo 这个异类,React.useMemo 但是正经的官方 Hook:

const Child = (props) => {
  useEffect(() => {
    props.fetchData()
  }, [props.fetchData])

  return useMemo(() => (
    // ...
  ), [props.fetchData])
}

能够看到,咱们利用 useMemo 包裹渲染代码,这样即使函数 Child 由于 props 的变化从新执行了,只要渲染函数用到的 props.fetchData 没有变,就不会从新渲染。

这里发现了 useMemo 的第一个好处:更细粒度的优化渲染

所谓更细粒度的优化渲染,是指函数 Child 总体可能用到了 AB 两个 props,而渲染仅用到了 B,那么使用 memo 方案时,A 的变化会致使重渲染,而使用 useMemo 的方案则不会。

useMemo 的好处还不止这些,这里先留下伏笔。咱们先看一个新问题:当参数愈来愈多时,使用 props 将函数、值在组件间传递很是冗长:

function Parent() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(0);
  const fetchData = useFetch(count, step);

  return <Child fetchData={fetchData} setCount={setCount} setStep={setStep} />;
}

虽然 Child 能够经过 memouseMemo 进行优化,但当程序复杂时,可能存在多个函数在全部 Function Component 间共享的状况 ,此时就须要新 Hook: useContext 来拯救了。

使用 Context 作批量透传

在 Function Component 中,可使用 React.createContext 建立一个 Context:

const Store = createContext(null);

其中 null 是初始值,通常置为 null 也不要紧。接下来还有两步,分别是在根节点使用 Store.Provider 注入,与在子节点使用官方 Hook useContext 拿到注入的数据:

在根节点使用 Store.Provider 注入:

function Parent() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(0);
  const fetchData = useFetch(count, step);

  return (
    <Store.Provider value={{ setCount, setStep, fetchData }}>
      <Child />
    </Store.Provider>
  );
}

在子节点使用 useContext 拿到注入的数据(也就是拿到 Store.Providervalue):

const Child = memo((props) => {
  const { setCount } = useContext(Store)

  function onClick() {
    setCount(count => count + 1)
  }

  return (
    // ...
  )
})

这样就不须要在每一个函数间进行参数透传了,公共函数能够都放在 Context 里。

可是当函数多了,Providervalue 会变得很臃肿,咱们能够结合以前讲到的 useReducer 解决这个问题。

使用 useReducer 为 Context 传递内容瘦身

使用 useReducer,全部回调函数都经过调用 dispatch 完成,那么 Context 只要传递 dispatch 一个函数就行了:

const Store = createContext(null);

function Parent() {
  const [state, dispatch] = useReducer(reducer, { count: 0, step: 0 });

  return (
    <Store.Provider value={dispatch}>
      <Child />
    </Store.Provider>
  );
}

这下不管是根节点的 Provider,仍是子元素调用都清爽不少:

const Child = useMemo((props) => {
  const dispatch = useContext(Store)

  function onClick() {
    dispatch({
      type: 'countInc'
    })
  }

  return (
    // ...
  )
})

你也许很快就想到,将 state 也经过 Provider 注入进去岂不更妙?是的,但此处请务必注意潜在性能问题。

state 也放到 Context 中

稍稍改造下,将 state 也放到 Context 中,这下赋值与取值都很是方便了!

const Store = createContext(null);

function Parent() {
  const [state, dispatch] = useReducer(reducer, { count: 0, step: 0 });

  return (
    <Store.Provider value={{ state, dispatch }}>
      <Count />
      <Step />
    </Store.Provider>
  );
}

Count Step 这两个子元素而言,可须要谨慎一些,假如咱们这么实现这两个子元素:

const Count = memo(() => {
  const { state, dispatch } = useContext(Store);
  return (
    <button onClick={() => dispatch("incCount")}>incCount {state.count}</button>
  );
});

const Step = memo(() => {
  const { state, dispatch } = useContext(Store);
  return (
    <button onClick={() => dispatch("incStep")}>incStep {state.step}</button>
  );
});

其结果是:不管点击 incCount 仍是 incStep,都会同时触发这两个组件的 Rerender。

其问题在于:memo 只能挡在最外层的,而经过 useContext 的数据注入发生在函数内部,会 绕过 memo

当触发 dispatch 致使 state 变化时,全部使用了 state 的组件内部都会强制从新刷新,此时想要对渲染次数作优化,只有拿出 useMemo 了!

useMemo 配合 useContext

使用 useContext 的组件,若是自身不使用 props,就能够彻底使用 useMemo 代替 memo 作性能优化:

const Count = () => {
  const { state, dispatch } = useContext(Store);
  return useMemo(
    () => (
      <button onClick={() => dispatch("incCount")}>
        incCount {state.count}
      </button>
    ),
    [state.count, dispatch]
  );
};

const Step = () => {
  const { state, dispatch } = useContext(Store);
  return useMemo(
    () => (
      <button onClick={() => dispatch("incStep")}>incStep {state.step}</button>
    ),
    [state.step, dispatch]
  );
};

对这个例子来讲,点击对应的按钮,只有使用到的组件才会重渲染,效果符合预期。 结合 eslint-plugin-react-hooks 插件使用,连 useMemo 的第二个参数依赖都是自动补全的。

读到这里,不知道你是否联想到了 ReduxConnect?

咱们来对比一下 ConnectuseMemo,会发现惊人的类似之处。

一个普通的 Redux 组件:

const mapStateToProps = state => (count: state.count);

const mapDispatchToProps = dispatch => dispatch;

@Connect(mapStateToProps, mapDispatchToProps)
class Count extends React.PureComponent {
  render() {
    return (
      <button onClick={() => this.props.dispatch("incCount")}>
        incCount {this.props.count}
      </button>
    );
  }
}

一个普通的 Function Component 组件:

const Count = () => {
  const { state, dispatch } = useContext(Store);
  return useMemo(
    () => (
      <button onClick={() => dispatch("incCount")}>
        incCount {state.count}
      </button>
    ),
    [state.count, dispatch]
  );
};

这两段代码的效果彻底同样,Function Component 除了更简洁以外,还有一个更大的优点:全自动的依赖推导

Hooks 诞生的一个缘由,就是为了便于静态分析依赖,简化 Immutable 数据流的使用成本。

咱们看 Connect 的场景:

因为不知道子组件使用了哪些数据,所以须要在 mapStateToProps 提早写好,而当须要使用数据流内新变量时,组件里是没法访问的,咱们要回到 mapStateToProps 加上这个依赖,再回到组件中使用它。

useContext + useMemo 的场景:

因为注入的 state 是全量的,Render 函数中想用什么均可直接用,在按保存键时,eslint-plugin-react-hooks 会经过静态分析,在 useMemo 第二个参数自动补上代码里使用到的外部变量,好比 state.countdispatch

另外能够发现,Context 很像 Redux,那么 Class Component 模式下的异步中间件实现的异步取数怎么利用 useReducer 作呢?答案是:作不到。

固然不是说 Function Component 没法实现异步取数,而是用的工具错了。

使用自定义 Hook 处理反作用

好比上面抛出的异步取数场景,在 Function Component 的最佳作法是封装成一个自定义 Hook:

const useDataApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl);

  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoading: false,
    isError: false,
    data: initialData
  });

  useEffect(() => {
    let didCancel = false;

    const fetchData = async () => {
      dispatch({ type: "FETCH_INIT" });

      try {
        const result = await axios(url);
        if (!didCancel) {
          dispatch({ type: "FETCH_SUCCESS", payload: result.data });
        }
      } catch (error) {
        if (!didCancel) {
          dispatch({ type: "FETCH_FAILURE" });
        }
      }
    };

    fetchData();

    return () => {
      didCancel = true;
    };
  }, [url]);

  const doFetch = url => setUrl(url);

  return { ...state, doFetch };
};

能够看到,自定义 Hook 拥有完整生命周期,咱们能够将取数过程封装起来,只暴露状态 - 是否在加载中:isLoading 是否取数失败:isError 数据:data

在组件中使用起来很是方便:

function App() {
  const { data, isLoading, isError } = useDataApi("https://v", {
    showLog: true
  });
}

若是这个值须要存储到数据流,在全部组件之间共享,咱们能够结合 useEffectuseReducer

function App(props) {
  const { dispatch } = useContext(Store);

  const { data, isLoading, isError } = useDataApi("https://v", {
    showLog: true
  });

  useEffect(() => {
    dispatch({
      type: "updateLoading",
      data,
      isLoading,
      isError
    });
  }, [dispatch, data, isLoading, isError]);
}

到此,Function Component 的入门概念就讲完了,最后附带一个彩蛋:Function Component 的 DefaultProps 怎么处理?

Function Component 的 DefaultProps 怎么处理?

这个问题看似简单,实则否则。咱们至少有两种方式对 Function Component 的 DefaultProps 进行赋值,下面一一说明。

首先对于 Class Component,DefaultProps 基本上只有一种你们都承认的写法:

class Button extends React.PureComponent {
  defaultProps = { type: "primary", onChange: () => {} };
}

然而在 Function Component 就五花八门了。

利用 ES6 特性在参数定义阶段赋值

function Button({ type = "primary", onChange = () => {} }) {}

这种方法看似很优雅,其实有一个重大隐患:没有命中的 props 在每次渲染引用都不一样。

看这种场景:

const Child = memo(({ type = { a: 1 } }) => {
  useEffect(() => {
    console.log("type", type);
  }, [type]);

  return <div>Child</div>;
});

只要 type 的引用不变,useEffect 就不会频繁的执行。如今经过父元素刷新致使 Child 跟着刷新,咱们发现,每次渲染都会打印出日志,也就意味着每次渲染时,type 的引用是不一样的。

有一种不太优雅的方式能够解决:

const defaultType = { a: 1 };

const Child = ({ type = defaultType }) => {
  useEffect(() => {
    console.log("type", type);
  }, [type]);

  return <div>Child</div>;
};

此时不断刷新父元素,只会打印出一第二天志,由于 type 的引用是相同的。

咱们使用 DefaultProps 的本意必然是但愿默认值的引用相同, 若是不想单独维护变量的引用,还能够借用 React 内置的 defaultProps 方法解决。

利用 React 内置方案

React 内置方案能较好的解决引用频繁变更的问题:

const Child = ({ type }) => {
  useEffect(() => {
    console.log("type", type);
  }, [type]);

  return <div>Child</div>;
};

Child.defaultProps = {
  type: { a: 1 }
};

上面的例子中,不断刷新父元素,只会打印出一第二天志。

所以建议对于 Function Component 的参数默认值,建议使用 React 内置方案解决,由于纯函数的方案不利于保持引用不变。

最后补充一个父组件 “坑” 子组件的经典案例。

不要坑了子组件

咱们作一个点击累加的按钮做为父组件,那么父组件每次点击后都会刷新:

function App() {
  const [count, forceUpdate] = useState(0);

  const schema = { b: 1 };

  return (
    <div>
      <Child schema={schema} />
      <div onClick={() => forceUpdate(count + 1)}>Count {count}</div>
    </div>
  );
}

另外咱们将 schema = { b: 1 } 传递给子组件,这个就是埋的一个大坑。

子组件的代码以下:

const Child = memo(props => {
  useEffect(() => {
    console.log("schema", props.schema);
  }, [props.schema]);

  return <div>Child</div>;
});

只要父级 props.schema 变化就会打印日志。结果天然是,父组件每次刷新,子组件都会打印日志,也就是 子组件 [props.schema] 彻底失效了,由于引用一直在变化。

其实 子组件关心的是值,而不是引用,因此一种解法是改写子组件的依赖:

const Child = memo(props => {
  useEffect(() => {
    console.log("schema", props.schema);
  }, [JSON.stringify(props.schema)]);

  return <div>Child</div>;
});

这样能够保证子组件只渲染一次。

但是真正罪魁祸首是父组件,咱们须要利用 Ref 优化一下父组件:

function App() {
  const [count, forceUpdate] = useState(0);
  const schema = useRef({ b: 1 });

  return (
    <div>
      <Child schema={schema.current} />
      <div onClick={() => forceUpdate(count + 1)}>Count {count}</div>
    </div>
  );
}

这样 schema 的引用能一直保持不变。若是你完整读完了本文,应该能够充分理解第一个例子的 schema 在每一个渲染快照中都是一个新的引用,而 Ref 的例子中,schema 在每一个渲染快照中都只有一个惟一的引用。

3. 总结

因此使用 Function Component 你入门了吗?

本次精读留下的思考题是:Function Component 开发过程当中还有哪些容易犯错误的细节?

讨论地址是:精读《Function Component 入门》 · Issue #157 · dt-fe/weekly

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

关注 前端精读微信公众号

special Sponsors

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证

相关文章
相关标签/搜索