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

简单聊一聊 hooks 与闭包 #6

Open
hacker0limbo opened this issue Dec 7, 2019 · 0 comments
Open

简单聊一聊 hooks 与闭包 #6

hacker0limbo opened this issue Dec 7, 2019 · 0 comments
Labels
javascript 原生 JavaScript 笔记整理 react react 笔记整理

Comments

@hacker0limbo
Copy link
Owner

hacker0limbo commented Dec 7, 2019

简单聊一聊 hooks 与闭包

变量引用

关于这方面问题不做深究, 可以看做是指针

let x = 0
let y = x
let z = `hello ${x}`

x = 1
console.log(y) // 0
console.log(z) // hello 0

闭包

  • 函数在创建的时候就会生成一个词法环境, 在运行的时候同样会创建另一个新的词法环境, 两个词法环境可能不同. 分析时需要理清楚
  • 闭包中的变量引用. 词法环境中如存在引用, 需要分析该引用在后面的函数(闭包)调用中是否被修改.

这里重点谈谈第二点, 关于闭包里的变量引用问题.

例子 1:

function outer() {
  let x = 0
  function inner() {
    let _x = x
    function log() {
      console.log({ x, _x })
    }

    return log
  }

  function change() {
    x += 1
  }

  return [inner, change]
}

let [inner, change] = outer()

let log = inner()
log() // { x: 0, _x: 0 }
change()

log() // { x: 1, _x: 0 } 

分析:

  • 首先调用outer()函数创建了一个闭包, 闭包中的变量为x = 0, inner函数和change在被创建的过程中形成词法作用域, 可以访问到该变量
  • 调用inner()函数, 此时在当前的词法作用域下创建一个新的闭包, 该闭包中创建了一个新的变量_x, 当然还存在之前引用的x变量. log函数在被创建时形成词法作用域, 可以访问到_xx, 当然这两个变量目前相等
  • 调用change()函数, 该函数在第一次声明的词法作用域下修改了x变量, 使其为1
  • 调用log()函数, 注意, 由于闭包的特性, log()函数可以访问到x_x, 但由于change()修改了最顶层的词法作用域里的x, 这里读取的x也为1. 不过, 由于这里的_x只在inner()函数调用(也就是声明log函数)的时候声明一次, 且指向 x = 1, 即使change()修改了x对其并无任何影响. 因此这时的log()函数调用的词法作用域为x = 1, _x = 0

例子 2:
该例子取自于 react-hooks-stale-closures 这篇文章. 虽然我个人觉得这篇文章的作者其实没写到点子上...

有如下两段代码

function createIncrement(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 = createIncrement(1);
const log = inc(); // 1
inc();             // 2
inc();             // 3

log();             // "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(); // 1
inc();             // 2
inc();             // 3

log();             // "Current value is 3"

这里就不细展开了, 具体的分析可以看 这个问题下的答案, 主要需要注意这几点:

  • value 在最顶层的词法作用域, 确实是不断在变化的
  • log() 函数在被调用的时候, 确实拿到的value值是最新的值, 但是第一段代码与第二段代码的区别在于message变量, 第一段代码中的messagevalue修改值之后其保存的value仍旧是原始的值(也就是 1)(此 value 非彼 value), 而第二段代的message并不是作为log函数的定义时的闭包变量而存在, 而是作为自己的作用域内的变量. 因此在调用log()函数的时候, 才会声明message, 这时候再去查找value, 得到的当然是最新值

对于第二段代码, 这么改效果也是一样的:

function createIncrementFixed(i) {
  let message;
  let value = 0;
  function increment() {
    value += i;
    console.log(value);
    message = `Current value is ${value}`;
    return function logValue() {
      console.log(message);
    };
  }
  return increment;
}

const inc = createIncrementFixed(1);
const log = inc(); // 1
inc();             // 2
inc();             // 3
log();             // "Current value is 3"

分析: 每次调用inc()的时候不仅修改顶层词法作用域里的value, 也在不断重写message, 使其内部的valuevalue变量始终保持一致

hooks

从计时器开始

上一篇已经讲到, 可以使用闭包来模拟类的行为, 还是以计数器为例, 假定有如下代码:

const Counter = () => {
  let value = 0

  const render = () => {
    setTimeout(() => {
      console.log(value)
    }, 2000)
  }

  const inc = () => {
    value += 1 
    render()
  }

  return { render, inc }
}

const c = Counter()

c.inc() // 3
c.inc() // 3
c.inc() // 3

可以看到结果, 2 秒以后输出值全为 3. 然而往往需求可能是, 每次打印出来的值应该是顺序的, 比如在这个例子里面希望 2 秒后依次打印 1, 2, 3

可以这么做:

const Counter = () => {
  let value = 0

  const render = () => {
    let v = value
    setTimeout(() => {
      console.log(v)
    }, 2000)
  }

  const inc = () => {
    value += 1 
    render()
  }

  return { render, inc }
}

const c = Counter()

c.inc() // 1
c.inc() // 2
c.inc() // 3

这里我们使用v这个临时变量存取当前(上一次)的value的值, 这样使得setTimeout这个闭包内部可以在 2 秒后, "正确"读取到当前属于自己的值. 而对于第一段代码, 由于inc()不断调用修改了顶层词法作用域里的value变量, setTimeout读取到的value的值永远都是最新的(因为 value 不断被更新...)

react

引子

先来看一个场景:
Dan 在他的 博客 里面有这样一个场景实例: 有一个类似 twitter 的页面, 你想要 follow 某个用户, 但是点击 follow 这个动作是一个异步请求可能需要时间, 在点击了 follow 这个操作之后, 立马切换到另一个用户的页面, 几秒钟之后客户端收到响应, 显示你 follow 了"这个"用户

具体的 live demo 点击这里查看

然而, 使用函数式组件写法, 和 class 组件写法带来的效果是很不同的, 两种写法的组件和效果分别如下:

class 组件:

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}

bug

函数式组件:

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}

fix

可以看到, 使用函数式组件可以得到正确的 follow, 延时请求所请求的 user 是之前点击的 Follow 按钮的 user, 而非像 class 组件一样发送了错误的 user 请求

下面会进行分析, 当然也推荐直接看 Dan 的 博客

分析

Dan 在另外一篇 文章 中总结的相当好:

  • 函数式组件在每一次渲染都有它自己的…所有, 你可以想象成每次 render 的时候都形成了一次快照, 保存了所有下面的东西, 每一份快照都是不同且独立的. 即
    • 每一次渲染都有自己的 props 和 state
    • 每一次渲染都有自己的事件处理函数
    • 每一次渲染都有自己的 useEffect()
  • class 组件之所以有时候"不太对"的原因是, React 修改了 class 中的 this.state 使其指向永远最新状态

例子

有如下代码: live demo

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

  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + count);
    }, 3000);
  }

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

一开始先点击Show alert按钮, 然后立马点击 3 次 Click me按钮, 3 秒过后浏览器打印出来的结果为打印出"You clicked on: 0"

更多次实验以后会发现, Show alert 只会显示在点击触发前那一刻所对应的 count 的值, 比如目前 count 为 5, 点击 Show alert之后立马再点击几次Click me, 3 秒过后浏览器打印的结果为 5

这是由于闭包的原因, 每次setTimeout()读取到的 count 的是当前 render状态下的值, 即使后面对count进行了改变, setTimeout()中的 count不受影响, 永远是当前 render 下的 count 的值, 而非最新的 count 的值

那如果想要得到最新的值呢?

最简单, 可以使用useRef()来存取最新的值, 那么代码可以改成如下:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(0)

  countRef.current = count

  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + countRef.current);
    }, 3000);
  }

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

这时候重复上述过程, 可以发现 3 秒之后能得到当前的最新值.

关于 useEffect
实际上 useEffect 也是一个函数, 和 handleAlertClick类似, 也可以实现类似需求: 即在组件 mount 的时候根据初始 state 只发送一个异步请求, 用户在等待请求的过程中对该 state 重新进行了设置 那么该请求中所涉及到的 state 应该是在 mount 时候的初始值, 也就是 initial state, 代码如下:

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

  useEffect(() => {
    setTimeout(() => {
      alert("You clicked on: " + count);
    }, 3000);
  }, [])

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

在页面初次渲染之后便开始发送请求, 此时点击几次Click me按钮, 3 秒之后会显示You clicked on: 0

当然, 如果想要实现在 mount 时发送请求携带的 state 是最新的用户操作过后的数据, 那么还是一样可以使用useRef()来存取最新的 state, 代码改成如下:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(0);

  countRef.current = count;

  useEffect(() => {
    setTimeout(() => {
      alert("You clicked on: " + countRef.current);
    }, 3000);
  }, [])

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

这样再点击Clik me之后, state 发生了修改, 那么之前发送的请求中的 state 也就是修改过后的最新的 state.

这里需要注意, 第一段代码中useEffect()虽然为空, 但是 eslint 会提示需要加上 count, 但是加上了并不是我们想要的效果, 代码会变成这样:

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

  useEffect(() => {
    setTimeout(() => {
      alert("You clicked on: " + count);
    }, 3000);
  }, [count])

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

那么最后的结果为, 每次点击Click me, 触发 count 的更新, 随之也触发useEffect()这个函数的调用, 那么请求也会每次被发送, 当然由于闭包, 3 秒之后会依次显示所对应的 count 的值, 比如点击了 3 次, 那么 3 秒过后依次打印 1, 2, 3

总结: 需要分析清楚你想要的是什么, 是需要最新的 state/props(可以用 ref), 还是想要每次 render 下所对应的自己的 state/props

另外, 非常推荐 Dan 的 A Complete Guide to useEffect, 虽然很长, 但是讲的是非常细致, 当然啃下来还是不容易的...

参考

@hacker0limbo hacker0limbo added react react 笔记整理 javascript 原生 JavaScript 笔记整理 labels Dec 7, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
javascript 原生 JavaScript 笔记整理 react react 笔记整理
Projects
None yet
Development

No branches or pull requests

1 participant