Promise面试题思考延伸

最近想起之前在V2EX上看到的一个问题:一个 async function 数组, 怎样一个一个顺序执行?

想做到的是,每过一秒,分别打印: this is 0 > this is 1 > this is 2 ~ this is 8

下面的代码结果是过一秒后全部执行。

是不是哪里写的不对呢,多谢指教

var jobs = [];

for (let i = 0; i < 8; i++) {
  jobs.push(async function () {
    setTimeout(function () {
      console.log("this is " + i);
    }, 1000);
  });
}

(async function () {
  for (const job of jobs) {
    await job();
  }
})();

这题考察对 Promise 、async/await 的理解。而这题又能让人联想到一个类似的setTimeout 循环问题——破解前端面试(80% 应聘者不及格系列):从 闭包说起

请问以下代码打印出什么数据

for (var i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(new Date(), i);
  }, 1000);
}

console.log(new Date(), i);

如何修改成每隔1秒打印一个数,以0、1、2、3、4、5 的顺序排列,并要求原有的代码块中的循环和两处 console.log 不变,该怎么改造代码?

此题面试官想考察循环、闭包、闭包的解决(IIFE)、ES6 知识点(let、Promise)、ES7 的Async/await,以及setTimeout 的第三个参数等等知识点,具体看文章能明白一二

这里我们讲第一道题目,如果要实现题主所说的效果,缺少了什么?

已知:let形成块级作用域,意味着每次循环,jobs就 push 一个 async 函数,这些都是同步执行

但是注意,async 中的函数也是同步执行,只有等到 await 时才会进入微任务中,所以当

  • i=0时,jobs 塞入一个 setTimeout(function() { console.log(“this is 0” )})
  • i=1时,jobs 塞入一个 setTimeout(function() { console.log(“this is 1” )})
  • i=2时,jobs 塞入一个 setTimeout(function() { console.log(“this is 2” )})
  • i=3时,jobs 塞入一个 setTimeout(function() { console.log(“this is 3” )})
  • i=7时,jobs 塞入一个 setTimeout(function() { console.log(“this is 7” )})

继续往下执行

(async function () {
  for (const job of jobs) {
    await job();
  }
})();

这里题主了解到 await 需要 async 配合使用,就写了立即执行匿名函数,执行数组 jobs,但问题是 jobs 中的每个子项都是执行 async function(){setTimeout},这里的 async 有意义吗?

jobs.push(async function () {
  setTimeout(function () {
    console.log("this is " + i);
  }, 1000);
});

如果要让 await 暂停进程并恢复进程(即await job()),我们需要的是什么?

去掉 async,使其变成一个普通的函数,结果执行结果一致

jobs.push(function () {
  setTimeout(function () {
    console.log("this is " + i);
  }, 1000);
});

同样,将普通函数改成箭头函数也是如此,一秒之后打印还是0~7

根据网友总结:

  1. 对于 promise 对象,await 会阻塞函数执行,等待 promise 的 resolve 返回值,作为 await 的结果,然后再执行下一个表达式
  2. 对于非 promise 对象,比如 箭头函数、同步表达式等等,await 等待函数或者直接量的返回,而不是等待其执行结果

所以如果要让 await 每隔1秒执行一个 job,那就需返回一个 promise 实例,基于此逻辑进行改造

...
jobs.push(function () {
    return new Promise((resolve, reject) => {
        setTimeout(function () {
            resolve()
        	console.log("this is " + i)
    	}, 1000)
    })
});
...

这样,就解决了这个问题

我们的逻辑是在循环中,每次向 jobs 中塞入一个函数,这个函数返回的是一个实例 Promise(即 await 遇到后会暂停等异步结束再继续后续代码)。当执行 await job() 时,我们知道是循环 jobs,await 让其等待执行,执行完第一个后,再执行第二个,循序执行,每一个等待1秒钟,就达到题目的要求

这里我们了解到 await 等待的是一个 promise 实例(如果非 promise 实例,就不用等待),既然说到 Promise,我们就延伸一下,then 的链式调用

Promise 的 then 方法支持链式调用,它有哪几种情况?

  • 不 return(返回)值, 值延续上一个 resolved
  • return
    • return 非 Promise 实例
    • return Promise 实例

不 return

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("johan");
  }, 2000);
});

promise
  .then((data) => {
    console.log(data);
    // 不返回任何值
  })
  .then((data) => {
    console.log("第二次", data);
  });
点击展开答案

johan

第二次 undefined

return 非 Promise 实例

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("johan");
  }, 2000);
});

promise
  .then((data) => {
    console.log(data);
    return "帝王johan";
  })
  .then((data) => {
    console.log("第二次", data);
  });
点击展开答案

johan

第二次 帝王johan

return Promise 实例

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("johan");
  }, 2000);
});

promise
  .then((data) => {
    console.log(data);
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve(`${data} next`);
      }, 4000);
    });
  })
  .then((data) => {
    console.log("第二次", data);
  });
点击展开答案

johan

第二次 johan next

以上三个例子可以得知,在 then 方法中的 onfulfilled 函数和 onrejected 函数,不仅支持不返回,而且支持非 Promise 实例的普通值,而且支持一个 Promise 实例。并且返回的这个 Promise 实例或非 Promise 实例的普通值将会传给下一个 then 方法的 onfulfilled 函数或者 onrejected 函数中

因为我们知道它是 Generator 函数的语法糖async 函数返回的是一个 Promise 对象,当函数执行时,一旦遇到 await 就会先返回,等到异步操作完成,再接着执行函数体内后面的语句

我们来一道题来测试一下

const myPromise = (val) => Promise.resolve(val);
const delay = (duration) => {
  /**/
};
myPromise(`hello`)
  .then(delay(1000))
  .then((val) => console.log(val)); // hello

myPromise 是个 Promise 实例,传入值 hello,经过 Promise.resolve 传到then 中,然后经 delay 再传递给下一个 then,打印出val,所以 delay(1000) 会返回一个 Promise 实例,这样,第二个 then 才能打印出 hello

const delay = (duration) => (val) =>
  new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(val);
    }, duration);
  });

芝麻开门,显示全文!

一文概述:从状态复用到Hooks

学习一项知识,必须问自己三个重要问题:1. 它的本质是什么。2. 它的第一原则是什么。3. 它的知识结构是怎样的

测试一下 Hooks 的熟练程度

为什么不能在 for 循环、if 语句里使用 Hooks

React.memo、React.useCallback、React.usememo 的作用,以及对比

useState 中的值是个对象,改变对象中的值,组件会渲染吗?如果用 React.memo() 包裹住呢

Hooks 的(实现)原理是什么?

Hooks 的本质是什么?为什么?

React Hooks,它带来了哪些便利?

React Hooks 当中的 useEffect 是如何区分生命周期钩子的

useEffect(fn, []) 和 componentDidMount 有什么差异


回答得如何?在了解一个概念前,疑惑越多,理解就越深

是什么

React Hooks 是 React 16.8 推出的新特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性

为什么会有 Hooks

我们一定要有个概念,即 React 的本质是什么?它的特征是 UI=f(data)、一切皆组件、声明式编程。那么,既然是 UI=f(data),data(数据)通过 function 来驱动 UI 视图变化。在业务中,你不能简单只展示,也需交互,交互就会更新状态,React 是通过 setState 来改变状态。但这仅限于类组件,所以在Hooks出现之前,函数式组件用来渲染组件(也称它为木偶组件),类组件用来控制状态

而后,为了让状态能更好的复用,提出了Mixinsrender props高阶组件。诚然,render props、高阶组件能虽然能解决,但是会带来副作用——组件会形成“嵌套地狱”

以及类组件本身的生命周期会使得复杂的组件变得难以理解、class 语法的学习成本等等,构成了React 团队提出 hooks——让函数式组件拥有状态管理

官网也阐述过设计Hooks的三大动机:

  1. 在组件之间复用状态逻辑很难
  2. 复杂组件变得难以理解
  3. 难以理解的 class

状态复用的实验

Mixins时代

在笔者尚未使用 React 之前就存在,现已被淘汰

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

这里不对其做分析,React官方文档在 Mixins Considered Harmful 一文中提到了 Mixins 带来的危害:

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

Render Props

指一种在 React 组件之间使用一个值为函数的 prop 共享代码的简单技术

具有 render prop 的组件接受一个返回 React 元素的函数,并在组件内部通过调用此函数来实现自己的渲染逻辑

<DataProvider render={data=> (
    <h1>Hello, {data.target}</h1>
)}>

具体可在官网了解

HOC(高阶组件)

HOC的原理其实很简单,它就是一个函数,并且它接受一个组件作为参数,并返回一个新的组件,把复用的地方放在高阶组件中,你在使用的时候,只需要做不同用处

打个比方:就好像给你一瓶水,你在渴的时候就会喝它;你在耍帅的时候拿它摆POSE;你在别人需要的时候给他喝帮助人…

Writing is cheap. Show me code

function Wrapper(WrappedComponent) {
  return class extends React.Component {
    componentDidMount() {
      console.log("我是一瓶水");
    }
    render() {
      return (
        <div>
          <div className="title">{this.props.title}</div>
          <WrappedComponent {...this.props} />
        </div>
      );
    }
  };
}
import "./styles.css";
import React from "react";
import Wrapper from "./Wrapper";

class A extends React.Component {
  render() {
    return <div>喝它</div>;
  }
}

class B extends React.Component {
  render() {
    return <div>耍帅摆POSE</div>;
  }
}

class C extends React.Component {
  render() {
    return <div>帮助别人</div>;
  }
}

const AA = Wrapper(A);
const BB = Wrapper(B);
const CC = Wrapper(C);

export default function App() {
  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
      <AA title="我是普通人" />
      <BB />
      <CC />
    </div>
  );
}

这样就很明显的看出 HOC 的好处,”一瓶水“是共同代码,A、B、C处理业务代码,然后将A、B、C传入HOC(一瓶水)中,返回了一个新的组件 AA、BB、CC。相同的代码得到了公用

HOC-demo

各位可以前往这里查看 demo

HOC 的用处不单单是代码复用,还可以做权限控制、打印日志等。但它的缺陷也没明显,当大量使用 HOC 后,会产生大量的嵌套,使得嵌套变得困难;并且 HOC 会劫持 props,在不遵守约定的情况下可能会造成冲突

总结下 HOC:

  • 用法:创建一个函数,该函数接收一个组件作为输入,除了组件还可以传递其他的参数,基于该组件返回一个不同的组件
  • 优点:代码复用,逻辑复用
  • 缺点:因为嵌套使得调试难度变高;会劫持props,或许造成冲突

Hooks 的出世

前有状态复用的不给力( Mixins 被淘汰,render props、HOC 的副作用又大),后有类组件的复杂组件难以理解、维护(过多的生命周期),class 属性造成的 this 指向又麻烦。于是乎,Hooks 大喊一声:我来也

它起码有三个好处

  • 逻辑复用
    • 秒杀render props、hoc
  • 业务代码更聚合
    • 秒杀类组件
  • 写法简洁
    • 秒杀类组件

useState

作用:让函数组件具有维持状态的能力,替代类组件的constructor初始化状态

例子:

const Counter = () => {
  const [count, setCount] = useState(0);
  return <div onClick={() => setCount(count + 1)}>{count}</div>;
};

特点:逻辑复用

在使用 useState 时,会出现两个衍生问题:

一:Capture Value 特性

函数式组件与类组件有何不同中曾介绍过,函数式组件能捕获渲染时所用的值。并举例组件中点三下加, setTimeout 3秒后弹出数字,在点两次加,3秒后展示3,而不是5。而类组件却能获得最新的数据,这是为什么?

因为函数式组件有 Capture Value 的特性。而从源码的角度看,每次调用 setXX 会引发 re-render 从而重渲染组件

如果想获得最新值,可以通过 useRef 来将值保存在内存中

二:useState 中的值是个对象,改变对象中的值,组件会渲染吗?怎么优化?

一般我们用 useState 尽量遵守单一值,但难免会遇到一些特殊情况,如果值是个对象,改变对象中的其中一个属性,其他属性不变,那么引用其他属性的组件是否会渲染呢?

const DemoSon = (props) => {
  console.log("render", props);
  return <div>{props.name}</div>;
};

const Demo = () => {
  const [data, setData] = useState({ foo: { name: "johan", bar: { baz: 1 } } });
  const handleClick = () => {
    setData({
      ...data,
      foo: {
        ...data.foo,
        bar: {
          baz: 2,
        },
      },
    });
  };

  return (
    <div onClick={handleClick}>
      {data.foo.bar.baz}
      <DemoSon name={data.foo.name} />
    </div>
  );
};

点击 div,修改 baz 的值,DemoSon 是否会渲染呢?答案是会的,为什么会渲染?因为你的引用值发生了变化,生成了新的虚拟DOM,渲染到视图上时,子组件就会渲染。如何优化,让数据不变的组件不重复渲染?我觉得有两种方式,一拆分 data,拆分成 foo 对象和name,因为 setData 并不改变 name,所以DemoSon 不会渲染,还有一种是通过 memo 包裹住 DemoSon,因为 memo 能避免重新渲染

可查看线上 demo

useEffect

作用:处理副作用,替代类组件的componentDidMount、componentDidUpdate、componentWillUnmount

使用方式:

// 没有第二个参数
// mount 阶段和 update 阶段都执行
useEffect(fn);

// 第二个参数为空数组
// 当 mount 阶段会执行
useEffect(fn, []);

// 第二个参数为依赖项
// 当依赖项(deps)数据更新时会执行
useEffect(fn, [deps]);

// 清除副作用
useEffect(() => {
  const subscription = props.source.subscribe();
  return () => {
    // 清除订阅
    subscription.unsubscribe();
  };
});

PS:以上注释中的 mount 阶段,即组件加载时;update 指数据(包括props、state)变化时

在使用 useEffect 时,会面临几个问题:

1. useEffect(fn, []) 和 componentDidMount 有什么区别?

虽然 useEffect(fn, []) 和 componentDidMount 都可以表示组件加载时执行,但从细节上两者有所不同。要谈起细节需从源码中聊起,具体可看 React 源码魔术师卡颂的这篇——useEffect(fn, [])和cDM有什么区别? 了解,这里我讲下我的理解

源码中把虚拟DOM和虚拟DOM渲染到真实DOM分为两个阶段。虚拟DOM存在内存中,在 JSX 中对数据增删改,虚拟DOM会对对应的数据打上标签,这个阶段称为 render 阶段;把虚拟DOM映射到真实DOM的操作被称为 commit 阶段,它负责把这些标签转换为具体的DOM操作

在 render 阶段

  • 插入 DOM 元素被打上 Placement 标签;
  • 更新 DOM 元素被打上 Update 标签;
  • 删除 DOM 元素被打上 Deletion 标签;
  • 更新 Ref 属性被打上 Ref 标签
  • useEffect 回调被打上 Passive 标签

而 commit 阶段分为三个子阶段

  • 渲染视图前(before mutation 阶段)
  • 渲染视图时(mutation 阶段)
  • 渲染视图后(layout 阶段)

被打上 Placement 标签的,会在 mutation 阶段时执行对应的 appendChild 操作,意味着 DOM 节点被插入到视图中,接着在 layout 阶段调用 componentDidMount

而被打上 Passive 标签的,它会在 commit 阶段的三个子阶段执行完成后再异步调用 useEffect 的回调函数

由此可见,它们的调用调用时机是不同的,useEffect(fn,[]) 是在 commit 阶段执行完以后异步调用回调函数,而 componentDidMount 会在 commit 阶段完成视图更新(mutation阶段)后再 layout 阶段同步调用

hooks 中也有一个和 componentDidMount 调用时机相同的 hooks——useLayoutEffect

其次useEffect(fn, []) 会捕获 props 和state,而 componentDidMount 并不会。使用 useEffect(fn, []) 的会第哦啊函数会拿到初始的 props 和 state,这个道理和 capture value 是一个道理

总结:两点不同,一、执行时机不同;二、useEffect(fn, []) 会对 props 和 state 进行捕获

下文会用demo说明 capture value 特性

2. 每一次渲染都有它自己的 props 和 state

先讨论一下渲染(rendering),我们来看一个计数器组件 Counter

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

  return (
    <div>
      <p>点击 {count} 次</p>
      <button onClick={() => setCount(count + 1)}>点击</button>
    </div>
  );
}

第一次渲染时,count 的初始值从 useState(0) 中获取。当调用 setCount(count + 1) ,React 重新渲染组件,此时 count 的值就成 1。如下所示:

// Mount 第一次渲染
function Counter() {
  const count = 0; // 默认从useState 中获得
  // ...
  <p>点击 {count} 次</p>;
  // ...
}

// Update 点击 1 次
function Counter() {
  const count = 1; // 通过 setCount 修改 count
  // ...
  <p>点击 {count} 次</p>;
  // ...
}

// Update 点击 2 次
function Counter() {
  const count = 2; //  通过 setCount 修改 count
  // ...
  <p>点击 {count} 次</p>;
  // ...
}

每当我们更新状态时,React 会重新渲染组件。每次渲染获得此刻(快照)的 count 状态

而在类组件中并不是捕获值

举个例子:

class ClassDemo extends React.Component {
  state = {
    count: 0,
  };
  componentDidMount() {
    setInterval(() => {
      this.setState({ count: this.state.count + 1 });
    }, 1000);
  }
  render() {
    return <div>我是Class Component, {this.state.count}</div>;
  }
}

页面上的 count 会每隔一秒钟加1,而换成函数式组件

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

  const handleClick = () => {
    setCount(count + 1);
  };

  return <div onClick={handleClick}>我是Function Component, {count}</div>;
};

永远是1

这就是 hooks 的capture value,类似例子在函数式组件与类组件有何不同介绍过

可前往线上demo查看

useLayoutEffect

作用:同步执行副作用

大部分情况下,使用 useEffect 就可以帮我们处理副作用,但是如果想要同步调用一些副作用,比如对 DOM 的操作,就需要使用 useLayoutEffect,useLayoutEffect 中的副作用会在 DOM 更新之后同步执行

与类组件中的 componentDidMount 效果一致,都是在 commit 阶段完成视图更新(mutation阶段)后在 layout阶段同步调用

useCallback

作用:记忆函数,避免函数重新生成。在函数传递给子组件时,可以避免子组件重复渲染

例子:

const memoizedCallback = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

可缓存的引用

在类组件中常困扰人的是 this 绑定问题

  1. render 方法中使用bind
class App extends React.Component {
  handleClick() {
    console.log("this > ", this);
  }
  render() {
    return <div onClick={this.handleClick.bind(this)}>test</div>;
  }
}
  1. render方法中使用箭头函数
class App extends React.Component {
  handleClick() {
    console.log("this > ", this);
  }
  render() {
    return <div onClick={(e) => this.handleClick(e)}>test</div>;
  }
}
  1. 构造函数中bind
class App extends React.Component {
  constructor(props) {
    super(props);
    this.handleClick = this.handleClick.bind(this);
  }
  handleClick() {
    console.log("this > ", this);
  }
  render() {
    return <div onClick={this.handleClick}>test</div>;
  }
}

4.在定义阶段使用箭头函数绑定

class App extends React.Component {
  handleClick = () => {
    console.log("this > ", this);
  };
  render() {
    return <div onClick={this.handleClick}>test</div>;
  }
}

前三种都会因 App 组件的props或state 变化而重新触发渲染,使其渲染新的handleClick。第四种将handleClick抽离出赋值为变量,通过 this 指向存储函数,起到了缓存作用

而函数式组件一定会渲染

function App() {
  const handleClick = () => {
    console.log("Click");
  };
  return <div onClick={handleClick}>test</div>;
}

但是 useCallback 能缓存函数,让它”记住“

function App() {
  const handleClick = useCallback(() => {
    console.log("Click");
  }, []);
  return (
    <div className="App">
      <Demo handleClick={handleClick} />
    </div>
  );
}

但使用 useCallback 必须使用 shouldComponentUpdate 或者 React.memo 来忽略同样的参数重复渲染

所以单独使用 useCallback 是不能的,它需要和 React.memo 配合

function Demo(props) {
  return <div onClick={props.handleClick}>test</div>;
}

const MemoDemo = memo(Demo);

function App() {
  const handleClick = useCallback(() => {
    console.log("Click");
  }, []);
  return (
    <div className="App">
      <Demo handleClick={handleClick} />
    </div>
  );
}

但是 useCallback 会使代码可读性变差,所以尽量不用 useCallback

不用 useCallback ,那怎么提高性能呢?

useMemo

作用:记忆组件。替代类组件的shouldComponentUpdate

useCallback 的功能完全可以由 useMemo 所取代,如果你想通过 useMemo 返回一个记忆函数也是完全可以的

useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

例子:

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

默认情况下,如果 React 父组件重新渲染,它包含的所有子组件都会重新渲染,即使子组件没有任何变化

useMemo 和 useCallback 接受的参数都是一样,都是在其依赖项发生变化后执行,都是返回缓存的值,区别在于 useMemo 返回的是函数运行的结果,useCallback 返回的是函数

useMemo 返回的是一个值,用于避免在每次渲染时都进行高开销的计算

useCallback VS useMemo

相同点:useCallback 和 useMemo 都是性能优化的手段,类似于类组件的 shouldComponentUpdate,在子组件中使用 shouldComponentUpdate,判断该组件的 props 和 state 有没有变化,从而避免每次父组件 render 时重新渲染子组件

区别:useCallback 和 useMemo 的区别是 useCallback 返回一个函数,当把它返回的这个函数作为子组件使用时,可以避免每次父组件更新时重新渲染这个子组件

memo

作用:避免重新渲染

只有当 props 改变时会重新渲染子组件

被 memo 包裹住后,当 props 不变时,子组件就不会渲染

React.memo() 方法可以防止子组件不必要渲染,从而提供组件性能。

关于性能优化 Dan 曾写过一篇文章:在你写memo()之前,其实在我们使用 useCallback、useMemo、memo前不妨试试 state 下移和内容提升。目的就是让不用渲染的组件不重复渲染

useRef

作用:

保存引用值,跟 createRef 类似。我们习惯用 ref 保存 DOM

使用 useRef 保存和更新一些数据是有一定好处的,它可以不通过内存来保存数据,使得这些数据再重渲染时不会被清除掉

它不仅仅是用来管理DOM ref 的,它还相当于 this,可以存放任何变量,很好的解决闭包带来的不方便性

如果我们想利用普通的变量再重渲染过程中追踪数据变化是不可行的,因为每次组件渲染时它都会被重新初始化。然而,如果使用 ref 的话,其中的数据能在每次组件渲染时保持不变。

例子:

const [count, setCount] = useState < number > 0;
const countRef = useRef < number > count;

函数式组件与类组件有何不同介绍过使用方法

其他

useContext:减少组件层级

useReducer: 类 redux 的方法,useState 是基于它扩张的

ForwardRef:转发 ref

useImperativeHandle :透传 Ref,父组件获取子组件内的方法

自定义 Hooks

由于 useState 和 useEffect 是函数调用,因为我们可以将其组合成自己的 Hooks

function MyResponsiveComponent() {
  const width = useWindowWidth();
  return <p> Window width is {width}</p>;
}

function useWindowWidth() {
  const [width, setWidth] = useState(window, innerWidth);
  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", handleResize);
    return () => {
      window.removeEventListener("resize", handleResize);
    };
  });
  return width;
}

自定义 Hooks 让不同的组件共享可重用的状态逻辑。注意状态本身是不共享的。每次调用 Hook 都只声明了其自身的独立状态

React Hooks的不足

虽然实现了大多数类组件的功能,但是还无法实现 getSnapshotBeforeUpdate 和 componentDidCatch 这两个 API

附录:使用规则

Hooks 的本质就是 JavaScript 函数,在使用它时需要遵守两条规则

只在最顶层使用 Hook

不要在循环,条件或嵌套函数中调用 Hook,确保总是在你的 React 函数的最顶层以及任何 return 之前调用他们。遵守这条规则,你就能确保 Hook 在每次渲染中都按照同样的顺序被调用。这让 React 能够在多次的 useState 和 useEffect 调用之间保持 hook 状态的正确

只在 React 函数中调用 Hook

不要再普通的 JavaScript 函数中调用 Hook,你可以:

  • 在 React 的函数组件中调用 Hook
  • 在自定义 Hook 中调用其他 Hook

参考资料

芝麻开门,显示全文!

函数式组件与类组件有何不同

前言

React 中最关键的知识点就是 组件,在 React 16.8 之前(还没有 Hooks 前),我们的应用大多写成 Class 组件,因为 Class 组件有生命周期,能控制状态(state)。但函数式组件只能默默站在后面,说自己是木偶组件(也叫无状态组件),传来 props,展示UI

以下文字都基于有了 Hooks 后

正文

函数式组件和类组件之间是否有什么根本上的区别?

函数式组件捕获渲染时的值

具体可以看这篇文章:函数式组件与类组件有何不同?

因为在 React 中 props 是不可变(immutable)的,它们永远不会改变。然而,this 是可变(mutable)的

事实上,这就是类组件 this 存在的意义。React 本身会随着时间的推移而改变,以便你可以在渲染方法以及生命周期方法中得到最新的实例

函数式组件会捕获当前状态下的值,如果你使用定时器改变当前值的状态,那函数式组件显示的还是原来的值,而不是最新值。而类组件会一直获取最新值

只要一渲染,函数式组件就会捕获当前的值。而类组件即使渲染了,但是它的 this 会指向最新的实例

类组件

可以看线上Demo

class ClassDemo extends React.Component {
  state = {
    value: "",
  };

  showMessage = () => {
    alert("最新值为 " + this.state.value);
  };

  handleMessageChange = (e) => {
    this.setState({ value: e.target.value });
  };

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

  render() {
    return (
      <div>
        <input value={this.state.value} onChange={this.handleMessageChange} />
        <button onClick={this.handleClick}>点击</button>
      </div>
    );
  }
}

这样的结果是点击后获取到最新的值,而不是 3 秒前的值。为什么?因为 this 可变,3 秒之后执行 alert("最新值为 " + this.state.value)。 this.state.value 指向最新的值

如果类组件如果想保存原来的值该怎么做?

一、调用事件之前读取this.props

可以看线上Demo

showMessage = (value) => {
  alert("最新值为 " + value);
};

handleClick = () => {
  const { value } = this.state;
  setTimeout(() => this.showMessage(value), 3000);
};

可以解决,但点击时获取到当前的 user,再传递给 this.showMessage,这样,即使 3 秒之后也是原来的值

缺点:每次都要从 this.props 中拿值,如果数据一多,写起来不符合人性

二、在构造函数中绑定方法

可以看线上Demo

constructor(props) {
    super(props);
    this.showMessage = this.showMessage.bind(this);
    this.handleClick = this.handleClick.bind(this);
}

这个方法解决不了问题。我们的问题是我们从 this.props 中读取数据太迟了—— 读取时已经不是我们所需要使用的上下文

三、利用闭包

把方法写进 render 中,这样每次渲染时就能捕获住当时所用的 props 或者 state

可以看线上Demo

class ClassDemo extends React.Component {
  state = {
    value: "",
  };

  render() {
    const { value } = this.state;

    const showMessage = () => {
      alert("最新值为 " + value);
    };

    const handleMessageChange = (e) => {
      this.setState({ value: e.target.value });
    };

    const handleClick = () => {
      setTimeout(showMessage, 3000);
    };
    return (
      <div>
        <input value={this.state.value} onChange={handleMessageChange} />
        <button onClick={handleClick}>点击</button>
      </div>
    );
  }
}

但是这个方法很蠢,这个写法和函数式组件有什么区别呢?还不如用函数式组件呢

函数式组件如果想保存最新的值呢

使用 useRef 保存最新的值,让组件获得最新的值

function MyComponent() {
  const ref = useRef(null);
}

首先,ref 与实例都扮演同样的角色,ref 对象是一个有 current 属性的一个容器

上次的例子我们用函数式组件就可以这样写:

const FunctionDemo = () => {
  const [value, setValue] = useState("");

  const refValue = useRef("");

  const showMessage = () => {
    alert("最新值为 " + refValue.current);
  };

  const handleMessageChange = (e) => {
    setValue(e.target.value);
    refValue.current = e.target.value;
  };

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

  return (
    <div>
      <input value={value} onChange={handleMessageChange} />
      <button onClick={handleClick}>点击</button>
    </div>
  );
};

可以看线上Demo

这里笔者提出两个疑问:

  • 为什么 ref 能保存住最新的值?

  • 为什么函数式组件会捕获,类组件不会呢?

后续文章会给出笔者的回答

参考资料

芝麻开门,显示全文!

从一道面试题引申到N道面试题

昨天分享了深入浅出 setState 原理篇 ,其中讲到 setState 是同步还是异步的问题?这不,引起了古老的回忆,翻开笔记,想起曾经有一个体验良好的面试,面试官从一道面试题出发,循序渐进,引出了各种知识点,这些知识点能检测出面试者的React知识点、ES6知识点、JS基础等。我在此基础上,加上自己的理解,整理一个个人认为考点较充足的面试分享

双方客套,面试正式开始,面试官正手来一个面试题

如下的代码, a 的值是多少

class A extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      a: 0,
    };
  }
  componentDidMount() {
    this.setState({ a: 1 });
    setTimeout(() => {
      this.setState({ a: 2 });
    }, 0);
    new Promise((resolve) => {
      resolve(this.setState({ a: 3 }));
    }).then(() => {
      this.setState({ a: 4 });
    });
  }
  render() {
    console.log("state", this.state.a);
    return <div>{this.state.a}</div>;
  }
}

这题考察到 “React 渲染生命周期” 以及 “setState 是同步还是异步” 知识点

答案是:0、3、4、2

首先是 React 渲染生命周期,当挂载时,其生命周期调用顺序为:

  • constructor
  • static getDerivedStateFromProps()
  • render
  • componentDidMount

所以先 render 一次 state.a,值为 0 ,接着进入 componentDidMount 生命周期,this.setState({ a: 1 })this.setState({ a: 3 }) 为同步操作,setTimeout 会将其中的回调函数(即() => { this.setState({ a: 2 })}) 放入宏任务中,then 之后的回调函数(即() =>{this.setState({ a: 4 })})会放入微任务中

因为(legacy 模式下)setState 的同周期内的 setState 会批处理合成为一个 setState,并以后者为主,所以 this.setState({a: 1}) 会被覆盖。因为调用了 setState ,触发了更新,意味着又 render 一次,此时的 state.a 就显示为 3。当此宏任务调用完后去查看微任务队列,发现有未执行的回调函数,执行它 this.setState({ a: 4 }) ,又一次调用 setState,触发更新,state.a 显示 4。微任务队列为空后,查看宏任务队列,发现回调函数 this.setState({ a: 2 }),执行,触发更新,state.a 显示 2

所以其结果为:0、0、4、2

不知道你对否~~

我们接着改造一个这个题,变成

class A extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      a: 0,
    };
  }
  componentDidMount() {
    this.setState({ a: 1 });
    console.log("a", this.state.a);
    setTimeout(() => {
      this.setState({ a: 2 });
      console.log("a", this.state.a);
    }, 0);
    new Promise((resolve) => {
      resolve(this.setState({ a: 3 }));
      console.log("a", this.state.a);
    }).then(() => {
      this.setState({ a: 4 });
      console.log("a", this.state.a);
    });
  }
  render() {
    return <div>{this.state.a}</div>;
  }
}

不在 render 展示 state.a ,而是在调用完 setState 后查看 state.a 的值,结果会如何呢?

改编后的题主要考察组件的数据更新和视图的更新是两码事

答案是:0、0、3、2

首先都是在 componentDidMount 中,其次,与上个案例一样,调用依次是

  • this.setState({ a: 1 });
  • this.setState({ a: 3 });
  • this.setState({ a: 4 });
  • this.setState({ a: 2 });

其区别在于调用 this.setState({ a: 1 }) 和 this.setState({ a: 3 }) 后,数据不会马上更新,调用 setState 后,会将调用压入队列中,到最后一并执行(批处理),所以此时查看 state.a 的值,会看到还是 0,因为它还没触发批处理。而 Promise、setTimeout 之类原生事件会同步执行,值就显示为你 setState 什么,我就显示什么

我们在上述两个例子中谈到了 Event Loop,在 React 中会因为性能优化而对 setState 做处理,如果在浏览器环境中,上述的例子会怎么展示呢?

console.log("0");
setTimeout(() => {
  console.log("1");
}, 0);
new Promise((resolve) => {
  resolve();
  console.log("2");
}).then(() => {
  console.log("3");
});
console.log("4");

这题主要考验了浏览器的 Event Loop 机制

答案:0、2、4、3、1

第一个值为 0 没有疑问

遇到 setTimeout,所以 console.log(“1”) 排入 宏任务队列

因为 new Promise 中的执行函数会同步执行,而 then 中的”console.log(“3”)“会进入微任务,所以第二个值为 2,

接着就是第三个值 4,再因为 Event Loop 机制(宏任务-执行全部微任务-再去找宏任务队列第一个),所以先执行微任务,第四个值为3

最后执行宏任务(setTimeout),第五个值为 5

既然说到了 Promise,不妨考考 Promise,手写一个太麻烦,没必要考课本。那来说说为什么 promise 中能 .then,它的链式调用的原理是什么?

每次 new Promise() 后能 .then().then().then(),因为它每次调用完 then 后,返回了 Promise 实例,所以才能一直调用下去

这样理解下来,链式调用的核心,就是调用完方法后返回对象本身(return this)

那我们出一道题,题目是

class Operator {...}
var op = new Operator(1)
op.add(3).minus(2).multi(2).divide(1)

写出 Operator 构造函数中的 add、minus、multi、divide

我的答案是:

class Operator {
  constructor(initNum) {
    this.num = initNum;
  }
  add(value) {
    this.num = this.num + value;
    return this;
  }
  minus(value) {
    this.num = this.num - value;
    return this;
  }
  multi(value) {
    this.num = this.num * value;
    return this;
  }
  divide(value) {
    this.num = this.num / value;
    return this;
  }
}

从这题可以引申出 class、ES6还有有哪些特性和柯里化等等

总结

从 this.setState 的一道面试题延申出各种问题,即考察了面试者对 this.setState 的理解,又考了浏览器的 Event Loop,并引申出 Promise 的链式调用,并用一道题目考察面试者的 JS 基础能力,再之后,还可以问 ES6 的特性和柯里化,知识广度就变大了,也能更好的考察面试者

芝麻开门,显示全文!

深入浅出 setState 原理篇

前言

想起自己(2021年) 8 月份面试时,被面试官们问了好几个 setState 的问题,现在想想,虽然回答上问题,但是了解的不深刻。我知道 setState 被设计成“异步”是为了性能,但是涉及到源码解读我就歇菜了;我知道如何让它同步,但是遇到真实的代码情况时,却不知道如何下手。说到底,当时是准备了面经把这些概念记下来,而没有真正理解它

在认识 setState 前,我们问几个常见问题

  • setState 是同步还是异步?
  • 如果是异步,怎么让它同步?
  • 为什么要这样设计?

基本概念和使用

React 的理念之一是 UI=f(data),修改 data 即驱动 UI 变化,那么怎么修改呢?React 提供了一个 API ——setState(类组件的修改方法)

官网介绍

setState() 将对组件 state 的更新排入队列,并通知 React 需要使用更新后的 state 重新渲染此组件及其子组件。这是用于更新用户界面以响应事件处理器和处理服务器数据的主要方式

为了更好的感知性能,React 会延迟调用它,然后通过一次传递更新多个组件。React 并不会保证 state 的变更会立即生效

setState() 并不总是立即更新组件。它会批量推迟更新。这使得在调用 setState() 后立即读取 this.state 成为了隐患。为了消除隐患,请使用 componentDidUpdate 或者 setState 的回调函数(setState(updater, callback)),这两种方式都可以保证在应用更新后触发

除非 shouldComponentUpdate() 返回 false,否则 setState() 将始终执行重新渲染操作。如果可变对象被使用,且无法在 shouldComponentUpdate() 中实现条件渲染,那么仅在新旧状态不一致调用 setState()可以避免不必要的重新渲染

使用方法

setState(updater, [callback]);

参数一为带有形式参数的 updater 函数:

(state, props) => stateChange;

// 例如
// this.setState((state, props) => {
//   return {counter: state.counter + props.step};
// });

setState 的第一个参数除了接受函数外,还可以接受对象类型:

setState(stateChange[, callback])

// 例如:this.setState({count: 2})

setState 的第二个参数为可选的回调函数,它将在 setState 完成合并重新渲染组件后执行。通常,我们建议使用 componentDidUpdate 来代替此方法

setState(stateChange[, callback])

// 例如: this.setState({count: 2}, () => {console.log(this.state.count)})

与 setState 回调相比,使用 componentDidUpdate 有什么优势?

stackoverflow 有人问过,也有人回答过:

  • 一致的逻辑

  • 批量更新

  • 什么时候 setState 会比较好?

    • 当外部代码需要等待状态更新时,如 Promise

setState 的特性——批处理

如果在同一周期内对多个 setState 进行处理,例如,在同一周期内多次设置商品数据,相当于:

this.setState({count: state.count + 1});
this.setState({count: state.count + 1});
this.setState({count: state.count + 1});
// ===
Object.assign(
  count,
  {quantity: state.quantity + 1},
  {quantity: state.quantity + 1},
  ...
)

后调的 setState 将覆盖同一周期内先调用 setState 的值

  • setState(stateChange[, callback])
  • setState((state, props) => stateChange[, callback])

setState 必引发更新过程,但不一定会引发 render 被执行,因为 shouldCompomentUpdate 可以返回 false

批处理引发的问题

问题1:连续使用 setState,为什么不能实时改变

state.count = 0;
this.setState({ count: state.count + 1 });
this.setState({ count: state.count + 1 });
this.setState({ count: state.count + 1 });
// state.count === 1,不是 3

因为 this.setState 词 API 为会进行批处理,后调的 setState 会覆盖统一周期内先调用的 setState 的值,如下图所示:

state.count = 0;
this.setState({ count: state.count + 2 });
this.setState({ count: state.count + 3 });
this.setState({ count: state.count + 4 });
// state.count === 4

问题2:为什么要 setState,而不是直接 this.state.xx = oo?

因为 setState 做的事情不仅仅只是修改了 this.state 的值,另外最重要的是它会触发 React 的更新机制,会进行diff,然后将 patch 部分更新到真实 dom 里

如果你直接 this.state.xx = oo 的话,state 的值确实会改,但是它不会驱动 React 重渲染。setState 能帮助我们更新视图,引发 shouldComponentUpdate、render 等一系列函数的调用。至于批处理,React 会将 setState 的效果放入队列中,在事件结束之后产生一次重新渲染,为的就是把 Virtual DOM 和 DOM 树操作降到最小,用于提高性能

当调用 setState 后,React 的 生命周期函数 会依次顺序执行

  • static getDerivedStateFromProps
  • shouldComponentUpdate
  • render
  • getSnapshotBeforeUpdate
  • componentDidUpdate

问题3:那为什么会出现异步的情况呢?(为什么这么设计?)

因为性能优化。假如每次 setState 都要更新数据,更新过程就要走五个生命周期,走完一轮生命周期再拿 render 函数的结果去做 diff 对比和更新真实 DOM,会很耗时间。所以将每次调用都放一起做一次性处理,能降低对 DOM 的操作,提高应用性能

问题4:那如何在表现出异步的函数里可以准确拿到更新后的 state 呢?

通过第二个参数 setState(partialState, callback) 中的 callback 拿到更新后的结果

onHandleClick() {
    this.setState(
        {
            count: this.state.count + 1,
        },
        () => {
            console.log("点击之后的回调", this.state.count); // 最新值
        }
    );
}

或者可以直接给 state 传递函数来表现出同步的情况

this.setState((state) => {
  console.log("函数模式", state.count);
  return { count: state.count + 1 };
});

执行原理

首先先了解三种渲染模式

  • legacy 模式:ReactDOM.render(<App />, rootNode) 。这是当前 React app 使用的方式。当前没有计划删除本模式,但是这个模式可能不支持新功能
  • blocking 模式:ReactDOM.createBlockingRoot(rootNode).render(<App />) 。目前正在实验中,作为迁移到 concurrent 模式的第一个步骤
  • concurrent 模式 :ReactDOM.createRoot(rootNode).render(<App />)。目前再实验中,未来稳定之后,打算作为 React 的模式开发模式。这个模式开启了所有的新功能
    • 拥有不同的优先级,更新的过程可以被打断

在 legacy 模式下,在 React 的 setState 函数实现中,会根据一个变量 isBatchingUpdates 判断是直接更新 this.state 还是放到队列中回头再说,而 isBatchingUpdates 默认是 false,也就表示 setState 会同步更新 this.state,但是,有一个函数 batchedUpdates,这个函数会把 isBatchingUpdates 修改为 true,而当 React 在调用事件处理函数之前就会调用这个 batchedUpdates,造成的后果,就是由 React 控制的事件处理过程 setState 不会同步更新 this.state

像 addEventListener 绑定的原生事件、setTimeout/setInterval 会走同步,除此之外,也就是 React 控制的事件处理 setState 会异步

而 concurrent 模式都是异步,这也是未来 React 18 的默认模式

总结

首先,我们总结下关键知识点

  • setState 不会立即改变 React 组件中 state 的值
  • setState 通过引发一次组件的更新过程来引发重新绘制
  • 多次 setState 函数调用产生的效果会合并(批处理)

其次,回答一下文章开头的问题(第二第三问题在文中已经回答)

setState 是同步还是异步?

  • 代码同步,渲染看模式
    • legacy 模式,非原生事件、setTimeout/setInterval 的情况下为异步;addEventListener 绑定原生事件、setTimeout/setInterval 时会同步
    • concurrent 模式:异步

image-20220221160905787

参考资料

芝麻开门,显示全文!

非计算机专业的人如何转行程序员看后观感

最近在 bilibili 上看到宿琛的《非计算机专业的人如何转行程序员》的分享,有兴趣的可以看一下啊。他分享了自己的经历,很厉害,大学就知道自己不喜欢什么想要什么,想要什么后就去努力去读研究生,读完了之后发现编程能力比较弱,就去线上看公开课,因为英文可以,看英文视频能学进去,这样的经历不是一般人能复制的,他推荐了公开课资源平台 coursera 课程edx 课程,以及一些列觉得不错的课程,我

在评论区里又看到 CS自学指南

image-20220217151547811

天呐,要成为一个计算机高手,要学习这么多

学习有必要学这么多吗?即使你都学会了,

你说要取法乎上,但是这个上是要有英语基础,你没有良好的英语怎么听得懂,即使你磕磕绊绊听完了,你能理解多少,

成为工程师是把自己

我不反对学习,而且我也一直有在学习,

我认为的学习,先从功能点入手,慕课的实战课蛮好的,学到即赚到

芝麻开门,显示全文!

碎皮扯淡:好奇是第一原动力

我的一切知识源于好奇。

  • 好奇《挪威的森林》到底有多色情,先去看电影,再去看小说,发现一点都不色情,纯爱情文学小说
  • 好奇《金瓶梅》到底有多色情,先去看了杨思敏版的,再去看小说(新加坡南洋出版社出版无删减版)。电影是刺激,但小说中有关色情的部分少的可怜,还各种隐晦,远没有现代影视直接
  • 这些好奇带来的知识引发了我的思考。一切知识都需要自我感受。别人说电影好看,也许她是个电影小白,没看过几部电影,所以逢看称好。人们说《金瓶梅》淫秽,搁现在,这裸露程度比美剧还少了几分,怎么能说淫秽呢

在大学时期的我来说:好奇是驱动人进步的钥匙,色情是第一原动力

现阶段,虽然对色情已经没有太多研究,对理财感兴趣了,只要有资本,就可以通过资本获利,而且对于接下来十年中的我来言,钱无疑是最重要的努力动力之一

关于性方面的老师可推荐几位供君学习:

性文学老师:王小波、李银河、亨利·米勒、兰陵笑笑生、李渔、冯唐、爱伦坡

性电影老师:伊娃·格林电影、多看美女的电影、多看资本主义国家(美日韩)的电影

芝麻开门,显示全文!

笔记:npm换源更新

看到一篇文章,说是 npm.taobao.orgregistry.npm.taobao.org 域名将于 2022 年 05 月 31 日起停止服务。有必要更新一下 npm 的源,顺便复习一下 npm 的一些列命令

查看 npm 配置

npm config list // 查看基本配置
npm config list -l // 查看所有配置

image-20220211095840101

没找到,拿查看所有配置看看,一查发现 metrics-registry = "https://registry.npmjs.org/",那就说明源还是 npmjs 的源,看来公司电脑的 npm 用的还是 npm 官方的源,而全局还下载了 cnpm,因为是 npm 包,所以看版本,远程拉去最新的即可,先看看版本

cnpm -v

image-20220211101038446

查看 cnpm 的官网:中国 NPM 镜像 ,文中有这么一句:

当前 registry.npmmirror.com 是从 r.cnpmjs.org 进行全量同步的.

发现也没什么关系,所以我应该不会被影响

如何更新安装包

  1. 手动更新
    • 修改 package.json 中依赖包版本,执行 npm install --force
  2. 使用 yarn 代替 npm
    • yarn upgrade
  3. 使用第三方插件
    • npm install npm-check-updates -g
    • ncu // 查看可更新包
    • ncu -u // 更新 package.json
    • npm install // 升级最新版本

发现第三种最有用

参考资料

芝麻开门,显示全文!

面试题:渲染十万条数据解决方案

笔者在之前的面试中遇到过“一次性给你 10000 条数据,怎么让它不卡之类的问题“,当初准备不充分,不知道怎么回答这类问题,说的方案过于简单,还扯到防抖节流之类的性能优化点上,这篇文章原本2022年1月29日计划写,后因春节过年耽误,直至近日动笔

查阅资料后发现有三种解决方案:

  • 虚拟列表(也叫按需渲染或可视区域渲染)
  • 延迟渲染(即懒渲染)
  • 时间分片

虚拟列表是最主流的解决方案,不渲染所有的数据,只渲染可视区域中的数据。当用户滑(滚)动时,通过监听 scroll 来判断是上滑还是下拉,从而更新数据。同理 IntersectionObserver 和 getBoundingClientRect 都能实现

延迟渲染,也叫懒加载。顾名思义,最开始不渲染所有数据,只渲染可视区域中的数据(同虚拟列表一致)。当滚动到页面底部时,添加数据(concat),视图渲染新增DOM

时间分片主要是分批渲染DOM,使用 requestAnimationFrame 来让动画更加流畅

先说最主流的方案

虚拟列表

什么是虚拟列表

虚拟列表是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,是对长列表渲染的优化手段

说的明白一点,就是展示可视区域中的内容,当你向上向下滚动时,通过 DOM API 替换可视区域中的数据,做到动态加载十万条数据

两种解决思路

关于无限滚动,早期通过监听 scroll 事件,这是最常见的解决方案。可去 图片懒加载 中查看,简单来说,就是通过子项的 offsetTop(偏移高度)与 innerHeight(视窗高度)+ scrollTop(滚动高度)做对比来实现,当偏移高度 < 视窗高度+滚动高度时,说明已经滚到下方,就可展示图片

图片懒加载 中我们也提及 IntersectionObserver(交叉观察者)API,以此来解决 scroll 所不具备的效果,即 IntersectionObserver API 是异步的,不随目标元素的滚动同步触发,性能消耗小。当然还可以通过 getBoundingClientRect 来实现,getBoundingClientRect 方法返回元素的大小机器相对于视窗的位置

PS:所以目前来说有三种方法,在文末 demo 中会附上单独使用这三种解决方案的代码

在这里,因本人实力有限,未破解 getBoundingClientRect 向上滑动时的页面抖动问题,只有 scroll 和 IntersectionObserver 两种解决方案(getBoundingClientRect 方法也放在代码中,但向上滑动会抖动)

scroll 解决方案

先说 scroll 解决方案,简单来说,就是对其传来的数据进行分割展示,用到 slice 方法,它会返回一个新的数组

我们假设单个列表高度为 30px,一页展示的列表数量为 const count = Math.ceil(列表高度 / 30),展示的数据就是 visibleData = data.slice(start, start + count)(start 一开始为0)

当滚动时,动态修改 start 和 visibleData

import React, { useEffect, useState, useRef } from "react";

const VirtualList = (props) => {
  const { data } = props;

  const [start, setStart] = useState(0);
  const [visibleCount, setVisibleCount] = useState(null);
  const [visibleData, setVisibleData] = useState([]);
  const virtualRef = useRef(null);
  const virtualContentRef = useRef(null);

  useEffect(() => {
    const count = Math.ceil(virtualRef.current.clientHeight / 30);
    setVisibleCount(count);
    setVisibleData(data.slice(start, start + count));
  }, []);

  const onHandleScroll = () => {
    const scrollTop = virtualRef.current.scrollTop;
    const fixedScrollTop = scrollTop - (scrollTop % 30);
    virtualContentRef.current.style.webkitTransform = `translate3d(0, ${fixedScrollTop}px, 0)`;
    setStart(Math.floor(scrollTop / 30));
    setVisibleData(data.slice(start, start + visibleCount));
  };

  return (
    <div className="virtual-list" ref={virtualRef} onScroll={onHandleScroll}>
      <div
        className="virtual-list-phantom"
        style={{ height: data.length * 30 + "px" }}
      ></div>
      <div className="virtual-list-content" ref={virtualContentRef}>
        {visibleData.map((item) => (
          <div className="virtual-list-item" key={item.key}>
            {item.key}
            {item.value}
          </div>
        ))}
      </div>
    </div>
  );
};

export default VirtualList;

注:virtual-list-phantom 会让滚动条看起来很高,个人认为有无都不影响观感

(有virtual-list-phantom)效果如下:

虚拟列表scroll有virtual-list-phantom

(无virtual-list-phantom)效果如下:

虚拟列表scroll无virtual-list-phantom

这种方法的精髓在于设置开始渲染的点和展示的数据,当他滚动时动态修改,但是因为 scroll 会频繁触发,当渲染的数据变多后会有性能问题

IntersectionObserver 解决方案

通过 IntersectionObserver 的特性,当目标对象中的 entry.isIntersecting 为 true 或者 intersectionRatio > 0 (元素与祖先元素交叉、可见)时,说明本来不可见的元素浮现在视图中,表示它向上或向下滑动,我们动态设置视图中的顶部和底部 id 即可对其判断。当下滑时 entry.traget.id === 'bottom',我们修改 start 和 end;同理,当上滑时entry.traget.id === 'top 时,我们也一样修改 start 和 end

附上部分代码:

...
const [start, setStart] = useState(0);
const [end, setEnd] = useState(THRESHOLD);
const [observer, setObserver] = useState(null);
const bottomElement = useRef();
const topElement = useRef();
...
const Observer = new IntersectionObserver(callback, options);
const callback = (entries, observer) => {
    entries.forEach(entry => {
        const dataLength = data.length;
        if (entry.isIntersecting && entry.target.id === "bottom") {
            const maxStartIndex = dataLength - 1 - THRESHOLD;
            const maxEndIndex = dataLength - 1;
            const newStart = end - 5 <= maxStartIndex ? end - 5 : maxStartIndex;
            const newEnd = end + 10 <= maxEndIndex ? end + 10 : maxEndIndex;
            setStart(newStart);
            setEnd(newEnd);
        }
        if (entry.isIntersecting && entry.target.id === "top") {
            const newEnd =
                  end === THRESHOLD
            ? THRESHOLD
            : end - 10 > THRESHOLD
            ? end - 10
            : THRESHOLD;
            const newStart = start === 0 ? 0 : start - 10 > 0 ? start - 10 : 0;
            setStart(newStart);
            setEnd(newEnd);
        }
    });
};
const updatedList = data.slice(start, end);
return (
    <div style={{ position: "relative", textAlign: "center" }}>
      {updatedList.map((item, index) => {
        const top = height * (index + start) + "px";
        const refVal = getReference(index, index === lastIndex);
        const id = index === 0 ? "top" : index === lastIndex ? "bottom" : "";
        return (
          <div
            className="io-virtual-list-item"
            key={item.key}
            style={{ top }}
            ref={refVal}
            id={id}
          >
            {item.key}
          </div>
        );
      })}
    </div>
);
...

效果如下:

IntersectionObserver效果

推荐用这种方法,IntersectionObserver 是异步 API,性能消耗小,缺点是有些落后浏览器不支持,如公司需要兼容这类用户,需引入 polyfill

懒加载

不多介绍,一句话解释:最开始不渲染所有数据,只展示视图上可见的数据,当滚动到页面底部时,加载更多数据

实现原理:通过监听父级元素的 scroll 事件,当然也可以通过 IntersectionObserver 或 getBoundingClientRect 等 API 实现

但 scroll 事件会频繁触发,所以需要手写节流;滚动元素内有大量 DOM ,容易造成卡顿,建议使用 IntersectionObserver

因为之前在讲 图片懒加载 时说过思路,这里就不贴,文末会附上demo

时间分片

参考 如何高性能的渲染十万条数据(时间分片) 所举例子,对于大量数据渲染时,JS 运算并不是性能的瓶颈,性能的瓶颈主要在于渲染阶段。也就是说 JS 执行是很快的,页面卡顿是因为同时渲染大量 DOM 所引起的,可采用分批渲染的方式来解决

let ul = document.getElementById("container");
// 插入十万条数据
let total = 100000;
// 一次插入 20 条
let once = 20;
// 总页数
let page = total / once;
// 每条记录的索引
let index = 0;
// 循环加载数据
function loop(curTotal, curIndex) {
  if (curTotal <= 0) {
    return false;
  }
  // 每页多少条
  let pageCount = Math.min(curTotal, once);
  setTimeout(() => {
    for (let i = 0; i < pageCount; i++) {
      let li = document.createElement("li");
      li.innerText = curIndex + i + " : " + ~~(Math.random() * total);
      ul.appendChild(li);
    }
    loop(curTotal - pageCount, curIndex + pageCount);
  }, 0);
}
loop(total, index);

我的理解是,通过递归来渲染DOM,刚开始可以是20个,20个渲染完后再渲染剩下的,循环如此,将其全部渲染完。又因为浏览器的渲染机制是“宏任务—微任务—GUI渲染—宏任务…”。遂第一个 loop 执行后,先等页面渲染完,再执行下一轮的 setTimeout(宏任务)

使用 setTimeout 来做分片会有问题,就是当我们快递下拉时,会出现闪屏或白屏现象?

这是因为人眼识别帧数为24帧。当帧数为24帧时,连续的画面会形成动画,老一辈的动画片,例如《大闹天空》《哪吒闹海》之类都是一秒里有24个画面(24帧),平滑动画的最佳循环间隔就是 1000 / 24,约等于 41.67ms

而电脑显示器的刷新频率为 60 帧,大概相当于每秒重绘 60 次。同理,如果想骗过人眼,平滑动画的最佳循环时间就是 1000 / 60,约等于 16.7ms

而 setTimeout 的执行时间并不是确定的,虽然我们写了 setTimeout(() => {}, 0) ,但这是不准确的,按照 H5 标准规定 setTimeout 的第二个参数不能小于 4ms,不足会自动增加

所以当第一个宏任务完成,第一个微任务完成,第一次渲染页面后,4 毫秒后再执行第二个宏任务,这样就导致了实际执行时间慢了 4 毫秒,当一个周期(宏任务+微任务+GUI渲染+4ms)的总和时间大于16.7ms,就会出现掉帧现象,这也是为什么 React 要使用 Fiber 架构的原因

加上各类电子设备的刷新频率不同,也会导致一个周期的总时间大于16.7ms

requestAnimationFrame 正是解决这一问题的关键API,它告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。

因为他是浏览器所提供的原生 API,所以各类电子设备都能使用,根据不同的刷新频率,给于不同的动画执行时间,就不会引起丢帧现象

...
function loop(curTotal, curIndex) {
    if (curTotal <= 0) {
        return false;
    }
    // 每页多少条
    let pageCount = Math.min(curTotal, once);
    window.requestAnimationFrame(function () {
        for (let i = 0; i < pageCount; i++) {
            let li = document.createElement('li');
            li.innerText = curIndex + i + ' : ' + ~~(Math.random() * total)
            ul.appendChild(li)
        }
        loop(curTotal - pageCount, curIndex + pageCount)
    })
}
loop(total, index);

总结

渲染十万条数据有三种解决方案,为虚拟列表、懒加载、时间分片。最优选是虚拟列表,DOM 树上只挂载有限的DOM;懒加载和时间分片的缺点在于插入大量的DOM,占内存运行时会造成卡顿

无论是虚拟列表还是懒加载,传统的做法是 scroll + 节流,这种做法的优势是老 API,兼容性刚刚的,缺点是,滑多了还是会引起性能问题,当然 IntersectionObserver 也是一样的,无非是换了个 API 做“元素是否出现在视图”判断,最好的方案是用 IntersectionObserver(交叉观察器),异步加载、性能消耗小

附上线上demo示例:

参考资料

芝麻开门,显示全文!

面试题:promise的链式怎么实现的

写文章为了让自己好记住知识点,也为了后续当作笔记来看待

前言

之前有过一次面试,面试官问 promise 相关的知识点,然后我回答了,他追问说 promise 的链式怎么实现?我当时没反应过来。面试官很有耐心,说 jquery 也是有链式的,你看过 jquery 的源码吗?它的链式怎么写的?我还是不知道,后来没通过面试,这个知识点常被我回忆,现在正好有时间,来写一写

正文

答案是:返回this

先来一个例子:

var person = {
  name: "johan",
  age: 28,
  sayName: function () {
    console.log("my name is" + this.name);
  },
  sayAge: function () {
    console.log("my age is " + this.age);
  },
};

命名一个对象,它有两个方法,sayName 和 sayAge ,如果我想这样表示 person.sayName().sayAge() 呢?怎么做,在方法 sayName 和 sayAge 中返回 this,即

var person = {
  name: "johan",
  age: 28,
  sayName: function () {
    console.log("my name is" + this.name);
    return this;
  },
  sayAge: function () {
    console.log("my age is " + this.age);
    return this;
  },
};

这就是表示,调用方法 sayName 、sayAge 后,返回调用者,即例子 person.sayName() ,person 调用 sayName,调用完后返回值还是 person。所以它可以继续链式调用 sayAge,因为它表示的还是 person

Promise 中的链式

Promise 本身没有链式,但是 Promise 的实例对象中的 then 有链式

function MyPromise(executor) {

}

MyPromise.prototype.then = function (onFulfilled, onRejected) {
    return new Promise((resolve, reject) => {
        ...
    })
}

当你使用 Promise 时,一般是这样使用:

let promise = new Promise((resolve, reject) => {
  setTimeout(resolve, 1000);
});
promise.then(() => {
  console.log("1s后显示");
});

如果加上链式

promise
  .then(() => {
    console.log("1s后显示,第一个");
  })
  .then(() => {
    console.log("1s后显示,第二个");
  });

所以很明显,每调用一次 then,就是返回一个实例对象(return new Promise

Jquery 中的链式

源码太多内容,就拿 core.js 中的代码为例子

jQuery.fn = jQuery.prototype = {
  // The current version of jQuery being used
  jquery: version,

  constructor: jQuery,

  // The default length of a jQuery object is 0
  length: 0,

  toArray: function () {
    return slice.call(this);
  },

  // Get the Nth element in the matched element set OR
  // Get the whole matched element set as a clean array
  get: function (num) {
    // Return all the elements in a clean array
    if (num == null) {
      return slice.call(this);
    }

    // Return just the one element from the set
    return num < 0 ? this[num + this.length] : this[num];
  },

  // Take an array of elements and push it onto the stack
  // (returning the new matched element set)
  pushStack: function (elems) {
    // Build a new jQuery matched element set
    var ret = jQuery.merge(this.constructor(), elems);

    // Add the old object onto the stack (as a reference)
    ret.prevObject = this;

    // Return the newly-formed element set
    return ret;
  },

  // Execute a callback for every element in the matched set.
  each: function (callback) {
    return jQuery.each(this, callback);
  },

  map: function (callback) {
    return this.pushStack(
      jQuery.map(this, function (elem, i) {
        return callback.call(elem, i, elem);
      })
    );
  },

  slice: function () {
    return this.pushStack(slice.apply(this, arguments));
  },

  first: function () {
    return this.eq(0);
  },

  last: function () {
    return this.eq(-1);
  },

  even: function () {
    return this.pushStack(
      jQuery.grep(this, function (_elem, i) {
        return (i + 1) % 2;
      })
    );
  },

  odd: function () {
    return this.pushStack(
      jQuery.grep(this, function (_elem, i) {
        return i % 2;
      })
    );
  },

  eq: function (i) {
    var len = this.length,
      j = +i + (i < 0 ? len : 0);
    return this.pushStack(j >= 0 && j < len ? [this[j]] : []);
  },

  end: function () {
    return this.prevObject || this.constructor();
  },
};

我们不用看全部,但看其中的方法,是不是和最开始的例子很像——return this

所以链式不可怕,可怕的是,动都不动就自我劝退

芝麻开门,显示全文!