React Hooks 详解

1,021 阅读1分钟

React Hooks

  • 状态:useState
  • 副作用:useEffect
    • useLayoutEffect
  • 上下文:useContext
  • ReduxuseReducer
  • 记忆:useMemo
    • 回调:useCallback
  • 引用:useRef
    • uselmperativeHandle
  • 自定义:Hook
    • useDebugValue

setState

  1. 首次渲染组件时,App会被调用,得到虚拟dom,创建真实的dom
  2. 当点击button时,调用setN,再次调用App,得到虚拟dom,用DOM Diff算法更新dom
function App() {
  const [n, setN] = React.useState(0);
  console.log("App 运行了"); // App 被调用就会执行一次
  console.log(`n: ${n}`); // App 被调用后 n 每次都不一样
  return (
    <div className="App">
      <p>{n}</p>
      <p><button onClick={() => setN(n + 1)}>+1</button></p>
    </div>
  );
}

nApp被调用后每次都会变化,但是setN()却不会改变n

  • setN一定会修改数据x,将n + 1存入x
  • setN一定会触发App重新渲染`
  • useState肯定会从x读取n的最新值
  • 每个组件都有自己的数据x,我们将其命名成state

自己来实现setState

  • 声明一个myUseState,接收一个初始值initialValue
  • 将初始值initialValue赋值给一个中间变量state
  • 内部声明一个函数setState,接收一个newValue,再将newValue赋值给state,并执行App
  • 返回statesetState
const myUseState = initialValue => {
  let state = initialValue;
  const setState = newValue => {
    state = newValue;
    render();
  };
  return [state, setState];
};

const render = () => {
  ReactDOM.render(<App />, rootElement);
};

function App() {
  const [n, setN] = myUseState(0);
  return (
    <div className="App">
      <p>{n}</p>
      <p><button onClick={() => setN(n + 1)}>+1</button></p>
    </div>
  );
}

但是这样有个问题每次执行setN时,都会把state设置为初始值,因为每次执行setN都会传入一个初始值0

解决这个问题就是将state变成全局变量

let _state;
const myUseState = initialValue => {
  _state = _state === undefined ? initialValue : _state;
  const setState = newValue => {
    _state = newValue;
    render();
  };
  return [_state, setState];
};

const render = () => {
  ReactDOM.render(<App />, rootElement);
};

function App() {
  const [n, setN] = myUseState(0);
  return (
    <div className="App">
      <p>{n}</p>
      <p><button onClick={() => setN(n + 1)}>+1</button></p>
    </div>
  );
}

如果一个组件使用了两个useState

function App() {
  const [n, setN] = myUseState(0);
  const [M, setM] = myUseState(1); // 会出问题,第一个会被覆盖
  return (
    <div className="App">
      <p>{n}</p>
      <p><button onClick={() => setN(n + 1)}>+1</button></p>
      <p>{m}</p>
      <p><button onClick={() => setM(m + 1)}>+1</button></p>
    </div>
  );
}

由于所有数据都存在一个_state中,所以会冲突。

可以使用数组去解决_state重复问题。

  • _state声明为[],同时声明一个索引index = 0
  • myUseState方法内部声明一个临时变量currentIndex,用来保存索引index
  • 用索引去初始化_state
  • setState时也将通过索引去操作_state
  • index += 1
  • 返回_state[currentIndex]setState
  • 每次调用render方法是将index重置为0
let _state = [];
let index = 0;
const myUseState = initialValue => {
  const currentIndex = index;
  _state[currentIndex] =
    _state[currentIndex] === undefined ? initialValue : _state[currentIndex];

  const setState = newValue => {
    _state[currentIndex] = newValue;
    render();
  };
  index += 1;
  return [_state[currentIndex], setState];
};

const render = () => {
  index = 0;
  ReactDOM.render(<App />, rootElement);
};

function App() {
  const [n, setN] = myUseState(0);
  const [m, setM] = myUseState(0);
  return (
    <div className="App">
      <p>{n}</p>
      <p><button onClick={() => setN(n + 1)}>N+1</button></p>
      <p>{m}</p>
      <p><button onClick={() => setM(m + 1)}>M+1</button></p>
    </div>
  );
}

useState调用顺序

  • 若第一渲染时n是第一个,m是第二个,k是第三个
  • 则第二次渲染时必须保证顺序完全一致
  • React不允许出现如下代码
function App() {
  const [n, setN] = myUseState(0);
  let m, setM;
  if (n % 2 === 1) [m, setM] = myUseState(0); // 报错
  return (
    <div className="App">
      <p>{n}</p>
      <p><button onClick={() => setN(n + 1)}>N+1</button></p>
      <p>{m}</p>
      <p><button onClick={() => setM(m + 1)}>M+1</button></p>
    </div>
  );
}

报错信息:React has detected a change in the order of Hooks called by App. This will lead to bugs and errors if not fixed.

问题:

App用了_stateindex那其他组件用什么?

  • 给每个组件创建一个_stateindex

放在全局作用域重名了咋整

  • 放在组件对象的虚拟节点对象上

感觉会是bug

function App() {
  const [n, setN] = myUseState(0);
  const log () => setTimeout(() => console.log(`n: ${n}`), 3000)
  return (
    <div className="App">
      <p>{n}</p>
      <p>
        <button onClick={() => setN(n + 1)}>N+1</button>
        <button onClick={log}>log</button>
      </p>
    </div>
  );
}

先点击N+1时,再点击log,输出是没有问题

如果先点击log,在点击N+1,就会发现输出的居然是0。难道N+1后输出不是1么。

因为setN是不会改变n,而是生成一个新的n

解决上面那个感觉是bug

  • 全局变量,window.xxx
  • useRef不仅可以用于div,还能用于任意数据
  • useContext不能能贯穿始终,还能贯穿不同组件
function App() {
  const nRef = React.useRef(0);   // { current: 0 }
  const log () => setTimeout(() => console.log(`n: ${React.useRef(0)}`), 3000);
  const update = React.useState(null)[1];
  return (
    <div className="App">
      <p>{nRef.current}</p>
      <p>
        <button onClick={() => {
          nRef.current += 1;  // 这里不能实时更新
          update(nRef.current;
          )}}>N+1</button>
        <button onClick={log}>log</button>
      </p>
    </div>
  );
}

React.current += 1不会让App重新渲染。

注意

  • 不可以局部更新,因为setState不会合并属性
  • 对象地址要变
  • useState接收函数
    • js引擎不会去解析函数,减少多余的计算过程
  • setN接收函数,两步操作都会生效
    setN(i => i + 1);
    setN(n => n + 1);
    

总结

  • 每个函数组件对象一个React节点
  • 每个节点保存着stateindex
  • useState会读取state[index]
  • indexuseState出现的顺序决定
  • setState会修改state,并触发更新。

Tips

  1. 这里是对React做了简化,方便理解。
  2. React对象节点应该是FiberNode
  3. _state的真实名称为memorizedStateindex的实现用到了链表

useReducer

用来践行Flux/Redux的思想

  • 创建初始值initial
  • 创建所有操作reducer(state, action)
  • 传给useReducer,得到读和写的API
  • 调用写({type: '操作类型'})

总的来说useReduceruseState的复杂版

const initial = { n: 0}
const reducer = (state, action) => {
  if(action.type === "add") {
    return { n: state.n + action.number }
  } else if(action.type === "multi"){
    return { n: state.n + action.number }
  } else {
    throw new Error("unknow type")
  }
}

functon App() {
  const [state, dispatch] = React.useReducer(reducer, initial)
  const {n} = state
  const onClick = () => { dispatch({type: "add", number: 1}) }
  const onClick2 = () => { dispatch({type: "multi", number: 2}) }

  return (
    <div className="App">
      <h1>n:{n}</h1>
      <button onClick={onClick}>+1</button>
      <button onClick={onClick2}>+1</button>
    </div>
  )
}

什么时候使用

如果发现有几个变量适合放在一起就用useReducer,否则就用useState

使用useReducer代替redux

const store = { user: null, books: null, movies: null };
const reducer = (state, action) => {
  switch (action.type) {
    case "setUser":
      return { ...state, user: action.user };
    case "setBooks":
      return { ...state, books: action.books };
    case "setMovies":
      return { ...state, movies: action.movies };
  }
};
const Context = createContext(null);
function App() {
  const [state, dispatch] = useReducer(reducer, store);

  return (
    <Context.Provider value={{ state, dispatch }}>
      <User />
      <hr />
      <Books />
      <Movies />
    </Context.Provider>
  );
}

function User() {
  const { state, dispatch } = useContext(Context);
  useEffect(() => {
    ajax("/user").then(user => {dispatch({ type: "setUser", user });});
  }, []);
  return (
    <div>
      <h1>个人信息</h1>
      <div>name:{state.user ? state.user.name : ""}</div>
    </div>
  );
}

function Books() {
  const { state, dispatch } = useContext(Context);
  useEffect(() => {
    ajax("/books").then(books => {dispatch({ type: "setBooks", books });});
  }, []);
  return (
    <div>
      <h1>我的书籍</h1>
      <ol>{state.books ? state.books.map(book => <li key={book.id}>{book.name}</li>) : "加载中"}</ol>
    </div>
  );
}
function Movies() {
  const { state, dispatch } = useContext(Context);
  useEffect(() => {
    ajax("/movies").then(movies => {dispatch({ type: "setMovies", movies });});
  }, []);
  return (
    <div>
      <h1>我的电影</h1>
      <ol>{state.movies ? state.movies.map(movie => <li key={movie.id}>{movie.name}</li>) : "加载中"}</ol>
    </div>
  );
}

function ajax(path) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (path === "/user") {
        resolve({ id: 1, name: "Frank" });
      } else if (path === "/books") {
        resolve([
          { id: 1, name: "JavaScript 高级程序设计" },
          { id: 2, name: "JavaScript 精粹" }
        ]);
      } else if (path === "/movies") {
        resolve([
          { id: 1, name: "爱在黎明破晓前" },
          { id: 2, name: "恋恋笔记本" }
        ]);
      }
    }, 2000);
  });
}

useContext

  • 上下文

    • 全局变量的全局的上下文
    • 上下文是局部的全局变量
  • 使用方法

    • 使用C = createContext(initial)创建上下文
    • 使用<C.provider>圈定作用域
    • 在作用域内使用useContext(C)来使用上下文

useEffect

  • 副作用

    • 对环境的改变即为副作用,如修改document.title
    • 但我们不一定非要把副作用放在useEffect
    • 实际上叫做afterRender更好,每次render后运行
  • 用途

    • 作为componentDidMount使用,[]作为第二个参数
    • 作为componentDidUpdate使用,可指定依赖
    • 作为componentWillUnmount使用,通过return
    • 以上三种用途可以同时存在
  • 特点

    • 如果同时存在多个useEffect,会按照出现次序执行

模拟componentDidMountcomponentDidUpdate

function App() {
  const [n, setN] = useState(0);
  const onClick = () => {
    setN(i => i + 1);
  };
  useEffect(() => {
    console.log("第一次渲染后执行");
  }, []);
  useEffect(() => {
    if (n !== 0) console.log("n 变化了执行");
  }, [n]);

  useEffect(() => {
    console.log("任何一个 state 变化了都执行");
  });

  return (
    <div>
      n:{n}
      <button onClick={onClick}>+1</button>
    </div>
  );
}

模拟componentWillUnmount

useEffect内部加上return就可以模拟componentWillUnmount

function App() {
  const [n, setN] = useState(0);
  const onClick = () => {
    setN(i => i + 1);
  };
  useEffect(() => {
    const timerid = setInterval(() => {
      console.log(1);
    }, 1000);
    return () => {
      window.clearInterval(timerid);
    };
  }, []);

  return (
    <div>
      n:{n}
      <button onClick={onClick}>+1</button>
    </div>
  );
}

useLayoutEffect

  • 布局副作用

    • useEffect在浏览器渲染完成之后执行
    • useLayoutEffect在浏览器渲染前执行
  • 特点

    • useLayoutEffect总是比useEffect先执行
    • useLayoutEffect里的任务最好影响了Layout
  • 经验

    • 为了用途体验,优先使用useEffect(优先渲染)
function App() {
  const [value, setValue] = useState(0);
  useEffect(() => {
    document.querySelector("#x").innerText = `value:1000`;
  }, [value]);

  return (
    <div id="x" onClick={() => setValue(1)}>
      value:{value}
    </div>
  );
}

使用useEffect的一个问题:

  • 如果修改的是DOM元素,那么在页面初始化后页面显示的是value:0然后在变成value:1000,这中间就有一个闪烁的过程。

useLayoutEffect代替useEffect就可以解决这个问题

看一下具体的过程:

App() => 执行 => 虚拟DOM => DOM => 改变外观 => useEffect
                               => useLayoutEffect

在渲染样式前,就对DOM修改,这样就能避免页面闪烁问题了。

从下面代码可以看出useLayoutEffect先于useEffect执行

function App() {
  const [value, setValue] = useState(0);
  useEffect(() => {
    console.log(1)      // 后打印
  }, []);

  useLayoutEffect(()=>{
    console.log(2)      // 先打印
  },[])

  return (
    <div id="x" onClick={() => setValue(0)}>
      value:{value}
    </div>
  );
}

从下面代码可以看出使用useEffect的时间比useLayoutEffect明显要长

function App() {
  const [n, setN] = useState(0);
  const time = useRef(null);
  const onClick = () => {
    setN(i => i + 1);
    time.current = performance.now();
  };

  useLayoutEffect(() => {
    if (time.current) console.log(performance.now() - time.current);
  });

  useEffect(() => {
    if (time.current) console.log(performance.now() - time.current);
  });
  
  return (
    <div id="x">
      n:{n}
      <button onClick={onClick}>+1</button>
    </div>
  );
}

memo

  • React默认有多余的render
  • 如果props不变,就没有必要执行一个函数组件
  • 但是,有个bug
    • 添加了监听函数之后,一秒破功
    • 因为App运行时会在执行监听函数,生成新的函数
    • 新旧函数虽然功能一样,但是地址不一样
function App() {
  const [n, setN] = React.useState(0);
  const [m, setM] = React.useState(0);
  const onClick = () => {
    setN(n + 1);
  };

  return (
    <div className="App">
      <div>
        <button onClick={onClick}>update n {n}</button>
      </div>
      <Child data={m} />
    </div>
  );
}

function Child(props) {
  console.log("child 执行了")
  return <div>child: {props.data}</div>;
}

当点击按钮的时候,n会变,这时Child组件,也会跟着变化,其实这里的Child组件是不需要变化的。

可以使用memo优化

function App() {
  const [n, setN] = React.useState(0);
  const [m, setM] = React.useState(0);
  const onClick = () => {
    setN(n + 1);
  };
  const onClick2 = () => {
    setM(m + 1);
  };
  return (
    <div className="App">
      <div>
        <button onClick={onClick}>update n {n}</button>
        <button onClick={onClick2}>update m {m}</button>
      </div>
      <Child data={m} />
    </div>
  );
}

const Child = React.memo(props => {
  console.log("child 执行了");
  return <div>child: {props.data}</div>;
});

使用memo的好处是,当组件依赖的数据变了,组件才会执行。

useMemo

  • 第一个参数是() => value
  • 第二个参数是依赖[m,n]
  • 只有当依赖变化时,才会计算出新的value
  • 如果依赖不变,那么就重用之前的value
function App() {
  const [n, setN] = React.useState(0);
  const [m, setM] = React.useState(0);
  const onClick = () => {
    setN(n + 1);
  };
  const onClickChild = () => {};
  return (
    <div className="App">
      <div>
        <button onClick={onClick}>update n {n}</button>
      </div>
      <Child data={m} onClick={onClickChild} />
    </div>
  );
}

const Child = React.memo(props => {
  console.log("child 执行了");
  return <div onClick={props.onClick}>child: {props.data}</div>;
});

当点击按钮时,App重新执行了,导致onClickChild函数会被执行。因为函数是引用类型,每次执行都会生成一个新地址,所以Child就会重新执行。

使用useMemoonClickChild包装一下,就可以了,当m变化时,执行Child组件

function App() {
  const [n, setN] = React.useState(0);
  const [m, setM] = React.useState(0);
  const onClick = () => {
    setN(n + 1);
  };
  const onClickChild = useMemo(() => () => {}, [m]);
  return (
    <div className="App">
      <div>
        <button onClick={onClick}>update n {n}</button>
      </div>
      <Child data={m} onClick={onClickChild} />
    </div>
  );
}

const Child = React.memo(props => {
  console.log("child 执行了");
  return <div onClick={props.onClick}>child: {props.data}</div>;
});

注意

  • 如果你的value是一个函数,那么你就要写成() => (x) => console.log(x),这是一个返回函数的函数,特别奇怪,这就有了useCallback

useCallback

const onClickChild = useMemo(() => () => {}, [m]);
/* 上下等价 */
const onClickChild = useCakkback(() => {}, [m]);

useRef

  • 如果需要一个值,在组件不断render时保持不变
  • 初始化:const count = useRef(0)
  • 读取:const.current
  • 为什么需要current
    • 为了保证两次useRef是同一个值,只有引用能做到
function App() {
  const [n, setN] = React.useState(0);
  const count = useRef(0);      // { current: 0 }
  const onClick = () => {
    setN(n + 1);
  };
  useEffect(() => {
    count.current += 1;     // 不能执行操作 count,count 实际是个对象
    console.log(count.current);
  });
  return (
    <div className="App">
      <div>
        <button onClick={onClick}>update n {n}</button>
      </div>
    </div>
  );
}
  • useState/useRenducer每次都变
  • useMemo/useCallback有条件的变
  • useRef永远不变

forwardRef

  • 让函数组件支持ref
    • props无法传递ref属性
    • 实现ref的传递
  • useRef
    • 可以用来引用DOM对象
    • 也可以用来引用普通对象
  • forwordRef
    • 由于props不包含ref,所以需要forwardRef
    • 为什么props不包含ref,因为大部分的时候不需要用到
function App() {
  const buttonRef = useRef(null);
  return (
    <div className="App">
      <div>
        <Button ref={buttonRef}>按钮</Button>
      </div>
    </div>
  );
}
const Button = forwardRef((props, ref) => {
  console.log(props)
  console.log(ref)
  return <button className="red" ref={ref} {...props} />;
});

useImperativeHandle

function App() {
  const buttonRef = useRef(null);
  useEffect(() => {
    console.log(buttonRef.current);
  });
  return (
    <div className="App">
      <div>
        <Button ref={buttonRef}>按钮</Button>
      </div>
    </div>
  );
}
const Button = forwardRef((props, ref) => {
  const realButton = createRef(null);
  useImperativeHandle(ref, () => ({
    x: () => {
      realButton.current.remove();
    }
  }));
  return <button ref={ref} {...props} />;
});

自定义Hook

  • 封装数据操作
  • 还可以在自定义Hook里使用Context

stale closure陈旧的闭包

function createIncrementFixed(i){
  let value = 0;
  function increment(){
    value += i;
    console.log(value)
    const message = `Current value is ${value}`
    return function logValue(){
      console.log(message)
    }
  }
  return increment;
}
const inc = createIncrementFixed(1)
const log = inc()   // logs 1
inc()               // logs 2
inc()               // logs 3
// works
log()               // logs "Current value is 1"

下面是解决方法:

function createIncrementFixed(i){
  let value = 0;
  function increment(){
    value += i;
    console.log(value)
    return function logValue(){
      const message = `Current value is ${value}`
      console.log(message)
    }
  }
  return increment;
}
const inc = createIncrementFixed(1)
const log = inc()   // logs 1
inc()               // logs 2
inc()               // logs 3
// works
log()               // logs "Current value is 3"

另外可添加微信ttxbg180218交流