Giter VIP home page Giter VIP logo

dream's Introduction

关于仓库

这是一个写博客的地方,当然除了写博客,我还会在这里记录我平时学习的一些内容,比如刷的 Leetcode、学的 Mooc、书籍笔记以及写的一些小玩具,有兴趣的可以 Star 和 Watch。

作者个人信息

微信扫码关注公众号,订阅更多精彩内容 加笔者微信进群与大厂大佬讨论技术

博客文章相关

Github 的阅读体验稍显逊色,你可以选择在我的网站上阅读。

新发

从零开始造轮子

Vue 3 源码解析

React 源码解析

需要注意一点:文章的风格分为了两部分。 从调度原理开始,笔者抛弃了单纯讲源码的方式。而是将重点放在了原理上,尽可能地脱离源码讲原理,这种方式能更快更好地让读者学习到知识。

重学 JS 系列

React 进阶系列

提升工作效率

JS

框架 相关

面试

杂谈

我的公众号及群

觉得内容有帮助可以关注下我的公众号 「前端真好玩」或者加入前端进阶群一起成长。

dream's People

Contributors

kiesun avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

dream's Issues

Promise 你真的用明白了么?

Promise 关于 API 这块大家应该都能熟练使用,但是和微任务相关的你可能还存在知识盲区。

前置知识

在开始正文前,我们先把本文涉及到的一些内容提前定个基调。

Promise 哪些 API 涉及了微任务?

Promise 中只有涉及到状态变更后才需要被执行的回调才算是微任务,比如说 thencatchfinally ,其他所有的代码执行都是宏任务(同步执行)。

上图中蓝色为同步执行,黄色为异步执行(丢到微任务队列中)。

这些微任务何时被加入微任务队列?

这个问题我们根据 ecma 规范来看:

  • 如果此时 Promise 状态为 pending,那么成功或失败的回调会分别被加入至 [[PromiseFulfillReactions]][[PromiseRejectReactions]] 中。如果你看过手写 Promise 的代码的话,应该能发现有两个数组存储这些回调函数。

  • 如果此时 Promise 状态为非 pending 时,回调会成为 Promise Jobs,也就是微任务。

了解完以上知识后,正片开始。

同一个 then,不同的微任务执行

初级

Promise.resolve()
  .then(() => {
    console.log("then1");
    Promise.resolve().then(() => {
      console.log("then1-1");
    });
  })
  .then(() => {
    console.log("then2");
  });

以上代码大家应该都能得出正确的答案:then1 → then1-1 → then2

虽然 then 是同步执行,并且状态也已经变更。但这并不代表每次遇到 then 时我们都需要把它的回调丢入微任务队列中,而是等待 then 的回调执行完毕后再根据情况执行对应操作。

基于此,我们可以得出第一个结论:链式调用中,只有前一个 then 的回调执行完毕后,跟着的 then 中的回调才会被加入至微任务队列。

中级

大家都知道了 Promise resolve 后,跟着的 then 中的回调会马上进入微任务队列。

那么以下代码你认为的输出会是什么?

let p = Promise.resolve();

p.then(() => {
  console.log("then1");
  Promise.resolve().then(() => {
    console.log("then1-1");
  });
}).then(() => {
  console.log("then1-2");
});

p.then(() => {
  console.log("then2");
}); 

按照一开始的认知我们不难得出 then2 会在 then1-1 后输出,但是实际情况却是相反的。

基于此我们得出第二个结论:每个链式调用的开端会首先依次进入微任务队列。

接下来我们换个写法:

let p = Promise.resolve().then(() => {
  console.log("then1");
  Promise.resolve().then(() => {
    console.log("then1-1");
  });
}).then(() => {
  console.log("then2");
});

p.then(() => {
  console.log("then3");
});

上述代码其实有个陷阱,then 每次都会返回一个新的 Promise,此时的 p 已经不是 Promise.resolve() 生成的,而是最后一个 then 生成的,因此 then3 应该是在 then2 后打印出来的。

顺便我们也可以把之前得出的结论优化为:同一个 Promise 的每个链式调用的开端会首先依次进入微任务队列。

高级

以下大家可以猜猜 then1-2 会在何时打印出来?

Promise.resolve()
  .then(() => {
    console.log("then1");
    Promise.resolve()
      .then(() => {
        console.log("then1-1");
        return 1;
      })
      .then(() => {
        console.log("then1-2");
      });
  })
  .then(() => {
    console.log("then2");
  })
  .then(() => {
    console.log("then3");
  })
  .then(() => {
    console.log("then4");
  });

这题肯定是简单的,记住第一个结论就能得出答案,以下是解析:

  • 第一次 resolve 后第一个 then 的回调进入微任务队列并执行,打印 then1

  • 第二次 resolve 后内部第一个 then 的回调进入微任务队列,此时外部第一个 then 的回调全部执行完毕,需要将外部的第二个 then 回调也插入微任务队列。

  • 执行微任务,打印 then1-1then2,然后分别再将之后 then 中的回调插入微任务队列

  • 执行微任务,打印 then1-2then3 ,之后的内容就不一一说明了

接下来我们把 return 1 修改一下,结果可就大不相同啦:

Promise.resolve()
  .then(() => {
    console.log("then1");
    Promise.resolve()
      .then(() => {
        console.log("then1-1");
        return Promise.resolve();
      })
      .then(() => {
        console.log("then1-2");
      });
  })
  .then(() => {
    console.log("then2");
  })
  .then(() => {
    console.log("then3");
  })
  .then(() => {
    console.log("then4");
  });

当我们 return Promise.resolve() 时,你猜猜 then1-2 会何时打印了?

答案是最后一个才被打印出来。

为什么在 then 中分别 return 不同的东西,微任务的执行顺序竟有如此大的变化?以下是笔者的解析。

PS:then** 返回一个新的 Promise,并且会用这个 Promise 去 resolve 返回值,这个概念需要大家先了解一下。**

根据 Promise A+ 规范

根据规范 2.3.2,如果 resolve 了一个 Promise,需要为其加上一个 thenresolve

if (x instanceof MyPromise) {
  if (x.currentState === PENDING) {
  } else {
    x.then(resolve, reject);
  }
  return;
}

上述代码节选自手写 Promise 实现。

那么根据 A+ 规范来说,如果我们在 then 中返回了 Promise.resolve 的话会多入队一次微任务,但是这个结论还是与实际不符的,因此我们还需要寻找其他权威的文档。

根据 ECMA - 262 规范

根据规范 25.6.1.3.2,当 Promise resolve 了一个 Promise 时,会产生一个NewPromiseResolveThenableJob,这是属于 Promise Jobs 中的一种,也就是微任务。

This Job uses the supplied thenable and its then method to resolve the given promise. This process must take place as a Job to ensure that the evaluation of the then method occurs after evaluation of any surrounding code has completed.

并且该 Jobs 还会调用一次 then 函数来 resolve Promise,这也就又生成了一次微任务。

这就是为什么会触发两次微任务的来源。

最后

文章到这里就完结了,大家有什么疑问都可以在评论区提出。

推荐关注我的微信公众号【前端真好玩】,工作日推送高质量文章。

笔者就职于酷家乐,家装设计行业独角兽。一流的可视化、前端技术团队,有兴趣的可以简历投递至 [email protected]

本文使用 mdnice 排版

- END -

重学 JS 系列:聊聊继承

这是重学 JS 系列的第二篇文章,写这个系列的初衷也是为了夯实自己的 JS 基础。既然是重学,肯定不会从零开始介绍一个知识点,如有遇到不会的内容请自行查找资料。

原型

继承得靠原型来实现,当然原型不是这篇文章的重点,我们来复习一下即可。

其实原型的概念很简单:

  • 所有对象都有一个属性 __proto__ 指向一个对象,也就是原型
  • 每个对象的原型都可以通过 constructor 找到构造函数,构造函数也可以通过 prototype 找到原型
  • 所有函数都可以通过 __proto__ 找到 Function 对象
  • 所有对象都可以通过 __proto__ 找到 Object 对象
  • 对象之间通过 __proto__ 连接起来,这样称之为原型链。当前对象上不存在的属性可以通过原型链一层层往上查找,直到顶层 Object 对象

其实原型中最重要的内容就是这些了,完全没有必要去看那些长篇大论什么是原型的文章,初学者会越看越迷糊。

当然如果你想了解更多原型的深入内容,可以阅读我 之前写的文章

ES5 实现继承

ES5 实现继承总的来说就两种办法,之前写过这方面的内容,就直接复制来用了。

总的来说这部分的内容我觉得在当下更多的是为了应付面试吧。

组合继承

组合继承是最常用的继承方式,

function Parent(value) {
	this.val = value
}
Parent.prototype.getValue = function() {
	console.log(this.val)
}
function Child(value) {
	Parent.call(this, value)
}
Child.prototype = new Parent()

const child = new Child(1)

child.getValue() // 1
child instanceof Parent // true

以上继承的方式核心是在子类的构造函数中通过 Parent.call(this) 继承父类的属性,然后改变子类的原型为 new Parent() 来继承父类的函数。

这种继承方式优点在于构造函数可以传参,不会与父类引用属性共享,可以复用父类的函数,但是也存在一个缺点就是在继承父类函数的时候调用了父类构造函数,导致子类的原型上多了不需要的父类属性,存在内存上的浪费。

寄生组合继承

这种继承方式对组合继承进行了优化,组合继承缺点在于继承父类函数时调用了构造函数,我们只需要优化掉这点就行了。

function Parent(value) {
	this.val = value
}
Parent.prototype.getValue = function() {
	console.log(this.val)
}

function Child(value) {
	Parent.call(this, value)
}
Child.prototype = Object.create(Parent.prototype, {
	constructor: {
		value: Child,
		enumerable: false,
		writable: true,
		configurable: true
	}
})

const child = new Child(1)

child.getValue() // 1
child instanceof Parent // true

以上继承实现的核心就是将父类的原型赋值给了子类,并且将构造函数设置为子类,这样既解决了无用的父类属性问题,还能正确的找到子类的构造函数。

Babel 如何编译 ES6 Class 的

为什么在前文说 ES5 实现继承更多的是应付面试呢,因为我们现在可以直接使用 class 来实现继承。

但是 class 毕竟是 ES6 的东西,为了能更好地兼容浏览器,我们通常都会通过 Babel 去编译 ES6 的代码。接下来我们就来了解下通过 Babel 编译后的代码是怎么样的。

function _possibleConstructorReturn (self, call) { 
		// ...
		return call && (typeof call === 'object' || typeof call === 'function') ? call : self; 
}

function _inherits (subClass, superClass) { 
		// ...
		subClass.prototype = Object.create(superClass && superClass.prototype, { 
				constructor: { 
						value: subClass, 
						enumerable: false, 
						writable: true, 
						configurable: true 
				} 
		}); 
		if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; 
}


var Parent = function Parent () {
		// 验证是否是 Parent 构造出来的 this
		_classCallCheck(this, Parent);
};

var Child = (function (_Parent) {
		_inherits(Child, _Parent);

		function Child () {
				_classCallCheck(this, Child);
		
				return _possibleConstructorReturn(this, (Child.__proto__ || Object.getPrototypeOf(Child)).apply(this, arguments));
		}

		return Child;
}(Parent));

以上代码就是编译出来的部分代码,隐去了一些非核心代码,我们先来阅读 _inherits 函数。

设置子类原型部分的代码其实和寄生组合继承是一模一样的,侧面也说明了这种实现方式是最好的。但是这部分的代码多了一句 Object.setPrototypeOf(subClass, superClass),其实这句代码的作用是为了继承到父类的静态方法,之前我们实现的两种继承方法都是没有这个功能的。

然后 Child 构造函数这块的代码也基本和之前的实现方式类似。所以总的来说 Babel 实现继承的方式还是寄生组合继承,无非多实现了一步继承父类的静态方法。

继承存在的问题

讲了这么些如何实现继承,现在我们来考虑下继承是否是一个好的选择?

总的来说,我个人不怎么喜欢继承,原因呢就一个个来说。

我们先看代码。假如说我们现在要描述几辆不同品牌的车,车必然是一个父类,然后各个品牌的车都分别是一个子类。

class Car {
		constructor (brand) {
				this.brand = brand
		}
		wheel () {
				return '4 个轮子'
		}
		drvie () {
				return '车可以开驾驶'
		}
		addOil () {
				return '车可以加油'
		}
}
Class OtherCar extends Car {}

这部分代码在当下看着没啥毛病,实现了车的几个基本功能,我们也可以通过子类去扩展出各种车。

但是现在出现了新能源车,新能源车是不需要加油的。当然除了加油这个功能不需要,其他几个车的基本功能还是需要的。

如果新能源车直接继承车这个父类的话,就出现了第一个问题 ,大猩猩与香蕉问题。这个问题的意思是我们现在只需要一根香蕉,但是却得到了握着香蕉的大猩猩,大猩猩其实我们是不需要的,但是父类还是强塞给了子类。继承虽然可以重写父类的方法,但是并不能选择需要继承什么东西。

另外单个父类很难描述清楚所有场景,这就导致我们可能又需要新增几个不同的父类去描述更多的场景。随着不断的扩展,代码势必会存在重复,这也是继承存在的问题之一。

除了以上两个问题,继承还存在强耦合的情况,不管怎么样子类都会和它的父类耦合在一起。

既然出现了强耦合,那么这个架构必定是脆弱的。一旦我们的父类设计的有问题,就会对维护造成很大的影响。因为所有的子类都和父类耦合在一起了,假如更改父类中的任何东西,都可能会导致需要更改所有的子类。

如何解决继承的问题

继承更多的是去描述一个东西是什么,描述的不好就会出现各种各样的问题,那么我们是否有办法去解决这些问题呢?答案是组合。

什么是组合呢?你可以把这个概念想成是,你拥有各种各样的零件,可以通过这些零件去造出各种各样的产品,组合更多的是去描述一个东西能干什么。

现在我们把之前那个车的案例通过组合的方式来实现。

function wheel() {
	return "4 个轮子";
}
function drvie() {
	return "车可以开驾驶";
}
function addOil() {
	return "车可以加油";
}
// 油车
const car = compose(wheel, drvie, addOil)
// 新能源车
const energyCar = compose(wheel, drive)

从上述伪代码中想必你也发现了组合比继承好的地方。无论你想描述任何东西,都可以通过几个函数组合起来的方式去实现。代码很干净,也很利于复用。

最后

其实这篇文章的主旨还是后面两小节的内容,如果你还有什么疑问欢迎在评论区与我互动。

我所有的系列文章都会在我的 Github 中最先更新,有兴趣的可以关注下。今年主要会着重写以下三个专栏

  • 重学 JS
  • React 进阶
  • 重写组件

最后,觉得内容有帮助可以关注下我的公众号 「前端真好玩」咯,会有很多好东西等着你。

几道高级前端面试题解析

为什么 0.1 + 0.2 != 0.3,请详述理由

因为 JS 采用 IEEE 754 双精度版本(64位),并且只要采用 IEEE 754 的语言都有该问题。

我们都知道计算机表示十进制是采用二进制表示的,所以 0.1 在二进制表示为

// (0011) 表示循环
0.1 = 2^-4 * 1.10011(0011)

那么如何得到这个二进制的呢,我们可以来演算下

小数算二进制和整数不同。乘法计算时,只计算小数位,整数位用作每一位的二进制,并且得到的第一位为最高位。所以我们得出 0.1 = 2^-4 * 1.10011(0011),那么 0.2 的演算也基本如上所示,只需要去掉第一步乘法,所以得出 0.2 = 2^-3 * 1.10011(0011)

回来继续说 IEEE 754 双精度。六十四位中符号位占一位,整数位占十一位,其余五十二位都为小数位。因为 0.10.2 都是无限循环的二进制了,所以在小数位末尾处需要判断是否进位(就和十进制的四舍五入一样)。

所以 2^-4 * 1.10011...001 进位后就变成了 2^-4 * 1.10011(0011 * 12次)010 。那么把这两个二进制加起来会得出 2^-2 * 1.0011(0011 * 11次)0100 , 这个值算成十进制就是 0.30000000000000004

下面说一下原生解决办法,如下代码所示

parseFloat((0.1 + 0.2).toFixed(10))

10 个 Ajax 同时发起请求,全部返回展示结果,并且至多允许三次失败,说出设计思路

这个问题相信很多人会第一时间想到 Promise.all ,但是这个函数有一个局限在于如果失败一次就返回了,直接这样实现会有点问题,需要变通下。以下是两种实现思路

// 以下是不完整代码,着重于思路 非 Promise 写法
let successCount = 0
let errorCount = 0
let datas = []
ajax(url, (res) => {
		 if (success) {
				 success++
				 if (success + errorCount === 10) {
						 console.log(datas)
				 } else {
						 datas.push(res.data)
				 }
		 } else {
				 errorCount++
				 if (errorCount > 3) {
						// 失败次数大于3次就应该报错了
						 throw Error('失败三次')
				 }
		 }
})
// Promise 写法
let errorCount = 0
let p = new Promise((resolve, reject) => {
		if (success) {
				 resolve(res.data)
		 } else {
				 errorCount++
				 if (errorCount > 3) {
						// 失败次数大于3次就应该报错了
						reject(error)
				 } else {
						 resolve(error)
				 }
		 }
})
Promise.all([p]).then(v => {
	console.log(v);
});

基于 Localstorage 设计一个 1M 的缓存系统,需要实现缓存淘汰机制

设计思路如下:

  • 存储的每个对象需要添加两个属性:分别是过期时间和存储时间。
  • 利用一个属性保存系统中目前所占空间大小,每次存储都增加该属性。当该属性值大于 1M 时,需要按照时间排序系统中的数据,删除一定量的数据保证能够存储下目前需要存储的数据。
  • 每次取数据时,需要判断该缓存数据是否过期,如果过期就删除。

以下是代码实现,实现了思路,但是可能会存在 Bug,但是这种设计题一般是给出设计思路和部分代码,不会需要写出一个无问题的代码

class Store {
	constructor() {
		let store = localStorage.getItem('cache')
		if (!store) {
			store = {
				maxSize: 1024 * 1024,
				size: 0
			}
			this.store = store
		} else {
			this.store = JSON.parse(store)
		}
	}
	set(key, value, expire) {
		this.store[key] = {
			date: Date.now(),
			expire,
			value
		}
		let size = this.sizeOf(JSON.stringify(this.store[key]))
		if (this.store.maxSize < size + this.store.size) {
			console.log('超了-----------');
			var keys = Object.keys(this.store);
			// 时间排序
			keys = keys.sort((a, b) => {
				let item1 = this.store[a], item2 = this.store[b];
				return item2.date - item1.date;
			});
			while (size + this.store.size > this.store.maxSize) {
				let index = keys[keys.length - 1]
				this.store.size -= this.sizeOf(JSON.stringify(this.store[index]))
				delete this.store[index]
			}
		}
		this.store.size += size

		localStorage.setItem('cache', JSON.stringify(this.store))
	}
	get(key) {
		let d = this.store[key]
		if (!d) {
			console.log('找不到该属性');
			return
		}
		if (d.expire > Date.now) {
			console.log('过期删除');
			delete this.store[key]
			localStorage.setItem('cache', JSON.stringify(this.store))
		} else {
			return d.value
		}
	}
	sizeOf(str, charset) {
		var total = 0,
			charCode,
			i,
			len;
		charset = charset ? charset.toLowerCase() : '';
		if (charset === 'utf-16' || charset === 'utf16') {
			for (i = 0, len = str.length; i < len; i++) {
				charCode = str.charCodeAt(i);
				if (charCode <= 0xffff) {
					total += 2;
				} else {
					total += 4;
				}
			}
		} else {
			for (i = 0, len = str.length; i < len; i++) {
				charCode = str.charCodeAt(i);
				if (charCode <= 0x007f) {
					total += 1;
				} else if (charCode <= 0x07ff) {
					total += 2;
				} else if (charCode <= 0xffff) {
					total += 3;
				} else {
					total += 4;
				}
			}
		}
		return total;
	}
}

详细说明 Event loop

众所周知 JS 是门非阻塞单线程语言,因为在最初 JS 就是为了和浏览器交互而诞生的。如果 JS 是门多线程的语言话,我们在多个线程中处理 DOM 就可能会发生问题(一个线程中新加节点,另一个线程中删除节点),当然可以引入读写锁解决这个问题。

JS 在执行的过程中会产生执行环境,这些执行环境会被顺序的加入到执行栈中。如果遇到异步的代码,会被挂起并加入到 Task(有多种 task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为。

console.log('script start');

setTimeout(function() {
	console.log('setTimeout');
}, 0);

console.log('script end');

以上代码虽然 setTimeout 延时为 0,其实还是异步。这是因为 HTML5 标准规定这个函数第二个参数不得小于 4 毫秒,不足会自动增加。所以 setTimeout 还是会在 script end 之后打印。

不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 task

console.log('script start');

setTimeout(function() {
	console.log('setTimeout');
}, 0);

new Promise((resolve) => {
		console.log('Promise')
		resolve()
}).then(function() {
	console.log('promise1');
}).then(function() {
	console.log('promise2');
});

console.log('script end');
// script start => Promise => script end => promise1 => promise2 => setTimeout

以上代码虽然 setTimeout 写在 Promise 之前,但是因为 Promise 属于微任务而 setTimeout 属于宏任务,所以会有以上的打印。

微任务包括 process.nextTickpromiseObject.observeMutationObserver

宏任务包括 scriptsetTimeoutsetIntervalsetImmediateI/OUI rendering

很多人有个误区,认为微任务快于宏任务,其实是错误的。因为宏任务中包括了 script ,浏览器会先执行一个宏任务,接下来有异步代码的话就先执行微任务。

所以正确的一次 Event loop 顺序是这样的

  1. 执行同步代码,这属于宏任务
  2. 执行栈为空,查询是否有微任务需要执行
  3. 执行所有微任务
  4. 必要的话渲染 UI
  5. 然后开始下一轮 Event loop,执行宏任务中的异步代码

通过上述的 Event loop 顺序可知,如果宏任务中的异步代码有大量的计算并且需要操作 DOM 的话,为了更快的 界面响应,我们可以把操作 DOM 放入微任务中。

Node 中的 Event loop

Node 中的 Event loop 和浏览器中的不相同。

Node 的 Event loop 分为6个阶段,它们会按照顺序反复运行

┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<──connections───     │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
	 └───────────────────────┘

timer

timers 阶段会执行 setTimeoutsetInterval

一个 timer 指定的时间并不是准确时间,而是在达到这个时间后尽快执行回调,可能会因为系统正在执行别的事务而延迟。

下限的时间有一个范围:[1, 2147483647] ,如果设定的时间不在这个范围,将被设置为1。

I/O

I/O 阶段会执行除了 close 事件,定时器和 setImmediate 的回调

idle, prepare

idle, prepare 阶段内部实现

poll

poll 阶段很重要,这一阶段中,系统会做两件事情

  1. 执行到点的定时器
  2. 执行 poll 队列中的事件

并且当 poll 中没有定时器的情况下,会发现以下两件事情

  • 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者系统限制
  • 如果 poll 队列为空,会有两件事发生
    • 如果有 setImmediate 需要执行,poll 阶段会停止并且进入到 check 阶段执行 setImmediate
    • 如果没有 setImmediate 需要执行,会等待回调被加入到队列中并立即执行回调

如果有别的定时器需要被执行,会回到 timer 阶段执行回调。

check

check 阶段执行 setImmediate

close callbacks

close callbacks 阶段执行 close 事件

并且在 Node 中,有些情况下的定时器执行顺序是随机的

setTimeout(() => {
		console.log('setTimeout');
}, 0);
setImmediate(() => {
		console.log('setImmediate');
})
// 这里可能会输出 setTimeout,setImmediate
// 可能也会相反的输出,这取决于性能
// 因为可能进入 event loop 用了不到 1 毫秒,这时候会执行 setImmediate
// 否则会执行 setTimeout

当然在这种情况下,执行顺序是相同的

var fs = require('fs')

fs.readFile(__filename, () => {
		setTimeout(() => {
				console.log('timeout');
		}, 0);
		setImmediate(() => {
				console.log('immediate');
		});
});
// 因为 readFile 的回调在 poll 中执行
// 发现有 setImmediate ,所以会立即跳到 check 阶段执行回调
// 再去 timer 阶段执行 setTimeout
// 所以以上输出一定是 setImmediate,setTimeout

上面介绍的都是 macrotask 的执行情况,microtask 会在以上每个阶段完成后立即执行。

setTimeout(()=>{
		console.log('timer1')

		Promise.resolve().then(function() {
				console.log('promise1')
		})
}, 0)

setTimeout(()=>{
		console.log('timer2')

		Promise.resolve().then(function() {
				console.log('promise2')
		})
}, 0)

// 以上代码在浏览器和 node 中打印情况是不同的
// 浏览器中打印 timer1, promise1, timer2, promise2
// node 中打印 timer1, timer2, promise1, promise2

Node 中的 process.nextTick 会先于其他 microtask 执行。

setTimeout(() => {
	console.log("timer1");

	Promise.resolve().then(function() {
		console.log("promise1");
	});
}, 0);

process.nextTick(() => {
	console.log("nextTick");
});
// nextTick, timer1, promise1

入门前端一年半总结

白驹过隙,光阴似箭。时间,真的过得很快。

我是 17 年下半年开始正式学习前端的,到现在为止一年半也差不多了,借此新年之际,来个小小的总结。

很多人看到我才入门前端一年半会很诧异,我之前去大厂面试的时候,也有面试官评价我说,「写出来的东西不像是这个年限有的」,但是总归是因为自身项目经验不多的原因,去年的跳槽并没有如愿进入想去的公司。但是目前呆的「宋小菜」也很不错,这几个月的经历让我觉得没有做错决定。

在这一年半的时间中,我开源了一大一小的项目。大的项目获得了 14K 的 stars,小的项目也有近 1K 的 stars。上个礼拜也发售了自己的第一本「前端面试之道」小册,也因此结识了很多业内的大佬,同时也获得了多个大佬的推荐,小册目前的评价也很不错,这些算是这段时间的一个成就吧。

很多人对于我的学习经历很感兴趣,趁着这个机会我也分享一下自己的经历。

首先,先说一下个人理解的学习。学习的第一步是知道自己学习的这个知识问题是什么,答案是什么,然后找到这些问题和答案之间的关系,这个关系是我们需要学习的东西,最后能把这个关系通过通俗易懂的语言输出出来,那么这个知识你一定学会了。

很多人认为自己学了很多,看了很多,但是又说不出个所以然。其实这压根不算什么学习,充其量只是记忆了一些知识的描述,并没有学到问题和答案之间的关联是什么。就比如说 111 * 120 答案是多少,你可能并不能马上回答出来。但是你知道他和答案的关联,你就能通过这个关联找到答案。我们就是需要学习这个关联。

在学习的过程中,我并没有阅读很多书,或者说看完的书一只手都可以数过来,同时也没有一大早起床或者很晚睡觉。我也喜欢打游戏,也喜欢刷刷手机,大家喜欢的我应该都喜欢,并没有像很多学霸一样一直在学习。

总归来说,我和大部分人一样,那么你可能会诧异,你到底咋学的?

首先我可以流畅阅读点英文,也有不错的网络条件可以访问想访问的网站,这些条件能够让我接触到更高质量的资料,能够通过搜索引擎更快地解决问题可能是一个方面。

第二方面,我喜欢列点计划。对长远的事情会早早的考虑周全然后定好计划,对于短期的事情会设立一个 deadline 争取去完成。就比如我的开源是很早就有一个大的计划的,然后逐步分解这一个大的开源项目到具体的时间。管理好自己的时间,在可控的时间内完成预期的计划,其他的时间就是我打游戏、刷剧的时间了,毕竟劳逸结合还是很重要的,我始终做不到抛弃这些一直学习。

其他几个方面就是些很零散的东西了。比如说花钱买点知识付费的课程,不要把有限的时间都浪费在找寻资料的过程中;比如说在工作中发现需要学习的内容,解决不知道要学习什么的困惑;比如说时刻有一个危机感,有一个清楚的自我认知,知道自己现在还很弱。

另外,我并不打算列举一堆资料出来,我其实认为这个没什么必要。因为想学的自然找的到资料,不想学的,列举多少资料也只是徒增了一个书签而已。

更多的可能是找到一个适合自己学习的方式,而不是靠一堆资料来增加一个虚假感。毕竟时间就那么多,我们不可能学完那么多的东西,即使 React 核心团队的 Dan 也有很多知识盲区。我们应该先思考什么是自己适合的学习方式,然后再去找寻对应的资料学习。条条大路通罗马,只要找到了适合自己的学习方式,然后持续学习,那么迟早有一天你会成为别人口中的大牛。

有些人就是能 4,5 点起床学习,有些人就是一年能读很多书,但是这种途径强加于自己身上,可能就是不能坚持,那么这个途径就不是适合自己的。既然不适合自己,就不要强迫自己去干这件事情。可能这话有点丧,但是如果真的你能把一件不喜欢的事情持续坚持下去,你一定是极少数的那批人。但是,大部分的我们,真的很平庸,我也很平庸。程序员这个行业虽然工资看着光鲜,但是这绝对不是你牛逼的原因,也不是行业牛逼的原因,而是资本牛逼。

对于我个人而言,列好计划,知道自己需要学习什么,然后努力去完成,这样就对得起这些时间了。

选择,远比努力重要。

最后,19 年的展望是什么?虽然我立了几个 flag,但是并不打算写出来。毕竟做成了 flag 才有用,否则都是屁话,自我安慰罢了。

写到最后,好像也没总结个啥。其实并不怎么想把自己学了哪些资料,做了什么事情都一一列举出来,大概觉得这种形式并没有什么用吧。

毕竟学就行了。途径是怎么样的,谁关心呢?只要有结果就行了。

深度解析原型中的各个难点

本文不会过多介绍基础知识,而是把重点放在原型的各个难点上。

大家可以先仔细分析下该图,然后让我们进入主题

prototype

首先来介绍下 prototype 属性。这是一个显式原型属性,只有函数才拥有该属性。基本上所有函数都有这个属性,但是也有一个例外

let fun = Function.prototype.bind()

如果你以上述方法创建一个函数,那么可以发现这个函数是不具有 prototype 属性的。

prototype 如何产生的

当我们声明一个函数时,这个属性就被自动创建了。

function Foo() {}

并且这个属性的值是一个对象(也就是原型),只有一个属性 constructor

constructor 对应着构造函数,也就是 Foo

constructor

constructor 是一个公有且不可枚举的属性。一旦我们改变了函数的 prototype ,那么新对象就没有这个属性了(当然可以通过原型链取到 constructor)。

那么你肯定也有一个疑问,这个属性到底有什么用呢?其实这个属性可以说是一个历史遗留问题,在大部分情况下是没用的,在我的理解里,我认为他有两个作用:

  • 让实例对象知道是什么函数构造了它
  • 如果想给某些类库中的构造函数增加一些自定义的方法,就可以通过 xx.constructor.method 来扩展

_proto_

这是每个对象都有的隐式原型属性,指向了创建该对象的构造函数的原型。其实这个属性指向了 [[prototype]],但是 [[prototype]] 是内部属性,我们并不能访问到,所以使用 _proto_ 来访问。

因为在 JS 中是没有类的概念的,为了实现类似继承的方式,通过 _proto_ 将对象和原型联系起来组成原型链,得以让对象可以访问到不属于自己的属性。

实例对象的 _proto_ 如何产生的

从上图可知,当我们使用 new 操作符时,生成的实例对象拥有了 _proto_属性。

function Foo() {}
// 这个函数是 Function 的实例对象
// function 就是一个语法糖
// 内部调用了 new Function(...)

所以可以说,在 new 的过程中,新对象被添加了 _proto_ 并且链接到构造函数的原型上。

new 的过程

  1. 新生成了一个对象
  2. 链接到原型
  3. 绑定 this
  4. 返回新对象

在调用 new 的过程中会发生以上四件事情,我们也可以试着来自己实现一个 new

function create() {
	// 创建一个空的对象
	let obj = new Object()
	// 获得构造函数
	let Con = [].shift.call(arguments)
	// 链接到原型
	obj.__proto__ = Con.prototype
	// 绑定 this,执行构造函数
	let result = Con.apply(obj, arguments)
	// 确保 new 出来的是个对象
	return typeof result === 'object' ? result : obj
}

对于实例对象来说,都是通过 new 产生的,无论是 function Foo() 还是 let a = { b : 1 }

对于创建一个对象来说,更推荐使用字面量的方式创建对象。因为你使用 new Object() 的方式创建对象需要通过作用域链一层层找到 Object,但是你使用字面量的方式就没这个问题。

function Foo() {}
// function 就是个语法糖
// 内部等同于 new Function()
let a = { b: 1 }
// 这个字面量内部也是使用了 new Object()

Function.proto === Function.prototype

对于对象来说,xx.__proto__.contrcutor 是该对象的构造函数,但是在图中我们可以发现 Function.__proto__ === Function.prototype,难道这代表着 Function 自己产生了自己?

答案肯定是否认的,要说明这个问题我们先从 Object 说起。

从图中我们可以发现,所有对象都可以通过原型链最终找到 Object.prototype ,虽然 Object.prototype 也是一个对象,但是这个对象却不是 Object 创造的,而是引擎自己创建了 Object.prototype所以可以这样说,所有实例都是对象,但是对象不一定都是实例。

接下来我们来看 Function.prototype 这个特殊的对象,如果你在浏览器将这个对象打印出来,会发现这个对象其实是一个函数。

我们知道函数都是通过 new Function() 生成的,难道 Function.prototype 也是通过 new Function() 产生的吗?答案也是否定的,这个函数也是引擎自己创建的。首先引擎创建了 Object.prototype ,然后创建了 Function.prototype ,并且通过 __proto__ 将两者联系了起来。这里也很好的解释了上面的一个问题,为什么 let fun = Function.prototype.bind() 没有 prototype 属性。因为 Function.prototype 是引擎创建出来的对象,引擎认为不需要给这个对象添加 prototype 属性。

所以我们又可以得出一个结论,不是所有函数都是 new Function() 产生的。

有了 Function.prototype 以后才有了 function Function() ,然后其他的构造函数都是 function Function() 生成的。

现在可以来解释 Function.__proto__ === Function.prototype 这个问题了。因为先有的 Function.prototype 以后才有的 function Function() ,所以也就不存在鸡生蛋蛋生鸡的悖论问题了。对于为什么 Function.__proto__ 会等于 Function.prototype ,个人的理解是:其他所有的构造函数都可以通过原型链找到 Function.prototype ,并且 function Function() 本质也是一个函数,为了不产生混乱就将 function Function()__proto__ 联系到了 Function.prototype 上。

总结

  • Object 是所有对象的爸爸,所有对象都可以通过 __proto__ 找到它
  • Function 是所有函数的爸爸,所有函数都可以通过 __proto__ 找到它
  • Function.prototypeObject.prototype 是两个特殊的对象,他们由引擎来创建
  • 除了以上两个特殊对象,其他对象都是通过构造器 new 出来的
  • 函数的 prototype 是一个对象,也就是原型
  • 对象的 __proto__ 指向原型, __proto__ 将对象和原型连接起来组成了原型链

Vuex 源码深度解析

Vuex **

在解读源码之前,先来简单了解下 Vuex 的**。

Vuex 全局维护着一个对象,使用到了单例设计模式。在这个全局对象中,所有属性都是响应式的,任意属性进行了改变,都会造成使用到该属性的组件进行更新。并且只能通过 commit 的方式改变状态,实现了单向数据流模式。

Vuex 解析

Vuex 安装

在看接下来的内容前,推荐本地 clone 一份 Vuex 源码对照着看,便于理解。

在使用 Vuex 之前,我们都需要调用 Vue.use(Vuex) 。在调用 use 的过程中,Vue 会调用到 Vuex 的 install 函数

install 函数作用很简单

  • 确保 Vuex 只安装一次
  • 混入 beforeCreate 钩子函数,可以在组件中使用 this.$store
export function install(_Vue) {
  // 确保 Vuex 只安装一次
  if (Vue && _Vue === Vue) {
    if (process.env.NODE_ENV !== 'production') {
      console.error(
        '[vuex] already installed. Vue.use(Vuex) should be called only once.'
      )
    }
    return
  }
  Vue = _Vue
  applyMixin(Vue)
}

// applyMixin
export default function(Vue) {
  // 获得 Vue 版本号
  const version = Number(Vue.version.split('.')[0])
  // Vue 2.0 以上会混入 beforeCreate 函数
  if (version >= 2) {
    Vue.mixin({ beforeCreate: vuexInit })
  } else {
    // ...
  }
  // 作用很简单,就是能让我们在组件中
  // 使用到 this.$store
  function vuexInit() {
    const options = this.$options
    if (options.store) {
      this.$store =
        typeof options.store === 'function' ? options.store() : options.store
    } else if (options.parent && options.parent.$store) {
      this.$store = options.parent.$store
    }
  }
}

Vuex 初始化

this._modules

本小节内容主要解析如何初始化 this._modules

export class Store {
  constructor (options = {}) {
    // 引入 Vue 的方式,自动安装
    if (!Vue && typeof window !== 'undefined' && window.Vue) {
      install(window.Vue)
    }
    // 在开发环境中断言
    if (process.env.NODE_ENV !== 'production') {
      assert(Vue, `must call Vue.use(Vuex) before creating a store instance.`)
      assert(typeof Promise !== 'undefined', `vuex requires a Promise polyfill in this browser.`)
      assert(this instanceof Store, `store must be called with the new operator.`)
    }
    // 获取 options 中的属性
    const {
      plugins = [],
      strict = false
    } = options

    // store 内部的状态,重点关注 this._modules
    this._committing = false
    this._actions = Object.create(null)
    this._actionSubscribers = []
    this._mutations = Object.create(null)
    this._wrappedGetters = Object.create(null)
    this._modules = new ModuleCollection(options)
    this._modulesNamespaceMap = Object.create(null)
    this._subscribers = []
    this._watcherVM = new Vue()


    const store = this
    const { dispatch, commit } = this
    // bind 以下两个函数上 this 上
    // 便于 this.$store.dispatch
    this.dispatch = function boundDispatch (type, payload) {
      return dispatch.call(store, type, payload)
    }
    this.commit = function boundCommit (type, payload, options) {
      return commit.call(store, type, payload, options)
    }
}

接下来看 this._modules 的过程,以 以下代码为例

const moduleA = {
  state: { ... },
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}

const moduleB = {
  state: { ... },
  mutations: { ... },
  actions: { ... }
}

const store = new Vuex.Store({
  state: { ... },
  modules: {
    a: moduleA,
    b: moduleB
  }
})

对于以上代码,store 可以看成 root 。在第一次执行时,会初始化一个 rootModule,然后判断 root 中是否存在 modules 属性,然后递归注册 module 。对于 child 来说,会获取到他所属的 parent, 然后在 parent 中添加 module

export default class ModuleCollection {
  constructor (rawRootModule) {
    // register root module (Vuex.Store options)
    this.register([], rawRootModule, false)
  }
  register (path, rawModule, runtime = true) {
    // 开发环境断言
    if (process.env.NODE_ENV !== 'production') {
      assertRawModule(path, rawModule)
    }
    // 初始化 Module
    const newModule = new Module(rawModule, runtime)
    // 对于第一次初始化 ModuleCollection 时
    // 会走第一个 if 条件,因为当前是 root
    if (path.length === 0) {
      this.root = newModule
    } else {
      // 获取当前 Module 的 parent
      const parent = this.get(path.slice(0, -1))
      // 添加 child,第一个参数是
      // 当前 Module 的 key 值
      parent.addChild(path[path.length - 1], newModule)
    }

    // 递归注册
    if (rawModule.modules) {
      forEachValue(rawModule.modules, (rawChildModule, key) => {
        this.register(path.concat(key), rawChildModule, runtime)
      })
    }
  }
}

export default class Module {
  constructor (rawModule, runtime) {
    this.runtime = runtime
    // 用于存储 children
    this._children = Object.create(null)
    // 用于存储原始的 rawModule
    this._rawModule = rawModule
    const rawState = rawModule.state

    // 用于存储 state
    this.state = (typeof rawState === 'function' ? rawState() : rawState) || {}
  }
}
installModule

接下来看 installModule 的实现

// installModule(this, state, [], this._modules.root)
function installModule(store, rootState, path, module, hot) {
  // 判断是否为 rootModule
  const isRoot = !path.length
  // 获取 namespace,root 没有 namespace
  // 对于 modules: {a: moduleA} 来说
  // namespace = 'a/'
  const namespace = store._modules.getNamespace(path)

  // 为 namespace 缓存 module
  if (module.namespaced) {
    store._modulesNamespaceMap[namespace] = module
  }

  // 设置 state
  if (!isRoot && !hot) {
    // 以下逻辑就是给 store.state 添加属性
    // 根据模块添加
    // state: { xxx: 1, a: {...}, b: {...} }
    const parentState = getNestedState(rootState, path.slice(0, -1))
    const moduleName = path[path.length - 1]
    store._withCommit(() => {
      Vue.set(parentState, moduleName, module.state)
    })
  }
  // 该方法其实是在重写 dispatch 和 commit 函数
  // 你是否有疑问模块中的 dispatch 和 commit
  // 是如何找到对应模块中的函数的
  // 假如模块 A 中有一个名为 add 的 mutation
  // 通过 makeLocalContext 函数,会将 add 变成
  // a/add,这样就可以找到模块 A 中对应函数了
  const local = (module.context = makeLocalContext(store, namespace, path))

  // 以下几个函数遍历,都是在
  // 注册模块中的 mutation、action 和 getter
  // 假如模块 A 中有名为 add 的 mutation 函数
  // 在注册过程中会变成 a/add
  module.forEachMutation((mutation, key) => {
    const namespacedType = namespace + key
    registerMutation(store, namespacedType, mutation, local)
  })

  module.forEachAction((action, key) => {
    const type = action.root ? key : namespace + key
    const handler = action.handler || action
    registerAction(store, type, handler, local)
  })

  // 这里会生成一个 _wrappedGetters 属性
  // 用于缓存 getter,便于下次使用
  module.forEachGetter((getter, key) => {
    const namespacedType = namespace + key
    registerGetter(store, namespacedType, getter, local)
  })

  // 递归安装模块
  module.forEachChild((child, key) => {
    installModule(store, rootState, path.concat(key), child, hot)
  })
}
resetStoreVM

接下来看 resetStoreVM 的实现,该属性实现了状态的响应式,并且将 _wrappedGetters 作为 computed 属性。

// resetStoreVM(this, state)
function resetStoreVM(store, state, hot) {
  const oldVm = store._vm

  // 设置 getters 属性
  store.getters = {}
  const wrappedGetters = store._wrappedGetters
  const computed = {}
  // 遍历 _wrappedGetters 属性
  forEachValue(wrappedGetters, (fn, key) => {
    // 给 computed 对象添加属性
    computed[key] = () => fn(store)
    // 重写 get 方法
    // store.getters.xx 其实是访问了
    // store._vm[xx]
    // 也就是 computed 中的属性
    Object.defineProperty(store.getters, key, {
      get: () => store._vm[key],
      enumerable: true, // for local getters
    })
  })

  // 使用 Vue 来保存 state 树
  // 同时也让 state 变成响应式
  const silent = Vue.config.silent
  Vue.config.silent = true
  // 当访问 store.state 时
  // 其实是访问了 store._vm._data.$$state
  store._vm = new Vue({
    data: {
      $$state: state,
    },
    computed,
  })
  Vue.config.silent = silent

  // 确保只能通过 commit 的方式改变状态
  if (store.strict) {
    enableStrictMode(store)
  }
}

常用 API

commit 解析

如果需要改变状态的话,一般都会使用 commit 去操作,接下来让我们来看看 commit 是如何实现状态的改变的

commit(_type, _payload, _options) {
  // 检查传入的参数
  const { type, payload, options } = unifyObjectStyle(
    _type,
    _payload,
    _options
  )

  const mutation = { type, payload }
  // 找到对应的 mutation 函数
  const entry = this._mutations[type]
  // 判断是否找到
  if (!entry) {
    if (process.env.NODE_ENV !== 'production') {
      console.error(`[vuex] unknown mutation type: ${type}`)
    }
    return
  }
  // _withCommit 函数将 _committing
  // 设置为 TRUE,保证在 strict 模式下
  // 只能 commit 改变状态
  this._withCommit(() => {
    entry.forEach(function commitIterator(handler) {
      // entry.push(function wrappedMutationHandler(payload) {
      //   handler.call(store, local.state, payload)
      // })
      // handle 就是 wrappedMutationHandler 函数
      // wrappedMutationHandler 内部就是调用
      // 对于的 mutation 函数
      handler(payload)
    })
  })
  // 执行订阅函数
  this._subscribers.forEach(sub => sub(mutation, this.state))
}
dispatch 解析

如果需要异步改变状态,就需要通过 dispatch 的方式去实现。在 dispatch 调用的 commit 函数都是重写过的,会找到模块内的 mutation 函数。

dispatch(_type, _payload) {
  // 检查传入的参数
  const { type, payload } = unifyObjectStyle(_type, _payload)

  const action = { type, payload }
  // 找到对于的 action 函数
  const entry = this._actions[type]
  // 判断是否找到
  if (!entry) {
    if (process.env.NODE_ENV !== 'production') {
      console.error(`[vuex] unknown action type: ${type}`)
    }
    return
  }
  // 触发订阅函数
  this._actionSubscribers.forEach(sub => sub(action, this.state))

  // 在注册 action 的时候,会将函数返回值
  // 处理成 promise,当 promise 全部
  // resolve 后,就会执行 Promise.all
  // 里的函数
  return entry.length > 1
    ? Promise.all(entry.map(handler => handler(payload)))
    : entry[0](payload)
}
各种语法糖

在组件中,如果想正常使用 Vuex 的功能,经常需要这样调用 this.$store.state.xxx 的方式,引来了很多的不便。为此,Vuex 引入了语法糖的功能,让我们可以通过简单的方式来实现上述的功能。以下以 mapState 为例,其他的几个 map 都是差不多的原理,就不一一解析了。

function normalizeNamespace(fn) {
  return (namespace, map) => {
    // 函数作用很简单
    // 根据参数生成 namespace
    if (typeof namespace !== 'string') {
      map = namespace
      namespace = ''
    } else if (namespace.charAt(namespace.length - 1) !== '/') {
      namespace += '/'
    }
    return fn(namespace, map)
  }
}
// 执行 mapState 就是执行
// normalizeNamespace 返回的函数
export const mapState = normalizeNamespace((namespace, states) => {
  const res = {}
  // normalizeMap([1, 2, 3]) => [ { key: 1, val: 1 }, { key: 2, val: 2 }, { key: 3, val: 3 } ]
  // normalizeMap({a: 1, b: 2, c: 3}) => [ { key: 'a', val: 1 }, { key: 'b', val: 2 }, { key: 'c', val: 3 } ]
  // function normalizeMap(map) {
  //   return Array.isArray(map)
  //     ? map.map(key => ({ key, val: key }))
  //     : Object.keys(map).map(key => ({ key, val: map[key] }))
  // }
  // states 参数可以参入数组或者对象类型
  normalizeMap(states).forEach(({ key, val }) => {
    res[key] = function mappedState() {
      let state = this.$store.state
      let getters = this.$store.getters
      if (namespace) {
        // 获得对应的模块
        const module = getModuleByNamespace(this.$store, 'mapState', namespace)
        if (!module) {
          return
        }
        state = module.context.state
        getters = module.context.getters
      }
      // 返回 State
      return typeof val === 'function'
        ? val.call(this, state, getters)
        : state[val]
    }
    // mark vuex getter for devtools
    res[key].vuex = true
  })
  return res
})

最后

以上是 Vue 的源码解析,虽然 Vuex 的整体代码并不多,但是却是个值得阅读的项目。如果你在阅读的过程中有什么疑问或者发现了我的错误,欢迎在评论中讨论。

前端进阶之道网站的文章浏览异常

根据文章提示扫码关注公众号并回复关键字了,但是文章并没有自动解锁。公公众号提示“提供的服务异常”。希望作者能尽快修复一下,感谢~~

Hooks 的性能优化及可能会遇到的坑总结

组件 PureRender

class 组件中性能优化可以通过 shouldComponentUpdate 实现或者继承自 PureComponent,当然后者也是通过 shouldComponentUpdate 去做的,内部对 stateprops 进行了 shallowEqual。

对于函数组件来说并没有这个生命周期可以调用,因此想实现性能优化只能通过 React.memo(<Component />) 来做,这种做法和继承 PureComponent 的原理一致。

另外如果你的函数组件需要拿到它的 ref,可以使用以下工具函数:

function memoForwardRef<N, P>(comp: RefForwardingComponent<N, P>) {
  return memo(forwardRef<N, P>(comp));
}

但是并不是以上做法以后性能就万事大吉了,你还得保证传递的 props 以及内部的状态的引用不发生预期之外的变化。

保持局部不变

对于函数组件来说,变量的引用是需要重点关注的问题,无论是函数亦或者对象。

const Child = React.memo(({ columns }) => {
  return <Table columns={columns} />
})
const Parent = () => {
  const data = [];
  return <Child columns={data} />
}

对于以上组件来说,每次 Parent 渲染的时候虽然 columns 内容没有变,但是 columns 的引用已经变了。当 props 传递给 Child 的时候,即使使用了 React.memo 但是性能优化也失效了。

对于这种情况,可以通过 useMemo 将引用存储起来,依赖不变引用也就不变。

const data = useMemo(() => [], [])

useMemo 的场景多是用于值的计算。比如密集型计算场景下你肯定不希望组件重新渲染的时候,依赖项没有变更缺重复执行计算函数得到相同的值。

对于函数来说,如果你想保存它的引用的话可以使用 useCallback 来做。

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


  // 这样写函数,每次重新渲染都会再次创建一个新的函数
  const onIncrement = () => {
    setCount(count => count + 1)
  }

  const onIncrement = useCallback(() => {
    setCount(count => count + 1)
  }, [])

  return (
    <div>
      <button onClick={onIncrement}>INCREMENT</button>
      <p>{count}</p>
    </div>
  )
}

对于以上代码来说,组件每次渲染的时候使用了 useCallback 包裹的 onIncrement 函数引用不会改变,这也就意味着不需要频繁创建及销毁函数了。

但是在 useCallback 存在依赖的情况下函数引用并不一定按照你的想法正常保持不变,比如如下案例:

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

  const onIncrement = useCallback(() => {
    setCount(count => count + 1)
  }, [])
  
  const onLog = useCallback(() => {
    console.log(count)
  }, [count])

  return (
    <div>
      <button onClick={onIncrement}>INCREMENT</button>
      <button onClick={onLog}>Log</button>
      <p>{count}</p>
    </div>
  )
}

count 每次改变造成组件重新渲染的时候,onLog 函数都会重新创建一次。两种常规方法可以保持在这种情况下函数引用不被改变。

  1. 使用 useEventCallback
  2. 使用 useReducer
function useEventCallback(fn, dependencies) {
  const ref = useRef(() => {
    throw new Error('Cannot call an event handler while rendering.');
  });

  useEffect(() => {
    ref.current = fn;
  }, [fn, ...dependencies]);

  return useCallback(() => {
    const fn = ref.current;
    return fn();
  }, [ref]);
}

useEventCallback 使用了 ref 不变的特性,保证回调函数的引用永远不变。另外在 Hooks 中,dispatch 也是不变的,所以把依赖 ref 改成 dispatch,然后在回调中调用 dispatch 就是另一种做法了。

性能优化并不是银弹

凡事都有两面性,在引入以上这些性能优化的时候你已经降低了原本的性能,毕竟它们都是有使用代价的,我们可以来阅读下 useCallbackuseMemo 的核心源码:

function updateCallback(callback, deps) {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

function updateMemo(nextCreate, deps) {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

上述源码实现思路大致是从 fiber 中取出 memoizedState,然后对比前后 Deps,对比的实现也采用了 shallowEqual,最后如果有变化的话就重置 memoizedState

可以看出来,本文中讲到的性能优化方案基本都是采用了 shallowEqual 来对比前后差异,所以没必要为了性能优化而优化。

Hooks 的坑

Hooks 的坑 99% 都是闭包引起的,我们通过一个例子来了解下什么情况下会因为闭包导致问题。

function App() {
  const [state, setState] = React.useState(0)
  // 连点三次你觉得答案会是什么?
  const handleClick = () => {
    setState(state + 1)
    setTimeout(() => {
      console.log(state)
    }, 2000)
  }

  return (
    <>
      <div>{state}</div>
      <button onClick={handleClick} />
    </>
  )
}

上述代码触发三次 handleClick 后你觉得答案会是什么?可能答案与你所想的不大一样,结果是:

0
1
2

因为每次 render 都有一份新的状态,因此上述代码中的 setTimeout 使用产生了一个闭包,捕获了每次 render 后的 state,也就导致了输出了 0、1、2。

如果你希望输出的内容是最新的 state 的话,可以通过 useRef 来保存 state。前文讲过 ref 在组件中只存在一份,无论何时使用它的引用都不会产生变化,因此可以来解决闭包引发的问题。

function App() {
  const [state, setState] = React.useState(0)
  // 用 ref 存一下
  const currentState = React.useRef(state)
  // 每次渲染后更新下值
  useEffect(() => {
    currentState.current = state
  })

  const handleClick = () => {
    setState(state + 1)
    // 这样定时器里通过 ref 拿到最新值
    setTimeout(() => {
      console.log(currentState.current)
    }, 2000)
  }

  return (
    <>
      <div>{state}</div>
      <button onClick={handleClick} />
    </>
  )
}

其实闭包引发的问题多半是保存了 old 的值,只要想办法拿到最新的值其实基本上就解决问题了。

写在最后

如果你觉得我有遗漏什么或者写的不对的,欢迎指出。

我很想听听你的想法,谢谢阅读。

微信扫码关注公众号,订阅更多精彩内容                                                                 加笔者微信群聊技术                                                                    

【第三期】本周我们 37 人学了什么

程序员这行如果想一直做下去,那么持续学习是必不可少的。

大家找工作通常会喜欢技术氛围好点的团队,因为这样能够帮助自己更好的成长,但是并不是每个团队都拥有这样的氛围。于是萌发一个念头,想建立一个地方,让一些人能在这块地方记录自己学习到的内容。这些内容通常会是一个小点,可能并不足以写成一篇文章。但是这个知识点可能很多人也不知道,那么通过这种记录的方式让别人同样也学习到这个知识点就是一个很棒的事情了。

如果你也想参与这个记录的事情,欢迎贡献你的一份力量,地址在这里

本周总共有 37 人贡献了他们所学到的知识,选取了一部分不错的分享并整合成文,更详细的内容推荐前往仓库阅读。

TS 中使用 typeof 关键字可以自动获取数据类型

在写 React 项目时,有些时候,你的 state 可能会有默认值,比如:

const initialState = {
   username: '',
   mobile: '',
   isVip: false,
   addresses: [],
}

type IState = typeof initialState

class Comp extends Component<any, IState> {
   constructor(props) {
        super(props);
        this.state = {
            ...initialState
        };
    }
}

这样就不用分开定义 state 的初始值和 state 的类型了。

交换数组指定位置元素位置

从掘金过来的,感觉这个项目挺有意思的,大家互相学习吧!
分享一下最近get到的一个小技巧。交换数组指定位置元素位置:

例如: [1 ,2, 3, 4] ===> [1, 2, 4, 3]

// x , y是要交换元素的位置(index+1)
function arrIndexExchange(array, x, y){
    array.splice(x - 1, 1, ...array.splice(y - 1, 1, array[x - 1]));
    return array;
};

位运算

请注意 位运算适用于 32 位整数,所以精度可能会丢失

  • 用 "|" 取整
let num=1.5
num=num|0; // 1
  • 用 ">>" 取半
let num=4;
num=num>>1; // 2
  • 用 ">>" 加倍
let num=2;
num=num<<1; / / 4
  • 用 "^" 两值交换
let a=1;
let b=2;

a^=b;
b^=a;
a^=b;
// a===2,b===1
  • 用 "&" 判断奇数
let n=3;
let m=4;
n&1===1; // true 奇数
m&1===1; // false 偶数
  • 用 "~" 判断项是否存在
let firstname="Ma";
let fullname="Jack Ma";
let isExist=!!~fullname.indexOf(firstname); // true

arguments 的坑

注意严格模式下,没有这个问题

  • 当非严格模式中的函数 没有 包含 剩余参数默认参数解构赋值,那么arguments对象中的值会跟踪参数的值(反之亦然)
function sidEffecting(ary) {
  ary[0] = ary[2];
}
function bar(a,b,c) {
  c = 10 // 注意这里,其实它修改的就是 arguments 对象里的参数
  sidEffecting(arguments);
  return a + b + c;
}
bar(1,1,1);   // 21
  • 当非严格模式中的函数 包含剩余参数默认参数解构赋值,那么arguments对象中的值不会跟踪参数的值(反之亦然)
function sidEffecting(ary) {
  ary[0] = ary[2];
}
function bar(a,b,c=3) {
  c = 10
  sidEffecting(arguments);
  return a + b + c;
}
bar(1,1,1); // 12

用canvas实现文字渐变效果

var context = canvas.getContext('2d')
var g = context.createLinearGradient(0,0,canvas.width,0)
g.addColorStop(0, 'red')
g.addColorStop(0.5, 'blue')
g.addColorStop(1, 'purple')
context.fillStyle = g
context.font = '36px fantasy'
context.fillText('hello canvas', 0, 100)

效果动图

ES6 proxy 深度代理一个对象

function deepProxy(object, handler) {
    if (isComplexObject(object)) {
        addProxy(object, handler);
    }
    return new Proxy(object, handler);
}

function addProxy(obj, handler) {
    for (let i in obj) {
        if (typeof obj[i] === 'object') {
            if (isComplexObject(obj[i])) {
                addProxy(obj[i], handler);
            }
            obj[i] = new Proxy(obj[i], handler);
        }
    }
}

function isComplexObject(object) {
    if (typeof object !== 'object') {
        return false;
    } else {
        for (let prop in object) {
            if (typeof object[prop] == 'object') {
                return true;
            }
        }
    }
    return false;
}

let person = {
    txt: 123,
    name: 'tnt',
    age: 26,
    status: {
        money: 'less',
        fav: [1, 2, 3]
    }
};
let proxyObj = deepProxy(person, {
    get(target, key, receiver) {
        console.log(`get--${target}--${key}`);
        return Reflect.get(target, key);
    },
    set(target, key, value, receiver) {
        console.log(`set--${target}--${key}-${value}`);
        return Reflect.set(target, key, value);
    }
});
proxyObj.status.test = 13;
proxyObj.status.fav.push('33');

格式化秒为 04:19 类型的小函数

const convertDuration = time => {
  let minutes = Math.floor(time / 60);
  let seconds = Math.floor(time - minutes * 60);
  minutes = String(minutes).length < 2 ? String(minutes).padStart(2,'0'): minutes;
  seconds = String(seconds).length < 2 ? String(seconds).padStart(2,'0'): seconds;
  return minutes + ":" + seconds;
}

convertDuration(252); // "04:12"

hooks 与 react-hot-loader 冲突

之前把老的 React 项目升级成了最新版,在使用 hooks 的时候遇到了一个问题。当时通过谷歌找到了问题所在,升级了 react-hot-loader 包以后就顺便解决了问题,为了继续做工作就没有具体去了解原因。

今天有时间了就搜索了一番资料,总结了一下内容:

在正确使用 hooks 的情况下出现了以下报错:

Uncaught Error: Hooks can only be called inside the body of a function component.

Hooks 确实有限制必须在函数组件中使用,但是我确实正确的使用了。于是搜索了一波资料,发现了一个 issus 出现了与我一样的问题:Incompatibility with react-hot-loader

在阅读 issus 的过程中,发现 react-hot-loader 开发者给出了如下解答:

屏幕快照 2019-08-03 下午6.50.33.png

大致意思是无状态组件(也就是函数组件)会被转换成类组件,这是因为无状态组件没有更新的方法。

因此搜索了下 react-hot-loader 的工作原理,发现官方同样给出了一份文档:How React Hot Loader works

内容中有说到为了渲染 react-tree,不得不把所有的无状态组件都转换成了无状态的类组件。

屏幕快照 2019-08-03 下午6.50.51.png

最后

这周的分享内容质量很高,我也从中汲取到了一些知识。

这是一个需要大家一起分享才能持续下去的事情,光靠我一人分享是做不下去的。欢迎大家参与到这件事情中来,地址在这里

webGL相关知识

  1. 创建3d上下文
    const gl = canvas.getContext('webgl');

  2. 顶点着色器 vertexShader 和 片元着色器fragmentShader 共同组成 program 着色器
    // program可以有多个,这里只是创建对应的容器
    const program = gl.createProgram();
    const vertexShader = gl.createShader(gl.VERTEX_SHADER);
    const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);

  3. 填充着色器内容(GLES代码)
    // 顶点着色器代码
    const VSHADER_SOURCE = attribute vec4 a_Position; attribute float a_PointSize; void main () { gl_Position = a_Position; gl_PointSize = a_PointSize; };

// 片元着色器代码
const FSHADER_SOURCE = #ifdef GL_ES precision mediump float; uniform vec4 u_FragColor; #endif void main () { float d = distance(gl_PointCoord, vec2(0.5, 0.5)); if (d < 0.5) { gl_FragColor = u_FragColor; } else { discard; } };

  1. js代码连接GLES代码
    const connectShader = (program, shader, shaderCode) => {
    // 连接着色器与代码
    gl.shaderSource(shader, shaderCode);
    // 编译着色器
    gl.compileShader(shader);
    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    const info = gl.getShaderInfoLog(shader);
    console.log(info);
    }
    gl.attachShader(program, shader);
    }
    connectShader(program, vertexShader, VSHADER_SOURCE);
    connectShader(program, fragmentShader, FSHADER_SOURCE);

  2. 使用program(因为program能存在多个,相当于激活当前工作着色器)
    gl.linkProgram(program);
    gl.useProgram(program);

  3. 理解3d坐标系统
    一般以右手坐标系为主(从左 -> 右 为x轴正方向,从下 -> 上 为y轴正方向,从后 -> 前 为z轴正方向),
    原点在canvas中心,
    无关canvas长,宽,x轴,y轴取值始终为[-1, 1]范围,需要我们自己归一化相应的坐标

  4. 生成顶点数据
    function calculateX (width) {
    return (1 / (canvas.width / 2)) * x;
    }

function calculateY (height) {
return (1 / (canvas.height / 2)) * y;
}

function createData () {
let arr = [];
const max = 50;
const n = 20;
const m = 10;
for (let i = 0; i < n; i++) {
for (let j = 0; j < m; j++) {
const x = calculateX(-(canvas.width / 2) + i * 50);
const y = calculateY(-(canvas.height / 2) + j * 20 + max)
const item = [x, y];
arr = arr.concat(item);
}
}
return new Float32Array(arr);
}

  1. 在GPU内,开辟一块缓冲区,存储数据
    const points = createData()
    const buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);

  2. 告知webgl如何解析数据
    const byte = points.BYTES_PER_ELEMENT;
    const a_Position = gl.getAttribLocation(program, 'a_Position');
    gl.vertexAttribPointer(a_Position, 2, this.gl.FLOAT, false, byte * 0, 0);
    gl.enableVertexAttribArray(a_Position);

  3. 绘图
    gl.clearColor(0, 0, 0, 1);
    // 清空缓冲区
    gl.clear(gl.COLOR_BUFFER_BIT);
    // 画点
    gl.drawArrays(gl.POINTS, 0, size);

  4. 绘制图形,只提供了三种 画点,画线,画三角形。 如果要绘制其它图形,需要我们提前计算好图形的顶点数据

还在看那些老掉牙的性能优化文章么?这些最新性能指标了解下

性能优化相关的文章其实网上挺多,但是大部分都是在讲如何优化性能,也就是讲方法论。但是在实际工作中,如何量化性能优化也是相当重要的一环。今天本文会介绍谷歌提倡的七个用户体验指标(也可以认为是性能指标),每个指标分别根据以下几点讲解:

  1. 指标本身的作用、测量、推荐时间区间等
  2. 如何指标进行优化,该内容会在文末统一讲解

FP & FCP

首次绘制,FP(First Paint),这个指标用于记录页面第一次绘制像素的时间。

首次内容绘制,FCP(First Contentful Paint),这个指标用于记录页面首次绘制文本、图片、非空白 Canvas 或 SVG 的时间。

这两个指标看起来大同小异,但是 FP 发生的时间一定小于等于 FCP,如下图是掘金的指标:

FP 指的是绘制像素,比如说页面的背景色是灰色的,那么在显示灰色背景时就记录下了 FP 指标。但是此时 DOM 内容还没开始绘制,可能需要文件下载、解析等过程,只有当 DOM 内容发生变化才会触发,比如说渲染出了一段文字,此时就会记录下 FCP 指标。因此说我们可以把这两个指标认为是和白屏时间相关的指标,所以肯定是最快越好。

上图是官方推荐的时间区间,也就是说如果 FP 及 FCP 两指标在 2 秒内完成的话我们的页面就算体验优秀。

LCP

最大内容绘制,LCP(Largest Contentful Paint),用于记录视窗内最大的元素绘制的时间,该时间会随着页面渲染变化而变化,因为页面中的最大元素在渲染过程中可能会发生改变,另外该指标会在用户第一次交互后停止记录。指标变化如下图:

LCP 其实能比前两个指标更能体现一个页面的性能好坏程度,因为这个指标会持续更新。举个例子:当页面出现骨架屏或者 Loading 动画时 FCP 其实已经被记录下来了,但是此时用户希望看到的内容其实并未呈现,我们更想知道的是页面主要的内容是何时呈现出来的。

此时 LCP 指标是能够帮助我们实现想要的需求的。

上图是官方推荐的时间区间,在 2.5 秒内表示体验优秀。

TTI

首次可交互时间,TTI(Time to Interactive)。这个指标计算过程略微复杂,它需要满足以下几个条件

  1. 从 FCP 指标后开始计算
  2. 持续 5 秒内无长任务(执行时间超过 50 ms)且无两个以上正在进行中的 GET 请求
  3. 往前回溯至 5 秒前的最后一个长任务结束的时间
这里你可能会疑问为什么长任务需要定义为 50ms 以外?

Google 提出了一个 RAIL 模型:

对于用户交互(比如点击事件),推荐的响应时间是 100ms 以内。那么为了达成这个目标,推荐在空闲时间里执行任务不超过 50ms(W3C 也有这样的标准规定),这样能在用户无感知的情况下响应用户的交互,否则就会造成延迟感。

长任务也会在 FID 及 TBT 指标中使用到。

因此这是一个很重要的用户体验指标,代表着页面何时真正进入可用的状态。毕竟光内容渲染的快也不够,还要能迅速响应用户的交互。想必大家应该体验过某些网站,虽然内容渲染出来了,但是响应交互很卡顿,只能过一会才能流畅交互的情况。

FID

首次输入延迟,FID(First Input Delay),记录在 FCP 和 TTI 之间用户首次与页面交互时响应的延迟。

这个指标其实挺好理解,就是看用户交互事件触发到页面响应中间耗时多少,如果其中有长任务发生的话那么势必会造成响应时间变长。

其实在上文我们就讲过 Google 推荐响应用户交互在 100ms 以内:

TBT

阻塞总时间,TBT(Total Blocking Time),记录在 FCP 到 TTI 之间所有长任务的阻塞时间总和。

假如说在 FCP 到 TTI 之间页面总共执行了以下长任务(执行时间大于 50ms)及短任务(执行时间低于 50ms)

那么每个长任务的阻塞时间就等于它所执行的总时间减去 50ms

所以对于上图的情况来说,TBT 总共等于 345ms。

这个指标的高低其实也影响了 TTI 的高低,或者说和长任务相关的几个指标都有关联性。

CLS

累计位移偏移,CLS(Cumulative Layout Shift),记录了页面上非预期的位移波动。

大家想必遇到过这类情况:页面渲染过程中突然插入一张巨大的图片或者说点击了某个按钮突然动态插入了一块内容等等相当影响用户体验的网站。这个指标就是为这种情况而生的,计算方式为:位移影响的面积 * 位移距离。

以上图为例,文本移动了 25% 的屏幕高度距离(位移距离),位移前后影响了 75% 的屏幕高度面积(位移影响的面积),那么 CLS 为 0.25 * 0.75 = 0.1875

CLS 推荐值为低于 0.1,越低说明页面跳来跳去的情况就越少,用户体验越好。毕竟很少有人喜欢阅读或者交互过程中网页突然动态插入 DOM 的情况,比如说插入广告~

介绍完了所有的指标,接下来我们来了解哪些是用户体验三大核心指标、如何获取相应的指标数据及如何优化。

三大核心指标

Google 在今年五月提出了网站用户体验的三大核心指标,分别为:

  • LCP
  • FID
  • CLS

LCP 代表了页面的速度指标,虽然还存在其他的一些体现速度的指标,但是上文也说过 LCP 能体现的东西更多一些。一是指标实时更新,数据更精确,二是代表着页面最大元素的渲染时间,通常来说页面中最大元素的快速载入能让用户感觉性能还挺好。

FID 代表了页面的交互体验指标,毕竟没有一个用户希望触发交互以后页面的反馈很迟缓,交互响应的快会让用户觉得网页挺流畅。

CLS 代表了页面的稳定指标,尤其在手机上这个指标更为重要。因为手机屏幕挺小,CLS 值一大的话会让用户觉得页面体验做的很差。

如何获取指标

Lighthouse

你可以通过安装 Lighthouse 插件来获取如下指标

web-vitals-extension

官方出品,你可以通过安装 web-vitals-extension 插件来获取三大核心指标

web-vitals 库

官方出品,你可以通过安装 web-vitals 包来获取如下指标

代码使用方式也挺简单:

import {getCLS, getFID, getLCP} from 'web-vitals';

getCLS(console.log);
getFID(console.log);
getLCP(console.log);

Chrome DevTools

这个工具就不多做介绍了,打开 Performance 即可快速获取如下指标

如何优化指标

资源优化

该项措施可以帮助我们优化 FP、FCP、LCP 指标。

  • 压缩文件、使用 Tree-shaking 删除无用代码
  • 服务端配置 Gzip 进一步再压缩文件体积
  • 资源按需加载
  • 通过 Chrome DevTools 分析首屏不需要使用的 CSS 文件,以此来精简 CSS
  • 内联关键的 CSS 代码
  • 使用 CDN 加载资源及 dns-prefetch 预解析 DNS 的 IP 地址
  • 对资源使用 preconnect,以便预先进行 IP 解析、TCP 握手、TLS 握手
  • 缓存文件,对首屏数据做离线缓存
  • 图片优化,包括:用 CSS 代替蹄片、裁剪适配屏幕的图片大小、小图使用 base64 或者 PNG 格式、支持 WebP 就尽量使用 WebP、渐进式加载图片

网络优化

该项措施可以帮助我们优化 FP、FCP、LCP 指标。

这块内容大多可以让后端或者运维帮你去配置,升级至最新的网络协议通常能让你网站加载的更快。

比如说使用 HTTP2.0 协议、TLS 1.3 协议或者直接拥抱 QUIC 协议~

优化耗时任务

该项措施可以帮助我们优化 TTI、FID、TBT 指标。

  • 使用 Web Worker 将耗时任务丢到子线程中,这样能让主线程在不卡顿的情况下处理 JS 任务
  • 调度任务 + 时间切片,这块技术在 React 16 中有使用到。简单来说就是给不同的任务分配优先级,然后将一段长任务切片,这样能尽量保证任务只在浏览器的空闲时间中执行而不卡顿主线程

不要动态插入内容

该项措施可以帮助我们优化 CLS 指标。

  • 使用骨架屏给用户一个预期的内容框架,突兀的显示内容体验不会很好
  • 图片切勿不设置长宽,而是使用占位图给用户一个图片位置的预期
  • 不要在现有的内容中间插入内容,起码给出一个预留位置

最后

以上是笔者对于用户体验指标的一些内容整理,如果有不懂的或者错误的地方欢迎指正及交流。

推荐关注我的微信公众号【前端真好玩】,工作日推送高质量文章。

笔者就职于酷家乐,家装设计行业独角兽。一流的可视化、前端技术团队,有兴趣的可以简历投递至 [email protected]

本文使用 mdnice 排版

- END -

总感觉自己不会的太多了,不知该如何下手?

经常有读者问我这问题,表示前端要学的实在太多了,然后给我列举了一大堆技术栈:什么三大框架、各种全家桶、小程序、umi、flutter、SSR、Node 等等,反正是把前端技术栈列举了一遍~

前端东西确实蛮多,但也没必要什么都想学。一旦你有这个想法,多半会像个无头苍蝇乱飞。这个看看,那个学点,到头来啥东西都没学好。

这样的例子其实我在读者里看到好些了,学习确实看起来是在学习,啥资料都收藏了,今天看会这个技术的视频,明天拿上另一个技术的书读起来,但是这种学习方式相当低效,另外啥资料都收集还会造成一个时间完全不够用的假象。如果没有一个学习的目标规划,只能事倍功半(可能连半都没有)。因为编程这个事情其中一部分就是靠大量的编码,如果你今天学这明天看那,没有大量的练习让你去训练自己到最后就是啥都不学不好。

先了解自己到底要什么

知道自己要什么是学习之前必须搞定的,否则就是无头苍蝇四处乱来了。

如果你真的没有什么思路的话,我这里推荐三个路子:

  1. 基础,反正无论什么场景下我都会推荐先学好基础,基础不好谈别的就是耍流氓
  2. 公司中用的或者将来要用的技术栈,觉得哪个还学的不好就先学哪个
  3. 看大公司的招聘要求(切记要看大公司的,因为大公司的要求不会是需要你学一大堆,只有小公司才会需要你这也会那也会),然后挑出要求中你还不熟练的开学

深度还是广度?

其实这个问题个人觉得没有绝对答案,两者各有好处。

挖掘深度有助于你成为一个领域中的专家,虽然绝大部分人是没有这个机会的啦,但是比一部分人我们肯定是做得到的,所以挖掘深度归结到底能帮助你成为行业内不那么容易淘汰的人。

挖掘广度有助于帮助你触类旁通,了解更多的概念等等,另外个人体感也会有学的越多就越快的感觉。当然这个挖掘广度不是前文说的那种啥都要去学的做法,而是在学习一个方向的时候顺带把有联系的内容也学上一点。

举个例子今天你打算开始学 Redux(React 的状态管理库),那么在学习 Redux 的过程中,你可以考虑顺带学习一下它的竞品对比 Redux 的优势缺陷是什么等等。这里需要注意的是没有让你把它的竞品也全部学一遍,而是了解竞品的优势及缺陷(这是广度),挖掘深度是好好学 Redux 直到能造出一样的轮子(这是挖掘深度到很后面了)。

建立知识体系

构建知识体系相当重要,否则不管你学到什么都是单独的一块知识,和其他内容不存在联系的话很容易忘记。

大家应该之前有在网上看到过前端知识脑图这类的东西,这个其实就算是一种次点(因为这种只是一个细分领域下的划分,没有和更多的细分领域产生联系)的知识体系,当然能先掌握它也是很棒的。

更好的方式是你学到的知识尽可能的要与别的知识连接起来,能与越多的知识联系起来越好。

举个例子今天面试官问了你一个理论知识,这时候如果你能先讲出理论知识,又能讲出有关联的理论知识,最后用工作中的实例去描述这个知识,这种就算是一个不错的知识体系实践。你既将这个理论知识与别的理论知识连接了起来,又能与实战中的例子产生关联。

那么我们该如何建立自己的知识体系呢?方法很简单:

  1. 把自己学到的知识用自己的话写成笔记
  2. 画脑图,把笔记浓缩到脑图中
  3. 学到新的知识重复一和二步骤,然后思考新学习到的内容是否可以与别的知识产生联系,能产生联系就用箭头双向连接起来

不要想着啥都学

文章开头列举的很多技术栈比如:flutter、SSR、umi 这些其实很多笔者也并不熟悉,但我不会老是想着我啥时候去学一下它们。

因为人的精力肯定是有限的,对于在工作中大概率用不到的东西我向来的策略是了解这个技术栈,读一下它的 Readme,知道它到底解决了什么问题就行,除此以外就不会再继续学习了,只有当我真的有需要这些技术栈的时候我才会去学习它们。

这个策略我也推荐大家可以用起来,因为真的没有必要超前很多去学习一门不知道什么时候才能用得到技术。前文笔者也说过编程是需要大量练习的,没有练习的话过段时间可能你就有点忘记了(反正笔者会这样),然后再过段时间这个技术可能更新迭代大版本了,那你学的东西可能还没用上就得重学了,有那时间打游戏不好嘛~

本文使用 mdnice 排版

微信扫码关注公众号,订阅更多精彩内容                                                                 加笔者微信群聊技术                                                                    

你在 19 年剩余的时间里还能学点什么?

时间过得真快,转眼之间 19 年都已经快进入 9 月份了。

今天就来谈谈在这剩余的时间中我们还可以学点什么来充实自己,提高自己的竞争力。

前端基础

前端基础的重要性我觉得不需要多说了,无论是写出健壮的代码还是定位问题亦或者是面试中都是相当重要的一块内容。

如果你认为自身的前端基础还不过关的话,应该把大量的时间放在学习基础上。

计算机基础

计算机基础对于前端开发者来说最重要的是以下三点:

  • 网络
  • 数据结构
  • 算法

以上这三点的共通性在于:你学了它们,在面试的时候总会带来不小的帮助。

这其中网络应该算是最重要的一块内容,毕竟性能优化常常需要你了解一点其中的知识。

另外两者虽然平时工作中很少用到,但是你保不准会遇到需要的时候。另外如果你以后想阅读源码的话,会发现源码中对于数据结构的运用会相当频繁。

框架

Angular 暂且不提,毕竟国内使用率是远不及 React 和 Vue 的。

如果你还没有熟悉框架的使用,在基础打好的前提下可以把精力优先放在这一块,通读文档是关键,因为很多你在开发中可能会遇到的问题在文档中都已经解释了。

如果你已经熟悉它们的使用,那么可以酌情考虑学习一下框架内部的原理。虽说工作上基本不需要你了解框架内部的机制,但是在面试的时间这经常是个必问点。

另外对于使用 Vue 的开发者来说。今年肯定会发布 Vue 3.0,那么新版本的学习应该是必经之路了。

对于使用 React 的开发者来说,切入 Hooks 或许会是一个不错的选择,当然要小心避免其中的坑。

跨端

跨域开发应该是今年热门的一个点,前有 React Native,后有火爆的 Flutter,另外还有各种小程序来横插一杠。

对于 React Native 或者 Flutter 来说。如果不是公司需要你去参与原生的开发,只是单纯的想自己玩一玩的话,那么学习它们我是持保留态度的。

因为学习这门技术并不能提升我的技术能力,充其量只是多具备了一门在原生上写 UI 的能力。如果你想玩转这个领域,那么一定会需要深入原生开发,这个成本就更大了。除非是公司需要,否则花费大量精力在其中个人是认为不值得的。

另外对于小程序来说,微信在这块应该算是龙头老大,当然还有其他的各种小程序。如果你只需要在微信上做小程序开发的话,那么选择余地会相对来说多点。比如 MpVue(千万别选,我踩了一大堆的坑)、Wepy、Taro 等等。

这些框架可以帮助我们快速进入小程序的开发。这些选择中 Taro 相对来说是个不错的选择,社区的活跃度以及反馈都是远远超过其他竞品的,另外也支持编译为多端小程序(其实还能编译成 React Native 和 H5)。唯一的问题应该是限制了你必须使用 React 技术栈。

热门点

列举几个在当下依旧热门的几个技术点:

  • TypeScript
  • GraphQL

TypeScript 应该算是当下相当热门的一个技术点了。很多框架要不已经 TypeScript 化,要不正在路上。

TypeScript 与 JS 最大的区别就在于增加了静态类型检查(当然一些语法糖也很舒服)。有个这个检查机制,对于开发和维护一个大型项目能带来极大的帮助,无论是修改老代码还是减少 Bug 的发生率。

当然如果你一直在开发小型项目,上不上 TypeScript 其实差别也没那么明显,但是学了并且用了肯定比写 JS 会舒服一点(前提是不要到处 any)。

对于 GraphQL 来说,了解过这块内容的应该都知道它能让请求接口变得相当舒适,当然它也会带来一定的成本,比如说调试困难。

当然虽说 GraphQL 不错,但是学习它最重要的一点我认为是能把它在团队中推动起来。如果推不动,还不如不学。

个人软技能

在这块我认为写 PPT 是个相当重要的软技能。无论评绩效、年终述职、晋升以及技术分享都会需要用到这个技能。

一个优秀的 PPT 是一个相当大的加分项,因为这能让你把想表达的东西更加清晰的呈现给对方。

最后

时间对于大家都是公平的,当下做了学习什么的决定后就不要再过虑了,毕竟没有什么决定在以后是一定正确的,其实决定以后马上行动起来才是正确的。

觉得内容有帮助可以关注下我的公众号 「前端真好玩」咯,定期分享以下主题内容:

  • 前端小知识、冷知识
  • 原理内容
  • 提升工作效率
  • 个人成长

很好用的 UI 调试技巧

在业务开发过程中,想必大家经常会需要查看一个元素的位置及大小并修改它的 CSS,因此就会频繁使用到 DevTools 中的选择元素功能。

其实我们可以使用一个 CSS 技巧给所有元素加上 outline,这样就能迅速了解自己所需的元素位置信息,无须再选择元素查看了。

我们只需要添加以下 CSS 就能为任何网站添加这样的效果

html * {
    outline: 1px solid red
}

需要注意的是这里我没有使用 border 的原因是 border 会增加元素的大小但是 outline 不会。

通过这个技巧不仅能帮助我们在开发中迅速了解元素所在的位置,还能帮助我们方便地查看任意网站的布局。

笔者最喜欢用这个技巧来查看元素是否对齐。

但是当下这个技巧需要我们手动添加 CSS 来实现,显得略微有点鸡肋,是否可以通过一个开关来实现任意网页开启关闭这个功能呢?

答案是有的,我们需要借助 Chrome 的书签功能。

  1. 打开书签管理页
  2. 右上角三个点「添加新书签」
  3. 名称随意,粘贴以下代码到网址中
javascript: (function() {
	var elements = document.body.getElementsByTagName('*');
	var items = [];
	for (var i = 0; i < elements.length; i++) {
		if (elements[i].innerHTML.indexOf('html * { outline: 1px solid red }') != -1) {
			items.push(elements[i]);
		}
	}
	if (items.length > 0) {
		for (var i = 0; i < items.length; i++) {
			items[i].innerHTML = '';
		}
	} else {
		document.body.innerHTML +=
			'<style>html * { outline: 1px solid red }</style>';
	}
})();

然后我们就可以在任意网站上点击刚才创建的书签,内部会判断是否存在调试的 style。存在的话就删除,不存在的话就添加,通过这种方式我们就能很方便的通过这个技巧查看任意网页的布局了。

PS:以上书签的技巧参考自此处,原内容略微繁琐,笔者改动了 style 中的内容。

最后

觉得内容有帮助可以关注下我的公众号 「前端真好玩」咯,定期分享以下主题内容:

  • 前端小知识、冷知识
  • 原理内容
  • 提升工作效率
  • 个人成长

一位掘金年度作者的年度总结

2018年总结

很荣幸成为了掘金的年度作者,小小的满足了一下虚荣心,当然我应该是年榜作者里最菜的一位了。

掘金年度回顾可以点击这里查看:http://2018.juejin.im/

认识我的人应该都知道,我是去年才正式转的前端,经验是远远比不过上榜的其他作者的。当然为了明年继续上榜,今年还会持续输出文章和开源。

在过去的一年中,我做了以下几件改变职业道路的事情:

  • 年初开源了一个 React 的项目,积累了一点人气
  • 在金三银四的时候发现各种面经文章层出不穷,但是套路都是对单个题目进行讲解,作用微乎其微。但是我也从这里发现了巨大的商机,面试的内容一定会有很多人感兴趣,何不将这些大厂面试题收集起来,然后从中提炼出常考知识点,然后针对每个知识点做详细的描述,毕竟只有理解了知识点,才能攻克各式的面试题。于是我花了近半年的时候,打造了一个十多万字的开源项目,至今已获得 14 K Stars
  • 开源项目初期就获得了很好的成效,于是我趁热打铁,跳槽进了满意的公司
  • 持续在掘金输出文章,提高自身的人气
  • 出版了一本掘金小册,不到一个月的时间就发售了四千本,并且获得了很不错的评价,也有很多出版社联系我出书

从以上几件事情大家应该可以发现,我去年一年主体发力在打造个人的品牌上面,一旦提高了个人的影响力,很多在之前看起来很难的事情就能迎刃而解了。

现在我也逐渐喜欢上了写作,因为写作可以帮助我梳理巩固知识,也能帮助到别人,让别人认可自己,还能提高自己的影响力,这样的事情何乐而不为呢。

如果你也有一个写作的想法,我可以给出一点个人的小建议。首先前提是看得懂英文文章,然后你可以去阅读一些不错的文章,试着翻译文章或者理解文章并且将理解的内容输出出来,这样的方式能较好的度过一段写作的适应期,因为大部分的人可能空有这么一个写作的想法但却不知道该输出什么内容。

然后我想说一下这一年来我做出这些内容背后的一些思考,希望能对你有所启发。

首先我之前是做原生开发的,学习前端一开始也是因为想拓宽个人技术栈而已,所以就去学了。一开始当然是学习基础了,然后就开始接触框架的内容,为了提升自己的水平,也就去试着做了一个 React 的项目,当然后端也是自己实现的,总体来说还是偏向于 Demo 级别,并且也尝试着开源了出去,结果发现成效还不错,在这个时候就坚定了继续学习下去的决心。

接下来就到了金三银四的跳槽季了,发现面经文章实在是太火了,于是就想出了从知识点入手,开源一个项目的想法。对于这个想法我一开始的思考就是:从知识点入手,我能够持续夯实自己的基础,也会触碰到一些知识盲区,进而去弥补这些漏洞,这样的持续写作一定能让我打好一个不错的基础。

随着时间的推进,我更相信我这个想法一定是正确的,因为我自己都从持续的写作中弥补了一些知识盲区,更何况是广大的读者们,一旦开源必定会火,后续的结果也验证了我的想法。

开源项目一火,邮箱里就塞满了各种公司的面试邀请,我也就趁热打铁,直接转行,入职了一家满意的公司。

后续就是持续的一些写作继续提高个人的影响力,然后在十月份的时候开始书写 「前端面试之道」小册,内容从之前的开源项目中引申出来,知识点更全部也更加深度。

这本小册也经过了近十位大佬的审校,后续读者的评价也很好,就是当下更新的进度确实拖慢了,也是因为年前事情特别的多,写技术文章又不是一件很容易的事情,为了不滥竽充数,我也就只能拖慢进度了,也请广大读者谅解。

总的来说,我做的几件事情背后都有强有力的一个目的。

  • 开源 React 是为了熟悉框架那套东西
  • 开源面试项目是为了夯实基础以及提高影响力,进而顺利跳槽
  • 写文章同样也是为了提升自己以及提高影响力
  • 写小册同样也是为了提升自己以及提高影响力,当然还有附带的金钱利益

所以说,我们在业余中做的事情,最好每一件事情都有它背后的一个目的,并且确保这个目的大概率会达成,这样才值得花时间坚持去做一件事情,因为业余时间真的很宝贵,这样延迟满足的事情如果有,一定要尽力去完成。一年前,我还默默无闻;经过一年的努力,我起码拥有了一批自己的粉丝肯定了自己,那么我做的这些事情,就值得我继续做下去。

2019 的输出展望

趁着年轻,再多写点文章,老了就写不动了

  • 计划输出一个系列,取名 「重学 JS」,内容偏向于底层
  • 开源一个组件库,当然我知道一定比不过市面上的组件库,所以我会以另辟蹊径的方式去做这件事情
  • 其他一些七七八八的碎片文章,内容可能会偏向于 Node 这块吧

一些链接

时代变了,现在居然可以这样写 CSS 了

现在大部分搞前端的应该还是这样写 CSS 的:

.mock {
    margin: auto;
    font-size: 16px;
    // ...
}
<div class='mock'>mock</div>

以上代码就是举个例子,大部分情况应该都是写一个类,然后整一堆样式进去。

但是这种方式写多了以后,你应该会感受到一些痛点,比如说:

  • 取名困难,节点结构一多,取名真的是个难事。当然了,我们可以用一些规范或者选择器的方式去规避一些取名问题。
  • 需要用 JS 控制样式的时候又得多写一个类,尤其交互多的场景。
  • 组件复用大家都懂,但是样式复用少之又少,这样就造成了冗余代码变多。
  • 全局污染,这个其实现在挺多工具都能帮我们自动解决了。
  • 死代码问题。JS 我们通过 tree shaking 的方式去除用不到的代码减少文件体积,但是 CSS 该怎么去除?尤其当项目变大以后,无用 CSS 代码总会出现。
  • 样式表的插入顺序影响了 CSS 到底是如何生效的。
  • 等等,不一一说明了。其实对于笔者而言,第一二块在开发中是最难受的两个点,尤其是刚写前端,需要做活动 / 产品页的时候。

当下,社区里有一些 CSS 方案,能够解决以上一些痛点:

  • Atom CSS
  • CSS-in-JS
  • 上述两者的结合体

本文就来聊聊以上三种方案的优缺点以及各自方案的代表作。

Atom CSS

首先来聊聊啥叫做 Atom CSS:意思是一个类只干一件事,比如说:

.m-8 {
    margin: 8px;
}

想象一下你按照这样的**搞出一大堆类似的类名,就能整出一个践行 Atom CSS 方案的三方库了,tailwindcss 就是这个方案里的佼佼者。其实 Atom CSS 很多人应该早都用过了,栅格系统上就有它的身影,无非不清楚原来它就是 Atom CSS 罢了。

我们先来看看如果用 tailwindcss 的话,写好样式的 HTML 大概长啥样:

上图是人家官网上的,在这之前还有一段挺炫的动画。看起来好像挺方便的,写上一堆类名就能出左边好看的样式了,省了很多写样式的时间,但是读者们可以来想想这种方式它会有啥好处及弊端?

在说优缺点之前,我们先来聊聊 Atom CSS 的历史。其实它并不是一个新兴产物,这玩意你往前推个十年就能看到它的讨论。正所谓天道好轮回,苍天饶过谁。Atom CSS 以前火过,而且是被喷火的,沉寂了几年之后这几年又被拿出来说了。

接下来我们以 tailwindcss 为例来聊聊 Atom CSS 方案的优劣点。

优劣点

如果你想在团队内部推广这个产品,学习成本会是一个问题,毕竟需要大家都看得懂你这坨东西到底是啥意思,这算一个很明显的缺陷。但是对于语法问题你还真的不用怎么担心,tailwindcss 是有语法补全的工具链的,Webstorm 已经内置了,VSCode 需要大家自行装个插件,所以喷写 tailwindcss 语法麻烦的可以歇一歇。

样式复用,就像写组件一样,这次我们是把样式一个个抽离了出来,这样带来的一大好处是减少了 CSS 代码文件体积。

原本传统的写法是定义一个类,然后写上需要的样式:

.class1 {
    font-size: 18px;
    margin: 10px;
}
.class2 {
    font-size: 16px;
    color: red;
    margin: 10px;
}

这种写法是存在一部分样式重复的,换成 Atom CSS 就能减少一部分代码的冗余。

把 CSS 当成组件来写。大家乍一看 tailwindcss 官网肯定会觉得我在 HTML 里写个样式要敲那么多类是有病吧?

<figure class="md:flex bg-gray-100 rounded-xl p-8 md:p-0">
  <img class="w-32 h-32 md:w-48 md:h-auto md:rounded-none rounded-full mx-auto" src="/sarah-dayan.jpg" alt="" width="384" height="512">
  <div class="pt-6 md:p-8 text-center md:text-left space-y-4">
    <blockquote>
      <p class="text-lg font-semibold">
        “Tailwind CSS is the only framework that I've seen scale
        on large teams. It’s easy to customize, adapts to any design,
        and the build size is tiny.”
      </p>
    </blockquote>
    <figcaption class="font-medium">
      <div class="text-cyan-600">
        Sarah Dayan
      </div>
      <div class="text-gray-500">
        Staff Engineer, Algolia
      </div>
    </figcaption>
  </div>
</figure>

其实我们是可以利用 Atom CSS 一次只干一件事的特性,将这些类随意组装成我们想要的类,这样就可以提供出来一个更上层的通用样式来复用。

比如说项目中的按钮都是存在通用的圆角、内边距、字体等,这样我们就可以封装出这样一个类:

.btn {
    @apply p-8 rounded-xl font-semibold
}

效率工具。tailwindcss 用的好肯定是能提高写布局的效率的,尤其对于需要做响应式的页面而言。当然这东西其实也算是甲之蜜糖乙之砒霜,评价两极分化很严重,有人认为提高了效率,也有人认为反而是增加了成本,或者说是脱裤子放屁。

提供了一整套规范化的设计模式,直接点说就是 tailwindcss 给你内置好一套优秀的设计主题了。但是这玩意对于规范的视觉团队来说是个不小的福音,不规范的话就可能是火葬场了。下面我给大家举个例子:

// tailwind.config.js
const colors = require('tailwindcss/colors')

module.exports = {
  theme: {
    screens: {
      sm: '480px',
      md: '768px',
      lg: '976px',
      xl: '1440px',
    },
    colors: {
      gray: colors.coolGray,
      blue: colors.lightBlue,
      red: colors.rose,
      pink: colors.fuchsia,
    },
    fontFamily: {
      sans: ['Graphik', 'sans-serif'],
      serif: ['Merriweather', 'serif'],
    },
    extend: {
      spacing: {
        '128': '32rem',
        '144': '36rem',
      },
      borderRadius: {
        '4xl': '2rem',
      }
    }
  }
}

以上是 tailwindcss 的主题配置文件,大家可以按照视觉的要求来做调整。比如说今天视觉觉得屏幕的 lg 尺寸应该是 976px,过段时间又觉得需要改成 1000px。对于开发者而言我们只需要修改一行代码就能全局生效了,很舒服。

但是假如说视觉原本定义的边距规则如下:

// tailwind.config.js
module.exports = {
  theme: {
    spacing: {
      px: '1px',
      0: '0',
      0.5: '0.125rem',
      1: '0.25rem',
      1.5: '0.375rem',
      2: '0.5rem',
      2.5: '0.625rem',
      3: '0.75rem',
      3.5: '0.875rem',
      4: '1rem',
      5: '1.25rem',
      6: '1.5rem',
      7: '1.75rem',
      8: '2rem',
      // ...
    },
  }
}

现在需要我们把 6 换成 1.6rem,但是这个规则只需要作用在某些组件上,此时我们需要如何修改样式?新增一个 spacing 然后一个个去替换需要的地方么?

上述场景笔者认为还是不少见的,最起码在我们公司内部是存在这样的问题。已经定义了视觉规范并体现在内部的组件库上,但是在业务中还是有不少视觉会去动组件的基本样式,这里改个边距,那里改个颜色等等。原本组件库是为了帮助开发者提效的,但是在这种场景下开发者反而会抱怨改动样式极大提高了他们的成本,并且大部分情况下还不得不这样做。

再说回传统 CSS 的问题,其实 tailwindcss 也解决了一部分,但是仍旧存在没解决的点,比如说:

  • 死代码问题没解决
  • 样式表的插入顺序依旧有影响

以上说了那么多,其实对于我们使用 tailwindcss 而言,有利也有弊。它肯定是存在很好用的场景的,比如说写个人的产品页,或者说业务中样式变化不频繁的场景中,但是如果说需要业务中全量切换到 tailwindcss 的话,笔者肯定是持保留态度的。

对于 Atom CSS 来说,大家应该是不能否认它的优点的,但是我们是否有办法在尽可能避免它的缺点的情况下又获得它的优点呢?答案是有的,但是在讲答案之前我想先来聊聊 CSS-in-JS。

CSS-in-JS

CSS-in-JS(下文以 CIJ 缩写表示)核心就是在用 JS 写 CSS,这同样也是一个颇具争议的技术方案。

在这个领域下有两个库比较流行,分别为:styled-components(下文以 sc 缩写表示) 以及 Emotion。笔者目前已经用了一年多的 sc 了,来粗略谈谈它的优缺点。

我们先来了解下 sc 是怎么使用的。首先说下 sc 和 Emotion 的语法是趋于一致的,应该是为了 API 层面的统一吧,甚至前者还依赖了后者的一些包,以下是 sc 的常用写法:

const Button = styled.a`
  display: inline-block;

  ${props => props.primary && css`
    background: white;
    color: black;
  `}
`
render(
  <div>
    <Button
      href="https://github.com/styled-components/styled-components"
      target="_blank"
      rel="noopener"
      primary
    >
      GitHub
    </Button>

    <Button as={Link} href="/docs">
      Documentation
    </Button>
  </div>
)

用法我们不多展开,有兴趣的可以去官方看看,基本没有学习成本的,主要是一些样式组件上的使用。

另外 sc 并不是最终生成了内联样式,而是帮我们插入了 style 标签。

优劣点

笔者用了一年多的 sc,感觉这种方案对于 React 来说是很香的。并且解决了我很讨厌的传统写 CSS 的一些点,所以关于优劣点这段的讲述会有点主观。

首先 CSS-in-JS 这种方案不仅能让我们完整使用到 CSS 的功能,而且还扩充了一些用法。比如说选择器这块,在 sc 中我们能通过选择组件的方式来编写样式,如下代码:

const Button = styled.a`
  ${Icon} {
    color: green;
  }
`

另外既然我们通过 JS 来管理 CSS 了,那么我们就可以充分享受 JS 带来的工具链好处了。一旦项目中出现没有使用到的样式组件,那么 ESLint 就可以帮助我们找到那些死代码并清除,这个功能对于大型项目来说还是能减少一部分代码体积的。

除此之外,样式污染、取名问题、自动添加前缀这些问题也很好的解决了。

除了以上这些,再来聊两点不容易注意到的。

首先是动态切换主题。因为我们是通过 JS 来写 CSS 了,那么我们就可以动态地控制样式。如果你的项目有切换主题这种类似的大量动态 CSS 的需求,那么这个方案会是一个不错的选择。

还有个点是按需加载。因为我们是通过 JS 写的 CSS,现阶段打包基本都走的 code split,那么就可以实现 CSS 文件的按需加载,而不是传统方式的一次性全部加载进来(当然也是可以优化的,只是没那么方便)。

聊完了优点我们再来说说缺点。

第一个缺点很明显,有学习成本,当然笔者觉得这个学习曲线还是平缓的。

运行时成本,sc 本身就有文件体积,加上还需要动态生成 CSS,那么这其中必定有性能上的损耗。项目越大影响的也会越大,如果你的项目对于性能有很高的要求,那么需要谨慎考虑使用。另外因为 CSS 动态生成,所以不能像传统 CSS 一样缓存 CSS 文件了。

代码复用性和传统写 CSS 的方式没啥两样。

最后点是代码耦合问题。会有人觉得在大型项目中将 CSS 及 JS 写在一起会增加维护成本,并且也不符合 CSS 需要分离开来想法。

Atom CSS 加上 CSS-in-JS 的缝合怪

看了上文,如果你觉得两种方案都挺好的话,可以了解下 twin.macro,这个库(还有别的竞品)把这两种方案融合了起来。

import 'twin.macro'

const Input = () => <input tw="border hover:border-black" />
const Input = tw.input`border hover:border-black`

这种方案之上其实还有更好玩的方式,能帮助我们尽量取其精华而去其糟粕。

自动生成 Atom CSS 的 CSS-in-JS 方案

假如说我不仅想用 CSS-in-JS,还想把 Atom CSS 也给整上,但是又不想记 / 写一大堆类名,我这个想法能实现么?

答案是有的。利用运行时的方式把单个样式抽离出来,最后实现虽然我们写的是 CSS-in-JS,但是最终呈现的是 Atom CSS 的样子。

styletron 举个例子,开发时候的代码长这样:

import { styled } from "styletron-react";

export default () => {
  // Create a styled component by passing
  // an element name and a style object
  const Anchor = styled("a", {
    fontSize: "20px",
    color: "red"
  });
  return <Anchor href="/getting-started">Start!</Anchor>;
};

实际编译出来的时候长这样:

<html>
  <head>
    <style>
      .foo {
        font-size: 20px;
      }
      .bar {
        color: red;
      }
    </style>
  </head>
  <body>
    <a href="/getting-started" class="foo bar">Start!</a>
  </body>
</html>

这样的方式就能很好地享受到两种方案带来的好处了。但是这类方案笔者找了些竞品,觉得还没有前两者方案来的流行,大家了解一下即可。另外这种方式带来的运行时成本应该会更大,也许可以配套打包工具在本地先做一次预编译(一个不成熟的想法,说错勿喷)?

总结

说了那么多方案,可能读者会有疑问,那么我到底该用啥?这里笔者说下自己的想法。

首先对于 sc 来说,笔者觉得很香,在项目中大范围用起来未尝不可,当然我们还可以搭配着 Atom CSS 一起来写通用样式。

对于 Atom CSS,笔者个人认为不适合项目中大规模使用,起码在我们公司内部不会是一个好方案,毕竟视觉真的会来动某些通用样式。

大家也可以来说说各自的看法。

本文首发于公众号「前端真好玩」,欢迎关注。

剖析 React 源码:调度原理

这是我的剖析 React 源码的第四篇文章,之前的文章都是具体剖析代码,但是觉得这种方式可能并不是太好。因此从这篇文章开始,我打算把在源码中学习到的内容单独写成一篇文章,这样对于读者来说可能更加的友好。

文章相关资料

为什么需要调度?

大家都知道 JS 和渲染引擎是一个互斥关系。如果 JS 在执行代码,那么渲染引擎工作就会被停止。假如我们有一个很复杂的复合组件需要重新渲染,那么调用栈可能会很长

调用栈过长,再加上如果中间进行了复杂的操作,就可能导致长时间阻塞渲染引擎带来不好的用户体验,调度就是来解决这个问题的。

React 会根据任务的优先级去分配各自的 expirationTime,在过期时间到来之前先去处理更高优先级的任务,并且高优先级的任务还可以打断低优先级的任务(因此会造成某些生命周期函数多次被执行),从而实现在不影响用户体验的情况下去分段计算更新(也就是时间分片)。

React 如何实现调度

React 实现调度主要靠两块内容:

  1. 计算任务的 expriationTime
  2. 实现 requestIdleCallback 的 polyfill 版本

接下来就让笔者为大家一一介绍着两块内容。

expriationTime

expriationTime 在前文简略的介绍过它的作用,这个时间可以帮助我们对比不同任务之间的优先级以及计算任务的 timeout。

那么这个时间是如何计算出来的呢?

当前时间指的是 performance.now(),这个 API 会返回一个精确到毫秒级别的时间戳(当然也并不是高精度的),另外浏览器也并不是所有都兼容 performance API 的。如果使用 Date.now() 的话那么精度会更差,但是为了方便起见,我们这里统一把当前时间认为是 performance.now()

常量指的是根据不同优先级得出的一个数值,React 内部目前总共有五种优先级,分别为:

var ImmediatePriority = 1;
var UserBlockingPriority = 2;
var NormalPriority = 3;
var LowPriority = 4;
var IdlePriority = 5;

它们各自的对应的数值都是不同的,具体的内容如下

var maxSigned31BitInt = 1073741823;

// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY = maxSigned31BitInt;

也就是说,假设当前时间为 5000 并且分别有两个优先级不同的任务要执行。前者属于 ImmediatePriority,后者属于 UserBlockingPriority,那么两个任务计算出来的时间分别为 49995250。通过这个时间可以比对大小得出谁的优先级高,也可以通过减去当前时间获取任务的 timeout。

requestIdleCallback

说完了 expriationTime,接下来的主题就是实现 requestIdleCallback 了,我们首先来了解下该函数的作用

该函数的回调方法会在浏览器的空闲时期依次调用, 可以让我们在事件循环中执行一些任务,并且不会对像动画和用户交互这样延迟敏感的事件产生影响。

在上图中我们也可以发现,该回调方法是在渲染以后才执行的。那么介绍完了函数的作用,接下来就来说说它的兼容性吧。

这个函数的兼容性并不是很好,并且它还有一个致命的缺陷:

requestIdleCallback is called only 20 times per second - Chrome on my 6x2 core Linux machine, it's not really useful for UI work.

也就是说 requestIdleCallback 只能一秒调用回调 20 次,这个完全满足不了现有的情况,由此 React 团队才打算自己实现这个函数。

如果你想了解更多关于替换 requestIdleCallback 的内容,可以阅读 该 Issus

如何实现 requestIdleCallback

实现 requestIdleCallback 函数的核心只有一点,如何多次在浏览器空闲时且是渲染后才调用回调方法?

说到多次执行,那么肯定得使用定时器了。在多种定时器中,唯有 requestAnimationFrame 具备一定的精确度,因此 requestAnimationFrame 就是当下实现 requestIdleCallback 的一个步骤。

requestAnimationFrame 的回调方法会在每次重绘前执行,另外它还存在一个瑕疵:页面处于后台时该回调函数不会执行,因此我们需要对于这种情况做个补救措施

rAFID = requestAnimationFrame(function(timestamp) {
	// cancel the setTimeout
	localClearTimeout(rAFTimeoutID);
	callback(timestamp);
});
rAFTimeoutID = setTimeout(function() {
	// 定时 100 毫秒是算是一个最佳实践
	localCancelAnimationFrame(rAFID);
	callback(getCurrentTime());
}, 100);

requestAnimationFrame 不执行时,会有 setTimeout 去补救,两个定时器内部可以互相取消对方。

使用 requestAnimationFrame 只完成了多次执行这一步操作,接下来我们需要实现如何知道当前浏览器是否空闲呢?

大家都知道在一帧当中,浏览器可能会响应用户的交互事件、执行 JS、进行渲染的一系列计算绘制。假设当前我们的浏览器支持 1 秒 60 帧,那么也就是说一帧的时间为 16.6 毫秒。如果以上这些操作超过了 16.6 毫秒,那么就会导致渲染没有完成并出现掉帧的情况,继而影响用户体验;如果以上这些操作没有耗时 16.6 毫秒的话,那么我们就认为当下存在空闲时间让我们可以去执行任务。

因此接下去我们需要计算出当前帧是否还有剩余时间让我们使用。

let frameDeadline = 0
let previousFrameTime = 33
let activeFrameTime = 33
let nextFrameTime = performance.now() - frameDeadline + activeFrameTime
if (
	nextFrameTime < activeFrameTime &&
	previousFrameTime < activeFrameTime
) {
	if (nextFrameTime < 8) {
		nextFrameTime = 8;
	}
	activeFrameTime =
		nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime;
} else {
	previousFrameTime = nextFrameTime;
}

以上这部分代码核心就是得出每一帧所耗时间及下一帧的时间。简单来说就是假设当前时间为 5000,浏览器支持 60 帧,那么 1 帧近似 16 毫秒,那么就会计算出下一帧时间为 5016。

得出下一帧时间以后,我们只需对比当前时间是否小于下一帧时间即可,这样就能清楚地知道是否还有空闲时间去执行任务。

那么最后一步操作就是如何在渲染以后才去执行任务。这里就需要用到事件循环的知识了

想必大家都知道微任务宏任务的区别,这里就不再赘述这部分的内容了。从上图中我们可以发现,在渲染以后只有宏任务是最先会被执行的,因此宏任务就是我们实现这一步的操作了。

但是生成一个宏任务有很多种方式并且各自也有优先级,那么为了最快地执行任务,我们肯定得选择优先级高的方式。在这里我们选择了 MessageChannel 来完成这个任务,不选择 setImmediate 的原因是因为兼容性太差。

到这里为止,requestAnimationFrame + 计算帧时间及下一帧时间 + MessageChannel 就是我们实现 requestIdleCallback 的三个关键点了。

调度的流程

上文说了这么多,这一小节我们将来梳理一遍调度的整个流程。

  • 首先每个任务都会有各自的优先级,通过当前时间加上优先级所对应的常量我们可以计算出 expriationTime高优先级的任务会打断低优先级任务
  • 在调度之前,判断当前任务是否过期,过期的话无须调度,直接调用 port.postMessage(undefined),这样就能在渲染后马上执行过期任务了
  • 如果任务没有过期,就通过 requestAnimationFrame 启动定时器,在重绘前调用回调方法
  • 在回调方法中我们首先需要计算每一帧的时间以及下一帧的时间,然后执行 port.postMessage(undefined)
  • channel.port1.onmessage 会在渲染后被调用,在这个过程中我们首先需要去判断当前时间是否小于下一帧时间。如果小于的话就代表我们尚有空余时间去执行任务;如果大于的话就代表当前帧已经没有空闲时间了,这时候我们需要去判断是否有任务过期,过期的话不管三七二十一还是得去执行这个任务。如果没有过期的话,那就只能把这个任务丢到下一帧看能不能执行了

调度不会仅仅只有 React 拥有

在未来,调度这个功能不会仅仅只有 React 才拥有。因为 React 已经尝试把调度这个模块单独抽离成一个库,这个库在未来能够被大家置入到自己的应用中去提高用户体验。

并且社区也有提案,希望浏览器能自带这方面的功能,具体可以阅读 这个库

最后

阅读源码是一个很枯燥的过程,但是收益也是巨大的。如果你在阅读的过程中有任何的问题,都欢迎你在评论区与我交流。

另外写这系列是个很耗时的工程,需要维护代码注释,还得把文章写得尽量让读者看懂,最后还得配上画图,如果你觉得文章看着还行,就请不要吝啬你的点赞。

最后,觉得内容有帮助可以关注下我的公众号 「前端真好玩」咯,会有很多好东西等着你。

剖析 React 源码:render 流程(二)

这是我的剖析 React 源码的第三篇文章,如果你没有阅读过之前的文章,请务必先阅读一下 第一篇文章 中提到的一些注意事项,能帮助你更好地阅读源码。

文章相关资料

此篇文章内容衔接 render 流程(一),当然不看上一篇文章也没什么问题,因为内容并没有强相关。

现在请大家打开 我的代码 并定位到 react-dom 文件夹下的 src 中的 ReactDOM.js 文件,今天的内容会从这里开始。

ReactRoot.prototype.render

在上一篇文章中,我们介绍了当 ReactDom.render 执行时,内部会首先判断是否已经存在 root,没有的话会去创建一个 root。在今天的文章中,我们将会了解到存在 root 以后会发生什么事情。

大家可以先定位到代码的第 592 行。

大家可以看到,在上述的代码中调用了 unbatchedUpdates 函数,这个函数涉及到的知识其实在 React 中相当重要。

大家都知道多个 setState 一起执行,并不会触发 React 的多次渲染。

// 虽然 age 会变成 3,但不会触发 3 次渲染
this.setState({ age: 1 })
this.setState({ age: 2 })
this.setState({ age: 3 })

这是因为内部会将这个三次 setState 优化为一次更新,术语是批量更新(batchedUpdate),我们在后续的内容中也能看到内部是如何处理批量更新的。

对于 root 来说其实没必要去批量更新,所以这里调用了 unbatchedUpdates 函数来告知内部不需要批量更新。

然后在 unbatchedUpdates 回调内部判断是否存在 parentComponent。这一步我们可以假定不会存在 parentComponent,因为很少有人会在 root 外部加上 context 组件。不存在 parentComponent 的话就会执行 root.render(children, callback),这里的 render 指的是 ReactRoot.prototype.render

render 函数内部我们首先取出 root,这里的 root 指的是 FiberRoot,如果你想了解 FiberRoot 相关的内容可以阅读 上一篇文章。然后创建了 ReactWork 的实例,这块内容我们没有必要深究,功能就是为了在组件渲染或更新后把所有传入 ReactDom.render 中的回调函数全部执行一遍。

接下来我们来看 updateContainer 内部是怎么样的。

我们先从 FiberRoot 的 current 属性中取出它的 fiber 对象,然后计算了两个时间。这两个时间在 React 中相当重要,因此我们需要单独用一小节去学习它们。

时间

首先是 currentTime,在 requestCurrentTime 函数内部计算时间的最核心函数是 recomputeCurrentRendererTime

function recomputeCurrentRendererTime() {
	const currentTimeMs = now() - originalStartTimeMs;
	currentRendererTime = msToExpirationTime(currentTimeMs);
}

now() 就是 performance.now(),如果你不了解这个 API 的话可以阅读下 相关文档originalStartTimeMs 是 React 应用初始化时就会生成的一个变量,值也是 performance.now(),并且这个值不会在后期再被改变。那么这两个值相减以后,得到的结果也就是现在离 React 应用初始化时经过了多少时间。

然后我们需要把计算出来的值再通过一个公式算一遍,这里的 | 0 作用是取整数,也就是说 11 / 10 | 0 = 1

接下来我们来假定一些变量值,代入公式来算的话会更方便大家理解。

假如 originalStartTimeMs2500,当前时间为 5000,那么算出来的差值就是 2500,也就是说当前距离 React 应用初始化已经过去了 2500 毫秒,最后通过公式得出的结果为:

currentTime = 1073741822 - ((2500 / 10) | 0) = 1073741572

接下来是计算 expirationTime这个时间和优先级有关,值越大,优先级越高。并且同步是优先级最高的,它的值为 1073741823,也就是之前我们看到的常量 MAGIC_NUMBER_OFFSET 加一。

computeExpirationForFiber 函数中存在很多分支,但是计算的核心就只有三行代码,分别是:

// 同步
expirationTime = Sync
// 交互事件,优先级较高
expirationTime = computeInteractiveExpiration(currentTime)
// 异步,优先级较低
expirationTime = computeAsyncExpiration(currentTime)

接下来我们就来分析 computeInteractiveExpiration 函数内部是如何计算时间的,当然 computeAsyncExpiration 计算时间的方式也是相同的,无非更换了两个变量。

以上这些代码其实就是公式,我们把具体的值代入就能算出结果了。

time = 1073741822 - ((((1073741822 - 1073741572 + 15) / 10) | 0) + 1) * 10 = 1073741552

另外在 ceiling 函数中的 1 * bucketSizeMs / UNIT_SIZE 是为了抹平一段时间内的时间差,在抹平的时间差内不管有多少个任务需要执行,他们的过期时间都是同一个,这也算是一个性能优化,帮助渲染页面行为节流。

最后其实我们这个计算出来的 expirationTime 是可以反推出另外一个时间的:

export function expirationTimeToMs(expirationTime: ExpirationTime): number {
	return (MAGIC_NUMBER_OFFSET - expirationTime) * UNIT_SIZE;
}

如果我们将之前计算出来的 expirationTime 代入以上代码,得出的结果如下:

(1073741822 - 1073741552) * 10 = 2700

这个时间其实和我们之前在上文中计算出来的 2500 毫秒差值很接近。因为 expirationTime 指的就是一个任务的过期时间,React 根据任务的优先级和当前时间来计算出一个任务的执行截止时间。只要这个值比当前时间大就可以一直让 React 延后这个任务的执行,以便让更高优先级的任务执行,但是一旦过了任务的截止时间,就必须让这个任务马上执行。

这部分的内容一直在算来算去,看起来可能有点头疼。当然如果你嫌麻烦,只需要记住任务的过期时间是通过当前时间加上一个常量(任务优先级不同常量不同)计算出来的。

另外其实你还可以在后面的代码中看到更加直观且简单的计算过期时间的方式,但是目前那部分代码还没有被使用起来。

scheduleRootUpdate

当我们计算出时间以后就会调用 updateContainerAtExpirationTime,这个函数其实没有什么好解析的,我们直接进入 scheduleRootUpdate 函数就好。

首先我们会创建一个 update这个对象和 setState 息息相关

// update 对象的内部属性
expirationTime: expirationTime,
tag: UpdateState,
// setState 的第一二个参数
payload: null,
callback: null,
// 用于在队列中找到下一个节点
next: null,
nextEffect: null,

对于 update 对象内部的属性来说,我们需要重点关注的是 next 属性。因为 update 其实就是一个队列中的节点,这个属性可以用于帮助我们寻找下一个 update。对于批量更新来说,我们可能会创建多个 update,因此我们需要将这些 update 串联并存储起来,在必要的时候拿出来用于更新 state

render 的过程中其实也是一次更新的操作,但是我们并没有 setState,因此就把 payload 赋值为 {element} 了。

接下来我们将 callback 赋值给 update 的属性,这里的 callback 还是 ReactDom.render 的第三个参数。

然后我们将刚才创建出来的 update 对象插入队列中,enqueueUpdate 函数内部分支较多且代码简单,这里就不再贴出代码了,有兴趣的可以自行阅读。函数核心作用就是创建或者获取一个队列,然后把 update 对象入队。

最后调用 scheduleWork 函数,这里开始就是调度相关的内容,这部分内容我们将在下一篇文章中来详细解析。

总结

以上就是本文的全部内容了,这篇文章其实核心还是放在了计算时间上,因为这个时间和后面的调度息息相关,最后通过一张流程图总结一下 render 流程两篇文章的内容。

最后

阅读源码是一个很枯燥的过程,但是收益也是巨大的。如果你在阅读的过程中有任何的问题,都欢迎你在评论区与我交流。

另外写这系列是个很耗时的工程,需要维护代码注释,还得把文章写得尽量让读者看懂,最后还得配上画图,如果你觉得文章看着还行,就请不要吝啬你的点赞。

下一篇文章还是 render 流程相关的内容。

最后,觉得内容有帮助可以关注下我的公众号 「前端真好玩」咯,会有很多好东西等着你。

React 进阶系列:Hooks 该怎么用

这是 React 进阶系列的第一篇文章,这个系列内容会包括一些 React 的新知识以及原理内容,有兴趣的可以持续关注。

注意:Hooks 在 React 16.8 版本中才正式发布

为什么要用 Hooks

组件嵌套问题

之前如果我们需要抽离一些重复的逻辑,就会选择 HOC 或者 render props 的方式。但是通过这样的方式去实现组件,你打开 React DevTools 就会发现组件被各种其他组件包裹在里面。这种方式首先提高了 debug 的难度,并且也很难实现共享状态。

但是通过 Hooks 的方式去抽离重复逻辑的话,一是不会增加组件的嵌套,二是可以实现状态的共享。

class 组件的问题

如果我们需要一个管理状态的组件,那么就必须使用 class 的方式去创建一个组件。但是一旦 class 组件变得复杂,那么四散的代码就很不容易维护。另外 class 组件通过 Babel 编译出来的代码也相比函数组件多得多。

Hooks 能够让我们通过函数组件的方式去管理状态,并且也能将四散的业务逻辑写成一个个 Hooks 便于复用以及维护。

Hooks 怎么用

前面说了一些 Hooks 的好处,接下来我们就进入正题,通过实现一个计数器来学习几个常用的 Hooks。

useState

useState 的用法很简单,传入一个初始 state,返回一个 state 以及修改 state 的函数。

// useState 返回的 state 是个常量
// 每次组件重新渲染之后,当前 state 和之前的 state 都不相同
// 即使这个 state 是个对象
const [count, setCount] = useState(1)

setCount 用法是和 setState 一样的,可以传入一个新的状态或者函数。

setCount(2)
setCount(prevCount => prevCount + 1)

useState 的用法是不是很简单。假如现在需要我们实现一个计数器,按照之前的方式只能通过 class 的方式去写,但是现在我们可以通过函数组件 + Hooks 的方式去实现这个功能。

function Counter() {
	const [count, setCount] = React.useState(0)
	return (
		<div>
			Count: {count}
			<button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
			<button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
		</div>
	);
}

useEffect

现在我们的计时器需求又升级了,需要在组件更新以后打印出当前的计数,这时候我们可以通过 useEffect 来实现

function Counter() {
	const [count, setCount] = React.useState(0)
	
	React.useEffect(() => {
		console.log(count)
	})
	
	return (
		<div>
			Count: {count}
			<button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
			<button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
		</div>
	);
}

以上代码当我们改变计数的时候,就会打印出正确的计数,我们其实基本可以把 useEffect 看成是 componentDidUpdate,它们的区别我们可以在下一个例子中看到。

另外 useEffect 还可以返回一个函数,功能类似于 componentWillUnmount

function Counter() {
	const [count, setCount] = React.useState(0)
	
	React.useEffect(() => {
		console.log(count)
		return () => console.log('clean', count)
	})
	
	// ...
}

当我们每次更新计数时,都会先打印 clean 这行 log

现在我们的需求再次升级了,需要我们在计数器更新以后延时两秒打印出计数。实现这个再简单不过了,我们改造下 useEffect 内部的代码即可

React.useEffect(() => {
		setTimeout(() => {
				console.log(count)
		}, 2000)
})

当我们快速点击按钮后,可以在两秒延时以后看到正确的计数。但是如果我们将这段代码写到 componentDidUpdate 中,事情就变得不一样了。

componentDidUpdate() {
		setTimeout(() => {
				console.log(this.state.count)
		}, 2000)
}

对于这段代码来说,如果我们快速点击按钮,你会在延时两秒后看到打印出了相同的几个计数。这是因为在 useEffect 中我们通过闭包的方式每次都捕获到了正确的计数。但是在 componentDidUpdate 中,通过 this.state.count 的方式只能拿到最新的状态,因为这是一个对象。

当然如果你只想拿到最新的 state 的话,你可以使用 useRef 来实现。

function Counter() {
	const [count, setCount] = React.useState(0)
	const ref = React.useRef(count)
	
	React.useEffect(() => {
		ref.current = count
		setTimeout(() => {
				console.log(ref.current)
		}, 2000)
	})
	
	//...
}

useRef 可以用来存储任何会改变的值,解决了在函数组件上不能通过实例去存储数据的问题。另外你还可以 useRef 来访问到改变之前的数据。

function Counter() {
	const [count, setCount] = React.useState(0)
	const ref = React.useRef()
	
	React.useEffect(() => {
		// 可以在重新赋值之前判断先前存储的数据和当前数据的区别
		ref.current = count
	})
	
	<div>
			Count: {count}
			PreCount: {ref.current}
			<button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
			<button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
	</div>
	
	//...
}

现在需求再次升级,我们需要通过接口来获取初始计数,我们通过 setTimeout 来模拟这个行为。

function Counter() {
	const [count, setCount] = React.useState();
	const [loading, setLoading] = React.useState(true);

	React.useEffect(() => {
		setLoading(true);
		setTimeout(() => {
			setCount(1);
			setLoading(false);
		}, 2000);
	});
	return (
		<div>
			{!loading ? (
				<div>
					Count: {count}
					<button onClick={() => setCount(pre => pre + 1)}>+</button>
					<button onClick={() => setCount(pre => pre - 1)}>-</button>
				</div>
			) : (
				<div>loading</div>
			)}
		</div>
	);
}

如果你去执行这段代码,会发现 useEffect 无限执行。这是因为在 useEffect 内部再次触发了状态更新,因此 useEffect 会再次执行。

解决这个问题我们可以通过 useEffect 的第二个参数解决

React.useEffect(() => {
		setLoading(true);
		setTimeout(() => {
			setCount(1);
			setLoading(false);
		}, 2000);
}, []);

第二个参数传入一个依赖数组,只有依赖的属性变更了,才会再次触发 useEffect 的执行。在上述例子中,我们传入一个空数组就代表这个 useEffect 只会执行一次。

现在我们的代码有点丑陋了,可以将请求的这部分代码单独抽离成一个函数,你可能会这样写

const fetch = () => {
		setLoading(true);
		setTimeout(() => {
			setCount(1);
			setLoading(false);
		}, 2000);
}

React.useEffect(() => {
		fetch()
}, [fetch]);

但是这段代码出现的问题和一开始的是一样的,还是会无限执行。这是因为虽然你传入了依赖,但是每次组件更新的时候 fetch 都会重新创建,因此 useEffect 认为依赖已经更新了,所以再次执行回调。

解决这个问题我们需要使用到一个新的 Hooks useCallback。这个 Hooks 可以生成一个不随着组件更新而再次创建的 callback,接下来我们通过这个 Hooks 再次改造下代码

const fetch = React.useCallback(() => {
		setLoading(true);
		setTimeout(() => {
			setCount(1);
			setLoading(false);
		}, 2000);
}, [])

React.useEffect(() => {
		fetch()
}, [fetch]);

大功告成,我们已经通过几个 Hooks + 函数组件完美实现了原本需要 class 组件才能完成的事情。

总结

通过几个计数器的需求我们学习了一些常用的 Hooks,接下来总结一下这部分的内容。

  • useState:传入我们所需的初始状态,返回一个常量状态以及改变状态的函数
  • useEffect:第一个参数接受一个 callback,每次组件更新都会执行这个 callback,并且 callback 可以返回一个函数,该函数会在每次组件销毁前执行。如果 useEffect 内部有依赖外部的属性,并且希望依赖属性不改变就不重复执行 useEffect 的话,可以传入一个依赖数组作为第二个参数
  • useRef:如果你需要有一个地方来存储变化的数据
  • useCallback:如果你需要一个不会随着组件更新而重新创建的 callback

另外我还封装了几个常用的 Hooks API,有兴趣的可以阅读下代码,仓库中的代码会持续更新。

最后

我们通过这篇文章学习了如何使用 Hooks,如果你还有什么疑问欢迎在评论区与我互动。

我所有的系列文章都会在我的 Github 中最先更新,有兴趣的可以关注下。今年主要会着重写以下三个专栏

  • 重学 JS
  • React 进阶
  • 重写组件

最后,觉得内容有帮助可以关注下我的公众号 「前端真好玩」咯,会有很多好东西等着你。

VueRouter 源码深度解析

路由原理

在解析源码前,先来了解下前端路由的实现原理。
前端路由实现起来其实很简单,本质就是监听 URL 的变化,然后匹配路由规则,显示相应的页面,并且无须刷新。目前单页面使用的路由就只有两种实现方式

  • hash 模式
  • history 模式

www.test.com/##/ 就是 Hash URL,当 ## 后面的哈希值发生变化时,不会向服务器请求数据,可以通过 hashchange 事件来监听到 URL 的变化,从而进行跳转页面。

History 模式是 HTML5 新推出的功能,比之 Hash URL 更加美观

VueRouter 源码解析

重要函数思维导图

以下思维导图罗列了源码中重要的一些函数

路由注册

在开始之前,推荐大家 clone 一份源码对照着看。因为篇幅较长,函数间的跳转也很多。

使用路由之前,需要调用 Vue.use(VueRouter),这是因为让插件可以使用 Vue

export function initUse(Vue: GlobalAPI) {
	Vue.use = function(plugin: Function | Object) {
		// 判断重复安装插件
		const installedPlugins =
			this._installedPlugins || (this._installedPlugins = [])
		if (installedPlugins.indexOf(plugin) > -1) {
			return this
		}
		const args = toArray(arguments, 1)
		// 插入 Vue
		args.unshift(this)
		// 一般插件都会有一个 install 函数
		// 通过该函数让插件可以使用 Vue
		if (typeof plugin.install === 'function') {
			plugin.install.apply(plugin, args)
		} else if (typeof plugin === 'function') {
			plugin.apply(null, args)
		}
		installedPlugins.push(plugin)
		return this
	}
}

接下来看下 install 函数的部分实现

export function install(Vue) {
	// 确保 install 调用一次
	if (install.installed && _Vue === Vue) return
	install.installed = true
	// 把 Vue 赋值给全局变量
	_Vue = Vue
	const registerInstance = (vm, callVal) => {
		let i = vm.$options._parentVnode
		if (
			isDef(i) &&
			isDef((i = i.data)) &&
			isDef((i = i.registerRouteInstance))
		) {
			i(vm, callVal)
		}
	}
	// 给每个组件的钩子函数混入实现
	// 可以发现在 `beforeCreate` 钩子执行时
	// 会初始化路由
	Vue.mixin({
		beforeCreate() {
			// 判断组件是否存在 router 对象,该对象只在根组件上有
			if (isDef(this.$options.router)) {
				// 根路由设置为自己
				this._routerRoot = this
				this._router = this.$options.router
				// 初始化路由
				this._router.init(this)
				// 很重要,为 _route 属性实现双向绑定
				// 触发组件渲染
				Vue.util.defineReactive(this, '_route', this._router.history.current)
			} else {
				// 用于 router-view 层级判断
				this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
			}
			registerInstance(this, this)
		},
		destroyed() {
			registerInstance(this)
		},
	})
	// 全局注册组件 router-link 和 router-view
	Vue.component('RouterView', View)
	Vue.component('RouterLink', Link)
}

对于路由注册来说,核心就是调用 Vue.use(VueRouter),使得 VueRouter 可以使用 Vue。然后通过 Vue 来调用 VueRouter 的 install 函数。在该函数中,核心就是给组件混入钩子函数和全局注册两个路由组件。

VueRouter 实例化

在安装插件后,对 VueRouter 进行实例化。

const Home = { template: '<div>home</div>' }
const Foo = { template: '<div>foo</div>' }
const Bar = { template: '<div>bar</div>' }

// 3. Create the router
const router = new VueRouter({
	mode: 'hash',
	base: __dirname,
	routes: [
		{ path: '/', component: Home }, // all paths are defined without the hash.
		{ path: '/foo', component: Foo },
		{ path: '/bar', component: Bar },
	],
})

来看一下 VueRouter 的构造函数

constructor(options: RouterOptions = {}) {
		// ...
		// 路由匹配对象
		this.matcher = createMatcher(options.routes || [], this)

		// 根据 mode 采取不同的路由方式
		let mode = options.mode || 'hash'
		this.fallback =
			mode === 'history' && !supportsPushState && options.fallback !== false
		if (this.fallback) {
			mode = 'hash'
		}
		if (!inBrowser) {
			mode = 'abstract'
		}
		this.mode = mode

		switch (mode) {
			case 'history':
				this.history = new HTML5History(this, options.base)
				break
			case 'hash':
				this.history = new HashHistory(this, options.base, this.fallback)
				break
			case 'abstract':
				this.history = new AbstractHistory(this, options.base)
				break
			default:
				if (process.env.NODE_ENV !== 'production') {
					assert(false, `invalid mode: ${mode}`)
				}
		}
	}

在实例化 VueRouter 的过程中,核心是创建一个路由匹配对象,并且根据 mode 来采取不同的路由方式。

创建路由匹配对象

export function createMatcher(
	routes: Array<RouteConfig>,
	router: VueRouter
): Matcher {
	// 创建路由映射表
	const { pathList, pathMap, nameMap } = createRouteMap(routes)

	function addRoutes(routes) {
		createRouteMap(routes, pathList, pathMap, nameMap)
	}
	// 路由匹配
	function match(
		raw: RawLocation,
		currentRoute?: Route,
		redirectedFrom?: Location
	): Route {
		//...
	}

	return {
		match,
		addRoutes,
	}
}

createMatcher 函数的作用就是创建路由映射表,然后通过闭包的方式让 addRoutesmatch 函数能够使用路由映射表的几个对象,最后返回一个 Matcher 对象。

接下来看 createMatcher 函数时如何创建映射表的

export function createRouteMap(
	routes: Array<RouteConfig>,
	oldPathList?: Array<string>,
	oldPathMap?: Dictionary<RouteRecord>,
	oldNameMap?: Dictionary<RouteRecord>
): {
	pathList: Array<string>,
	pathMap: Dictionary<RouteRecord>,
	nameMap: Dictionary<RouteRecord>,
} {
	// 创建映射表
	const pathList: Array<string> = oldPathList || []
	const pathMap: Dictionary<RouteRecord> = oldPathMap || Object.create(null)
	const nameMap: Dictionary<RouteRecord> = oldNameMap || Object.create(null)
	// 遍历路由配置,为每个配置添加路由记录
	routes.forEach(route => {
		addRouteRecord(pathList, pathMap, nameMap, route)
	})
	// 确保通配符在最后
	for (let i = 0, l = pathList.length; i < l; i++) {
		if (pathList[i] === '*') {
			pathList.push(pathList.splice(i, 1)[0])
			l--
			i--
		}
	}
	return {
		pathList,
		pathMap,
		nameMap,
	}
}
// 添加路由记录
function addRouteRecord(
	pathList: Array<string>,
	pathMap: Dictionary<RouteRecord>,
	nameMap: Dictionary<RouteRecord>,
	route: RouteConfig,
	parent?: RouteRecord,
	matchAs?: string
) {
	// 获得路由配置下的属性
	const { path, name } = route
	const pathToRegexpOptions: PathToRegexpOptions =
		route.pathToRegexpOptions || {}
	// 格式化 url,替换 /
	const normalizedPath = normalizePath(path, parent, pathToRegexpOptions.strict)
	// 生成记录对象
	const record: RouteRecord = {
		path: normalizedPath,
		regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
		components: route.components || { default: route.component },
		instances: {},
		name,
		parent,
		matchAs,
		redirect: route.redirect,
		beforeEnter: route.beforeEnter,
		meta: route.meta || {},
		props:
			route.props == null
				? {}
				: route.components
				? route.props
				: { default: route.props },
	}

	if (route.children) {
		// 递归路由配置的 children 属性,添加路由记录
		route.children.forEach(child => {
			const childMatchAs = matchAs
				? cleanPath(`${matchAs}/${child.path}`)
				: undefined
			addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
		})
	}
	// 如果路由有别名的话
	// 给别名也添加路由记录
	if (route.alias !== undefined) {
		const aliases = Array.isArray(route.alias) ? route.alias : [route.alias]

		aliases.forEach(alias => {
			const aliasRoute = {
				path: alias,
				children: route.children,
			}
			addRouteRecord(
				pathList,
				pathMap,
				nameMap,
				aliasRoute,
				parent,
				record.path || '/' // matchAs
			)
		})
	}
	// 更新映射表
	if (!pathMap[record.path]) {
		pathList.push(record.path)
		pathMap[record.path] = record
	}
	// 命名路由添加记录
	if (name) {
		if (!nameMap[name]) {
			nameMap[name] = record
		} else if (process.env.NODE_ENV !== 'production' && !matchAs) {
			warn(
				false,
				`Duplicate named routes definition: ` +
					`{ name: "${name}", path: "${record.path}" }`
			)
		}
	}
}

以上就是创建路由匹配对象的全过程,通过用户配置的路由规则来创建对应的路由映射表。

路由初始化

当根组件调用 beforeCreate 钩子函数时,会执行以下代码

beforeCreate () {
// 只有根组件有 router 属性,所以根组件初始化时会初始化路由
	if (isDef(this.$options.router)) {
		this._routerRoot = this
		this._router = this.$options.router
		this._router.init(this)
		Vue.util.defineReactive(this, '_route', this._router.history.current)
	} else {
		this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
	}
	registerInstance(this, this)
}

接下来看下路由初始化会做些什么

init(app: any /* Vue component instance */) {
		// 保存组件实例
		this.apps.push(app)
		// 如果根组件已经有了就返回
		if (this.app) {
			return
		}
		this.app = app
		// 赋值路由模式
		const history = this.history
		// 判断路由模式,以哈希模式为例
		if (history instanceof HTML5History) {
			history.transitionTo(history.getCurrentLocation())
		} else if (history instanceof HashHistory) {
			// 添加 hashchange 监听
			const setupHashListener = () => {
				history.setupListeners()
			}
			// 路由跳转
			history.transitionTo(
				history.getCurrentLocation(),
				setupHashListener,
				setupHashListener
			)
		}
		// 该回调会在 transitionTo 中调用
		// 对组件的 _route 属性进行赋值,触发组件渲染
		history.listen(route => {
			this.apps.forEach(app => {
				app._route = route
			})
		})
	}

在路由初始化时,核心就是进行路由的跳转,改变 URL 然后渲染对应的组件。接下来来看一下路由是如何进行跳转的。

路由跳转

transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
	// 获取匹配的路由信息
	const route = this.router.match(location, this.current)
	// 确认切换路由
	this.confirmTransition(route, () => {
		// 以下为切换路由成功或失败的回调
		// 更新路由信息,对组件的 _route 属性进行赋值,触发组件渲染
		// 调用 afterHooks 中的钩子函数
		this.updateRoute(route)
		// 添加 hashchange 监听
		onComplete && onComplete(route)
		// 更新 URL
		this.ensureURL()
		// 只执行一次 ready 回调
		if (!this.ready) {
			this.ready = true
			this.readyCbs.forEach(cb => { cb(route) })
		}
	}, err => {
	// 错误处理
		if (onAbort) {
			onAbort(err)
		}
		if (err && !this.ready) {
			this.ready = true
			this.readyErrorCbs.forEach(cb => { cb(err) })
		}
	})
}

在路由跳转中,需要先获取匹配的路由信息,所以先来看下如何获取匹配的路由信息

function match(
	raw: RawLocation,
	currentRoute?: Route,
	redirectedFrom?: Location
): Route {
	// 序列化 url
	// 比如对于该 url 来说 /abc?foo=bar&baz=qux##hello
	// 会序列化路径为 /abc
	// 哈希为 ##hello
	// 参数为 foo: 'bar', baz: 'qux'
	const location = normalizeLocation(raw, currentRoute, false, router)
	const { name } = location
	// 如果是命名路由,就判断记录中是否有该命名路由配置
	if (name) {
		const record = nameMap[name]
		// 没找到表示没有匹配的路由
		if (!record) return _createRoute(null, location)
		const paramNames = record.regex.keys
			.filter(key => !key.optional)
			.map(key => key.name)
		// 参数处理
		if (typeof location.params !== 'object') {
			location.params = {}
		}
		if (currentRoute && typeof currentRoute.params === 'object') {
			for (const key in currentRoute.params) {
				if (!(key in location.params) && paramNames.indexOf(key) > -1) {
					location.params[key] = currentRoute.params[key]
				}
			}
		}
		if (record) {
			location.path = fillParams(
				record.path,
				location.params,
				`named route "${name}"`
			)
			return _createRoute(record, location, redirectedFrom)
		}
	} else if (location.path) {
		// 非命名路由处理
		location.params = {}
		for (let i = 0; i < pathList.length; i++) {
			// 查找记录
			const path = pathList[i]
			const record = pathMap[path]
			// 如果匹配路由,则创建路由
			if (matchRoute(record.regex, location.path, location.params)) {
				return _createRoute(record, location, redirectedFrom)
			}
		}
	}
	// 没有匹配的路由
	return _createRoute(null, location)
}

接下来看看如何创建路由

// 根据条件创建不同的路由
function _createRoute(
	record: ?RouteRecord,
	location: Location,
	redirectedFrom?: Location
): Route {
	if (record && record.redirect) {
		return redirect(record, redirectedFrom || location)
	}
	if (record && record.matchAs) {
		return alias(record, location, record.matchAs)
	}
	return createRoute(record, location, redirectedFrom, router)
}

export function createRoute(
	record: ?RouteRecord,
	location: Location,
	redirectedFrom?: ?Location,
	router?: VueRouter
): Route {
	const stringifyQuery = router && router.options.stringifyQuery
	// 克隆参数
	let query: any = location.query || {}
	try {
		query = clone(query)
	} catch (e) {}
	// 创建路由对象
	const route: Route = {
		name: location.name || (record && record.name),
		meta: (record && record.meta) || {},
		path: location.path || '/',
		hash: location.hash || '',
		query,
		params: location.params || {},
		fullPath: getFullPath(location, stringifyQuery),
		matched: record ? formatMatch(record) : [],
	}
	if (redirectedFrom) {
		route.redirectedFrom = getFullPath(redirectedFrom, stringifyQuery)
	}
	// 让路由对象不可修改
	return Object.freeze(route)
}
// 获得包含当前路由的所有嵌套路径片段的路由记录
// 包含从根路由到当前路由的匹配记录,从上至下
function formatMatch(record: ?RouteRecord): Array<RouteRecord> {
	const res = []
	while (record) {
		res.unshift(record)
		record = record.parent
	}
	return res
}

至此匹配路由已经完成,我们回到 transitionTo 函数中,接下来执行 confirmTransition

transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
	// 确认切换路由
	this.confirmTransition(route, () => {}
}
confirmTransition(route: Route, onComplete: Function, onAbort?: Function) {
	const current = this.current
	// 中断跳转路由函数
	const abort = err => {
		if (isError(err)) {
			if (this.errorCbs.length) {
				this.errorCbs.forEach(cb => {
					cb(err)
				})
			} else {
				warn(false, 'uncaught error during route navigation:')
				console.error(err)
			}
		}
		onAbort && onAbort(err)
	}
	// 如果是相同的路由就不跳转
	if (
		isSameRoute(route, current) &&
		route.matched.length === current.matched.length
	) {
		this.ensureURL()
		return abort()
	}
	// 通过对比路由解析出可复用的组件,需要渲染的组件,失活的组件
	const { updated, deactivated, activated } = resolveQueue(
		this.current.matched,
		route.matched
	)

	function resolveQueue(
			current: Array<RouteRecord>,
			next: Array<RouteRecord>
		): {
			updated: Array<RouteRecord>,
			activated: Array<RouteRecord>,
			deactivated: Array<RouteRecord>
		} {
			let i
			const max = Math.max(current.length, next.length)
			for (i = 0; i < max; i++) {
				// 当前路由路径和跳转路由路径不同时跳出遍历
				if (current[i] !== next[i]) {
					break
				}
			}
			return {
				// 可复用的组件对应路由
				updated: next.slice(0, i),
				// 需要渲染的组件对应路由
				activated: next.slice(i),
				// 失活的组件对应路由
				deactivated: current.slice(i)
			}
	}
	// 导航守卫数组
	const queue: Array<?NavigationGuard> = [].concat(
		// 失活的组件钩子
		extractLeaveGuards(deactivated),
		// 全局 beforeEach 钩子
		this.router.beforeHooks,
		// 在当前路由改变,但是该组件被复用时调用
		extractUpdateHooks(updated),
		// 需要渲染组件 enter 守卫钩子
		activated.map(m => m.beforeEnter),
		// 解析异步路由组件
		resolveAsyncComponents(activated)
	)
	// 保存路由
	this.pending = route
	// 迭代器,用于执行 queue 中的导航守卫钩子
	const iterator = (hook: NavigationGuard, next) => {
	// 路由不相等就不跳转路由
		if (this.pending !== route) {
			return abort()
		}
		try {
		// 执行钩子
			hook(route, current, (to: any) => {
				// 只有执行了钩子函数中的 next,才会继续执行下一个钩子函数
				// 否则会暂停跳转
				// 以下逻辑是在判断 next() 中的传参
				if (to === false || isError(to)) {
					// next(false)
					this.ensureURL(true)
					abort(to)
				} else if (
					typeof to === 'string' ||
					(typeof to === 'object' &&
						(typeof to.path === 'string' || typeof to.name === 'string'))
				) {
				// next('/') 或者 next({ path: '/' }) -> 重定向
					abort()
					if (typeof to === 'object' && to.replace) {
						this.replace(to)
					} else {
						this.push(to)
					}
				} else {
				// 这里执行 next
				// 也就是执行下面函数 runQueue 中的 step(index + 1)
					next(to)
				}
			})
		} catch (e) {
			abort(e)
		}
	}
	// 经典的同步执行异步函数
	runQueue(queue, iterator, () => {
		const postEnterCbs = []
		const isValid = () => this.current === route
		// 当所有异步组件加载完成后,会执行这里的回调,也就是 runQueue 中的 cb()
		// 接下来执行 需要渲染组件的导航守卫钩子
		const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid)
		const queue = enterGuards.concat(this.router.resolveHooks)
		runQueue(queue, iterator, () => {
		// 跳转完成
			if (this.pending !== route) {
				return abort()
			}
			this.pending = null
			onComplete(route)
			if (this.router.app) {
				this.router.app.$nextTick(() => {
					postEnterCbs.forEach(cb => {
						cb()
					})
				})
			}
		})
	})
}
export function runQueue (queue: Array<?NavigationGuard>, fn: Function, cb: Function) {
	const step = index => {
	// 队列中的函数都执行完毕,就执行回调函数
		if (index >= queue.length) {
			cb()
		} else {
			if (queue[index]) {
			// 执行迭代器,用户在钩子函数中执行 next() 回调
			// 回调中判断传参,没有问题就执行 next(),也就是 fn 函数中的第二个参数
				fn(queue[index], () => {
					step(index + 1)
				})
			} else {
				step(index + 1)
			}
		}
	}
	// 取出队列中第一个钩子函数
	step(0)
}

接下来介绍导航守卫

const queue: Array<?NavigationGuard> = [].concat(
	// 失活的组件钩子
	extractLeaveGuards(deactivated),
	// 全局 beforeEach 钩子
	this.router.beforeHooks,
	// 在当前路由改变,但是该组件被复用时调用
	extractUpdateHooks(updated),
	// 需要渲染组件 enter 守卫钩子
	activated.map(m => m.beforeEnter),
	// 解析异步路由组件
	resolveAsyncComponents(activated)
)

第一步是先执行失活组件的钩子函数

function extractLeaveGuards(deactivated: Array<RouteRecord>): Array<?Function> {
	// 传入需要执行的钩子函数名
	return extractGuards(deactivated, 'beforeRouteLeave', bindGuard, true)
}
function extractGuards(
	records: Array<RouteRecord>,
	name: string,
	bind: Function,
	reverse?: boolean
): Array<?Function> {
	const guards = flatMapComponents(records, (def, instance, match, key) => {
		// 找出组件中对应的钩子函数
		const guard = extractGuard(def, name)
		if (guard) {
			// 给每个钩子函数添加上下文对象为组件自身
			return Array.isArray(guard)
				? guard.map(guard => bind(guard, instance, match, key))
				: bind(guard, instance, match, key)
		}
	})
	// 数组降维,并且判断是否需要翻转数组
	// 因为某些钩子函数需要从子执行到父
	return flatten(reverse ? guards.reverse() : guards)
}
export function flatMapComponents(
	matched: Array<RouteRecord>,
	fn: Function
): Array<?Function> {
	// 数组降维
	return flatten(
		matched.map(m => {
			// 将组件中的对象传入回调函数中,获得钩子函数数组
			return Object.keys(m.components).map(key =>
				fn(m.components[key], m.instances[key], m, key)
			)
		})
	)
}

第二步执行全局 beforeEach 钩子函数

beforeEach(fn: Function): Function {
		return registerHook(this.beforeHooks, fn)
}
function registerHook(list: Array<any>, fn: Function): Function {
	list.push(fn)
	return () => {
		const i = list.indexOf(fn)
		if (i > -1) list.splice(i, 1)
	}
}

在 VueRouter 类中有以上代码,每当给 VueRouter 实例添加 beforeEach 函数时就会将函数 push 进 beforeHooks 中。

第三步执行 beforeRouteUpdate 钩子函数,调用方式和第一步相同,只是传入的函数名不同,在该函数中可以访问到 this 对象。

第四步执行 beforeEnter 钩子函数,该函数是路由独享的钩子函数。

第五步是解析异步组件。

export function resolveAsyncComponents(matched: Array<RouteRecord>): Function {
	return (to, from, next) => {
		let hasAsync = false
		let pending = 0
		let error = null
		// 该函数作用之前已经介绍过了
		flatMapComponents(matched, (def, _, match, key) => {
			// 判断是否是异步组件
			if (typeof def === 'function' && def.cid === undefined) {
				hasAsync = true
				pending++
				// 成功回调
				// once 函数确保异步组件只加载一次
				const resolve = once(resolvedDef => {
					if (isESModule(resolvedDef)) {
						resolvedDef = resolvedDef.default
					}
					// 判断是否是构造函数
					// 不是的话通过 Vue 来生成组件构造函数
					def.resolved =
						typeof resolvedDef === 'function'
							? resolvedDef
							: _Vue.extend(resolvedDef)
					// 赋值组件
					// 如果组件全部解析完毕,继续下一步
					match.components[key] = resolvedDef
					pending--
					if (pending <= 0) {
						next()
					}
				})
				// 失败回调
				const reject = once(reason => {
					const msg = `Failed to resolve async component ${key}: ${reason}`
					process.env.NODE_ENV !== 'production' && warn(false, msg)
					if (!error) {
						error = isError(reason) ? reason : new Error(msg)
						next(error)
					}
				})
				let res
				try {
					// 执行异步组件函数
					res = def(resolve, reject)
				} catch (e) {
					reject(e)
				}
				if (res) {
					// 下载完成执行回调
					if (typeof res.then === 'function') {
						res.then(resolve, reject)
					} else {
						const comp = res.component
						if (comp && typeof comp.then === 'function') {
							comp.then(resolve, reject)
						}
					}
				}
			}
		})
		// 不是异步组件直接下一步
		if (!hasAsync) next()
	}
}

以上就是第一个 runQueue 中的逻辑,第五步完成后会执行第一个 runQueue 中回调函数

// 该回调用于保存 `beforeRouteEnter` 钩子中的回调函数
const postEnterCbs = []
const isValid = () => this.current === route
// beforeRouteEnter 导航守卫钩子
const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid)
// beforeResolve 导航守卫钩子
const queue = enterGuards.concat(this.router.resolveHooks)
runQueue(queue, iterator, () => {
	if (this.pending !== route) {
		return abort()
	}
	this.pending = null
	// 这里会执行 afterEach 导航守卫钩子
	onComplete(route)
	if (this.router.app) {
		this.router.app.$nextTick(() => {
			postEnterCbs.forEach(cb => {
				cb()
			})
		})
	}
})

第六步是执行 beforeRouteEnter 导航守卫钩子,beforeRouteEnter 钩子不能访问 this 对象,因为钩子在导航确认前被调用,需要渲染的组件还没被创建。但是该钩子函数是唯一一个支持在回调中获取 this 对象的函数,回调会在路由确认执行。

beforeRouteEnter (to, from, next) {
	next(vm => {
		// 通过 `vm` 访问组件实例
	})
}

下面来看看是如何支持在回调中拿到 this 对象的

function extractEnterGuards(
	activated: Array<RouteRecord>,
	cbs: Array<Function>,
	isValid: () => boolean
): Array<?Function> {
	// 这里和之前调用导航守卫基本一致
	return extractGuards(
		activated,
		'beforeRouteEnter',
		(guard, _, match, key) => {
			return bindEnterGuard(guard, match, key, cbs, isValid)
		}
	)
}
function bindEnterGuard(
	guard: NavigationGuard,
	match: RouteRecord,
	key: string,
	cbs: Array<Function>,
	isValid: () => boolean
): NavigationGuard {
	return function routeEnterGuard(to, from, next) {
		return guard(to, from, cb => {
			// 判断 cb 是否是函数
			// 是的话就 push 进 postEnterCbs
			next(cb)
			if (typeof cb === 'function') {
				cbs.push(() => {
					// 循环直到拿到组件实例
					poll(cb, match.instances, key, isValid)
				})
			}
		})
	}
}
// 该函数是为了解决 issus ##750
// 当 router-view 外面包裹了 mode 为 out-in 的 transition 组件
// 会在组件初次导航到时获得不到组件实例对象
function poll(
	cb: any, // somehow flow cannot infer this is a function
	instances: Object,
	key: string,
	isValid: () => boolean
) {
	if (
		instances[key] &&
		!instances[key]._isBeingDestroyed // do not reuse being destroyed instance
	) {
		cb(instances[key])
	} else if (isValid()) {
		// setTimeout 16ms 作用和 nextTick 基本相同
		setTimeout(() => {
			poll(cb, instances, key, isValid)
		}, 16)
	}
}

第七步是执行 beforeResolve 导航守卫钩子,如果注册了全局 beforeResolve 钩子就会在这里执行。

第八步就是导航确认,调用 afterEach 导航守卫钩子了。

以上都执行完成后,会触发组件的渲染

history.listen(route => {
	this.apps.forEach(app => {
		app._route = route
	})
})

以上回调会在 updateRoute 中调用

updateRoute(route: Route) {
		const prev = this.current
		this.current = route
		this.cb && this.cb(route)
		this.router.afterHooks.forEach(hook => {
			hook && hook(route, prev)
		})
}

至此,路由跳转已经全部分析完毕。核心就是判断需要跳转的路由是否存在于记录中,然后执行各种导航守卫函数,最后完成 URL 的改变和组件的渲染。

深入框架本源系列 —— Virtual Dom

该系列会逐步更新,完整的讲解目前主流框架中底层相通的技术,接下来的代码内容都会更新在 这里

为什么需要 Virtual Dom

众所周知,操作 DOM 是很耗费性能的一件事情,既然如此,我们可以考虑通过 JS 对象来模拟 DOM 对象,毕竟操作 JS 对象比操作 DOM 省时的多。

举个例子

// 假设这里模拟一个 ul,其中包含了 5 个 li
[1, 2, 3, 4, 5] 
// 这里替换上面的 li
[1, 2, 5, 4]

从上述例子中,我们一眼就可以看出先前的 ul 中的第三个 li 被移除了,四五替换了位置。

如果以上操作对应到 DOM 中,那么就是以下代码

// 删除第三个 li
ul.childNodes[2].remove()
// 将第四个 li 和第五个交换位置
let fromNode = ul.childNodes[4]
let toNode = node.childNodes[3]
let cloneFromNode = fromNode.cloneNode(true)
let cloenToNode = toNode.cloneNode(true)
ul.replaceChild(cloneFromNode, toNode)
ul.replaceChild(cloenToNode, fromNode)

当然在实际操作中,我们还需要给每个节点一个标识,作为判断是同一个节点的依据。所以这也是 Vue 和 React 中官方推荐列表里的节点使用唯一的 key 来保证性能。

那么既然 DOM 对象可以通过 JS 对象来模拟,反之也可以通过 JS 对象来渲染出对应的 DOM

以下是一个 JS 对象模拟 DOM 对象的简单实现

export default class Element {
	/**
	 * @param {String} tag 'div'
	 * @param {Object} props { class: 'item' }
	 * @param {Array} children [ Element1, 'text']
	 * @param {String} key option
	 */
	constructor(tag, props, children, key) {
		this.tag = tag
		this.props = props
		if (Array.isArray(children)) {
			this.children = children
		} else if (isString(children)) {
			this.key = children
			this.children = null
		}
		if (key) this.key = key
	}
	// 渲染
	render() {
		let root = this._createElement(
			this.tag,
			this.props,
			this.children,
			this.key
		)
		document.body.appendChild(root)
		return root
	}
	create() {
		return this._createElement(this.tag, this.props, this.children, this.key)
	}
	// 创建节点
	_createElement(tag, props, child, key) {
		// 通过 tag 创建节点
		let el = document.createElement(tag)
		// 设置节点属性
		for (const key in props) {
			if (props.hasOwnProperty(key)) {
				const value = props[key]
				el.setAttribute(key, value)
			}
		}
		if (key) {
			el.setAttribute('key', key)
		}
		// 递归添加子节点
		if (child) {
			child.forEach(element => {
				let child
				if (element instanceof Element) {
					child = this._createElement(
						element.tag,
						element.props,
						element.children,
						element.key
					)
				} else {
					child = document.createTextNode(element)
				}
				el.appendChild(child)
			})
		}
		return el
	}
}

Virtual Dom 算法简述

既然我们已经通过 JS 来模拟实现了 DOM,那么接下来的难点就在于如何判断旧的对象和新的对象之间的差异。

DOM 是多叉树的结构,如果需要完整的对比两颗树的差异,那么需要的时间复杂度会是 O(n ^ 3),这个复杂度肯定是不能接受的。于是 React 团队优化了算法,实现了 O(n) 的复杂度来对比差异。

实现 O(n) 复杂度的关键就是只对比同层的节点,而不是跨层对比,这也是考虑到在实际业务中很少会去跨层的移动 DOM 元素。

所以判断差异的算法就分为了两步

  • 首先从上至下,从左往右遍历对象,也就是树的深度遍历,这一步中会给每个节点添加索引,便于最后渲染差异
  • 一旦节点有子元素,就去判断子元素是否有不同

Virtual Dom 算法实现

树的递归

首先我们来实现树的递归算法,在实现该算法前,先来考虑下两个节点对比会有几种情况

  1. 新的节点的 tagName 或者 key 和旧的不同,这种情况代表需要替换旧的节点,并且也不再需要遍历新旧节点的子元素了,因为整个旧节点都被删掉了
  2. 新的节点的 tagNamekey(可能都没有)和旧的相同,开始遍历子树
  3. 没有新的节点,那么什么都不用做
import { StateEnums, isString, move } from './util'
import Element from './element'

export default function diff(oldDomTree, newDomTree) {
	// 用于记录差异
	let pathchs = {}
	// 一开始的索引为 0
	dfs(oldDomTree, newDomTree, 0, pathchs)
	return pathchs
}

function dfs(oldNode, newNode, index, patches) {
	// 用于保存子树的更改
	let curPatches = []
	// 需要判断三种情况
	// 1.没有新的节点,那么什么都不用做
	// 2.新的节点的 tagName 和 `key` 和旧的不同,就替换
	// 3.新的节点的 tagName 和 key(可能都没有) 和旧的相同,开始遍历子树
	if (!newNode) {
	} else if (newNode.tag === oldNode.tag && newNode.key === oldNode.key) {
		// 判断属性是否变更
		let props = diffProps(oldNode.props, newNode.props)
		if (props.length) curPatches.push({ type: StateEnums.ChangeProps, props })
		// 遍历子树
		diffChildren(oldNode.children, newNode.children, index, patches)
	} else {
		// 节点不同,需要替换
		curPatches.push({ type: StateEnums.Replace, node: newNode })
	}

	if (curPatches.length) {
		if (patches[index]) {
			patches[index] = patches[index].concat(curPatches)
		} else {
			patches[index] = curPatches
		}
	}
}

判断属性的更改

判断属性的更改也分三个步骤

  1. 遍历旧的属性列表,查看每个属性是否还存在于新的属性列表中
  2. 遍历新的属性列表,判断两个列表中都存在的属性的值是否有变化
  3. 在第二步中同时查看是否有属性不存在与旧的属性列列表中
function diffProps(oldProps, newProps) {
	// 判断 Props 分以下三步骤
	// 先遍历 oldProps 查看是否存在删除的属性
	// 然后遍历 newProps 查看是否有属性值被修改
	// 最后查看是否有属性新增
	let change = []
	for (const key in oldProps) {
		if (oldProps.hasOwnProperty(key) && !newProps[key]) {
			change.push({
				prop: key
			})
		}
	}
	for (const key in newProps) {
		if (newProps.hasOwnProperty(key)) {
			const prop = newProps[key]
			if (oldProps[key] && oldProps[key] !== newProps[key]) {
				change.push({
					prop: key,
					value: newProps[key]
				})
			} else if (!oldProps[key]) {
				change.push({
					prop: key,
					value: newProps[key]
				})
			}
		}
	}
	return change
}

判断列表差异算法实现

这个算法是整个 Virtual Dom 中最核心的算法,且让我一一为你道来。
这里的主要步骤其实和判断属性差异是类似的,也是分为三步

  1. 遍历旧的节点列表,查看每个节点是否还存在于新的节点列表中
  2. 遍历新的节点列表,判断是否有新的节点
  3. 在第二步中同时判断节点是否有移动

PS:该算法只对有 key 的节点做处理

function listDiff(oldList, newList, index, patches) {
	// 为了遍历方便,先取出两个 list 的所有 keys
	let oldKeys = getKeys(oldList)
	let newKeys = getKeys(newList)
	let changes = []

	// 用于保存变更后的节点数据
	// 使用该数组保存有以下好处
	// 1.可以正确获得被删除节点索引
	// 2.交换节点位置只需要操作一遍 DOM
	// 3.用于 `diffChildren` 函数中的判断,只需要遍历
	// 两个树中都存在的节点,而对于新增或者删除的节点来说,完全没必要
	// 再去判断一遍
	let list = []
	oldList &&
		oldList.forEach(item => {
			let key = item.key
			if (isString(item)) {
				key = item
			}
			// 寻找新的 children 中是否含有当前节点
			// 没有的话需要删除
			let index = newKeys.indexOf(key)
			if (index === -1) {
				list.push(null)
			} else list.push(key)
		})
	// 遍历变更后的数组
	let length = list.length
	// 因为删除数组元素是会更改索引的
	// 所有从后往前删可以保证索引不变
	for (let i = length - 1; i >= 0; i--) {
		// 判断当前元素是否为空,为空表示需要删除
		if (!list[i]) {
			list.splice(i, 1)
			changes.push({
				type: StateEnums.Remove,
				index: i
			})
		}
	}
	// 遍历新的 list,判断是否有节点新增或移动
	// 同时也对 `list` 做节点新增和移动节点的操作
	newList &&
		newList.forEach((item, i) => {
			let key = item.key
			if (isString(item)) {
				key = item
			}
			// 寻找旧的 children 中是否含有当前节点
			let index = list.indexOf(key)
			// 没找到代表新节点,需要插入
			if (index === -1  || key == null) {
				changes.push({
					type: StateEnums.Insert,
					node: item,
					index: i
				})
				list.splice(i, 0, key)
			} else {
				// 找到了,需要判断是否需要移动
				if (index !== i) {
					changes.push({
						type: StateEnums.Move,
						from: index,
						to: i
					})
					move(list, index, i)
				}
			}
		})
	return { changes, list }
}

function getKeys(list) {
	let keys = []
	let text
	list &&
		list.forEach(item => {
			let key
			if (isString(item)) {
				key = [item]
			} else if (item instanceof Element) {
				key = item.key
			}
			keys.push(key)
		})
	return keys
}

遍历子元素打标识

对于这个函数来说,主要功能就两个

  1. 判断两个列表差异
  2. 给节点打上标记

总体来说,该函数实现的功能很简单

function diffChildren(oldChild, newChild, index, patches) {
	let { changes, list } = listDiff(oldChild, newChild, index, patches)
	if (changes.length) {
		if (patches[index]) {
			patches[index] = patches[index].concat(changes)
		} else {
			patches[index] = changes
		}
	}
	// 记录上一个遍历过的节点
	let last = null
	oldChild &&
		oldChild.forEach((item, i) => {
			let child = item && item.children
			if (child) {
				index =
					last && last.children ? index + last.children.length + 1 : index + 1
				let keyIndex = list.indexOf(item.key)
				let node = newChild[keyIndex]
				// 只遍历新旧中都存在的节点,其他新增或者删除的没必要遍历
				if (node) {
					dfs(item, node, index, patches)
				}
			} else index += 1
			last = item
		})
}

渲染差异

通过之前的算法,我们已经可以得出两个树的差异了。既然知道了差异,就需要局部去更新 DOM 了,下面就让我们来看看 Virtual Dom 算法的最后一步骤

这个函数主要两个功能

  1. 深度遍历树,将需要做变更操作的取出来
  2. 局部更新 DOM

整体来说这部分代码还是很好理解的

let index = 0
export default function patch(node, patchs) {
	let changes = patchs[index]
	let childNodes = node && node.childNodes
	// 这里的深度遍历和 diff 中是一样的
	if (!childNodes) index += 1
	if (changes && changes.length && patchs[index]) {
		changeDom(node, changes)
	}
	let last = null
	if (childNodes && childNodes.length) {
		childNodes.forEach((item, i) => {
			index =
				last && last.children ? index + last.children.length + 1 : index + 1
			patch(item, patchs)
			last = item
		})
	}
}

function changeDom(node, changes, noChild) {
	changes &&
		changes.forEach(change => {
			let { type } = change
			switch (type) {
				case StateEnums.ChangeProps:
					let { props } = change
					props.forEach(item => {
						if (item.value) {
							node.setAttribute(item.prop, item.value)
						} else {
							node.removeAttribute(item.prop)
						}
					})
					break
				case StateEnums.Remove:
					node.childNodes[change.index].remove()
					break
				case StateEnums.Insert:
					let dom
					if (isString(change.node)) {
						dom = document.createTextNode(change.node)
					} else if (change.node instanceof Element) {
						dom = change.node.create()
					}
					node.insertBefore(dom, node.childNodes[change.index])
					break
				case StateEnums.Replace:
					node.parentNode.replaceChild(change.node.create(), node)
					break
				case StateEnums.Move:
					let fromNode = node.childNodes[change.from]
					let toNode = node.childNodes[change.to]
					let cloneFromNode = fromNode.cloneNode(true)
					let cloenToNode = toNode.cloneNode(true)
					node.replaceChild(cloneFromNode, toNode)
					node.replaceChild(cloenToNode, fromNode)
					break
				default:
					break
			}
		})
}

最后

Virtual Dom 算法的实现也就是以下三步

  1. 通过 JS 来模拟创建 DOM 对象
  2. 判断两个对象的差异
  3. 渲染差异
let test4 = new Element('div', { class: 'my-div' }, ['test4'])
let test5 = new Element('ul', { class: 'my-div' }, ['test5'])

let test1 = new Element('div', { class: 'my-div' }, [test4])

let test2 = new Element('div', { id: '11' }, [test5, test4])

let root = test1.render()

let pathchs = diff(test1, test2)
console.log(pathchs)

setTimeout(() => {
	console.log('开始更新')
	patch(root, pathchs)
	console.log('结束更新')
}, 1000)

当然目前的实现还略显粗糙,但是对于理解 Virtual Dom 算法来说已经是完全足够的了。

文章中的代码你可以在 这里 阅读。本系列更新的文章都会更新在这个仓库中,有兴趣的可以关注下。

下篇文章的内容将会是状态管理,敬请期待。

最后附上我的公众号

深度解析 Vue 响应式原理

Vue 初始化

在 Vue 的初始化中,会先对 props 和 data 进行初始化

Vue.prototype._init = function(options?: Object) {
  // ...
  // 初始化 props 和 data
  initState(vm)
  initProvide(vm)
  callHook(vm, 'created')

  if (vm.$options.el) {
    // 挂载组件
    vm.$mount(vm.$options.el)
  }
}

接下来看下如何初始化 props 和 data

export function initState(vm: Component) {
  // 初始化 props
  if (opts.props) initProps(vm, opts.props)
  if (opts.data) {
    // 初始化 data
    initData(vm)
  }
}
function initProps(vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = (vm._props = {})
  // 缓存 key
  const keys = (vm.$options._propKeys = [])
  const isRoot = !vm.$parent
  // 非根组件的 props 不需要观测
  if (!isRoot) {
    toggleObserving(false)
  }
  for (const key in propsOptions) {
    keys.push(key)
    // 验证 prop
    const value = validateProp(key, propsOptions, propsData, vm)
    // 通过 defineProperty 函数实现双向绑定
    defineReactive(props, key, value)
    // 可以让 vm._props.x 通过 vm.x 访问
    if (!(key in vm)) {
      proxy(vm, `_props`, key)
    }
  }
  toggleObserving(true)
}

function initData(vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function' ? getData(data, vm) : data || {}
  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' &&
      warn(
        'data functions should return an object:\n' +
          'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
        vm
      )
  }
  // proxy data on instance
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  while (i--) {
    const key = keys[i]
    if (props && hasOwn(props, key)) {
    } else if (!isReserved(key)) {
      // 可以让 vm._data.x 通过 vm.x 访问
      proxy(vm, `_data`, key)
    }
  }
  // 监听 data
  observe(data, true /* asRootData */)
}
export function observe(value: any, asRootData: ?boolean): Observer | void {
  // 如果 value 不是对象或者使 VNode 类型就返回
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  // 使用缓存的对象
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    // 创建一个监听者
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}
export class Observer {
  value: any
  dep: Dep
  vmCount: number // number of vms that has this object as root $data

  constructor(value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    // 通过 defineProperty 为对象添加 __ob__ 属性,并且配置为不可枚举
    // 这样做的意义是对象遍历时不会遍历到 __ob__ 属性
    def(value, '__ob__', this)
    // 判断类型,不同的类型不同处理
    if (Array.isArray(value)) {
      // 判断数组是否有原型
      // 在该处重写数组的一些方法,因为 Object.defineProperty 函数
      // 对于数组的数据变化支持的不好,这部分内容会在下面讲到
      const augment = hasProto ? protoAugment : copyAugment
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
  // 遍历对象,通过 defineProperty 函数实现双向绑定
  walk(obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
  // 遍历数组,对每一个元素进行观测
  observeArray(items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

Object.defineProperty

无论是对象还是数组,需要实现双向绑定的话最终都会执行这个函数,该函数可以监听到 setget 的事件。

export function defineReactive(
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 创建依赖实例,通过闭包的方式让
  // set get 函数使用
  const dep = new Dep()
  // 获得属性对象
  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // 获取自定义的 getter 和 setter
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }
  // 如果 val 是对象的话递归监听
  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    // 拦截 getter,当取值时会触发该函数
    get: function reactiveGetter() {
      const value = getter ? getter.call(obj) : val
      // 进行依赖收集
      // 初始化时会在初始化渲染 Watcher 时访问到需要双向绑定的对象
      // 从而触发 get 函数
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    // 拦截 setter,当赋值时会触发该函数
    set: function reactiveSetter(newVal) {
      const value = getter ? getter.call(obj) : val
      // 判断值是否发生变化
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // 如果新值是对象的话递归监听
      childOb = !shallow && observe(newVal)
      // 派发更新
      dep.notify()
    },
  })
}

Object.defineProperty 中自定义 getset 函数,并在 get 中进行依赖收集,在 set 中派发更新。接下来我们先看如何进行依赖收集。

依赖收集

依赖收集是通过 Dep 来实现的,但是也与 Watcher 息息相关

export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }
  // 添加观察者
  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
  // 移除观察者
  removeSub (sub: Watcher) {
    remove(this.subs, sub)
  }

  depend () {
    if (Dep.target) {
      // 调用 Watcher 的 addDep 函数
      Dep.target.addDep(this)
    }
  }
  // 派发更新
  notify () {
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}
// 同一时间只有一个观察者使用,赋值观察者
Dep.target = null

对于 Watcher 来说,分为两种 Watcher,分别为渲染 Watcher 和用户写的 Watcher。渲染 Watcher 是在初始化中实例化的。

export function mountComponent(
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  // ...
  let updateComponent
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
  } else {
    // 组件渲染,该回调会在初始化和数据变化时调用
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }
  // 实例化渲染 Watcher
  new Watcher(
    vm,
    updateComponent,
    noop,
    {
      before() {
        if (vm._isMounted) {
          callHook(vm, 'beforeUpdate')
        }
      },
    },
    true /* isRenderWatcher */
  )
  return vm
}

接下来看一下 Watcher 的部分实现

export default class Watcher {
  constructor(
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    // ...
    if (this.computed) {
      this.value = undefined
      this.dep = new Dep()
    } else {
      this.value = this.get()
    }
  }

  get() {
    // 该函数用于缓存 Watcher
    // 因为在组件含有嵌套组件的情况下,需要恢复父组件的 Watcher
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      // 调用回调函数,也就是 updateComponent 函数
      // 在这个函数中会对需要双向绑定的对象求值,从而触发依赖收集
      value = this.getter.call(vm, vm)
    } catch (e) {
      if (this.user) {
        handleError(e, vm, `getter for watcher "${this.expression}"`)
      } else {
        throw e
      }
    } finally {
      // "touch" every property so they are all tracked as
      // dependencies for deep watching
      if (this.deep) {
        traverse(value)
      }
      // 恢复 Watcher
      popTarget()
      // 清理依赖,判断是否还需要某些依赖,不需要的清除
      // 这是为了性能优化
      this.cleanupDeps()
    }
    return value
  }
  // 在依赖收集中调用
  addDep(dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        // 调用 Dep 中的 addSub 函数
        // 将当前 Watcher push 进数组
        dep.addSub(this)
      }
    }
  }
}
export function pushTarget(_target: ?Watcher) {
  // 设置全局的 target
  if (Dep.target) targetStack.push(Dep.target)
  Dep.target = _target
}
export function popTarget() {
  Dep.target = targetStack.pop()
}

以上就是依赖收集的全过程。核心流程是先对配置中的 props 和 data 中的每一个值调用 Obeject.defineProperty() 来拦截 setget 函数,再在渲染 Watcher 中访问到模板中需要双向绑定的对象的值触发依赖收集。

派发更新

改变对象的数据时,会触发派发更新,调用 Depnotify 函数

notify () {
  // 执行 Watcher 的 update
  const subs = this.subs.slice()
  for (let i = 0, l = subs.length; i < l; i++) {
    subs[i].update()
  }
}
update () {
  if (this.computed) {
    // ...
  } else if (this.sync) {
    // ...
  } else {
  // 一般会进入这个条件
    queueWatcher(this)
  }
}
export function queueWatcher(watcher: Watcher) {
// 获得 id
  const id = watcher.id
  // 判断 Watcher 是否 push 过
  // 因为存在改变了多个数据,多个数据的 Watch 是同一个
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
    // 最初会进入这个条件
      queue.push(watcher)
    } else {
      // 在执行 flushSchedulerQueue 函数时,如果有新的派发更新会进入这里
      // 插入新的 watcher
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // 最初会进入这个条件
    if (!waiting) {
      waiting = true
      // 将所有 Watcher 统一放入 nextTick 调用
      // 因为每次派发更新都会引发渲染
      nextTick(flushSchedulerQueue)
    }
  }
}
function flushSchedulerQueue() {
  flushing = true
  let watcher, id

  // 根据 id 排序 watch,确保如下条件
  // 1. 组件更新从父到子
  // 2. 用户写的 Watch 先于渲染 Watch
  // 3. 如果在父组件 watch run 的时候有组件销毁了,这个 Watch 可以被跳过
  queue.sort((a, b) => a.id - b.id)

  // 不缓存队列长度,因为在遍历的过程中可能队列的长度发生变化
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
    // 执行 beforeUpdate 钩子函数
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    // 在这里执行用户写的 Watch 的回调函数并且渲染组件
    watcher.run()
    // 判断无限循环
    // 比如在 watch 中又重新给对象赋值了,就会出现这个情况
    if (process.env.NODE_ENV !== 'production' && has[id] != null) {
      circular[id] = (circular[id] || 0) + 1
      if (circular[id] > MAX_UPDATE_COUNT) {
        warn(
          'You may have an infinite update loop ' +
            (watcher.user
              ? `in watcher with expression "${watcher.expression}"`
              : `in a component render function.`),
          watcher.vm
        )
        break
      }
    }
  }
    // ...
}

以上就是派发更新的全过程。核心流程就是给对象赋值,触发 set 中的派发更新函数。将所有 Watcher 都放入 nextTick 中进行更新,nextTick 回调中执行用户 Watch 的回调函数并且渲染组件。

Object.defineProperty 的缺陷

以上已经分析完了 Vue 的响应式原理,接下来说一点 Object.defineProperty 中的缺陷。

如果通过下标方式修改数组数据或者给对象新增属性并不会触发组件的重新渲染,因为 Object.defineProperty 不能拦截到这些操作,更精确的来说,对于数组而言,大部分操作都是拦截不到的,只是 Vue 内部通过重写函数的方式解决了这个问题。

对于第一个问题,Vue 提供了一个 API 解决

export function set(target: Array<any> | Object, key: any, val: any): any {
  // 判断是否为数组且下标是否有效
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    // 调用 splice 函数触发派发更新
    // 该函数已被重写
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  // 判断 key 是否已经存在
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' &&
      warn(
        'Avoid adding reactive properties to a Vue instance or its root $data ' +
          'at runtime - declare it upfront in the data option.'
      )
    return val
  }
  // 如果对象不是响应式对象,就赋值返回
  if (!ob) {
    target[key] = val
    return val
  }
  // 进行双向绑定
  defineReactive(ob.value, key, val)
  // 手动派发更新
  ob.dep.notify()
  return val
}

对于数组而言,Vue 内部重写了以下函数实现派发更新

// 获得数组原型
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
// 重写以下函数
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse',
]
methodsToPatch.forEach(function(method) {
  // 缓存原生函数
  const original = arrayProto[method]
  // 重写函数
  def(arrayMethods, method, function mutator(...args) {
    // 先调用原生函数获得结果
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    // 调用以下几个函数时,监听新数据
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // 手动派发更新
    ob.dep.notify()
    return result
  })
})

一句话讲明白 WebAssembly、微前端等技术背后的核心

WebAssembly 是在浏览器端可执行的字节码,主要解决的问题是性能。编辑器能把 C、C++、Go、TS 等语言编译成 WebAssembly 并能在浏览器中运行。

使用场景一般就是对性能有很高要求的应用,另外也可以把一些本来需要在后端完成的操作放到前端来做。比如视频解码、图片处理等等。

我们需要学他嘛?99.9% 的开发者都不需要去学习它,WebAssembly 更多的是让原本写 C++、Go 语言的这批人能在浏览器上干些原本做不到的事情。

微前端借鉴了后端微服务的**,核心就是把原本庞大的应用拆包,能够让这些单独的包独立打包部署运行,可以直接看成把一个应用拆成了一个个小的模块。

微前端适合庞大且老旧的工程,协作人员很多。举个例子,你有一个项目很老旧了,技术栈用的还是 JQuery 或者 React 很老的版本。当下你们需要使用 React 16 去开发新功能的话,这时候重写整个应用肯定是不现实的。那么此时你就可以通过微前端去解决问题,在不影响不修改旧功能的同时又能使用新的技术栈去写新功能。或者更极端一点,通过微前端你们团队可以在这个项目里各自使用三大框架而不影响他人。

如果你的项目小,协作的人也不多,没什么必要去做微前端,了解一下它解决的问题就行了。

Serverless 也就是无服务架构,当然它不是真的不需要服务器了。服务器还是需要的,只是现在服务器不需要我管了,只需要提供代码逻辑就好了,它帮助开发者更聚焦在代码层面而不是工程层面。

Serverless 中目前最常见的分类应该就是云函数了(FaaS)。写完代码以及运行的条件然后往云上一丢就好了,什么部署啦、扩容啦、容灾啦等等你啥都不用管,只管调用函数就好了。

那么前端需要学习 Serverless 么?你觉得你学这个有啥用?

脚手架的核心很简单,就是帮你运行了 git clone xxx,当然这是它最简单的一个实现。

在工程中使用的脚手架,一般都是为了帮助开发者根据选项快速生成模板,并集成了一套开发及部署中常用的套件。

你如果想自己搞个脚手架其实也很方便。核心就是搞出几套模板,比如说适用于 PC 端、手机端的,多页单页应用,JS 或者 TS 的。这样一分你就能搞出很多套模板了,然后集成下 Webpack 配置、npm script 等等。最后用上命令行的开发库,提供给用户几个选项,然后分别去这些模板的仓库里拉代码,over~

当然以上的做法不高级,更高级的做法还能动态修改模板,如果你想实现这样的脚手架,推荐直接看三大框架的脚手架了。

虽然前端技术栈看着很多很杂,看着高大上其实就那样,而且很多都没啥必要学,了解一下这些技术解决了什么问题,如何做的就够了。

最后

觉得文章还行的读者可以点个赞,另外有任何问题也可以评论区交流。

另外笔者的第二次公开课 1.12 晚上 8 点在 B 站直播,有兴趣的可以扫码下放公众号二维码,发送「公开课」获取直播详情。

微信扫码关注公众号,订阅更多精彩内容                                                                 加笔者微信群聊技术                                                                    

如何正确的使用你的时间

你是否时常会焦虑时间过的很快,没时间学习,本文将会分享一些个人的见解。

花时间补基础,读文档

在工作中我们时常会花很多时间去 debug,但是你是否发现很多问题最终只是你基础不扎实或者文档没有仔细看。

基础是你技术的基石,一定要花时间打好基础,而不是追各种新的技术。一旦你的基础扎实,学习各种新的技术也肯定不在话下,因为新的技术,究其根本都是相通的。

文档同样也是一门技术的基础。一个优秀的库,开发人员肯定已经把如何使用这个库都写在文档中了,仔细阅读文档一定会是少写 bug 的最省事路子。

学会搜索

如果你还在使用百度搜索编程问题,请尽快抛弃这个垃圾搜索引擎。同样一个关键字,使用百度和谷歌,谷歌基本完胜的。即使你使用中文在谷歌中搜索,得到的结果也往往是谷歌占优,所以如果你想迅速的通过搜索引擎来解决问题,那一定是谷歌。

学点英语

说到英语,一定是大家所最不想听的。其实我一直认为程序员学习英语是简单的,因为我们工作中是一直接触着英语,并且看懂技术文章,文档所需要的单词量是极少的。我时常在群里看到大家发出一个问题的截图问什么原因,其实在截图中英语已经很明白的说明了问题的所在,如果你的英语过关,完全不需要浪费时间来提问和搜索。所以我认为学点英语也是节省时间中很重要的一点。

那么如何去学习呢,chrome 装个翻译插件,直接拿英文文档或文章读,不会的就直接划词翻译,然后记录下这个单词并背诵。每天花半小时看点英文文档和文章,坚持两个月,你的英语水平不说别的,看文档和文章绝对不会有难题了。这一定是一个很划算的个人时间投资,花点时间学习英语,能为你将来的技术之路铺平很多坎。

画个图,想一想再做

你是否遇到过这种问题,需求一下来,看一眼,然后马上就按照设计稿开始做了,可能中间出个问题导致你需要返工。

如果你存在这样的问题,我很推荐在看到设计稿和需求的时候花点时间想一想,画一画。考虑一下设计稿中是否可以找到可以拆分出来的复用组件,是否存在之前写过的组件。该如何组织这个界面,数据的流转是怎么样的。然后画一下这个页面的需求,最后再动手做。

利用好下班时间学习

说到下班时间,那可能就有人说了公司很迟下班,这其实是国内很普遍的情况。但是我认为正常的加班是可以的,但是强制的加班就是在损耗你的身体和前途。

可以这么说,大部分的 996 公司,加班的这些时间并不会增加你的技术,无非就是在写一些重复的业务逻辑。也许你可以拿到更多的钱,但是代价是身体还有前途。程序员是靠技术吃饭的,如果你长久呆在一个长时间加班的公司,不能增长你的技术还要吞噬你的下班学习时间,那么你一定会废掉的。如果你遇到了这种情况,只能推荐尽快跳槽到非 996 的公司。

那么如果你有足够的下班时间,一定要花上 1, 2 小时去学习,上班大家基本都一样,技术的精进就是看下班以后的那几个小时了。如果你能利用好下班时间来学习,坚持下去,时间一定会给你很好的答复。

列好 ToDo

我喜欢规划好一段时间内要做的事情,并且要把事情拆分为小点。给 ToDo 列好优先级,紧急的优先级最高。相同优先级的我喜欢先做简单的,因为这样一旦完成就能划掉一个,提高成就感。

反思和整理

每周末都会花上点时间整理下本周记录的笔记和看到的不错文章。然后考虑下本周完成的工作和下周准备要完成的工作。

面试前如何准备才能提高成功率

又到了一年中的招聘旺季的时候,想必很多人都萌动了跳槽的心,但是肯定很多人会关心当下好不好找工作,怎么样才能找到好的工作这些类似的问题。

那么本文就是来解答这些问题的,如果说你想知道以下几点,就可以看下去了

  • 当下好找工作嘛
  • 如何写简历
  • 如何挑选靠谱的公司
  • 我多少多少经验能拿多少的工资
  • 问到项目中的技术难点怎么回答
  • 2019 年前端面试押题
  • 如何和 HR 聊天,比如谈钱等等

当下好找工作嘛

想必大家现在经常能看到某某公司又裁员了,会担心是不是找不到工作了。其实总的来说虽然当下的环境确实不怎么好,但是有裁员的公司,也肯定有招人的公司,并且招人的公司一定比裁员的公司多得多,就比如我司「宋小菜」就有很多的 HC。

那么再来回答这个问题「当下好不好找工作」。对于技术好的人来说,永远不会担心这个问题。但是对于技术不那么好的人来说,确实需要一些技巧才能比别人有更多的机会。比如说写一封清晰明了的简历,在面试前好好准备等等。

总的来说,机会是留给有准备的人。无论环境好与差,认真准备的你肯定会比别人有更多的机会。

如何写简历

平时有在做修改简历的收费服务,在公司里也会当个面试官啥的,总的来说也算看过很多简历了。但是大部分人的简历的套路都是一样的,项目用了什么技术栈,做了什么功能,总的来说就是流水账。

那么一旦你写出有别于这类套路的简历,你的简历必然会被用人方多看几眼,增加点成功率也是必然的。

一般来说我会这样建议别人写简历:

  • 全文杜绝任何的精通字眼,如果你真的精通,不需要投简历找工作了
  • 简历控制在两页左右,不需要找什么简历模板,直接 Markdown 生成 PDF 文件
  • 任何英文单词注意大小写,数字以及英文注意与中文之间有空格
  • 无需写一大堆个人技术栈,几个前端必备技能以及与对方匹配的技术栈足以。什么都放上去的话是柄双刃剑,并且更多的时候坑的是自己
  • 项目中无需介绍这个项目到底有啥功能,单刀直入这个项目中最值得说的内容。比如遇到的 Bug,自己的思考等等。但是可能很多人会说,业务很简单没什么好说的或者压根没遇到什么问题。遇到这个问题的时候,首先跳脱出业务的框架,去思考其他的问题。相信每个人都写过组件,但是对于如何设计一个好的组件来说就不是每个人都能做的事情了。另外对于没遇到什么问题的情况,最简单的方式就是一行行的看 Git Commit 信息,从这方面找到灵感
  • 写项目经验最好按照这样的思路:遇到了什么问题,如何解决以及结果
  • 假设简历上的每个技术点面试官都会问到,斟酌所有的知识点,保证都能回答

如何挑选靠谱的公司

一家靠谱的公司,一定是以下几点加起来的:牛逼的核心管理层、不错的 idea、高额的融资、有前景的行业。

前两点对于求职者来说基本是黑盒,因此我们只能从后面两点来挑选公司了。

早期的融资越高,领投的公司越牛逼,那么这家公司靠谱的几率越大。因为这些公司的决策人都不是傻子,人家肯定有充足的理由才会选择相信并投钱给这家公司。

另外一个有前景的行业也是必须条件,如果你不知道啥是有前景的行业,就从与人息息相关的行业挑选吧。比如吃、住、行、教育等等。

这时候你可能会说,那我如果了解到一家公司的这些情况呢?那么「天眼查」可以完美解决你这个问题。我们可以通过这个网站详细了解到一家公司的融资、行业、竞品、法律风险、管理层、产品等等信息。

我多少多少经验能拿多少的工资

对于这个问题,我真的很想说多少经验真的和多少工资没多大关系。

相同的一年经验,有人只能拿 10K,但是有人却能拿到 20K,原因就在于两个人的技术能力不一样。

你技术好,就能比别人多要工资;你技术一般,就只能拿少点的工资,这是一个很现实的问题。所以工资只与技术挂钩,而不是你所谓的经验。

如果只是想了解行业平均薪水,直接去看当地的企业给出的工资是多少就好了。如果觉得自己薪水不符合行业平均薪水,就勇敢的跳槽呗。

问到项目中的技术难点怎么回答

对于这个问题来说,面试官考察的就是你的学习能力以及解决问题的能力。

很多人遇到这个问题会很懵逼,感觉基本啥问题都谷歌解决了。当然如果你真的有遇到不是马上能通过谷歌解决的问题便是极好的,按照描述问题、如何解决问题、结果这几个步骤来回答就行了。

那么如果你觉得你的项目真的很简单,没有什么可说的话,就按照以下的几个思路来聊这个话题。比如说你之前从没接触过某个技术,你是如何去学习这个技术的,在学习的过程中遇到了什么问题,怎么解决的。比如说你写了这个项目,自己有了什么感悟、想法。

因为这道题目面试官不是说一定要听你讲出项目里到底遇到了什么难的问题,而是考察你的学习能力以及解决问题的能力。即使你没有什么干货可以说,说点自己的学习过程、挫折、想法感悟也是可以的,毕竟总比说感觉都很简单来的好。

2019 年前端面试押题

说到面试押题,不得不拿出我的快 15K Stars 的 repo。认真读完这个 repo,随便去找面经看,百分之 70 的题目你都能回答,当然 我的小册 效果会更佳点。。

反正 repo 开源的,大家看了就会来感谢我的 [滑稽.jpg]

如何和 HR 聊天,比如谈钱等等

首先你面到 HR 了,说明你基本已经成为备选人之一了。这时候 HR 会和你聊很多问题,这些问题都是为了了解你的一些个人情况的。比如说性格啦、反应能力、情商等等。另外大部分公司的 HR 并没有一票否决权,面试没有成功多半是有更好的备选人而不是因为 HR 把你卡掉了。

然后说到谈钱的一个问题。首先以最少的工资招到需要的人肯定是 HR 的考核之一,所以压价是很正常的一个事情。并且上家公司的薪资也是一个很重要的参考,一般来说涨薪幅度在 30% 以上是很牛逼的事情了,通常都在 20% 左右。

你的开价一般就是 offer 的上限了,考虑到压价的情况,你可以在原本期望薪水上上浮 1K 左右,然后可以根据面试的情况来有选择性的开价。

  • 面的不错,本来想要 16K 的,那么就多要个 1-2K 没啥问题
  • 面的一般,那就报 16 K 吧
  • 面的一般或者不大好,但是又很想进这家公司,可以酌情下降 1-2K,这个主要还是看自己
  • 不怎么想去这家公司,随意开价

最后

你可以关注我的 blog,这里会第一时间更新我的所有文章。并且除了文章以外,还会记录我平时学习的一些内容,比如刷的 Leetcode、学的 Mooc、书籍笔记以及写的一些小玩具。

最后,觉得内容有帮助可以关注下我的公众号 「前端真好玩」咯,会有很多好东西等着你。

重学 JS:为啥 await 在 forEach 中不生效

这是重学 JS 系列的第三篇文章,写这个系列的初衷也是为了夯实自己的 JS 基础或者了解一些之前不知道的东西。既然是重学,肯定不会从零开始介绍一个知识点,如有遇到不会的内容请自行查找资料。

不知道你有没有写过类似的代码,反正以前我是写过

function test() {
	let arr = [3, 2, 1]
	arr.forEach(async item => {
		const res = await fetch(item)
		console.log(res)
	})
	console.log('end')
}

function fetch(x) {
	return new Promise((resolve, reject) => {
		setTimeout(() => {
			resolve(x)
		}, 500 * x)
	})
}

test()

我当时期望的打印顺序是

3
2
1
end

结果现实与我开了个玩笑,打印顺序居然是

end
1
2
3

为什么?

其实原因很简单,那就是 forEach 只支持同步代码。

我们可以参考下 Polyfill 版本的 forEach,简化以后类似就是这样的伪代码

while (index < arr.length) {
		// 也就是我们传入的回调函数
		callback(item, index)
}

从上述代码中我们可以发现,forEach 只是简单的执行了下回调函数而已,并不会去处理异步的情况。并且你在 callback 中即使使用 break 也并不能结束遍历。

怎么解决?

一般来说解决的办法有两种。

第一种是使用 Promise.all 的方式

async function test() {
	let arr = [3, 2, 1]
	await Promise.all(
		arr.map(async item => {
			const res = await fetch(item)
			console.log(res)
		})
	)
	console.log('end')
}

这样可以生效的原因是 async 函数肯定会返回一个 Promise 对象,调用 map 以后返回值就是一个存放了 Promise 的数组了,这样我们把数组传入 Promise.all 中就可以解决问题了。但是这种方式其实并不能达成我们要的效果,如果你希望内部的 fetch 是顺序完成的,可以选择第二种方式。

另一种方法是使用 for...of

async function test() {
	let arr = [3, 2, 1]
	for (const item of arr) {
		const res = await fetch(item)
		console.log(res)
	}
	console.log('end')
}

这种方式相比 Promise.all 要简洁的多,并且也可以实现开头我想要的输出顺序。

但是这时候你是否又多了一个疑问?为啥 for...of 内部就能让 await 生效呢。

因为 for...of 内部处理的机制和 forEach 不同,forEach 是直接调用回调函数,for...of 是通过迭代器的方式去遍历。

async function test() {
	let arr = [3, 2, 1]
	const iterator = arr[Symbol.iterator]()
	let res = iterator.next()
	while (!res.done) {
		const value = res.value
		const res1 = await fetch(value)
		console.log(res1)
		res = iterator.next()
	}
	console.log('end')
}

以上代码等价于 for...of,可以看成 for...of 是以上代码的语法糖。

最后

以上就是本篇文章的全部内容了,如果你还有什么疑问欢迎在评论区与我互动。

我所有的系列文章都会在我的 Github 中最先更新,有兴趣的可以关注下。今年主要会着重写以下三个专栏

  • 重学 JS
  • React 进阶
  • 重写组件

最后,觉得内容有帮助可以关注下我的公众号 「前端真好玩」咯,会有很多好东西等着你。

剖析 React 源码:render 流程(一)

这是我的剖析 React 源码的第二篇文章,如果你没有阅读过之前的文章,请务必先阅读一下 第一篇文章 中提到的一些注意事项,能帮助你更好地阅读源码。

文章相关资料

现在请大家打开 我的代码 并定位到 react-dom 文件夹下的 src 中的 ReactDOM.js 文件,今天的内容会从这里开始。

render

想必大家在写 React 项目的时候都写过类似的代码

ReactDOM.render(<APP />, document.getElementById('root')

这句代码告诉了 React 应用我们想在容器中渲染出一个组件,这通常也是一个 React 应用的入口代码,接下来我们就来梳理整个 render 的流程,并且会分为几篇文章来讲解,因为流程实在太长了。

首先请大家先定位到 ReactDOM.js 文件的第 702 行代码,开始今天的旅程。

这部分代码其实没啥好说的,唯一需要注意的是在调用 legacyRenderSubtreeIntoContainer 函数时写死了第四个参数 forceHydratefalse。这个参数为 true 时表明了是服务端渲染,因为我们分析的是客户端渲染,因此后面有关这部分的内容也不会再展开。

接下来进入 legacyRenderSubtreeIntoContainer 函数中,这部分代码分为两块来讲。第一部分是没有 root 之前我们首先需要创建一个 root(对应这篇文章),第二部分是有 root 之后的渲染流程(对应接下来的文章)。

一开始进来函数的时候肯定是没有 root 的,因此我们需要去创建一个 root,大家可以发现这个 root 对象同样也被挂载在了 container._reactRootContainer 上,也就是我们的 DOM 容器上。
如果你手边有 React 项目的话,在控制台键入如下代码就可以看到这个 root 对象了。

document.querySelector('#root')._reactRootContainer

大家可以看到 rootReactRoot 构造函数构造出来的,并且内部有一个 _internalRoot 对象,这个对象是本文接下来要重点介绍的 fiber 对象,接下来我们就来一窥究竟吧。

首先还是和上文中提到的 forceHydrate 属性相关的内容,不需要管这部分,反正 shouldHydrate 肯定为 false

接下来是将容器内部的节点全部移除,一般来说我们都是这样写一个容器的的

<div id='root'></div>

这样的形式肯定就不需要去移除子节点了,这也侧面说明了一点那就是容器内部不要含有任何的子节点。一是肯定会被移除掉,二来还要进行 DOM 操作,可能还会涉及到重绘回流等等。

最后就是创建了一个 ReactRoot 对象并返回。接下来的内容中我们会看到好几个 root,可能会有点绕。

ReactRoot 构造函数内部就进行了一步操作,那就是创建了一个 FiberRoot 对象,并挂载到了 _internalRoot 上。和 DOM 树一样,fiber 也会构建出一个树结构(每个 DOM 节点一定对应着一个 fiber 对象),FiberRoot 就是整个 fiber 树的根节点,接下来的内容里我们将学习到关于 fiber 相关的内容。这里提及一点,fiber 和 Fiber 是两个不一样的东西,前者代表着数据结构,后者代表着新的架构。

createFiberRoot 函数内部,分别创建了两个 root,一个 root 叫做 FiberRoot,另一个 root 叫做 RootFiber,并且它们两者还是相互引用的。

这两个对象内部拥有着数十个属性,现在我们没有必要一一去了解它们各自有什么用处,在当下只需要了解少部分属性即可,其他的属性我们会在以后的文章中了解到它们的用处。

对于 FiberRoot 对象来说,我们现在只需要了解两个属性,分别是 containerInfocurrent。前者代表着容器信息,也就是我们的 document.querySelector('#root');后者指向 RootFiber

对于 RootFiber 对象来说,我们需要了解的属性稍微多点

function FiberNode(
	tag: WorkTag,
	pendingProps: mixed,
	key: null | string,
	mode: TypeOfMode,
) {
	this.stateNode = null;
	this.return = null;
	this.child = null;
	this.sibling = null;
	this.effectTag = NoEffect;
	this.alternate = null;
}

stateNode 上文中已经讲过了,这里就不再赘述。

returnchildsibling 这三个属性很重要,它们是构成 fiber 树的主体数据结构。fiber 树其实是一个单链表树结构,returnchild 分别对应着树的父子节点,并且父节点只有一个 child 指向它的第一个子节点,即便是父节点有好多个子节点。那么多个子节点如何连接起来呢?答案是 sibling,每个子节点都有一个 sibling 属性指向着下一个子节点,都有一个 return 属性指向着父节点。这么说可能有点绕,我们通过图来了解一下这个 fiber 树的结构。

const APP = () => (
		<div>
				<span></span>
				<span></span>
		</div>
)
ReactDom.render(<APP/>, document.querySelector('#root'))

假如说我们需要渲染出以上组件,那么它们对应的 fiber 树应该长这样

从图中我们可以看到,每个组件或者 DOM 节点都会对应着一个 fiber 对象。另外你手边有 React 项目的话,也可以在控制台输入如下代码,查看 fiber 树的整个结构。

// 对应着 FiberRoot
const fiber = document.querySelector('#root')._reactRootContainer._internalRoot

另外两个属性在本文中虽然用不上,但是看源码的时候笔者觉得很有意思,就打算拿出来说一下。

在说 effectTag 之前,我们先来了解下啥是 effect,简单来说就是 DOM 的一些操作,比如增删改,那么 effectTag 就是来记录所有的 effect 的,但是这个记录是通过位运算来实现的,这里effectTag 相关的二进制内容。

如果我们想新增一个 effect 的话,可以这样写 effectTag |= Update;如果我们想删除一个 effect 的话,可以这样写 effectTag &= ~Update

最后是 alternate 属性。其实在一个 React 应用中,通常来说都有两个 fiebr 树,一个叫做 old tree,另一个叫做 workInProgress tree。前者对应着已经渲染好的 DOM 树,后者是正在执行更新中的 fiber tree,还能便于中断后恢复。两棵树的节点互相引用,便于共享一些内部的属性,减少内存的开销。毕竟前文说过每个组件或 DOM 都会对应着一个 fiber 对象,应用很大的话组成的 fiber 树也会很大,如果两棵树都是各自把一些相同的属性创建一遍的话,会损失不少的内存空间及性能。

当更新结束以后,workInProgress tree 会将 old tree 替换掉,这种做法称之为 double buffering,这也是性能优化里的一种做法,有兴趣的同学可以自行查找资料。

总结

以上就是本文的全部内容了,最后通过一张流程图总结一下这篇文章的内容。

最后

阅读源码是一个很枯燥的过程,但是收益也是巨大的。如果你在阅读的过程中有任何的问题,都欢迎你在评论区与我交流。

另外写这系列是个很耗时的工程,需要维护代码注释,还得把文章写得尽量让读者看懂,最后还得配上画图,如果你觉得文章看着还行,就请不要吝啬你的点赞。

下一篇文章还是 render 流程相关的内容。

最后,觉得内容有帮助可以关注下我的公众号 「前端真好玩」咯,会有很多好东西等着你。

【第一期】本周我们 36 人学了什么

程序员这行如果想一直做下去,那么持续学习是必不可少的。

大家找工作通常会喜欢技术氛围好点的团队,因为这样能够帮助自己更好的成长,但是并不是每个团队都拥有这样的氛围。于是萌发一个念头,想建立一个地方,让一些人能在这块地方记录自己学习到的内容。这些内容通常会是一个小点,可能并不足以写成一篇文章。但是这个知识点可能很多人也不知道,那么通过这种记录的方式让别人同样也学习到这个知识点就是一个很棒的事情了。

如果你也想参与这个记录的事情,欢迎贡献你的一份力量,地址在这里

本周总共有 36 个人贡献了它所学到的知识,以下是一些整合后的内容,更详细的内容推荐前往仓库阅读。

笔者在微信环境中直播功能的实践

兼容问题

视频兼容相关

在安卓中,直接使用原生 video 会导致全屏播放,盖住所有元素,因此使用 x5 播放器。但是 x5 播放器还是存在问题,虽然不会盖住元素,但是会自己添加特效(盖一层导航栏蒙层)。

<video
  className='live-detail__video vjs-big-play-centered'
  id='live-player'
  controls={false}
  playsInline
  webkit-playsinline='true'
  x5-video-player-type='h5'
  x5-video-orientation='portrait'
  x5-playsinline='true'
  style={style}
/>

这样可以在安卓下使用 x5 播放器, playsInlinewebkit-playsinline 属性可以在 iOS 环境下启用内联播放。但是通过属性设置内联播放兼容性并不怎么好,所以这时候我们需要使用 iphone-inline-video 这个库,通过 enableInlineVideo(video) 就可以了。

视频自动播放

在安卓下视频自动播放的兼容性很差,因此只能让用户手动触发视频播放。但是在 iOS 下可以通过监听微信的事件实现视频的自动播放。

document.addEventListener("WeixinJSBridgeReady", function () {
    player.play()
}, false)

iOS 下协议问题

因为页面使用的是 HTTPS 协议,iOS 强制规定在 HTTPS 页面下也必须使用安全协议。因此使用 ws 协议的话在 iOS 下报错,后续使用 wss 协议解决。

体验问题

iOS 下键盘弹起收下

在 iOS 中,输入框弹起键盘前后,页面都可能出现问题,需要监听下键盘弹起收起的状态,然后自己滚动一下。

// 监听键盘收起及弹出状态
document.body.addEventListener('focusout', () => {
  if (isIphone()) {
    setTimeout(() => {
      document.body.scrollTop = document.body.scrollHeight
    }, 100)
  }
})

document.body.addEventListener('focusin', () => {
  if (isIphone()) {
    setTimeout(() => {
      document.body.scrollTop = document.body.scrollHeight
    }, 100)
  }
})

性能优化

聊天数据渲染

考虑到直播中聊天数据频繁,因此所有接收到的数据会先存入一个数组 buffer 中,等待 2 秒后统一渲染。

// 接收到消息就先 push 到缓存数组中
this.bufferAllComments.push({
  customerName: fromCustomerName,
  commentId,
  content,
  commentType
})
// 利用定时器,每两秒将数组中的中的 concat 到当前聊天数据中并清空缓存
this.commentTimer = setInterval(() => {
  if (this.bufferAllComments.length) {
    this.appendChatData(this.bufferAllComments)
    this.bufferAllComments = []
  }
}, 2000)

另外直播中其实涉及到了很多异步数据的拉取及状态的变更,这时候如果能使用 RxJS 能很好的解决数据流转的问题,后续可以尝试重构这部分的代码。

链表作为聊天数据的载体

同样考虑到直播中聊天数据频繁插入,因此使用链表来存储显示的聊天数据,目前只存储 50 条数据,这样删除前面的只要很简单。

  • 使用链表的原因是考虑到频繁的去除数组头部数据去造成空间复杂度的问题
  • 另外也实现了支持迭代器的功能,代码如下:
[Symbol.iterator] () {
  let current = null; let target = this
  return {
    next () {
      current = current != null ? current.next : target.head
      if (current != null) {
        return { value: current.value, done: false }
      }
      return { value: undefined, done: true }
    },
    return () {
      return { done: true }
    }
  }
}

JS

数组求和

let arr = [1, 2, 3, 4, 5]
eval(arr.join('+'))

数组完全展开

function myFlat(arr) {
  while (arr.some(t => Array.isArray(t))) {
   	arr = ([]).concat.apply([], arr);
  }
  return arr;
}
var arrTest1 = [1, [2, 3, [4]], 5, 6, [7, 8], [[9, [10, 11], 12], 13], 14];  
// Expected Output: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
console.log(myFlat(arrTest1)) 

实现 sleep 函数

function sleep(interval) {
  return new Promise(resolve => {
    setTimeout(resolve, interval);
  })
}
async function test() {
  for (let index = 0; index < 10; index++) {
    console.log(index);
    await sleep(2000)
  }
}

正则过滤违规词

var ma = "大xx".split('');
var regstr = ma.join('([^\u4e00-\u9fa5]*?)');
var str = "这是一篇文章,需要过滤掉大xx这三个词,大xx中间出汉字以外的字符 大_/_傻a1v逼和 大傻a1v逼";
var reg = new RegExp(regstr , 'g');
str.replace(reg,"<替换的词>");

Node

开启 Gzip

const express = require('express');
const compression = require('compression');
const app = express();
app.use(compression());

统计 Git 代码行数

const { exec } = require('child_process');
const { argv } = require('yargs');

const readLines = stdout => {
  const stringArray = stdout
    .toString()
    .split(/(\n)/g)
    .filter(str => str !== '\n' && str);
  const dataArray = [];
  stringArray.map(str => {
    const data = str.split(/(\t)/g).filter(str => str !== '\t');
    const [newLine, deletedLine, filePath] = data;
    dataArray.push({ newLine, deletedLine, filePath });
  });
  return dataArray;
};


try {
  if (!argv.commit) throw new Error('')
  exec(`git diff ${argv.commit} --numstat`, (error, stdout, stderr) => {
    console.table(readLines(stdout));
  });
} catch (e) {
  console.log(e);
}

实现一个only函数

var obj = {
    name: 'tobi',
    last: 'holowaychuk',
    email: '[email protected]',
    _id: '12345'
};

const only = (obj, para) => {
    if (!obj || !para) { new Error('please check your args!') }
    let newObj = {};
    let newArr = Array.isArray(para) ? para : typeof (para) === 'string' ? para.split(/ +/) : [];
    newArr.forEach((item) => {
        if (item && obj[item] && !newObj[item]) {
            newObj[item] = obj[item];
        }
    })
    return newObj;
}
// {email: "[email protected]", last: "holowaychuk", name: "tobi"}
console.log(only(obj, ['name', 'last', 'email']));
console.log(only(obj, 'name last    email'));

其他

工作中培养起来的几点认知

  1. 优先做最重要的事情,(可以自己写在笔记本上,每天的任务,也可以利用todolist类似的软件)
  2. 懂得“闭环思维”,(对领导定期汇报项目进展,对同事、下属及时同步项目进度)
  3. 拥有解决问题并快速解决问题的能力(解决各种问题,锻炼解决问题的思维,一条路不通要想别的方法)
  4. 做一个靠谱、聪明、皮实、值得信赖的人。提高自己的不可替代性。
  5. 凡事有交代,件件有着落,事事有回音。
  6. 感激bug,是bug让自己成长,要成长必须多解决bug.多承担任务。
  7. 积极乐观,做一个正能量的人。(远离负能量的人和事)

一段脚本,数组数据量超额后,内存突然变小了?

最后

这是一个需要大家一起分享才能持续下去的事情,光靠我一人分享是做不下去的。欢迎大家参与到这件事情中来,地址在这里

剖析 React 源码:先热个身

这是我的 React 源码解读课的第一篇文章,首先来说说为啥要写这个系列文章:

  • 现在工作中基本都用 React 了,由此想了解下内部原理
  • 市面上 Vue 的源码解读数不胜数,但是反观 React 相关的却寥寥无几,也是因为 React 源码难度较高,因此我想来攻克这个难题
  • 自己觉得看懂并不一定看懂了,写出来让读者看懂才是真懂了,因此我要把我读懂的东西写出来

这个系列文章预计篇数会超过十篇,React 版本为 16.8.6,以下是本系列文章你必须需要注意的地方:

  • 这是一门进阶课,如果涉及到你不清楚的内容,请自行谷歌,另外最好具备 React 的开发能力
  • 这是一门讲源码的课,只阅读是不大可能真正读懂的,需要辅以 Demo 和 Debug 才能真正理解代码的用途
  • 我 fork 了一份 16.8.6 版本的代码,并且会为读过的代码加上详细的中文注释。等不及我文章的同学可以先行阅读 我的仓库并且在阅读本系列文章的时候也请跟着阅读我注释的代码。因为版本不同可能会导致代码不同,并且我不会在文章中贴上大段的代码,只会对部分代码做更详细的解释,其他的代码可以跟着我的注释阅读
  • 阅读源码最先遇到的问题会是不知道该从何开始,我这份代码注释可以帮助大家解决这个问题,你只需要跟着我的 commit 阅读即可
  • 不会对任何 DEV 环境下的代码做解读,不会对所有代码进行解读,只会解读核心功能(即使这样也会是一个大工程)
  • 最后再提及一遍,请务必文章和 代码 相结合来看,为了篇幅考虑我不会将所有的代码都贴上来,我拷贝的累,读者看的也累

这篇文章内容不会很难,先给大家热个身,请大家打开 我的代码 并定位到 react 文件夹下的 src,这个文件夹也就是 React 的入口文件夹了。

开始进入正文前先说下这个系列中我的行文思路:1. 代码尽量通过图片展示,既美观又方便阅读,反正不需要大家复制代码。2. 文章中只会讲我认为重要或者有意思的代码,对于其他代码请自行阅读我的仓库,反正已经注释好代码了。3. 对于流程长的函数调用会使用流程图的方式来总结。4. 不会干巴巴的只讲代码,会结合实际来聊聊这些 API 能帮助我们解决什么问题。

文章相关资料

React.createElement

大家在写 React 代码的时候肯定写过 JSX,但是为什么一旦使用 JSX 就必须引入 React 呢?

这是因为我们的 JSX 代码会被 Babel 编译为 React.createElement,不引入 React 的话就不能使用 React.createElement 了。

<div id='1'>1</div>
// 上面的 JSX 会被编译成这样
React.createElement("div", {
	id: "1"
}, "1")

那么我们就先定位到 ReactElement.js 文件阅读下 createElement 函数的实现

export function createElement(type, config, children) {}

首先 createElement 函数接收三个参数,具体代表着什么相信大家可以通过上面 JSX 编译出来的东西自行理解。

然后是对于 config 的一些处理:

这段代码对 ref 以及 key 做了个验证(对于这种代码就无须阅读内部实现,通过函数名就可以了解它想做的事情),然后遍历 config 并把内建的几个属性(比如 refkey)剔除后丢到 props 对象中。

接下里是一段对于 children 的操作

首先把第二个参数之后的参数取出来,然后判断长度是否大于一。大于一的话就代表有多个 children,这时候 props.children 会是一个数组,否则的话只是一个对象。因此我们需要注意在对 props.children 进行遍历的时候要注意它是否是数组,当然你也可以利用 React.Children 中的 API,下文中也会对 React.Children 中的 API 进行讲解。

最后就是返回了一个 ReactElement 对象

内部代码很简单,核心就是通过 $$typeof 来帮助我们识别这是一个 ReactElement,后面我们可以看到很多这样类似的类型。另外我们需要注意一点的是:通过 JSX写的 <APP /> 代表着 ReactElementAPP 代表着 React Component。

以下是这一小节的流程图内容:

ReactBaseClasses

上文中讲到了 APP 代表着 React Component,那么这一小节我们就来阅读组件相关也就是 ReactBaseClasses.js 文件下的代码。

其实在阅读这部分源码之前,我以为代码会很复杂,可能包含了很多组件内的逻辑,结果内部代码相当简单。这是因为 React 团队将复杂的逻辑全部丢在了 react-dom 文件夹中,你可以把 react-dom 看成是 React 和 UI 之间的胶水层,这层胶水可以兼容很多平台,比如 Web、RN、SSR 等等。

该文件包含两个基本组件,分别为 ComponentPureComponent,我们先来阅读 Component 这部分的代码。

构造函数 Component 中需要注意的两点分别是 refsupdater,前者会在下文中专门介绍,后者是组件中相当重要的一个属性,我们可以发现 setStateforceUpdate 都是调用了 updater 中的方法,但是 updater 是 react-dom 中的内容,我们会在之后的文章中学习到这部分的内容。

另外 ReactNoopUpdateQueue 也有一个单独的文件,但是内部的代码看不看都无所谓,因为都是用于报警告的。

接下来我们来阅读 PureComponent 中的代码,其实这部分的代码基本与 Component 一致

PureComponent 继承自 Component,继承方法使用了很典型的寄生组合式。

另外这两部分代码你可以发现每个组件都有一个 isXXXX 属性用来标志自身属于什么组件。

以上就是这部分的代码,接下来的一小节我们将会学习到 refs 的一部分内容。

Refs

refs 其实有好几种方式可以创建:

  • 字符串的方式,但是这种方式已经不推荐使用
  • ref={el => this.el = el}
  • React.createRef

这一小节我们来学习 React.createRef 相关的内容,其余的两种方式不在这篇文章的讨论范围之内,请先定位到 ReactCreateRef.js 文件。

内部实现很简单,如果我们想使用 ref,只需要取出其中的 current 对象即可。

另外对于函数组件来说,是不能使用 ref 的,如果你不知道原因的话可以直接阅读 文档

当然在之前也是有取巧的方式的,就是通过 props 的方式传递 ref,但是现在我们有了新的方式 forwardRef 去解决这个问题。

具体代码见 forwardRef.js 文件,同样内部代码还是很简单

这部分代码最重要的就是我们可以在参数中获得 ref 了,因此我们如果想在函数组件中使用 ref 的话就可以把代码写成这样:

const FancyButton = React.forwardRef((props, ref) => (
	<button ref={ref} className="FancyButton">
		{props.children}
	</button>
))

ReactChildren

这一小节会是这篇文章中最复杂的一部分,可能需要自己写个 Demo 并且 Debug 一下才能真正理解源码为什么要这样实现。

首先大家需要定位到 ReactChildren.js 文件,这部分代码中我只会介绍关于 mapChildren 函数相关的内容,因为这部分代码基本就贯穿了整个文件了。

如果你没有使用过这个 API,可以先自行阅读 文档

对于 mapChildren 这个函数来说,通常会使用在组合组件设计模式上。如果你不清楚什么是组合组件的话,可以看下 Ant-design,它内部大量使用了这种设计模式,比如说 Radio.GroupRadio.Button,另外这里也有篇 文档 介绍了这种设计模式。

我们先来看下这个函数的一些神奇用法

React.Children.map(this.props.children, c => [[c, c]])

对于上述代码,map 也就是 mapChildren 函数来说返回值是 [c, c, c, c]。不管你第二个参数的函数返回值是几维嵌套数组,map 函数都能帮你摊平到一维数组,并且每次遍历后返回的数组中的元素个数代表了同一个节点需要复制几次。

如果文字描述有点难懂的话,就来看代码吧:

<div>
		<span>1</span>
		<span>2</span>
</div>

对于上述代码来说,通过 c => [[c, c]] 转换以后就变成了

<span>1</span>
<span>1</span>
<span>2</span>
<span>2</span>

接下里我们进入正题,来看看 mapChildren 内部到底是如何实现的。

这段代码有意思的部分是引入了对象重用池的概念,分别对应 getPooledTraverseContextreleaseTraverseContext 中的代码。当然这个概念的用处其实很简单,就是维护一个大小固定的对象重用池,每次从这个池子里取一个对象去赋值,用完了就将对象上的属性置空然后丢回池子。维护这个池子的用意就是提高性能,毕竟频繁创建销毁一个有很多属性的对象会消耗性能。

接下来我们来学习 traverseAllChildrenImpl 中的代码,这部分的代码需要分为两块来讲

这部分的代码相对来说简单点,主体就是在判断 children 的类型是什么。如果是可以渲染的节点的话,就直接调用 callback,另外你还可以发现在判断的过程中,代码中有使用到 $$typeof 去判断的流程。这里的 callback 指的是 mapSingleChildIntoContext 函数,这部分的内容会在下文中说到。

这部分的代码首先会判断 children 是否为数组。如果为数组的话,就遍历数组并把其中的每个元素都递归调用 traverseAllChildrenImpl,也就是说必须是单个可渲染节点才可以执行上半部分代码中的 callback

如果不是数组的话,就看看 children 是否可以支持迭代,原理就是通过 obj[Symbol.iterator] 的方式去取迭代器,返回值如果是个函数的话就代表支持迭代,然后逻辑就和之前的一样了。

讲完了 traverseAllChildrenImpl 函数,我们最后再来阅读下 mapSingleChildIntoContext 函数中的实现。

bookKeeping 就是我们从对象池子里取出来的东西,然后调用 func 并且传入节点(此时这个节点肯定是单个节点),此时的 func 代表着 React.mapChildren 中的第二个参数。

接下来就是判断返回值类型的过程:如果是数组的话,还是回归之前的代码逻辑,注意这里传入的 funcc => c,因为要保证最终结果是被摊平的;如果不是数组的话,判断返回值是否是一个有效的 Element,验证通过的话就 clone 一份并且替换掉 key,最后把返回值放入 result 中,result 其实也就是 mapChildren 的返回值。

至此,mapChildren 函数相关的内容已经解析完毕,还不怎么清楚的同学可以通过以下的流程图再复习一遍。

其余内容

前面几小节的内容已经把 react 文件夹下大部分有意思的代码都讲完了,其他就剩余了一些边边角角的内容。比如 memocontexthookslazy,这部分代码有兴趣的可以直接自行阅读,反正内容都还是很简单的,难的部分都在 react-dom 文件夹中。

其他文章列表

最后

阅读源码是一个很枯燥的过程,但是收益也是巨大的。如果你在阅读的过程中有任何的问题,都欢迎你在评论区与我交流,当然你也可以在仓库中提 Issus。

另外写这系列是个很耗时的工程,需要维护代码注释,还得把文章写得尽量让读者看懂,最后还得配上画图,如果你觉得文章看着还行,就请不要吝啬你的点赞。

下一篇文章就会是 Fiber 相关的内容,并且会分成几篇文章来讲解。

最后,觉得内容有帮助可以关注下我的公众号 「前端真好玩」咯,会有很多好东西等着你。

【第二期】本周我们 55 人学了什么

程序员这行如果想一直做下去,那么持续学习是必不可少的。

大家找工作通常会喜欢技术氛围好点的团队,因为这样能够帮助自己更好的成长,但是并不是每个团队都拥有这样的氛围。于是萌发一个念头,想建立一个地方,让一些人能在这块地方记录自己学习到的内容。这些内容通常会是一个小点,可能并不足以写成一篇文章。但是这个知识点可能很多人也不知道,那么通过这种记录的方式让别人同样也学习到这个知识点就是一个很棒的事情了。

如果你也想参与这个记录的事情,欢迎贡献你的一份力量,地址在这里

本周总共有 55 人贡献了他们所学到的知识,以下是一些整合后的内容,更详细的内容推荐前往仓库阅读。

JS

解决键盘弹出后挡表单的问题

window.addEventListener('resize', function () {
if (
  document.activeElement.tagName === 'INPUT' ||
  document.activeElement.tagName === 'TEXTAREA' ||
  document.activeElement.getAttribute('contenteditable') == 'true'
) {
  window.setTimeout(function () {
    if ('scrollIntoView' in document.activeElement) {
      document.activeElement.scrollIntoView();
    } else {
      // @ts-ignore
      document.activeElement.scrollIntoViewIfNeeded();
    }
  }, 0);
}
})

图片加载相关

首先是实现图片懒加载

<ul>
	<li><img src="./img/default.png" data="./img/1.png" alt=""></li>
	<li><img src="./img/default.png" data="./img/2.png" alt=""></li>
	<li><img src="./img/default.png" data="./img/3.png" alt=""></li>
	<li><img src="./img/default.png" data="./img/4.png" alt=""></li>
	<li><img src="./img/default.png" data="./img/5.png" alt=""></li>
	<li><img src="./img/default.png" data="./img/6.png" alt=""></li>
	<li><img src="./img/default.png" data="./img/7.png" alt=""></li>
	<li><img src="./img/default.png" data="./img/8.png" alt=""></li>
</ul>
let imgs =  document.querySelectorAll('img')
// 窗口可视区高度
let clientHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
// img 距离窗口可视区顶部的距离 imgs[i].getBoundingClientRect().top
function lazyLoadImg () {
    for (let i = 0; i < imgs.length; i ++) {
        if((imgs[i].getBoundingClientRect().top + imgs[i].height)>=0&&imgs[i].getBoundingClientRect().top < clientHeight ){
            imgs[i].src = imgs[i].getAttribute('data')
        }
    }      
}
window.addEventListener('scroll', lazyLoadImg);

但是这种方式会引起图片下载过程中闪白一下,可以通过 JS 预先加载图片解决。

同时上述的懒加载解决方案已经很老了,可以使用最新的 API Intersection_Observer 来做这件事,会更简单而且可控一些。

无loop生成指定长度的数组

const List1 = len => ''.padEnd(len, ',').split('.')

const List2 = len => [...new Array(len).keys()]

异步的 Promise的 then 方法的回调是何时被添加到microtasks queue中的?

今天刷博客的时候看到一个题:

const pro = new Promise((resolve, reject) => {
    const pro1 = new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(3);
        }, 0);
    });
    resolve(4);
    pro1.then((args) => {
        console.log(args);
    });
});
pro.then((args) => {
    console.log(args);
});

很多人都知道这道题的输出结果是4,3;但是我对题主的这个问题产生了很大的疑问,因为个人并没有着手实现过符合promise A/A+规划的promise,所以每次做这种题都是凭着平时的使用经验,实际上内心虚得很,然后自己查阅了 spec:ECMAScript 2018 Language Specification 根据 spec,如果调用 then 时 promise 是 pending 状态,回调会进入 promise 的 [[PromiseFulfill/RejectReactions]] 列表里;否则会进入 PromiseJobs。

PromiseJob 以及 Job Queue 是 ES 中的说法,而 macroTask 和 microTask 是浏览器中的概念,包括 setTimeout 也是宿主环境提供的。因此输出 4 3 是 ECMAScript 和 浏览器两种规范共同约束的结果。

PromiseJob 对应浏览器中的一个 microTask。对于调用 then 时 promise 处于 pending 状态,回调函数进入到对应的 reactions 队列中。当该 promise 被 fulfill 或 reject 时,则 flush 对应的 reactions 队列 ,其中的每个 reaction 对应一个 PromiseJob 被按序 enqueue 到 Job Queue如果调用 then 时 promise 处于其他两个状态,JS 引擎就直接 enqueue 一个对应的 PromiseJob 到 Job Queue示例中的代码。

在浏览器中如下顺序:

0. current cycle of evevt loop start
1. Install Timer,Timer get enqueued
2. Resovle pro, because there is no fulfillReaction binding to pro, do nothing
3. call then() at pro1, because pro1 is pending, add fulfillReaction to pro1
4. call then() at pro, because pro is reolved,immediately enqueue a PromiseJob
5. current macroTask is finished
6. run all PromiseJobs(microTasks) in order, 
7. console.log(4)
8. current cycle of event loop is finishedanother cycle starts
9. Timer Fires, and pro1 is resolved
10. at this time, pro1 hasfulfillReactions,enqueue every fulfillReaction as a PromiseJob in order
11. current macro Job is finished 
12. run all PromiseJobs in order
13. console.log(3)
14. current cycle of event loop is finished

移动端打开指定App或者下载App

navToDownApp() {
      let u = navigator.userAgent
      if (/MicroMessenger/gi.test(u)) {
        // 如果是微信客户端打开,引导用户在浏览器中打开
        alert('请在浏览器中打开')
      }
      if (u.indexOf('Android') > -1 || u.indexOf('Linux') > -1) {
        // Android
        if (this.openApp('en://startapp')) {
          this.openApp('en://startapp') // 通过Scheme协议打开指定APP
        } else {
          //跳转Android下载地址
        }
      } else if (u.indexOf('iPhone') > -1) {
        if (this.openApp('ios--scheme')) {
          this.openApp('ios--scheme') // 通过Scheme协议打开指定APP
        } else {
          // 跳转IOS下载地址
        }
      }
    },
    openApp(src) {
      // 通过iframe的方式试图打开APP,如果能正常打开,会直接切换到APP,并自动阻止a标签的默认行为
      // 否则打开a标签的href链接
      let ifr = document.createElement('iframe')
      ifr.src = src
      ifr.style.display = 'none'
      document.body.appendChild(ifr)
      window.setTimeout(function() {
        // 打开App后移出这个iframe
        document.body.removeChild(ifr)
      }, 2000)
    }

利用 a 标签解析 URL

function parseURL(url) {
    var a =  document.createElement('a');
    a.href = url;
    return {
        host: a.hostname,
        port: a.port,
        query: a.search,
        params: (function(){
            var ret = {},
                seg = a.search.replace(/^\?/,'').split('&'),
                len = seg.length, i = 0, s;
            for (;i<len;i++) {
                if (!seg[i]) { continue; }
                s = seg[i].split('=');
                ret[s[0]] = s[1];
            }
            return ret;
        })(),
        hash: a.hash.replace('#','')
    };
}

数组去重

  var array = [1, 2, 1, 1, '1'];
  function unique(array) {
    var obj = {};
    return array.filter(function(item, index, array){
      return obj.hasOwnProperty(typeof item + item) ? false : (obj[typeof item + item] = true)
    })
  }

利用一个空的 Object 对象,我们把数组的值存成 Object 的 key 值,比如 Object[value1] = true,在判断另一个值的时候,如果 Object[value2]存在的话,就说明该值是重复的。

因为 1 和 '1' 是不同的,但是这种方法会判断为同一个值,这是因为对象的键值只能是字符串,所以我们可以使用 typeof item + item 拼成字符串作为 key 值来避免这个问题

JS 函数对象参数的陷阱

上周在实现某个弹层功能的时候,用到了rc-util里的 contains 方法函数, 结果 code-review 的时候同事对该代码提出了疑问:

rc-util 源码仓库

export default function contains(root, n) {
  let node = n;
  while (node) {
    if (node === root) {
      return true;
    }
    node = node.parentNode;
  }
  return false;
}

上述代码是 antd 内部抽象的一个工具方法,用来判断某个dom是否为另一个dom的祖先节点。

同事疑问的是 let node = n; 这段代码是不是多余的?

首先一开始的理解是 函数参数 n 是一个对象,一个dom节点对象。
如果用 node 保存 n 的值,防止 node = node.parentNode 这段代码执行的时候,会改变传入的实参 n 对应的值。

毕竟以下的代码我们都很熟悉:

function contains(root, n) {
  if(n) {
    n.a = 3
  }
}

const A = {a:1};
const B = {a:2};
contains(A,B)
console.log(B)    // {a:3}

即当实参为对象时,函数内部是可以改变该对象的值从而影响函数之外的实参。

但是测试另外一段代码,发现和理解的不一样:

function contains(root, n) {
  if(n) {
    n = {a:3}
  }
}

const A = {a:1};
const B = {a:2}
contains(A,B)
console.log(B) // {a:2}

n.a = 3n = {a:3} 这两段代码是不一样的。

网上也有相关资料,其实可以简单的理解为: 当函数一开始执行时,n 是指向实参 B 的一个引用.

n.a = 3 是在引用上关联了一个属性,此时和 B 还是同一个引用,因此会改变实参B的值。

n = {a:3} 则使得 n 不再指向实参 B, 而是指向一个新对象{a:3},也就是 nB 彻底断绝了关系,因此不会改变实参 B 的值。

是不是可以给蚂蚁的团队提个issue建议删除该代码,不过有这句代码也不会有什么bug~

相关资料:JavaScript深入之参数按值传递

其他

kill 指定端口

以下命令可以 kill 掉 8080 端口,当然你也可以选择通过 npm 命令的方式指定需要 kill 的端口。

lsof -i tcp:8080 | grep LISTEN | awk '{print $2}'| awk -F"/" '{ print $1 }' | xargs kill -9

另外以上命令在 windows 上是不可用的。如果有多平台的需求的话,可以直接使用 Kill-port-process

Linux下通过命令行替换文本

# 将wxml文件的i标签替换为text
grep '<i ' -rl . --include='*.wxml' --exclude-dir=node_module --exclude-dir=dist | xargs sed -i -e 's/<i /<text /g'
grep '</i>' -rl . --include='*.wxml' --exclude-dir=node_module --exclude-dir=dist | xargs sed -i -e 's/<\/i>/<\/text>/g'

如何判断文件中的换行符是 LF(\n) 还是 CRLF(\r\n)

文章链接,通过这篇文章可以了解到换行符到底是什么。

另外这位大佬每天都将学习到的知识记录了下来,感兴趣的可以 阅读一下

毕业半年感悟

  • 自身的实力最重要,要有一样核心技能,其他方面也要有所涉猎。
  • 公司带给个人的影响是很大的,如果一个公司不愿意培养你,真的不值得去付出。
  • 沟通确实很重要,沟通不明确会导致接下来一系列的问题。
  • 说话是后天锻炼出来的,多和人交流,话到嘴边留三分。
  • 不用讨厌加班,人与人拉开差距就在下班后的几个小时,加班可以学习啊。雷军还说过你拿3000块钱换我一个月的青春,多不划算。

最后

这周的分享内容质量很高,我也从中汲取到了一些知识。

这是一个需要大家一起分享才能持续下去的事情,光靠我一人分享是做不下去的。欢迎大家参与到这件事情中来,地址在这里

重学 JS 系列:聊聊 new 操作符

这是重学 JS 系列的第一篇文章,写这个系列的初衷也是为了夯实自己的 JS 基础。既然是重学,肯定不会从零开始介绍一个知识点,如有遇到不会的内容请自行查找资料。

new 的作用

我们先来通过两个例子来了解 new 的作用

function Test(name) {
	this.name = name
}
Test.prototype.sayName = function () {
		console.log(this.name)
}
const t = new Test('yck')
console.log(t.name) // 'yck'
t.sayName() // 'yck'

从上面一个例子中我们可以得出这些结论:

  • new 通过构造函数 Test 创建出来的实例可以访问到构造函数中的属性
  • new 通过构造函数 Test 创建出来的实例可以访问到构造函数原型链中的属性,也就是说通过 new 操作符,实例与构造函数通过原型链连接了起来

但是当下的构造函数 Test 并没有显式 return 任何值(默认返回 undefined),如果我们让它返回值会发生什么事情呢?

function Test(name) {
	this.name = name
	return 1
}
const t = new Test('yck')
console.log(t.name) // 'yck'

虽然上述例子中的构造函数中返回了 1,但是这个返回值并没有任何的用处,得到的结果还是和之前的例子完全一样。

那么通过这个例子,我们又可以得出一个结论:

  • 构造函数如果返回原始值(虽然例子中只有返回了 1,但是你可以试试其他的原始值,结果还是一样的),那么这个返回值毫无意义

试完了返回原始值,我们再来试试返回对象会发生什么事情吧

function Test(name) {
	this.name = name
	console.log(this) // Test { name: 'yck' }
	return { age: 26 }
}
const t = new Test('yck')
console.log(t) // { age: 26 }
console.log(t.name) // 'undefined'

通过这个例子我们可以发现,虽然构造函数内部的 this 还是依旧正常工作的,但是当返回值为对象时,这个返回值就会被正常的返回出去。

那么通过这个例子,我们再次得出了一个结论:

  • 构造函数如果返回值为对象,那么这个返回值会被正常使用

这两个例子告诉了我们一点,构造函数尽量不要返回值。因为返回原始值不会生效,返回对象会导致 new 操作符没有作用。

通过以上几个例子,相信大家也大致了解了 new 操作符的作用了,接下来我们就来尝试自己实现 new 操作符。

自己实现 new 操作符

首先我们再来回顾下 new 操作符的几个作用

  • new 操作符会返回一个对象,所以我们需要在内部创建一个对象
  • 这个对象,也就是构造函数中的 this,可以访问到挂载在 this 上的任意属性
  • 这个对象可以访问到构造函数原型上的属性,所以需要将对象与构造函数链接起来
  • 返回原始值需要忽略,返回对象需要正常处理

回顾了这些作用,我们就可以着手来实现功能了

function create(Con, ...args) {
	let obj = {}
	Object.setPrototypeOf(obj, Con.prototype)
	let result = Con.apply(obj, args)
	return result instanceof Object ? result : obj
}

这就是一个完整的实现代码,我们通过以下几个步骤实现了它:

  1. 首先函数接受不定量的参数,第一个参数为构造函数,接下来的参数被构造函数使用
  2. 然后内部创建一个空对象 obj
  3. 因为 obj 对象需要访问到构造函数原型链上的属性,所以我们通过 setPrototypeOf 将两者联系起来。这段代码等同于 obj.__proto__ = Con.prototype
  4. obj 绑定到构造函数上,并且传入剩余的参数
  5. 判断构造函数返回值是否为对象,如果为对象就使用构造函数返回的值,否则使用 obj,这样就实现了忽略构造函数返回的原始值

接下来我们来使用下该函数,看看行为是否和 new 操作符一致

function Test(name, age) {
	this.name = name
	this.age = age
}
Test.prototype.sayName = function () {
		console.log(this.name)
}
const a = create(Test, 'yck', 26)
console.log(a.name) // 'yck'
console.log(a.age) // 26
a.sayName() // 'yck'

结果很完美

最后

我们通过这篇文章重学了 new 操作符,如果你还有什么疑问欢迎在评论区与我互动。

我所有的系列文章都会在这个仓库中最先更新,有兴趣的可以关注下。今年主要会着重写以下三个专栏

  • 重学 JS
  • React 进阶
  • 重写组件

最后,觉得内容有帮助可以关注下我的公众号 「前端真好玩」咯,会有很多好东西等着你。

Redux 源码深度解析

本文是 Redux 源码解析视频的文字版,如果你想看我一句句代码解析的视频版,可以关注文末的公众号。每周末都会发布一些视频,每天也会推送一些我认为不错的文章。

前言

在进入正题前,我们首先来看一下在项目中是如何使用 Redux 的,根据使用步骤来讲解源码。以 我开源的 React 项目 为例。

// 首先把多个 reducer 通过 combineReducers 组合在一起
const appReducer = combineReducers({
	user: UserReducer,
	goods: GoodsReducer,
	order: OrdersReducer,
	chat: ChatReducer
});
// 然后将 appReducer 传入 createStore,并且通过 applyMiddleware 使用了中间件 thunkMiddleware
// replaceReducer 实现热更新替换
// 然后在需要的地方发起 dispatch(action) 引起 state 改变
export default function configureStore() {
	const store = createStore(
		rootReducer,
		compose(
			applyMiddleware(thunkMiddleware),
			window.devToolsExtension ? window.devToolsExtension() : f => f
		)
	);

	if (module.hot) {
		module.hot.accept("../reducers", () => {
			const nextRootReducer = require("../reducers/index");
			store.replaceReducer(nextRootReducer);
		});
	}

	return store;
}

介绍完了使用步骤,接下来进入正题。

源码解析

首先让我们来看下 combineReducers 函数

// 传入一个 object
export default function combineReducers(reducers) {
 // 获取该 Object 的 key 值
	const reducerKeys = Object.keys(reducers)
	// 过滤后的 reducers
	const finalReducers = {}
	// 获取每一个 key 对应的 value
	// 在开发环境下判断值是否为 undefined
	// 然后将值类型是函数的值放入 finalReducers
	for (let i = 0; i < reducerKeys.length; i++) {
		const key = reducerKeys[i]

		if (process.env.NODE_ENV !== 'production') {
			if (typeof reducers[key] === 'undefined') {
				warning(`No reducer provided for key "${key}"`)
			}
		}

		if (typeof reducers[key] === 'function') {
			finalReducers[key] = reducers[key]
		}
	}
	// 拿到过滤后的 reducers 的 key 值
	const finalReducerKeys = Object.keys(finalReducers)
	
	// 在开发环境下判断,保存不期望 key 的缓存用以下面做警告  
	let unexpectedKeyCache
	if (process.env.NODE_ENV !== 'production') {
		unexpectedKeyCache = {}
	}
		
	let shapeAssertionError
	try {
	// 该函数解析在下面
		assertReducerShape(finalReducers)
	} catch (e) {
		shapeAssertionError = e
	}
// combineReducers 函数返回一个函数,也就是合并后的 reducer 函数
// 该函数返回总的 state
// 并且你也可以发现这里使用了闭包,函数里面使用到了外面的一些属性
	return function combination(state = {}, action) {
		if (shapeAssertionError) {
			throw shapeAssertionError
		}
		// 该函数解析在下面
		if (process.env.NODE_ENV !== 'production') {
			const warningMessage = getUnexpectedStateShapeWarningMessage(
				state,
				finalReducers,
				action,
				unexpectedKeyCache
			)
			if (warningMessage) {
				warning(warningMessage)
			}
		}
		// state 是否改变
		let hasChanged = false
		// 改变后的 state
		const nextState = {}
		for (let i = 0; i < finalReducerKeys.length; i++) {
		// 拿到相应的 key
			const key = finalReducerKeys[i]
			// 获得 key 对应的 reducer 函数
			const reducer = finalReducers[key]
			// state 树下的 key 是与 finalReducers 下的 key 相同的
			// 所以你在 combineReducers 中传入的参数的 key 即代表了 各个 reducer 也代表了各个 state
			const previousStateForKey = state[key]
			// 然后执行 reducer 函数获得该 key 值对应的 state
			const nextStateForKey = reducer(previousStateForKey, action)
			// 判断 state 的值,undefined 的话就报错
			if (typeof nextStateForKey === 'undefined') {
				const errorMessage = getUndefinedStateErrorMessage(key, action)
				throw new Error(errorMessage)
			}
			// 然后将 value 塞进去
			nextState[key] = nextStateForKey
			// 如果 state 改变
			hasChanged = hasChanged || nextStateForKey !== previousStateForKey
		}
		// state 只要改变过,就返回新的 state
		return hasChanged ? nextState : state
	}
}

combineReducers 函数总的来说很简单,总结来说就是接收一个对象,将参数过滤后返回一个函数。该函数里有一个过滤参数后的对象 finalReducers,遍历该对象,然后执行对象中的每一个 reducer 函数,最后将新的 state 返回。

接下来让我们来看看 combinrReducers 中用到的两个函数

// 这是执行的第一个用于抛错的函数
function assertReducerShape(reducers) {
// 将 combineReducers 中的参数遍历
	Object.keys(reducers).forEach(key => {
		const reducer = reducers[key]
		// 给他传入一个 action
		const initialState = reducer(undefined, { type: ActionTypes.INIT })
		// 如果得到的 state 为 undefined 就抛错
		if (typeof initialState === 'undefined') {
			throw new Error(
				`Reducer "${key}" returned undefined during initialization. ` +
					`If the state passed to the reducer is undefined, you must ` +
					`explicitly return the initial state. The initial state may ` +
					`not be undefined. If you don't want to set a value for this reducer, ` +
					`you can use null instead of undefined.`
			)
		}
		// 再过滤一次,考虑到万一你在 reducer 中给 ActionTypes.INIT 返回了值
		// 传入一个随机的 action 判断值是否为 undefined
		const type =
			'@@redux/PROBE_UNKNOWN_ACTION_' +
			Math.random()
				.toString(36)
				.substring(7)
				.split('')
				.join('.')
		if (typeof reducer(undefined, { type }) === 'undefined') {
			throw new Error(
				`Reducer "${key}" returned undefined when probed with a random type. ` +
					`Don't try to handle ${
						ActionTypes.INIT
					} or other actions in "redux/*" ` +
					`namespace. They are considered private. Instead, you must return the ` +
					`current state for any unknown actions, unless it is undefined, ` +
					`in which case you must return the initial state, regardless of the ` +
					`action type. The initial state may not be undefined, but can be null.`
			)
		}
	})
}

function getUnexpectedStateShapeWarningMessage(
	inputState,
	reducers,
	action,
	unexpectedKeyCache
) {
	// 这里的 reducers 已经是 finalReducers
	const reducerKeys = Object.keys(reducers)
	const argumentName =
		action && action.type === ActionTypes.INIT
			? 'preloadedState argument passed to createStore'
			: 'previous state received by the reducer'
	
	// 如果 finalReducers 为空
	if (reducerKeys.length === 0) {
		return (
			'Store does not have a valid reducer. Make sure the argument passed ' +
			'to combineReducers is an object whose values are reducers.'
		)
	}
		// 如果你传入的 state 不是对象
	if (!isPlainObject(inputState)) {
		return (
			`The ${argumentName} has unexpected type of "` +
			{}.toString.call(inputState).match(/\s([a-z|A-Z]+)/)[1] +
			`". Expected argument to be an object with the following ` +
			`keys: "${reducerKeys.join('", "')}"`
		)
	}
		// 将参入的 state 于 finalReducers 下的 key 做比较,过滤出多余的 key
	const unexpectedKeys = Object.keys(inputState).filter(
		key => !reducers.hasOwnProperty(key) && !unexpectedKeyCache[key]
	)

	unexpectedKeys.forEach(key => {
		unexpectedKeyCache[key] = true
	})

	if (action && action.type === ActionTypes.REPLACE) return

// 如果 unexpectedKeys 有值的话
	if (unexpectedKeys.length > 0) {
		return (
			`Unexpected ${unexpectedKeys.length > 1 ? 'keys' : 'key'} ` +
			`"${unexpectedKeys.join('", "')}" found in ${argumentName}. ` +
			`Expected to find one of the known reducer keys instead: ` +
			`"${reducerKeys.join('", "')}". Unexpected keys will be ignored.`
		)
	}
}

接下来让我们先来看看 compose 函数

// 这个函数设计的很巧妙,通过传入函数引用的方式让我们完成多个函数的嵌套使用,术语叫做高阶函数
// 通过使用 reduce 函数做到从右至左调用函数
// 对于上面项目中的例子
compose(
		applyMiddleware(thunkMiddleware),
		window.devToolsExtension ? window.devToolsExtension() : f => f
) 
// 经过 compose 函数变成了 applyMiddleware(thunkMiddleware)(window.devToolsExtension()())
// 所以在找不到 window.devToolsExtension 时你应该返回一个函数
export default function compose(...funcs) {
	if (funcs.length === 0) {
		return arg => arg
	}

	if (funcs.length === 1) {
		return funcs[0]
	}

	return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

然后我们来解析 createStore 函数的部分代码

export default function createStore(reducer, preloadedState, enhancer) {
	// 一般 preloadedState 用的少,判断类型,如果第二个参数是函数且没有第三个参数,就调换位置
	if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
		enhancer = preloadedState
		preloadedState = undefined
	}
	// 判断 enhancer 是否是函数
	if (typeof enhancer !== 'undefined') {
		if (typeof enhancer !== 'function') {
			throw new Error('Expected the enhancer to be a function.')
		}
		// 类型没错的话,先执行 enhancer,然后再执行 createStore 函数
		return enhancer(createStore)(reducer, preloadedState)
	}
	// 判断 reducer 是否是函数
	if (typeof reducer !== 'function') {
		throw new Error('Expected the reducer to be a function.')
	}
	// 当前 reducer
	let currentReducer = reducer
	// 当前状态
	let currentState = preloadedState
	// 当前监听函数数组
	let currentListeners = []
	// 这是一个很重要的设计,为的就是每次在遍历监听器的时候保证 currentListeners 数组不变
	// 可以考虑下只存在 currentListeners 的情况,如果我在某个 subscribe 中再次执行 subscribe
	// 或者 unsubscribe,这样会导致当前的 currentListeners 数组大小发生改变,从而可能导致
	// 索引出错
	let nextListeners = currentListeners
	// reducer 是否正在执行
	let isDispatching = false
	// 如果 currentListeners 和 nextListeners 相同,就赋值回去
	function ensureCanMutateNextListeners() {
		if (nextListeners === currentListeners) {
			nextListeners = currentListeners.slice()
		}
	}
	// ......
}

接下来先来介绍 applyMiddleware 函数

在这之前我需要先来介绍一下函数柯里化,柯里化是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术。

function add(a,b) { return a + b }   
add(1, 2) => 3
// 对于以上函数如果使用柯里化可以这样改造
function add(a) {
		return b => {
				return a + b
		}
}
add(1)(2) => 3
// 你可以这样理解函数柯里化,通过闭包保存了外部的一个变量,然后返回一个接收参数的函数,在该函数中使用了保存的变量,然后再返回值。
// 这个函数应该是整个源码中最难理解的一块了
// 该函数返回一个柯里化的函数
// 所以调用这个函数应该这样写 applyMiddleware(...middlewares)(createStore)(...args)
export default function applyMiddleware(...middlewares) {
	return createStore => (...args) => {
	 // 这里执行 createStore 函数,把 applyMiddleware 函数最后次调用的参数传进来
		const store = createStore(...args)
		let dispatch = () => {
			throw new Error(
				`Dispatching while constructing your middleware is not allowed. ` +
					`Other middleware would not be applied to this dispatch.`
			)
		}
		let chain = []
		// 每个中间件都应该有这两个函数
		const middlewareAPI = {
			getState: store.getState,
			dispatch: (...args) => dispatch(...args)
		}
		// 把 middlewares 中的每个中间件都传入 middlewareAPI
		chain = middlewares.map(middleware => middleware(middlewareAPI))
		// 和之前一样,从右至左调用每个中间件,然后传入 store.dispatch
		dispatch = compose(...chain)(store.dispatch)
		// 这里只看这部分代码有点抽象,我这里放入 redux-thunk 的代码来结合分析
		// createThunkMiddleware返回了3层函数,第一层函数接收 middlewareAPI 参数
		// 第二次函数接收 store.dispatch
		// 第三层函数接收 dispatch 中的参数
{function createThunkMiddleware(extraArgument) {
	return ({ dispatch, getState }) => next => action => {
	// 判断 dispatch 中的参数是否为函数
		if (typeof action === 'function') {
		// 是函数的话再把这些参数传进去,直到 action 不为函数,执行 dispatch({tyep: 'XXX'})
			return action(dispatch, getState, extraArgument);
		}

		return next(action);
	};
}
const thunk = createThunkMiddleware();

export default thunk;}
// 最后把经过中间件加强后的 dispatch 于剩余 store 中的属性返回,这样你的 dispatch
		return {
			...store,
			dispatch
		}
	}
}

好了,我们现在将困难的部分都攻克了,来看一些简单的代码

// 这个没啥好说的,就是把当前的 state 返回,但是当正在执行 reducer 时不能执行该方法
function getState() {
		if (isDispatching) {
			throw new Error(
				'You may not call store.getState() while the reducer is executing. ' +
					'The reducer has already received the state as an argument. ' +
					'Pass it down from the top reducer instead of reading it from the store.'
			)
		}

		return currentState
}
// 接收一个函数参数
function subscribe(listener) {
		if (typeof listener !== 'function') {
			throw new Error('Expected listener to be a function.')
		}
// 这部分最主要的设计 nextListeners 已经讲过,其他基本没什么好说的
		if (isDispatching) {
			throw new Error(
				'You may not call store.subscribe() while the reducer is executing. ' +
					'If you would like to be notified after the store has been updated, subscribe from a ' +
					'component and invoke store.getState() in the callback to access the latest state. ' +
					'See http://redux.js.org/docs/api/Store.html#subscribe for more details.'
			)
		}

		let isSubscribed = true

		ensureCanMutateNextListeners()
		nextListeners.push(listener)

// 返回一个取消订阅函数
		return function unsubscribe() {
			if (!isSubscribed) {
				return
			}

			if (isDispatching) {
				throw new Error(
					'You may not unsubscribe from a store listener while the reducer is executing. ' +
						'See http://redux.js.org/docs/api/Store.html#subscribe for more details.'
				)
			}

			isSubscribed = false

			ensureCanMutateNextListeners()
			const index = nextListeners.indexOf(listener)
			nextListeners.splice(index, 1)
		}
	}
 
function dispatch(action) {
// 原生的 dispatch 会判断 action 是否为对象
		if (!isPlainObject(action)) {
			throw new Error(
				'Actions must be plain objects. ' +
					'Use custom middleware for async actions.'
			)
		}

		if (typeof action.type === 'undefined') {
			throw new Error(
				'Actions may not have an undefined "type" property. ' +
					'Have you misspelled a constant?'
			)
		}
// 注意在 Reducers 中是不能执行 dispatch 函数的
// 因为你一旦在 reducer 函数中执行 dispatch,会引发死循环
		if (isDispatching) {
			throw new Error('Reducers may not dispatch actions.')
		}
// 执行 combineReducers 组合后的函数
		try {
			isDispatching = true
			currentState = currentReducer(currentState, action)
		} finally {
			isDispatching = false
		}
// 然后遍历 currentListeners,执行数组中保存的函数
		const listeners = (currentListeners = nextListeners)
		for (let i = 0; i < listeners.length; i++) {
			const listener = listeners[i]
			listener()
		}

		return action
	}
 // 然后在 createStore 末尾会发起一个 action dispatch({ type: ActionTypes.INIT });
 // 用以初始化 state

结尾

如果大家还对 Redux 有疑问的话可以在下方留言。该文章为视频的文字版,视频在 1月14号上传。

大家可以关注我的公众号,我在公众号中会更新视频,并且每晚都会推送一篇我认为不错的文章。

面试数十人有感

平时会接一些模拟面试的服务,几个月下来也面试几十个人了,来和大家聊聊面试了这些人的感想。

万丈高楼起于垒土

基础对于每个人都是很重要的一步。无论你做的是什么领域的东西,计算机科学中的必修课必须是要学好的。就前端而言,OS、数据结构与算法、网络这几块内容是必须要掌握的基础的,这些基础不扎实,你的天花板势必不高。但是其实在面试的过程中,我发现无论是社招的还是校招的,基本上基础都不大行。我认为,对于校招生来说,基础不好基本和大厂绝缘了;对于社招来说,基础不好虽然也同样能干活,但是天花板不高会导致你的技术成长会随着年龄增长变得越来越低,最后结果也基本是停留于二三线公司。

所以任何一个想要在技术这条路上走的更远的人,都应该好好学习计算机科学。如果你已经脱离学校了并且英语还行,我强烈推荐 CS61 系列,如果你能将这三门课完整的学习下来也基本有大部分科班本科的素养了。

说完计算机基础,那么来说说前端的基础吧。在面试的过程中,发现大部分人问基础知识点还是能够说出来的,但是仅限于知道。对于如何将多个知识点串联起来或者知识点的更深层次的问题就很少有人能够答好。

打个比方,跨域这种问烂的问题想必大家都能答出点东西。这个也是我常会问的一个问题,当然我还会配合几个问题去问。比如问什么浏览器要引入跨域这个机制;跨域请求到底有没有正常发出去并收到响应;是否了解跨域预检。对于以上三个问题,能够答出的人并不多,更多人只是知道我该如何去解决跨域,但是对于为什么要有跨域反而知道的并不多。这也侧面反应了大部分人并没有深入挖掘知识的意识,只是停留于表面。

对于基础而言,我认为所有人都应该好好夯实。如果你是转行的,那么首先应该把前端基础学好,然后有空的时候去补计算机基础;如果你是科班出来的,首先肯定是学好计算机基础,然后才是前端基础,这样你才能站得高,成长得快。

框架

现在框架已经是前端绕不开的话题了。很多人会纠结于我该挑选哪个框架或者哪个框架牛逼等等,首先在学习框架前,我觉得你应该先打好基础,而不是好高骛远的先使用框架。再者,几大热门的框架底层的**都是一致的,并没有哪个好哪个差之分。

在面试的过程中,框架原理其实是绕不开的题目了。但是说实话,面试下来的结果却是很少有人熟悉框架原理,只是停留于会用框架。这其实是一个不好的现象,打个比方,一个机器建造出来可以通过说明书的方式让流水线工人去使用,就算一批工人走了,还可以再去招一批,你并没有什么核心竞争力。但是对于会修理机器的或者会造机器的人来说,找工作会很好找,因为这是核心技术人才。放到编程里也是一样的,单纯会使用 API 的人并不会成为稀缺人才,充其量一个中级开发。但是如果你熟悉 API 底层的原理,那么也许你就可以晋升成为高级开发,从而提高个人的核心竞争力。并且一旦你熟悉了底层原理,你的眼界也会高于别人,不会纠结于我该选择什么框架,不会担心个人的经验是否会很大程度影响找工作。

所以,在这里我想讲的是:学习一件事物,熟练使用它是基本,在熟练使用以后,应该转而去学习他底层的原理机制,甚至自己去实现一个类似的东西。当你这样去做的时候,永远不需要担心自己是否会淘汰,因为你已经领先所有只会用 API 的人了,这部分绝对是最多的。

最后,对于每个想在面试中获得好的结果的人来说,都应该做到以下几点:

  • 夯实自己的基础,基础决定了大楼的高度
  • 有深入挖掘知识的**,对于每个知识点都应该考虑一下这玩意怎么实现的,为什么要有这玩意
  • 不要做框架 API 的熟练工,尽量去了解框架底层的原理机制

如果你能做到以上几点,你差不多就领先百分之 90 的人,路已经给各位指好了,就看各位走不走了。

看完跳槽少说涨 5 K,前端面试从准备到谈薪完全指南(近万字精华)

本文将从以下几个角度来聊聊面试这件事情

前端面试从准备到谈薪指南

面试题篇

面试题只能应对 1 - 2 面,刷题固然重要,但是对于项目相关的准备也是必须的。一般来说目前面试题能准备的范围如下:

  • JS 基础 / 进阶相关 
  • HTML /  CSS 相关,这方面问的真的很少了
  • 浏览器 / 性能优化 / 工程相关
  • 框架使用相关,也就是基础问题
  • 框架原理相关,就算你没看过源码,你也得知道它的原理,当下的面试基本是不会原理就寸步难行
  • 计科相关,比如算法 / 数据结构 / 网络,基本这三样,最多加个操作系统

以上是大致范围,大家可以照着把题目归类,当然除了这些还会有些别的,比如说设计模式等等的问题。另外会刷面试题只是一部分,如果只能生搬硬套,稍微题目变种一下就不会的话也没啥用。更好的办法是把这些内容内化,了解这个题目为什么要这样解,并且和自身的项目所结合。比如说项目中做过性能优化,那么你就可以把相关的性能优化答案都聊一下。

以下几个链接的内容大部分都是笔者身边朋友所写,就职的都是一二线公司。这些内容看完足以,没必要一直盯着面试题去刷,其他还有我们需要准备的内容,面试题并不是本文的重点。

怎么谈做过的项目

谈好项目经历才是面试环节中最重要的一点,即使之前的题目你答得再好,项目经历讲不好依旧凉凉。

项目考察一是为了确认这个项目是否是你做过的,二是为了了解你的技术深度,是否是做过就算还是会有自己的思考。

考察的问题一般分为以下几点:

  • 项目基础相关的内容,比如涉及到的技术栈、功能、业务相关的问题。

  • 项目具体的细节内容,比如说这个功能你是如何实现的,为什么这样做等等。

  • 考察深度问题,比如说你做这个项目的时候有没有遇到过什么问题,是如何解决的,另外也可能会与上面的面试题结合起来问

基于以上几点,你可以这样去准备项目问答:

  1. 这个项目涉及的技术栈相关的内容,无论是基础的还是深度的,因为这里很可能会问到框架原理。
  2. 想想做这个项目的过程中是否有遇到过一些困难,最终是如何解决的,实在想不起来的话可以看看 Git Commit。
  3. 这个项目自己是否做过一些优化,包括代码、开发效率、性能、体验等等相关的领域。
  4. 这个项目当中存在的一些问题,可能的解决思路。
  5. 这个项目最终达成的成果。
  6. 这个项目带给你的成长是什么,当然别说让我学会了某某 API 这种没价值的内容。

另外项目这块还要结合着简历来说,因为面试官问你项目肯定是从简历上得来的问题,下文中会写到如何在简历中写项目经历。

面试如何请假、如何提出辞职

其实真的没必要考虑我该如何请假才能让上级觉得我不是去面试的,当然实话实说请假是去面试的肯定也不行。既然要请假,那么就直接说家里有事、自己有事就行了,一般人不会那么事逼问你到底干嘛去的。

开口提辞职时先要有一个借口,比如什么通勤太远啦、加班太多啦等等的一些个人原因。然后再感激一下领导和公司这一段时间的栽培给自己带来了很大的成长,最后表示在离职之前会认真交接好所有的工作,希望领导能批准自己的离职申请。

这时领导可能会开始挽留你,记住一点:一旦决定辞职就别犹豫,上级挽留也一定不要留下来,因为在你辞职的那一刻起公司就认为你是个不稳定的因子,即使你被挽留下来也不大可能会有什么好的发展,同时也不要因为公司曾经带给了你成长所以犹豫到底要不要走。人往高处走,水往低处流,人生没有不散的宴席。

准备简历篇

简历不是用来记你的流水账的。罗列一堆技术点、你完成了什么任务以及你的自我评价没多大价值,只是造就了一份又臭又长的简历。

你可以按照以下几点来修改自己的简历:

  1. 控制简历页数在 2 页以下,简历不是写得越长越牛逼,而是用内容去吸引人家的。

  2. 按照用人方的要求以及自身具备但别人不怎么会的领域去写技术栈,不用大篇幅地去罗列技术栈。你熟悉 React 的话人家就默认你熟悉前端三大件了,更不用说用编辑器写代码、用 Git 提交代码、用 Ajax 请求数据了,把原本用来罗列这些技术栈的篇幅留给更重要的项目吧

  3. 写项目经历的时候把重点的几个项目拿出来介绍就行了,不需要把你做过的所有项目都罗列出来。具体内容可以参考 Star 法则,也就是做了什么,得到了怎样的结果。怎样的结果是最重要的而不是罗列自己做了什么任务。用数据去量化你的结果是一个很好的方式,不知道怎么去量化的话可以多了解下你的上级是如何写 PPT、画大饼的。举个例子你们要提高日活,那么肯定会有个具体提高的数值,这个数值就是可量化的。

  4. 斟酌熟悉、精通等字眼,不要给自己挖坑。最后确保每一个写上去的技术点自己都能说出点什么,不要出现面试官问你一个技术点却只能答出用过。

  5. 别用 Word 格式,容易出问题,PDF 是更好的选择。

  6. 不推荐用模板,要不花里胡哨要不都是招聘网站的 Logo,自己用 Markdown 写完直接转 PDF 就好了。

  7. 文件命名格式:姓名_求职岗位必写

一般来说简历的排版格式如下:

排版格式
你的个人信息:姓名、年级、性别、手机号、邮箱、学校及专业
你的技术栈,按照用人方来罗列  
项目经历挑几个讲,按照 Star 法则

如何粗略判断公司是否靠谱

毕竟不是每个人都能去大公司的,所以分辨一个公司是否靠谱是相当重要的,这关系到将来几个月甚至几年的职业道路。

这里笔者推荐使用「天眼查」去查询一家公司的信息,在这里我们可以查询到一家公司的几个重要指标

  • 具体的一个融资情况,一家公司好不好,拥有的资本肯定是重要的一块。一家不错的公司,往往前期融到的金额就很高并且领投的 VC 也是知名的,比如 IDG 资本、高瓴资本、红杉资本等等
  • 核心团队的介绍,通过介绍我们可以了解到高管的一个教育背景,行业的经验等等
  • 公司涉及到了哪些司法、经营上的风险

然后还可以在脉脉、群里问问这公司是否靠谱,不靠谱的公司就别投递简历了。

投递简历篇

首选一定是内推,实在没办法才选择各大招聘网站投递。现在获取内推的渠道实在太多了,比如微博、知乎、V2ex、脉脉,再不行也还能群里问问。

另外还需要注意分批投递简历,投递前应该先把想投递的几个公司分出几个档次。先投递档次最低的,就算失败了,也就当在攒经验。这样多面几次,把握大了就可以开始投递更加心仪的公司了,增加成功几率。

最后如果你是通过邮件投简历的话,可以选择在早上上班的时候去投递。

通用问答篇

自我介绍

自我介绍应该是 99% 的一面都会问到的一个问题,所以推荐面试前直接写一份自我介绍。

自我介绍是用于让面试官快速了解你信息的一个环节,但是切记不要啰里啰嗦地说一大堆,准备以下几个环节即可:

  1. 个人信息,就把简历里写的个人信息说一下,另外还可以附带一些个人的荣誉(社招的就不用去讲学校里获得的荣誉了,除非是有什么大赛得过奖)。
  2. 介绍匹配的技术栈。
  3. 挑一个个人认为最好的项目说一下,描述方式也是按照 Star 法则。这个项目如果是匹配用人方招聘需求的那就更好了。
  4. 自身亮点,比如平时有写文章或者维护的 Github 等等,提升面试官对你的好感。

按照上述几个环节,大致可以整理出这样的格式:

面试官你好,我叫 XXX,就读于 XX 学校 XX 专业,拥有 XX 年前端工作经验,获得过 ACM 省级金牌(介绍自己获得过有含金量的比赛名次),曾供职于 XX 公司(介绍先前工作过的一二线企业)。我在上家公司任职 XX 岗位,主要负责 XX 工作,擅长 XX 技术栈。其中在我负责的 XX 项目中,我完成了 XX 工作,实现了性能 XX% 的提升(这里就是按照 Star 法则去介绍一个自己负责的最佳项目)。另外我还坚持写作,在 XX 平台发表了 XX 文章,共计获得了 XX 点赞/阅读(这里就是介绍自身的亮点)。以上就是我的自我介绍,谢谢!

职业规划

这个其实就是想了解你与公司发展的匹配程度如何。假如说你一个写代码的说过几年想做产品了、运营了、创业了,那么可能就有点危险了。只要你讲出符合自己职业的道路即可,比如说想晋级到高工 -> 架构师等等。

你的缺点

这个问题切记不能回答自己的性格缺陷、能力不行、沟通不好等等,可以说一些工作中遇到的问题。比如说在某次需求评审的时候因为自己没有坚持个人的想法,导致这个需求存在的问题没有解决掉,最后这个项目结果不好没有达到预期等等。

你有什么想问我的

这个问题确实不怎么好答,相信很多人都被这个问题困扰过

  • 回答没什么想问的呢,可能会给面试官一个你并不想进公司的感觉
  • 瞎问呢又怕惹得面试官不高兴了

其实这个问题问得好的话反而是一个能很好了解对方公司的一个渠道。

以下是一些笔者认为不错的提问,能够很好地了解到对方公司的一些东西,包括开发流程、职业晋升、公司发展等等。大家可以选择性地提出 2 - 3 个感兴趣的问题,这样不仅能帮助到自身了解到公司的一些情况,也能给予面试官一个不错的印象,以下问题针对于技术面:

  • 公司常用的技术栈是什么?
  • 你们如何测试代码?
  • 你们如何解决线上故障?
  • 你们如何准备故障恢复?是否有完善的发布机制?
  • 公司是否有技术分享交流活动?有的话,多久一次呢?
  • 一次迭代的流程是怎么样的?从 PRD 评审开始到发布这一整个流程。
  • 公司技术团队的架构和人员组成?
  • 有公司级别的学习资源吗?比如电子书订阅或者在线课程?
  • 你们认为和竞品相比有什么优势?

为什么从上家公司离职

这个问题无论如何都不能说上家公司的任何不好,不管是加班多、上下级问题、与同事之间的矛盾或者其他的情况。

一般就把问题归于自身就行,可以说考虑到自身的职业发展,想去一个更加适合自己成长的公司。

谈薪篇

到手的才是真的,饼太大容易噎着,当然饼也是有可能兑现的,这就看自身机遇了,一般来说在薪资满意的情况下,再去吃饼:比如说期权。

这里简单说下 offer 里的期权到底是什么。假如公司承诺给你 5000 期权,1 美金的行权价,4 年行权。这就意味着你可以通过 1 美金买一股期权,但是 1 股期权不一定就等比上市后的股票,还可能需要稀释。假如稀释 10 倍的话那也就是 500 股票,你还得花 4 年才能拿到所有的股票,最后行权还有税,所以说大部分的期权其实没啥用。

接下来就是具体谈薪的部分啦。

在面试之前首先要想好自己想要的薪资,假如说你当前薪资为 10K,那么涨幅在 3K 以上是正常的。如果只有 1K - 2K 的涨幅跳槽是没多大意义,毕竟换公司存在成本。另外很多 HR 会压低你的报价,毕竟公司都是有预算的,能省一点是一点,所以我们需要给出一个压价的空间。所以在具体报价的时候你可以给出 14 - 15K 的心理价位,如果对方接受了那么皆大欢喜,如果压价到自己的期望薪资的话也不差。

最后在和 HR 讨论待遇的时候,应该问清楚以下几点

  • 具体的工资(也就是合同上签订的工资),不要杂七杂八什么绩效加起来的那种
  • 五险一金缴纳的比例,这个在交满和不交满的情况下其实是很大一笔收益。交满虽然自己交的也多了,但是大头公积金是能取出来的,医疗保险看病也用得到,只有养老金稍微虚幻了一点
  • 加班是否有加班工资或者调休
  • 是否是 996,我个人很不推荐 996 的公司
  • 加薪升职的情况
  • 其他各种福利,比如餐补、房补、交通补、节假日福利、另外的保险等等,这个算是锦上添花
  • 年终奖如何发放,员工平均能拿到几个月

选择 Offer 篇

这里分校招和社招来讲。

校招

对于校招来说,平台 > 团队 > 其他。在平台差不多的情况下可以去选择更好的团队,但是在平台存在差距的情况下务必要选择平台更大的,职业生涯初期就职的平台越好那么将来也会更顺,即使好的平台工资给的低也不要紧,毕竟这段校招的经历不会长。

社招

对于社招来说,其实还是看自己缺什么去补什么的。假如说你缺钱,那么可能有更好的平台摆在你面前也会选择给更多钱的一方;假如说你想去个更大的平台,那么可能小平台开的价更高你也不想去;假如说你想通勤近点多陪陪家人,那么远的公司肯定也就不考虑了。

如果你觉得几个条件自己都不缺或者把握不好的,可以参考下笔者的思路:

  1. 按照权重先这样选择:平台 | 薪资(两者看个人选择) > 团队 > 加班 & 通勤。
  2. 钱多有时候不一定好。钱多如果加班也多,那肯定比不过薪资差点但是不加班的公司。另外 HR 和你谈的年终奖也不一定拿得到,说不到到了年终把你裁了呢~
  3. 去深入了解下具体要去的团队,可以加个未来上级的微信聊聊,同时也四处询问下这个团队是否靠谱。毕竟一个团队以及直属领导的好坏,会直接影响着你的绩效和晋升空间。
  4. 通勤时间,如果你已经有房了,那么通勤时间是需要考虑上的。如果每次通勤需要一小时以上外加公司还要加班的话,其实幸福感会蛮低的。

最后

觉得文章还行的读者可以点个赞,另外有任何问题也可以评论区交流。

PS:讲道理标题有点标题党的意思,但是文章内容过得去应该也还行~

微信扫码关注公众号,订阅更多精彩内容                                                                 加笔者微信群聊技术                                                                    

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.