跳到主要内容

· 阅读需 8 分钟
youniaogu

虚拟列表

1. 前言

我们先来看个问题:

某天,产品要求我们展示一个大数据列表(几万或者十几万条),但一次性渲染出来会存在性能问题,请问该怎么做?

  • 分页

前端分页或者后端分页都可以,这是最常见的处理方式,也是最好的处理方式

那如果产品不想要分页,坚决不要,这时候该怎么办?答案是:

  • 虚拟列表

下面的场景,我们来思考下:

一个订单列表容器高 200px,一个订单组件高 50px,也就是说最多能完整显示 4 个订单(部分出现的话是 5 个)

这时后台接口返回 100 个订单,于是我们把 100 个订单都渲染出来

但订单列表容器最多只能显示 5 个订单组件,也就是说订单 6-100 暂时是无用的,只有订单 1-5 是必须渲染的,只需要渲染必要的元素,这就是虚拟列表的核心思想

2. 什么是虚拟列表?

简单来说,就是通过计算,动态展示列表的一部分,达到节约资源的效果

通过scrollTop可以知道一个元素的内容垂直滚动的像素大小,计算出当前位置对应原列表元素的 index,从而渲染需要的列表部分

虚拟列表滚动过程:

<div style="display: flex; align-items: center; text-align: center;">
<div>
<img src={require("./assets/screenshots/23.png").default} width="150" />
<p>1)初始状态</p>
</div>
<span> => </span>
<div>
<img src={require("./assets/screenshots/24.png").default} width="150" />
<p>2)滑动一段距离</p>
</div>
<span> => </span>
<div>
<img src={require("./assets/screenshots/25.png").default} width="150" />
<p>3)重新渲染后</p>
</div>
</div>

3. 固定高度实现

在滚动后,我们需要计算 top 的值

top为4个订单的高度

top为5个订单的高度

下面代码为了直接体现虚拟列表的实现,没有进行 api 抽象,详细请看:github

import React, { Component } from "react";

class VirtualList extends Component {
constructor(props) {
super(props);

this.list = new Array(100000).fill(null).map((_, index) => {
return index + 1;
});
this.height = 600; //容器高度
this.itemHeight = 60; //单个子元素高度
this.state = { visibleData: [], startOffset: 0, offsetHeight: 0 }; //初始化数据
this.totalHeight = this.list.length * this.itemHeight; //原列表总高度
this.visibleCount = Math.ceil(this.height / this.itemHeight); //容器可以显示的个数(向上取整)
this.startIndex = 0; //开始的index
this.endIndex = this.startIndex + this.visibleCount; //结束的index
}

componentDidMount() {
this.updateVisibleData(); //更新列表
}

updateVisibleData = () => {
const visibleData = this.list.slice(this.startIndex, this.endIndex);
const startOffset = this.startIndex * this.itemHeight;
const offsetHeight = visibleData.length * this.itemHeight;

this.setState({
visibleData,
startOffset,
offsetHeight,
});
};
handleScroll = (e) => {
const scrollTop = this.node.scrollTop;
const index = scrollTop / this.itemHeight;

this.startIndex = Math.floor(index);
this.endIndex =
this.startIndex + this.visibleCount + (index % 1 > 0 ? 1 : 0); //因为startIndex向下取整,所以endIndex需要根据情况适当+1

this.updateVisibleData();
};

renderListItem = (item) => {
return (
<div
key={item}
style={{
height: this.itemHeight,
boxSizing: "border-box",
borderBottom: "1px solid black",
}}
>
{item}
</div>
);
};

render() {
const { visibleData, startOffset, offsetHeight } = this.state;

return (
<div
style={{
position: "relative",
overflowY: "auto",
height: this.height,
border: "1px solid black",
}}
ref={(node) => (this.node = node)}
onScroll={this.handleScroll}
>
<div style={{ height: this.totalHeight }}>
<div
style={{
position: "absolute",
left: 0,
right: 0,
top: startOffset,
height: offsetHeight,
}}
>
{visibleData.map(this.renderListItem)}
</div>
</div>
</div>
);
}
}

export default VirtualList;

除了设置height来撑开滚动容器的高度,还可以使用paddingmargin,但需要额外计算 bottom 的距离

4. 测试

下面是 react 创建长度为 100000 的列表需要的时间,其中渲染和 js 脚本占了大部分时间,分别是 4.9s 和 1.5s,渲染久很容易理解,毕竟需要处理 100000 个 dom 元素,脚本久主要是因为 react 里虚拟 dom 处理的原因

再下面是 react 创建长度为 100000 的虚拟列表需要的时间,可以看到 0.5s 都不到就渲染好了

5. 白屏?

有好处就有坏处,虚拟列表在快速滑动时,onScroll事件响应的速度如果未能跟上滑动的速度,就会导致向下滑动话新的页面没能及时渲染出来,这就是白屏问题

可以尝试缓存的方式,在首尾额外渲染一部分的元素,有一定缓存距离,这样不那么容易出现白屏问题,但事实上,滑动速度要是快过缓存距离还是会有白屏现象,尤其是拖动滚动条,所以说并不能完全解决问题

上面图示里,订单列表尾部多渲染了 3 个订单作为缓存,只要一次滑动的距离不超过 3 个订单高度,即使事件响应和渲染速度较慢,也不会出现白屏问题(向上滑动同理,只要首部增加缓存即可)

6. 缓存

增加缓存只需要修改startIndexendIndex的逻辑即可

import React, { Component } from "react";

class VirtualList extends Component {
constructor(props) {
super(props);

this.list = new Array(100000).fill(null).map((_, index) => {
return index + 1;
});
this.cache = 5; //首尾缓存数量
... //省略
this.endIndex = this.startIndex + this.visibleCount + this.cache; //结束的index
}

...

handleScroll = e => {
const scrollTop = this.node.scrollTop;
const index = scrollTop / this.itemHeight;
const startIndex = Math.floor(index);
const offsetIndex = index % 1 > 0 ? 1 : 0;

if (startIndex >= this.cache) {
this.startIndex = startIndex - this.cache;
} else {
this.startIndex = 0;
}

if (
startIndex >=
this.list.length - (this.visibleCount + this.cache + offsetIndex)
) {
this.endIndex = this.list.length;
} else {
this.endIndex = startIndex + this.visibleCount + this.cache + offsetIndex;
}

this.updateVisibleData();
};

...

增加cache参数,修改handleScroll函数

7. 动态高度实现

上面的场景和实现都是以列表子元素固定高度来实现,那如果高度不固定该怎么办?

未完待续...

· 阅读需 4 分钟
youniaogu

浮点数计算问题

1. 前言

很多人都知道 js 里的 number 是以浮点数形式储存,遵循IEEE 754 标准,所以在进行小数计算时会有一些误差,其实浮点数计算问题不止 js,其他语言也会有类似情况

举个 🌰:

可以看到 0.1+0.2 的结果并不是 0.3,0.1+0.7 也不是 0.8,是什么问题导致这些误差的呢?

2. 问题原因

我们知道计算机只会进行二进制计算,所以十进制需要转换成二进制。但有一个问题,十进制小数部分如果转换成二进制,会存在无限循环的情况

比如:

0.3 转换为二进制

0.1 转换为二进制

二进制小数存在无限循环,而计算机的存储空间是有限的,不可能无限的存储下去,所以只能存储一部分长度,也就是说无限循环部分会被截断,存储起来的将会是一个近似值

IEEE 754 标准下小数部分长度最大为 52 位,如果第 53 位为 1 会进行四舍五入,其他情况会直接截断

也就是说 0.1(十进制 0.1) ≈ 00111111 10111001 10011001 10011001 10011001 10011001 10011001 10011010(64 精度下储存的二进制 0.1)

两个近似值相加得出来也会是一个近似值,自然会存在偏差

3. 小数的最小精度

最小精度指的是最后一位上的单位值,也就是说要想知道小数最小精度,就必须找出除 0 和负数外最小的数

按照标准,小数长度最多为 52 位,小数位与整数位相反,越往右位数代表的数越小,所以小数位能表示的最小数为:

00000000 00000000 00000000 00000000 00000000 00000000 0001

也就是 2^-52(2 的-52 次方),计算器里算出结果为

约为 2.22*10^-16,也就是说精确到小数点后 15 位,加上个位有效位数为 16 位也就是最小精度

4. 为什么 0.1+0.3 能得出 0.4?

上面说到近似值加近似值还是个近似值,那为什么 js 里大部分的小数加减乘除却能得到准确的数值,比如 0.1 + 0.3 === 0.4?

原因在于双精度浮点数是有最小精度的,只能精确到十进制小数点后 15 位,再往后就是不准确的了,而 js 用使用后 16 位进行四舍五入处理

  • 理论上:0.1 + 0.3 ≈ 0.4
  • 经过 16 位精度的四舍五入后:0.1 + 0.3 === 0.4

结果的误差在 16 位之外或者四舍五入后得到正确的结果,这就是大部分小数能够得出正确结果的原因

· 阅读需 3 分钟
youniaogu

宏任务和微任务

参考资料:Jake Archibald: 在循环 - JSConf.Asia,里面关于V8 的事件循环、主线程、宏任务、微任务讲的特别详细,如果有能力建议直接看视频

1. 事件循环

一个事件循环可以简单分为两部分,宏任务(tasks)和微任务(microtasks)

它们之间有着重要的规则,下面先放出结论:

  • 宏任务会优先进入任务栈并且执行,微任务放在微任务栈,只有在任务栈为空时进入
  • 当前宏任务执行完毕任务栈为空后,后续宏任务会放到下一个事件循环中执行

2. 举个 🌰

setTimeout(() => {
console.log(1);
}, 0);

Promise.resolve().then(() => {
console.log(2);
});

相信很多人都知道上面这道题的答案,问题是为什么 Promise 会比 setTimeout 快?

如果不知道宏微任务,只知道的 js 单线程,使用事件循环实现异步,很可能会以为 setTimeout 和 promise 都在 Event Loop 2 中执行,根据先后顺序,得出答案是1 2,以前我也是这么认为

事实上 promise 并不在 Event Loop 2 中,而是在 Event Loop 1 的微任务中

视频中有一道十分有趣的题:

当我们点击按钮,会输出什么?

下面是点击按钮后的事件循环图,在第一个监听函数里,先是把Microtasks 1放入了微任务栈,以及Listener 1宏任务放入任务栈里,完成任务后栈空,继而执行Microtasks 1,第二个监听函数类似,所以会依次输出Listener 1、Microtasks 1、Listener 2、Microtasks 2

第二种情况:

我们使用 js 去触发按钮点击事件,会输出什么?

下面同样是点击按钮后的事件循环图,但不同的是触发Script后只有Listener 1Listener 2执行完毕后Script才会执行完成,才能进行微任务的执行,所以执行的结果是Listener 1、Listener 2、Microtasks 1、Microtasks 2

· 阅读需 5 分钟
youniaogu

网格布局

MDN 里对 grid 的介绍和对比其他布局讲解的十分详细,想仔细学习的建议点进下面的链接

网格布局的基本概念

grid layout 和其它布局方法的联系

下面部分是我对链接里重要内容的截取,以及自己的理解

1. CSS 网格布局和弹性盒布局的区别

CSS 网格布局弹性盒布局的主要区别在于弹性盒布局是为一维布局服务的(沿横向或纵向的),而网格布局是为二维布局服务的(同时沿着横向和纵向)。这两个规格有一些相同的特性。如果你已经掌握如何使用弹性盒布局的话,你可能会想知道这些相似之处怎样在能帮助你掌握网格布局

CSS 网格在对 row 进行控制的同时,还能控制 column,而弹性盒只能控制 row 或 column,这也是弹性盒的一大不足

举个例子,实现 3 行 3 列布局,要求行不满 3 个时左对其:

这时 Flexbox 就会有个常见问题,怎样才能让最后一行单独左对其呢?

这个问题在 Flexbox 上并不是无解,有两种方法:

  • justify-content 使用 flex-start,计算各个子元素的 width、margin,让它刚好 3 个时换行

  • justify-content 使用 space-between,在末尾行不满时添加填充,让其按 3 个元素进行分割

grid 的话很简单:

  • 设定 grid-template-columns 属性为 repeat(3, 1fr)即可

可以看到,flex 虽然也能实现,但相比 grid 来说比较复杂

2. 使用 auto-fit 和 minmax 模拟 flex

grid-template-columns: repeat(auto-fit, minmax(100px, 1fr))相当于 flex: 1 0 100px;

grid-template-columns: repeat(auto-fit, minmax(0, 100px))相当于 flex: 0 1 100px;

grid-template-columns: repeat(auto-fit, minmax(0, 1fr))相当于 flex: 1 1 auto;

3. auto-fill 和 auto-fit 的区别是什么?

auto-fill 会保留后面的匿名格子,auto-fit 则不会

举个 🌰:

外层是个宽度为 450px 容器,column 使用 repeat(auto-fill, minmax(100px,1fr)),子元素初始宽度为 100px,一共三个,这时容器有多余的 150px

使用 auto-fill 的话,会多生成 1 个 100px 宽度的匿名格子,也就是红色标注的 4,再去分配剩下的 50px 宽度,计算下来每个子元素宽度为 112.5px

使用 auto-fit 的话,则会直接分割多余的 150px,这样每个子元素宽度为 150px

auto-fit 更贴近 flex 的特性

4. 我该用哪个?

当抉择该用网格还是弹性盒时,你可以问自己一个简单的问题:

  • 我只需要按行或者列控制布局?那就用弹性盒子
  • 我需要同时按行和列控制布局?那就用网格

除了满足布局需求外,我们还需要考虑浏览器兼容性

可以看到网格布局不兼容 IE,安卓 UC、Opera Mini 未知,Firefox 存在 bugcolumn 里 repeat()不能重复使用

如果项目对于兼容性要求没有那么严格,可以尝试使用网格布局,网页结构复杂的情况下,相比弹性盒布局能够用更少的元素完成布局(注意避开 Firefox 上的 bug,不要重复使用 repeat())

5. 盒对齐

弹性盒特性已经被加入到新规范盒Box Alignment Level 3。意味它们能被用在包括网格布局的其它规范中。它们未来也可能被用在其他的布局方法中

简单的说,就是你可以在网格布局里使用 align-item、align-content、align-self、justify-item、justify-content、justify-self 等属性,可以说网格布局的能力和多样性被大大加强

· 阅读需 2 分钟
youniaogu

防抖和节流

防抖和节流是 js 处理高频事件函数时经常会用到,通过降低函数的执行次数达到节约资源的目的,是前端常用的一种优化手段

  • 防抖:延迟事件函数,n 秒后执行,如果 n 秒内事件又被触发时重新计时
  • 节流:事件函数 n 秒内只能执行一次,执行完后倒计时开始

1. 防抖

function debounce(timescope) {
return function (fn) {
let timeout = null;

return function () {
if (timeout) {
clearTimeout(timeout);
}

timeout = setTimeout(() => {
fn.apply(this, arguments);
}, timescope);
};
};
}

2. 节流

function throttle(timescope) {
return function (fn) {
let cooldown = false;

return function () {
if (cooldown) {
return;
}

cooldown = true;
fn.apply(this.arguments);

setTimeout(() => {
cooldown = false;
}, timescope);
};
};
}

3. 比较

防抖和节流的区别在于:

  1. 是否延迟事件函数执行
  2. 重复触发事件函数是否重新倒计时

相同点:

  1. 都是为了降低事件函数执行的频率

4. 应用场景

  • input 框需要根据输入的内容实时查询结果,可以在输入停止一段时间后再进行查询,这是防抖
  • 点击按钮提交表单时,为了防止重复提交,一段时间内或者到提交完成前不允许提交,这是节流

· 阅读需 5 分钟
youniaogu

面试题汇总

1. 实现 New 关键字

function New(fn, ...args) {
let obj = {};

obj.__proto__ = fn.prototype;

const ret = fn.apply(this, args);

if (ret instanceof Object) {
return ret;
}

return obj;
}

2. Array.prototype.flat

flat 的作用是根据指定的数组深度摊平数组,可以使用 Infinity(无穷大)展开任意深度的数组

var newArray = arr.flat([depth]);

depth 深度是可选,默认为 1

下面是使用 concat 和 reduce 模拟的 flat

var arr1 = [1, 2, 3, [1, 2, 3, 4, [2, 3, 4]]];

function flatDeep(arr, d = 1) {
return d > 0
? arr.reduce(
(acc, val) =>
acc.concat(Array.isArray(val) ? flatDeep(val, d - 1) : val),
[]
)
: arr.slice();
}

flatDeep(arr1, Infinity);

3. 下面的代码打印什么内容,为什么?

var b = 10;
(function b() {
b = 20;
console.log(b);
})();
  • 具名并且自执行函数,函数名只读(类似常量),不可修改
  • 非严格模式下给常量命名静默失败,简单来说b = 20;相当于给函数 b 赋值,于是静默失败
  • 严格模式下给常量命名报错 TypeError

4. 二分查找

注意:二分查找是特定在有序数组里才能使用的查找算法

const a = [1, 3, 6, 7, 8, 11, 30, 60, 63, 90];

function binaySearch(array, data) {
const max = array.length - 1; // 9
const mid = Math.floor(max / 2); // 4

if (max < 0) {
return -1;
}

if (array[mid] === data) {
return data;
}

if (array[mid] > data) {
return binaySearch(array.slice(0, mid), data);
} else {
return binaySearch(array.slice(mid + 1, max + 1), data);
}
}

a.forEach((data) => console.log(binaySearch(a, data)));

5. 冒泡排序

Array.prototype.bubbleSort = function () {
let isSorted = false;

for (let i = 0; i < this.length - 1; i++) {
let haveSort = false;

if (isSorted) {
return;
}

for (let l = 0; l < this.length - 1 - i; l++) {
if (this[l] > this[l + 1]) {
const bigger = this[l];
this[l] = this[l + 1];
this[l + 1] = bigger;
haveSort = true;
}
}

if (!haveSort) {
isSorted = true;
}
}
};

使用haveSort标记一次冒泡中是否有互换,如果没有,表明列表已经是排序完成,结束冒泡

6. 插入排序

插入排序建议结合下图理解

20.gif

将选取的数插入到已经排序好的数组里

Array.prototype.insertSort = function () {
for (let i = 1; i < this.length; i++) {
const current = this[i];

let l = i - 1;
while (l >= 0 && current < this[l]) {
this[l + 1] = this[l];
l--;
}
this[l + 1] = current;
}
};

7. 选择排序

每次遍历选择出最小的那个放在最前面

Array.prototype.selectSort = function () {
for (let i = 0; i < this.length - 1; i++) {
let min = i;

for (let l = i + 1; l < this.length; l++) {
if (this[l] < this[min]) {
min = l;
}
}

if (min !== i) {
const bigger = this[i];
this[i] = this[min];
this[min] = bigger;
}
}
};

8. 快速排序

在数组中选择一个作为基准,将小于的放在左边,大于的放在右边,再分别对左边和右边进行同样操作(需要用到递归)

Array.prototype.quickSort = function () {
if (this.length <= 1) {
return this;
}

let mid = this[0],
left = [],
right = [];

for (let i = 1; i < this.length; i++) {
if (this[i] <= mid) {
left.push(this[i]);
} else {
right.push(this[i]);
}
}

return [].concat(left.quickSort()).concat(mid).concat(right.quickSort());
};

9. 实现 call,apply,bind

Function.prototype.CALL = function (context, ...args) {
context.fn = this;
context.fn(...args);

delete context.fn;
};
Function.prototype.APPLY = function (context, args) {
context.fn = this;
context.fn(...args);

delete context.fn;
};
Function.prototype.BIND = function (context, ...args) {
const self = this;

function RETURNFN(...bindArgs) {
let contextOrThis = context;
if (this instanceof RETURNFN) {
contextOrThis = this;
}

contextOrThis.fn = self;
contextOrThis.fn(...args, ...bindArgs);
delete contextOrThis.fn;
}

RETURNFN.prototype = this.prototype;
return RETURNFN;
};

· 阅读需 9 分钟
youniaogu

react-router-dom 简单讲解

示例 demo:github

本文代码比较粗糙,有能力建议自行看源码

1. 前言

react-router-dom 是基于history库开发的 react 路由管理库,这里主要简单讲解 react-router-dom 的 api,理解他们的作用和流程,history部分将不深入讨论

下面的实现和讲解主要基于 class,无 hook 部分

2. api 介绍

react-router-dom 主要有BrowserRouterHashRouterRouterSwitchRouteLinkRedirectwithRouter 这几个常用 api,其中 BrowserRouterHashRouterRouter封装后的 api

  • Router:接收history实例化后的对象,绑定history监听函数,并通过context传递下来
  • BrowserRouter | HashRouter:分别基于createBrowserHistorycreateHashHistory封装后的Router
  • Switch:会按顺序依次匹配,只会将第一个匹配到的渲染出去
  • Route:根据设定的 path 匹配当前的 pathname,判断是否渲染传递的 component 或者 children
  • Link:类似于 a 标签,点击后进行路由跳转
  • Redirect:当组件渲染时进行路由跳转
  • withRouter:一个高阶组件,为组件提供history控制 api

官方文档里建议 Switch 的 children 应当为 Route 或 Redirect 组件,但实际其它组件或元素也是可行的,后面会说明原因

其中BrowserRouterHashRouterLink来自 react-router-dom,其它 api 都来自 react-router,react-router-dom 只是进行了转接而已

只所以分成 react-router-dom 和 react-router,是因为 react-router 不止服务于web,还有native环境,通用的 api 放在了 react-router,而基于 web 特定的 api 则放在了 react-router-dom

下面会贴出自己简化后的代码,来理解 api 的实现原理

3. Router

  1. 绑定history监听函数,当路由变化时修改 state
  2. locationhistorymatch通过context传递下去

match 是当 Route 或组件未设置 path 时提供的默认值

import React, { Component, createContext } from "react";

const RouterContext = createContext({});

export class Router extends Component {
constructor(props) {
super(props);

this.state = {
location: props.history.location,
};
this._isMounted = false;

this.uninstall = props.history.listen(({ location }) => {
if (this._isMounted) {
this.setState({
location,
});
}
});
}

componentDidMount() {
this._isMounted = true;
}
componentWillUnmount() {
this.uninstall();
this._isMounted = false;
}

render() {
const { location } = this.state;
const { history, children } = this.props;
const value = {
location,
history,
match: {
path: "/",
url: "/",
params: {},
isExact: location.pathname === "/",
},
};

return (
<RouterContext.Provider value={value}>{children}</RouterContext.Provider>
);
}
}

设置_isMounted标记避免组件在尚未加载完成的情况下使用 setState

未加载完成的情况下使用 setState,严格模式下会报错,正常模式下会失败无提示

4. BrowserRouter | HashRouter

  1. 使用createBrowserHistorycreateHashHistory创建实例
  2. 通过 props 传递给 Router
...
import { createBrowserHistory, createHashHistory } from "history";

export class BrowserRouter extends Component {
constructor(props) {
super(props);

this.history = createBrowserHistory(props);
}

render() {
return <Router history={this.history} children={this.props.children} />;
}
}

export class HashRouter extends Component {
constructor(props) {
super(props);

this.history = createHashHistory(props);
}

render() {
return <Router history={this.history} children={this.props.children} />;
}
}

5. Switch

Switch里会像数组遍历那样遍历 children,依次将 props 上面的 path 或者 from 和 context 里的 location.pathname 进行比对,将第一个匹配成功的 children 渲染出来

而当组件上不存在 path 和 from 时,将会使用 context 上的 match 进行判断,下面简化代码里有判断的逻辑,从结果上来说,如果一个组件在Switch未设置 path 或 from,它将会是默认匹配成功,也就是匹配任意 pathname

Switch 只关心 props 上的 path 和 from,只要组件设置了 path 或 from 属性,就能进行匹配,未设置的情况会匹配任意 pathname,这就是为什么我们能在 Switch 中使用 Route、Redirct 以外组件的原因

大家可以简单测试一下,在Switch最上面加上:

<div path="math pathname">i am not Route or Redirct</div>

如果 path 命中,将会显示i am not Route or Redirct,而不是下面的组件

<div>i am not Route or Redirct</div>

如果不设置 path,将会匹配任意 pathname

...
export class Switch extends Component {
render() {
return (
<RouterContext.Consumer>
{context => {
const { math, location } = context;

let isMatch = false,
element;
React.Children.forEach(this.props.children, child => {
if (!isMatch) {
element = child;

const path = child.props.path || child.props.from

isMatch = path ? location.pathname === path : math;
}
});

if (element && isMatch) {
return cloneElement(element, { location, computedMatch: match });
}
return null;
}}
</RouterContext.Consumer>
);
}
}

Switch里使用了 React.Children.forEach 去遍历 children,除 forEach 外还有 map、count、only、toArray 一共五个 api

我们知道 forEach 遍历是不能中断的,而 find、some 等方法在满足条件后会自动退出遍历,性能上会比 forEach 更好,为什么这里要用 forEach?

3.png

源码里注释解释了为什么不用 toArray().find 而是 forEach,原因在于 toArray 需要给每个 children 添加 key,当多个 Route 使用同一个 component 的时候,会出现同时触发一个组件的 unmount 和 remount 的情况,他们不希望这样

5. Route

Route的工作很单一,根据是否匹配来判断是否渲染组件

在未设置 path 的情况下,Route 会直接渲染组件

...
export class Route extends Component {
render() {
return (
<RouterContext.Consumer>
{context => {
const { match, path, children } = this.props;
const { location } = context;

const isMatch = match ? match : path ? location.pathname === path : context.match;

if (isMatch) {
return children;
} else {
return null;
}
}}
</RouterContext.Consumer>
);
}
}

下面代码虽然能实现相应的功能,但与源码相差较大,源码里关于 ref 的转发做了比较多的操作,建议直接参考源码的实现

...
export class Link extends Component {
handleClick = method => {
return event => {
event.preventDefault();

method(this.props.to);
};
};

render() {
const { to, replace, children, ...otherProps } = this.props;

return (
<RouterContext.Consumer>
{context => {
const { history } = context;
const method = replace ? history.replace : history.push;

return (
<a href={to} onClick={this.handleClick(method)}>
{children}
</a>
);
}}
</RouterContext.Consumer>
);
}
}

7. Redirect

官方没有用 class 实现Redirect,而是设计为 function 类型,返回Lifecycle组件,使用Lifecycle的生命周期来触发跳转,并没有直接跳转,而是稍微绕了一下

这么设计的原因,个人猜测是为了代码统一使用 Consumer 的形式,而 constructor 里不能使用 Consumer,导致 Redirct 无法在 didmount 之前获取 context,所以改成 function 形式。

下面是我将Redirect作为一个组件来实现的例子:

...
export class Redirect extends Component {
static contextType = RouterContext;

constructor(props, context) {
super(props, context);

const { push = false } = props;
const { history } = context;

this.method = push ? history.push : history.replace;
}

componentDidMount() {
this.method(this.props.to);
}
componentDidUpdate(prevProps) {
if (prevProps.to !== this.props.to) {
this.method(this.props.to);
}
}

render() {
return null;
}
}

8. withRouter

为了满足其他组件能够手动的进行路由跳转,需要将history对象暴露出来

使用 hoc 的时候,传递 props 和 ref 同时还需要复制静态方法,参考:hoist-non-react-statics

...
export function withRouter(WrapComponent) {
class WrappedComponent extends Component {
render() {
return (
<RouterContext.Consumer>
{context => {
return <WrapComponent {...this.props} {...context} />;
}}
</RouterContext.Consumer>
);
}
}

return hoistStatics(WrappedComponent, WrapComponent);
}

8. 写在最后

在看了源码并简单实现后,让我惊讶的是 Link 比想象中要复杂很多,以及 pathname 的匹配方法,还有 Switch 的匹配方式

希望这篇文章能对你有所帮助,谢谢

· 阅读需 3 分钟
youniaogu

前端版本号定义

版本号结构:

主版本号.次版本号.修订号

版本号能够表明版本的重要性和破坏性,在开发过程中有着很重要的意义

1. 版本号定义

版本号定义参考语义化版本 2.0.0,npm 语义化版本控制也是以它为基准的

里面规定版本号必须遵守以下规则:

  • 主版本号:当你做了不兼容的 API 修改,
  • 次版本号:当你做了向下兼容的功能性新增,
  • 修订号:当你做了向下兼容的问题修正。

2. 区别

在前端的系统里面通常并没有 api 等特性,所以上面的规则并不完全适用,所以在规则方面要做一些变更

在版本号的修改上面,一百个人有一个种方法,下面是我个人的一些理解:

  • 主版本号:当项目结构发生改变、推出很重要功能或者是多个次版本号更替后变更
  • 次版本号:当新增功能并且项目结构无多大修改时新增
  • 修订号:当你修改了 bug 或者做了一些小的样式或逻辑调整

规则是死的,人是活得,只有适合才是最好的

2. 如何理解 dependencies 内的版本号

  • ^:从左边数第一个不等于 0 的版本号不允许改变,右边的可以匹配到最新的版本
  • ~:如果存在次版本号,则只能匹配到修订号的最新版。如果不存在次版本号(指的是~1,而不是~1.0 或~1.0.0,因为 0 也算是次版本号),可以匹配到次版本号最新版本
  • latest:匹配最新版本号
  • *:匹配最新版本号
  • 1.2.3:指定版本号
  • -:匹配两个版本号的区间
  • > >= < <= =:运算符匹配

· 阅读需 2 分钟
youniaogu

redux-saga 中取消 fetch 请求

在阅读本篇文章前,建议先阅读redux-saga 文档,理解基础用法

redux-saga 可以使用cancel取消任务,但并不能取消已经发起的请求。而xhrfetch都提供了取消请求的方法,所以我们可以通过封装fetch,在取消任务的同时取消请求。

1. 关于 fetch 请求的取消

参考AbortController

2. 原理

yield 关键字在被取消时候会抛出错误,所以只需要用generator函数包住fetch请求,在里面通过try catch获取错误,最后取消请求

封装fetch

//fetch.js
function* fetchData(options) {
const controller = new AbortController();

try {
return yield fetchDataWithoutCancel({
...options,
signal: controller.signal,
});
} finally {
controller.abort();
}
}

3. 实际运用

takeLatest结合使用:
function* cancelFetchSaga() {
yield takeLatest('CANCEL_FETCH', function*() {
const data = yield call(fetchData, {
url: 'http://httpstat.us/200?sleep=1000',
}),

console.log(data);
});
}

每当执行cancelFetch任务,上一个未完成任务就会被取消,同时请求也会被取消

使用racedelay实现超时取消请求:
function* cancelFetchSaga() {
yield takeLatest("CANCEL_FETCH", function* () {
const { data, timeout } = yield race({
data: call(fetchData, {
url: "http://httpstat.us/200?sleep=2000",
}),
timeout: delay(1000),
});

if (timeout) {
console.log("fetch canceled!");
} else {
console.log(data);
}
});
}

1s 后请求会被取消

4. 测试用工具

  1. httpstat,可以自定义返回延迟

  2. chrome network 里设置 throtting

pic2

· 阅读需 4 分钟
youniaogu

为什么用 fetch?

下面罗列了网上经常讨论,关于 fetch 的缺点,以及目前的解决方案

1. 没有自动 stringify

封装抽象方法,很简单

2. 接口抛出 4xx、5xx 类错误会归到成功里,只有网络错误才会抛出 reject

400 ≤ status < 600 情况下,可以在 then 里通过 res.ok 判断是否为错误情况,在根据 status 进行处理,而网络请求错误则在 catch 里处理

3. 缺少默认配置

需要自己封装默认的headerscredentials,不能开箱即用

4. 兼容性

IE 不兼容

在 fetch 刚出的时候很多人吐槽它的兼容性,但到 2020 年,caniuse上显示全球浏览器兼容性覆盖率达到了 95.11%,可以放心使用

如果需要兼容 IE,可以考虑使用whatwg-fetch,这是个 polyfill 库,原理是使用 XMLHttpRequest 来模拟 fetch

5. 请求超时、取消请求

可以使用AbortController实现相关功能

6. 下载进度

可以使用ReadableStream实现相关功能

7. 上传进度

暂无解决方案

优点
  1. 语法上比较简单简洁
  2. 基于浏览器提供的原生方法,使用promise实现
  3. 属于原生 js 系列,不需要引入第三方库
  4. 属于底层 api,能够自行封装和控制
对比 axios

axios 是应用于 web 和 node 的 http 请求第三方库,使用promise,web 基于XMLHttpRequest实现,node 基于http模块实现

axios 功能十分强大,基本上 fetch 能做到的 axios 都能做到,甚至能做 fetch 做不到的事情,并且 api 简单,在兼容性方面,axios 最低兼容至 ie11

可以说 axios 是全面碾压 fetch,那么还有什么理由坚持 fetch 呢?请继续往下看

为什么坚持 fetch
  1. fetch 作为底层 api,目前除了上传进度无解以外,其他都有对应的处理方法,只要抽象封装后完全能够应对大部分的场景

  2. 高度封装的库确实能够很方便的满足需求,但这中间封装的过程和原理却无从得知,会用仅仅是达到 20%而已

  3. 大部分情况下,http 请求并不需要特别复杂的功能,不需要引入额外库的 fetch 会是更好的选择

  4. 自己封装底层 api,能够更贴合实际业务中的需求,更加的理解里面的流程,出问题时能够更好的解决