React hooks 入门

此教程翻译自Academind课程, 源地址:https://www.youtube.com/watch?v=-MlNBTSg_Ww

为何需要React Hooks

为了状态和生命周期而去写Class组件往往是繁琐的, 而且组合优于继承的原则, 使得在多数情况下能定义函数组件就不去定义类组件 在新版React(16.8)中, 新增了React Hooks, 使我们可以在函数组件中使用状态和生命周期函数, 使得函数组件更加灵活, 减轻类组件使用负担


从state开始(useState)

项目目录:
src/
	|-components/
  |---TabList.js
  |---Schedule.js
  |-hooks/
  |---http.js

首先我们声明一个无状态函数组件, 内容如下:

const TabList = () => {
  const currentTab = 1;
  return <div>
      CurrentTab: {currentTab}
    </div>;
};

这是一个普通的无状态组件, 我们使用新的useState API来赋予初始值: (useState的调用参数和state一样, 可赋予任何值)(useState的参数为state的初始值) useState方法返回1个数组, 数组内包含2个参数, 第一个参数是当前state:

import React, { useState } from 'react';

const TabList = () => {
  const [state] = useState({
    selectedTab: 1
  });
  return <div>
    CurrentTab: {state.selectedTab}
    </div>
}

useState方法返回1个数组, 第二个参数是改变状态函数(此处暂时理解为setState, 但≠setState)

const TabList = () => {

  const [tabState, setTabState] = useState(1);


  const changeTabState = () => {
    setTabState(tableState + 1);
  };

  return <div>
              CurrentTab: {tabState}
              <button onClick={changeTabState}>
                Just CurrentTab + 1
              </button>
            </div>;
};

刷新浏览器即可看到setTableState’模仿’了class组件中的setState方法. 为什么说模仿呢 ? 我们修改下useState的参数, 现在传递一个多属性的对象值, 如下:

const TabList = () => {

  const [tabState, setTabState] = useState({
    selectedTab: 1,
    text: 'initValue'
  });


  const changeTabState = () => {
    setTabState({
      selectedTab: tabState.selectedTab + 1
    });
  };

  return <div>
           <div>
  					CurrentTab: {tabState.selectedTab}
           </div>
           <div>
              <button onClick={changeTabState}>
                Just CurrentTab + 1
  							</button>
           </div>
           CurrentText: {tabState.text}
   		</div>;
};

刷新浏览器: 第一次text的state加载正常, 我们点击按钮, text消失了???

useState引发的问题

从上段代码中, 我们可以发现useState返回的参数中, 使用修改状态的函数时候, 跟class中的setState并不一致. 在class中, 我们每次使用setState进行改变状态的时候, React做出的操作是mergeData, 我们简单理解为Object.assign方法, 而在useState返回值中, 我们修改是直接replaceData, 是直接替换了状态, 替换了新的值上去, 所以, 上述代码中text的值在更新状态后丢失了, 导致渲染空数据.

useState问题的解决方案

手动mergeData

就像redux那样, 我们每次返回值的时候把上一次的值再传递过去, 可以很快的解决旧数据丢失问题:

  const changeTabState = () => {
    setTabState({
      ...tabState,
      selectedTab: tabState.selectedTab + 1
    });
  };

定义多个state

在class中, 我们有且仅有一个state是因为state是作为class的属性而存在的, 而在函数组件中, useState是一个方法, 我们不难想到, 可以定义多个state去分别更新不同的state, 这也是React的灵活设计方式:

const TabList = () => {
  const [tabState, setTabState] = useState(1);
  const [textState, setTextState] = useState('initial value');

  const changeTabState = () => {
    setTabState(tabState + 1);
  };

  const changeTextState = () => {
    setTextState('new value');
  };

  return (
    <React.Fragment>
      <div>CurrentTab: {tabState}</div>
      <div>
        <button onClick={changeTabState}>Just CurrentTab + 1</button>
      </div>
      <div>CurrentText: {textState}</div>
      <div>
        <button onClick={changeTextState}>set new Text Value</button>
      </div>
    </React.Fragment>
  );
};

生命周期函数useEffect

在React hooks中, 所有生命周期函数都将由useEffect这一个API实现

componentDidMount

我们通常在componentDidMount函数中发起网络请求, 确认接收到参数再渲染数据. 我们使用useEffect函数来实现此场景: useEffect是一个函数, 可接收一个方法 我们定义一个新的组件作为TabList的子组件, 来测试是否还原了’componentDidMount’方法:

const TabList = () => {
  const [clickTimeState, setClickTimeState] = useState(0);

  const changeClickTime = () => {
    setClickTimeState(clickTimeState + 1);
  };

  return (
    <React.Fragment>
      <div>ClickTime: {clickTimeState}</div>
      <div>
        <button onClick={changeClickTime}>Just clickTime + 1</button>
      </div>
      <Schedule clickTime={clickTimeState} />
    </React.Fragment>
  );
};
import React, { useEffect } from 'react';

const Schedule = ({ clickTime }) => {
  useEffect(() => {
    console.log('clickTime', clickTime);
    console.log('useEffect works');
  });

  return <React.Fragment>This is Schedule</React.Fragment>;
};

我们多次点击按钮, 发现每次都执行了useEffect中的代码, 现在, 证明了一点: useEffect中的函数在每次props改变都会执行. 在componentDidMount中, 自己状态改变只会执行一次, 如果组件内状态改变了, 但是useEffect中的函数代码没有执行, 就证明我们’还原’了componentDidMount这个API. 我们现在模拟一个网络请求来修改组件内状态:

const actions = ['eat', 'sleep'];

let pointer = 0;

const newScheduleList = [];
for (let i = 0; i < 10; i++) {
  if (actions[pointer]) {
    newScheduleList.push({
      id: i,
      action: actions[pointer]
    })
  } else {
    newScheduleList.push({
      id: i,
      action: actions[0]
    });
    pointer = 0;
  }
  pointer++; 
}

// clickTime后续会用到
const Schedule = ({clickTime}) => {
  const [loading, setLoading] = useState(false);
  const [scheduleList, setScheduleList] = useState([]);

  useEffect(() => {
    setLoading(true);
    console.log('useEffect works');
    setTimeout(() => {
      setLoading(false);
      setScheduleList(newScheduleList);
    }, 1000);
  });

	return (
    <React.Fragment>
      {loading ? (
        'loading...'
      ) : (
        <ul>
          {scheduleList.map((item) => (
            <li key={item.id}>
              {item.id} -- {item.action}
            </li>
          ))}
        </ul>
      )}
    </React.Fragment>
  );
};

    刷新浏览器, 打开控制台, 我们发现进入了一个死循环, useEffect内的函数在无限制执行

    分析后不难发现, useEffect好像在每次componentDidUpdate执行, props不变的情况下, 我们修改了setLoading, 此时state变化, 第二次进入useEffect内函数代码, 所以陷入了一个死循环

useEffect接受2个参数, 第一个为函数, 第二个为数组, 第二个参数用来监听dependencies, 每次数组内的值变 化, 触发第一个函数, 默认监听所有dependencies, 第一次render必定执行useEffect函数. 现在, 我们第二个参数传递一个空数组: [], 即为不监听任何变化, 每次state/props改变不重新执行第一个参数的函数, 刷新浏览器, useEffect内函数的代码只执行了一次

  useEffect(() => {
    setLoading(true);
    console.log('componentDidMount ?');
    setTimeout(() => {
      setLoading(false);
      setScheduleList(newScheduleList);
    }, 1000);
  }, []);

我们至此还原了componentDidMount


componentDidUpdate

通常在class组件中, 我们对props传递过来的id不同而做一次网络请求,,如下:

componentDidUpdate(prevProps) {
  if (prevProps.selectedId !== props.selectedId) {
    // ... send xhr
    this.fetchData();
  }
}

为了实现这个业务, 我们仅需在useEffect方法第二个参数传递指定的监听值即可, 不需要再次调用一个useEffect, 否则会在第一次渲染时候触发2次fetchData

  const fetchData = () => {    
    setLoading(true);
    console.log('triggered fetchData');
    setTimeout(() => {  
      setLoading(false);
      setScheduleList(newScheduleList);
    }, 1000);
  };
  
  useEffect(() => {
    console.log('fetchData when clickTime diff');
    fetchData();
  }, [clickTime])

这样, 每次点击按钮就触发了重新请求


componentWillUnmount

通常我们在componentWillUnmount里移除事件监听, 取消Obvervable订阅, 以便及时清理内存 useEffect接受函数可以return一个函数, 作为componentWillUnmount的实现: Schedule组件修改代码:

  useEffect(() => {
    console.log('fetchData when clickTime diff');
    fetchData();
    return () => {
      console.log('componentWillUnmount ?');
    }
  }, [clickTime])

TabList组件代码:

const TabList = () => {
  const [clickTimeState, setClickTimeState] = useState(0);

  const [showSchedule, setShowSchedule] = useState(true);

  const changeClickTime = () => {
    setClickTimeState(clickTimeState + 1);
  };

  const changeShowSchedule = () => {
    setShowSchedule(false);
  }

  return (
    <React.Fragment>
      <div>ClickTime: {clickTimeState}</div>
      <div>
        <button onClick={changeClickTime}>Just clickTime + 1</button>
      </div>
      <div>
        <button onClick={changeShowSchedule}>
          Unmount Schedule
        </button>
      </div>
      {showSchedule ? <Schedule clickTime={clickTimeState} /> : ''}
    </React.Fragment>
  );
};

点击Unmout Schedule按钮, 控制台输出了Schedule组件的console代码 看起来好像是完美’还原’, 但是点击clickTime按钮, 发现了新的问题: 控制台依旧输出了componentWillUnmount?的代码

useEffect里定义的返回函数看似在每次unmount时候执行, 实际上当状态改变时, 依旧会执行此返回函数, 因为我们监听了clickTime的变化, 这样操作相当浪费, 不符合预期效果

为了避免浪费的内存, 我们再次调用一次useEffect方法, 第二个参数传递一个空数组, 表示不监听任何值变化, 此时, Schedule组件内有2个useEffect方法调用:

  useEffect(() => {
    console.log('fetchData when clickTime diff');
    fetchData();
    // return () => {
    //  console.log('componentWillUnmount ?');
    // }
  }, [clickTime]);

  useEffect(() => {
    return () => {
      console.log('componentWillUnmount ?');
    }
  }, []);

此时, 刷新浏览器, 我们多次点击clickTime按钮, componentWillUnmount的log没有执行, 点击Unmount Schedule按钮, 代码才执行, 符合预期效果


shouldComponentUpdate

在class组件中, 我们通常在状态变更时候定义此函数来进行判断新旧状态, 避免不必要的重新渲染:

shouldComponentUpdate(nextProps, nextState) {
	return nextProps.selectedId !== props.selectedId || nextState.isLoading !== this.state.isLoading;
}

此处就不在useEffect的可控功能内了, 在React16.6中, 有一个memo方法, 该方法可记录储存当前组件, 仅当需要的props更新才重新渲染, 我们还可在调用memo的时候使用第二个参数, 传递一个函数, 函数的参数为prevProps和nextProps. 为了实现效果, 我们在TabList组件传递一个无用的参数uselessProp给Schedule组件: TabList组件代码:

const TabList = () => {
  const [clickTimeState, setClickTimeState] = useState(0);

  const [showSchedule, setShowSchedule] = useState(true);

  const [color, setColor] = useState('#1890ff');

  // 定义一个无用的prop传递给Schedule组件
  const [uselessProp, setUselessProp] = useState(0);

  const changeClickTime = () => {
    setClickTimeState(clickTimeState + 1);
  };

  const changeShowSchedule = () => {
    setShowSchedule(false);
  }

  const changeColor = () => {
    setColor('cyan');
  }

  const changeUselessProp = () => {
    setUselessProp(uselessProp + 1);
  }

  return (
    <React.Fragment>
      <div>ClickTime: {clickTimeState}</div>
      <div>
        <button onClick={changeClickTime}>Just clickTime + 1</button>
      </div>
      <div>
        <button onClick={changeShowSchedule}>
          Unmount Schedule
        </button>
        <button onClick={changeColor}>
          set Color to cyan
        </button>
        <button onClick={changeUselessProp}>
          set uselessProp change
        </button>
      </div>
      {showSchedule ? <Schedule clickTime={clickTimeState} color={color} uselessProp={uselessProp} /> : ''}
    </React.Fragment>
  );
};

Schedule组件:

const Schedule = ({ clickTime, color }) => {
  const [loading, setLoading] = useState(false);
  const [scheduleList, setScheduleList] = useState([]);

  const fetchData = () => {
    setLoading(true);
    console.log('triggered fetchData');
    setTimeout(() => {
      setLoading(false);
      setScheduleList(newScheduleList);
    }, 1000);
  };

  useEffect(() => {
    console.log('fetchData when clickTime diff');
    fetchData();
  }, [clickTime]);

  useEffect(() => {
    return () => {
      console.log('componentWillUnmount ?');
    };
  }, []);

  // 查看渲染次数
  // 不使用memo, 每次改变uselessProp都会重新渲染
  console.log('rendering schedule...');

  return (
    <React.Fragment>
      {loading ? (
        'loading...'
      ) : (
        <ul>
          {scheduleList.map((item) => (
            <li style={{ color: color }} key={item.id}>
              {item.id} -- {item.action}
            </li>
          ))}
        </ul>
      )}
    </React.Fragment>
  );
};

export default Schedule;

使用memo包装Schedule组件, 仅需修改导出代码(注意: 函数返回值与shouleComponentUpdate相反)

// export default Schedule;
export default React.memo(Schedule, (prevProps, nextProps) => {
  // 注意: 此处的函数返回值和shouldComponentUpdate截然相反, 返回true是不重新渲染, 返回false是重新渲染
  return (
    prevProps.clickTime === nextProps.clickTime &&
    prevProps.color === nextProps.color
  );
});

React hooks的可共享逻辑

自定义hooks

定义一个文件在src/hooks下,命名为http.js, 复制Schedule组件的fetchData代码:

mport { useState } from 'react';

const fetchData = (url, data) => {
  return new Promise((res, rej) => {
    setTimeout(() => {
      if (url !== '/') {
        res({
          success: true,
          data: data
        });
      } else {
        rej({
          success: false,
          message: 'not valid url'
        });
      }
    }, 1000);
  });
};

export const useHttp = (url, data) => {
  const [loading, setLoading] = useState(false);
  const [fetchedData, setFetchedData] = useState(null);

  console.log('triggered fetchData');
  setLoading(true);
  fetchData(url, data)
    .then((res) => {
      setFetchedData(res);
    })
    .catch((err) => {
      setFetchedData(err);
    })
    .finally(() => {
      setLoading(false);
    });
  return [loading, fetchedData];
};

但是这个hooks是不合理的, 往往组件内逻辑是很复杂的, 我们可能要执行一次或监听不同的值而执行多次useHttp, 这样就要写不止一次的useHttp() 我们应该在useHttp方法内引用useEffect使得该自定义钩子更加灵活, 可以不必在useEffect内调用useHttp

import { useState, useEffect } from 'react';

// mock Data
const actions = ['eat', 'sleep'];

let pointer = 0;

const newScheduleList = [];
for (let i = 0; i < 10; i++) {
  if (actions[pointer]) {
    newScheduleList.push({
      id: i,
      action: actions[pointer]
    });
  } else {
    newScheduleList.push({
      id: i,
      action: actions[0]
    });
    pointer = 0;
  }
  pointer++;
}
// end mock Data

const fetchData = (url, data) => {
  console.log('fetchData params:', url, data);
  return new Promise((res, rej) => {
    setTimeout(() => {
      if (url === '/scheduleList') {
        res({
          success: true,
          data: newScheduleList
        });
      } else {
        rej({
          success: false,
          message: 'not valid url'
        });
      }
    }, 1000);
  });
};

export const useHttp = (url, data, dependencies) => {
  const [loading, setLoading] = useState(false);
  const [fetchedData, setFetchedData] = useState(null);

  console.log('triggered fetchData');
  useEffect(() => {
    setLoading(true);
    fetchData(url, data).then(res => {
      setFetchedData(res);
    }).catch(err => {
      setFetchedData(err);
    }).finally(() => {
      setLoading(false);
    })
  }, dependencies);

  return [loading, fetchedData];
};

修改Schedule组件的逻辑

import React, { useEffect } from 'react';
import { useHttp } from '../hooks/http';

const Schedule = ({ clickTime, color }) => {
  // 不必再去定义loading状态, loading现在由useHttp控制
  // const [loading, setLoading] = useState(false);
  // const [scheduleList, setScheduleList] = useState([]);

  // clickTime为我们监听的值
  const [loading, fetchedData] = useHttp('/scheduleList', { test: 1 }, [clickTime]);
  // 我们定义的fetchedData初始值为null, 一定要做判断
  const scheduleList = fetchedData ? fetchedData.data.map(item => ({
    ...item,
    success: true
  })) : [];

  // useEffect(() => {
  //   console.log('fetchData when clickTime diff');
  //  
  // }, [clickTime]);

  useEffect(() => {
    return () => {
      console.log('componentWillUnmount ?');
    };
  }, []);

  console.log('rendering schedule...');

  return (
    <React.Fragment>
      {loading ? (
        'loading...'
      ) : (
        <ul>
          {scheduleList.map((item) => (
            <li style={{ color: color }} key={item.id}>
              {item.id} -- {item.action}
            </li>
          ))}
        </ul>
      )}
    </React.Fragment>
  );
};

export default React.memo(Schedule, (prevProps, nextProps) => {
  return (
    prevProps.clickTime === nextProps.clickTime &&
    prevProps.color === nextProps.color
  );
});

刷新浏览器, 每次clickTime只会触发一次http请求, 完美抽离了http hooks


总结

useState: 

const [state, setState] = useState({});
// 替换 state + setState

useEffect :

// 替换componentDidMount
useEffect(() => {
  // code here...
}, [])

// 替换componentWillUnmount
useEffect(() => {
  return () => {
    // code here...
  }
}, [])

// 部分替换componentDidUpdate处理逻辑
useEffect(() => {
  // 当value1, value2变化, 执行下列代码
  // code here....
}, [value1, value2])

// 替换componentDidUpdate(不建议以下写法, 每次更新都会触发)
useEffect(() => {
  // code here...
})

 memo:

// 替换shouldComponentUpdate
React.memo(ReactComponent) // 自行判断是否更新
React.memo(ReactComponent, (prevProps, nextProps) => {
  // 当id相同, 不重新渲染, 此处返回值与shouldComponentUpdate相反
  return prevProps.id === nextProps.id;
})

注意: 使用useEffect时不能嵌套在代码块中使用, React仅会在组件内寻径一层, 譬如, 以下代码不会生效:

if (id === 0) {
  useEffect(() => {}, []) // wrong way!
}