Git Product home page Git Product logo

article's Introduction

2024

2023

2022

2021

2020

2019

2018

其他

article's People

Contributors

dependabot[bot] avatar fe-sadhu avatar

Stargazers

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

Watchers

 avatar  avatar  avatar

Forkers

andyyyf arieshaw

article's Issues

深入js之内存管理与内存泄漏

由于js有自动垃圾回收机制,内存的分配和回收都实现了自动管理,所以不少jser都不太注意内存空间。但其实想要真正掌握js这门 “真·牛逼” 的语言,想对后续知识点理解更深刻,那么就得掌握内存空间相关知识。

JS运行的时候,会有栈内存(stack)和堆内存(heap),当我们用new实例化一个类的时候,这个new出来的对象就保存在heap里面,而这个对象的引用则存储在stack里。程序通过stack里的引用找到这个对象。例如var a = [1,2,3];,a的值是存储在stack里的引用,heap里存储着内容为[1,2,3]的Array对象。当栈中的变量被重新赋值,原来在堆中存储的对象就会被释放,这个过程就叫垃圾回收。

简单来说:

  • 基础类型值存在 栈内存
  • 引用类型值存在 堆内存

小tips: 为什么基础类型值要放在栈中,引用类型值放在堆中?

记住一句话:能量是守衡的,无非是时间换空间,空间换时间的问题 堆比栈大,栈比堆的运算速度快,对象是一个复杂的结构,并且可以自由扩展,如:数组可以无限扩充,对象可以自由添加属性。将他们放在堆中是为了不影响栈的效率。而是通过引用的方式查找到堆中的实际对象再进行操作。相对于简单数据类型而言,简单数据类型就比较稳定,并且它只占据很小的内存。不将简单数据类型放在堆是因为通过引用到堆中查找实际对象是要花费时间的,而这个综合成本远大于直接从栈中取得实际值的成本。所以简单数据类型的值直接存放在栈中。

栈数据结构

栈数据结构的逻辑就是一句话:先进后出,后进先出。有个例子举得好,想想在乒乓球盒里取球和放球,如下图自己理解。

堆数据结构(heap)

堆数据结构是一种树状结构,大部分时候是完全二叉树,使用一个数组就可以存储,利于存储并且便于索引。可以在很快的时间内找到需要的值。举个例子,不同书摆在书柜,知道书名可以很快找到该书。

基础数据类型

js中的基础数据类型还记得吧:undefinednullstringnumberboolean。还有ES6增加的sympol(暂不考虑)。

注意:基本数据类型的值都是按值访问的,我们可以直接操作保存在变量中实际的值。

堆内存与引用数据类型

除了以上6种基础数据类型外,就是引用数据类型了,如Object类型,Array类型,Date类型,Function类型等,统称为Object类型

对于引用数据类型,它的值是一个对象,这个对象保存在堆内存当中。

注意: 引用数据类型是按引用访问的,所以我们不能直接操作保存在堆内存中的对象,而是要去操作对象对应的引用,类似于一个地址指针,指向保存在堆内存中的对象实际值。

举个例子说明

var a = 1;
var b = 'sadhu';
var c = true;
var d = undefined;
var e = null;

var arr = [1, 2];
var obj = {
    place: 'heap'
}

在这个例子中,我们画个图说明:

当要访问堆内存空间的引用数据类型的值时,是先从stack里拿到引用,通过引用去取堆内存空间的值。

我们现在清楚了这流程后,可以来看一道面试题了:

var a = 10;
var b = a;
b = 20;
console.log(a, b); // 10, 20 
// 这里仅仅把a的值复制给b
var a = [1, 2, 3];
var b = a;
b.push(4);
console.log(a, b); //[1,2,3,4] [1,2,3,4]
// 因为这里是把a对应的指向 [1, 2, 3]的 引用 复制给了b。所以此时b和a都指向了 [1, 2, 3]。

内存空间管理与生命周期

那么这个自动垃圾收集机制的原理是什么呢?其实就是找出那些不再继续使用的值,然后释放其占用的内存。垃圾收集器会每隔固定的时间段就执行一次释放操作。

比如局部作用域中的局部变量在函数执行完毕后很容易被垃圾收集器回收。但是全局变量就比较难判断,所以尽量少用。

举个例子来理解js的内存生命周期:

var a = 20;  // 在内存中给数值变量分配空间 
alert(a + 100);  // 使用内存
a = null; // 使用完毕之后,释放内存空间

由例子可看出,js的生命周期是:

  1. 在定义变量的时候就为变量分配了所需的内存空间
  2. 使用分配到的内存(读、写)
  3. 不需要内存空间时将其释放、归还。

垃圾回收

在JavaScript中,最常用的是通过标记清除的算法来找到哪些对象是不再继续使用的,因此a = null其实仅仅只是做了一个释放引用的操作,让a原本对应的值失去引用,脱离执行环境,这个值会在下一次垃圾收集器执行操作时被找到并释放。而在适当的时候解除引用,是为页面获得更好性能的一个重要方式。

还有种,现在浏览器不再使用的垃圾收集算法是引用计数

引用计数算法

这是最简单的垃圾收集算法。此算法把“对象是否不再需要”简化定义为**“对象有没有其他对象引用到它”**。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。

限制:循环引用

这个简单的算法有一个限制,就是如果一个对象引用另一个(形成了循环引用),他们可能“不再需要”了,但是他们不会被回收。

function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o
  return "azerty";
}
f();
// 两个对象被创建,并互相引用,形成了一个循环
// 他们被调用之后不会离开函数作用域
// 所以他们已经没有用了,可以被回收了
// 然而,引用计数算法考虑到他们互相都有至少一次引用,所以他们不会被回收

实际当中的例子:

IE 6, 7 对DOM对象进行引用计数回收。对他们来说,一个常见问题就是内存泄露:

var div = document.createElement("div");
div.onclick = function(){
  doSomething();
}; 
// div有了一个引用指向事件处理属性onclick
// 事件处理里也有一个对div的引用可以在函数作用域中被访问到
// 这个循环引用会导致两个对象都不会被垃圾回收

IE中有一部分对象并不是原生JavaScript对象。例如,其BOM和DOM中的对象就是使用C++以COM(Component Object Model,组件对象模型)对象的形式实现的,而COM对象的垃圾收集机制采用的就是引用计数策略。因此,即使IE的JavaScript引擎是使用标记清除策略来实现的,但JavaScript访问的COM对象依然是基于引用计数策略的。换句话说,只要在IE中涉及COM对象,就会存在循环引用的问题

var element = document.getElementById("some_element");
var myObject = new Object();
myObject.element = element;
element.someObject = myObject;

DOM元素与原生JavaScript对象之间创建了循环引用。由于存在这个循环引用,即使将例子中的DOM从页面中移除,它也永远不会被回收。

在不使用它们的时候手工断开连接:

myObject.element = null;
element.someObject = null;

为了解决上述问题,IE9把BOM和DOM对象都转换成了真正的JavaScript对象。

JavaScript引擎目前都不再使用这种算法;但在IE中访问非原生JavaScript对象(如DOM元素)时,这种算法仍然可能会导致问题。

标记-清除算法 Mark and sweep

这个算法把“对象是否不再需要”简化定义为**“对象是否可以获得”**。

这个算法假定设置一个叫做根的对象(在Javascript里,根是全局对象)。定期的,垃圾回收器将从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和所有不能获得的对象。

算法工作原理:

垃圾回收器在运行的时候会给存储在内存中的所有变量都加上标记(当然,可以使用任何标记方式)。然后它会去掉环境中的变量以及被环境中的变量引用的变量标记。而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。最后,垃圾收集器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间。

这个算法比前一个要好,因为“有零引用的对象”总是不可获得的,但是相反却不一定,参考“循环引用”(比如A、B循环引用了,C对象(从根开始找,能引用到)引用的对象是A,当C对象切断与A的引用后,AB就都会被回收)。

循环引用不再是问题了

在上面的示例中,函数调用返回之后,两个对象从全局对象出发无法获取。因此,他们将会被垃圾回收器回收。第二个示例同样,一旦 div 和其事件处理无法从根获取到,他们将会被垃圾回收器回收。

限制: 那些无法从根对象查询到的对象都将被清除

尽管这是一个限制,但实践中我们很少会碰到类似的情况,所以开发者不太会去关心垃圾回收机制。

内存泄漏

能导致内存泄漏的一定是一定是一定只是引用类型的变量,比如函数和其他自定义对象。而值类型的变量是不存在内存泄漏的,比如字符串、数字、布尔值等。

当我们用JavaScript代码创建一个引用类型的时候(以下简称对象),js引擎会在内存中开辟一块空间来存放数据,并把指针引用交给那个变量。内存是有限的,js引擎必须保证当开辟的对象没用的时候,把所分配的内存空间释放出来,这个过程叫做垃圾回收,负责回收的叫做垃圾回收器(GC)。

内存泄漏是指我们已经无法再通过js代码来引用到某个对象,但垃圾回收器却认为这个对象还在被引用,因此在回收的时候不会释放它。导致了分配的这块内存永远也无法被释放出来。如果这样的情况越来越多,会导致内存不够用而系统崩溃。

造成内存泄漏的主要原因与解决办法

1、意外的全局变量

未定义的变量会在全局对象创建一个新变量,如下:

function foo(arg) {
    bar = "this is a hidden global variable";
}

函数 foo 内部忘记使用 var ,实际上JS会把bar挂载到全局对象上,意外创建一个全局变量。

function foo(arg) {
    window.bar = "this is an explicit global variable";
}

另一个意外的全局变量可能由 this 创建。

function foo() {
    this.variable = "potential accidental global";
}

// Foo 调用自己,this 指向了全局对象(window)
// 而不是 undefined
foo();

解决办法:
在 JavaScript 文件头部加上 'use strict',使用严格模式避免意外的全局变量,此时上例中的this指向undefined。如果必须使用全局变量存储大量数据时,确保用完以后把它设置为 null 或者重新定义。

2、被遗忘的计时器或回调函数

var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        // 处理 node 和 someResource
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);

上面的例子表明,在节点node或者数据不再需要时,定时器依旧指向这些数据。所以哪怕当node节点被移除后,interval 仍旧存活并且垃圾回收器没办法回收,它的依赖也没办法被回收,除非终止定时器。

3、脱离 DOM 的引用

有时,保存 DOM 节点内部数据结构很有用。假如你想快速更新表格的几行内容,把每一行 DOM 存成字典(JSON 键值对)或者数组很有意义。此时,同样的 DOM 元素存在两个引用:一个在 DOM 树中,另一个在字典中。将来你决定删除这些行时,需要把两个引用都清除

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
    text: document.getElementById('text')
};
function doStuff() {
    image.src = 'http://some.url/image';
    button.click();
    console.log(text.innerHTML);
    // 更多逻辑
}
function removeButton() {
    // 按钮是 body 的后代元素
    document.body.removeChild(document.getElementById('button'));
    // 此时,仍旧存在一个全局的 #button 的引用
    // elements 字典。button 元素仍旧在内存中,不能被 GC 回收。
}

此外还要考虑 DOM 树内部子节点的引用问题。假如你的 JavaScript 代码中保存了表格某一个 的引用。将来决定删除整个表格的时候,直觉认为 GC 会回收除了已保存的 以外的其它节点。实际情况并非如此:此 是表格的子节点,子元素与父元素是引用关系。由于代码保留了 的引用,导致整个表格仍待在内存中。保存 DOM 元素引用的时候,要小心谨慎。

闭包真是造成内存泄漏的原因之一吗?

不是!

闭包只是其内部函数记住并访问了所在的词法作用域(以chrome的说法),也会产生较大的内存占用但是,和内存泄漏有关系吗?看着一些系列文总结说与内存泄漏有关系你们就觉得与内存泄漏有关系呀?硬说有关系那是上世纪某个版本IE的bug了。

看个例子:

// 定义函数(分配内存)
var run = function () {

  // 定义一个巨大的数组
  var str = new Array(1000000).join('*');

  // 定义一个使用 str 的 闭包(该闭包是以高程的说法,此说法准确来说不算正确,不过也仅仅只是叫法问题。相关内容我会在接下来写文阐述。)
  var doSthWithStr = function () {
    if (str === 'something')
      console.log("Hi there");
  };

  // 调用这个闭包
  doSthWithStr();
};

// setInterval每隔 1 s 调用 run 函数,因此 run 不会被回收。
// 每次调用完 run 之后,由于内部没有外部对象引用,run内部的变量和闭包会被回收
// 所以不会出现内存一直增长的问题。
setInterval(run, 1000);

这例子不也应用闭包了,那咋没造成内存泄漏呢?

再来,改一下上面的例子变成内存泄漏:

var run = function () {
  var str = new Array(1000000).join('*');
  var doSthWithStr = function () {
  if (str !== 'something')
    console.log("Hi there");
  };
  
  // setInterval每隔 1 s 调用 run 函数,因此 run 不会被回收。
  // 由于 doSthWithStr 作为回调函数传给了 setInterval,所以不会被回收
  // 而 str 在它的词法作用域中,并且在doSthWithStr函数内部有调用,所以也不会被回收(闭包只是造成在doSthWithStr中有访问到str,而因为doSthWithStr不会被回收,所以str才不会被回收。)
  // 因此内存占用会一直增长
  setInterval(doSthWithStr, 100);
};
setInterval(run, 1000);

你可以把上述代码粘贴到 chrome 的 console 中运行,然后打开 timeline tab,录制内存使用量。你会看到内存使用量以每秒 1 M 的速度增长。

再来看个例子:

var fn = null;
function foo() {
    var a = 2;
    function innerFoo() {
        console.log(a);
    }
    fn = innerFoo; // 将 innnerFoo的引用,赋值给全局变量中的fn
}

function bar() {
    fn(); // 此处的保留的innerFoo的引用
}

foo();
bar(); // 2

关于此处,闭包仅仅只是让内部函数innerFoo里保留了对foo内(chrome的叫法)的变量对象中的值的访问。

foo()执行完毕之后,按照常理,其执行环境生命周期会结束,所占内存被垃圾收集器释放。但是通过fn = innerFoo(造成内存泄漏的原因是这个),函数innerFoo的引用被保留了下来,复制给了全局变量fn。那么标记清除就会从根(window)开始找,找到从根开始引用的对象fn,找到fn引用的对象innerFoo,所以innerFoo不会被GC回收,因为闭包能让内部函数innerFoo访问到foo的变量对象的值,所以foo的变量对象也被保留了下来。所以Foo()执行完毕之后,所占内存未被垃圾收集器释放。

关于此处更多的理解与例子:

  1. 问答模式,里面有人答的很好。js的闭包和回调到底怎么才会造成真的内存泄漏呢?
  2. 贺师俊贺老关于js闭包是否真的会造成内存泄漏?
  3. JS内存泄漏实例解析
  4. V8内存管理机制及垃圾回收

如何排查内存泄漏

阮老师有详细记录

最后

为了预防内存泄露及持有不必要的内存,应记得在函数结尾或适宜的时候对不需要引用的对象进行释放。为了确保有效地回收内存,应及时解除不再使用的全局对象、全局对象属性以及循环引用变量的引用。

本文完。

希望看到各位技术人对这篇文章有不同的有“证据”,符合逻辑的分析、看法~

文章会第一时间更新在GitHub,觉得写得还不错的,可以点个star支持下作者🍪

深入js之造bind轮子

Function.prototype.bind方法的定义,MDN上如是说:

bind()方法创建一个新的函数,在调用时设置this关键字为提供的值。并在调用新函数时,将给定参数列表作为原函数的参数序列的前若干项

官方文字性的语句,没用过不容易理解,根据我的理解和使用经历,简单提要下:

  1. 创建一个新函数。
  2. 可以传参。
  3. 新函数被调用时,其内的this指向bind()的第一个参数。
  4. 可以柯里化。
  5. 当 bind 返回的函数作为构造函数的时候,bind 时指定的 this 值会失效,但传入的参数依然生效。换句话说,new可以使bind绑定的this失效。

接下来根据这些提要,一步一步仿写出一个bind来。

第一步

上一篇文章介绍了 JS复习笔记之造call.apply轮子 的实现,现在我们可以利用 call来绑定this ,并且 return一个新函数 。

所以:

// 第一版代码
Function.prototype.mybind = function (obj) {
  var self = this;
  return function () {
    return self.apply(obj)
  }
}

// 例子
const obj = {
  value: 1
}

function bar () {
  console.log(this.value);
  return this.value;
}

const newBar = bar.bind(obj);
const newBar2 = bar.mybind(obj);

console.log(newBar());
console.log('*******华丽的分界线*******');
console.log(newBar2());

输出是:

1
1
*******华丽的分界线*******
1
1

第一步我们实现了摘要的第1、3点和不完整的第2点。

接下来第二步我们实现 柯里化 和 完整的第2点 。

第二步

利用 模拟bind方法的arguments 与 返回新函数的arguments 参数拼接成一个数组传入 aplly() 来实现。

// Second Codes
Function.prototype.mybind = function (obj) {
  var self = this;
  var args = Array.prototype.slice.call(arguments, 1); // 切出从第一位开始到最后的函数参数数组args
  return function () {
    var newArgs = Array.prototype.slice.call(arguments);
    return self.apply(obj, args.concat(newArgs));
  }
}

// Example
const obj = {
  value: 1
}

function bar (name, age) {
  console.log(this.value);
  console.log(name);
  console.log(age);
}

const newBar = bar.bind(obj, 'Sadhu');
const newBar2 = bar.mybind(obj, 'Sadhu');

newBar(17);
console.log('*******华丽的分界线*******');
newBar2(17);

输出:

1
Sadhu
17
*******华丽的分界线*******
1
Sadhu
17

第三步

实现摘要的第5点,new可以使bind绑定的this失效。具体什么意思呢?

举个例子:

const obj = {
  value: 1
}

const value = 2;

function bar (name, age) {
  this.name = name;
  this.age = age;
  console.log(this.value);
}

bar.prototype.habit = 'coding'; // 划重点

const newBar = bar.bind(obj, 'sadhu');

const newObj = new newBar(17);
console.log('*******华丽的分界线*******');
console.log(newObj.name);
console.log(newObj.age);
console.log(newObj.habit);
// undefined 
//*******华丽的分界线*******
// sadhu
// 17
// coding

此处全局和obj中都有 value 。但是new调用newBar()执行

console.log(this.value);

时依然返回 undefined。说明bind绑定的this失效了。其实此时这里的this已经指向了 newObj 实例,这里可以看造new轮子的文章。

还有个需要关注的要实现一点,我上述代码中 划重点 了。根据输出的情况来看,意味着:

意味着绑定函数bar的prototype属性 等于 newObj.__proto__ 等于 newBar.prototype

好,搞清楚了需求,接下来来实现。

// codes
Function.prototype.mybind = function (obj) {
  var self = this;
  var args = Array.prototype.slice.call(arguments, 1);
  
  var fBound = function () {
    var newArgs = Array.prototype.slice.call(arguments);
    // 当fBound为构造函数时,它的this指向实例。 instanceof判断为true。
    // 当fBound为普通函数时,它的this默认指向winodw。 instanceof判断为false。
    return self.apply(this instanceof fBound ? this : obj, args.concat(newArgs));
  }
  // 这样返回函数fBound的实例就可以访问到绑定函数的prototype属性上的值。
  fBound.prototype = this.prototype;
  return fBound;
}

测试:

// Example
const value = 2;

const obj = {
  value: 1
}

function bar (name, age) {
  this.name = name;
  this.age = age;
  console.log(this.value);
}

bar.prototype.habit = 'coding'; // 划重点

const newBar = bar.bind(obj, 'sadhu');
const newBar2 = bar.mybind(obj, 'sadhu')

const newObj2 = new newBar2(17);
console.log('*******华丽的分界线*******');
console.log(newObj2.name);
console.log(newObj2.age);
console.log(newObj2.habit);
// undefined 
//*******华丽的分界线*******
// sadhu
// 17
// coding

根据输出结果看,目前为止的模拟,输出是正确的。

这样就完了吗?有没有发现代码中哪里有点不对劲?

提示:
fBound.prototype = this.prototype;

接下来我们进行优化。

轮子代码优化

在第三步的写法中,我们使用了fBound.prototype = this.prototype,那么我们直接修改 fBound.prototype 的时候也会直接修改了绑定函数的 prototype 。

此时我们可以通过一个空函数来进行中转。

Function.prototype.mybind = function (obj) {
  var self = this;
  var args = Array.prototype.slice.call(arguments, 1);
  var fNOP = function() {};

  var fBound = function () {
    var newArgs = Array.prototype.slice.call(arguments);
    return self.apply(this instanceof fBound ? this : obj, args.concat(newArgs));
  }
  
  fNOP.prototype = this.prototype; // 他俩指向的是同一原型对象。
  fBound.prototype = new fNOP(); // 之后要找原型链上的属性就是 fBound实例.__proto__ === fBound.prototype, fBound.prototype.__ptoto__ === FNOP.prototype === this.prototype。
  return fBound;
}

此时对fBound.prototype的操作就不会同步到绑定函数的prototype的修改了。

到此为止代码完成百分之98了,再来最后的细节优化。

最终代码

最后优化内容:

  1. 调用bind的不是函数就报错。
  2. 做个兼容。
Function.prototype.mybind = Function.prototype.bind || function (obj) {
  if (typeof this !== "function") {
    throw new Error("Function.prototype.bind - what is trying to be bound is not callable");
  }
  var self = this;
  var args = Array.prototype.slice.call(arguments, 1);
  
  var fNOP = function () {};

  var fBound = function () {
    var newArgs = Array.prototype.slice.call(arguments);
    return self.apply(this instanceof fBuond ? this : obj, args.concat(newArgs));
  }
  fNOP.prototype = this.prototype;
  fBound.prototype = new fNOP();
  return fBound;
}

参考:

  1. MDN
  2. JavaScript深入之bind的模拟实现

标注图+部分举例聊聊Vue生命周期

“你不需要立马弄明白所有的东西,不过随着你的不断学习和使用,它的参考价值会越来越高。”

现在项目中遇到了,好好回头总结一波Vue生命周期,以后用到的时候再来翻翻。

啥叫Vue生命周期?

每个 Vue 实例在被创建时都要经过一系列的初始化过程。

例如:从开始创建、初始化数据、编译模板、挂载Dom、数据变化时更新DOM、卸载等一系列过程。

我们称 这一系列的过程 就是Vue的生命周期。

通俗说就是Vue实例从创建到销毁的过程,就是生命周期。

同时在这个过程中也会运行一些叫做生命周期钩子的函数,这给了用户在不同阶段添加自己的代码的机会,利用各个钩子来完成我们的业务代码。

啥也不说,先来个干货

这是对于Vue生命周期,官网给的那张图的标注图,图片网上看到的,我觉得标注地很nice,建议一步步仔细看完图片,然后把图片自己悄悄保存下来,对照着图片的内容看第二部分的举例说明。

我相信程序员看代码比看文字更容易理解

对照着上图标注的内容,我们一个钩子一个钩子地举例说明。

1.beforeCreate

实例初始化之后、创建实例之前的执行的钩子事件。

如下例子:

<body>
<div id="root">{{test}}</div>
<script type="text/javascript">
	const vm = new Vue({
		el: '#root',
		data: {
			test: '天王盖地虎'
		},
		beforeCreate() {
			console.log('beforeCreate钩子事件:');
			console.log(this.$data);
			console.log(this.$el);
		}
	})
</script>
</body>

得到的结果是:


小总结:创建实例之前,数据观察和事件配置都没好准备好。也就是数据也没有、DOM也没生成。

2.created

实例创建完成后执行的钩子

在上一段代码例子中,我们再来console一下。

<body>
<div id="root">{{test}}</div>
<script type="text/javascript">
	const vm = new Vue({
		el: '#root',
		data: {
			test: '天王盖地虎'
		},
		created() {
			console.log('created钩子事件:');
			console.log(this.$data);
			console.log(this.$el);
		}
	})
</script>
</body>

得到的结果是:

小总结:实例创建完成后,我们能读取到数据data的值,但是DOM还没生成,挂载属性el还不存在。

3.beforeMount

将编译完成的html挂载到对应的虚拟DOM时触发的钩子

此时页面并没有内容。

即此阶段解读为: 即将挂载

我们打印下此时的$el

beforeMount() {
			console.log('beforeMount钩子事件:');
			console.log(this.$el);
		}

得到的结果是:

小总结:此时的el不再是undefined,成功关联到我们指定的dom节点。但是此时的{{test}}还没有成功渲染成data中的数据,页面没有内容。

PS:相关的render函数首次被调用。

4.mounted

编译好的html挂载到页面完成后所执行的事件钩子函数。

此时的阶段解读为: 挂载完毕阶段

我们再打印下此时$el看看:

mounted() {
			console.log('mounted钩子事件:');
			console.log(this.$el);
		}

得到的结果是:


可见, {{test}}已经成功渲染成data里面test对应的值“天王盖地虎”了。

小总结:此时编译好的HTML已经挂载到了页面上,页面上已经渲染出了数据。一般会利用这个钩子函数做一些ajax请求获取数据进行数据初始化。

PS:mounted在整个实例中只执行一次。

5.beforeUpdate

小总结:当修改vue实例的data时,vue就会自动帮我们更新渲染视图,在这个过程中,vue提供了beforeUpdate的钩子给我们,在检测到我们要修改数据的时候,更新渲染视图之前就会触发钩子beforeUpdate。

6.updated

小总结:此阶段为更新渲染视图之后,此时再读取视图上的内容,已经是最新的内容。

PS:

1、该钩子在服务器端渲染期间不被调用。

2、应该避免在此期间更改状态,因为这可能会导致更新无限循环。

7.beforeDestroy

小总结:调用实例的destroy( )方法可以销毁当前的组件,在销毁前,会触发beforeDestroy钩子。

8.destroyed

小总结:成功销毁之后,会触发destroyed钩子,此时该实例与其他实例的关联已经被清除,Vue实例指示的所有东西都会解绑定,所有的事件监听器会被移除,所有的子实例也会被销毁。

话在最后

其实还有三个生命周期钩子没列出来:activated、deactivated、errorCaptured。这三个大家遇到了自行了解哈,暂且这样吧。

Monorepo 策略与方案选型小记

由于最近负责抽象公司项目模块化成 SDK,故预研了 Monorepo 、Lerna、yarn workspaces 管理多模块的方案,记一笔调研结论。

方案调研

采用维基百科的解释:

In version control systems, a monorepo ("mono" meaning 'single' and "repo" being short for 'repository') is a software development strategy where code for many projects is stored in the same repository.

也就是说 Monorepo 是版本管理系统中的一种“开发策略”,宏观上说就是单 repository 管理多 projects(packages) 。

采用 Monorepo 管理多模块的优势以及与单 package 单 repo 的对比:

  1. 利于跨模块 (package) 调试
    若单模块 A 就是一个 repo,要 npm link 给别的 repo 调试,且 A 源码修改了,得重新 npm link 一次。 Monorepo 的话,则是动态引用的各模块。
  2. 利于各模块的版本发布管理
    各模块统一发布,有一个依赖模块改变了,会自动改变依赖与被依赖模块的版本号,且可以选择统一|分别控制版本号的处理。(Fixed | Independent mode)
    单模块单 repo 的话,改变了依赖模块 A,手动修改版本号发布后,得手动去找被依赖模块且改版本号然后发布。repo 越多越麻烦。
  3. 利于各模块的依赖管理
    不会装各 packages 间的重复依赖,所有依赖提升到根目录。(导致的缺点就是只调试一个包也要装全部依赖)
  4. 方便生成 CHANGELOG,管理 issue、pr
    因为就一个 repo,commit 可以借工具规范生成 changelog 。

缺点就是一个 repo 的体积过大。但相对以上前三点来说,可以接受。

根据调研,一个理想的 monorepo 结构:

.
├── packages
│      ├─ module-a
│      │    ├─ src            # 模块 a 的源码
│      │    └─ package.json   # 自动生成的,仅模块 a 的依赖
│      └─ module-b
│           ├─ src            # 模块 b 的源码
│           └─ package.json   # 自动生成的,仅模块 b 的依赖
├── tsconfig.json             # 配置文件,对整个项目生效
├── .eslintrc                 # 配置文件,对整个项目生效
├── node_modules              # 整个项目只有一个外层 node_modules
└── package.json              # 包含整个项目所有依赖

所有全局配置文件只有一个,这样不会导致 IDE 遇到子文件夹中的配置文件,导致全局配置失效或异常。

node_modules 也只有一个,既保证了项目依赖的一致性,又避免了依赖被重复安装,节省空间的同时还提高了安装速度。

兄弟模块之间通过模块 package.json 定义的 name 相互引用,保证模块之间的独立性,但又不需要真正发布或安装这个模块,通过 tsconfig.json 的 paths 与 webpack 的 alias 共同实现虚拟模块路径的效果。

再结合 Lerna 根据联动发布功能,使每个子模块都可以独立发布。

Lerna 是业界知名度最高的 Monorepo 管理工具,功能完整。

再根据对别的团队 Monorepo 方案调研结果,决定采用 yarn workspaces + lerna 方案管理,也是 yarn 官方推荐的方案,核心是:使用 yarn workspaces 来管理依赖,使用 lerna 来管理 npm 包的版本发布。

Lerna + yarn workspaces

以下是自己建 demo repo 跟着文档尝试后,小记的常用指令及含义。 也建议亲自折腾下体会更深。

Combine yarn workspaces and Lerna

// root -> lerna.json
{
  "npmClient": "yarn",
  "useWorkspaces": true
}
// root -> package.json
{
  "workspaces": [
    "packages/*"
  ],
}

因 lerna 本身就是基于 yarn 、npm、git 开发,所以在利用 lerna 构建好项目后开启 yarn workspace 功能仅作如上配置即可。

Lerna

  1. lerna init
    创建 lerna 仓库,默认 Fixed 模式。 lerna init --independent 可以创建 Independent 模式。

Fixed 模式 -> 所有 package 版本一致
Independent 模式 -> 可以独立控制各个 package 版本

  1. lerna create xxx
    创建新模块

  2. lerna add <package>[@version] [--dev] [--exact] [--peer]
    假如有两个 package-1, package-2,。
    lerna add babel , 该命令会在package-1和package-2下安装babel
    lerna add react --scope=package-1 ,该命令会在package-1下安装react
    lerna add package-2 --scope=package-1,该命令会在package-1下安装package-2 (软链)

  3. lerna bootstrap | yarn install
    安装所有依赖项并链接所有的交叉依赖

  4. lerna exec
    在 packages 中对应包下的执行任意命令。
    如要执行 package-A 下的 yarn start
    lerna exec --scope package-A -- yarn start
    如果不带 --scope package,则默认在根目录执行,如
    lerna exec -- rm -rf ./node_modules

  5. lerna run --scope my-component test
    执行 my-component 下的 npm scripts test

  6. lerna ls
    查看 packages
    lerna list --json 带路径一起查

  7. lerna changed/updated/diff
    查出待 publish 的 packages
    diff 的话会可视化修改
    不主动 git commit | tag 的话,lerna 不会检测到,lerna 底层就是基于 git npm 开发的

  8. lerna clean
    删除 packages 下的 node_modules
    (lerna clean 不会删除项目最外层的根 node_modules)

  9. lerna publish
    发布 packages 到 npm 仓库,发包前需要登录 npm 账号,否则会上传 git 成功,上传 npm 失败。
    (package.json 中的 ”private“: true 不会发布)

publish 内部做得事情:

  1. 运行 lerna updated 来决定哪一个包需要被 publish
  2. 如果有必要,将会更新 lerna.json 中的 version (Fixed 模式)
  3. 将所有更新过的的包中的 package.json 的 version 字段更新
  4. 将所有更新过的包中的依赖更新
  5. 为新版本创建一个 git commit 或 tag
  6. 将包 publish 到 npm 上

该命令也有许多的参数,例如 --skip-git 将不会创建 git commit 或 tag,--skip-npm 将不会把包 publish 到 npm 上。

yarn workspaces

  1. yarn install
    lerna bootstrap 效果一致,会自动帮忙解决安装和 link 问题

  2. yarn workspaces info
    各 package 依赖树关系

  3. 安装 | 删除依赖
    i. 给某个 package 安装 | 删除依赖:
    yarn workspace packageB add [email protected] 将 packageA 作为 packageB 的依赖进行安装,如果想要不同 package 间的 link,必须明确指定版本号。
    yarn workspace packageB add -D react
    删除: yarn workspace packageB remove packageA
    ii. 给根目录 安装 | 删除依赖(适用所有 packages):
    yarn add -W -D commitizen root package 安装 commitizen
    yarn remove -W commitizen root package 移除 commitizen

  4. 执行 scripts
    运行 packageA 的 dev 命令: yarn workspace packageA dev
    每个工作区运行命令: yarn workspaces run xxx

最佳实践

除了以上 ,最佳实践决定参考 vivo 团队实现淘系团队实现,之后开发过程中,再根据项目自身情况进行调整。

深入js之闭包

理解

闭包是一种特殊的对象。

它由两部分组成。执行上下文(代号A),以及在该执行上下文中创建的函数(代号B)。

当B执行时,如果访问了A中变量对象中的值,那么闭包就会产生。

在大多数理解中,包括许多著名的书籍,文章里都以函数B的名字代指这里生成的闭包。而在chrome中,则以执行上下文A的函数名代指闭包。

因此我们只需要知道,一个闭包对象,由A、B共同组成,在以后的篇幅中,我将以chrome的标准来称呼。

举个例子:

// demo01
function foo() {
    var a = 20;
    var b = 30;

    function bar() {
        return a + b;
    }

    return bar;
}

var bar = foo();
bar();

上面的例子,首先有执行上下文foo,在foo中定义了函数bar,而通过对外返回bar的方式让bar得以执行。当bar执行时,访问了foo内部的变量a,b。因此这个时候闭包产生。

闭包有两个特性:

  1. 通过闭包,我们可以在其他的执行上下文中,访问到函数的内部变量。(比如上面那个例子中bar在全局执行上下文中执行,访问到了foo函数内的变量a、b)
  2. 闭包有比较高的内存占用(因为特性1才有的特性2,而且特性2绝不是闭包造成内存泄漏的原因,具体解释看我内存分析那篇文章:深入js之内存管理与内存泄漏

再举个例子:

var fn = null;
function foo() {
    var a = 2;
    function innnerFoo() {
        console.log(a);
    }
    fn = innnerFoo; // 将 innnerFoo的引用,赋值给全局变量中的fn
}

function bar() {
    fn(); // 此处的保留的innerFoo的引用
}

foo();
bar(); // 2

foo()执行完毕之后,按照常理,其执行环境生命周期会结束,所占内存被垃圾收集器释放。但是通过fn = innerFoo(造成内存泄漏的原因是这个),函数innerFoo的引用被保留了下来,复制给了全局变量fn。那么标记清除就会从根(window)开始找,找到从根开始引用的对象fn,找到fn引用的对象innerFoo,所以innerFoo不会被GC回收,因为闭包能让内部函数innerFoo访问到foo的变量对象的值,所以foo的变量对象也被保留了下来。于是,函数fn在函数bar内部执行时,依然可以访问这个被保留下来的变量对象。所以此刻仍然能够访问到变量a的值。

下面证明为什么我们称这个foo函数名代指闭包(以chrome的说法):

在上面的图中,红色箭头所指的正是闭包。其中Call Stack为当前的函数调用栈,Scope为当前正在被执行的函数的作用域链,Local为当前的局部变量。

注意:在闭包中,能访问到的变量,仍然是作用域链上能够查询到的变量。

修改下上面例子:

var fn = null;
function foo() {
    var a = 2;
    function innnerFoo() {
        console.log(c); // 在这里,试图访问函数bar中的c变量,会抛出错误
        console.log(a);
    }
    fn = innnerFoo; // 将 innnerFoo的引用,赋值给全局变量中的fn
}

function bar() {
    var c = 100;
    fn(); // 此处的保留的innerFoo的引用
}

foo();
bar();

此时会报错c没有定义:Uncaught ReferenceError: c is not defined

应用场景

  • 柯里化

之后文章会讲到。

  • 模块化(变量私有化,通过模块暴露出的api访问或操作内部私有化的变量/函数)

举个例子:

var foo = (function() {
    var secret = 'secret';
    // 闭包内的函数可以访问secret变量,而secret变量对于外部却是隐藏的
    return {
        get_secret: function() {
            return secret;
        },
        new_secret: function( new_secret ) {
            secret = new_secret;
        }
    };
})();

foo.get_secret(); // 得到 'secret'
foo.secret; // Type error,访问不能
foo.new_secret('a new secret');
foo.get_secret(); // 得到 'a new secret'

经典题型

1、利用闭包,修改下面的代码,让循环输出的结果依次为1, 2, 3, 4, 5

for (var i=1; i<=5; i++) {
    setTimeout( function timer() {
        console.log(i);
    }, i*1000 );
}

解决方法分两步:一是使用自执行函数提供闭包条件,二是传入i值并保存在闭包中。

解法一:

for(var i=1; i<=5; i++) {
    (function(i) {
        setTimeout( function timer() {
        console.log(i);
    }, i*1000)
    })(i)
    
}

解法二:

for(var i=1; i<=5; i++) {
    setTimeout(
        (function(i){
            return function timer() {
                console.log(i);
            }
        })(i), i*1000)
}

2、

var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}

data[0]();
data[1]();
data[2]();

解法交给你。

本文完。

希望看到各位技术人对这篇文章有不同的有“证据”,符合逻辑的分析、看法~

文章会第一时间更新在GitHub,觉得写得还不错的,可以点个star支持下作者🍪

深入js之分析防抖与节流

防抖

防抖是什么?

对于一定时间段的连续的函数调用,只让其执行一次。

常用于DOM事件的监听回调中:核心是维护一个定时器,事件被触发n秒后再执行回调,如果n秒内又被触发,则重新计时。

什么时候应用防抖

前端开发中会遇到一些频繁的事件触发,如:

  1. window 的 resize、scroll
  2. mousedown、mousemove
  3. keyup、keydown

这些事件触发后都会执行一个回调函数,如果短时间内有很多次事件触发,则浏览器需要短时间内执行很多次回调,如果次数过于频繁,浏览器会反应不过来,就会出现 卡顿 现象,此时就需要防抖/节流。

举个例子:

// 模拟一段ajax请求
    function ajax(context) {
      console.log('ajax request' + context);
    }

    let inputa = document.getElementById('unDebounce');

    inputa.addEventListener('keyup', function(e) {
      ajax(e.target.value);
    })

此处也频繁执行了keyup的回调,因为这个例子很简单,所以浏览器完全反应的过来,但例子复杂的话,浏览器就会有卡顿了。此时我们可以通过 防抖节流 来解决。

跟着underscore的防抖代码学习

核心是维护一个定时器,事件被触发n秒后再执行回调,如果n秒内又被触发,则重新计时。

    // 模拟一段ajax请求
    function ajax(context) {
      console.log('ajax request' + context.target.value);
    }
    
    function debounce(fn, wait) {
      var timeout = null;

      return function() {
        var context = this; // this指向DOM元素
        var args = arguments; // JavaScript 在事件处理函数中会提供事件对象 event 作为参数。

        clearTimeout(timeout);
        timeout = setTimeout(function() {
          fn.apply(context, args)
        }, wait)
      }
    }

    let inputb = document.getElementById('Debounce');

    inputb.addEventListener('keyup', debounce(ajax, 1000))

可以看到,我们加入了防抖以后,当你在频繁的输入(事件频繁被触发)时,并不会发送请求,只有当你在指定间隔内没有输入时,才会执行函数。如果停止输入但是在指定间隔内又输入,会重新触发计时。

完善防抖函数debounce

立即执行

上面的debounce函数已经可以满足很多场景了,但如果有这样一个需求:

我不希望非要等到事件停止触发后才执行,我希望立刻执行函数,然后等到停止触发 n 秒后,才可以重新触发执行。

我们可以加个 immediate 参数判断是否是立刻执行,如下:

function debounce(fn, wait, immediate) { // immediate为立即执行一次的开关
      var timeout = null;
      return function() {
        // 一开始立即执行一次fn,若在wait时间内再次触发事件执行回调,callNow 会为 false,不会调用到fn。若在wait时间后再触发事件则会再次立即执行。
        var context = this;
        var args = arguments;

        if (timeout) clearTimeout(timeout);
        if (immediate) {
          var callNow = !timeout;
          timeout = setTimeout(function() {
            timeout = null;
          }, wait);
          if (callNow) fn.apply(context, args);
        } else {
          timeout = setTimeout(function() {
            fn.apply(context, args);
          }, wait)
        }
      }
    }

来看下效果:

返回值

getUserAction 函数可能是有返回值的,所以我们也要返回函数的执行结果,但是当 immediate 为 false 的时候,因为使用了 setTimeout ,我们将 func.apply(context, args) 的返回值赋给变量,最后再 return 的时候,值将会一直是 undefined,所以我们只在 immediate 为 true 的时候返回函数的执行结果。

function debounce(fn, wait, immediate) {
      var timeout = null;
      var result;
      return function() {
        var context = this;
        var args = arguments;

        if (timeout) clearTimeout(timeout);
        if (immediate) {
          var callNow = !timeout;
          timeout = setTimeout(function() {
            timeout = null;
          }, wait);
          if (callNow) result = fn.apply(context, args);
        } else {
          timeout = setTimeout(function() {
            fn.apply(context, args);
          }, wait)
        }
        return result;
      }
    }

手动取消防抖

希望能取消 debounce 函数,比如说 debounce 的时间间隔是 10 秒钟,immediate 为 true,这样的话,我只有等 10 秒后才能重新触发事件,现在我希望有一个按钮,点击后,取消防抖,这样我再去触发,就可以又立刻执行了。

其实很简单,我们在函数上挂个cancel方法就行了:

function debounce(func, wait, immediate) {

    var timeout = null, result;

    var debounced = function () {
        var context = this;
        var args = arguments;

        if (timeout) clearTimeout(timeout);
        if (immediate) {
            // 如果已经执行过,不再执行
            var callNow = !timeout;
            timeout = setTimeout(function(){
                timeout = null;
            }, wait)
            if (callNow) result = func.apply(context, args)
        }
        else {
            timeout = setTimeout(function(){
                func.apply(context, args)
            }, wait);
        }
        return result;
    };

    debounced.cancel = function() {
        clearTimeout(timeout);
        timeout = null;
    };

    return debounced;
}

比如,需要用一个按钮,点击一下就可以取消,就可以这样使用:

var xxx = debounce(fn, 10000, true);
inputb.addEventListener('keyup', xxx));

document.getElementById("button").addEventListener('click', function(){
    xxx.cancel();
})

至此我们就实现了一个 underscore 中的 debounce 函数。

节流

节流是什么?

让一个函数不要执行得太频繁,减少一些过快的调用来节流

规定在一个单位时间内,只能触发一次函数。如果这个单位时间内触发多次函数,只有一次生效。

也常用于在DOM事件的监听回调中:如果你持续触发事件,每隔一段时间,只执行一次事件。

什么时候应用节流

函数节流有哪些应用场景?哪些时候我们需要间隔一定时间触发回调来控制函数调用频率?

  • DOM 元素的拖拽功能实现(mousemove)
  • 射击游戏的 mousedown/keydown 事件(单位时间只能发射一颗子弹)
  • 计算鼠标移动的距离(mousemove)
  • Canvas 模拟画板功能(mousemove)
  • 搜索联想(keyup)
  • 监听滚动事件判断是否到页面底部自动加载更多:给 scroll 加了 debounce 后,只有用户停止滚动后,才会判断是否到了页面底部;如果是 throttle 的话,只要页面滚动就会间隔一段时间判断一次。

按上面的例子:

function throttle(fun, delay) {
        let last, deferTimer
        return function (args) {
            let that = this
            let _args = arguments
            let now = +new Date()
            if (last && now < last + delay) { // 在设置的间隔时间内执行。这段代码保证在间隔时间内停止触发时,delay秒后还会执行一次。
                clearTimeout(deferTimer)
                deferTimer = setTimeout(function () {
                    last = now
                    fun.apply(that, _args)
                }, delay)
            }else { // 一开始立即执行
                last = now
                fun.apply(that,_args)
            }
        }
    }

exp2

跟着underscore节流代码学习

见:JavaScript专题之跟着 underscore 学节流

小结

函数节流和函数去抖的核心其实就是限制某一个方法被频繁触发,而一个方法之所以会被频繁触发,大多数情况下是因为 DOM 事件的监听回调,而这也是函数节流以及去抖多数情况下的应用场景。

按照DOM事件的监听回调来说两者区别的话:防抖是只有等事件停止触发后 n 秒才执行函数,节流是持续触发的时候,每 n 秒执行一次函数。

本文完。

希望看到各位技术人对这篇文章有不同的有“证据”,符合逻辑的分析、看法~

文章会第一时间更新在GitHub,觉得写得还不错的,可以点个star支持下作者🍪


参考:

  1. JavaScript专题之跟着 underscore 学节流
  2. JavaScript专题之跟着 underscore 学防抖
  3. JavaScript 函数节流和函数去抖应用场景辨析
  4. 7分钟理解JS的节流、防抖及使用场景

深入js之数组去重

兼容方式

var arr = [1, 1, '1', '1'];

function unique(arr) {
  var res = [];
  for (var i = 0; i < arr.length; i++) {
    for (var j = 0; j < res.length; j++) {
      if (arr[i] === res[j]) break;
    }
    if (j === res.length) { // 遍历完都无重复则会相等
      res.push(arr[i]);
    }
  }

  return res
}

console.log(unique(arr));

indexOf

var arr = [1, 1, '1', '1', 2, 3, 4, 4, 3];

function unique(arr) {
  var res = [];
  for (var i = 0; i < arr.length; i++) {
    if(res.indexOf(arr[i]) === -1) {
      res.push(arr[i]);
    }
  }

  return res;
}

console.log(unique(arr));

排序后再去重

var arr = [1, 1, '1', '1', 2, 3, 4, 4, 3];

function unique(arr) {
  var res = [];
  var sortArr = arr.concat().sort(); // 浅拷贝一个新数组来sort排序
  var seen;
  // console.log(sortArr); // [ 1, 1, '1', '1', 2, 3, 3, 4, 4 ]
  for (var i=0; i<sortArr.length; i++) {
    if(!i || seen !== sortArr[i]) { // 不是第一个元素或者不等于上一个元素
      res.push(sortArr[i]);
    }
    seen = sortArr[i];
  }
  return res;
}

console.log(unique(arr));

对一个已经排好序的数组去重,这种方法效率肯定高于使用 indexOf

结合indexOf和排序去重封装一个工具函数

知道了这两种方法后,我们可以去尝试写一个名为 unique 的工具函数,我们根据一个参数 isSorted 判断传入的数组是否是已排序的,如果为 true,我们就判断相邻元素是否相同,如果为 false,我们就使用 indexOf 进行判断

var array1 = [1, 2, '1', 2, 1];
var array2 = [1, 1, '1', 2, 2];

// 第一版
function unique(array, isSorted) {
    var res = [];
    var seen = [];

    for (var i = 0, len = array.length; i < len; i++) {
        var value = array[i];
        if (isSorted) {
            if (!i || seen !== value) {
                res.push(value)
            }
            seen = value;
        }
        else if (res.indexOf(value) === -1) {
            res.push(value);
        }        
    }
    return res;
}

console.log(unique(array1)); // [1, 2, "1"]
console.log(unique(array2, true)); // [1, "1", 2]

Array.prototype.filter简化去重

简化indexOf

var arr = [1, 1, '1', '1', 2, 3, 4, 4, 3];

function unique(arr) {
  var res = arr.filter(function (item, index, array) {
    return array.indexOf(item) === index; // indexOf只返回该值在数组中的顺序第一次出现的索引
  })
  return res;
}

console.log(unique(arr));

简化排序

function sortUnique(arr) {
  var res = arr.concat().sort().filter(function(item, index, array) {
    return !index || item !== array[index - 1];
  })
  return res;
}

console.log(sortUnique(arr));

ES6的方法

如Set和Map这两个数据结构,以Set为例,类似数组,并且保证值不重复,似乎天生就为数组去重而生。

采用Set的话有很多种写法:

// Set数据结构
var arr = [1, 1, '1', '1', 2, 3, 4, 4, 3];

function unique(arr) {
  // 第一种形式
  // var value = new Set(arr);
  // return Array.from(value); // return Array.from(new Set(arr));

  // 第二种形式
  return [...new Set(arr)];
}

// 再简化第三种形式
var unique1 = (arr) => [...new Set(arr)];

console.log(unique1(arr));

采用Map:

var arr = [1, 1, '1', '1', 2, 3, 4, 4, 3];

function unique(arr) {
  var value = new Map();
  return arr.filter((item) => !value.has(item) && value.set(item, 1));
}

console.log(unique(arr));

特殊类型比较

去重的方法就到此结束了,然而要去重的元素类型可能是多种多样,除了例子中简单的 1 和 '1' 之外,其实还有 null、undefined、NaN、对象等,那么对于这些元素,之前的这些方法的去重结果又是怎样呢?

关于此处见这篇总结:JavaScript专题之数组去重

本文完。

希望看到各位技术人对这篇文章有不同的有“证据”,符合逻辑的分析、看法~

文章会第一时间更新在GitHub,觉得写得还不错的,可以点个star支持下作者🍪

深入js之造new轮子

先搞清楚new

首先,我们要知道,创建一个用户自定义对象需要两步:

  1. 通过编写函数来定义对象类型。
  2. 通过new来创建对象实例。

通过此处引出了new的描述,new是干嘛的呢?引用MDN的一句话:

new 运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。

初看时可能会有点懵,我们可以对这段话修枝剪叶留下枝干: new运算符可以创建 对象实例 。(之后再通过new的运用,补全其枝叶。)

这句话就符合最开头说的创建一个用户自定义对象的第二步了。实际应用也是,new运算符经常是与一个函数结合使用。

来看个例子:

// example
function Foo(name, age) {
  this.name = name;
  this.age = age;
  this.sex = 'male';
}
Foo.prototype.brother = "宇智波鼬";

Foo.prototype.chat = function () {
  console.log('how are you? 鸣人');
}

var person = new Foo('佐助', 21);

console.log(person.name);
console.log(person.sex);
console.log(person.brother);
person.chat();
// 佐助
// male
// 宇智波鼬
// how are you? 鸣人

从这个例子中我们可以看出,:

  1. 生成了新对象(实例)person。
  2. Foo函数里的this指向该实例对象person。
  3. 这个对象实例可以访问Foo.prototype上的属性。

MDN上帮我们总结的更清楚,当代码 new Foo(...) 执行时,会发生以下事情:

  1. 一个继承自 Foo.prototype 的新对象被创建。("继承"用得不是很准确,应该叫委托才好,具体参见you-dont-konw-js)
  2. 使用指定的参数调用构造函数 Foo ,并将 this 绑定到新创建的对象。new Foo 等同于 new Foo(),也就是没有指定参数列表,Foo 不带任何参数调用的情况。
  3. 由构造函数返回的对象就是 new 表达式的结果。如果构造函数没有显式返回一个对象,则使用步骤1创建的对象。(一般情况下,构造函数不返回值,但是用户可以选择主动返回对象,来覆盖正常的对象创建步骤)

注: 由同一个构造函数创建的实例各个独立且其原型相同,原型对象都等于 构造函数.prototype === 实例.__proto__

第一步

有了对new的认识后,咱们开始仿写。因为 new 是个关键字,没法像之前的call、bind那样子写方法覆盖。我们可以写个方法 copyNew()。

以上面例子为例,我们让 new Foo(xxx) 的效果等于 copyNew(Foo, xxx)就行。

来第一步代码:

// codes
function copyNew() {
  var obj = new Object();

  var constructor = [].shift.call(arguments); // 取出第一个构造函数参数。注意shift可以改变原数组。

  obj.__proto__ = constructor.prototype; // 这样obj就可以访问在构造函数的prototype上的属性

  // 根据apply经典继承来让函数的this指向实例
  constructor.apply(obj, arguments);

  return obj
}

have a test:

// example
function Foo(name, age) {
  this.name = name;
  this.age = age;
  this.sex = 'male';
}
Foo.prototype.brother = "宇智波鼬";

Foo.prototype.chat = function () {
  console.log('how are you? 鸣人');
}

var person = copyNew(Foo, '佐助', 21);

console.log(person.name);
console.log(person.sex);
console.log(person.brother);
person.chat();
// 佐助
// male
// 宇智波鼬
// how are you? 鸣人

第一步成功。

第二步

我们现在考虑下构造函数有返回值的情况。

1、假设构造函数的返回值是对象。

举个例子:

// 假设构造函数的返回值是对象
function Foo(name, age) {
  this.brother = '宇智波鼬';
  this.age = age
  return {
    habit: 'coding',
    name: name
  }
}

const person = new Foo('佐助', 21);

console.log(person.age);
console.log(person.brother);
console.log('**********华丽的分割线**********');
console.log(person.habit);
console.log(person.name);
// undefined
// undefined
//**********华丽的分割线**********
// coding
// 佐助

我们可以发现,构造函数Foo的person实例完全只能访问Foo返回的对象中的属性。

那构造函数的返回值不是对象呢? 我们试一个基本类型的值。

2、假设构造函数的返回值是基本类型的值

// 假设构造函数的返回值是一个基本类型的值
function Foo(name, age) {
  this.brother = '宇智波鼬';
  this.age = age
  return 'sadhu'
}

const person = new Foo('佐助', 21);

console.log(person.age);
console.log(person.brother);
console.log('**********华丽的分割线**********');
console.log(person.habit);
console.log(person.name);
// 21
// 宇智波鼬
//**********华丽的分割线**********
// undefined
// undefined

结果刚刚相反,相当于无返回值时的处理。

所以我们可以根据这两个例子的结果去完善最后的代码,根据这个思路:

若当构造函数返回值是对象时,则new调用后也返回该对象实例。若当构造函数的返回值是基本类型或者无返回值时,都当作无返回值情况处理,该返回什么就返回什么。

最终代码:

function copyNew() {
  var obj = new Object();
  var constructor = [].shift.call(arguments);
  obj.__proto__ = constructor.prototype;
  var result = constructor.apply(obj, arguments);
  return typeof result === 'object' ? result : obj;
}

参考:

  1. MDN
  2. JavaScript深入之new的模拟实现

宏观理解 React 原理

在不同领域帧有不同的含义,在视频与计算机动画领域中,我们看到的动画是由一张张静态图片组成,帧(frame)指的就是每一张静态的图片。
在人眼观看物体时,成像于视网膜,经由视神经传给人脑,感觉到物体的像,但当物体移去时,视神经对物体的印象不会立即消失,而要延续 0.1-0.4 秒的时间,人眼的这种性质被称为眼睛的视觉暂留

由于人眼的视觉暂留现象,所以当一张张静态图片快速消失出现时,我们会感觉到是动态的。
帧的度量叫帧率,用于测量显示帧数,测量单位为每秒显示帧数(frame per second, FPS)或赫兹。比如 60fps 代表每秒显示 60 帧。一般来说  FPS 用于描述视频、电子绘图或游戏每秒播放多少帧。
与 FPS 有个类似的概念叫做刷新率,单位为赫兹(Hz)。与 FPS 的区别是一般指屏幕每秒绘制新图像(帧)的次数。所以无论是 PC 显示器或是手机屏幕一定有一个刷新率的值。
负责渲染 UI 的应用就需要在刷新率的限制内按时提供每帧图像,如果提供得慢,跟不上刷新率,就会造成视觉上的卡顿。
举例说,浏览器就是一个有渲染 UI 功能的应用,如果此时硬件显示器是 60Hz 刷新率,1000ms / 60 = 16.6 ms,也就是浏览器需要在 16.6 ms 内完成输出一个画面(一帧),若未按时完成画面则叫做掉帧,帧率越低,人眼对掉帧的感觉越明显,越觉得卡顿。

// 利用 rAf 做实验验证浏览器没有自己的刷新率、最高匹配硬件刷新率的结论。 
let then = Date.now();
let count = 0;
const nextFrame = () => {
    requestAnimationFrame(() => {
        count++;
        if (count % 20 === 0) {
            const now = Date.now();
            const frame = ((now - then) / count).toFixed(2);  
            const fps =  (1000 / frame).toFixed(2);
            console.log('frame: ', frame, ' fps: ',fps);
        }
        nextFrame();
        
    })
}
nextFrame();

在浏览器输出每帧画面的过程中,会做以下事情:

可以看到,在一帧里,浏览器会依次处理用户输入事件、执行脚本、处理窗口变更、滚动、媒体查询、动画、执行 requestAnimationFrame 和 Intersection-Observer 回调、布局计算、绘制等。

设计理念

对于渲染 UI 应用而言,非快速响应就是掉帧。遇到设备性能不足或计算量大的任务,为了尽量避免掉帧,Web 框架能做的只能是避免每帧执行太多 JS 导致应用无法按时处理本帧的后续内容以及开启下一帧的工作。对此 React 给出的答卷是实现 时间分片(Time Slice) 技术。

把一个执行时间很长的长任务拆解为一个个执行时间很短的短任务,然后分别放在每一帧中执行的技术就是时间分片。

仅仅时间分片还不够,这只解决了掉帧问题,比如当前页面正在执行渲染十万行数据长列表这个长任务的一个个短任务,页面上还有个 input 输入框,实现时间分片后,浏览器能正常处理到每一帧中的 input events ,所以能及时收到用户输入。若无特殊策略,处理用户输入的响应会排队等待渲染十万行数据长列表的每个短任务执行完后再执行,但这通常是不符合用户体验的。
React 团队对于人机交互的研究成果表明,用户对不同操作的卡顿的感知程度不同。当用户在文本框输入内容时,即使从“键盘开始输入”到“文本框显示字符”之间只有轻微的延迟,也会使用户感觉到卡顿。但是当用户点击按钮加载数据时,即使从“点击按钮”到“显示数据”之间会经历数秒加载时间,用户也不会感觉到卡顿。
所以,React 又为每个更新任务划分了优先级,允许高优先级任务打断低优先级任务,高优先级任务执行完后接着执行低优先级任务。

架构

调度器、协调器、渲染器

上面讲述了对于 UI 非快速响应的用户体验问题,React 提出了时间分片与更新优先级的解决方案,具体来说,需要做三件事情:

  1. 为不同更新赋予不同优先级
  2. 调度不同更新以时间分片机制执行
  3. 如果更新正在进行(正在处理 Virtual DOM),有更高优先级的更新产生,则会中断当前更新,优先处理高优先级更新

要能做到以上 3 件事情,需要 React 底层实现:

  1. 用于调度优先级任务的调度算法
  2. 支持时间分片机制的调度器
  3. 支持处理可中断更新的协调器以及可中断的 Virtual Dom 实现。

由于 JS 跨平台的特性,理论上,React 处理 UI 的逻辑是跨平台通用的。各平台的使用区别应是拿到处理 UI 的结果后,调用各自宿主环境的操控真实 UI 的 API 将结果呈现在 UI 上。所以,React 也是为不同宿主环境写了对应的渲染器

举例说明

const Demo = () => {
	const [count, updateCount] = useState(0);

	return (
	<ul>
		<button onClick={() => updateCount(count + 1)}>乘以{count}</button>
		<li>{1 * count}</li>
		<li>{2 * count}</li>
		<li>{3 * count}</li>
	</ul>
	)
}

可中断的 Virtual Dom 实现

显卡的双缓冲机制

上述表明,UI 应用绘制的最终产物是一张图片,这张图片被发送给显卡后即可显示在屏幕上。显卡包含前缓冲区与后缓冲区。对于刷新率为 60Hz 的显示器,每秒会从前缓冲区读取 60 次图像,将其显示在显示器上。显卡的职责是:合成图像并写入后缓冲区。一旦后缓冲区被完整地写入图像,前后缓冲区就会互换,显示器再次从前缓冲区读取时就能读到一张完整的新图片,这就是显卡的双缓冲机制。

Fiber

Fiber 就是 React 里的 VDom 实现,不同的是,Fiber 除了描述 DOM 外,还包含了优先级更新机制相关信息。

Fiber 的工作原理类似于显卡的双缓冲机制。 当 React 有更新存在时,内存里有两棵 Fiber 树:current 树和 workInProgress 树。 current 树类比显卡的前缓冲区,对应真实 UI 显示,wip 树类比显卡的后缓冲区,所以的更新都会先在 wip 树里处理完成后,才提交给 renderer 渲染到 UI 上,最后切换 current 树与 wip 树。
所以,只要在本次更新生成的 wip 树没被提交给 renderer 之前,该 wip 树都是可以被覆盖或丢弃的,也就是本次更新被打断了。

架构实现

下篇会从源码讲解 React 是怎样实现这一套架构:剖析 React 内部运行机制

深入js之分析继承的多种方式

学习继承的重要性,不用多说。下面直接开始啦。

原型链继承

原型链继承首先得知道原型链吧。忘记了可以去看这篇文章复习下:深入js之原型与原型链

function Person(name) {
  this.name = name;
  this.body = ['head', 'arm', 'leg'];
}

Person.prototype.act = function() {
  console.log('run');
}

function Wife(hobby) {
  this.hobby = hobby;
  this.friend = [1, 2, 3];
}

Wife.prototype.job = function() {
  console.log('FE');
}

// 将父构造函数的实例赋给子构造函数的原型。
Wife.prototype = new Person(); // Wife.prototype.__proto__ === Person.prototype

const kk = new Wife('clothes');

这方式的缺点:

  1. 多个实例对引用类型的操作会被篡改

  2. 子类型的原型上的 constructor 属性被重写了

  3. 给子类型原型添加属性和方法必须在替换原型之后

  4. 创建子类型实例时无法向父类型的构造函数传参

多个实例对引用类型的操作会被篡改

因为Person构造函数的实例里有引用类型的值,并且Person构造函数的实例是Wife构造函数的实例的原型对象,所以Wife的实例的原型链上存在了引用类型的值,自然多个Wife实例对该引用类型的操作会被篡改。举个例子,在上例基础上:

const kk = new Wife('clothes');

const ww = new Wife('cook');

kk.body.push('eye');

console.log(ww.body);
// [ 'head', 'arm', 'leg', 'eye' ]

子类型的原型上的 constructor 属性被重写

我们知道每个原型对象本身都有一个 constructor 属性指向与之对应的构造函数。但是在原型链继承方式中,我们直接执行了这个操作:Wife.prototype = new Person(),这个操作直接把Person的实例传给Wife的原型,覆盖了原本Wife对应的原型对象。又因为Person里面没有定义constructor属性,所以其实例也不会有,即现在Wife的原型对象也没有constructor属性了。

此时如果调用实例.constructor的话,会其在原型链上找到这个属性,这个属性指向的是Person构造函数:

console.log(ww.constructor);
// [Function: Person]

给子类型原型添加属性和方法必须在替换原型之后

创建子类型实例时无法向父类型的构造函数传参

这张图就说明了。

借用构造函数继承(经典继承)

function Person(name) {
  this.name = name;
  this.body = ['head', 'arm', 'leg'];
}

Person.prototype.act = function() {
  console.log('run');
}

function Wife(hobby) {
  Person.call(this, 'Linda')
  this.hobby = hobby;
  this.friend = [1, 2, 3];
}

Wife.prototype.job = function() {
  console.log('FE');
}

const kk = new Wife('clothes');

可以看出,此时可以给父类传参,并且该方法不会重写子类的原型,故也不会损坏子类的原型方法。此外,由于每个实例都会将父类中的属性复制一份,所以也不会发生多个实例篡改引用类型的问题(因为父类的实例属性不在原型中了)。

缺点也很明显,就是父构造函数原型对象上的属性和方法不晓得跑到哪里去了,因为该继承方式只能继承父构造函数实例的属性和方法而不能继承父构造函数原型上的属性和方法。

组合继承

该继承方式吸收上两种继承方式的优点:借用构造函数实现对构造函数实例上的属性和方法的继承,借用原型链实现对构造函数原型上的属性和方法的继承。

function Person(name) {
  this.name = name;
  this.body = ['head', 'arm', 'leg'];
}

Person.prototype.act = function() {
  console.log('run');
}

function Wife(hobby) {
  this.hobby = hobby;
  this.friend = [1, 2, 3];

  Person.call(this, 'Linda'); // 第二次调用父构造函数
}

Wife.prototype = new Person(); // 第一次调用父构造函数

Wife.prototype.constructor = Wife; // 修正constructor指针

Wife.prototype.job = function () {
  console.log('FE');
}

const kk = new Wife('clothes'); 

缺点也很明显。调用了两次父构造函数,造成Wife实例里和原型里都存在父构造函数的属性name月body。根据原型链的规则,实例上的这两个属性会屏蔽原型链上的两个同名属性。

原型式继承

该方式通过借助原型基于已有对象创建新的对象。

首先创建一个名为 object 的函数,然后在里面中创建一个空的函数 F,并将该函数的 prototype 指向传入的对象,最后返回该函数的实例。本质来讲,object() 对传入的对象做了一次 浅拷贝

function object(obj) {
  function F() {};
  F.prototype = obj;
  return new F();
}

测试:

function object(obj) {
  function F() {};
  F.prototype = obj;
  return new F();
}

const kk = {
  name: 'Linda',
  friends: [1, 2, 3],
  job: function() {
    console.log('FE');
  }
}

const exp = object(kk);

原型式继承相当于浅拷贝,通过原型链来访问属性,所以会导致引用类型被多个实例篡改。

在上面例子中加上这段代码输出什么?:

const exp2 = object(kk);
exp.name = 'sadhu';
console.log(exp2.name); // Linda

exp.name = 'sadhu'实际是在exp自身添加了属性name而非修改了原型上的name值。

寄生式继承

创建一个仅用于封装继承过程的函数,该函数在内部以某种形式来做增强对象,最后返回对象。

function creatObj(o) {
  const clone = Object.create(o);

  clone.act = 'coding'
  clone.job = function() {
    console.log('FE');
  };

  return clone;
}

const kk = {
  name: 'Linda',
  friends: [1, 2, 3],
  job: function() {
    console.log('FE');
  }
}

const exp = creatObj(kk);

缺点:

  1. 每次创建对象都会创建一遍方法。
  2. 引用类型 会被多个实例篡改。

注:Object.create()API的仅针对第一个参数的polyfill就是 :

function object(obj) {
  function F() {};
  F.prototype = obj;
  return new F();
}

寄生组合式继承

上面我们谈到了 组合继承,它的缺点是会调用两次父类,因此父类的实例属性会在子类的实例和其原型上各自创建一份,这会导致实例属性屏蔽原型链上的同名属性。

好在我们有 寄生组合式继承,它本质上是通过 寄生式继承 来继承父类的原型,然后再将结果指定给子类的原型。这可以说是在 ES6 之前最好的继承方式了。

function creatObj(child, parent) {
  const prototype = Object.create(parent.prototype);
  prototype.constructor = child
  child.prototype = prototype;
}

举个例子:

// function object(o) {
//   function F() {};
//   F.prototype = o;
//   return new F();
// }

function creatObj(child, parent) {
  const prototype = Object.create(parent.prototype); // 或者const prototype = object(parent.prototype)
  prototype.constructor = child
  child.prototype = prototype;
}

function Person(name) {
  this.name = name;
  this.body = ['head', 'arm', 'leg'];
}

Person.prototype.act = function() {
  console.log('run');
}

function Wife(hobby) {
  this.hobby = hobby;
  this.friend = [1, 2, 3];

  Person.call(this, 'Linda');
}

// Wife.prototype = new Person(); 

// Wife.prototype.constructor = Wife; // 修正constructor指针
creatObj(Wife, Person);

Wife.prototype.job = function() {
  console.log('FE');
}

const kk = new Wife('clothes');

引用《JavaScript高级程序设计》中对寄生组合式继承的夸赞就是:

这种方式的高效率体现它只调用了一次 Parent 构造函数,并且因此避免了在 Parent.prototype 上面创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用 instanceof 和 isPrototypeOf。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。

其实也有个缺点:要在子构造函数的原型上添加属性和方法只能在实现寄生组合式继承之后。

本文完。

希望看到各位技术人对这篇文章有不同的有“证据”,符合逻辑的分析、看法~

文章会第一时间更新在GitHub,觉得写得还不错的,可以点个star支持下作者🍪


参考:

  1. JavaScript深入之继承的多种方式和优缺点
  2. JavaScript 七大继承全解析

Mobx 源码与设计**

Proxy

拦截方式

Mobx 暴露的拦截的 API 有多种,概括来说可以分为装饰器式和基于 observable 方法调用。

装饰器

对装饰器不太明白的同学,可以见我以往一篇文章:装饰器原理探究 ,通过分析转译后的 ES 代码得出装饰器的行为。

由于装饰器在 ES 里还处于提案中且各阶段的装饰器行为不一致,故 mobx 6.x 起就淘汰了装饰器的写法(也可以手动开启),本文的源码分析基于 mobx 5.x 版本(所述原理与 6.x 一模一样),此时装饰器基于 babel

{
  "plugins": [
    ["@babel/plugin-proposal-decorators", { "legacy": true }],
  ]
}

该配置下实现,使用历史遗留(stage 1)的装饰器中的语法和行为。

import { observable } from 'mobx';

class A {
  @observable a = 1;
}

此处 @observable 装饰器的行为其实就是在实例化前往 A 的原型上挂 getter setter。

{
            configurable: true,
            enumerable: enumerable,
            get: function() {
                initializeInstance(this)
                return this[prop]
            },
            set: function(value) {
                initializeInstance(this)
                this[prop] = value
            }
        }

在实例化时会执行 instance.a = 1 赋值操作,触发 setter ,走到 mobx 处理类实例的逻辑:

1. 往实例上挂 对象管理类(adm)
2. 递归包装 value , 并收集在 adm
3. 为实例上的 key (a) 挂 getter setter
{
            configurable: true,
            enumerable: true,
            get() {
                // 收集依赖
                return this[$mobx].read(propName)
            },
            set(v) {
                // 触发更新
                this[$mobx].write(propName, v)
            }
}

宏观来讲,此后访问装饰的属性就会走到 this[$mobx].read(propName) 收集副作用,当属性改变就走到 this[$mobx].write(propName, v) 执行副作用。

observable 命令式调用

命令式调用就是如下这种:

const xxx = observable(xxx) || observable.xx(xxx);

一定会有一个返回值,当我们操作返回值的时候,就会做收集 | 执行副作用的行为。

下面会挨个解析各个类型的拦截情况。

先说明个概念,在 Mobx里,所有需要被观察的 value ,除了数组、Set,都会被 ObservableValue 类包装(为了方便之后对其实例简称 OV ),做的工作就是:

(1)使用 enhancer 处理 value

(2)管理(1)中包装后的 value (读写、收集依赖等)

enhancer 有多种,若用户不作额外配置,Mobx 里默认对每个 value 使用 deepEnhancer 进行包装,其实就是递归对这个 value 做 observable 命令式调用 的操作。

primitive value

对于原始类型 value ,Mobx 里只支持使用 observable.box(val) 这个 API 进行拦截,其实内部就是返回了个 OV。

如果读写分别用 OV 暴露 get set API。

object

使用 observable.object(val) 进行拦截,内部做了三件事:

  1. 新建一个空对象 { }, 并给 { } 挂上 对象管理类(adm)
  2. Proxy 拦截 { },并把代理对象保存在 adm.proxy
  3. 遍历 val 的 keys:
    1. 递归包装 value , 并收集 OV 在 adm
    2. 在 { } 挂上每个 key 的 getter setter (同装饰器挂的 getter setter 一样)
  4. 返回代理对象

Proxy 的 handlers 有:

// 暂时只关注读写
{
    get(target: IIsObservableObject, name: PropertyKey) {
        // ... 忽略暂时无关代码
        const adm = getAdm(target)
        // 拿到 OV
        const observable = adm.values.get(name)
        if (observable instanceof Atom) {
          	// 此处等同于调用 adm.read(propName)
            const result = (observable as any).get() 
            // ...
            return result
        }
        // ... 
    },
    set(target: IIsObservableObject, name: PropertyKey, value: any) {
        if (!isPropertyKey(name)) return false
        set(target, name, value)
      	// 这个 set 方法针对对象最终执行如下
      	// // ...
        // if (isObservableObject(obj)) {
        //  const adm = ((obj as any) as IIsObservableObject)[$mobx]
        //  // 拿到 OV
        //  const existingObservable = adm.values.get(key)
        //  if (existingObservable) {
        //      adm.write(key, value)
        //  }
        //  // ...
      	// }
        // //...
        return true
    },
}

由上可知,此处的读取处理最终也是和装饰器方式修饰的对象属性的读写处理相同。

array

使用 observable.array(val) 进行拦截,内部做了五件事:

  1. 初始化 数组管理类 (adm) ,挂载在 [ ] 上,再把 [ ] 挂载在 adm.values
  2. Proxy 拦截 [ ],并把代理对象挂载在 adm.proxy
  3. 遍历 val,递归包装每个元素
  4. 更新 3 中一个个 OV 在 adm.values 里
  5. 返回代理对象

Handler 如下:

get(target, name) {
        if (name === $mobx) return target[$mobx]
        if (name === "length") return target[$mobx].getArrayLength()
        if (typeof name === "number") {
            return arrayExtensions.get.call(target, name)
        }
        if (typeof name === "string" && !isNaN(name as any)) {
          	
            return arrayExtensions.get.call(target, parseInt(name))
        }
        if (arrayExtensions.hasOwnProperty(name)) {
            // arrayExtensions 捕获数组方法
            return arrayExtensions[name]
        }
        return target[name]
    },
set(target, name, value): boolean {
        if (name === "length") {
            target[$mobx].setArrayLength(value)
        }
        if (typeof name === "number") {
            arrayExtensions.set.call(target, name, value)
        }
        if (typeof name === "symbol" || isNaN(name)) {
            target[name] = value
        } else {
            // numeric string
            arrayExtensions.set.call(target, parseInt(name), value)
        }
        return true
    },

以读取为例说明,在 arrayExtensions 里是这样的:

get(index: number): any | undefined {
            const adm: ObservableArrayAdministration = this[$mobx]
            if (adm) {
                if (index < adm.values.length) {
                    adm.atom.reportObserved()
                    return adm.dehanceValue(adm.values[index])
                }
                // ...
            }
            return undefined
        },

        set(index: number, newValue: any) {
            const adm: ObservableArrayAdministration = this[$mobx]
            const values = adm.values
            if (index < values.length) {
                // update at index in range
                checkIfStateModificationsAreAllowed(adm.atom)
                const oldValue = values[index]
                // ...
                // 新的被 enhancer 包装过的 value 
                newValue = adm.enhancer(newValue, oldValue)
                const changed = newValue !== oldValue
                if (changed) {
                    values[index] = newValue // 改变 adm 里收集的旧 value
                    // 通知更新
                    adm.notifyArrayChildUpdate(index, newValue, oldValue)
                }
            } 
          	// ...
        }

之前说过,数组不会被 ObservableValue 包装,因为在其管理类里面,已经实现了 ObservableValue 的工作,也就是:

(1)使用 enhancer 处理 value

(2)管理(1)中包装后的 value (读写、收集依赖等)

其实, arrayExtensions 里的操作,核心也是收集依赖和触发更新。

map

使用 observable.map(val) 进行拦截,内部做了三件事:

  1. 初始化 map 管理类
  2. 遍历 val ,挨个 ObservableValue 包装 value,收集在管理类的 this._data
  3. 返回 map 管理类实例

返回的实例,有 Map 的 API 方法,以读写为例:

get(key: K): V | undefined {
  			// this._data.get(key)!.get() 等同于调用对象 adm 的 adm.read(propName)
  			// 收集依赖
        if (this.has(key)) return this.dehanceValue(this._data.get(key)!.get())
        return this.dehanceValue(undefined)
    }

set(key: K, value: V) {
        const hasKey = this._has(key)
        // ...
        if (hasKey) {
            this._updateValue(key, value)
        } else {
            this._addValue(key, value)
        }
        return this
    }

_updateValue(key: K, newValue: V | undefined) {
  			// 拿到 ObservableV
        const observable = this._data.get(key)!
        // enhancer 新 value,然后对比旧 value 是否相等
        newValue = (observable as any).prepareNewValue(newValue) as V
        if (newValue !== globalState.UNCHANGED) {
            // ...
            // 更新并通知更新
            observable.setNewValue(newValue as V)
            // ...
        }
    }

set

使用 observable.set(val) 进行拦截,内部做了三件事:

  1. 初始化 set 管理类
  2. 遍历 val ,挨个 enhancer 包装 value,收集在管理类的 this._data
  3. 返回 set 管理类实例

和 Map 一样,返回的 set 管理类也有 Set 的相关 API,以获取所有 values 和 add 为例:

add(value: T) {
        // ... 
        if (!this.has(value)) {
            transaction(() => {
                // 往本地缓存的 _data 里新增 enhancer 后的 value
                this._data.add(this.enhancer(value, undefined))
                // 通知依赖更新
                this._atom.reportChanged()
            })
            // ...
        }

        return this
    }

keys(): IterableIterator<T> {
        return this.values()
    }

values(): IterableIterator<T> {
        // 通知收集依赖
        this._atom.reportObserved()
        const self = this
        let nextIndex = 0
        const observableValues = Array.from(this._data.values())
        // 在 for of 中挨个读 _data 的值
        return makeIterable<T>({
            next() {
                return nextIndex < observableValues.length
                    ? { value: self.dehanceValue(observableValues[nextIndex++]), done: false }
                    : { done: true }
            }
        } as any)
    }

由上述可知,其实就是对于不同的数据结构,处理的核心的就是拦截被观察者 getter setter 或相关 API,达到在读取时收集依赖,变化时通知依赖更新的目的。

Derivation

Mobx 里有派生的概念,类似于观察者。在 Derivation 内使用了 Proxy 的产物,每当产物有变化时则派生(通知)了 Derivation(观察者)。

一些概念:

transaction

引用了数据库事务的概念,Mobx 中的事务用于批量处理 Reaction(Derivation 管理者) 的执行,避免不必要的重新计算。Mobx 的事务实现比较简单,使用 startBatch 和 endBatch 来开始和结束一个事务:

function startBatch() {
  // 通过一个全局的变量 inBatch 标识事务嵌套的层级
  globalState.inBatch++
}

function endBatch() {
  // 最外层事务结束时,才开始执行重新计算
  if (--globalState.inBatch === 0) {
    // 执行所有 Reaction
    runReactions()
    // 处理不再被观察的 ObservableV
    const list = globalState.pendingUnobservations
    for (let i = 0; i < list.length; i++) {
      const observable = list[i]
      observable.isPendingUnobservation = false
      if (observable.observers.length === 0) {
          observable.onBecomeUnobserved()
      }
    }
    globalState.pendingUnobservations = []
  }
}

例如,一个 Action 开始和结束时同时伴随着事务的启动和结束,确保 Action 中(可能多次)对状态的修改只触发一次 Reaction 的重新执行。

function startAction() {
  // ...
  startBatch()
  // ...
}
function endAction() {
  // ...
  endBatch()
  // ...
}
Reaction

Reaction 就是 Derivation 的管理者,实现了 Derivation 的接口:

interface IDerivation extends IDepTreeNode {
  // 依赖数组
  observing: IObservable[]
  // 每次执行收集到的新依赖数组
  newObserving: null | IObservable[]
  // 依赖的状态
  dependenciesState: IDerivationState
  // 每次执行都会有一个 uuid,配合 Observable 的 lastAccessedBy 属性做简单的性能优化
  runId: number
  // 执行时新收集的未绑定依赖数量
  unboundDepsCount: number
  // 依赖过期时执行
  onBecomeStale()
}
Derivation 状态机

Derivation 通过 dependenciesState 属性标记依赖的四种状态:

  1. NOT_TRACKING:在执行之前,或事务之外,或未被观察(计算值)时,所处的状态。此时 Derivation 没有任何关于依赖树的信息。枚举值-1
  2. UP_TO_DATE:表示所有依赖都是最新的,这种状态下不会重新计算。枚举值0
  3. POSSIBLY_STALE:计算值才有的状态,表示深依赖发生了变化,但不能确定浅依赖是否变化,在重新计算之前会检查。枚举值1
  4. STALE:过期状态,即浅依赖发生了变化,Derivation 需要重新计算。枚举值2

任何状态都趋于 UP_TO_DATE。

------------------------- 2 ------------------------- STALE

-------------↓--- 1 ------------------ POSSIBLY_STALE
↓ ↓
------- 0 -------- UP_TO_DATE

-1--- NOT_TRACKING

状态机的规律是:

  1. 初始都是 NOT_TRACKING,绑定起依赖和派生关系后集体变为 U_T_D。
    解绑则回退为 NOT_TRACKING。

  2. 某收集的依赖发生变化时,其自身依赖状态和 Derivation (onBecomeStale后)都变为 STALE。
    在 Derivation 重新处理后,其自身和收集的依赖都变为 U_T_D。

  3. 计算属性计算后(含第一次),自身派生状态、收集的依赖状态都变为 U_T_D。(符合 2 第二句 Derivation 重新处理后,其自身和收集的依赖都变为 U_T_D)在第一次被绑定后,符合 1。

    若计算属性收集的某依赖 A 状态发生变化时,将 A 状态和 计算属性派生状态(onBecomeStale后) 为 STALE(符合 2 第一句),并且把 计算属性依赖状态、计算属性派生的 Derivation 置为 P_STALE(区别)。在计算属性重新计算后自身派生状态、收集的所有依赖状态变更为 U_T_D(符合 2 第二句),若计算结果无变更,把计算属性依赖状态、计算属性派生的 Derivation 变回 U_T_D 。若有变更,则把 计算属性派生的 Derivation 变为 STALE,接着重新处理 计算属性派生的 Derivation,把其和其收集的依赖(含计算属性作为依赖)状态 变为 U_P_D。

下面以 AutoRun、Computed Value 、React Render 为例分析 Derivation 的源码。

AutoRun

流程

常规用法是:

autorun(cb)

首先,会为 AutoRun 这个 Derivation 初始化一个 Reaction 用于管理:

function autorun(
    view: (r: IReactionPublic) => any, // cb
    opts: IAutorunOptions = EMPTY_OBJECT // 忽略
): IReactionDisposer {

    const name: string = (opts && opts.name) || (view as any).name || "Autorun@" + getNextId()
    const runSync = !opts.scheduler && !opts.delay
    let reaction: Reaction

    if (runSync) {
        // normal autorun
        // 用一个 reaction 来管理该 autorun
        reaction = new Reaction(
            name,
            function(this: Reaction) {
                this.track(reactionRunner)
            },
            opts.onError,
            opts.requiresObservable
        )
    }
    function reactionRunner() {
        view(reaction)
    }
		// 将该 reaction 列入计划表
    reaction.schedule()
    // 返回销毁方法
    return reaction.getDisposer()
}

计划表维护了一个全局的数组,里面存的 Reactions 就是该 batch(批次) 中需要执行的 Reaction。

schedule() {
        // Reaction 已经在重新计算的计划表内,直接返回
        if (!this._isScheduled) {
            this._isScheduled = true
            // 该 Reaction 加入全局的待重新计算数组中
            globalState.pendingReactions.push(this)
            runReactions()
        }
    }
export function runReactions() {
    // 惰性更新,若此时处于事务中,inBatch > 0,会直接返回
    if (globalState.inBatch > 0 || globalState.isRunningReactions) return
    reactionScheduler(runReactionsHelper)
}
function runReactionsHelper() {
    globalState.isRunningReactions = true
    // 取出当前批次收集的所有 Reaction
    const allReactions = globalState.pendingReactions
    let iterations = 0

    // 当执行 Reaction 时,可能触发新的 Reaction(Reaction 内允许设置 Observable的值),加入到 pendingReactions 中
    while (allReactions.length > 0) {
        // 设定 Reaction 计算的最大迭代次数,避免造成死循环
        if (++iterations === MAX_REACTION_ITERATIONS) {
            // ... error
            allReactions.splice(0) // clear reactions
        }
        let remainingReactions = allReactions.splice(0)
        for (let i = 0, l = remainingReactions.length; i < l; i++)
            remainingReactions[i].runReaction()
    }
    globalState.isRunningReactions = false
}

接下来就是执行 Reaction 的逻辑了,主要目的是运行 cb ,收集用到的 OV。

runReaction() {
        if (!this.isDisposed) {
            // 开启一个事务处理,因为运行 cb 的过程中可能会再加 Reaction 到计划表(比如依赖更新)
            startBatch()
            this._isScheduled = false
            // 判断 Reaction 收集的依赖状态
            // 如状态机所示,只有在 NO_TRACKING | STALE | 判断 COMPUTED 值变化时才会执行 Reaction 
            if (shouldCompute(this)) {
                this._isTrackPending = true

                try {
                  	// 处理 cb 
                    this.onInvalidate()
                    // ...
                } catch (e) {
                    this.reportExceptionInDerivation(e)
                }
            }
            endBatch()
        }
    }

this.onInvalidate 这里就开始处理 cb 了,核心逻辑是:

function trackDerivedFunction<T>(derivation: IDerivation, f: () => T, context: any) {
    // ...
    // 把 Reaction 和之前收集的被观察者状态都置为 UP_TO_DATE
    changeDependenciesStateTo0(derivation)
    derivation.newObserving = new Array(derivation.observing.length + 100)
    // 记录新的依赖的数量
    derivation.unboundDepsCount = 0
    // 每次执行都分配一个 uid
    derivation.runId = ++globalState.runId
    // 当前 Derivation 记录到全局的 trackingDerivation 中,这样被观察的 Observable 在其 reportObserved 方法中就能获取到该 Derivation
    const prevTracking = globalState.trackingDerivation
    globalState.trackingDerivation = derivation
    let result
    if (globalState.disableErrorBoundaries === true) {
        // debug 环境不 catch 异常,若出错堆栈清晰
        result = f.call(context)
    } else {
        try {
            // 执行响应函数 cb ,收集使用到的所有依赖,加入 newObserving 数组中
            result = f.call(context)
        } catch (e) {
            result = new CaughtException(e)
        }
    }
    globalState.trackingDerivation = prevTracking
    // 比较新旧依赖,更新依赖
    bindDependencies(derivation)
    // 如果配置了 requiresObservable 但是 cb 内没引用 OV 的话,报警告
    warnAboutDerivationWithoutDependencies(derivation)
		// ...
}
getter 里干了啥?(追踪依赖)

执行 cb 的时候,读取到 observable 的值,以装饰器修饰方式为例,会走到:

read(key: PropertyKey) {
        return this.values.get(key)!.get()
}

this.values.get(key) 拿到的就是 OV,OV 的 get:

public get(): T {
        this.reportObserved()
        return this.dehanceValue(this.value)
}

function reportObserved(observable: IObservable): boolean {
    // ...
    const derivation = globalState.trackingDerivation
    if (derivation !== null) {
        // 避免重复收集 OV 
        if (derivation.runId !== observable.lastAccessedBy) {
            observable.lastAccessedBy = derivation.runId
            derivation.newObserving![derivation.unboundDepsCount++] = observable
            if (!observable.isBeingObserved) {
                observable.isBeingObserved = true
                observable.onBecomeObserved() // 触发监听钩子
            }
        }
        return true
    } else if (observable.observers.size === 0 && globalState.inBatch > 0) {
        // 如果 OV 没有 derivation 观察了,准备清除 Observable 
        queueForUnobservation(observable)
    }

    return false
}

其实就是把 OV 收集在 Reaction 的 newObserving 上,至此追踪依赖就结束了。

处理依赖

接着就是处理收集到的依赖:

  1. 替换 Derivation 的依赖数组为新收集的依赖
  2. 找出新旧依赖数组不相交的元素,解绑旧依赖数组中不相交的 OV 与该 Derivation 的关系(OV 不再收集 Derivation),绑定新依赖数组中不相交的 OV 与该 Derivation 的关系
function bindDependencies(derivation: IDerivation) {
    // invariant(derivation.dependenciesState !== IDerivationState.NOT_TRACKING, "INTERNAL ERROR bindDependencies expects derivation.dependenciesState !== -1");
    const prevObserving = derivation.observing
    const observing = (derivation.observing = derivation.newObserving!)
    // 记录更新依赖过程中,新观察的 Derivation 的最新状态
    let lowestNewObservingDerivationState = IDerivationState.UP_TO_DATE

    // Go through all new observables and check diffValue: (this list can contain duplicates):
    //   0: first occurrence, change to 1 and keep it
    //   1: extra occurrence, drop it
    // 遍历新的 observing 数组,使用 diffValue 这个属性来辅助 diff 过程:
    // 所有 Observable 的 diffValue 初值都是0(要么刚被创建,继承自 BaseAtom 的初值0;
    // 要么经过上次的 bindDependencies 后,置为了0)
    // 如果 diffValue 为0,保留该 Observable,并将 diffValue 置为1
    // 如果 diffValue 为1,说明是重复的依赖,无视掉
    let i0 = 0,
        l = derivation.unboundDepsCount // 新收集的 ObservableValue 数量
    for (let i = 0; i < l; i++) {
        const dep = observing[i]
        if (dep.diffValue === 0) {
            // 这次此次 Reaction 最新收集的依赖
            dep.diffValue = 1
            // i0 不等于 i,即前面有重复的 dep 被无视,依次往前移覆盖
            if (i0 !== i) observing[i0] = dep
            i0++
        }

        // Upcast is 'safe' here, because if dep is IObservable, `dependenciesState` will be undefined,
        // not hitting the condition
        if (((dep as any) as IDerivation).dependenciesState > lowestNewObservingDerivationState) {
            lowestNewObservingDerivationState = ((dep as any) as IDerivation).dependenciesState
        }
    }
    observing.length = i0 // 只保留最新一次追踪 Reaction 收集的依赖

    derivation.newObserving = null // newObserving shouldn't be needed outside tracking (statement moved down to work around FF bug, see #614)

    // Go through all old observables and check diffValue: (it is unique after last bindDependencies)
    //   0: it's not in new observables, unobserve it
    //   1: it keeps being observed, don't want to notify it. change to 0
    // 遍历 prevObserving 数组,检查 diffValue:(经过上一次的 bindDependencies  后,该数组中不会有重复)
    // 如果为 0,说明没有在 newObserving 中出现,调用 removeObserver 将 dep 和 derivation 间的联系移除
    // 如果为 1,依然被观察,将 diffValue 置为0(在下面的循环有用处)
    l = prevObserving.length
    while (l--) {
        const dep = prevObserving[l]
        if (dep.diffValue === 0) {
            removeObserver(dep, derivation)
        }
        dep.diffValue = 0
    }

    // Go through all new observables and check diffValue: (now it should be unique)
    //   0: it was set to 0 in last loop. don't need to do anything.
    //   1: it wasn't observed, let's observe it. set back to 0
    // 再次遍历新的 observing 数组,检查 diffValue
    // 如果为0,说明是在上面的循环中置为了0,即是本来就被观察的依赖,什么都不做
    // 如果为1,说明是新增的依赖,调用 addObserver 新增依赖,并将 diffValue 置为0,为下一次 bindDependencies 做准备
    while (i0--) {
        const dep = observing[i0]
        if (dep.diffValue === 1) {
            dep.diffValue = 0
            addObserver(dep, derivation)
        }
    }

    // Some new observed derivations may become stale during this derivation computation
    // so they have had no chance to propagate staleness (#916)
    // 某些新观察的 Derivation 可能在依赖更新过程中过期
    // 避免这些 Derivation 没有机会传播过期的信息(#916)
    if (lowestNewObservingDerivationState !== IDerivationState.UP_TO_DATE) {
        derivation.dependenciesState = lowestNewObservingDerivationState
        derivation.onBecomeStale()
    }
}

上面用了 diffValue 标志位,降低朴素算法的时间复杂度为线性,给个例子吧:

const a = {};
const b = {};
const c = {};

const prev = [a, b];
const curr = [b, c];

// 找出不相交的 a, c 并做一些处理你会怎么做?  

// 朴素算法的处理就是 O(n^2)
prev.forEach((p, ip) => {
	curr.forEach((c, ic) => {
    // includes 时间复杂度为 O(n),假设用户用 set,has 是常数级的,暂且视此处也为常数级
    if (p !== c && prev.includes(c)) {
      // 解绑
    } 
    
    if (p !== c && !prev.includes(c)) {
      // 绑定
    } 
  })
})

如果加个 diffValue 作为标志的话,算法就为:

const a = {d: 0};
const b = {d: 0};
const c = {d: 0};

const prev = [a, b];
const curr = [b, c];

curr.forEach(c => c.d = 1);
prev.forEach(p => {
	if (p.d === 0) {
			// 解绑
  }
  p.d = 0;
})
curr.forEach(c => {
  if (c.d === 1) {
    // 绑定
  }
  c.d = 0;
})

至此,依赖关系处理完了,该 Derivation 上收集了使用的 OV,每个 OV 也收集了派生的 Derivation。并且把该 Derivation、之前收集的依赖的状态置为了 UP_TO_DATE。

derivation.dependenciesState = IDerivationState.UP_TO_DATE
OV.lowestObserverState = IDerivationState.UP_TO_DATE

新绑定的依赖状态为 NOT_TRACKING | UP_TO_DATE。

setter 里干了啥?

同样以装饰器修饰的属性为例:

write(key: PropertyKey, newValue) {
        const instance = this.target
        // 拿到 OV
        const observable = this.values.get(key)
       	// 处理计算值情况
        if (observable instanceof ComputedValue) {
            observable.set(newValue)
            return
        }
        // enhance 新值,Object.is 对比新旧值
        newValue = (observable as any).prepareNewValue(newValue)

        if (newValue !== globalState.UNCHANGED) {
          	// 值变化
            // ...
            (observable as ObservableValue<any>).setNewValue(newValue)
            // ...
        }
    }

setNewValue(newValue: T) {
        const oldValue = this.value
        this.value = newValue
        this.reportChanged()
        // ...
    }

reportChanged() {
        startBatch()
  			// 通知变化
        propagateChanged(this)
        endBatch()
    }

通知变化其实做了三件事情:

  1. 把 OV 的状态变为 STALE
  2. 遍历 OV 绑定的所有 Derivation,并处理
  3. 处理完一个 Derivation 则变更其状态为 STALE
export function propagateChanged(observable: IObservable) {
    // invariantLOS(observable, "changed start");
    if (observable.lowestObserverState === IDerivationState.STALE) return
    observable.lowestObserverState = IDerivationState.STALE

    // Ideally we use for..of here, but the downcompiled version is really slow...
    // 如果被解除 observableValue 和 Observer 的绑定关系,这里就不会遍历到。
    observable.observers.forEach(d => {
        if (d.dependenciesState === IDerivationState.UP_TO_DATE) {
            // ...
          	// 遍历 OV 绑定的所有 Derivation,并处理
            d.onBecomeStale()
        }
        d.dependenciesState = IDerivationState.STALE
    })
    // invariantLOS(observable, "changed end");
}

d.onBecomeStale() 干了啥呢?

其实就是再把该 Derivation 加入计划表,排期执行 Reaction,重复我们上面的流程。

onBecomeStale() {
        this.schedule()
    }
Reaction 流程概览

Computed Value

CV 是比较特殊的存在,即作为依赖,也作为派生。它是用它的副作用里的依赖,是它内部依赖的派生。

流程

在 Mobx 里也是用一个类 ComputedValue 来管理:

class ComputedValue {
  dependenciesState = IDerivationState.NOT_TRACKING // 作为派生的初始状态
  lowestObserverState = IDerivationState.UP_TO_DATE // 作为依赖的初始状态
	observing: IObservable[] = [] // CV 作为派生,收集的所有依赖
  newObserving = null // 每 batch 执行中新收集的依赖
  observers = new Set<IDerivation>() // CV 作为依赖,收集的所有派生
  // ...
  constructor(options: IComputedValueOptions<T>) {
   			// 检错机制,参数必须含 get 
        invariant(options.get, "missing option for computed: get")
    		// getter 回调作为内部依赖的派生
        this.derivation = options.get!
        this.name = options.name || "ComputedValue@" + getNextId()
        // 处理 setter
    		// ...
    		// 对于新旧计算结果的对比方法,默认 Object.is
        this.equals =
            options.equals ||
            ((options as any).compareStructural || (options as any).struct
                ? comparer.structural
                : comparer.default)
    		// getter 回调计算的上下文
        this.scope = options.context
				// 是否必须要求在副作用内使用计算属性
        this.requiresReaction = !!options.requiresReaction
    		// 是否一直强制绑定计算属性以及内部依赖。 (默认当计算属性没被用时,会同步解绑计算属性与其内部依赖)
        this.keepAlive = !!options.keepAlive
    }
}

每次当计算属性被访问时,会触发内部 get 方法,主要做两件事:

  1. 通知被观察
  2. 评估是否需要计算,若需要,则处理一些状态改变。
public get(): T {
        if (this.isComputing) fail(`Cycle detected in computation ${this.name}: ${this.derivation}`)
        if (globalState.inBatch === 0 && this.observers.size === 0 && !this.keepAlive) {
            // 在非副作用里访问,简单计算出返回值
            if (shouldCompute(this)) {
                this.warnAboutUntrackedRead()
                startBatch() // See perf test 'computed memoization'
                this.value = this.computeValue(false)
                endBatch()
            }
        } else {
          	// 在副作用里访问
            // 通知被观察,加入 Reaction.newObserving,之后会建立起计算属性与其派生的绑定关系
            reportObserved(this)
            // 评估作为 Derivation 是否需要计算
            // 若需要,重新计算完后,自身作为 D 的状态变为 U_T_D 。依赖状态变更为 U_T_D
            // 若值有改变,则改变自身作为 OV 的状态为 STALE,收集的观察者(第一次读取时没有)的状为 STALE 
            if (shouldCompute(this)) if (this.trackAndCompute()) propagateChangeConfirmed(this)
        }
        const result = this.value!

        if (isCaughtException(result)) throw result.cause
        return result
    }
评估计算

第一次访问肯定需要计算的,我们来看下评估计算的方法:

export function shouldCompute(derivation: IDerivation): boolean {
    switch (derivation.dependenciesState) {
        case IDerivationState.UP_TO_DATE:
            return false
        case IDerivationState.NOT_TRACKING: // 第一次访问时
        case IDerivationState.STALE:
            return true
        case IDerivationState.POSSIBLY_STALE: {
            // 暂时跳过
        }
    }
}

在知道允许计算后,就开始计算和追踪依赖了

private trackAndCompute(): boolean {
        // ...
        const oldValue = this.value
        // 有没有解除计算属性与其内部依赖的绑定关系,第一次肯定是没有绑定关系的
        const wasSuspended =
            /* see #1208 */ this.dependenciesState === IDerivationState.NOT_TRACKING
        // 新计算的值
        const newValue = this.computeValue(true)
        const changed =
            wasSuspended ||
            isCaughtException(oldValue) ||
            isCaughtException(newValue) ||
            !this.equals(oldValue, newValue)
        if (changed) {
            // 若有改变则赋新值
            this.value = newValue
        }
        return changed
    }

computeValue(track: boolean) {
        this.isComputing = true
        globalState.computationDepth++
        let res: T | CaughtException
        if (track) {
            // 不仅计算、也追踪内部依赖
            res = trackDerivedFunction(this, this.derivation, this.scope)
        } else {
          	// 简单重新计算
            if (globalState.disableErrorBoundaries === true) {
                res = this.derivation.call(this.scope)
            } else {
                try {
                    res = this.derivation.call(this.scope)
                } catch (e) {
                    res = new CaughtException(e)
                }
            }
        }
        globalState.computationDepth--
        this.isComputing = false
        return res
    }

trackDerivedFunction 很熟悉了,在讲解 AutoRun 时分析过了,主要就是干了三件事,其实就是计算和追踪依赖:

  1. 因为派生即将执行,所以改变派生与依赖的状态为 U_T_D
  2. 执行派生
  3. 建立派生与依赖的绑定关系

如果结果有改变的话,就执行 propagateChangeConfirmed(this),也就是改变 CV 作为依赖、以及其派生的状态为 STALE 了。

export function propagateChangeConfirmed(observable: IObservable) {
    // invariantLOS(observable, "confirmed start");
    // 让 computedValue 作为 OV ,改变自身状态与其收集的 Derivation 都为不稳定
    if (observable.lowestObserverState === IDerivationState.STALE) return
    observable.lowestObserverState = IDerivationState.STALE
		// 第一次访问计算属性时,还未建立起派生与计算属性的绑定关系,所以 observers 为空
  	// 之后访问的情况下,就会把派生的状态由 P_S 转为 STALE 了
    observable.observers.forEach(d => {
        if (d.dependenciesState === IDerivationState.POSSIBLY_STALE)
            d.dependenciesState = IDerivationState.STALE
        else if (
            d.dependenciesState === IDerivationState.UP_TO_DATE // this happens during computing of `d`, just keep lowestObserverState up to date.
        ) // 当派生已经开始重新处理时会遇到这个情况,此时不需要改变计算属性作为 OV 的状态和派生的状态了,因为派生已经重新处理了,并且也会拿到最新的计算值,此时直接把计算属性作为 OV 的状态设为 U_T_D 就好
          // 比如,计算属性的派生是与依赖 A 与计算属性绑定的
          // 某个 action 里面先改变了计算属性的深依赖值,再改变依赖 A 的值
					// 此时派生的状态会先变 P_S ,再变为 STALE,
          // 在一轮 batch 结束后,重新处理派生 Reaction,会直接重新计算计算属性的值,走到这个判断条件内,不需要再管派生应不应该重新处理了,人家已经由依赖 A 的变化确定要处理了。
            observable.lowestObserverState = IDerivationState.UP_TO_DATE
    })
    // invariantLOS(observable, "confirmed end");
}

之后在计算属性的派生接着处理,就会把计算属性作为依赖的状态和派生自己的状态变为 U_T_D,等着下一次依赖改变再次处理了。

计算属性的派生内其他依赖改变

这种情况会再次读取计算属性的值,但由于 shouldCompute 会评估计算属性的派生状态为 U_T_D,也就是其深依赖没有改变,所以会直接取上一次计算的结果来使用,不会再有其他任何处理。

计算属性的依赖改变

这种情况下就会按依赖变化的正常流程走,AutoRun 里讲过,触发深依赖的 setter,改变深依赖和派生(计算属性)的状态为 STALE,然后执行派生的 onBecomeStale() 方法。

onBecomeStale() 方法对于 Reaction 而言就是加入计划表,等待 batch 结束统一再次处理一遍 Reaction。对于计算属性而言稍微有点变化:

  1. 会改变计算属性作为 OV 的状态为 P_S,改变计算属性的派生的状态为 P_S
  2. 把计算属性的派生列入计划表
onBecomeStale() {
        propagateMaybeChanged(this)
    }

export function propagateMaybeChanged(observable: IObservable) {
    // invariantLOS(observable, "maybe start");
    if (observable.lowestObserverState !== IDerivationState.UP_TO_DATE) return
    observable.lowestObserverState = IDerivationState.POSSIBLY_STALE

    observable.observers.forEach(d => {
        if (d.dependenciesState === IDerivationState.UP_TO_DATE) {
            d.dependenciesState = IDerivationState.POSSIBLY_STALE
            if (d.isTracing !== TraceMode.NONE) {
                logTraceInfo(d, observable)
            }
          	// 将 Reaction 加入计划表,等待重新处理
            d.onBecomeStale()
        }
    })
    // invariantLOS(observable, "maybe end");
}

然后等深依赖的 batch 结束,就会在计划表取出 Reaction 做处理,回到 Autorun 里的逻辑:

runReaction() {
        if (!this.isDisposed) {
            // 开启一个事务处理,因为运行 cb 的过程中可能会再加 Reaction 到计划表(比如依赖更新)
            startBatch()
            this._isScheduled = false
            // 判断 Reaction 收集的依赖状态
            // 如状态机所示,只有在 NO_TRACKING | STALE | 判断 COMPUTED 值变化时才会执行 Reaction 
            if (shouldCompute(this)) {
                this._isTrackPending = true

                try {
                  	// 处理 cb 
                    this.onInvalidate()
                    // ...
                } catch (e) {
                    this.reportExceptionInDerivation(e)
                }
            }
            endBatch()
        }
    }
评估计算

此时派生的状态就是 P_S,评估计算时就会走到下面的逻辑:

  1. 找到派生依赖的计算属性,并重新计算,改变计算属性深依赖和自身作为派生的状态为 U_T_D
  2. 若重新计算值有变化,则会改变计算属性作为 OV 的状态和计算属性派生的状态为 STALE,接着处理派生,让派生回调重新执行,重新建立依赖绑定关系。
  3. 若重新计算值没变化,则直接返回旧值,改变派生和计算属性作为 OV 的状态为 U_T_D,阻止派生继续处理。
export function shouldCompute(derivation: IDerivation): boolean {
    switch (derivation.dependenciesState) {
        case IDerivationState.UP_TO_DATE:
            return false
        case IDerivationState.NOT_TRACKING:
        case IDerivationState.STALE:
            return true
        case IDerivationState.POSSIBLY_STALE: {
            // state propagation can occur outside of action/reactive context #2195
            const prevAllowStateReads = allowStateReadsStart(true)
            // 此处对 CV 的 get 不需要 reportObserved (untrackedStart 的作用),之后会再执行进行收集
            // 这里的主要目的是:判断重新计算的值有没有改变,然后根据结果做一些状态变更
            const prevUntracked = untrackedStart() // no need for those computeds to be reported, they will be picked up in trackDerivedFunction.
            const obs = derivation.observing, // 拿到所有 OV
                l = obs.length
            for (let i = 0; i < l; i++) {
                const obj = obs[i]
                // 找到 CV 的 OV
                if (isComputedValue(obj)) {
                    if (globalState.disableErrorBoundaries) {
                        obj.get()
                    } else {
                        try {
                          	// 再次调用 get 重新计算,具体逻辑上面分析过
                            obj.get()
                        } catch (e) {
                            // we are not interested in the value *or* exception at this moment, but if there is one, notify all
                            // 如果 CV getter 执行异常,那就默认让副作用继续执行一次
                            untrackedEnd(prevUntracked)
                            allowStateReadsEnd(prevAllowStateReads)
                            return true
                        }
                    }
                    // if ComputedValue `obj` actually changed it will be computed and propagated to its observers.
                    // and `derivation` is an observer of `obj`
                    // invariantShouldCompute(derivation)
                  	// 若重新计算有变化了,其派生的状态会变成 STALE
                    if ((derivation.dependenciesState as any) === IDerivationState.STALE) {
                        untrackedEnd(prevUntracked)
                        allowStateReadsEnd(prevAllowStateReads)
                        // 允许派生运行
                        return true
                    }
                }
            }
          	// 如果重新计算值没有变化,则重置派生与计算属性作为依赖的状态为 U_T_D
            changeDependenciesStateTo0(derivation)
            untrackedEnd(prevUntracked)
            allowStateReadsEnd(prevAllowStateReads)
            // 不允许派生继续运行
            return false
        }
    }
}

以上,就达到了计算属性依赖无变化时直接应用旧计算值(避免多余计算)、计算属性依赖变化且重新计算值变化时才会重新处理副作用(避免无效副作用)的目的。

React render

类组件

我们可以用 @observer 去装饰一个组件:

import { observer } from 'mobx-react';

@observer
class AComponent {
  render() {
    return ...;
  };
}

实际 observer 做的核心工作就把 render 函数作为派生(用一个派生包住),然后每次 track 重新执行 render 的行为来收集依赖,当依赖改变的时候,就触发 React.Component.prototype.forceUpdate() 去强制重新执行 render 收集依赖、更新视图。

N.B. observer 装饰后,React 的一些 lifecycle 钩子无法触发,所以其实内部还做了一些伪造钩子的操作比如 shouldUpdate、willUnMount 以及一些优化和 fix bug 的操作,对于这些操作这里跳过,只讲核心原理代码。

我们来看看源码里是怎样实现的:

export function observer<T extends IReactComponent>(component: T): T {
    // ... 错误操作的报警

    // ... 处理 ForwardRef 
  
    // 处理 Function component  暂且跳过
    if (
        typeof component === "function" &&
        (!component.prototype || !component.prototype.render) &&
        !component["isReactClass"] &&
        !Object.prototype.isPrototypeOf.call(React.Component, component)
    ) {
        return observerLite(component as React.StatelessComponent<any>) as T
    }
		// Class Component
    return makeClassComponentObserver(component as React.ComponentClass<any, any>) as T
}

export function makeClassComponentObserver(
    componentClass: React.ComponentClass<any, any>
): React.ComponentClass<any, any> {
  	// 组件原型
    const target = componentClass.prototype

    if (componentClass[mobxObserverProperty]) {
      	// 错误操作报警
        const displayName = getDisplayName(target)
        console.warn(
            `The provided component class (${displayName}) 
                has already been declared as an observer component.`
        )
    } else {
      	// 表示组件已被 Mobx 作为观察者
        componentClass[mobxObserverProperty] = true
    }
		// 错误报警
    if (target.componentWillReact)
        throw new Error("The componentWillReact life-cycle event is no longer supported")
		// 实现 shouldComponentUpdate
    if (componentClass["__proto__"] !== PureComponent) {
        if (!target.shouldComponentUpdate) target.shouldComponentUpdate = observerSCU
        else if (target.shouldComponentUpdate !== observerSCU)
            // n.b. unequal check, instead of existence check, as @observer might be on superclass as well
            throw new Error(
                "It is not allowed to use shouldComponentUpdate in observer based components."
            )
    }

    // 将 Props、State 包装成 OV
    makeObservableProp(target, "props")
    makeObservableProp(target, "state")
		// 原始 render
    const baseRender = target.render
    // 被拦截的 render,只首次 mount 会调这个
    target.render = function () {
      	// 原始 render 外部包了一层派生
        return makeComponentReactive.call(this, baseRender)
    }
    patch(target, "componentWillUnmount", function () {
        if (isUsingStaticRendering() === true) return
      	// 组件卸载时,解绑派生与依赖的绑定,避免内存泄漏
        this.render[mobxAdminProperty]?.dispose()
        this[mobxIsUnmounted] = true

        if (!this.render[mobxAdminProperty]) {
            // Render may have been hot-swapped and/or overriden by a subclass.
            const displayName = getDisplayName(this)
            console.warn(
                `The reactive render of an observer class component (${displayName}) 
                was overriden after MobX attached. This may result in a memory leak if the 
                overriden reactive render was not properly disposed.`
            )
        }
    })
    return componentClass
}

function makeComponentReactive(render: any) {
    if (isUsingStaticRendering() === true) return render.call(this)

  	// 处理 forceUpdate 带来的副作用 ...

    const initialName = getDisplayName(this)
    const baseRender = render.bind(this)

    let isRenderingPending = false

    // 创建一个派生, 带来的副作用就是第二个回调参数
    const reaction = new Reaction(`${initialName}.render()`, () => {
        if (!isRenderingPending) {
            // N.B. Getting here *before mounting* means that a component constructor has side effects (see the relevant test in misc.js)
            // This unidiomatic React usage but React will correctly warn about this so we continue as usual
            // See #85 / Pull #44
            isRenderingPending = true
            if (this[mobxIsUnmounted] !== true) {
                let hasError = true
                try {
                    // 处理 forceUpdate 带来的副作用 ...
                  	// forceUpdate 强制重渲染
                    if (!this[skipRenderKey]) Component.prototype.forceUpdate.call(this)
                    hasError = false
                } finally {
                    // 处理 forceUpdate 带来的副作用 ...
                    if (hasError) reaction.dispose()
                }
            }
        }
    })

    reaction["reactComponent"] = this
    reactiveRender[mobxAdminProperty] = reaction
  	// 之后 forceUpdate 的时候,重新执行的 render 都只是 reactiveRender
    this.render = reactiveRender

    function reactiveRender() {
        isRenderingPending = false
        let exception = undefined
        let rendering = undefined
        // 为该 reaction 派生收集原 render 函数内的依赖
        reaction.track(() => {
            try {
              	// 执行原 render 函数,拿到虚拟节点
                rendering = _allowStateChanges(false, baseRender)
            } catch (e) {
                exception = e
            }
        })
        if (exception) {
            throw exception
        }
      	// 返回虚拟节点给 React
        return rendering
    }

    return reactiveRender.call(this)
}
函数组件

函数组件要达到的目的和类组件是一致的,都是 rerender 重新收集依赖,依赖变化触发 rerender。但是函数组件不能用 forceUpdate 这个 API,所以 Mobx 内部用了 React hooks 的小 trick 去实现了 forceUpdate 的效果。

由于这种小 trick 带来的副作用更多,所以这部分 mobx-react-light 里的处理很冗余,提炼代码来做讲解:

function observerLite(baseFuncComponent) {
  return function(props) {
    const [tick, setTick] = useState(0);
    // 利用 useState 伪造 forceUpdate
    function forceUpdate() {
      setTick(tick + 1);
    }
    // 造一个函数组件的派生,useMemo 保证派生不会 rebuild
    // 组件内依赖变化时,invoke forceUpdate,rerender
    const r = useMemo(() => new Reaction('包裹函数组件的派生', forceUpdate), []);
    // 组件卸载时解绑派生与依赖,避免内存泄漏
    useEffect(() => () => r.dispose(), []);
    
    let vnodes = null;
    // 每轮 rerender 重新执行函数组件,追踪依赖
    r.track(() => {
      vnodes = baseFuncComponent({...props, tick});
    })
		// 返回虚拟节点给 React
    return vnodes;
  }
}

其余 API

action

以装饰器 action 修饰函数 fn 为例,其实就是重写了 fn 的描述符,把函数体由 createAction 包了一层:

return {
  // name 函数名、descriptor.value 函数体
  value: createAction(name, descriptor.value),
  enumerable: false,
  configurable: true, // See #1477
  writable: true // for typescript, this must be writable, otherwise it cannot inherit :/ (see inheritable actions test)
}
export function createAction(actionName: string, fn: Function, ref?: Object): Function & IAction {
    // ...
  	// 外部调用 fn 时,真正执行的是这个方法
    const res = function() { 
      	// executeAction 内部核心工作就是让 fn 的执行处于一轮事务当中
        return executeAction(actionName, fn, ref || this, arguments)
    }
    ;(res as any).isMobxAction = true
    // ...
    return res as any
}
export function executeAction(actionName: string, fn: Function, scope?: any, args?: IArguments) {
    const runInfo = _startAction(actionName, scope, args)
    try {
        return fn.apply(scope, args)
    } catch (err) {
        runInfo.error = err
        throw err
    } finally {
        _endAction(runInfo)
    }
}

// startAction 除了 startBatch 以外的操作,都是为了确实新开启一轮事务的纯净性,不被之前上下文的操作所影响。
export function _startAction(actionName: string, scope: any, args?: IArguments): IActionRunInfo {
    // ...
    let startTime: number = 0   
  	// 在 action 里,对 OV 的读取不收集方法 fn。因为 action 方法并不是副作用,而是要改变依赖的动作。
    const prevDerivation = untrackedStart()
    startBatch() // 开启一轮新事务
  	// 允许对依赖写
    const prevAllowStateChanges = allowStateChangesStart(true)
    // 允许对依赖读
    const prevAllowStateReads = allowStateReadsStart(true)
    // 记录该轮事务的一些信息,方便 endAction 时回退,保持开启事务前的状态纯净。
    const runInfo = {
        prevDerivation,
        prevAllowStateChanges,
        prevAllowStateReads,
        notifySpy,
        startTime,
        actionId: nextActionId++,
        parentActionId: currentActionId
    }
    currentActionId = runInfo.actionId
    return runInfo
}

// 除了结束事务的操作,其余都是根据记录的该轮事务的一些信息,回退保持开启事务前的状态纯净。
export function _endAction(runInfo: IActionRunInfo) {
    if (currentActionId !== runInfo.actionId) {
        fail("invalid action stack. did you forget to finish an action?")
    }
    currentActionId = runInfo.parentActionId

    if (runInfo.error !== undefined) {
        globalState.suppressReactionErrors = true
    }
  	// 回退开启事务前依赖的改变权限
    allowStateChangesEnd(runInfo.prevAllowStateChanges)
    // 回退开启事务前依赖的读取权限
    allowStateReadsEnd(runInfo.prevAllowStateReads)
    // 结束事务,准备批量处理收集的 Reaction
    endBatch()
  	// 回退开启事务前的派生追踪
    untrackedEnd(runInfo.prevDerivation)
    // ...
    globalState.suppressReactionErrors = false
}

其实看源码一目了然了,主要目的就是让 action 的函数执行身处于一轮新的事务中,好处就是为了多次改变某 Derivation 的依赖时,只处理一次。回归上文讲得 transaction 的概念:

一个 Action 开始和结束时同时伴随着事务的启动和结束,确保 Action 中(可能多次)对状态的修改只触发一次 Reaction 的重新执行。

额外 API

额外的 API 在熟悉了上文的所有内容后,阅读起来应该比较简单了,鉴于 API 太多,不一一做分析,感兴趣自行挖掘。

Mobx 设计**

Mobx 作者 Michel Weststrate 有在一篇推文中阐述过 Mobx 设计理念,但是有点过于细节,不熟悉 Mobx 机制的同学可能不太看得懂。以下,在基于这篇推文结合上述源码,我用中文提炼一下,感兴趣可以去看原文。

对状态改变作出反应永远好过于对状态改变作出动作

针对这点其实与 Vue 响应式传递的理念相同,就是数据驱动

再分析这句话,“作出反应” 意味着状态与副作用的绑定关系由框架(库)给你做好,状态改变自动通知到副作用,不用使用者(开发者)人为地处理。

“作出动作”则是在使用者已知状态更改的情况下,手动去通知副作用更新。 这起码就有一个操作是使用者必做的:手动在副作用内订阅状态的变化,这至少带来两个缺陷:

  1. 无法保证订阅量的冗余性,可能订阅多了可能少了,导致应用出现不符合预期的情况。
  2. 会让业务代码变得更 dirty,不好组织

最小的、一致的订阅集

以 render 作为副作用举例,假如 render 里有条件语句:

render() {
  if (依赖 A) {
    return 组件 1;
  }
  return 依赖 B ? 组件 2 : 组件 3
}

首先,如果交给用户手动订阅,必须只能依赖 A、B 的状态一起订阅才行,如果订阅少了无法出现预期的 re-render。

然后交给框架去做处理怎样才好? 依赖 A、B 一起订阅当然没毛病,但是假设依赖 A、B 初始化时都有值,我们有必要让 render 订阅依赖 B 的状态吗?

没必要,为什么?想一想如果此时依赖 B 的状态变化了 re-render 呈现的效果会有什么不同吗?

所以在初始化时就订阅所有的状态是冗余的,假如应用程序复杂、状态多了,没必要的内存分配就会更多,对性能有损耗。

故 Mobx 实现了运行时处理依赖的机制,保证副作用绑定的是最小的、一致的订阅集。源码参见上述 “getter 里干了啥?” 与 “处理依赖” 章节。

派生计算的合理性

说人话就是:杜绝丢失计算、冗余计算

丢失计算:Mobx 的策略是引入状态机的概念去管理依赖与派生,让数学的逻辑性保证不会丢失计算。

冗余计算:

  1. 对于非计算属性状态,引入事务概念,保证同一批次中所有对状态的同步更改,状态对应的派生只计算一次。
  2. 对于计算属性,计算属性作为派生时,当其依赖变化,计算属性不会立即重新计算,会等到计算属性自身作为状态所绑定的派生再次用到计算属性值时才去重新计算。并且计算出相同值会阻止派生继续处理。

通用性(笔者补充)

就 Mobx 库本身,与 UI render 没有绑定关系,与 event loop 中异步机制没绑定关系。

所以 Mobx 不像 Vue 2.x 响应式处理一样,需要收集 Wachter 然后赶在 ui render 前异步迭代处理 Wachter 对应的副作用。更新粒度也不一样,Vue 2.x 是组件, Mobx 就是副作用,副作用可以但不仅是组件。

不知道 Vue 3.x 把响应式抽成一个 package 后还是不是这样,没研读过其源码了。(不过 Vue 2.x 源码记得当时研究了很久,现在也忘得差不多了,现在再去捡起来又觉得耗时且带来的收益不大,可恶又无奈的学习边际效应 -_-||)

故:Mobx 适用于任一使用 ES 语法的场景。

若觉得有帮助,欢迎 star watch ✍🏼

服务端渲染(SSR)技术的探索总结

服务端渲染是什么?

服务端渲染(Server-Side Rendering)是一种用于 Web 开发的技术,旨在当浏览器访问某一路径时,在服务器上完成该路径页面所需数据的获取,拼接成一个完整的 HTML 文档返回给浏览器。

为什么会出现这种方案?

WEB 1.0 时代

服务端渲染的方案其实在 Web 1.0 时代就存在,像以往通过 asp、php 等技术开发 Web ,就是典型的服务端渲染。 整体流程是如下图的:

浏览器拿到的是一个完整的被服务器动态组装出来的 HTML 文本,然后将 HTML 渲染到页面中,过程没有任何 JavaScript 代码的参与,就算把网页的 js 脚本给禁止了,所有内容都会完整地展示出来。

这种模式在以往流行了很长一段时间,但随着市场对 WEB 应用的要求越来越高,渐渐也暴露出了缺点:

  1. 每次想要改变页面,哪怕只是局部,都需要重新请求一次,重新查一次数据库、组装一次 HTML
  2. 前后端代码混杂在一起,业务越复杂越难维护

前后端分离

随着 ajax 、NodeJS 的出现,诞生了前端工程师的职业,前端承担更多 WEB 应用的职责,前端团队接管了所有页面渲染的事,后端团队只负责提供所有数据查询与处理的 API。这就是前后端分离的模式:

这就是典型的客户端渲染(CSR)的模式,这种模式的优点就是解决了 WEB 1.0 模式的缺点:

  1. 每次改变页面内容、跳转页面都不用发起新的 html 请求,而是客户端自己处理并渲染
  2. 前后端代码分离,分团队管理,利于整体项目的维护和发展

我们常用的 MVVM 框架开发的单页应(SPA),基本都是这种模式。

服务端渲染

随着单页应用的发展,也慢慢暴露出了其缺点:

  1. SEO 不友好
  2. 当 JS 脚本臃肿时,首屏渲染时间变慢

为什么客户端渲染不利于 SEO ?

因为绝大部分搜索引擎爬虫只认识网页的 HTML 结构,通过算法解析 HTML 来计算出网页的搜索排名,但是客户端渲染去请求的页面的 HTML 结构很简单,真正给用户呈现的 DOM 是通过 JS 去处理并挂载的。

比如我们用 creat-react-app 跑一个项目:

可以看到服务端返回的 html 内容部分就一个 id 为 root 的空 div 标签,是一个空壳。但是我们看 Elements 下又是有内容的,这部分内容就是 JS 去处理并挂载的。

由于 JS 去处理后挂载的 HTML,是大部分搜索引擎爬虫无法解析的,所以客户端渲染对 SEO 不友好。

为什么首屏渲染时间变慢?

回顾下上述客户端渲染流程: 浏览器请求页面后,需要加载完 js 文件,然后执行 js 代码去获取数据、处理 DOM 并挂载后页面才会渲染出来。

随着业务的复杂,JS 文件会越来越臃肿,需要处理的数据、DOM 就会越来越多,所消耗的时间也越来越长,所以用户需要等待更久的时间才能看到页面。

服务端渲染的再次出现

为了在保留客户端渲染带来的益处的同时又能解决客户端渲染的缺点,大家就想基于 CSR 去找一些解决方案。

思考一下,反正 SEO 和首屏渲染都是作用于对服务器该地址的「第一次访问」,那么我能不能只针对性地优化「第一次访问」就行了?之后的一切操作还是交还给客户端渲染去管理。

那么想要 SEO,是不是页面第一次访问时响应是完整的 HTML 就行了?

由于是完整的 HTML,浏览器拿到 HTML 后就解析渲染出了页面结构,无需等待 js 去请求数据、处理挂载 DOM,是否首屏渲染速度自然就提升了?

其实现如今任何 SSR 方案主要做的两件事就是:

  1. 页面第一次访问时响应的是完整的 HTML
  2. 后续操作能继续交给客户端渲染

那么基于客户端渲染的服务端渲染确实解决了客户端渲染的两个痛点,那么这种方案有没有缺点呢? 自然是有的:

  1. 代码复杂度增加。为了实现服务端渲染,应用代码中需要兼容服务端和客户端两种运行情况(同构),而一部分依赖的外部扩展库却只能在客户端运行,需要对其进行特殊处理,才能在服务器渲染应用程序中运行。
  2. 需要更多的服务器负载均衡。由于服务器增加了渲染 HTML 的需求,使得原本只需要输出静态资源文件的 nodejs 服务,新增了数据获取的 IO 和渲染 HTML 的 CPU 占用,如果流量突然暴增,有可能导致服务器宕机,因此需要使用响应的缓存策略和准备相应的服务器负载。
  3. 涉及构建设置和部署的更多要求。与可以部署在任何静态文件服务器上的完全静态单页面应用程序 (SPA) 不同,服务器渲染应用程序,需要处于 Node.js server 运行环境。

所以在使用服务端渲染 SSR 之前,需要开发者考虑投入产出比,比如大部分应用系统都不需要 SEO,而且首屏时间并没有非常的慢,如果使用 SSR 反而小题大做了。

概念

同构

所谓同构,就是一套代码需要在服务端执行一遍、在客户端也执行一遍。

举个例子来说,比如 JSX,在服务端执行就是调用 renderToString(jsx)去生成 html string,在客户端执行就是为生成的 html 绑定事件。

数据注水和脱水

上面有提到过,对于 SSR 来说,数据请求的操作放在了服务端做。当请求到数据后,转化为组件所需的状态( State),然后把 状态 + 模版(JSX) 转化为 HTML 返回给浏览器端。

浏览器拿到 HTML 后会先渲染出 DOM 结构,然后请求并执行 js 文件,此时我们组件的代码也会在客户端被执行一次,也就是同构,我们会再次去初始化路由、状态。

又因为服务端已经请求过数据处理过一次组件状态了,如果不做任何操作,当客户端初始化状态时就拿不到服务端处理过的状态,客户端就会把初始化的状态覆盖给组件用,导致页面会再渲染成丢失了状态的组件。

为了避免这一情况,就出现了数据注水和脱水的方案,一般来说,注水操作就是在返回给客户端的 HTML 中新加个 script 标签,然后把服务端处理过的状态在 script 标签中保存在 window 全局对象里。脱水操作就是在客户端初始化状态前,取出 window 对象中服务端处理后的状态,作为初始状态去初始化组件的状态。

业界方案

Vue 的话有 Nuxt

React 的话有 Next

两个都是业内成熟的 SSR 框架。

轮子

基于 React 我造了一个 SSR 的轮子: 仓库地址。有心的同学可以从第一条 commit 开始看代码,看完一定更能理解上述概念。

番外 SEO 介绍

title 和 meta description 的真正作用

可能不少人认为 title 标签和 meta description 标签在 SEO 中起着关键作用,其实并不是。现在搜索引擎爬虫大多是读整体 HTML 的内容去分析的,分析内容涵盖了一个网站主要 3 个部分的内容:文本多媒体(主要是图片)和外部链接,通过这些来判断网站的类型和主题。

那么 title 和 meta description 的真正作用是什么呢? 其实是用户的转化率,比如,同样高排名的网站,title 和 meta description 做得更好的那个,在搜索页的内容就更吸引人,那自然点进网页浏览的用户就更多。

但是,单页应用,页面始终只有一份 title 和 description ,一般是用来描述网站主要的功能的,并且大部分 SEO 后给用户呈现的网址都是网站根路径,若用户想看的内容其实在另一路由下的内容,在只有一份 title 和 description 的情况,就得需要用户点进主页后去找想了解的页面。

若我们每个路由都有对应的 title 和 description 的话,在爬虫分析了整体内容后,会优先展示 title 和 description 更 match 的路由,用户可以直接了当地看到想看内容了。

怎样有多份呢? react-helmet提供了方法,它可以实现根据不同的组件显示来对应不同的网站标题和描述的功能。用法也比较简单,感兴趣可以去看看。

一般如何做好 seo

上文有描述过:**大部分爬虫分析内容涵盖了一个网站主要 3 个部分的内容:文本、多媒体(主要是图片)、外部链接,通过这些来判断网站的类型和主题。**所以我们想要做好 SEO ,也得对症优化这 3 个部分。

文本

对于文本来说,尽量不要抄袭已经存在的文章,以写技术博客为例,东拼西凑抄来的文章排名一般不会高,如果需要引用别人的文章要记得声明出处,不过最好是原创,这样排名效果会比较好。

多媒体

多媒体包含了视频、图片等文件形式,现在比较权威的搜索引擎爬虫比如 Google 做到对图片的分析是基本没有问题的,因此高质量、与网站主题贴合的图片也是加分项。

外部链接

也就是网站中 a 标签的指向,最好也是和当前网站相关的一些链接,更容易让爬虫分析。

预渲染

因为 SSR 架构对代码的开发、维护成本,以及对服务端性能的要求更高,所以应用时,开发人员要仔细斟酌投入产出比。如果需求只是想让 SEO 优化,对项目当下首屏的渲染速度满意的话,我们完全不必应用 SSR 方案,可以使用预渲染技术。

预渲染技术的原理就是:区分出访问我们网页的对象,如果是用户,则直接访问我们项目部署的地址与端口号,按照正常的客户端渲染流程去呈现页面。如果发现访问对象是搜索引擎爬虫,则代理到我们事先开启的一个服务,然后该服务同样访问我们的项目的地址,等待客户端渲染完成后,抓取渲染后的 HTML 返回给爬虫。

爬虫有了完整的 HTML 后,自然可以进行分析了。

首先识别访问对象,可以基于 nginx 去区分代理。然后等待项目渲染完成并抓取 HTML 的操作,业内有现成的工具 prerender。该工具原理大概就是起一个 server,爬虫访问时再起一个简易浏览器去访问真正的项目地址,等待项目客户端渲染完成后,抓取 HTML 返回给爬虫。

预渲染的缺点自然是等待返回结果的时间有点长,毕竟要先起一个浏览器,并且要等待客户端渲染完成,但返回的对象是爬虫,时间稍长些也没关系。

以上。

参考

装饰器原理探究

js 装饰器可以装饰类、类方法、类属性,以例子来说用法如下:

@cls
class A {
  @dec
  a = 1;

  @decFn fn() {console.log(this.a)}
}

function dec(target, prop, descriptor) {
  // target --- A.protocol
  // prop --- a
  // descriptor --- {
  //        configurable: true,
  //        enumerable: true,
  //        writable: true,
  //        initializer: function initializer() {
  //          return 1; // 初始化时,会绑定实例作为 this 执行该函数,把返回值赋值给属性
  //        },
  //      }
  console.log(target, prop, descriptor);
}

function decFn(target, prop, descriptor) {
  // target --- A.protocol
  // prop --- fn
  // descriptor --- {configurable: true, enumerable: false, writable: true, value: Function}
  console.log(target, prop, descriptor);
}

function cls(target) {
  // target --- A
  console.log(target);
  // 可对类进行操作
  // 若有返回值,则返回值作为新类
}

cls 就是类的装饰器,dec 就是实例属性装饰器,decFn就是类方法装饰器。

js 装饰器原本设计是代码运行前执行的,可以做静态分析之类的事情。但是由于装饰器语法还处于提案中,并且语法可能在提案的不同阶段都会变(比如 babel legacy: true/false 编译出来的结果不同),不稳定,所以引擎还未去实现它。

所以现在想用装饰器语法,必须借助工具,比如 babel 转译、tsc 编译。转/编译后的装饰器,实际也是 runtime 阶段执行,只不过在修饰的类被实例化之前 invoke 。

接下来就分析 babel legacy: true 编译后的代码探究 js 装饰器的原理。

首先是对类的装饰:

let A = cls(_class = class A {
}) || _class;

很好理解,就是类自身传进装饰器内,让开发者可以自行操作该类,若有返回值则把类的引用替换成返回值的,否则保持类的引用。

然后是对类属性的装饰:

let A = cls(_class = (_class2 = class A {
  constructor() {
    _initializerDefineProperty(this, "a", _descriptor, this);
  }


}, (_descriptor = _applyDecoratedDescriptor(_class2.prototype, "a", [dec], {
  configurable: true,
  enumerable: true,
  writable: true,
  initializer: function initializer() { 
  	// 实例属性的初始值
    return 1;
  }
})), _class2)) || _class;

_initializerDefineProperty 的行为就是在实例化时,给实例属性挂描述符。

function _initializerDefineProperty(target, property, descriptor, context) {
  // target --- 实例
  // property --- 属性
  // descriptor --- 描述符
  // context --- 实例
  if (!descriptor) return; // 装饰器有返回值,此处则不再挂描述符
  Object.defineProperty(target, property, {
    enumerable: descriptor.enumerable,
    configurable: descriptor.configurable,
    writable: descriptor.writable,
    value: descriptor.initializer
      ? descriptor.initializer.call(context)
      : void 0,
  });
}

_applyDecoratedDescriptor 的话主要目的就是执行装饰器。

function _applyDecoratedDescriptor(
  target, // 实例属性、类方法  --- 原型
  property, // 属性/方法名
  decorators, // [装饰器, ...]
  descriptor, // 描述符
  context, // 实例属性 --- undefined  类方法 --- 原型
) {
  var desc = {};
  Object.keys(descriptor).forEach(function (key) {
    desc[key] = descriptor[key];
  });
  desc.enumerable = !!desc.enumerable;
  desc.configurable = !!desc.configurable;
  if ("value" in desc || desc.initializer) {
    desc.writable = true;
  }
  // 以上构建属性描述符
   
  // 以下执行装饰器,像洋葱一样内部先执行
  desc = decorators
    .slice()
    .reverse()
    .reduce(function (desc, decorator) {
      return decorator(target, property, desc) || desc; // 若装饰器有返回值,则把返回值作为描述符
    }, desc);
  if (context && desc.initializer !== void 0) {
    desc.value = desc.initializer ? desc.initializer.call(context) : void 0;
    desc.initializer = undefined;
  }
  if (desc.initializer === void 0) {
    // 若属性装饰器走到此处,代表把装饰器的返回值作为了描述符,挂载在 原型 上
    Object.defineProperty(target, property, desc);
    desc = null;
  }
  return desc;
}

由上可知:

对类属性的装饰,也是在实例化前执行的。并且根据装饰器有没有返回值分情况对属性作操作:

  • 有返回值: 往类原型挂载属性,是赋值还是挂 getter setter 根据开发者来定
  • 无返回值: 往实例挂载属性,并赋初始化值

思考一个例子:

image.png

可见 getter setter 也是会按照原型链去找的。

最后是对类方法的修饰

let A =
  cls(
    (_class =
      ((_class2 = class A {
        constructor() {
          _initializerDefineProperty(this, "a", _descriptor, this);
        }

        fn() {
          console.log(this.a);
        }
      }),
      ((_descriptor = _applyDecoratedDescriptor(_class2.prototype, "a", [dec], {
        configurable: true,
        enumerable: true,
        writable: true,
        initializer: function initializer() {
          return 1;
        },
      })),
      _applyDecoratedDescriptor( // 对函数做处理
        _class2.prototype,
        "fn",
        [decFn],
        Object.getOwnPropertyDescriptor(_class2.prototype, "fn"), // {writable: true, enumerable: false, configurable: true, value: ƒ}
        _class2.prototype
      )),
      _class2))
  ) || _class;

经过对属性装饰器的分析,方法就很简单了,就是在实例化前执行装饰器,然后把类原型、方法名、方法描述符作为参数传进去,若有返回值,则把返回值当描述符挂在对象的方法名上,若无返回值,则挂方法原本的描述符。

Let's talk about Event Loop

照例思维导图。

单线程的js

我们通常说 JavaScript 是单线程的,实际上是指在 JS 引擎中负责解释和执行 JS 代码的线程只有一个,一般成为主线程,在这种前提下,为了让用户的操作不存在阻塞感,前端 APP 的运行需要依赖于大量的异步过程,所以当然浏览器中还存在一些其他的线程,比如处理 http 请求的线程、处理 DOM 事件的线程、定时器线程、处理文件读写的 I/O 线程等等。

异步过程

一个异步过程通常是这样的:

  1. 主线程发起一个异步请求,相应的工作线程接收请求并告知主线程已收到;
  2. 主线程可以继续执行后面的代码,同时工作线程执行异步任务;
  3. 工作线程完成工作后通知主线程,主线程收到通知后调用回调函数。

消息队列和事件循环

异步过程中,工作线程在异步操作完成后需要通知主线程,那么这个通知机制是怎样实现的呢?

答案是利用消息队列和事件循环。消息队列是一个先进先出的队列,里面存放着各种消息,我们可以简单的理解为消息就是注册异步任务时添加的回调函数;事件循环是指主线程重复从消息队列中获取消息、执行回调的过程,之所以称为事件循环,就是因为它经常被用类似如下方式来实现:

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

消息队列是一个存储着待执行任务的队列,其中的任务严格按照时间先后顺序执行,排在队头的任务将会率先执行,而排在队尾的任务会最后执行。消息队列每次仅执行一个任务,在该任务执行完毕之后,再执行下一个任务。执行栈则是一个类似于函数调用栈的运行容器,当执行栈为空时 JS 引擎便检查消息队列,如果不为空消息队列便将第一个任务压入执行栈中运行。

下面我们来看下述代码来验证我们的想法:

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

new Promise(function(resolve) {
    console.log(1)

    for (var i = 0; i < 10000; i++) {
        i == 9999 && resolve()
    }

    console.log(2)
}).then(function() {
    console.log(5)
});

console.log(3);

比较诡异的事情出现了,为什么结果是“1, 2, 3, 5, 4”,而不是“1, 2, 3, 4, 5”呢?!

按道理来说,执行 setTimeout 时因为延迟为0,所以 console.log(4) 直接插入至消息队列;创建 Promise 实例时同步执行其函数体内的代码,先打印 1,再循环10000次后执行 resolve 将 then 中的回调函数 console.log(5) 插入至消息队列,然后打印 2;最后执行 console.log(3) 打印 3;在主线程执行完成后读取消息队列,依次打印 4 和 5 。

上面的想法当然是比较天真的,实际上浏览器中仅有一个事件循环,然后消息队列是可以有多个的。

macro-queue: script (整体代码), setTimeout, setInterval, setImmediate, I/O, UI Rendering

micro-queue: process.nextTick, Promise, Object.observe, MutationObserver

并且 micro-queue 的任务优先级高于 macro-queue 的任务优先级,这两个任务队列执行顺序如下:取1个 macro-task 执行之,然后把所有 micro-task 顺序执行完,再取 macro-task 中的下一个任务,以此类推依次进行。

优先级:process.nextTick > promise.then > setTimeout > setImmediate

Tip:process.nextTick 永远大于 promise.then 原因其实很简单:在 NodeJS 中,_tickCallback 在每一次执行完 TaskQueue 中的一个任务后被调用,而在这个_tickCallback 中实质上干了两件事:

  1. 执行掉 nextTickQueue 中所有任务
  2. 第一步执行完后,执行 _runMicrotasks 函数(执行 micro-task 中的部分,即 promise.then 注册的回调)

总结

浏览器环境一般只能有一个事件循环(实际上有两类:browsing contexts 和 web workers),而一个事件循环可以多个任务队列,每个任务都有一个任务源。相同任务源的任务,只能放到一个任务队列中;不同任务源的任务,可以放到不同任务队列中。举个栗子,客户端可能实现一个包含鼠标键盘事件的任务队列,还有其他的任务队列,而给鼠标键盘事件的任务队列更高优先级,例如75%的可能性执行它,这样就能保证流畅的交互性,而且别的任务也能执行到。

至此,再返回去看之前的代码就不难分析出:代码执行开始把 setTimeout 的回调插入至 macro-queue 中,而打印完1后把 promise.then 的回调函数插入至 micro-queue 中,整体代码执行完后,按照消息队列的优先级,先执行 micro-task 即打印5,最后执行 macro-task 即打印4。


参考: Event Loop 那些事儿

内存管理与内存泄漏思考题

1、

var a = 20;
var b = a;
b = 30;

// 这时a的值是多少?

20

2、

var a = { name: '前端开发' }
var b = a;
b.name = '进阶';

// 这时a.name的值是多少

"进阶"

3、

var a = { name: '前端开发' }
var b = a;
a = null;

// 这时b的值是多少

{ name: '前端开发' }

4、

var a = {n: 1};
var b = a;
a.x = a = {n: 2};

a.x 	// 这时 a.x 的值是多少
b.x 	// 这时 b.x 的值是多少

解析:

关键在a.x = a = {n: 2};。.运算符高于=运算符。所以js引擎先执行a.x,因为此时a = {n : 1},所以a.x为undefined并且a和b此时等于都指向的是{n:1,x:undefined}。

执行了.运算符后开始执行=运算符,js是从右到左,所以先把{n:2}的引用给a,此刻:

a: {n: 2},
b: {
    n: 1,
    x: undefined
}

再把引用值a赋给a.x(因为a.x的.运算符已经运算过了,所以这里的a.x可以理解为就是一个普通的标识符,并且这标识符原本的值是undefined),就等于把{n: 2}的引用赋值给了这个标识符。此时:

b: {
   n: 1,
   x: {
       n: 2
   }
},
a: {
    n: 2
}

所以此时

console.log(a.x); // undefined
console.log(b.x); // {n: 2}

5、

从内存来看 null 和 undefined 本质的区别是什么?

  1. 值 null 是一个字面量,它不像undefined 是全局对象的一个属性。
  2. null值表示一个空对象指针,指示变量未指向任何对象,理解为尚未创建的对象比较好理解。undefined表示"缺少值",就是此处应该有一个值,但是还没有定义。

根据用途来理解第二点:

对于null:

(1)作为函数的参数,表示该函数的参数不是对象。(bind的柯里化使用)

(2)作为对象原型链的终点。Object.getPrototypeOf(Object.prototype) // null

(3)如果定义的变量在将来用于保存对象,那么最好将该变量初始化为null,而不是其他值。

(4)当一个数据不再需要使用时,我们最好通过将其值设置为null来释放其引用,这个做法叫做解除引用。(解除引用的真正作用是让值脱离执行环境)

对于undefined:

(1)变量被声明了,但没有赋值时,就等于undefined。

(2)调用函数时,应该提供的参数没有提供,该参数等于undefin ed。

(3)对象没有赋值的属性,该属性的值为undefined。

(4)函数没有返回值时,默认返回undefined。

var i;
i // undefined

function f(x){console.log(x)}
f() // undefined

var  o = new Object();
o.p // undefined

var x = f();
x // undefined

注意:

  1. typeof null // Object 历史遗留问题。
  2. null == undefined // true undefiend其实派生与null。
  3. 对于尚未声明过的变量,我们只能执行一项操作,即使用typeof操作符检测其数据类型,使用其他的操作都会报错。(如var i 的 i)

研究浏览器存储


提出四个问题:

  1. 什么样的数据适合放在cookie中?

  2. cookie是怎么设置的?

  3. cookie为什么会自动加到request header中?

  4. cookie怎么增删查改?

带着问题来阅读。

cookie

cookie 的来源

Cookie 的本职工作并非本地存储,而是“维持状态”。

因为HTTP协议是无状态的,HTTP协议自身不对请求和响应之间的通信状态进行保存,通俗来说,服务器不知道用户上一次做了什么,这严重阻碍了交互式Web应用程序的实现。在典型的网上购物场景中,用户浏览了几个页面,买了一盒饼干和两瓶饮料。最后结帐时,由于HTTP的无状态性,不通过额外的手段,服务器并不知道用户到底买了什么,于是就诞生了Cookie。它就是用来绕开HTTP的无状态性的“额外手段”之一。服务器可以设置或读取Cookies中包含信息,借此维护用户跟服务器会话中的状态。

我们可以把Cookie 理解为一个存储在浏览器里的一个小小的文本文件,它附着在 HTTP 请求上,在浏览器和服务器之间“飞来飞去”。它可以携带用户信息,当服务器检查 Cookie 的时候,便可以获取到客户端的状态。

在刚才的购物场景中,当用户选购了第一项商品,服务器在向用户发送网页的同时,还发送了一段Cookie,记录着那项商品的信息。当用户访问另一个页面,浏览器会把Cookie发送给服务器,于是服务器知道他之前选购了什么。用户继续选购饮料,服务器就在原来那段Cookie里追加新的商品信息。结帐时,服务器读取发送来的Cookie就行了。

cookie 是怎么工作的?

首先必须明确一点,存储cookie是浏览器提供的功能。cookie 其实是存储在浏览器中的纯文本,浏览器的安装目录下会专门有一个 cookie 文件夹来存放各个域下设置的cookie。

当网页要发http请求时,浏览器会先检查是否有相应的cookie,有则自动添加在request header中的cookie字段中。这些是浏览器自动帮我们做的,而且每一次http请求浏览器都会自动帮我们做。这个特点很重要,因为这关系到“什么样的数据适合存储在cookie中”。

存储在cookie中的数据,每次都会被浏览器自动放在http请求中,如果这些数据并不是每个请求都需要发给服务端的数据,浏览器这设置自动处理无疑增加了网络开销;但如果这些数据是每个请求都需要发给服务端的数据(比如身份认证信息),浏览器这设置自动处理就大大免去了重复添加操作。所以对于那设置“每次请求都要携带的信息(最典型的就是身份认证信息)”就特别适合放在cookie中,其他类型的数据就不适合了。

但在 localStorage 出现之前,cookie被滥用当做了存储工具。什么数据都放在cookie中,即使这些数据只在页面中使用而不需要随请求传送到服务端。当然cookie标准还是做了一些限制的:每个域名下的cookie 的大小最大为4KB,每个域名下的cookie数量最多为20个(但很多浏览器厂商在具体实现时支持大于20个)。

cookie 的格式

document.cookie

JS 原生的 API提供了获取cookie的方法:document.cookie(注意,这个方法只能获取非 HttpOnly 类型的cookie

控制台打印一下:

打印出的结果是一个字符串类型,因为cookie本身就是存储在浏览器中的字符串。但这个字符串是有格式的,由键值对 key=value 构成,键值对之间由一个分号和一个空格隔开。

cookie 的属性选项

每个cookie都有一定的属性,如什么时候失效,要发送到哪个域名,哪个路径等等。这些属性是通过cookie选项来设置的,cookie选项包括:expires、domain、path、secure、HttpOnly。在设置任一个cookie时都可以设置相关的这些属性,当然也可以不设置,这时会使用这些属性的默认值。在设置这些属性时,属性之间由一个分号和一个空格隔开。代码示例如下:

"key=name; expires=Thu, 25 Feb 2016 04:18:00 GMT; domain=ppsc.sankuai.com; path=/; secure; HttpOnly"

expires

expires选项用来设置“cookie 什么时间内有效”。expires其实是cookie失效日期,expires必须是 GMT 格式的时间(可以通过 new Date().toGMTString() 或者 new Date().toUTCString() 来获得)。

expires=Thu, 25 Feb 2016 04:18:00 GMT表示cookie讲在2016年2月25日4:18分之后失效,对于失效的cookie浏览器会清空。如果没有设置该选项,则默认有效期为session,即会话cookie。这种cookie在浏览器关闭后就没有了。

expires 是 http/1.0协议中的选项,在新的http/1.1协议中expires已经由 max-age 选项代替,两者的作用都是限制cookie 的有效时间。expires的值是一个时间点(cookie失效时刻= expires),而max-age 的值是一个以秒为单位时间段(cookie失效时刻= 创建时刻+ max-age)。
另外,max-age 的默认值是 -1(即有效期为 session );若max-age有三种可能值:负数、0、正数。负数:有效期session;0:删除cookie;正数:有效期为创建时刻+ max-age

domain 和 path

domain是域名,path是路径,两者加起来就构成了 URL,domain和path一起来限制 cookie 能被哪些 URL 访问。

一句话概括:

Domain 标识指定了哪些域名可以接受Cookie。如果没有设置domain,就会自动绑定到执行语句的当前域。如果设置为”.baidu.com”,则所有以”baidu.com”结尾的域名都可以访问该Cookie。

path对于指定域中的那个路径,应该向服务器发送 cookie。 例如,你可以指定 cookie 只有从http://www.wrox.com/books/ 中才能访问,那么 www.wrox.com 的页面就不会发送 cookie 信息,即使请求都是来自同一个域的。

特别说明1:
发生跨域xhr请求时,即使请求URL的域名和路径都满足 cookie 的 domain 和 path,默认情况下cookie也不会自动被添加到请求头部中。

特别说明2:
domain是可以设置为页面本身的域名(本域),或页面本身域名的父域,但不能是公共后缀 public suffix。举例说明下:如果页面域名为 www.baidu.com, domain可以设置为“www.baidu.com”,也可以设置为“baidu.com”,但不能设置为“.com”或“com”。

secure 与 http-only 以及 cookie 安全问题

secure 选项用来设置cookie只在确保安全的请求中才会发送。当请求是HTTPS或者其他安全协议时,包含 secure 选项的 cookie 才能被发送至服务器。

默认情况下,cookie不会带secure选项(即为空)。所以默认情况下,不管是HTTPS协议还是HTTP协议的请求,cookie 都会被发送至服务端。但要注意一点,secure选项只是限定了在安全情况下才可以传输给服务端,但并不代表你不能看到这个 cookie。

下面我们设置一个 secure类型的 cookie:

document.cookie = "name=huang; secure";

这里有个坑需要注意下:
如果想在客户端即网页中通过 js 去设置secure类型的 cookie,必须保证网页是https协议的。在http协议的网页中是无法设置secure类型cookie的。

httpOnly 这个选项用来设置cookie是否能通过 js 去访问。默认情况下,cookie不会带httpOnly选项(即为空),所以默认情况下,客户端是可以通过js代码去访问(包括读取、修改、删除等)这个cookie的。当cookie带httpOnly选项时,客户端则无法通过js代码去访问(包括读取、修改、删除等)这个cookie。

在客户端是不能通过js代码去设置一个httpOnly类型的cookie的,这种类型的cookie只能通过服务端来设置。

Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly

——httpOnly与安全

从上面介绍中,大家是否会有这样的疑问:为什么我们要限制客户端去访问cookie?其实这样做是为了保障安全。


试想:如果任何 cookie 都能被客户端通过document.cookie获取会发生什么可怕的事情。当我们的网页遭受了 XSS 攻击,有一段恶意的script脚本插到了网页中。这段script脚本做的事情是:通过document.cookie读取了用户身份验证相关的 cookie,并将这些 cookie 发送到了攻击者的服务器。攻击者轻而易举就拿到了用户身份验证信息,于是就可以摇摇大摆地冒充此用户访问你的服务器了(因为攻击者有合法的用户身份验证信息,所以会通过你服务器的验证)。

如何设置 cookie?

知道了cookie的格式,cookie的属性选项,接下来我们就可以设置cookie了。首先得明确一点:cookie既可以由服务端来设置,也可以由客户端来设置。

服务端设置 cookie

不管你是请求一个资源文件(如 html/js/css/图片),还是发送一个 ajax 请求,服务端都会返回 response 。而 response header 中有一项叫 set-cookie ,是服务端专门用来设置 cookie 的。如下图所示,服务端返回的 response header 中有5个 set-cookie 字段,每个字段对应一个 cookie (注意不能将多个 cookie 放在一个 set-cookie 字段中),set-cookie 字段的值就是普通的字符串,每个 cookie 还设置了相关属性选项。

注意:

  1. 一个set-Cookie字段只能设置一个cookie,当你要想设置多个 cookie,需要添加同样多的set-Cookie字段。

  2. 服务端可以设置cookie 的所有选项:expires、domain、path、secure、HttpOnly

客户端设置 cookie

在网页即客户端中我们也可以通过js代码来设置cookie,如:

document.cookie="age=12; expires=Thu, 26 Feb 2116 11:50:25 GMT; domain=sankuai.com; path=/";

注意:

客户端可以设置cookie 的下列选项:expires、domain、path、secure(有条件:只有在https协议的网页中,客户端设置secure类型的 cookie 才能成功),但无法设置HttpOnly选项。

跨域请求中 cookie

默认情况下,在发生跨域时,cookie 作为一种 credential 信息是不会被传送到服务端的。必须要进行额外设置才可以。详见:你真的会使用XMLHttpRequest吗?

cookie的缺陷

  • Cookie 不够大

Cookie的大小限制在4KB左右,对于复杂的存储需求来说是不够用的。当 Cookie 超过 4KB 时,它将面临被裁切的命运。这样看来,Cookie 只能用来存取少量的信息。此外很多浏览器对一个站点的cookie个数也是有限制的。

这里需注意:各浏览器的cookie每一个name=value的value值大概在4k,所以4k并不是一个域名下所有的cookie共享的,而是一个name的大小。

  • 过多的 Cookie 会带来巨大的性能浪费

Cookie 是紧跟域名的。同一个域名下的所有请求,都会携带 Cookie。大家试想,如果我们此刻仅仅是请求一张图片或者一个 CSS 文件,我们也要携带一个 Cookie 跑来跑去(关键是 Cookie 里存储的信息并不需要),这是一件多么劳民伤财的事情。Cookie 虽然小,请求却可以有很多,随着请求的叠加,这样的不必要的 Cookie 带来的开销将是无法想象的。

cookie是用来维护用户信息的,而域名(domain)下所有请求都会携带cookie,但对于静态文件的请求,携带cookie信息根本没有用,此时可以通过cdn(存储静态文件的)的域名和主站的域名分开来解决。

  • 由于在HTTP请求中的Cookie是明文传递的,所以安全性成问题,除非用HTTPS。

cookie 与 session

本片开头已说过 HTTP 是一个不保存状态的协议。session 是服务器端使用的一种记录客户端状态的机制,不同的是 cookie 保存在客户端浏览器中,而 session 保存在服务器上。

客户端浏览器访问服务器的时候,服务器把客户端信息以某种形式记录在服务器上。这就是 session。客户端浏览器再次访问时只需要从该 session 中查找该客户的状态就可以了。如果说 cookie 机制是通过检查客户身上的“通行证”来确定客户身份的话,那么 session 机制就是通过检查服务器上的“客户档案”来确认客户身份。session 相当于程序在服务器上建立的一份客户档案,客户来访的时候只需要查询客户档案表就可以了。

session对浏览器的要求

虽然 session 保存在服务器中,对客户端是透明的,它的正常运行仍然需要客户端浏览器的支持。这是因为 session 需要使用 cookie 作为识别标志。HTTP 协议是无状态的,session 不能依据 HTTP 连接来判断是否为同一客户,因此服务器向客户端浏览器发送一个名为 JSESSIONID 的 cookie,它的值为该 session 的 id。session 依据该cookie 来识别是否为同一用户。该 cookie 为服务器自动生成的,它的 MaxAge 属性一般为–1,表示仅当前浏览器内有效,并且各浏览器窗口间不共享,关闭浏览器就会失效。

禁用cookie时用sesson记录用户状态

URL 地址重写是对客户端不支持 cookie 的解决方案。URL 地址重写的原理是将该用户 session 的 id 信息重写到 URL 地址中。服务器能够解析重写后的 URL 获取 session 的 id。这样即使客户端不支持 cookie,也可以使用 session 来记录用户状态。

cookie session 区别

  1. cookie 数据存放在客户的浏览器上,session数据放在服务器上
  2. cookie 不是很安全,别人可以分析存放在本地的 COOKIE 并进行 COOKIE 欺骗考虑到安全应当使用 session
  3. session 会在一定时间内保存在服务器上。当访问增多,会比较占用你服务器的性能考虑到减轻服务器性能方面,应当使用 COOKIE

鉴于上述区别建议:

  • 将登陆信息等重要信息存放为 SESSION
  • 其他信息如果需要保留,可以放在 COOKIE 中

LocalStorage

1.LocalStorage的特点

  • 保存的数据长期存在,下一次访问该网站的时候,网页可以直接读取以前保存的数据。
  • 大小为5M左右
  • 仅在客户端使用,不和服务端进行通信
  • 接口封装较好

基于上面的特点,LocalStorage可以作为浏览器本地缓存方案,用来提升网页首屏渲染速度(根据第一请求返回时,将一些不变信息直接存储在本地)。

2.存入/读取数据

localStorage保存的数据,以“键值对”的形式存在。也就是说,每一项数据都有一个键名和对应的值。所有的数据都是以文本格式保存。

存入数据使用setItem方法。它接受两个参数,第一个是键名,第二个是保存的数据。
localStorage.setItem("key","value");

读取数据使用getItem方法。它只有一个参数,就是键名。
var valueLocal = localStorage.getItem("key");

具体步骤,请看下面的例子:

<script>
if(window.localStorage){
  localStorage.setItem('name','world'
  localStorage.setItem(“gender','female'
}
</script>
<body>
<div id="name"></div>
<div id="gender"></div>
<script>
var name=localStorage.getItem('name')
var gender=localStorage.getItem('gender')
document.getElementById('name').innerHTML=name
document.getElementById('gender').innerHTML=gender
</script>
</body>

3.使用场景

LocalStorage在存储方面没有什么特别的限制,理论上 Cookie 无法胜任的、可以用简单的键值对来存取的数据存储任务,都可以交给 LocalStorage 来做。

这里给大家举个例子,考虑到 LocalStorage 的特点之一是持久,有时我们更倾向于用它来存储一些内容稳定的资源。比如图片内容丰富的电商网站会用它来存储 Base64 格式的图片字符串:

sessionStorage

sessionStorage保存的数据用于浏览器的一次会话,当会话结束(通常是该窗口关闭),数据被清空;sessionStorage 特别的一点在于,即便是相同域名下的两个页面,只要它们不在同一个浏览器窗口中打开,那么它们的 sessionStorage 内容便无法共享;localStorage 在所有同源窗口中都是共享的;cookie也是在所有同源窗口中都是共享的。除了保存期限的长短不同,SessionStorage的属性和方法与LocalStorage完全一样。

1.sessionStorage的特点

  • 会话级别的浏览器存储
  • 大小为5M左右
  • 仅在客户端使用,不和服务端进行通信
  • 接口封装较好

基于上面的特点,sessionStorage 可以有效对表单信息进行维护,比如刷新时,表单信息不丢失。

2.使用场景

sessionStorage 更适合用来存储生命周期和它同步的会话级别的信息。这些信息只适用于当前会话,当你开启新的会话时,它也需要相应的更新或释放。比如微博的 sessionStorage就主要是存储你本次会话的浏览足迹:

lasturl 对应的就是你上一次访问的 URL 地址,这个地址是即时的。当你切换 URL 时,它随之更新,当你关闭页面时,留着它也确实没有什么意义了,干脆释放吧。这样的数据用 sessionStorage 来处理再合适不过。

3.sessionStorage 、localStorage 和 cookie 之间的区别

  • 共同点:都是保存在浏览器端,且都遵循同源策略。
  • 不同点:在于生命周期与作用域的不同。

作用域

localStorage只要在相同的协议、相同的主机名、相同的端口下,就能读取/修改到同一份localStorage数据。sessionStorage比localStorage更严苛一点,除了协议、主机名、端口外,还要求在同一窗口(也就是浏览器的标签页)下。

生命周期:

localStorage 是持久化的本地存储,存储在其中的数据是永远不会过期的,使其消失的唯一办法是手动删除;而 sessionStorage 是临时性的本地存储,它是会话级别的存储,当会话结束(页面被关闭)时,存储内容也随之被释放。

Web Storage 是一个从定义到使用都非常简单的东西。它使用键值对的形式进行存储,这种模式有点类似于对象,却甚至连对象都不是——它只能存储字符串,要想得到对象,我们还需要先对字符串进行一轮解析。

说到底,Web Storage 是对 Cookie 的拓展,它只能用于存储少量的简单数据。当遇到大规模的、结构复杂的数据时,得用到IndexedDB了。

indexedDB

IndexedDB 是一种低级API,用于客户端存储大量结构化数据(包括文件和blobs)。该API使用索引来实现对该数据的高性能搜索。IndexedDB 是一个运行在浏览器上的非关系型数据库。既然是数据库了,那就不是 5M、10M 这样小打小闹级别了。理论上来说,IndexedDB 是没有存储上限的(一般来说不会小于 250M)。它不仅可以存储字符串,还可以存储二进制数据。

1.IndexedDB的特点

  • 键值对储存。

IndexedDB 内部采用对象仓库(object store)存放数据。所有类型的数据都可以直接存入,包括 JavaScript 对象。对象仓库中,数据以"键值对"的形式保存,每一个数据记录都有对应的主键,主键是独一无二的,不能有重复,否则会抛出一个错误。

  • 异步

IndexedDB 操作时不会锁死浏览器,用户依然可以进行其他操作,这与 LocalStorage 形成对比,后者的操作是同步的。异步设计是为了防止大量数据的读写,拖慢网页的表现。

  • 支持事务。

IndexedDB 支持事务(transaction),这意味着一系列操作步骤之中,只要有一步失败,整个事务就都取消,数据库回滚到事务发生之前的状态,不存在只改写一部分数据的情况。

  • 同源限制

IndexedDB 受到同源限制,每一个数据库对应创建它的域名。网页只能访问自身域名下的数据库,而不能访问跨域的数据库。

  • 储存空间大

IndexedDB 的储存空间比 LocalStorage 大得多,一般来说不少于 250MB,甚至没有上限。

  • 支持二进制储存。

IndexedDB 不仅可以储存字符串,还可以储存二进制数据(ArrayBuffer 对象和 Blob 对象)。

2.IndexedDB的常见操作

在IndexedDB大部分操作并不是我们常用的调用方法,返回结果的模式,而是请求——响应的模式。

  • 建立打开IndexedDB ---- window.indexedDB.open("testDB")

这条指令并不会返回一个DB对象的句柄,我们得到的是一个IDBOpenDBRequest对象,而我们希望得到的DB对象在其result属性中

除了result,IDBOpenDBRequest接口定义了几个重要属性:

onerror: 请求失败的回调函数句柄

onsuccess:请求成功的回调函数句柄

onupgradeneeded:请求数据库版本变化句柄

<script>
function openDB(name){
var request=window.indexedDB.open(name)//建立打开IndexedDB
request.onerror=function (e){
console.log('open indexdb error')
}
request.onsuccess=function (e){
myDB.db=e.target.result//这是一个 IDBDatabase对象,这就是IndexedDB对象
console.log(myDB.db)//此处就可以获取到db实例
}
}
var myDB={
name:'testDB',
version:'1',
db:null
}
openDB(myDB.name)
</script>

控制台得到一个 IDBDatabase对象,这就是IndexedDB对象。

  • 关闭IndexedDB----indexdb.close()
function closeDB(db){
    db.close();
}
  • 删除IndexedDB ---- window.indexedDB.deleteDatabase(indexdb)
function deleteDB(name) {
  indexedDB.deleteDatabase(name)
}

3.WebStorage、cookie 和 IndexedDB之间的区别

从上表可以看到,cookie 已经不建议用于存储。如果没有大量数据存储需求的话,可以使用 localStorage 和 sessionStorage 。对于不怎么改变的数据尽量使用 localStorage 存储,否则可以用 sessionStorage 存储。

总结

就一张表格:

本文完。

希望看到各位技术人对这篇文章有不同的有“证据”,符合逻辑的分析、看法~

文章会第一时间更新在GitHub,觉得写得还不错的,可以点个star支持下作者🍪


参考:

  1. 聊一聊cookie
  2. 浏览器存储
  3. 谈谈cookie
  4. session理解
  5. 前端数据存储

剖析 React 内部运行机制

我们以首次启动项目到渲染完成为线索,从源码的角度看 React 的内部运行机制,看看 React 是如何实现我们在宏观理解 React 原理里介绍的架构。
只要创建支持并发模式的 React 项目,入口文件都会有类似这么一段代码:

// 初始化 Root 节点、render App 组件
ReactDOM.createRoot(document.getElementById('root')).render(
  <App />
)

这里就做了两件事情:

  1. React 的一些初始化工作
  2. 开始渲染组件

createRoot 做的初始化工作

我们已经在宏观理解 React 原理里讲过,Fiber 的工作原理类似于显卡的双缓冲机制,React 项目运行时内存里会存在两棵树:current Fiber 树和 wip Fiber 树,两棵树的协调管理就是通过此处要创建的 root 节点进行的。
我们可以看看 createRoot 的内部实现:

function createRoot(container) {
  // ...
  // 创建 Fiber root	节点
  const root = createFiberRoot(
    containerInfo, // DOM 容器
    tag, // 传入 ConcurrentRoot,代表需要创建一个 Concurrent Root
    hydrate,
    initialChildren,
    hydrationCallbacks,
    isStrictMode,
    concurrentUpdatesByDefaultOverride,
    identifierPrefix,
    onRecoverableError,
    transitionCallbacks,
    null,
  );
  // 拦截、监听、重写 DOM 容器的事件
  listenToAllSupportedEvents(rootContainerElement);

  return new ReactDOMRoot(root);
}

createFiberRoot 内部除了创建 FiberRoot 外,还创建了 current 树的根节点 HostRoot,内部实现如下:

function createFiberRoot(containerInfo: Container, tag: RootTag, ...) {
  // 创建 FiberRoot
  const root: FiberRoot = (new FiberRootNode(
    containerInfo, // DOM 容器
    tag, // ConcurrentRoot
    hydrate,
    identifierPrefix,
    onRecoverableError,
    formState,
  ): any);

  // 创建 current 树的根节点 HostRoot
  const uninitializedFiber = createHostRootFiber(
    tag,
    isStrictMode,
    concurrentUpdatesByDefaultOverride,
  );
  root.current = uninitializedFiber;
  uninitializedFiber.stateNode = root;

  // ...
  // 初始化 HostRoot 的 updateQueue
  initializeUpdateQueue(uninitializedFiber);

  // 返回 FiberRoot
  return root;
}

通过此处查看 FiberRootFiber 的数据结构,本质上它们都是 JS 对象,其不同字段代表了不同意义,我们在遇到的时候再说明
初始化工作完成后,以树形描述节点如下图

渲染流程

我们可以看到 createRoot 的返回值是 ReactDOMRoot 的实例,然后执行该实例的 render 方法去渲染组件。 我们来看对应的源码:

ReactDOMRoot.prototype.render =
  // $FlowFixMe[missing-this-annot]
  function (children: ReactNodeList): void {
    const root = this._internalRoot;
    // ...
    
    updateContainer(children, root, null, null);
  };

可以看到执行 render 本质上是执行 updateContainer 方法。

JSX

这个 children 就是 jsx ,举个例子,如下代码:

ReactDOM.createRoot(document.getElementById('root')).render(
	<div id="rootDiv" key="a_1">
	  <App />
  </div>
)

会被转译成:

import { jsx as _jsx } from "react/jsx-runtime";
ReactDOM.createRoot(document.getElementById('root')).render( /*#__PURE__*/_jsx("div", {
  id: "rootDiv",
  children: /*#__PURE__*/_jsx(App, {})
}, "a_1"));

看看 jsx 方法的实现:

/**
 * https://github.com/reactjs/rfcs/pull/107
 * @param {*} type jsx 标签的类型
 * @param {object} props 属性
 * @param {string} key 用于优化的 key
 */
function jsx(type, config, maybeKey) {
	let propName;

  // 去除了保留字段后存的 props 对象
  const props = {};

  let key = null;
  let ref = null;

  if (maybeKey !== undefined) {
    // ... 开发模式的一些检查
    key = '' + maybeKey;
  }
	
	// 处理 jsx 有展开运算符的情况
  if (hasValidKey(config)) {
    // ... 开发模式的一些检查
    key = '' + config.key;
  }

  if (hasValidRef(config)) {
    ref = config.ref;
  }

  // 除了内部保留 Props 外的所有属性都构造到 props 对象里
  for (propName in config) {
    if (
      hasOwnProperty.call(config, propName) &&
      !RESERVED_PROPS.hasOwnProperty(propName)
    ) {
      props[propName] = config[propName];
    }
  }

  // 处理 defaultProps
  if (type && type.defaultProps) {
    const defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }
	
	// 生成 React Element
  return ReactElement(
    type,
    key,
    ref,
    undefined,
    undefined,
    ReactCurrentOwner.current,
    props,
  );
}

function ReactElement(type, key, ref, self, source, owner, props) {
  const element = {
    // This tag allows us to uniquely identify this as a React Element
    $$typeof: REACT_ELEMENT_TYPE,

    // Built-in properties that belong on the element
    type,
    key,
    ref,
    props,

    // Record the component responsible for creating this element.
    _owner: owner,
  };

	// ...
	
  return element;
}

可以看到,jsx 的执行结果就是 React Element,而 React Element 就是包含了 jsx 标签的类型、属性、key、ref 的对象。

创建更新

接下来看看 updateContainer 源码

function updateContainer(element: ReactElement, container: FiberRoot) {
	// ...
	
	// current HostFiber
	const current = container.current;
	// 为当前 Fiber 节点申请一个更新优先级
  const lane = requestUpdateLane(current);
  
  // ... 处理 context
  
  // 以申请好的优先级创建一个 Update 更新对象
  const update = createUpdate(lane);
  update.payload = {element};
  
  // ...
  
  // 入更新队列、把当前优先级赋予 Fiber 、返回 FiberRoot
  const root = enqueueUpdate(current, update, lane);
  if (root !== null) {
    // 协调更新
    scheduleUpdateOnFiber(root, current, lane);
    
    // ...
  }

  return lane;
}

function createUpdate(lane: Lane): Update<mixed> {
  const update: Update<mixed> = {
    lane, // 优先级

    tag: UpdateState, // 更新的类型
    payload: null, // 更新的内容
    callback: null,

    next: null, // 链表指针
  };
  return update;
}

function scheduleUpdateOnFiber(root: FiberRoot, fiber: Fiber, lane: Lane) {
	// ...
	// 标志 FiberRoot 有个 pendingLane 的更新
  markRootUpdated(root, lane, eventTime);
  // ...
  
  // 每次有更新都会执行这个方法去调度
  ensureRootIsScheduled(root);
  // ...
}

可以看到,updateContainer 主要干了这些事情:

  1. 确定更新内容、优先级,创建一个更新 update,入更新队列等待被处理
  2. 将更新优先级赋予当前 Fiber、FiberRoot
  3. 调用 ensureRootIsScheduled 检测调度更新

调度更新

再来看 ensureRootIsScheduled 是怎么调度的:

// Use this function to schedule a task for a root. There's only one task per
// root; if a task was already scheduled, we'll check to make sure the priority
// of the existing task is the same as the priority of the next level that the
// root has work on. This function is called on every update, and right before
// exiting a task.
function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
  // callbackNode 存的是当前正在调度的 Schedule Task
  const existingCallbackNode = root.callbackNode;

  // Check if any lanes are being starved by other work. If so, mark them as
  // expired so we know to work on those next.
  // 为待处理的每个 Lane 计算过期时间,如果已有过期时间则判断有没有过期
  // 如果已过期就记录在 root.expiredLanes 里
  markStarvedLanesAsExpired(root, currentTime);

  // Determine the next lanes to work on, and their priority.
  // 确定要处理的下一批优先级
  // mount 时是 defaultLane
  const nextLanes = getNextLanes(
    root,
    root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
  );
  
  // 没有 Lanes 待处理
  if (nextLanes === NoLanes) {
    // Special case: There's nothing to work on.
    if (existingCallbackNode !== null) {
      cancelCallback(existingCallbackNode);
    }
    root.callbackNode = null;
    root.callbackPriority = NoLane;
    return;
  }

  // We use the highest priority lane to represent the priority of the callback.
  // 挑出待处理优先级里的最高优先级
  // (只有当 nextLanes 包含纠缠Lanes 或是 TransitionLanes 或 RetryLanes 时才会再次挑出不一样的 Lane)
  const newCallbackPriority = getHighestPriorityLane(nextLanes);

  // Check if there's an existing task. We may be able to reuse it.
  // callbackPriority 存的是正在调度任务的优先级
  const existingCallbackPriority = root.callbackPriority;
  // 优先级相同则无需重新调度,不同那么 priority 只会更高
  if (
    existingCallbackPriority === newCallbackPriority
  ) {
    // The priority hasn't changed. We can reuse the existing task. Exit.
    return;
  }

  // 有更高优先级任务,现有任务则取消
  if (existingCallbackNode != null) {
    // Cancel the existing callback. We'll schedule a new one below.
    cancelCallback(existingCallbackNode);
  }

  // Schedule a new callback.
  // 调度一个新的更新
  let newCallbackNode;
  // 同步执行优先级
  if (newCallbackPriority === SyncLane) {
    // ...
    // 让处理函数 performSyncWorkOnRoot 进入一个队列。
    // PS:performSyncWorkOnRoot 处理的内容与 performConcurrentWorkOnRoot 处理内容是一样的,
    // 只不过一个是同步执行,一个是并发执行。具体源码等讲完 performConcurrentWorkOnRoot 后再来看。
    // 目前可以理解 performSyncWorkOnRoot 处理的内容就是序列化生成新的完整的 Fiber 树,然后渲染到真实视图上。
    scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
    
    if (supportsMicrotasks) {
      // Flush the queue in a microtask.
      // ...
      // 调度一个微任务执行队列内所有回调
      scheduleMicrotask(() => {
        // In Safari, appending an iframe forces microtasks to run.
        // https://github.com/facebook/react/issues/22459
        // We don't support running callbacks in the middle of render
        // or commit so we need to check against that.
        if (
          (executionContext & (RenderContext | CommitContext)) ===
          NoContext
        ) {
          // Note that this would still prematurely flush the callbacks
          // if this happens outside render or commit phase (e.g. in an event).
          flushSyncCallbacks();
        }
      });
    } else {
      // Flush the queue in an Immediate task.
      scheduleCallback(ImmediateSchedulerPriority, flushSyncCallbacks);
    }
    newCallbackNode = null;
  } else {
    // Lane 优先级转 Scheduler 优先级
    let schedulerPriorityLevel;
    switch (lanesToEventPriority(nextLanes)) {
      case DiscreteEventPriority:
        schedulerPriorityLevel = ImmediateSchedulerPriority;
        break;
      case ContinuousEventPriority:
        schedulerPriorityLevel = UserBlockingSchedulerPriority;
        break;
      case DefaultEventPriority:
        // Mount 时走这里,中等优先级
        schedulerPriorityLevel = NormalSchedulerPriority;
        break;
      case IdleEventPriority:
        schedulerPriorityLevel = IdleSchedulerPriority;
        break;
      default:
        schedulerPriorityLevel = NormalSchedulerPriority;
        break;
    }
    // Scheduler 模块进行调度
    newCallbackNode = scheduleCallback(
      schedulerPriorityLevel,
      performConcurrentWorkOnRoot.bind(null, root),
    );
  }

  root.callbackPriority = newCallbackPriority; // 正在调度的优先级
  root.callbackNode = newCallbackNode; // Schedule Task
}

时间分片任务入口

Scheduler 调度原理的分析见React Scheduler,我们来看看调度的任务做了哪些事情,也就是 performConcurrentWorkOnRoot 里做了哪些事情:

// 每个时间分片任务的入口
function performConcurrentWorkOnRoot(root, didTimeout) { 
// ...

// 拿到正在调度的 Schedule Task
const originalCallbackNode = root.callbackNode;

// ...

// TODO: This was already computed in the caller. Pass it as an argument.
  let lanes = getNextLanes(
    root,
    root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
  );
  
  // 是否用时间分片去执行的标志位
  const shouldTimeSlice =
    !includesBlockingLane(root, lanes) && // 某些高优先级的 Lanes 禁用并发
    !includesExpiredLane(root, lanes) && // 过期 Lanes 禁用并发
    // didTimeout 是为了解决饥饿问题
    // 比如一个低优先级的 work 不断被打断,到过期时间之后,这个字段为 true,
    // 意味着我等待了太久时间,等不及并发执行了
    (disableSchedulerTimeoutInWorkLoop || !didTimeout);
  let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes) // 并发
    : renderRootSync(root, lanes); // 同步
  
  // 判断 root 的更新是否处理完了
  // 如果未处理完 exitStatus === RootInProgress
  // 如果处理完了或报异常了 exitStatus !== RootInProgress
  if (exitStatus !== RootInProgress) { 
	  // ... 异常情况处理
	  
	  // 更新处理完了
	  const finishedWork: Fiber = (root.current.alternate: any);
	  root.finishedWork = finishedWork;
    root.finishedLanes = lanes;
    finishConcurrentRender(root, exitStatus, lanes);
  }
  
  // 每执行完一个切片时间,继续开启调度,处理有没有被高优先级任务打断
  ensureRootIsScheduled(root, now());
  if (root.callbackNode === originalCallbackNode) {
    // The task node scheduled for this root is the same one that's
    // currently executed. Need to return a continuation.
    // 只是切片的时间到了,没有被高优先级任务打断,继续执行该 callback
    return performConcurrentWorkOnRoot.bind(null, root);
  }
  
  return null;
}

总结一下 performConcurrentWorkOnRoot  方法做的事情:

  1. 判断以何种方式去进行 Render 阶段处理
  2. 进入 Render 阶段处理(renderRootConcurrent | renderRootSync)
  3. 判断 Render 阶段处理结果
    1. → 若处理完了,调用 finishConcurrentRender 进入 commit 阶段,处理完 commit 阶段进入第 4 步
    2. → 若没处理完,直接进入第 4 步
  4. ensureRootIsScheduled 继续开启调度,处理有没有高优先级任务插队或是否无任务了。
    1. 若无任务,则退出执行
    2. 若被高优先级任务插队,中断当前任务触发的 Render 阶段执行,调度高优先级任务执行
    3. 若无高优先级任务插队,则继续调度处理当前任务触发的 Render 阶段

Render 阶段

接下来看看 Render 阶段到底处理了哪些事情,以 renderRootConcurrent 的源码来分析:

function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
  const prevExecutionContext = executionContext;
  executionContext |= RenderContext; // 标志已进入 Render 阶段

  // If the root or lanes have changed, throw out the existing stack
  // and prepare a fresh one. Otherwise we'll continue where we left off.
  // 如果 root 或 lanes 改变了,那么需要创建一个新的 WIP 树
  if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
    // ...
    prepareFreshStack(root, lanes);
  }

  do {
    try {
      // 开启一个 loop 持续构建 Fiber 树,直到时间分片时间不够
      workLoopConcurrent();
      break;
    } catch (thrownValue) {
      // ...
    }
  } while (true);

  // ... 
  // 标志 Render 阶段结束
  executionContext = prevExecutionContext;

  // 检查 WIP 是否处理完了.
  if (workInProgress !== null) {
    // ...
    // 代表 WIP 还未处理完,只是时间分片时间到了
    return RootInProgress;
  } else {
    // WIP 已处理完
    // ...
    // Set this to null to indicate there's no in-progress render.
    // 置空标志位
    workInProgressRoot = null;
    workInProgressRootRenderLanes = NoLanes;

    // Return the final exit status.
    return workInProgressRootExitStatus;
  }
}

先来看看是怎样生成一个 WIP 树的:

function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
  root.finishedWork = null;
  root.finishedLanes = NoLanes;

  if (workInProgress !== null) {
    // ...
  }


  workInProgressRoot = root;
  // 创建一个与 root.current 互相引用的 Fiber 节点 (rootWorkInProgress)
  const rootWorkInProgress = createWorkInProgress(root.current, null);
  // 标志位处理
  workInProgress = rootWorkInProgress;
  workInProgressRootRenderLanes = renderLanes = lanes;
  workInProgressIsSuspended = false;
  workInProgressThrownValue = null;
  workInProgressRootDidAttachPingListener = false;
  workInProgressRootExitStatus = RootInProgress;
  workInProgressRootFatalError = null;
  workInProgressRootSkippedLanes = NoLanes;
  workInProgressRootInterleavedUpdatedLanes = NoLanes;
  workInProgressRootRenderPhaseUpdatedLanes = NoLanes;
  workInProgressRootPingedLanes = NoLanes;
  workInProgressRootConcurrentErrors = null;
  workInProgressRootRecoverableErrors = null;

  // 会处理更新队列,处理所有入队的 Update,把相同 Fiber 的 Update 串联成链表,
  // 把每个 Fiber 的优先级层层冒泡给 parent.childLanes 直到处理到 HostFiber
  finishQueueingConcurrentUpdates();

  return rootWorkInProgress;
}

// This is used to create an alternate fiber to do work on.
export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
  let workInProgress = current.alternate;
  if (workInProgress === null) {
    // We use a double buffering pooling technique because we know that we'll
    // only ever need at most two versions of a tree. We pool the "other" unused
    // node that we're free to reuse. This is lazily created to avoid allocating
    // extra objects for things that are never updated. It also allow us to
    // reclaim the extra memory if needed.
    // 创建 fiber
    workInProgress = createFiber(
      current.tag,
      pendingProps,
      current.key,
      current.mode,
    );
    workInProgress.elementType = current.elementType;
    workInProgress.type = current.type;
    workInProgress.stateNode = current.stateNode;

    // 建立与 current 的相互引用
    workInProgress.alternate = current;
    current.alternate = workInProgress;
  } else {
    // ...
  }

  // Reset all effects except static ones.
  // Static effects are not specific to a render.
  workInProgress.flags = current.flags & StaticMask;
  workInProgress.childLanes = current.childLanes;
  workInProgress.lanes = current.lanes;

  workInProgress.child = current.child;
  workInProgress.memoizedProps = current.memoizedProps;
  workInProgress.memoizedState = current.memoizedState;
  workInProgress.updateQueue = current.updateQueue;

  // Clone the dependencies object. This is mutated during the render phase, so
  // it cannot be shared with the current fiber.
  const currentDependencies = current.dependencies;
  workInProgress.dependencies =
    currentDependencies === null
    ? null
    : {
      lanes: currentDependencies.lanes,
      firstContext: currentDependencies.firstContext,
    };

  // These will be overridden during the parent's reconciliation
  workInProgress.sibling = current.sibling;
  workInProgress.index = current.index;
  workInProgress.ref = current.ref;

  return workInProgress;
}

创建完 WIP 树后,此时以树形描述节点如下图:



接下来执行 workLoopConcurrent 开启一个 loop 持续构建 Fiber 树:

function workLoopConcurrent() {
  // ...

  // shouldYield 会在当前帧无空闲时间时停止遍历,直到有空闲时间后继续遍历。
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

performUnitOfWork 方法做的事情就是根据当前 wip 节点去创建下一个 Fiber 节点并赋值给 workInProgress,并将 workInProgress 与已创建的 Fiber 节点连接起来构成 Fiber 树:

function performUnitOfWork(unitOfWork: Fiber): void {
  const current = unitOfWork.alternate;
  // ...
  
  // 1. 创建(或复用) 子 Fiber 节点
  // 2. 串联进 Fiber 树
  // 3. 返回创建的子 Fiber 节点
  let next = beginWork(current, unitOfWork, renderLanes);

  // 把处理了的 pendingProps 保存在其 memoizedProps 里,
  // 之后有 update 触发,对比最新 pendingProps 和 memoizedProps,
  // 若没变化则代表 Props 没变,可对应做优化
  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    // 若当前 Fiber 节点是叶子节点 || Fiber 节点的子节点都被处理完 completeWork 后
    // 处理当前 Fiber 节点的 completeWork
    completeUnitOfWork(unitOfWork);
  } else {
    // 处理下一个 wip 节点
    workInProgress = next;
  }

}

可以看到,核心处理方法就是 beginWork 与 completeUnitOfWork,而且 Fiber 节点的处理顺序是符合 DFS 规律的。

beginWork

// 1. 创建(或复用) 子 Fiber 节点
// 2. 串联进 Fiber 树
// 3. 返回创建的子 Fiber 节点
function beginWork(
  current: Fiber | null, // mount 阶段,无 current fiber 树
  workInProgress: Fiber, // WIP 树
  renderLanes: Lanes,
): Fiber | null {
	// ... 处理一些判断能否复用现有 current Fiber 节点的情况
	
	// 根据 tag 不同,创建不同的子 Fiber 节点 
	switch (workInProgress.tag) {
    // 函数式组件 mount 时走这个 case
		case IndeterminateComponent: {
      return mountIndeterminateComponent(
        current,
        workInProgress,
        workInProgress.type,
        renderLanes,
      );
    }
    // HostFiber 走这个 case
    case HostRoot:
      return updateHostRoot(current, workInProgress, renderLanes);
    case ...
    ...
	}
}
function updateHostRoot(current, workInProgress, renderLanes) {
	// ...

  // 找出已处理过的 State、Children
  const nextProps = workInProgress.pendingProps;
  const prevState = workInProgress.memoizedState;
  const prevChildren = prevState.element;
  cloneUpdateQueue(current, workInProgress);
  // 处理当前 Fiber 的更新链表,计算最新 nextState,赋于 workInProgress.memoizedState
  processUpdateQueue(workInProgress, nextProps, null, renderLanes);
  // 找出最新处理的 State
  const nextState: RootState = workInProgress.memoizedState;
  const root: FiberRoot = workInProgress.stateNode;

  // ...

  // being called "element".
  // JSX 对象
  const nextChildren = nextState.element;

  // ... 
  
  if (nextChildren === prevChildren) {
    // 走复用 Fiber 节点流程
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }
  
  // 协调生成 nextChildren (React Element) 对应的 Fiber,并挂载到 WIP.child 下
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);

  // 此时 WIP.child 是新生成的 Fiber
  return workInProgress.child;
}

这里最关键的是调用 reconcileChildren 生成新的 Fiber 节点并挂在到 WIP.child 下以及把子节点返回出去继续处理。
无论是哪个 tag,几乎都会走这两个步骤,只不过在走这两步前会做一些不同 tag 特殊的逻辑,比如像 HostRoot、HostComponent 就是直接找出要处理的 JSX 对象,像函数式组件 tag 会额外执行 hook 和函数,像类组件 tag 就会实例化执行 render 方法等。
接下来看看 reconcileChildren 具体是怎么创建 Fiber 的

function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes,
) {
  if (current === null) {
    // If this is a fresh new component that hasn't been rendered yet, we
    // won't update its child set by applying minimal side-effects. Instead,
    // we will add them all to the child before it gets rendered. That means
    // we can optimize this reconciliation pass by not tracking side-effects.
    // 对于 mount 的组件 创建新 Fiber 节点挂载在 wip 树
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderLanes,
    );
  } else {
    // If the current child is the same as the work in progress, it means that
    // we haven't yet started any work on these children. Therefore, we use
    // the clone algorithm to create a copy of all the current children.

    // If we had any progressed work already, that is invalid at this point so
    // let's throw it out.
    // 如有 current 节点,则 diff current ,根据 diff 结果复用(生成)新节点
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderLanes,
    );
  }
}

mountChildFibers 和 reconcileChildFibers 其实本质上执行的都是同一个方法,区别在于是否需要跟踪副作用

export const reconcileChildFibers = ChildReconciler(true);
export const mountChildFibers = ChildReconciler(false);

function ChildReconciler(shouldTrackSideEffects) {
  // 省略一系列方法 ...

  function placeSingleChild(newFiber: Fiber): Fiber {
    // This is simpler for the single child case. We only need to do a
    // placement for inserting new children.
    if (shouldTrackSideEffects && newFiber.alternate === null) {
      newFiber.flags |= Placement | PlacementDEV;
    }
    return newFiber;
  }
  
  function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {
    if (!shouldTrackSideEffects) {
      // Noop.
      return;
    }
    // 挂载在父 Fiber 的 deletions 里,打上 ChildDeletion 标志
    const deletions = returnFiber.deletions;
    if (deletions === null) {
      returnFiber.deletions = [childToDelete];
      returnFiber.flags |= ChildDeletion;
    } else {
      deletions.push(childToDelete);
    }
  }

  function deleteRemainingChildren(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
  ): null {
    if (!shouldTrackSideEffects) {
      // Noop.
      return null;
    }

    let childToDelete = currentFirstChild;
    while (childToDelete !== null) {
      deleteChild(returnFiber, childToDelete);
      childToDelete = childToDelete.sibling;
    }
    return null;
  }
  
  function reconcileSingleElement(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    element: ReactElement,
    lanes: Lanes,
  ): Fiber {
    const key = element.key;
    let child = currentFirstChild;
    while (child !== null) {
      // key 相同
      if (child.key === key) {
        const elementType = element.type;
        if (
          // type 相同
          child.elementType === elementType
          // ...
        ) {
          // 标记 current child 的兄弟节点为待删除
          deleteRemainingChildren(returnFiber, child.sibling);
          // 复用当前 current child 节点
          const existing = useFiber(child, element.props);
          coerceRef(returnFiber, child, existing, element);
          existing.return = returnFiber;
          return existing;
        }

        // type 不同
        // current child 节点及其兄弟节点为待删除
        deleteRemainingChildren(returnFiber, child);
        break;
      } else {
        // 标记删除当前 child,继续尝试找出 key、type 相同的兄弟节点
        deleteChild(returnFiber, child);
      }
      child = child.sibling;
    }
    
    // 根据 element 创建新 Fiber 对象
    const created = createFiberFromElement(element, returnFiber.mode, lanes);
    coerceRef(returnFiber, currentFirstChild, created, element);
    created.return = returnFiber;
    return created;
  }

  
  function reconcileChildFibers(
    returnFiber: Fiber, // 父 Fiber 节点
    currentFirstChild: Fiber | null, // 当前第一个 Fiber 子节点
    newChild: any, // 新子节点 -- React Element
    lanes: Lanes,
  ): Fiber | null {
    // This function is not recursive.
    // If the top level item is an array, we treat it as a set of children,
    // not as a fragment. Nested arrays on the other hand will be treated as
    // fragment nodes. Recursion happens at the normal flow.

    // Handle top level unkeyed fragments as if they were arrays.
    // This leads to an ambiguity between <>{[...]}</> and <>...</>.
    // We treat the ambiguous cases above the same.
    const isUnkeyedTopLevelFragment =
      typeof newChild === 'object' &&
      newChild !== null &&
      newChild.type === REACT_FRAGMENT_TYPE &&
      newChild.key === null;
    if (isUnkeyedTopLevelFragment) {
      newChild = newChild.props.children;
    }

    // Handle object types
    if (typeof newChild === 'object' && newChild !== null) {
      // 单点 Diff
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE:
          return placeSingleChild(
            // reconcileSingleElement 为新 element 创建对应 Fiber,并串联进 wip
            reconcileSingleElement(
              returnFiber,
              currentFirstChild,
              newChild,
              lanes,
            ),
          );
        //...
      }

      // 多点 Diff
      if (isArray(newChild)) {
        return reconcileChildrenArray(
          returnFiber,
          currentFirstChild,
          newChild,
          lanes,
        );
      }

      // ...
    }

    // 处理文本节点
    if (
      (typeof newChild === 'string' && newChild !== '') ||
      typeof newChild === 'number'
    ) {
      return placeSingleChild(
        reconcileSingleTextNode(
          returnFiber,
          currentFirstChild,
          '' + newChild,
          lanes,
        ),
      );
    }

    // Remaining cases are all treated as empty.
    return deleteRemainingChildren(returnFiber, currentFirstChild);
  }

  return reconcileChildFibers;
}

看下怎么根据 React Element 生成 Fiber 对象的:

export function createFiberFromElement(
  element: ReactElement,
  mode: TypeOfMode,
  lanes: Lanes,
): Fiber {
  const type = element.type;
  const key = element.key;
  const pendingProps = element.props;
  const fiber = createFiberFromTypeAndProps(
    type,
    key,
    pendingProps,
    owner,
    mode,
    lanes,
  );

  return fiber;
}

export function createFiberFromTypeAndProps(
  type: any, // React$ElementType
  key: null | string,
  pendingProps: any,
  owner: null | Fiber,
  mode: TypeOfMode,
  lanes: Lanes,
): Fiber {
  let fiberTag = FunctionComponent;
  let resolvedType = type;
  // ... 根据不同 type 计算各种情况下合适的 fiberTag、resolvedType

  const fiber = createFiber(fiberTag, pendingProps, key, mode);
  fiber.elementType = type;
  fiber.type = resolvedType;
  fiber.lanes = lanes;

  return fiber;
}

总结来说,reconcile children 这个协调过程就是根据 React Element 创建或复用 child fiber 的过程,如果存在 current child 节点,那么可能还会给 child fiber 打上一些 effect flag ,用于 commit 阶段做一些额外的处理比如插入或删除 DOM 节点。
我们上面讲过,workLoopConcurrent 会以 DFS 的方式处理 Fiber 节点,那么直到遇到叶子结点之前都只会进行 beginWork 处理,举以下例子:

// main.tsx
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'

ReactDOM.createRoot(document.getElementById('root')!).render(
    <App />
)
// App.tsx
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'

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

  return (
    <>
      <div>
        <a href="https://vitejs.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>Vite + React</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
    </>
  )
}

export default App

beginWork 会处理 Fiber 直到第一次遇到 img 标签,此时 Fiber 树如下图:

img 标签是叶子结点,按上述 performUnitOfWork 的逻辑,要对其执行 completeUnitOfWork 方法了。

completeWork

completeUnitOfWork 的作用就是执行当前 Fiber 的 completeWork,协调下一个 Fiber 节点的处理,分几种情况:

  1. 当前 Fiber 的 completeWork 催生了新节点,协调走新节点的 beginWork
  2. 没催生新节点,但当前 Fiber 节点有兄弟节点,协调走兄弟节点的 beginWork
  3. 没催生新节点,也没兄弟节点或兄弟节点都已处理完 completeWork,协调走父节点的 completeWork

当协调处理完所有 Fiber 节点的 completeWork 后,就完成 Render 阶段的处理了,置标志位 workInProgressRootExitStatus 为 RootCompleted。

function completeUnitOfWork(unitOfWork: Fiber): void {
  // Attempt to complete the current unit of work, then move to the next
  // sibling. If there are no more siblings, return to the parent fiber.
  let completedWork: Fiber = unitOfWork;
  do {

    const current = completedWork.alternate;
    const returnFiber = completedWork.return;

    // 处理当前节点的 completeWork 工作
    let next = completeWork(current, completedWork, renderLanes);

    // 处理当前节点的 completeWork 催生了新 work
    // (只有 Suspense API 才会走到该逻辑)
    if (next !== null) {
      workInProgress = next;
      return;
    }

    // 处理兄弟节点的 beginWork
    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {
      workInProgress = siblingFiber;
      return;
    }
    
    // 当前节点及其兄弟节点都完成 completeWork 处理
    // 以 DFS 的规律回溯处理父节点的 completeWork
    completedWork = returnFiber;
    workInProgress = completedWork;

    // 直到处理到 HostFiber
  } while (completedWork !== null);

  // We've reached the root.
  // 完成 Render 阶段处理
  if (workInProgressRootExitStatus === RootInProgress) {
    workInProgressRootExitStatus = RootCompleted;
  }
}

下面来看 completeWork 的内部处理

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  const newProps = workInProgress.pendingProps;
 
  switch (workInProgress.tag) {
    // 省略一堆 tag 处理
    case IncompleteFunctionComponent: 
    case LazyComponent:
    case SimpleMemoComponent:
    case FunctionComponent:
    case ForwardRef:
    case Fragment:
    case Mode:
    case Profiler:
    case ContextConsumer:
    case MemoComponent:
      // 冒泡属性
      bubbleProperties(workInProgress);
      return null;
    case ClassComponent: {
      const Component = workInProgress.type;
      if (isLegacyContextProvider(Component)) {
        popLegacyContext(workInProgress);
      }
      bubbleProperties(workInProgress);
      return null;
    }
    case HostRoot: {
      // ...
      bubbleProperties(workInProgress);
      return null;
    }
    case HostComponent: {
      // ...
      const type = workInProgress.type;
      if (current !== null && workInProgress.stateNode != null) {
        // update 
        // 如果 prop 也没变化,则不做任何事,纯复用现成 DOM
        // 否则 workInProgress.flags |= Update;
        updateHostComponent(
          current,
          workInProgress,
          type,
          newProps,
          renderLanes,
        );
      } else {
        // mount
        const rootContainerInstance = getRootHostContainer();
        // 创建 Fiber 对应的 DOM 节点
        const instance = createInstance(
          type,
          newProps,
          rootContainerInstance,
          currentHostContext,
          workInProgress,
        );
        // 将所有子 fiber 的 DOM 节点挂载到当前 Fiber 刚创建的 DOM 节点下
        // (体会 DFS 自下而上处理的妙处)
        appendAllChildren(instance, workInProgress, false, false);
        // 保存一份引用
        workInProgress.stateNode = instance;

        if (
          // 挂载 props 到创建的 DOM 上
          finalizeInitialChildren(
            instance,
            type,
            newProps,
            currentHostContext,
          )
        ) {
          // 有些 prop 比如自动聚焦不适合在离屏 DOM 里设置,标记 Update 到 commit 阶段挂载时再处理。
          markUpdate(workInProgress);
        }
        
      }
      // 冒泡属性
      bubbleProperties(workInProgress);

      // ... preloadInstanceAndSuspendIfNeeded
      
      return null;
    }
  }

  throw new Error(
    `Unknown unit of work tag (${workInProgress.tag}). This error is likely caused by a bug in ` +
      'React. Please file an issue.',
  );
}

与 beginWork 一样,completeWork 会根据 tag 不同做一些不同的处理逻辑,比如对于宿主标签就会初始化对应的 DOM 并串联起子 DOM。此外,所有 tag 也会有一个共性的处理就是 bubbleProperties 属性冒泡,冒泡的是什么属性呢? 所有子 Fiber 的优先级以及副作用处理 effect flag。分别 merge 到 fiber 的 childLanes 和 subtreeFlags 上,好处是之后(commit 阶段)不用遍历整颗 Fiber 树就能知道要处理哪些 lanes 与 effect。

function bubbleProperties(completedWork: Fiber) {
  let newChildLanes = NoLanes;
  let subtreeFlags = NoFlags;

  let child = completedWork.child;
  while (child !== null) {
    // 冒泡 lanes
    newChildLanes = mergeLanes(
      newChildLanes,
      mergeLanes(child.lanes, child.childLanes),
    );

    // 冒泡 flags
    subtreeFlags |= child.subtreeFlags;
    subtreeFlags |= child.flags;

    child = child.sibling;
  }

  completedWork.subtreeFlags |= subtreeFlags;
  completedWork.childLanes = newChildLanes;
}

到此为止,Render 阶段的核心代码我们就过完了,接下来就是 performConcurrentWorkOnRoot 里讲过的,要进入 commit 流程了:

//...
function performConcurrentWorkOnRoot(root, didTimeout) {
  // ...
  
  let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes) // 并发
    : renderRootSync(root, lanes); // 同步
  
  // 判断 root 的更新是否处理完了
  // 如果未处理完 exitStatus === RootInProgress
  // 如果处理完了或报异常了 exitStatus !== RootInProgress
  if (exitStatus !== RootInProgress) { 
	  // ... 异常情况处理
	  
	  // 更新处理完了
	  const finishedWork: Fiber = (root.current.alternate: any);
	  root.finishedWork = finishedWork;
    root.finishedLanes = lanes;
    finishConcurrentRender(root, exitStatus, lanes);
  }  
  
  // ...
}

Commit 阶段

function finishConcurrentRender(
  root: FiberRoot,
  exitStatus: RootExitStatus,
  finishedWork: Fiber,
  lanes: Lanes,
) {
  switch (exitStatus) {
    // ... 省略特殊 case 处理
    case RootCompleted: {
      // The work completed. Ready to commit.
      commitRoot(
        root,
        workInProgressRootRecoverableErrors,
        workInProgressTransitions,
      );
      break;
    }
    default: {
      throw new Error('Unknown root exit status.');
    }
  }
}
function commitRoot(
  root: FiberRoot,
  recoverableErrors: null | Array<CapturedValue<mixed>>,
  transitions: Array<Transition> | null,
) {
   do {
    // 处理上次渲染未来得及执行的所有 useEffect 回调与其他同步任务。
    flushPassiveEffects();
  } while (rootWithPendingPassiveEffects !== null);

  const finishedWork = root.finishedWork;
  const lanes = root.finishedLanes;

  // 解除标志位引用
  root.finishedWork = null;
  root.finishedLanes = NoLanes;

  root.callbackNode = null;
  root.callbackPriority = NoLane;

  // Check which lanes no longer have any work scheduled on them, and mark
  // those as finished.
  let remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes);

  // Make sure to account for lanes that were updated by a concurrent event
  // during the render phase; don't mark them as finished.
  const concurrentlyUpdatedLanes = getConcurrentlyUpdatedLanes();
  remainingLanes = mergeLanes(remainingLanes, concurrentlyUpdatedLanes);

  // 从 root.pendingLanes 里去掉本次 Render 阶段已处理完的 lanes
  markRootFinished(root, remainingLanes);

  // 解除标志位引用
  workInProgressRoot = null;
  workInProgress = null;
  workInProgressRootRenderLanes = NoLanes;

  // 异步调度 useEffect
  if (
      (finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
      (finishedWork.flags & PassiveMask) !== NoFlags
    ) {
      if (!rootDoesHavePassiveEffects) {
        rootDoesHavePassiveEffects = true;
        pendingPassiveEffectsRemainingLanes = remainingLanes;
        pendingPassiveTransitions = transitions;
        
        scheduleCallback(NormalSchedulerPriority, () => {
          // 处理 useEffect 回调
          flushPassiveEffects();
          return null;
        });
      }
  }

  // 整颗 Fiber 树是否有待处理的副作用
  const subtreeHasEffects =
    (finishedWork.subtreeFlags &
      (BeforeMutationMask | MutationMask | LayoutMask | PassiveMask)) !==
    NoFlags;
  const rootHasEffect =
    (finishedWork.flags &
      (BeforeMutationMask | MutationMask | LayoutMask | PassiveMask)) !==
    NoFlags;

  // 处理副作用
  if (subtreeHasEffects || rootHasEffect) {
    
    // ...
    
    // 标志 Commit 阶段开始
    const prevExecutionContext = executionContext;
    executionContext |= CommitContext;

    // The first phase a "before mutation" phase. We use this phase to read the
    // state of the host tree right before we mutate it. This is where
    // getSnapshotBeforeUpdate is called.
    // 1. 处理 DOM 节点渲染/删除后的 autoFocus、blur 逻辑
    // 2. 触发 getSnapshotBeforeUpdate 调用的地方
    commitBeforeMutationEffects(
      root,
      finishedWork,
    );

    // The next phase is the mutation phase, where we mutate the host tree.
    // 根据 flags 操作真实 DOM(增删改)
    // (还会处理 useIntersectionEffect、ref 等)
    commitMutationEffects(root, finishedWork, lanes);

    // 切换 current Fiber 树
    root.current = finishedWork;

    // The next phase is the layout phase, where we call effects that read
    // the host tree after it's been mutated. The idiomatic use case for this is
    // layout, but class component lifecycles also fire here for legacy reasons.
    // 此时真实 DOM 已被修改,js 可以获取到更新后的 DOM 节点了
    // 此时主要处理的 effect 就是 useLayoutEffect 、各种 callback 如 setState 的第二个参数的调用
    commitLayoutEffects(finishedWork, root, lanes);

    // 标志 Commit 阶段结束
    executionContext = prevExecutionContext;

    // ...
  } else {
    // No effects.
    root.current = finishedWork;
  }

  
  const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;
  // 保存要处理的 passive effect 引用,异步调用时会访问这些引用
  if (rootDoesHavePassiveEffects) {
    // This commit has passive effects. Stash a reference to them. But don't
    // schedule a callback until after flushing layout work.
    rootDoesHavePassiveEffects = false;
    rootWithPendingPassiveEffects = root;
    pendingPassiveEffectsLanes = lanes;
  }

  // Always call this before exiting `commitRoot`, to ensure that any
  // additional work on this root is scheduled.
  // 可能 commit 阶段还会产生一些更新 或者 需启动更低优先级更新的处理,需再次调度 root
  ensureRootIsScheduled(root, now());

  
  // ... 

  // 执行同步任务,这样同步任务不需要等到下次事件循环再执行
  // 比如在 componentDidMount 中执行 setState 创建的更新会在这里被同步执行
  flushSyncWorkOnAllRoots();
    
  return null;
}

总结来说,commit 阶段主要做的事情如下:

  1. 协调 useEffect 的处理
  2. 移除本次更新中已处理的 Lanes
  3. 根据 effect flags 操作真实 DOM(增删改)以及一些生命周期和 API 的处理
  4. 切换 current 树为 wip 树,解除 wip 树引用
  5. 调度 root 更新

处理副作用的三个阶段

我们在代码注释里也看到了,React 是通过三个 phase 去处理副作用的,分别是 before mutation、mutation、layout。这三个阶段都会从 HostFiber 开始深度优先遍历地去找对应 effect flag 的 Fiber 节点,自下而上地去处理。
其中 before mutation 阶段做的事情主要就是:

  1. 处理 DOM 节点渲染/删除后的 autoFocus、blur 逻辑
  2. 触发 getSnapshotBeforeUpdate 调用

mutation 阶段做的事情主要是根据 effect flag 操作真实 DOM(增删改),也会处理 useIntersectionEffect、ref 等 API。
layout 阶段做的事情主要就是处理 useLayoutEffect、各种修改 DOM 后的 callback 的调用(如 setState 的第二个参数、render 第三个参数)
这三个阶段源码里处理的情况很多,这里不一一介绍,下面以首次渲染在 mutation 阶段里的处理带大家过一下源码,看看 DOM 是怎么挂载到容器的。

首次渲染的 DOM 是怎么挂载到容器的

// 在 mutation 阶段操作真实 DOM
commitMutationEffects(root, finishedWork, lanes);


export function commitMutationEffects(
  root: FiberRoot,
  finishedWork: Fiber,
  committedLanes: Lanes,
) {
  // 标志位处理
  inProgressLanes = committedLanes;
  inProgressRoot = root;
  setCurrentDebugFiberInDEV(finishedWork);
  // 处理实体
  commitMutationEffectsOnFiber(finishedWork, root, committedLanes);
  
  setCurrentDebugFiberInDEV(finishedWork);
  inProgressLanes = null;
  inProgressRoot = null;
}


function commitMutationEffectsOnFiber(
  finishedWork: Fiber,
  root: FiberRoot,
  lanes: Lanes,
) {
  const current = finishedWork.alternate;
  const flags = finishedWork.flags;
    
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case MemoComponent:
    case SimpleMemoComponent: {
        recursivelyTraverseMutationEffects(root, finishedWork, lanes);
        commitReconciliationEffects(finishedWork);
    
        if (flags & Update) {
          // ... 处理 useInsertEffect
        }
        return;
    }
    case HostRoot: {
        recursivelyTraverseMutationEffects(root, finishedWork, lanes);
        commitReconciliationEffects(finishedWork);
      
        if (flags & Update) {
          // ... SSR 相关的处理
        }
      return;
    }
    // ... 一系列 case 处理
    default: {
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      commitReconciliationEffects(finishedWork);
      return;
    }
 }
}

我们可以看到,几乎所有 tag 都要做的两个处理就是 recursivelyTraverseMutationEffects 与 commitReconciliationEffects。
先来看看 recursivelyTraverseMutationEffects 做了啥:

function recursivelyTraverseMutationEffects(
  root: FiberRoot,
  parentFiber: Fiber,
  lanes: Lanes,
) {
  // Deletions effects can be scheduled on any fiber type. They need to happen
  // before the children effects hae fired.
  // 增改前先递归删,免得操作到被删除 DOM 了
  const deletions = parentFiber.deletions;
  if (deletions !== null) {
    for (let i = 0; i < deletions.length; i++) {
      const childToDelete = deletions[i];
      try {
        // 删除 DOM 节点
        commitDeletionEffects(root, parentFiber, childToDelete);
      } catch (error) {
        captureCommitPhaseError(childToDelete, parentFiber, error);
      }
    }
  }
  
  if (parentFiber.subtreeFlags & MutationMask) {
    let child = parentFiber.child;
    while (child !== null) {
      // DFS
      commitMutationEffectsOnFiber(child, root, lanes);
      child = child.sibling;
    }
  }
}

直到找到叶子节点或包含 flag 的当前节点后,就会执行 commitReconciliationEffects(当前节点) 继续处理:

function commitReconciliationEffects(finishedWork: Fiber) {
  // Placement effects (insertions, reorders) can be scheduled on any fiber
  // type. They needs to happen after the children effects have fired, but
  // before the effects on this fiber have fired.
  const flags = finishedWork.flags;
  // 我们在创建 wip HostFiber 的 beginWork 时,走的是 reconcileChildFibers 逻辑,
  // 会给创建的 child fiber 打上 Placement flag
  if (flags & Placement) {
    try {
      commitPlacement(finishedWork);
    } catch (error) {
      captureCommitPhaseError(finishedWork, finishedWork.return, error);
    }
    // 去除已处理的 flag
    finishedWork.flags &= ~Placement;
  }

  // ssr
  if (flags & Hydrating) {
    finishedWork.flags &= ~Hydrating;
  }
}

在 commitPlacement 里,我们就会把创建好的离屏 DOM 直接插入到容器下:

function commitPlacement(finishedWork: Fiber): void {
  // 找到当前 Fiber 的父容器 Fiber(容器 Fiber 是指有指针指向真实 DOM 的 Fiber)
  // 此处是 HostFiber
  const parentFiber = getHostParentFiber(finishedWork);

  switch (parentFiber.tag) {
      case HostSingleton: {
        // ...
      }
      case HostComponent: {
        const parent: Instance = parentFiber.stateNode;
        if (parentFiber.flags & ContentReset) {
          // 有些标签需要在插入之前清空文本
          resetTextContent(parent);
          parentFiber.flags &= ~ContentReset;
        }
        
        const before = getHostSibling(finishedWork);
        // We only have the top Fiber that was inserted but we need to recurse down its
        // children to find all the terminal nodes.
        insertOrAppendPlacementNode(finishedWork, before, parent);
        break;
      }
      case HostRoot:
      case HostPortal: {
        // 容器真实 DOM
        const parent: Container = parentFiber.stateNode.containerInfo;
        // 固定各 renderer 提供 insertBefore 的插入 DOM 方法,web 仅支持 insertBefore API 
        // 所以需求是插入节点的话那么需要找到被插入的兄弟节点
        // 如果没兄弟节点,就是 append 添加
        const before = getHostSibling(finishedWork);
        // 执行插入或添加操作
        insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
        break;
      }
      default:
        throw new Error(
          'Invalid host parent fiber. This error is likely caused by a bug ' +
            'in React. Please file an issue.',
        );
  }
}

// 执行插入或添加操作
function insertOrAppendPlacementNodeIntoContainer(
  node: Fiber, 
  before: ?Instance,
  parent: Container,
): void {
  const {tag} = node;
  const isHost = tag === HostComponent || tag === HostText;
  if (isHost) {
    const stateNode = node.stateNode;
    if (before) {
      // renderer API 插入真实 DOM
      insertInContainerBefore(parent, stateNode, before);
    } else {
      // renderer API 添加真实 DOM
      appendChildToContainer(parent, stateNode);
    }
  } else if (
    tag === HostPortal ||
    (supportsSingletons ? tag === HostSingleton : false)
  ) {
    // ...
  } else {
    // 如果是非 DOM 节点,就往下找最近的 DOM 节点
    const child = node.child;
    if (child !== null) {
      insertOrAppendPlacementNodeIntoContainer(child, before, parent);
      let sibling = child.sibling;
      while (sibling !== null) {
        insertOrAppendPlacementNodeIntoContainer(sibling, before, parent);
        sibling = sibling.sibling;
      }
    }
  }
}

至此,页面上就有渲染好的真实 DOM 了(由于有些宿主标签的限制,会在下一帧才会绘制完整视图,但此时 js 是能拿到该视图对应的真实 DOM 的)。

深入js之深浅拷贝


照例思维导图。

赋值与深浅拷贝之间的区别:

细说赋值与浅拷贝的区别

当我们把一个对象赋值给一个新的变量时,赋的其实是该对象的在栈中的地址,而不是堆中的数据。也就是两个对象指向的是同一个存储空间,无论哪个对象发生改变,其实都是改变的存储空间的内容,因此,两个对象是联动的。

浅拷贝是按位拷贝对象,它会创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值;如果属性是内存地址(引用类型),拷贝的就是内存地址 ,因此如果其中一个对象改变了这个地址,就会影响到另一个对象。即默认拷贝构造函数只是对对象进行浅拷贝复制(逐个成员依次拷贝),即只复制对象空间而不复制资源。

我们先来看两个例子,对比赋值与浅拷贝会对原对象带来哪些改变?

// 对象赋值
var obj1 = {
  name: "zhangsan",
  age: "18",
  language: [1, [2, 3], [4, 5]]
};
var obj2 = obj1;
obj2.name = "lisi";
obj2.language[1] = ["二", "三"];
console.log("obj1", obj1);
console.log("obj2", obj2);

// 浅拷贝
var obj1 = {
  name: "zhangsan",
  age: "18",
  language: [1, [2, 3], [4, 5]]
};
var obj3 = shallowCopy(obj1);
obj3.name = "lisi";
obj3.language[1] = ["二", "三"];
function shallowCopy(src) {
  var dst = {};
  for (var prop in src) {
    if (src.hasOwnProperty(prop)) {
      dst[prop] = src[prop];
    }
  }
  return dst;
}
console.log("obj1", obj1);
console.log("obj3", obj3);

上面例子中,obj1 是原始数据,obj2 是赋值操作得到,而 obj3 浅拷贝得到。我们可以很清晰看到对原始数据的影响。

浅拷贝的实现方式

1. Object.assign()

Object.assign() 方法可以把任意多个的源对象自身的可枚举属性拷贝给目标对象,然后返回目标对象。但是 Object.assign() 进行的是浅拷贝,拷贝的是对象的属性的引用,而不是对象本身。

var obj = { a: { a: "kobe", b: 39 } };
var initalObj = Object.assign({}, obj);
initalObj.a.a = "wade";
console.log(obj.a.a); //wade

注意:当 object 只有一层的时候,是深拷贝。

let obj = {
  username: "kobe"
};
let obj2 = Object.assign({}, obj);
obj2.username = "wade";
console.log(obj); //{username: "kobe"}

2. Array.prototype.slice()

let arr = [
  1,
  3,
  {
    username: " kobe"
  }
];
let arr3 = arr.slice();
arr3[2].username = "wade";
console.log(arr);

修改新对象会改到原对象:

3. Array.prototype.concat()

let arr = [
  1,
  3,
  {
    username: "kobe"
  }
];
let arr2 = arr.concat();
arr2[2].username = "wade";
console.log(arr);

同样修改新对象会改到原对象:

关于 Array 的 slice 和 concat 方法的补充说明:Array 的 slice 和 concat 方法不修改原数组,只会返回一个浅复制了原数组中的元素的一个新数组。

4. 扩展运算符

let a = {
    name: "sadhu",
    book: {
        title: "You Don't Know JS",
        price: "45"
    }
}
let b = {...a};
console.log(b);
// {
// 	name: "sadhu",
// 	book: {title: "You Don't Know JS", price: "45"}
// } 

a.name = "change";
a.book.price = "55";
console.log(a);
// {
// 	name: "change",
// 	book: {title: "You Don't Know JS", price: "55"}
// } 

console.log(b);
// {
// 	name: "sadhu",
// 	book: {title: "You Don't Know JS", price: "55"}
// } 

手写一个简易浅拷贝

遍历对象,然后把属性和属性值都放在一个新的对象就好了。

var shallowCopy = function(obj) {
    // 只拷贝对象
    if (typeof obj !== 'object') return;
    // 根据obj的类型判断是新建一个数组还是对象
    var newObj = obj instanceof Array ? [] : {};
    // 遍历obj,并且判断是obj的属性才拷贝
    for (var key in obj) {
        if (obj.hasOwnProperty(key)) {
            newObj[key] = obj[key];
        }
    }
    return newObj;
}

关于思维导图中深拷贝的一点补充

就补充一个关于JSON.parse(JSON.stringify())例子:

let obj = {
  reg: /^asd\$/,
  fun: function() {},
  und: undefined,
  syb: Symbol("foo"),
  asd: "asd"
};
let cp = JSON.parse(JSON.stringify(obj));
console.log(cp);

可以看到,函数、正则、Symbol、undefined 都没有被正确的复制。

手写一个简易深拷贝

我们在浅拷贝的基础上判断一下属性值的类型,如果是对象,我们递归调用深拷贝函数:

var deepCopy = function(obj) {
    if (typeof obj !== 'object') return;
    var newObj = obj instanceof Array ? [] : {};
    for (var key in obj) {
        if (obj.hasOwnProperty(key)) {
            newObj[key] = typeof obj[key] === 'object' ? deepCopy(obj[key]) : obj[key];
        }
    }
    return newObj;
}

这只是简易,针对普通应用场景。有问题比如typeof判断返回的是function,这种函数的复制是一个很难解决的问题呀,即使是 jQuery 的 extend 也没有去处理函数。关于null和循环引用也没有考虑,需要用到时可以去看看我后面贴出的参考文章里的代码。

性能问题

尽管使用深拷贝会完全的克隆一个新对象,不会产生副作用,但是深拷贝因为使用递归,性能会不如浅拷贝,在开发中,还是要根据实际情况进行选择。

本文完。

希望看到各位技术人对这篇文章有不同的有“证据”,符合逻辑的分析、看法~

文章会第一时间更新在GitHub,觉得写得还不错的,可以点个star支持下作者🍪


参考:

  1. JavaScript深入之深拷贝与浅拷贝
  2. 详细解析赋值、浅拷贝和深拷贝的区别
  3. JavaScript专题之深浅拷贝

深入js之函数与函数式编程


照例思维导图。

提一下函数参数传递方式

总的来说,按值传递。

var param = 1

function foo(a) {
    console.log(a); // 1
    a = 2;
    console.log(a); // 2
}

foo(param);

console.log(param); // 1

这很好理解,当传递给函数参数的值是基本类型值的时候。直接把变量的值复制给函数参数,且变量与参数互不影响。

对于传递引用类型值的时候,其实是共享传递,什么是共享传递?共享传递就是传递对象的引用的副本

因为拷贝副本也是一种值的拷贝,所以在高程中也直接认为是按值传递了。

你可以理解为:参数如果是基本类型是按值传递,如果是引用类型按共享传递。

举个例子:

var obj = {
    value: 1
};
function foo(o) {
    o = 2;
    console.log(o); //2
}
foo(obj);
console.log(obj.value) // 1

传递给o的是obj引用的副本。当改变o的值的时候,对obj本身并没有影响。

函数式编程

函数式编程是一种被部分JavaScript程序员推崇的编程风格,更别说 Haskell 和 Scala 这种以函数式为教义的语言。原因是因为其能用较短的代码实现功能,如果掌握得当,能达到代码文档化(代码本身具有很高可读性甚至可以代替文档)的效果,当然,泛滥使用也会使代码可读性变差。

函数式编程以函数作为主要载体的编程方式,用函数去拆解、抽象一般的表达式。其思维建议我们把多次出现的功能封装在一个函数里,可以重复调用。

出道题:

现在有一个数组,array = [1, 3, 'h', 5, 'm', '4'],现在想要找出这个数组中的所有类型为number的子项。你会怎么做?

可能大多数人就这样写,直来直去简单明了(命令式编程):

var array = [1, 3, 'h', 5, 'm', '4'];
var res = [];
for(var i=0; i<array.length; i++) {
    if (typeof array[i] === 'number') {
        res.push(array[i]);
    }
}
console.log(res);

这样是可以实现功能,但这样写的坏处是什么呢?假如之后我们要为另一个数组找子项,那又要写重复的逻辑。当出现次数多了,代码就会变得糟糕和难以维护。运用函数式编程的思维来写的话,就可以把相同的功能封装起来。

function getNumbers(arr) {
    var res = [];
    arr.forEach((item) => {
        if (typeof item === 'number') {
            res.push(item);
        }
    })
    return res;
}

当我们将功能封装之后,我们实现同样的功能时,只需要写一行代码。而如果未来需求变动,或者稍作修改,我们只需要对getNumbers方法进行调整就可以了。而且我们在使用时,只需要关心这个方法能做什么,而不用关心他具体是怎么实现的。

函数是一等公民

所谓"第一等公民"(first class),指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。这些场景,我们应该见过很多。

只用"表达式",不用"语句"

"表达式"(expression)是一个单纯的运算过程,总是有返回值(函数式编程期望一个函数有输入,也有输出。);"语句"(statement)是执行某种操作,没有返回值。函数式编程要求,只使用表达式,不使用语句。也就是说,每一步都是单纯的运算,而且都有返回值。

比如下面这段代码:

var ele = document.querySelector('.test');
function setBackgroundColor(color) {
    ele.style.backgroundColor = color; // 这里面封装的仅仅只是一个语句,没有返回值。
}

// 多处使用
setBackgroundColor('red');
setBackgroundColor('#ccc');

按照函数式编程的习惯,应该改为如下:

function setBackgroundColor(ele, color) {
    ele.style.backgroundColor = color;
    return color;
}

// 多处使用
var ele = document.querySelector('.test');
setBackgroundColor(ele, 'red');
setBackgroundColor(ele, '#ccc');

纯函数

纯函数即不产生“副作用”的函数。相同的输入总会得到相同的输出。

怎么理解?可以理解为不要改变输入进去的原对象(可以返回新对象也好)。

举个例子:我们期望封装一个函数,能够得到传入数组的最后一项。那么可以通过下面两种方式来实现。

function getLast(arr) {
    return arr[arr.length - 1];
}
var arr = [1, 2, 3];
getLast(arr);
console.log(arr); // [1,2,3] 这种方式没改变原数组,该函数是纯函数
function getLast_(arr) {
    return arr.pop();
}
var arr = [1, 2, 3];
getLast_(arr); // [1,2] 改变了原数组。

getLast与getLast_虽然同样能够获得数组的最后一项值,但是getLast_改变了原数组。而当原始数组被改变,那么当我们再次调用该方法时,得到的结果就会变得不一样。这样不可预测的封装方式,在我们看来是非常糟糕的。它会把我们的数据搞得非常混乱。在JavaScript原生支持的数据方法中,也有许多不纯的方法,我们在使用时需要非常警惕,我们要清晰的知道原始数据的改变是否会留下隐患。

var source = [1, 2, 3, 4, 5];

source.slice(1, 3); // 纯函数 返回[2, 3] source不变
source.splice(1, 3); // 不纯的 返回[2, 3, 4] source被改变

source.pop(); // 不纯的
source.push(6); // 不纯的
source.shift(); // 不纯的
source.unshift(1); // 不纯的
source.reverse(); // 不纯的

// 我也不能短时间知道现在source被改变成了什么样子,干脆重新约定一下
source = [1, 2, 3, 4, 5];

source.concat([6, 7]); // 纯函数 返回[1, 2, 3, 4, 5, 6, 7] source不变
source.join('-'); // 纯函数 返回1-2-3-4-5 source不变

诸如闭包、函数柯里化也是函数式编程的风格。

一句话总结

如果用一句话总结的话,函数式编程是以函数作为主要载体的编程方式。

优点:

  • 语义更加清晰
  • 可复用性更高
  • 可维护性更好
  • 作用域局限,副作用少

本文完。

希望看到各位技术人对这篇文章有不同的有“证据”,符合逻辑的分析、看法~

文章会第一时间更新在GitHub,觉得写得还不错的,可以点个star支持下作者🍪


参考:

  1. 前端基础进阶(七):函数与函数式编程
  2. 我眼中的 JavaScript 函数式编程

深入js之造call.apply轮子

引用MDN关于此方法的一些描述:

  1. 语法:
    fun.call(thisArg, arg1, arg2, ...)。

    参数含义:

    thisArg: 在fun函数运行时指定的this值。需要注意的是,指定的this值并不一定是该函数执行时真正的this值,如果这个函数处于non-strict mode(非严格模式),则指定为nullundefined的this值会自动指向全局对象(浏览器中就是window对象),同时值为原始值(数字,字符串,布尔值)的this会指向该原始值的自动包装对象

    arg1, arg2,...:指定的参数列表
  2. 允许为不同的对象分配和调用属于一个对象的函数/方法。

    提供新的 this 值给当前调用的函数/方法。你可以使用call来实现继承:写一个方法,然后让另外一个新的对象来继承它(而不是在新对象中再写一次这个方法)。

举个例子:

function Pig(name, taste) {
  this.name = name;
  this.taste = taste;
}
function Cat(name, taste) {
  Pig.call(this, name, taste);
  this.shape = 'cute';
}

console.log(new Cat('honey', 'delicious').name) // honey

这个例子注意到3点

  • Pig函数执行了
  • call改变了Pig函数的this指向,指向到Cat
  • 上面的Cat函数体等同于把Pig的函数体给"拿"了过来:function Cat(name, taste) { this.name = name, this.taste = taste, this.shape = 'cute'}

再来个例子:

function Pig(name) {
  this.name = name;
  console.log(this.name);
  console.log(this.taste);
}

const foo = {
  taste: 'delicious'
}

Pig.call(foo, 'honey') //输出: honey, delicious

如果再给foo加上个name属性呢?注意看

function Pig(name) {
  this.name = name;
  console.log(this.name);
  console.log(this.taste);
}

const foo = {
  taste: 'delicious',
  name: "foo's name"
}

Pig.call(foo, 'honey') // 输出: honey, delicious
console.log(foo.name)  // 输出:honey

现在的情况可以看出,Pig函数体执行的时候,this会指向foo, Pig本身的函数体里this相关的修改同步于它指向的foo。

造轮子第一步

要模拟之前想想思路:根据call的特性,大致是调用的函数执行,执行时指向call的第一个参数里的this,可以传参,并且函数体里可以同步指向的this相关修改。

一步步来。我们把foo对象改变一下:

const foo = {
  taste: 'delicious',
  name: "foo's name",
  Pig: function() {
    console.log(this.name);
    console.log(this.taste);
  }
}

foo.Pig(); // 输出:foo's name

想想,此时是不是Pig函数体里的this指向了foo对象了?单论这点,等同于
Pig.call(foo)。

造轮子的第一步思路就在这里,分步骤来说就是:

1.将函数设置为对象的属性

2.执行该函数

3.删除该函数

1.foo.Pig = Pig
2.foo.Pig()
3.delete foo.Pig

根据这个思路写出造轮子第一步的代码:

// 第一步完整代码
Function.prototype.call2 = function(obj) {
  obj.fn = this // foo.Pig = this 此处this可以获取调用call2()的函数的函数体。this的显示绑定机制。
  obj.fn()
  delete obj.fn
}
// have a test
const foo = {
  taste: 'delicious',
  name: "foo's name"
}

function Pig() {
  console.log(this.taste);
  console.log(this.name);
}

Pig.call2(foo);  // 输出:delicious foo's name
console.log(foo); // 输出:{ taste: 'delicious', name: 'foo\'s name' }

看看输出结果,符合预期。调用的函数Pig执行了,执行时也是指向的第一个参数foo对象的this,并且函数体里可以同步指向的this相关修改。但是此时参数只能传第一个。接下来去改善。

造轮子第二步

根据call的特性,大致是调用的函数执行,执行时指向call的第一个参数里的this,可以传参,并且函数体里可以同步指向的this相关修改。我们现在参数那里还没有完善。

举个例子:

function Pig(weight, age) {
    console.log(this.taste);
    console.log(this.name);
    console.log(weight);
    console.log(age);
}

const foo = {
    taste: 'delicious',
    name: 'honey'
}

Pig.call(foo, 100, 6); // 输出:delicious honey 100 6

正常的传参应该是这样的,怎么去完善咱们的代码呢?

我们可以利用arguments类数组对象来取得传入的参数。以上一个例子而言,arguments应该是:

arguments = {
    0: foo,
    1: 100,
    2: 6,
    length: 3
}

利用ES6语法和call本身的第二步第一版代码

那我们像下面那样修改行吗?

Function.prototype.call2 = function(obj) {
    obj.fn = this;
    obj.fn(...arguments.slice(1)) // 注意此处
    delete obj.fn
}

不行的,类数组对象没有slice这个方法。我们得用

Array.prototype.slice.call(arguments, 1)

来把arguments这个类数组对象转换成数组,然后从第一个元素往后切,返回一个新数组。

所以代码修改后应该为:

// 第二步第一版完整代码
Function.prototype.call2 = function(obj) {
    obj.fn = this;
    var arr = Array.prototype.slice.call(arguments, 1); // 这个例子就是[100, 6]
    obj.fn(...arr);
    delete obj.fn;
}
// have a test
const foo = {
    taste: 'delicious',
    name: 'honey'
}

function Pig(weight, age) {
    console.log(this.taste);
    console.log(this.name);
    console.log(weight, age)
}

Pig.call2(foo, 100, 6)
// delicious
// honey
// 100 6

测试成功。但这里用到了ES6和call本身,下面写第二版代码,ES3原汁原味。

原汁原味ES3的第二步第二版代码

同样利用arguments。

类数组对象,有length属性,我们可以用循环构建一个参数数组。

const arr = [];
for (let i = 1, len = arguments.length; i < len; i++) {
    arr.push('arguments[' + i + ']');
}
// arr => ['arguments[1]', 'arguments[2]...']

好了,不定参参数数组有了。接下来就是想办法把这个数组的每一项以参数的格式放进obj.fn()的形参中。

我们可以先把arr数组变成字符串。有两种办法。

arr.toString / arr.join()

返回值都是 "arguments[1], arguments[2]..."

然后可以利用eval()这个魔鬼来直接把这字符串的两个引号去掉,达到形参格式要求。看下面逻辑。

eval('obj.fn(' + arr + ')')

在eval里此处的arr会自动调用arr.toString()方法。

最终相当于执行了obj.fn(arguments[1], arguments[2], ...)

所以:

// 原汁原味ES3的第二步第二版完整代码
Function.prototype.call2 = function(obj) {
    obj.fn = this;
    const arr = [];
    for (let i = 1, len = arguments.length; i < len; i++) {
        arr.push('arguments[' + i + ']'); // 写出arr.push(argumentsp[i])也可以
    }
    eval('obj.fn(' + arr + ')');
    delete obj.fn;
}

// have a test
const foo = {
    taste: 'delicious',
    name: 'honey'
}

function Pig(weight, age) {
    console.log(this.taste);
    console.log(this.name);
    console.log(weight, age);
}

Pig.call2(foo, 100, 6); 
// delicious
// honey
// 100 6

成功。这个就比较兼容了。

造轮子第三步

再完善两点。

  1. thisArg参数在非严格模式下,指定为null和undefined的this值会自动指向全局对象(浏览器中就是window对象)。
  2. 函数可以有返回值。

这比较容易解决,看代码:

// 第三步完整代码:
Function.prototype.call2 = function(obj) {
    var obj = obj || window;
    obj.fn = this;
    const arr = [];
    for (let i = 1, len = arguments.length; i < len; i++) {
        arr.push('arguments[' + i + ']'); 
    }
    const result = eval('obj.fn(' + arr + ')');
    delete obj.fn;
    return result;
}

// have a test
var name = "Darling";

const foo = {
    name: 'honey'
}

function Pig(weight, age) {
    console.log(this.name);
    return {
        weight: weight,
        age: age,
        name: this.name
    }
}

Pig.call2(null);
// Darling
// Object { 
//    weight: undefined
//    age: undefined
//    name: "Darling"
// }
Pig.call2(foo, 100, 6);
// honey
// Object {
//    weight: 100,
//    age: 6,
//    name: 'honey'
//}

OK,目前为止,造完了一个简单的call轮子call2😊。

Function.prototype.apply()的轮子

与call就参数差异,直接上代码。

Function.prototype.apply2 = function(obj, arr) {
    var obj = obj || window;
    obj.fn = this;
    var result;
    if (!arr) {
        result = obj.fn();
    } else {
        var args = [];
        for (let i = 0, len = arr.length; i < len; i++) {
            args.push('arr[' + i + ']');
        }
        result = eval('obj.fn(' + args + ')');
    }
    delete obj.fn;
    return result;
}

参考:

1.MDN文档

2.JavaScript深入之call和apply的模拟实现

3.不能使用call,apply,bind,如何用js实现call或者apply的功能?

深入js之原型与原型链


思维导图压阵。

什么是原型

从生活上说

何为“原型”? 从感性的角度来讲,原型是顺应人类自然思维的产物。有个成语叫做“照猫画虎”,这里的猫就是虎的原型,另一个俗语“比着葫芦画瓢”亦是如此。可见,“原型”可以是一个具体的、现实存在的事物。

而我们再看“类”。以房屋和图纸为例,这里图纸就是“类”。图纸的意义在于“指导”工人创造出真实的房子(实例)。因此“类”更倾向于是一种具有指导意义的理论和**。

所以,JavaScript 才是真正应该被称为“面向对象”的语言,因为它是少有的可以不通过类,直接创建对象的语言。

从技术上说

每个函数都有一个prototype属性(Symbol和Math除外),该属性指向一个对象,叫做原型对象。当函数被当做构造函数创建实例时,这个函数的prototype属性指向的原型对象就成为实例的原型对象。

原型对象都有一个constructor属性,原型对象的该属性指向该原型对象对应的构造函数。

每个实例都有一个隐藏的属性[[prototype]],指向它的原型对象,我们可以使用下面两种方式的任意一种来获取实例的原型对象。

注意:在 ES5 之前,为了能访问到 [[Prototype]],浏览器厂商创造了 proto 属性。但在 ES5 之后有了标准方法 Object.getPrototypeOf 和 Object.setPrototypeOf。尽管为了浏览器的兼容性,已经将 proto 属性添加到 ES6 规范中,但它已被不推荐使用。

instance.__proto__;

Object.getPrototypeOf(instance);

举个例子:

function Info() {};

const person = new Info();

console.log(Info.prototype === person.__proto__);
console.log('--')
console.log(Object.getPrototypeOf(person) === Info.prototype);
console.log('---')
console.log(Info.prototype.constructor === Info);

/*输出
true
--
true
---
true
*/

我们可以知道,原型对象可以有这些指向它:

Info.prototype === person.__proto__ === Object.getPrototypeOf(person)

那么原型对象与构造函数的关系是什么呢?

从上面代码也很容易看出构造函数与实例与原型对象之间的关系,见下图:

如果想知道new内部实现的,可以参考我这篇:深入js之造new轮子

原型链

什么是原型链呢?还是以上面的例子来说,构造函数Info的原型对象也是一个对象,也可能是另一个构造函数的实例,或者说它跟另一个构造函数对应的原型对象之间也有关系。测试一下?

function Info() {};

const person = new Info();

console.log(Object.getPrototypeOf(Info));

输出是这样的:

注意,这里指向了一个对象,并且这个对象有constructor属性,所以可以判断这个对象是一个原型对象,也可以通过上图红圈看出这个原型对象对应的构造函数是Object()。

我们完善下图形:

这就很清楚各个之间的关系了。

Object.prototype还有没有与它对应的原型对象呢?

测试一下:

是为null。

完善上面的关系图:

此处就很好呈现了层层渐进的原型对象、实例和构造函数的关系,原型链也已经在上图中体现出来。

橙色方框圈起来的就是所谓原型链。

用文字表述的话:每个对象都拥有一个原型对象,通过__proto__指针指向上一个原型 ,并从中继承方法和属性,同时原型对象也可能拥有原型,这样一层一层,逐级向上,最终指向 null(null 没有原型)。这种关系被称为原型链 (prototype chain)

一些方法

列举一些关于原型/原型链常用的内置方法。

  1. Object.create()

用于创建一个新的对象,它使用现有对象作为新对象的 proto。第一个参数为原型对象,第二个参数可选,可以传入属性描述符对象或 null,其他类型直接报错。

var person = {
  name : 'sadhu'
}

var friend = Object.create(person);

friend.hobby = 'FE';

  1. Object.getOwnPropertyNames()

该方法返回一个由指定对象的所有自身属性的属性名组成的数组。

  • 包括不可枚举属性

  • 但不包括 Symbol 值作为名称的属性

  • 不会获取到原型链上的属性

  • 当不存在普通字符串作为名称的属性时返回一个空数组

// 它只会获取自身属性,而不去关心原型链上的属性
Object.getOwnPropertyNames(friend); // ['hobby']
  1. Object.getPrototypeOf() / Object.setPrototypeOf()

这两个用于获取和设置一个对象的原型,它主要用来代替__proto__

  1. hasOwnProperty

用来判断一个对象本身是否含有该属性,返回一个 Boolean 值。

  • 原型链上的属性 一律返回 false

  • Symbol 类型的属性也可以被检测

friend.hasOwnProperty('hobby')
// true
friend.hasOwnProperty('name')
// false
  1. isPrototypeOf()

该方法用于检测一个对象是否存在于另一个对象的原型链上,返回一个 Boolean 值。

person.isPrototypeOf(friend) // person是否存在于friend的原型链上
// true
  1. instanceof

object instanceof constructor(构造函数)

instanceof 运算符用来检测 constructor.prototype 是否存在于参数 object 的原型链上。

function Person(name) {
  this.name = name;
}

var per = new Person('sadhu');

var friend = Object.create(per);

friend.hobby = 'FE';

console.log(friend instanceof Person); // Person.prototype是否存在于friend对象的原型链上

console.log(per.isPrototypeOf(friend)); // per对象是否存在于friend对象的原型链上

// true
// true

注意

最后是关于继承,不少文章都说“每一个对象都会从原型‘继承’属性”,实际上,继承是一个十分具有迷惑性的说法,引用《你不知道的JavaScript》中的话,就是:

继承意味着复制操作,然而 JavaScript 默认并不会复制对象的属性,相反,JavaScript 只是在两个对象之间创建一个关联,这样,一个对象就可以通过委托访问另一个对象的属性和函数,所以与其叫继承,委托的说法反而更准确些。

本文完。

希望看到各位技术人对这篇文章有不同的有“证据”,符合逻辑的分析、看法~

文章会第一时间更新在GitHub,觉得写得还不错的,可以点个star支持下作者🍪


参考:

  1. JavaScript深入之从原型到原型链
  2. 详解JS原型链与继承
  3. 从感性角度谈原型 / 原型链

234. 回文链表

题目

思路一: 双指针
由于链表不好定义尾指针,所以可以先把链表转换成数组,再对数组使用头尾双指针,依次判断是否相等。

代码实现:

var isPalindrome = function(head) {
  const vals = [];
  while (head !== null) {
      vals.push(head.val);
      head = head.next;
  }
  for (let i = 0, j = vals.length - 1; i < j; ++i, --j) {
      if (vals[i] !== vals[j]) {
          return false;
      }
  }
  return true;
};

时间复杂度: O(n);
空间复杂度: O(n);

思路二: 快慢指针
为了使空间复杂度为 O(1),我们可以定义快慢指针,都从起点出发,一轮迭代中,慢指针一次走一步,快指针走两步,当快指针到达末尾时,慢指针指向中间节点。

以中间节点为分隔,反转后半部分链表,再与前半部分链表在一轮循环中一个节点一个节点对比。

如果是奇数队列,则中间节点归属前半部分。

代码实现

const reverseList = (head) => {
  let prev = null;
  let curr = head;
  while (curr !== null) {
      let nextTemp = curr.next;
      curr.next = prev;
      prev = curr;
      curr = nextTemp;
  }
  return prev;
}

const endOfFirstHalf = (head) => {
  let fast = head;
  let slow = head;
  while (fast.next !== null && fast.next.next !== null) {
      fast = fast.next.next;
      slow = slow.next;
  }
  return slow;
}

var isPalindrome = function(head) {
  if (head == null) return true;

    // 找到前半部分链表的尾节点并反转后半部分链表
    const firstHalfEnd = endOfFirstHalf(head);
    const secondHalfStart = reverseList(firstHalfEnd.next);

    // 判断是否回文
    let p1 = head;
    let p2 = secondHalfStart;
    let result = true;
    while (result && p2 != null) {
        if (p1.val != p2.val) result = false;
        p1 = p1.next;
        p2 = p2.next;
    }        

    // 还原链表并返回结果
    firstHalfEnd.next = reverseList(secondHalfStart);
    return result;
};
时间复杂度: O(n)
空间复杂度: O(1)

理解 React Scheduler 最小堆

为什么用最小堆?

React 的需求

React 的 Scheduler 模块做的工作就是调度注册好的优先级任务,也就是做了如下两件事:

  1. 提供注册优先级任务的方法
  2. 调度优先级任务执行

使用者可以随时注册任意优先级的任务,Scheduler 每次调度执行的是最高优先级任务。

对于 React 而言,把更新任务拆成了一个个小任务,每个小任务的数据结构包含 expirationTime 字段,表示这个任务的过期时间,expirationTime 越小就表示过期时间越近,该任务的优先级就越高。所以 Scheduler 在每次调度时取出最小值就相当于取出优先级最高的任务。

React 时间分片期望是每帧都有一点时间去执行这些小任务,所以 Scheduler 每帧都会调度一下最高优先级任务执行,取出优先级任务的频次是很高的,相对来说注册优先级任务往往是有新的更新发生时才会注册,频次比较低。

如果你来实现,会怎么去管理一众优先级任务呢 ?

常规思路:用数组保存优先级任务,每次注册任务时就往数组 push,每次取出时,遍历取出最小值。

const list = [];
// 时间复杂度 O(1);
function push(task) {
  list.push(task);
}

function pop(task) {
  // 好时 O(1),坏时 O(n)
  const index = list.indexOf(task);
  // 好时 O(1),坏时 O(n)
  list.splice(index, 1);
}

function getTask() {
  if (!list.length) {
    return;
  }
  // O(n)
  return list.reduce((prev, curr) => { 
    return curr.expirationTime < prev.expirationTime ? curr : prev;
  }, { expirationTime: Infinity})
}

以上这个实现可以满足需求,但是性能不利于「高频取、低频插」。

你可能想着在插入时从小到大 sort 一下 list,然后取的时候直接取第一个元素就行。这样变成了 「取 O(1)」「插 O(nlogn)」。从性能角度来讲这样会比以上实现要更适合 React 的需求。

但是还可以再进一步,如果用最小堆的话,可以达到 「取 O(1)」「插 O(logn)」的性能。

堆的特性

  • 符合完全二叉树
  • 父节点的键值总是小于(大于)或等于任何一个子节点的键值

若是父节点的键值总是小于或等于任何一个子节点的键值,那么就是最小堆,否则是最大堆。

用数组来保存各个节点,有个规律就是:下标为 i 的节点的子节点是 2i + 1 与 2i + 2。

很简单地能推出父节点的下标是 : Math.floor(i - 1)/2 等同于 (i - 1) >>> 1

看不明白这规律和位运算的,草稿纸推演几遍。

二叉堆

最小堆的实现

这版实现其实和 React 的大差不差了,看懂这个就懂了 React 最小堆的实现

const heap = [];

// 取小值,O(1)
function peek() {
  return heap.length === 0 ? null : heap[0];
}

// 插入一个节点
function push(node) {
  const index = heap.length;
  // 添加在数组最后面  O(1)
  heap.push(node);
  // 上浮至对应位置 O(logn)
  siftUp(node, index);
}

function siftUp(node, i) {
  let index = i;
  while (index > 0) {
    // 找到父节点索引
    const parentIndex = (index - 1) >>> 1;
    const parent = heap[parentIndex];
    // 比较当前节点与父节点的大小
    if (compare(parent, node) > 0) {
      // 若更大,交换位置
      heap[parentIndex] = node;
      heap[index] = parent;
      index = parentIndex;
    } else {
      // 若更小,上浮完成
      return;
    }
  }
}

function compare(a, b) {
  // 假设 .value 就是该节点的值
  return a.value - b.value
}

// 剔除最后第一个节点
function pop() {
  if (heap.length === 0) {
    return null;
  }
  const first = heap[0];
  // O(1)
  const last = heap.pop();
  if (last !== first) {
    // 最后一个节点与第一个节点交换
    heap[0] = last;
    // 下沉至对应位置 O(logn)
    siftDown(last, 0);
  }
  return first;
}

function siftDown(node, i) {
  let index = i;
  const length = heap.length;
  const halfLength = length >>> 1;
  // 为什么比较一半就行,见下推理过程:
  // 每次变化,索引最大变为 2i + 2,肯定是小于等于 length - 1 的。
  // 2i + 2 <= length - 1   -->   i <= (length - 3) / 2;
  // --> i < length / 2  --> 近似于 i < length >>> 1
  // 其实这里 < length 也没问题,React 初版也是这么实现的
  while (index < halfLength) {
    // 左节点索引是 2i + 1
    const leftIndex = (index + 1) * 2 - 1;
    const left = heap[leftIndex];
    // 右节点是 2i + 2
    const rightIndex = leftIndex + 1;
    const right = heap[rightIndex];

    // 如果当前节点比左节点更小
    if (compare(left, node) < 0) {
      // 并且右节点 < 左节点
      if (rightIndex < length && compare(right, left) < 0) {
        // 交换更小的右节点与当前节点
        heap[index] = right;
        heap[rightIndex] = node;
        index = rightIndex;
      } else {
        // 交换更小的左节点和当前节点
        heap[index] = left;
        heap[leftIndex] = node;
        index = leftIndex;
      }
    // 如果比右节点更小,交换右节点
    } else if (rightIndex < length && compare(right, node) < 0) {
      heap[index] = right;
      heap[rightIndex] = node;
      index = rightIndex;
    } else {
      // 没有子节点
      return;
    }
  }
}

JS复习笔记之造new轮子

先搞清楚new

首先,我们要知道,创建一个用户自定义对象需要两步:

  1. 通过编写函数来定义对象类型。
  2. 通过new来创建对象实例。

通过此处引出了new的描述,new是干嘛的呢?引用MDN的一句话:

new 运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。

初看时可能会有点懵,我们可以对这段话修枝剪叶留下枝干: new运算符可以创建 对象实例 。(之后再通过new的运用,补全其枝叶。)

这句话就符合最开头说的创建一个用户自定义对象的第二步了。实际应用也是,new运算符经常是与一个函数结合使用。

来看个例子:

// example
function Foo(name, age) {
  this.name = name;
  this.age = age;
  this.sex = 'male';
}
Foo.prototype.brother = "宇智波鼬";

Foo.prototype.chat = function () {
  console.log('how are you? 鸣人');
}

var person = new Foo('佐助', 21);

console.log(person.name);
console.log(person.sex);
console.log(person.brother);
person.chat();
// 佐助
// male
// 宇智波鼬
// how are you? 鸣人

从这个例子中我们可以看出,:

  1. 生成了新对象(实例)person。
  2. Foo函数里的this指向该实例对象person。
  3. 这个对象实例可以访问Foo.prototype上的属性。

MDN上帮我们总结的更清楚,当代码 new Foo(...) 执行时,会发生以下事情:

  1. 一个继承自 Foo.prototype 的新对象被创建。("继承"用得不是很准确,应该叫委托才好,具体参见you-dont-konw-js)
  2. 使用指定的参数调用构造函数 Foo ,并将 this 绑定到新创建的对象。new Foo 等同于 new Foo(),也就是没有指定参数列表,Foo 不带任何参数调用的情况。
  3. 由构造函数返回的对象就是 new 表达式的结果。如果构造函数没有显式返回一个对象,则使用步骤1创建的对象。(一般情况下,构造函数不返回值,但是用户可以选择主动返回对象,来覆盖正常的对象创建步骤)

注: 由同一个构造函数创建的实例各个独立且其原型相同,原型对象都等于 构造函数.prototype === 实例.__proto__

第一步

有了对new的认识后,咱们开始仿写。因为 new 是个关键字,没法像之前的call、bind那样子写方法覆盖。我们可以写个方法 copyNew()。

以上面例子为例,我们让 new Foo(xxx) 的效果等于 copyNew(Foo, xxx)就行。

来第一步代码:

// codes
function copyNew() {
  var obj = new Object();

  var constructor = [].shift.call(arguments); // 取出第一个构造函数参数。注意shift可以改变原数组。

  obj.__proto__ = constructor.prototype; // 这样obj就可以访问在构造函数的prototype上的属性

  // 根据apply经典继承来让函数的this指向实例
  constructor.apply(obj, arguments);

  return obj
}

have a test:

// example
function Foo(name, age) {
  this.name = name;
  this.age = age;
  this.sex = 'male';
}
Foo.prototype.brother = "宇智波鼬";

Foo.prototype.chat = function () {
  console.log('how are you? 鸣人');
}

var person = copyNew(Foo, '佐助', 21);

console.log(person.name);
console.log(person.sex);
console.log(person.brother);
person.chat();
// 佐助
// male
// 宇智波鼬
// how are you? 鸣人

第一步成功。

第二步

我们现在考虑下构造函数有返回值的情况。

1、假设构造函数的返回值是对象。

举个例子:

// 假设构造函数的返回值是对象
function Foo(name, age) {
  this.brother = '宇智波鼬';
  this.age = age
  return {
    habit: 'coding',
    name: name
  }
}

const person = new Foo('佐助', 21);

console.log(person.age);
console.log(person.brother);
console.log('**********华丽的分割线**********');
console.log(person.habit);
console.log(person.name);
// undefined
// undefined
//**********华丽的分割线**********
// coding
// 佐助

我们可以发现,构造函数Foo的person实例完全只能访问Foo返回的对象中的属性。

那构造函数的返回值不是对象呢? 我们试一个基本类型的值。

2、假设构造函数的返回值是基本类型的值

// 假设构造函数的返回值是一个基本类型的值
function Foo(name, age) {
  this.brother = '宇智波鼬';
  this.age = age
  return 'sadhu'
}

const person = new Foo('佐助', 21);

console.log(person.age);
console.log(person.brother);
console.log('**********华丽的分割线**********');
console.log(person.habit);
console.log(person.name);
// 21
// 宇智波鼬
//**********华丽的分割线**********
// undefined
// undefined

结果刚刚相反,相当于无返回值时的处理。

所以我们可以根据这两个例子的结果去完善最后的代码,根据这个思路:

若当构造函数返回值是对象时,则new调用后也返回该对象实例。若当构造函数的返回值是基本类型或者无返回值时,都当作无返回值情况处理,该返回什么就返回什么。

最终代码:

function copyNew() {
  var obj = new Object();
  var constructor = [].shift.call(arguments);
  obj.__proto__ = constructor.prototype;
  var result = constructor.apply(obj, arguments);
  return typeof result === 'object' ? result : obj;
}

参考:

  1. MDN
  2. JavaScript深入之new的模拟实现

关于前端框架设计的一些思考

框架的通用之处

核心思路

任何现代 UI 库的设计抽象来说都符合一个公式:

                                             UI = f(state)

UI 即代表描述 UI 的数据结构,state 代表状态,f 代表转换 state 为 UI 的内部运行机制。而各个库的主要区别点就是 f 的实现不同。

换句话说,现代 UI 库都是状态(state)驱动(f)视图(UI),符合 MVVM 架构。

Model(state)改变 -> ViewModel(虚拟 DOM、Fiber 树类似描述 UI 的数据结构)改变 -> View(视图)改变。

组件

先来想想组件这个产物为什么会出现?

试想就算状态驱动视图,若状态与视图都维护在一个地方,开发者关注点集中在此,代码越多越容易乱。

一般这种情况下,如果是纯 JS 逻辑我们会考虑「拆分逻辑到不同的模块去管理」,那么完整逻辑就由各个模块的逻辑组合而成,若遇到问题,定位到对应模块修改,拆得好就不会影响到其他模块。这就是一种关注点分离,大逻辑分散到一个个小逻辑中,可维护性很好。同时我们在拆逻辑的时候若遇到使用频率高的逻辑,可以再拆成一个模块,再遇到就可以直接复用了,显然也提升了代码复用性。

在这个思维基础上加上视图呢?就是「拆分逻辑和视图到不同的模块去管理」。此时这个模块就是所谓组件。对比拆分模块,完整的逻辑和视图被拆分到一个个组件中,某块 UI 遇到问题就定位到具体组件去处理,拆得好就不会影响到其他 UI,一样得,若发现使用频率高的逻辑和视图,则可以再拆成一个公共组件,再遇到直接复用。

所以我觉得出现组件的本质原因是「关注点分离」,组件的定义就是 UI 工程中封装了状态、逻辑及视图的松散耦合单元。

我们知道了组件的意义,那怎么去拆呢?

我觉得只要遵循着职责清晰(耦合程度)逻辑清晰(易于阅读)可复用性好的前提下去拆组件就好。这没有绝对的银弹,并不意味着耦合度越低、灵活性越高就代表组件设计得好。比如对于开发各个团队通用的组件库时,把一个组件设计得很灵活是好事,可以提供不同 props 给开发者控制该组件不同功能。但是针对拆业务组件,只要你这一整块业务逻辑就是很多地方都需要用到的,并且代码量不算很大,编码逻辑清晰,那设计成一个大组件有何不可呢,再拆成粒度更细的一个个小组件则无意义了。

我认为组件、MVVM 都是在社区中经过实践一路演变来的利于软件工程开发的架构级别产物。目前各类前端框架还是实践着这类架构级别产物,没有说有哪个框架开创出一种更有优势的架构。

框架的不同之处

既然架构级别产物是通用的,理论上用 React 也能很好地开发完成需求,那为什么有其他框架的诞生呢?私认为这个话题离不开商业相关,一是有需求的地方就有市场,二是研究竞品创造可能有的需求来达到有市场的目的。

从用户使用的角度来看

以 React、Vue、Svelte 来讨论

  • React 是提供了相对较少的 API,给予开发者比较大的自由去进行开发,但是这些 API 的使用除了文档以外,需要开发者理解 React 的心智模型才能用得好,否则新手开发者容易写出 bug 或遇到性能瓶颈不知道怎么解决问题。
  • Vue 则是提供了相对较多的 API 以及一些约束,降低灵活性但是使得开发者学习了文档后能 hold 住大部分问题,对新手友好。
  • Svelte 为了 React 用户易上手,设计 API 时尽量贴近 React API 风格。

从产物性能的角度来看

用户的操作肯定是代码运行时的行为,理论上运行时发生的行为造成的代码执行量越少越不容易让用户感觉卡顿(掉帧)。

  • React 对于用户操作的反应路径是:用户操作导致状态改变 -> 从整个项目根节点开始 DFS 找出可复用的节点或状态改变导致有变化的节点 -> 提交到 UI 上
  • Vue 对于用户操作的反应路径是:用户操作导致状态改变 -> 根据绑定关系直接找出状态所在组件,diff 组件内的所有节点,找出可复用或有变化的节点 -> 提交到 UI 上
  • Svelte 对于用户操作的反应路径是:用户操作导致状态改变 -> 根据绑定关系直接找出状态对应节点并进行改变 -> 提交到 UI

可以感知到,运行时状态改变,尽管各个框架的目的都是找出变化的节点,对应改变 UI。但是由于内部实现机制不同, React 要从根节点开始找,Vue 从组件开始找,Svetle 直接找到对应节点。

所以,产物性能相较下来就是 React 够用、Vue 好一些、Svelte 最好。

想到什么再补充...

搭建 React + TS 开发环境(Webpack 5.x、Babel 7.x、React 17.x、TS 4.x)

基础设施

git init

初始化 git 仓库

.gitignore

添加 .gitignore 文件,推荐使用 vscode 插件 gitignore 进行配置,常用配置项(Node, VisualStudioCode, JetBrains, Windows, Linux, macOS

.nvmrc

node --version > ./.nvmrc 生成 .nvmrc 文件。

nvm (node version manager) 是 node 的版本管理工具,.nvmrc 是 nvm 的配置文件,很多工具在判断项目的 node 版本的时候会读取这个配置,例如 travis CI。

.npmrc

.npmrc 是给 npm 用的配置文件,当然你如果使用 yarn,yarn 也会遵守 .npmrc 配置,虽然 yarn 有专门的配置文件 .yarnrc

如果用 yarn npm 装依赖一定要用代理,并且也没有设置淘宝源,可以在 .npmrc 中配置这些的依赖过淘宝源,如 node-sass:

# .npmrc
SASS_BINARY_SITE=http://npm.taobao.org/mirrors/node-sass

建议还是使用 nrm 设置一下淘宝源

LICENSE

choose a license 中选择一份协议,复制粘贴到 LICENSE.txt 中,改一改配置如年份、作者名就行

npm init

npm/yarn init -y 初始化 package.json

然后可以看情况改造下,如:

{
  "name": "react-ts-build-from-scratch",
  "version": "1.0.0",
  "description": "build from scratch",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": {
    "name": "Sadhu",
    "url": "https://github.com/YxrSadhu",
    "email": "[email protected]"
  },
  "license": "ISC"
}

.vscode

.vscode/settings.json 是 VSCODE 的项目配置文件,可以做如下常规配置:

{
  // 默认 ESLint 并不能识别 .vue、.ts 或 .tsx 文件
  "eslint.validate": ["javascript", "javascriptreact", "vue", "typescript", "typescriptreact"],
  // 保存时自动修复
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true,
    "source.fixAll.stylelint": true
  },
  // stylelint 扩展自身的校验就够了
  "css.validate": false,
  "less.validate": false,
  "scss.validate": false,
  // 使用本地安装的 TypeScript 替代 VSCode 内置的来提供智能提示
  "typescript.tsdk": "./node_modules/typescript/lib",
  // 指定哪些文件不参与搜索
  "search.exclude": {
    "**/node_modules": true,
    "dist": true,
    "yarn.lock": true
  },
  // 指定哪些文件不被 VSCode 监听,预防启动 VSCode 时扫描的文件太多,导致 CPU 占用过高
  "files.watcherExclude": {
    "**/.git/objects/**": true,
    "**/.git/subtree-cache/**": true,
    "**/node_modules/*/**": true,
    "**/dist/**": true
  },
  "files.eol": "\n",
  "editor.tabSize": 2,
  "prettier.requireConfig": true,
  "cSpell.words": [
    "corejs",
    "pmmmwh",
    "stylelint",
    "webpackbar"
  ],
  // 保存文件时自动用 prettier 格式化
  "editor.formatOnSave": true,
  // 配置 VScode 使用 prettier 的 formatter
  "[javascript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[javascriptreact]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescriptreact]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[json]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[html]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[markdown]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[css]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[less]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[scss]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  }
}

CI 工具如 .travis.yml

各自工具各自实践

README.md

有些图标可以在 shields.io

commit

# 添加远程仓库地址
git remote add github [email protected]:xxx
# 添加所有修改到暂存区
git add -A
git commit -m "feat: xxxx"
# 推送到 github,关联 github 远程仓库和 master 分支,下次还是 master 分支就可以直接 git push 了
git push github -u master

commit 规范可以参考 阿里技术,也可以使用 commitizen来生成 commit。

若想 emoji 可以使用 gitmoji-cli 或者直接使用 VSCode 扩展 Gitmoji Commit 生成 git emoji。

也可以设置 git 钩子来做一些事情: husky + commitlint

linter & formatter

代码风格交给 Prettier
语法检查交给 ESLint

ESLint

根据项目技术栈,推荐 AlloyTeam 的配置规则,具体配置参考团队 github

当然你要用 airbnb 规则找不痛快也可以:)

自动修复 ESLint 错误 npm run eslint:fix

也可以下个 ESLint 插件,提供 IDE 检测及修复。

Prettier

yarn add prettier -D

然后 VSCODE 下 Prettier 插件,提供 IDE 检测及修复。

推荐配置:

// .prettierrc.js
module.exports = {
  // 一行最多 120 字符
  printWidth: 120,
  // 使用 2 个空格缩进
  tabWidth: 2,
  // 不使用缩进符,而使用空格
  useTabs: false,
  // 行尾需要有分号
  semi: true,
  // 使用单引号
  singleQuote: true,
  // 对象的 key 仅在必要时用引号
  quoteProps: 'as-needed',
  // jsx 不使用单引号,而使用双引号
  jsxSingleQuote: false,
  // 末尾需要有逗号
  trailingComma: 'all',
  // 大括号内的首尾需要空格
  bracketSpacing: true,
  // jsx 标签的反尖括号需要换行
  jsxBracketSameLine: false,
  // 箭头函数,只有一个参数的时候,也需要括号
  arrowParens: 'always',
  // 每个文件格式化的范围是文件的全部内容
  rangeStart: 0,
  rangeEnd: Infinity,
  // 不需要写文件开头的 @prettier
  requirePragma: false,
  // 不需要自动在文件开头插入 @prettier
  insertPragma: false,
  // 使用默认的折行标准
  proseWrap: 'preserve',
  // 根据显示样式决定 html 要不要折行
  htmlWhitespaceSensitivity: 'css',
  // vue 文件中的 script 和 style 内不用缩进
  vueIndentScriptAndStyle: false,
  // 换行符使用 lf
  endOfLine: 'lf',
  // 格式化嵌入的内容
  embeddedLanguageFormatting: 'auto',
};

stylelint

如果需要 css/scss/less linter 的话,可以用这个

yarn add -D stylelint stylelint-config-prettier stylelint-config-rational-order stylelint-config-standard stylelint-declaration-block-no-ignored-properties stylelint-order stylelint-scss

配置可以参考:

// .stylelintrc.json
{
  "extends": ["stylelint-config-standard", "stylelint-config-rational-order", "stylelint-config-prettier"],
  "plugins": [
    "stylelint-order",
    "stylelint-declaration-block-no-ignored-properties",
    "stylelint-scss",
    "stylelint-config-prettier" // 解决和 prettier 的冲突
  ],
  "rules": {
    "comment-empty-line-before": null,
    "declaration-empty-line-before": null,
    "function-name-case": "lower",
    "no-descending-specificity": null,
    "no-invalid-double-slash-comments": null
  },
  // 加 "**/typings/**/*" 的原因:https://github.com/stylelint/vscode-stylelint/issues/72
  "ignoreFiles": ["node_modules/**/*", "src/assets/**/*", "dist/**/*", "**/typings/**/*"]
}

可以安装 stylelint 插件,提供 IDE 检测及修复。

构建

Webpack 5 作为构建工具。(TODO: 尝试下 vite)

安装

yarn add -D webpack webpack-cli

基础配置

webpack basic config 参见该 commit
配置参数的意义都在注释中。

引入 Babel 在 webpack 打包过程中转译 tsx? | js 文件

参见该 commit
同样,注意点都在注释中。

引入 TS、React

yarn add react react-dom
yarn add -D typescript
yarn add -D @babel/preset-typescript @types/react @types/react-dom

babel 增加对 ts、react 的转译预设:

// .babelrc.js
"presets": [
    "@babel/env", // 把 useBuiltIns 等删了,因为和 @babel/runtime-corejs3 功能重复了
  + "@babel/preset-react",
  + "@babel/preset-typescript"
],

我采用的 tsconfig 配置,更多可参考官网,有些选项对 tsc 有效,有些选项对 IDE 增益:

{
  "compilerOptions": {
    "target": "ES3" /* 指定编译之后的版本目标: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
    "module": "esnext" /* 指定要使用的模块标准: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
    "lib": ["ESNext"] /* 指定 tsc 编译时要包含的库。不指定最新的话,代码里用不了新语法,因为 tsc 编译时无法识别 */,
    "isolatedModules": true, // 会在 babel 编译代码时提供一些额外的检查
    "noImplicitAny": false /* 是否默认禁用 any */,
    "removeComments": true /* 是否移除注释 */,
    "declaration": true /* 是否自动创建类型声明文件 */,
    "strict": true /* 启动所有类型检查 */,
    "allowJs": true,
    "checkJs": false,
    "noEmit": false /* tsc 编译的时候不生成代码 */,
    "jsx": "react" /* 指定jsx代码用于的开发环境, react -> 从 tsx 编译到 js, preserve -> 从 tsx 编译到 jsx */,
    "importHelpers": true /* 引入tslib里的辅助工具函数*/,
    "moduleResolution": "node" /* 选择模块解析策略,有'node'和'classic'两种类型 */,
    "experimentalDecorators": true /* 启用实验性的装饰器特性 */,
    "esModuleInterop": true /* 通过为导入内容创建命名空间,实现CommonJS和ES模块之间的互操作性, 适用于 Babel 编译 */,
    "allowSyntheticDefaultImports": true /* 允许从没有默认导出的模块中默认导入 */,
    "sourceMap": true /* 是否生成map文件 */,
    // 这里的 baseUrl 其实跟 ts 模块引入有很大关联的',
    // tsconfig.json 路径为根路径
    "baseUrl": "./",
    "paths": {
      // 路径映射,与 baseUrl 关联
      "Src/*": ["src/*"],
      "Components/*": ["src/components/*"],
      "Utils/*": ["src/utils/*"]
    },
    "outDir": "dist-ts"
  },
  "include": ["src"]
}

增加 webpack extensions 与别名的配置:

// webpack.common.js
resolve: {
    // 配置 extensions,在 import 的时候就可以不加文件后缀名了。
    // webpack 会按照定义的后缀名的顺序依次处理文件,比如上文配置 ['.tsx', '.ts', '.js', '.json'] ,webpack 会先尝试加上 .tsx 后缀,看找得到文件不,如果找不到就依次尝试进行查找,
    // 所以我们在配置时尽量把最常用到的后缀放到最前面,可以缩短查找时间。
    extensions: ['.tsx', '.ts', '.js', '.json'],
    alias: {
      Src: path.resolve(__dirname, '../src'),
      Components: path.resolve(__dirname, '../src/components'),
      Utils: path.resolve(__dirname, '../src/utils'),
    },
  },

因为 TS 对 css、图片资源等引入,这些配置还不支持识别,需要手动配置声明文件,可以在 types/xxx.d.ts 里声明,能够被全局识别。例子

实用配置

i. 利用 fork-ts-checker-webpack-plugin 插件,使得webpack 打包编译时,起一个单独的进程去并行地进行 TypeScript 的类型检查。 (我觉得利用 IDE 就够了)
ii. 利用 react-refresh/babel@pmmmwh/react-refresh-webpack-plugin 替代 react-hot-loader,性能会提升。
iii. 利用 mini-css-extract-plugin split css module
iiii. 利用 webpackbar 创建打包进度条
iiiii. 利用 webpack-bundle-analyzer 开一个 server 代理 bundle 分析页面
iiiiii. 利用 webpack.BannerPlugin 声明版权
iiiiiii. 利用 cross-env 兼容环境变量

以上配置在 commit 中有配置方法以及详细注释。

iiiiiiii. 定义定义静态资源输出路径以及格式: commit
iiiiiiiii. 配置 css modules : commit

以上。

完整配置见此 repo

深入js之对象的属性类型

在我们平常的使用中,给对象添加一个属性时,直接使用object.param的方式就可以了,或者直接在对象中挂载:

var person = {
    name: 'TOM'
}

在ECMAScript5中,对每个属性都添加了几个属性类型,来描述属性的特点。它们分别是:

  • configurable: 表示该属性是否能被delete删除。当其值为false时,其他的特性也不能被改变。默认值为true

  • enumerable: 是否能枚举。也就是是否能被for-in遍历。默认值为true

  • writable: 是否能修改值。默认为true

  • value: 该属性的具体值是多少。默认为undefined

  • get: 当我们访问属性的值时,get将被调用。该方法可以自定义返回的具体值时多少。get默认值为undefined

  • set: 当我们设置属性的值时,set方法将被调用。该方法可以自定义设置值的具体方式。set默认值为undefined

注意:不能同时设置value、writable 与 get、set的值。

我们可以通过Object.defineProperty方法来修改这些属性类型。

下面我们用一些简单的例子来演示一下这些属性类型的具体表现。

configurable

var person = {
  name: 'Sadhu'
}

delete person.name; // 使用delete删除该属性 返回true表示删除成功

Object.defineProperty(person, 'name', { // Object.defineProperty()重新添加name属性
  configurable: false,
  value: 'TOM'
})

console.log(person.name); // TOM 

delete person.name; // 已经不能删除了

console.log(person.name); // TOM

// 试图改变value
person.name = 'sadhu'; // 定义了configurable: false时,其他的特性也不能改变,定义过了value: 'TOM' ,再改变值也不能改变了。
console.log(person.name); // TOM 

enumerable

var person = {
  name: 'sadhu',
  age: 21
}

var params = []

// 使用for-in枚举person属性
for (var key in person) {
  params.push(key);
}

console.log(params); // [ 'name', 'age' ]

// 重新设置name属性的类型,让其不可被枚举.
Object.defineProperty(person, 'name', {
  enumerable: false
})

var _params = [];

for(var key in person) {
  _params.push(key);
}

console.log(_params); // [ 'age' ]

writable

var person = {
  name: 'sadhu'
}

person.name = 'kk';

console.log(person.name); // 'kk' 证明此时name属性是可写的

// 重新定义name属性的类型,让其不可写
Object.defineProperty(person, 'name', {
  writable: false
})

person.name = 'TOM'; // 此时修改无效。

console.log(person.name); // kk

value

var person = {
  name: 'sadhu'
}

Object.defineProperty(person, 'name', {
  value: 'kk'
})

console.log(person.name) // 'kk' 改变了属性原本的值sadhu。

var _person = {};

Object.defineProperty(_person, 'name', {
  value: 'sadhu'
})

console.log(_person.name); // 'sadhu'

get/set

var person = {}

// 通过get与set自定义访问与设置name属性的方式
Object.defineProperty(person, 'name', {
  get: function() {
    // 读取name属性的内容时,只会一直读到值'sadhu'
    return 'sadhu'
  },
  set: function(value) {
    var res = value + '后缀'
    console.log(res);
    return res;
  }
})

console.log(person.name); // sadhu 第一次访问,调用get

person.name = 'kk'; // kk后缀 尝试修改调用set

console.log(person.name); // sadhu 第二次访问还是调用get

console.log(person);

请尽量同时设置get、set。如果仅仅只设置了get,那么我们将无法设置该属性值。如果仅仅只设置了set,我们也无法读取该属性的值。

bject.defineProperties

Object.defineProperty只能设置一个属性的属性特性。当我们想要同时设置多个属性的特性时,需要使用Object.defineProperties.

var person = {}

Object.defineProperties(person, {
    name: {
        value: 'Jake',
        configurable: true
    },
    age: {
        get: function() {
            return this.value || 22
        },
        set: function(value) {
            this.value = value
        }
    }
})

person.name   // Jake
person.age    // 22

读取属性的特性值

var person = {};

Object.defineProperties(person, {
  name: {
    value: 'sadhu',
    writable: false,
    configurable: false
  },
  age: {
    get: function() {
      return this.value || 21
    },
    set: function(value) {
      this.value = value;
    }
  }
})

var desc = Object.getOwnPropertyDescriptor(person, 'name');

console.log(desc);

/*输出
desc {
    value: 'sadhu',
    writable: false,
    enumerable: false,
    configurable: false
}
*/

本文完。

希望看到各位技术人对这篇文章有不同的有“证据”,符合逻辑的分析、看法~

文章会第一时间更新在GitHub,觉得写得还不错的,可以点个star支持下作者🍪


参考:
详解面向对象、构造函数、原型与原型链

webpack初探及核心概念笔记

注:最好的最新的学习资料永远是官方文档,此篇笔记仅是学习官方文档过程中的总结。

webpack定义

webpack is a module bundler.
webpack是一个模块打包工具。(对ES Module/CommonJs/CMD/AMD等模块引入规范的引入模块都可以正确识别)

正确安装webpack

  1. node init (初始化node配置,此操作生成package.json文件)

  2. 初始化后改变一下packge.json配置

    • 当项目不会被外部引用,只有自己使用的时候要去掉 "main" 配置项,没有必要向外暴露一个index.js
    • scripts再去掉
    • 这就初始化好了
  3. webpack安装方式

    • 全局安装

      npm install webpack webpack-cli -g

      这样不推荐,万一别的项目需要旧版本的webpack,而我们全局安装当前最新版本的webpack的话,老版本项目就启动不了了,所以我们需要使用项目分别各自安装webpack的方式。

    • 在局部项目中安装

      npm install webpack webpack-cli --save-dev(等价 -D)
      说明:
      --save--save-dev可以省掉手动修改package.json文件的步骤。
      npm install module-name -save 自动把模块和版本号添加到dependencies部分(运行时的依 赖)
      npm install module-name -save-dve 自动把模块和版本号添加到devdependencies部分(开发时的依赖)

      注:
      devDependencies 下列出的模块,是我们开发时用的,发布后用不到它。dependencies 下的 模块,则是我们发布后还需要依赖的模块,譬如像jQuery库或者Angular框架类似的,开发完后需要 依赖它们,否则执行不了。
      补充:
      正常使用npm install时,会下载dependencies和devDependencies中的模块,当使用npm install –production或者注明NODE_ENV变量值为production时,只会下载dependencies中的模块。

    • 顺便一提 webpack 相关命令

      1. install 好 webpack 后查看版本号 -> webpack -v
      2. 全局删除安装好的 webpack -> npm uninstall webpack webpack-cli -g
  4. 局部项目 webpack 安装好后

    • 项目目录会多出 node_modules 文件夹,这个文件夹是装的 webpack 和它所依赖的包,都安装在这里。
    • 此时直接输入 webpack 命令例如 webpack -v 会报错,是不行的,因为nodejs会去全局找,而我们只安装在了局部。所以此时我们使用 node 提供的命令 -> npx
      例如要找版本,则使用 npx webpack -v 而不是 webpack -v
      npx 命令的话会在 node_modules 文件夹下找,找到 webpack 后再去执行相关命令(该文件夹下有个webpack文件夹)
  5. 补充

    • 要下载特定版本的 webpack,需要这样:
      npm install webpack@版本号
    • 若把 node_modules 删除了,再执行npm install这个命令,就会把项目所依赖的包都安装好,项目目录下 node_modules 就会回来。

webpack的配置文件

在没有手动写配置文件的时候,我们npx webpack 某个文件 or npx webpack打包的话,是采用的 webpack 默认的配置文件,配置文件名为 webpack.config.js,当然这个名称可以修改,具体方式见引用。根据每个项目的特点,我们是可以手写配置文件的。

配置文件默认名称是可以修改的,假如修改为 vue.config.js , 那么命令行里执行一句 npx webpack --config vue.config.js 就行了,这句意思是打包的时候以 vue.config.js 为配置文件进行打包。

我们可以手写一下配置文件:

// webpack.config.js
const path = require('path'); // nodejs的路径模块

module.exports = { // CommonJs的语法
  entry: './src/index.js', // 要打包的入口文件
  output: { // 配置打包后输出的文件
    filename: 'sadhu.js',
    path: path.resolve(__dirname, 'sadhu_dist') // 此时的__dirname就是配置文件的当前路径,sadhu_dist就是该路径下打包后的文件夹
  }
}

配置好后直接执行一下npx webpack,文件目录就会变成:

网页正确显示。

我们再来谈谈 npm scripts 这个方法。

我们一般用 vue/react 打包时都用的命令 npm run dev/build/...,而不是用的 npx webpack 或者 webpack 啥啥啥的 , 这就是因为用到了 npm scripts 方法。
我们来修改 package.json 里 scripts 的字段为这样:

这样的话,我们就可以在命令行运行 npm run build,等同于运行了 webpack 指令去进行打包,这就是所谓的 npm scripts 方法。

webpack-cli

webpack-cli 这个包就是为了命令行能正常识别 webpack 指令,不装这个包就识别不了。

loader

webpack默认识别引入的模块是js模块,如果要引入其他模块比如引入一个图片模块:

那么默认就打包不成功,webpack不知道怎么办。

默认不成功,但是我们可以在模块打包的时候,手动配置一个 loader 告诉 webpack 要怎样打包。

module.exports = {
  entry: './src/index.js', // 要打包的入口文件
  module: { // 配置当打包一个模块儿的时候干啥
    rules: [{
      test: /\.jpg$/, // 当打包以 .jpg 结尾的文件时使用下一行的 loader 来帮助我们坐打包
      use: {
        loader: 'file-loader' // 我们需要先安装一下 file-loader 这个工具 -> npm install file-loader -D
      }
    }]
  },
  output: {
    filename: 'sadhu.js',
    path: path.resolve(__dirname, 'sadhu_dist') // 此时的__dirname就是配置文件的当前路径,sadhu_dist就是该路径下打包后的文件夹
  }
}

所以,loader的定义就是:
loader其实就是一个打包方案,对于某些特定后缀特定文件,webpack本身不知道怎么打包,就可以借助配置loader。

使用 url-loader 的例子

module: { // 配置当打包一个模块儿的时候干啥
    rules: [{
      test: /\.jpg$/, // 当打包以 .jpg 结尾的文件时使用下一行的 loader 来帮助我们坐打包
      use: {
        loader: 'url-loader', // 我们需要先安装一下 file-loader 这个工具 -> npm install file-loader -D
        options: { // 配置参数参考文档,各个什么意思参考官方文档
          name: '[name]_[hash].[ext]',
          outputPath: 'images/',
          limit: 2048 // 在文件大小 > 2kb 时,输出在 images文件夹里,反之,转为 base64 码嵌进 js 中。
        }
      }
    }]
  },

loader的东西还是要多查文档。

使用Plugins让打包跟快捷

用处定义

plugin 可以在webpack运行到某个时刻的时候,帮你做一些事情。(类似于 vue/react 的生命周期函数钩子,如下的 htmlWebpackPlugin 就是在打包完成之后帮我们在 dist 目录下加一个 html 模板)

htmlWebpackPlugin举例

该 plugin 会在打包结束后自动生成一个 html 文件, 并把打包生成的 js 自动引入到这个 html 文件中。

cleanWebpackPlugin举例

该 plugin 的作用是会每次当你输入命令重新打包之前,会删除原来的打包输出的 dist 文件。(导致重新打包后会生成新的 dist 文件。)
用法:

  1. npm install clean-webpack-plugin -D
  2. 在配置文件 webpack.config.js 中定义: const cleanWebpackPlugin = require('clean-webpack-plugin')
  3. 在 plugin 中实例化。(plugins: [new cleanWebpackPlugin(['dist'])]))

sourceMap

定义

使用 sourceMap 来映射业务文件与打包后的文件的关系,更利于排错,控制台直接报出的就是打包前业务文件里第几行的错。总结来说,当打包后的代码出错的时候,配置了sourceMap的话,就直接在控制台提示的是源代码的错误位置。

最佳实践

development 开发环境下,配置sourceMap为 devtool: 'cheap(为了更快性能)-module(为了找出webpack配置的错)-eval-source-map'
production 生产环境下,配置sourceMap为 devtool: 'cheap-module-source-map'

配置devServer来提高开发效率

见下图以及注释:
原本我们是手动 npx webpack 或者 npm run xxx 进行打包后,生成 dist 目录,然后手动打开 dist 目录下的html文件查看页面,配置 webpack-dev-server 后,自动化执行了,见下图注释。

热模块更新 HMR -> Hot Module Replacement

定义(作用)

引用官网一句话:模块热替换(Hot Module Replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允许在运行时更新各种模块,而无需进行完全刷新。(即当我们修改某个模块的内容时,在不变动当前渲染得情况下进行页面修改。)
原理去官网看。

在 webpack-dev-server 中开启热更新

  1. 首先在 devServer 中配置:

  1. 然后引入 webpack 包

  2. 写入 plugins 中

  3. 看情况需要在源代码中编写 module.hot.accept() 实现当某模块修改时要进行什么操作。

HMR的例子

我的webpack的repo里有 引入css模块修改和js模块修改的例子。

注意

在有用 css-loader 的情况下,我们直接修改 css 的一些内容就会在页面当前渲染得情况下响应相应修改。
但是对于webpack默认就可以识别的引入js模块,当某个js模块内内容修改时,不会直接在页面当前渲染情况下响应相应修改,需要自己利用 module.hot.accept('xx', () => {}) 来编写相应代码,拿我repo里js模块修改的例子来说:

这里是当 number.js 里内容修改后,删除修改代码前的 number.js 生成的DOM节点,再次执行修改后的 number.js 生成新的DOM节点挂载到页面上。这样在页面上,就是当 number.js 模块内容修改时,在当前页面渲染的情况下页面响应了该模块的修改内容。
而 css 模块修改内容就直接可以在当前页面渲染情况下响应修改的原因是我们 use 了 css-loader,在 css-loader 中帮我们写了其相应的 module.hot.accept() 的逻辑,所以我们不用自己写,页面直接在当前渲染情况下响应内容。

配置 Babel

按照文档来,一般情况下,分为两种情况来配置:

  1. 对于一般的业务逻辑,要把 ES6 转化为 ES5 语法去兼容以前版本的浏览器的话,使用如下配置:

  1. 我们要生成一些第三方模块 or UI库等库打包的时候,不能使用方法1造成 babel污染全局变量,所以要使用如下配置:
options: {
"plugins": [[
      "@babel/plugin-transform-runtime", {
       "corejs": 2,
       "helpers": true,
       "regenerator": true,
       "useESModules": false
      }]]
}

知识角

JS 转换 UTC 时间为时间戳的精确转换方法:

new Date(Date.parse(value)).getTime();

如果反过来用,比如:

Date.parse(new Date('2021-06-29T16:07:01.352Z'))  Date.parse(new Date('2021-06-29T16:07:01.941Z'))

误差会被抹平。

GMT+0800 的意思是格林威治时间 + 8 区。

new Date(时间戳)只能精确到秒 > Wed Jul 14 2021 13:43:23 GMT+0800 (**标准时间)

要显示毫秒可以利用 :toISOString
new Date(1626241402963).toISOString() > 2021-07-14T05:43:23.168Z

深入js之函数柯里化

什么是柯里化

wiki上这样定义:

在计算机科学中,柯里化(英语:Currying),又译为卡瑞化或加里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

柯里化又称部分求值,字面意思就是不会立刻求值,而是到了需要的时候再去求值。

举个例子:

function add(a, b) {
  return a + b;
}

function curryingAdd(a) {
  return function(b) {
    return a + b;
  }
}

add(1, 2); // 3
curryingAdd(1)(2); // 3

函数add是一般的一个函数,就是将传进来的参数a和b相加;函数curryingAdd就是对函数add进行柯里化的函数;这样一来,原来我们需要直接传进去两个参数来进行运算的函数,现在需要分别传入参数a和b。

为什么要这样做?

  • 提前绑定好函数里面的某些参数,达到参数复用的效果,提高了适用性.
  • 固定易变因素
  • 延迟计算

总之,函数的柯里化能够让你重新组合你的应用,把你的复杂功能拆分成一个一个的小部分,每一个小的部分都是简单的,便于理解的,而且是容易测试的。

如何对函数进行柯里化

由浅入深讲解如何对一个多参数的函数进行柯里化。

第一步(浅)

假如我们要实现一个功能,就是输出语句name喜欢song,其中name和song都是可变参数;那么一般情况下我们会这样写:

function print(name, song) {
  console.log(name + '喜欢的歌曲是:' + song);
}

对print函数进行柯里化后的函数应该是这样:

function curryingPrint(name) {
  return function(song) {
    console.log(name + '喜欢的歌曲是:' + song);
  }
}

var tomLike = curryingPrint('Tom');
tomLike('七里香');
var jerryLike = curryingPrint('Jerry');
jerryLike('雅俗共赏');

// Tom喜欢的歌曲是:七里香
// Jerry喜欢的歌曲是:雅俗共赏

第二步(中)

上面我们虽然对函数print进行了柯里化,但是我们可不想在需要柯里化的时候,都像上面那样不断地进行函数的嵌套,这样代码会很难看,也不容易维护了。所以我们要创造一些帮助其它函数进行柯里化的函数来解决这个问题,我们暂且叫它为curryingHelper吧,一个简单的curryingHelper函数如下所示:

function curryingHelper(fn) {
  var args = Array.prototype.slice.call(arguments, 1);
  return function() {
    var traceArgs = Array.prototype.slice.call(arguments);
    var resArgs = args.concat(traceArgs);
    return fn.apply(null, resArgs); //返回执行结果
  }
}

验证一下?

function showMsg(name, age, fruit) {
  console.log('My name is ' + name + ', I\'m ' + age + ' years old, ' + ' and I like eat ' + fruit);
}

var curryingShowMsg1 = curryingHelper(showMsg, 'sadhu');
curryingShowMsg1(21, 'apple');

console.log('---');

var curryingShowMsg2 = curryingHelper(showMsg, 'sadhu', 21);
curryingShowMsg2('apple');

console.log('---');

var curryingShowMsg3 = curryingHelper(showMsg);
curryingShowMsg3('sadhu', 21, 'apple');

/* 输出
 My name is sadhu, I'm 21 years old,  and I like eat apple
---
My name is sadhu, I'm 21 years old,  and I like eat apple
---
My name is sadhu, I'm 21 years old,  and I like eat apple
*/

证明,我们这个柯里化的函数是🙆的。这里的curryingHelper就是一个高阶函数,函数为参数,返回值也是函数。

第三步(深)

上面的柯里化帮助函数确实已经能够达到我们的一般性需求了,但是它还不够好,我们希望那些经过柯里化后的函数可以每次只传递进去一个参数,然后可以进行多次参数的传递,拿上面的例子来说,就是在能实现curryingHelper的返回函数的调用方式的基础上再实现如下这样传:

betterShowMsg('sadhu')(21)('apple');

动下脑筋,写一个betterCurryingHelper函数来实现:

function curryingHelper(fn) {
  var args = Array.prototype.slice.call(arguments, 1);
  return function() {
    var traceArgs = Array.prototype.slice.call(arguments);
    var resArgs = args.concat(traceArgs);
    return fn.apply(null, resArgs); //返回执行结果
  }
}

function betterCurryingHelper(fn, len) {
  var length = len || fn.length // 可以指出fn总形参的个数
  return function() {
    var allArgsFulfilled = (arguments.length >= length);
    
    // 如果参数全部满足,就可以终止递归调用
    if (allArgsFulfilled) {
      return fn.apply(null, arguments);
    } else {
      var argsNeedFulfilled = [fn].concat(Array.prototype.slice.call(arguments));
      return betterCurryingHelper(curryingHelper.apply(null, argsNeedFulfilled), length - arguments.length);
    }
  }
}

curryingHelper()是第二步里的那个函数。这里的代码其实没啥难度,关键在于你能不能理清递归的过程。理解不了的仔细多看看。

验证一下?

// 验证一下
function showMsg(name, age, fruit) {
  console.log('My name is ' + name + ', I\'m ' + age + ' years old, ' + ' and I like eat ' + fruit);
}

var betterShowMsg = betterCurryingHelper(showMsg);

betterShowMsg('sadhu', 21, 'apple');
console.log('---');
betterShowMsg('sadhu', 21)('apple');
console.log('---');
betterShowMsg('sadhu')(21, 'apple');
console.log('---');
betterShowMsg('sadhu')(21)('apple');

/* 输出
My name is sadhu, I'm 21 years old,  and I like eat apple
---
My name is sadhu, I'm 21 years old,  and I like eat apple
---
My name is sadhu, I'm 21 years old,  and I like eat apple
---
My name is sadhu, I'm 21 years old,  and I like eat apple
*/

成功,刺激。

一个应用场景:给setTimeout传递地进来的函数添加参数。

一般情况下,我们如果想给一个setTimeout传递进来的函数添加参数的话,一般会使用这种方法:

function hello(name) {
    console.log('Hello, ' + name);
}
setTimeout(hello('dreamapple'), 3600); //立即执行,不会在3.6s后执行
setTimeout(function() {
    hello('dreamapple');
}, 3600); // 3.6s 后执行

我们使用了一个新的匿名函数包裹我们要执行的函数,然后在函数体里面给那个函数传递参数值.

当然,在ES5里面,我们也可以使用函数的bind方法,如下所示:

setTimeout(hello.bind(this, 'dreamapple'), 3600); // 3.6s 之后执行函数

这样也是非常的方便快捷,并且可以绑定函数执行的上下文.

我们本篇文章是讨论函数的柯里化,当然我们这里也可以使用函数的柯里化来达到这个效果:

setTimeout(curryingHelper(hello, 'dreamapple'), 3600); // 其中curryingHelper是上面已经提及过的

这样也是可以的,是不是很酷.其实函数的bind方法也是使用函数的柯里化来完成的。可以看这里:深入js之造bind轮子

关于柯里化的性能

当然,使用柯里化意味着有一些额外的开销;这些开销一般涉及到这些方面,首先是关于函数参数的调用,操作arguments对象通常会比操作命名的参数要慢一点;还有,在一些老的版本的浏览器中arguments.length的实现是很慢的;直接调用函数fn要比使用fn.apply()或者fn.call()要快一点;产生大量的嵌套作用域还有闭包会带来一些性能还有速度的降低.但是,大多数的web应用的性能瓶颈时发生在操作DOM上的,所以上面的那些开销比起DOM操作的开销还是比较小的。

本文完。

希望看到各位技术人对这篇文章有不同的有“证据”,符合逻辑的分析、看法~

文章会第一时间更新在GitHub,觉得写得还不错的,可以点个star支持下作者🍪


参考:

  1. 掌握JavaScript函数的柯里化
  2. JavaScript 中的函数式编程实践
  3. 函数式JavaScript(4):函数柯里化
  4. 前端开发者进阶之函数柯里化Currying
  5. js基础篇之——JavaScript的柯里化函数详解
  6. JavaScript专题之函数柯里化

深入js之深究ES6规范前后的执行上下文相关

此文结构是ES5规范前和后的执行上下文相关知识分开介绍。如果对ES5之前的比较熟悉了,可以跳过。想复习一下或不熟悉的也可以看一看。(看了网上很多相关记录的, 其实我觉得基本都众说纷纭,很多种说法,而且有些知识点已经不是以前书中所记录的样子了,包括某些著名书籍如第三版高程/y-d-k-js。当然我这仅仅只是站在chrome浏览器的角度上说的这话,我的理解以chrome的调试为准。)在参考过不少国内外资料也包括部分规范后,我决定写几篇文章记录下,于我而言算为之后秋招复习,更想知道各位对这篇文章有不同的有“证据”,符合逻辑的分析、看法。

ES5规范前

执行上下文是什么?

执行上下文的定义是: 当前js代码被解析和执行时所处环境的抽象概念。

可以理解为,当前js代码的运行环境。

再来了解下js的运行环境,有三种:

  • 全局环境(当js代码运行起来时,会首先进入该环境)
  • 函数环境(当函数被调用执行时,会进入当前函数中执行代码)
  • eval(不建议使用)

每进入一个不同的运行环境就会创建一个相应的执行上下文(Execution Context),容易知道一个js程序中一般会创建多个执行上下文,并且js引擎会以栈的方式对这些执行上下文进行处理,形成函数调用栈(call stack)

一张图理解函数调用栈(call stack)

简单分析下,这个图里例子的函数调用栈的出栈入栈情况。

1. 首先,js引擎进入全局环境,创建全局执行上下文Global Context(图中是用main()来代表)并入栈。
2. 代码执行到console.log()函数,创建该函数的函数执行上下文,入栈。
4. 接着调用bar(6)函数,创建bar函数执行上下文,入栈。
5. 执行到调用foo(...),创建foo函数执行上下文,入栈。
6. foo函数的函数体执行完了,返回该函数返回值,出栈。
7. bar函数的函数体执行完了,返回该函数返回值,出栈。
8. console.log()函数执行完,返回值到console上,出栈。
9. 若此时关闭了浏览器,则全局执行上下文Global Context出栈。

总结:

  1. 全局环境的EC在代码运行起来时创建并入栈,一定永远处于call stack栈底,并且在关闭浏览器时出栈。
  2. 函数执行上下文在函数被调用时创建并入栈,在函数体代码执行结束时出栈,等待垃圾回收。

再来个例子测试下理解了没?

var color = 'blue';

function changeColor() {
    var anotherColor = 'red';

    function swapColors() {
        var tempColor = anotherColor;
        anotherColor = color;
        color = tempColor;
    }

    swapColors();
}

changeColor();

思考一下再看答案:

执行上下文的生命周期

知道了执行上下文和函数调用栈是啥,结合起来是怎么利用的。接下来,就来分析下执行上下文的生命周期。

执行上下文的生命周期有两个阶段:

  • 创建阶段

    此阶段主要做了三件事情:

    1. 创建变量对象Variable Object。
    2. 建立作用域链Scope Chain。(此时的作用域链应为[VO, [[scope]],具体区别稍后会说。)
    3. 绑定this。(因为this的指向取决于函数的调用方式)

  • 代码执行阶段

    此阶段会完成变量赋值,函数引用,以及执行其他代码。

用张完整的图来解释执行上下文的生命周期就是:

了解了执行上下文的生命周期后,接下来我们从创建阶段开始一步步说。

创建阶段

我们在介绍生命周期那里解释了创建阶段会做的三件事情,现在从创建变量对象开始说起。

创建变量对象

一、对于函数执行上下文而言

创建变量对象(Variable Object),前辈们基本都是总结出了三个过程:

  1. 创建arguments对象,检查当前上下文中的参数,建立该对象的属性与属性值,并且把形参初始化在VO中(不理解的话看以下例子),仅在函数环境(非箭头函数)中进行,全局环境没有此过程。
  2. 检查当前上下文的函数声明,按代码顺序查找,将找到的函数提前声明,如果当前上下文的变量对象没有该函数名属性,则在该变量对象以函数名建立一个属性,属性值则为指向该函数所在堆内存地址的引用,如果存在,则会被新的引用覆盖。
  3. 检查当前上下文的变量声明,按代码顺序查找,将找到的变量提前声明,如果当前上下文的变量对象没有该变量名属性,则在该变量对象以变量名建立一个属性,属性值为undefined;如果存在,则忽略该变量声明。

注:

  1. 在全局环境中,window对象就是全局执行上下文的变量对象,所有的变量和函数都是window对象的属性方法。
  2. 所以函数声明提前和变量声明提升是在创建变量对象中进行的,且函数声明优先级高于变量声明。

面试里所谓的变量提升,其实创建变量对象的三个规则里已经说明了。

二、对于全局执行上下文而言,

以浏览器为例,全局对象为window。

全局上下文有一个特殊的地方,它的变量对象,就是window,所有的变量和函数都是window对象的属性方法。而这个特殊,在this指向上也同样适用,this也是指向window。

注: 全局上下文的生命周期,与程序的生命周期一致,只要程序运行不结束,比如关掉浏览器窗口,全局上下文就会一直存在。其他所有的上下文环境,都能直接访问全局上下文的属性。

利用以上规则,我们举个例子来分析创建阶段的执行上下文EC

function fun(a, b) {
    var num = 1;
    var foo = function () {};
    function test() {
        console.log(num)
    };
    var test = 2;
}

fun(2, 3)
// 以浏览器为例
// 全局执行上下文 windowEC
windowEC = {
    VO: window,
    scopeChain: [],
    this: window
}

// 函数执行上下文 funEC
funEC = {
    VO: {
        arguments: {
            0: 2,
            1: 3,
            length: 2
        },
        a: undefined, // 形参初始化在VO中
        b: undefined,
        test: <test reference>, // 表示test函数所在堆内存地址的引用
        num: undefined, // 变量声明提升
        foo: undefined
    },
    scopeChain: [...], // 暂时不管作用域链,稍后解释。
    this: window
}

看懂了吗? 举个例子再试试?会的可以跳过。

function test() {
    console.log(foo);
    console.log(bar);

    var foo = 'Hello';
    console.log(foo);
    var bar = function () {
        return 'world';
    }

    function foo() {
        return 'hello';
    }
}

test();

注:上面我们解释了创建变量对象Variable Object的过程,但需要注意,此时仅仅只是执行上下文生命周期中的创建阶段,尚未进入执行阶段,此时的VO中的属性是不能访问的。怎样才能访问呢?接下来让执行阶段的操作来揭秘。

创建阶段的作用域链和this绑定暂时跳过,稍后介绍。

执行阶段

我们已经知道代码执行阶段会完成变量赋值函数引用,以及执行其他代码

说白了就是引擎开始执行代码了嘛。

举个例子:var a = 2,在上下文创建阶段扫描代码,变量提升'var a',可以理解为此时执行的是var a (实际并没有执行而只是扫描)。而在执行阶段等于引擎就执行的是a=2了。但是注意一点,a=2这个操作,实际是 作用域(规则)配合引擎进行的LHS查询,为VO中的a属性赋值2。那我们之前不是刚说过VO中的属性是不能访问的吗?这里怎么可以访问了?其实我们之前说的没错,只是到了执行阶段VO产生一些变化了:

进入执行阶段之后,变量对象(Variable Object)转变为了活动对象(Activi Object),里面的属性都能被访问了,然后开始进行执行阶段的操作。

这也就是我们思维导图里画的所谓的 VO => AO

变量对象VO与活动对象AO的区别

他们其实都是同一个对象,只是处于执行上下文的不同生命周期。不过只有处于函数调用栈栈顶的执行上下文中的变量对象,才会变成活动对象。

还是上面那个例子,给观众姥爷们感受一下执行阶段产生的影响(下面的例子也会把作用域链的值也写出来,用数组表示,不懂的可先感受下,暂时跳过):

function fun(a, b) {
    var num = 1;
    var foo = function () {};
    function test() {
        console.log(num)
    };
    var test = 2;
}

fun(2, 3)
// 函数上下文创建阶段: 
funEC = {
    VO = {
        arguments: {
            0: 2,
            1: 3,
            length: 2
        },
        a: undefined,
        b: undefined,
        test: <test reference>,
        num: undefined,
        foo: undefined
    },
    scopeChain: [VO, GO(window)], // 也可以写成 [VO, [[scope]]] 
    this: window
}
// 函数上下文执行阶段: 
// VO => AO
funEC = {
    AO = {
        arguments: {
            0: 2,
            1: 3,
            length: 2
        },
        a: 2,
        b: 3,
        test: 2,
        num: 1,
        foo: <foo reference>
    },
    scopeChain: [AO, GO(window)], // 也可以写成 [AO, [[scope]]]
    this: window
}

贴个图验证下:


局部变量指的是当前函数的局部变量。在函数执行上下文中,当前函数的局部变量、函数、形参都是声明保存在变量对象中的。

再来个例子,试着写一写?

function test() {
    console.log(foo);
    console.log(bar);

    var foo = 'Hello';
    console.log(foo);
    var bar = function () {
        return 'world';
    }

    function foo() {
        return 'hello';
    }
}

test();

作用域链

JavaScript代码的整个执行过程,分为两个阶段,代码编译阶段与代码执行阶段。编译阶段由编译器完成,将代码翻译成可执行代码,这个阶段作用域规则会确定。执行阶段由引擎完成,主要任务是执行可执行代码,执行上下文在这个阶段创建。这个要清楚哟。

我们知道函数在调用激活时,会开始创建对应的执行上下文,在执行上下文生成的过程中,变量对象,作用域链,以及this的值会分别被确定。这里我们来说下作用域链:

作用域链,是由当前环境与上层环境的一系列变量对象组成,它保证了当前执行环境对符合访问权限的变量和函数的有序访问。

结合一个例子来理解:

var a = 20;

function test() {
    var b = a + 10;

    function innerTest() {
        var c = 10;
        return b + c;
    }

    return innerTest();
}

test();

在上面的例子中,当执行到刚刚调用innerTest函数,进入innerTest函数环境,此时处于innerTest执行上下文的创建阶段。全局执行上下文和test函数执行上下文已进入执行阶段,所以他们的活动对象和变量对象分别是AO(global),AO(test)和VO(innerTest),而innerTest的作用域链由当前执行环境的变量对象(未进入执行阶段前)与上层环境的一系列活动对象组成,如下:

innerTestEC = {
    VO: {...},  // 变量对象
    scopeChain: [VO(innerTest), AO(test), AO(global)], // 作用域链
    this: window
}

我们这里直接使用数组表示作用域链,作用域链的活动对象或变量对象可以直接理解为作用域(但实际上作用域只是一套规则)。

  • 作用域链的第一项永远是当前作用域(当前上下文的变量对象或活动对象);

  • 最后一项永远是全局作用域(全局执行上下文的活动对象);

  • 作用域链保证了变量和函数的有序访问,查找方式是沿着作用域链从左至右查找变量或函数,找到则会停止查找,找不到则一直查找到全局作用域,再找不到则会抛出引用错误。


(上图AO(global)。写AO(innerTest)表示此时是进入执行阶段时函数的作用域链)

验证一下?

注: 闭包可以使内部函数访问外层函数的局部变量,即访问外层函数环境的活动对象属性。

再来看看我们思维导图:

这里提到了[[scope]],而且为什么作用域链可以表示成 AO+[[scope]] 呢?

首先我们要知道[[scope]]是啥。

借用下汤姆大叔的解释:

  1. 理论上函数应该能访问一个更高一层上下文的变量对象。实际上它正是这样,这种机制是通过函数内部的[[scope]]属性来实现的。
  2. [[scope]]是所有父变量对象的层级链,处于当前函数上下文之上,在函数创建时存于其中。
  3. [[scope]]在函数创建时被存储--静态(不变的),永远永远,直至函数销毁。即:函数可以永不调用,但[[scope]]属性已经写入,并存储在函数对象中。

看不懂?没事儿,看下面张图(例子还是上一个,没有改变):

现在知道为什么作用域链还可以表示成 AO+[[scope]] 了吧?还没反应过来的再去仔细看看上上张图。

关于ES5规范前的执行上下文相关知识,暂且写到这里。有额外的知识会在之后的文章中再记录。

目前到此为止没有疑惑吗?

我有。

JS代码的整个执行过程不是分编译阶段(编译器完成,将代码翻译成可执行代码)和执行阶段(引擎完成,执行可执行代码)嘛。

下面阐述两个观点:

  1. 我们都知道函数执行上下文是在函数调用时创建的。那么都执行到调用函数这步了,引擎肯定已经开始执行可执行代码了。

  2. 再来,从创建变量对象过程中包含声明提升我们就知道这应该是在代码执行前进行的,也就是在编译阶段进行的!比如var a = 2var a代码被扫描,a变量提升,应该是在编译阶段进行的;a = 2则是在引擎在执行阶段执行。也就是说,创建变量对象过程应该发生在编译阶段,而网上也有文章如是说。这也是尚未进入上下文执行阶段,变量对象VO不能访问其中属性的一种解释。

这两个观点有错吗?如果没错,这两个观点矛盾吗?思考下。







我一度觉得矛盾,究矛盾根源就是:我认为JS代码整个执行过程会很直男地从左到右,过了编译阶段进入执行阶段,就永远不能再进编译阶段。而这就与上面两个观点相驳:已经在执行阶段进行到创建函数上下文了,怎么可能又再返回编译阶段呢?

带着疑问各处找资料参阅书籍的时候,最终在书上找到了答案(原话):

  1. JavaScript compilation doesn't happen in a build step ahead of time, as with other languages. // JS的编译过程不是发生在构建之前的。
  2. JS engines use all kinds of tricks (like JITs, which lazy compile and even hot re-compile, etc.) which are well beyond the "scope" of our discussion here. // js引擎会用尽各种方法(比如JIT,可以延迟编译甚至实施重编译)来保证性能最佳。
  3. that any snippet of JavaScript has to be compiled before (usually right before!) it's executed. // 任何js代码片段在执行前都要进行编译(通常就在执行前)。

ES5规范后

推荐一篇比较权威的译文:【译】理解 Javascript 执行上下文和执行栈

思维导图:

关于这部分,我是之前做了些笔记在笔记本,我拍照贴上来,字不好看但还能看清吧...不想看就跳过哈~这笔记就是个小总结,没啥干货,东西都在那篇译文里。

聪明的你可以自行理解对比下es5前后规范执行上下文的不同与相同之处。

本文完。

希望看到各位技术人对这篇文章有不同的有“证据”,符合逻辑的分析、看法~

觉得写得还不错的,可以点个star支持下作者🖖🏼

从 MVC 到 Flux,从 Redux 到 Mobx

前端状态管理的工具库纷杂,在开启一个新项目的时候不禁让人纠结,该用哪个?其实每个都能达到我的目的,我们想要的无非就是管理好系统内的状态,使代码利于维护和拓展,尽可能降低系统的复杂度。

使用 Vue 的同学可能更愿意相信其官方的生态,直接上 vuex/pinia,不用过多纠结。由于我平常使用 React 较多,故就当前应用较广泛的 Redux、Mobx 俩工具库为例,研读了一番,记录下自己的一些闲言碎语。

注意:以下不会涉及到各个库的具体用法,多是探讨各自的设计理念、推崇的模式(patterns),提前说明,以免耽误大家时间。

Redux、Mobx 或多或少都借鉴了 Flux 理念,比如大家经常听到的 “单向数据流” 这项原则最开始就是由 Flux 带入前端领域的,所以我们先来聊聊 Flux。

Flux

Flux 是由 facebook 团队推出的一种架构理念,并给出一份代码实现

为什么会有 Flux 的诞生?

Facebook 一开始是采用传统的 MVC 范式进行系统的开发

但随着业务逻辑的复杂,渐渐地发现代码里越来越难去加入新功能,很多状态耦合在了一起,对于状态的处理也耦合在了一起

造成了 FB 团队对 MVC 吐槽最深的两个点:

  1. Controller 的中心化不利于扩展,核心是因为 Controller 里需要处理大量复杂的对于 Model 更改的逻辑
  2. 对于 Model 的更改可能来源于各个方向。 可能是开发者本身想对 Model 进行更改、可能是 View 上的某个回调想对 Model 进行更改,可能是一个 Model 的更改引发了另一个 Model 的更改。

我们可以大概总结出,基于 MVC 的数据流向就有三种:

  • Controller -> Model -> View
  • Controller -> Model -> View -> Model -> View ... (loop)
  • Controller -> Model1 -> Model2 -> View1 -> view2 ...

并且这三种数据流向在实际业务中还很有可能是交织在一起。

为了改善以上 MVC 在复杂应用中的缺陷,降低系统整体复杂度,FB 团队推出了 Flux 架构,结合 React 重构了他们的代码,这就是 Flux 架构诞生的原因。

Flux 具体是什么

Flux 由几个部分组成:

  • Store
  • Action
  • View
  • Dispatcher

Store 就是存放 Model 的地方,View 和 MVC 的 View 一样就是视图,Action 可以理解为操作 Model 的行为,Dispatcher 可以理解为找到 Action 对应 Store 并执行操作的分发执行者。

Dispatcher 是 Flux 的核心,它把对 Model 的操作给统一了起来,在 Flux 里,Dispatcher 与 View 可以视为 Model 的唯一输入输出。

那么相对于 MVC 带来的变化或者说好处是什么呢?

首先,数据流统一了,无论谁想要操作 Model,必须通过 disptacher ,同时 dispatcher 与 Action 配合,等同于是给 Model 约定了有限的、分离开的几种操作。对比 MVC 里中心化的 Controller 中对大量复杂的 Model 逻辑的操作,在 Flux 中就是被抽象分离到一个个 Action 里去了。所以状态在整个系统里的流向就是:

Action -> dispatcher -> Model -> View -> Action -> dispatcher -> Model -> View ...

这就是所谓的 “单向数据流”。 相对于 MVC 中多种数据流交叉,单向数据流明显降低了系统的复杂度。

又因为 Action 给 Model 约定了有限的几种操作,仅根据 Action 的输入,开发者就知道会发生怎样的变更,提高了代码的可预测性

基于 MVC 范式的代码,项目的长期维护者可能清楚各个地方状态变更会引发什么操作,但是若团队来了新人,想搞清楚这些肯定需要费不少劲,假如基于 Flux 架构,开发者只需要追踪一个 Action 是怎样在整个系统中流动的,就知道系统的其余部分是怎样工作的了。因为 Flux 的数据流动是单向的且一致的。比如通过 Action 就知道要对状态做怎样的变更,再搜一下哪里用了变更的状态就知道这里的视图会 rerender,同样的搜一下哪个地方 disptach 了这个 action,就知道谁想对状态做出改变。

另外,还可以结合纯函数的概念来感受下 Dispatcher 的设计

这在 Redux 的实现中体现地尤为明显,就是在找到 Action 对应的 Store 后,单纯只负责根据 Action 处理对 Model 的改动逻辑,不会改变入参或外部变量,相同的输入始终对应相同的 Store 更改。意味着之后任意一个时间点做出一个之前时间点的 Action,得到的更改后的状态与之前得到的是一致的。这就是所谓的 “时间旅行” 功能的原理,“时间旅行” 本质就是记录下每一次数据修改,只要每次修改都是无状态的,那么我们理论上就可以通过修改记录还原之前任意时刻的数据。

结合纯函数的设计至少可以带来两点好处:

  1. **方便开发者调试。**我们可以利用 “时间旅行” 复现之前任意时间点的状态,可以统一地在 dispatcher 里加日志看到何时做了什么改变。
  2. 基于纯函数构建的代码更容易写单测

Redux

Redux 就是基于 Flux 架构理念的一种函数式地实现,并做出了一些优化,所以 Redux 拥有 Flux 架构的所有优点。

上图是 Redux 官网给的展示 Redux 工作原理的 gif 图,朴素一点展示 Redux 的核心组成就是:

其中,Reducer 就是 Flux Dispatcher 的纯函数式实现,找到 Action 对应的 Model,返回一个更改后的对象给 Redux,Redux 在 Store 上应用更改。

据以上俩图,可以明显感知到 Redux 数据流动是单向的:

action -> middleware -> reducer -> store -> view -> action -> middleware -> reducer -> store -> view ...

解释 Redux 三个基本原则

Redux 官方表明可以用三个基本原则描述 Redux :“单一数据源“、“只读的 state“、“使用纯函数来执行修改“。

**“单一数据源“**相对于分散数据源肯定是优势的,除非各个数据源之间毫无联系。但只要是有联系的多个数据源,你始终要通过某些操作把各个数据源给联系起来,无疑增加了复杂度。

**“只读的 state”**也就是不允许直接修改 Model,必须创建个 action ,交给 reducer 处理,保证 Model 只有唯一输入,这是践行单向数据流的基本要求。

**“使用纯函数来执行修改”**就是要求用户编写的 reducer 必须得是纯函数,方便方便开发者调试也易于写单测。

另外,要求开发者编写纯函数的 reducer 还有个想突出点就是 Redux 推崇的 Immutable 特性。

Immutable 与 Mutable

什么是 Immutable ,什么是 Mutable ?

Immutable 意为「不可变的」。在编程领域,Immutable Data 是指一种一旦创建就不能更改的数据结构。它的理念是:在更改时,产生一个与原对象完全一样的新对象,指向不同的内存地址,互不影响。

Mutable 意为 「可变的」。与 Immutable 相反,Mutable Data 就是指一种创建后可以直接更改的数据结构。

对于 JS 而言,所有原始类型 (Undefined, Null, Boolean, Number, BigInt, String, Symbol) 都是 Immutable 的,但是引用类型的值都是 Mutable 的。

举两个例子直观感受下:

例一:

let a = { x: 1 };
let b = a;
b.x = 6;

a.x // 6

例二:

function doSomething(x) { /* 在此处改变 x 会影响到 str 和 obj 吗? */ };
var str = 'a string';
var obj = { an: 'object' };
doSomething(str); // 基础类型都是 immutable 的, function 得到的是一个副本
doSomething(obj); // 对象传递的是引用,在 function 内是 mutable 的
doAnotherThing(str, obj); // `str` 没有被改变, 但是 `obj` 可能已经变化了。

js 中其实有几种方式可以让值变为 Immutable 的,为了不跑题,大家可以去 wikipedia 拓展阅读。要注意的是,无论是writeable: falseObject.freezeconst,其修饰/冻结/声明的属性/值都只在第一层生效,假如属性/值嵌套了引用类型值,则需要递归去修饰/冻结/声明才能达到整体 Immutable 的目的。

Immutable 与 Mutable 的优缺

Mutable

其实比较显而易见,Mutable 的数据,开发者可以直接更改,但是要负担更改后副作用的思考。

优点:操作便利。

缺点:开发者内心要知道更改了 Mutable 数据后,会导致哪些副作用,会怎样影响到其他用到该数据的地方。

Immutable

其实就是 Mutable 的相反,不过可以基于 Immutable 特性做一些额外的功能。

优点:

  1. 避免了数据更改的副作用 (在多线程语言中就是有了所谓 “线程安全性”)

    JS 虽然没有多线程的概念,但有竞态的概念。 JS 中引用类型的值都是按引用传递的,在一个复杂应用中会有多个变量指向同一个内存地址的情况,如果有多个代码块同时更改这个引用,就会产生竞态。你需要关心这个对象会在哪个对方被修改,你对它的修改会不会影响其他代码的运行。使用 Immutable Data 就不会产生这个问题——因为每当状态更新时,都会产生一个新的对象。

  2. 状态可追溯

    由于每次修改都会创建一个新对象,且对象不会被修改,那么变更的记录就能够被保存下来,应用的状态变得可控、可追溯。Redux Dev Tool 和 Git 这两个能够实现「时间旅行」的工具就是秉承了 Immutable 的哲学。

缺点也是相对于 Mutable 方式的:

  1. 额外的CPU、内存开销
  2. 达到修改值的目的要做额外的操作

尽管生态内有如 Immutable.js、Immer.js 等库帮助我们更便捷地操作 Immutable 更改,但是这两个缺点也是无法避免的,只是尽可能地做优化。

至于是采用 Immutable 还是 Mutable 的方案去写代码,个人觉得还是得 case by case 地去聊,显然 Redux 推崇 Immutable,基于此提供了时间旅行的功能,React 推荐开发者使用 Immutable ,是因为 React 的 UI = fn(states) 模型中,React 对 state 是 shallowMerge 的,如果 mutable state 没改变引用,React 会认为不需要去 diff,自然不会 rerender 。但是 Mobx 就是推崇 Mutable 的,开发者使用体验很好。

Mobx

Mobx 是一个推崇自动收集依赖与执行副作用的响应式状态管理库,推荐开发者使用 Mutable 直接更改状态,框架内部帮我们管理(派生)每个 Mutable 的副作用并实现最优处理。

由上图可感知 Mobx 也是践行单向数据流的理念:

Action -> State -> Computed Value -> Reaction(like render) -> Action ...

这里引入了新的概念是 Computed ValueReaction 。Mobx 是多 Store 的,联系多个数据源的数据可以用 Computed Value ,同一个数据源想要多个数据派生出一个新数据也可以用 Computed ValueReaction 的话就是 state 的所有副作用,可以是 render 方法,可以是 Mobx 自带的 autorun、when 等。

Mobx 想要达到的目的其实就是开发者能自由地管理状态、让修改状态的行为简单直接,其余交给 Mobx。

想要达到这一目的,Mobx 内部就要做更多的事情,其作者 Michel Weststrate 有在一篇推文中阐述过 Mobx 设计原则,但是有点过于细节,不熟悉 Mobx 底层机制的同学可能不太看得懂。以下,在基于这篇推文结合对源码的探究,我提炼一下,感兴趣可以去看原文。

自荐对 Mobx 源码的解析文章: Mobx 源码与设计**。文章较长,建议专门找时间安静地一口气看完。

对状态改变作出反应永远好过于对状态改变作出动作

针对这点其实与 Vue 响应式传递的理念相同,就是数据驱动

再分析这句话,“作出反应” 意味着状态与副作用的绑定关系由框架(库)给你做好,状态改变自动通知到副作用,不用使用者(开发者)人为地处理。

“作出动作”则是在使用者已知状态更改的情况下,手动去通知副作用更新。 这起码就有一个操作是使用者必做的:手动在副作用内订阅状态的变化,这至少带来两个缺陷:

  1. 无法保证订阅量的冗余性,可能订阅多了可能少了,导致应用出现不符合预期的情况。
  2. 会让业务代码变得更 dirty,不好组织

最小的、一致的订阅集

以 render 作为副作用举例,假如 render 里有条件语句:

render() {
  if (依赖 A) {
    return 组件 1;
  }
  return 依赖 B ? 组件 2 : 组件 3
}

首先,如果交给用户手动订阅,必须只能依赖 A、B 的状态一起订阅才行,如果订阅少了无法出现预期的 re-render。

然后交给框架去做处理怎样才好? 依赖 A、B 一起订阅当然没毛病,但是假设依赖 A、B 初始化时都有值,我们有必要让 render 订阅依赖 B 的状态吗?

没必要,为什么?想一想如果此时依赖 B 的状态变化了 re-render 呈现的效果会有什么不同吗?

所以在初始化时就订阅所有的状态是冗余的,假如应用程序复杂、状态多了,没必要的内存分配就会更多,对性能有损耗。

故 Mobx 实现了运行时处理依赖的机制,**保证副作用绑定的是最小的、一致的订阅集。**源码见Mobx 源码与设计** 中 “getter 里干了啥?” 与 “处理依赖” 章节。

派生计算(副作用执行)的合理性

说人话就是:杜绝丢失计算、冗余计算

丢失计算:Mobx 的策略是引入状态机的概念去管理依赖与派生,让数学的逻辑性保证不会丢失计算。

冗余计算:

  1. 对于非计算属性状态,引入事务概念,保证同一批次中所有对状态的同步更改,状态对应的派生只计算一次。
  2. 对于计算属性,计算属性作为派生时,当其依赖变化,计算属性不会立即重新计算,会等到计算属性自身作为状态所绑定的派生再次用到计算属性值时才去重新计算。并且计算出相同值会阻止派生继续处理。

Redux vs Mobx

如上面分析,Redux 是一个重**轻代码的状态管理库,Mobx 则是相反,框架帮我们做了更多的事,用起来简单。

稍微总结下区别:

  1. Redux 要求开发者按它的模式(patterns)写代码,Mobx 则自由许多,用起来更简单。相对地,基于 Redux 开发的系统健壮性要强一些,使用 Mobx 却管理不好状态的话,会使系统更难维护(咦,这为啥没渲染?咦!这为啥渲染了这么多次??(逃 )。
  2. Redux 结合函数式与 Immutable 的特性提供了时间旅行功能,更方便开发者调试与回溯状态。 Mobx 则是有提供一个全局监听的钩子,监听每一个状态改变与副作用的触发,我们直接打日志调试,但是相对于 Redux 肯定是没那么方便的。
  3. Redux 推崇单 Store 管理状态,降低状态管理的复杂度。Mobx 则不给开发者设限,开发者可以以任一形式管理状态,如果是多 Store,提供了 Computed Value 作为多 Store 数据的联系桥梁。
  4. Mobx 框架内部会帮我们实现最优渲染(副作用执行),Redux 则需要我们编写各种 selector 或用 memo 手动去优化...

以上,欢迎有理有据地指正、补充。

若觉得有帮助,欢迎 star watch ✍🏼

参考:

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.