Giter VIP home page Giter VIP logo

blog's Introduction

我是 null,擅长移动 Web 开发、React 全家桶、前端工程化、Web 渲染等方面的技术栈。

闲暇之余,我喜欢在知乎(前端漫谈)输出技术博客,也是慕课网《web前端开发修炼指南》的作者,擅长移动 Web 开发、React 全家桶、前端工程化、Web 渲染等方面的技术栈。

感兴趣可以加我微信 gioryyin 一起尬聊。

React 系列

工作沉淀

Canvas

前端工程化杂谈

JavaScript 面向对象

重学 ES6

JavaScript 进阶

Underscore 源码解读

其他

blog's People

Contributors

yinguangyao avatar

Stargazers

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

Watchers

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

blog's Issues

js函数柯里化

前言

随着 React/Redux 的火热,函数式编程也逐渐被带入了前端的应用领域,甚至还诞生了 elm、ClojureScript 等基于 JavaScript 的函数式语言。熟练掌握这节课的内容,对后续学习函数式编程会有一定帮助。

1. 高阶函数

高阶函数也是函数式编程中的一个概念,使用范围比较广泛。在现在很火的 React 中,高阶组件就是基于高阶函数发展而来。
先看一下高阶函数的定义:

高阶函数,又称算子(运算符)或泛函,包含多于一个箭头的函数。
在数学和计算机科学中,高阶函数是至少满足下列一个条件的函数:

  1. 接受一个或多个函数作为输入
  2. 输出一个函数

举一个简单的例子:

const add = function(x, y, f) {
    return f(x) + f(y);
}

这个 add 函数就是一个高阶函数,它接收了另一个 f 函数。
而在 ES5 中出现的 forEachmapsomeevery 等函数也属于高阶函数,他们都接收了一个匿名函数作为参数:

const arr = [1, 2, 3];
const iterator = function(item, index) {
    console.log(item);
}
arr.forEach(iterator);

2. 偏函数

下面是维基百科对偏函数 (Partial application) 的定义:

In computer science, partial application (or partial function application) refers to the process of fixing a number of arguments to a function, producing another function of smaller arity.

翻译一下,意思就是在计算机科学中,部分应用程序(或者部分功能应用程序)是指固定一个函数的一些参数,然后产生另一个更小元的函数。

那么什么是元呢?元就是函数参数的个数,比如带有两个参数的函数被称为二元函数。

偏函数是函数式编程中的一部分,使用偏函数可以冻结那些预先确定的参数来缓存函数参数。在运行的时候,当获得需要的剩余参数后,可以将他们解冻,传递到最终的参数中,从而使用最终确定的所有参数去调用函数。

简单来说就是,把一个函数的某些参数给固定住(也就是设置默认值),返回一个新的函数,调用这个新函数会更简单。

举个比较简单的例子,下面的 sum_add_1 就是一个偏函数。

function sum(a, b) {
    return a + b;
}
// 正确调用
sum(2, 3); // 5
const sum_add_1 = partial(sum, 1);
sum_add_1(2); // 3
sum_add_1(3); // 4

那么怎么实现这个 partial 方法呢?实际上使用原生的 bind 方法就能产生一个偏函数。

const sum_add_1 = sum.bind(null, 1);

可是 bind 函数中一般需要传入上下文给第一个参数,我们这里可以实现一个无关上下文的 partial 函数。
由于 partial 函数执行后返回了一个新的函数,那么它一定是个高阶函数。可以考虑如下实现:

const partial = (func, ...args) => {
    return (...rest) => {
        return func.apply(this, [...args, ...rest])
    }
}

3. 柯里化

在 JS 的函数式编程中,柯里化是一个很重要的概念,这个概念在我们实际开发中也经常会用到。

3.1 定义

函数柯里化的意思就是你可以一次传很多参数给 curry 函数,也可以分多次传递,curry 函数每次都会返回一个函数去处理剩下的参数,一直到返回最后的结果。

这里还是举几个例子来说明一下:

3.2 柯里化求和函数

    // 普通方式
    var add1 = function(a, b, c){
        return a + b + c;
    }
    // 柯里化
    var add2 = function(a) {
        return function(b) {
            return function(c) {
                return a + b + c;
            }
        }
    }

这里每次传入参数都会返回一个新的函数,这样一直执行到最后一次返回 a+b+c 的值。
但是这种实现还是有问题的,这里只有三个参数,如果哪天产品经理告诉我们需要改成100次?我们就重新写100次?这很明显不符合开闭原则,所以我们需要对函数进行一次修改。

var add = function() {
    var _args = [];
    return function() {
        if(arguments.length === 0) {
            return _args.reduce(function(a, b) {
                return a + b;
            })
        }
        [].push.apply(_args, arguments);
        return arguments.callee;
    }
}
var sum = add();
sum(100, 200)(300);
sum(400);
sum(); // 1000

我们通过判断下一次是否传进来参数来决定函数是否运行,如果继续传进了参数,那我们继续把参数都保存起来,等运行的时候全部一次性运行,这样我们就初步完成了一个柯里化的函数。

3.3 通用柯里化函数

这里只是一个求和的函数,如果换成求乘积呢?我们是不是又需要重新写一遍?仔细观察一下我们的 add 函数,如果我们将if里面的代码换成一个函数执行代码,是不是就可以变成一个通用函数了?

var curry = function(fn) {
    var _args = [];
    return function() {
        if(arguments.length === 0) {
            return fn.apply(fn, _args);
        }
        [].push.apply(_args, arguments);
        return arguments.callee;
    }
}
var multi = function() {
    return [].reduce.call(arguments, function(a, b) {
        return a + b;
    })
}
var add = curry(multi);
add(100, 200, 300)(400);
add(1000);
add(); // 2000

在之前的方法上面,我们进行了扩展,这样我们就已经实现了一个比较通用的柯里化函数了。
也许你想问,我不想每次都使用那个丑陋的括号结尾怎么办?

var curry = function(fn) {
	var len = fn.length,
		args = [];
	return function() {
		Array.prototype.push.apply(args, arguments)
		var argsLen = args.length;
		if(argsLen < len) {
			return arguments.callee;
		}
		return fn.apply(fn, args);
	}
}
var add = function(a, b, c) {
	return a + b + c;
}

var adder = curry(add)
adder(1)(2)(3)

这里根据函数 fn 的参数数量进行判断,直到传入的数量等于 fn 函数需要的参数数量才会返回 fn 函数的最终运行结果,和上面那种方法原理其实是一样的,但是这两种方式都太依赖参数数量了。
我在简书还看到别人的另一种递归实现方法,实现思路和我类似。

// 简单实现,参数只能从右到左传递
function createCurry(func, args) {

    var arity = func.length;
    var args = args || [];

    return function() {
        var _args = [].slice.call(arguments);
        [].push.apply(_args, args);

        // 如果参数个数小于最初的func.length,则递归调用,继续收集参数
        if (_args.length < arity) {
            return createCurry.call(this, func, _args);
        }

        // 参数收集完毕,则执行func
        return func.apply(this, _args);
    }
}

这里是对参数个数进行了计算,如果需要无限参数怎么办?比如下面这种场景。

add(1)(2)(3)(2);
add(1, 2, 3, 4, 5);

这里主要有一个知识点,那就是函数的隐式转换,涉及到 toStringvalueOf 两个方法,如果直接对函数进行计算,那么会先把函数转换为字符串,之后再参与到计算中,利用这两个方法我们可以对函数进行修改。

var num = function() {
}
num.toString = num.valueOf = function() {
	return 10;
}
var anonymousNum = (function() { // 10
	return num;
}())

经过修改,我们的函数最终版是这样的。

var curry = function(fn) {
	var func = function() {
		var _args = [].slice.call(arguments, 0);
		var func1 = function() {
			[].push.apply(_args, arguments)
			return func1;
		}
		func1.toString = func1.valueOf = function() {
			return fn.apply(fn, _args);
		}
		return func1;
	}
	return func;
}
var add = function() {
	return [].reduce.call(arguments, function(a, b) {
		return a + b;
	})
}

var adder = curry(add)
adder(1)(2)(3)

那么我们说了那么多,柯里化究竟有什么用呢?

3.4 预加载

在很多场景下,我们需要的函数参数很可能有一部分一样,这个时候再重复写就比较浪费了,我们提前加载好一部分参数,再传入剩下的参数,这里主要是利用了闭包的特性,通过闭包可以保持着原有的作用域。

var match = curry(function(what, str) {
  return str.match(what);
});

match(/\s+/g, "hello world");
// [ ' ' ]

match(/\s+/g)("hello world");
// [ ' ' ]

var hasSpaces = match(/\s+/g);
// function(x) { return x.match(/\s+/g) }

hasSpaces("hello world");
// [ ' ' ]

hasSpaces("spaceless");
// null

上面例子中,使用 `hasSpaces 函数来保存正则表达式规则,这样可以有效的实现参数的复用。

3.5 动态创建函数

这个其实也是一种惰性函数的**,我们可以提前执行判断条件,通过闭包将其保存在有效的作用域中,来看一种我们平时写代码常见的场景。

 var addEvent = function(el, type, fn, capture) {
     if (window.addEventListener) {
         el.addEventListener(type, function(e) {
             fn.call(el, e);
         }, capture);
     } else if (window.attachEvent) {
         el.attachEvent("on" + type, function(e) {
             fn.call(el, e);
         });
     } 
 };

在这个例子中,我们每次调用 addEvent 的时候都会重新进行if语句进行判断,但是实际上浏览器的条件不可能会变化,你判断一次和判断N次结果都是一样的,所以这个可以将判断条件提前加载。

var addEventHandler = function(){
    if (window.addEventListener) {
        return function(el, sType, fn, capture) {
            el.addEventListener(sType, function(e) {
                fn.call(el, e);
            }, (capture));
        };
    } else if (window.attachEvent) {
        return function(el, sType, fn, capture) {
            el.attachEvent("on" + sType, function(e) {
                fn.call(el, e);
            });
        };
    }
}
var addEvent = addEventHandler();
addEvent(document.body, "click", function() {}, false);
addEvent(document.getElementById("test"), "click", function() {}, false);

但是这样做还是有一种缺点,因为我们无法判断程序中是否使用了这个方法,但是依然不得不在文件顶部定义一下 addEvent,这样其实浪费了资源,这里有一种更好的解决方法。

var addEvent = function(el, sType, fn, capture){
    if (window.addEventListener) {
        addEvent =  function(el, sType, fn, capture) {
            el.addEventListener(sType, function(e) {
                fn.call(el, e);
            }, (capture));
        };
    } else if (window.attachEvent) {
        addEvent = function(el, sType, fn, capture) {
            el.attachEvent("on" + sType, function(e) {
                fn.call(el, e);
            });
        };
    }
}

addEvent 函数里面对其重新赋值,这样既解决了每次运行都要判断的问题,又解决了必须在作用域顶部执行一次造成浪费的问题。

4. 反柯里化

上面我们介绍过函数柯里化,从字面意思上来理解,反柯里化恰恰和柯里化相反,是为了扩大适用范围,创建一个应用范围更广的函数。使本来只有特定对象才适用的方法,扩展到更多的对象。
看下面一个例子,我们给函数增加一个反柯里化的方法。

Function.prototype.unCurry = function() {
    const self = this;
    return function() {
        return Function.prototype.call.apply(self, arguments);
    }
}

通过反柯里化方法,甚至可以让对象使用数组的 push 方法:

const obj = {};
const push = Array.prototype.push.unCurry();
push(obj, 1, 2, 3);
console.log(obj); // { 0: 1, 1: 2, 2: 3}

但是直接在函数原型上面修改不太好,这里可以实现一个更加通用的反柯里化方法。

const unCurry= function(fn) {
    return function(target, ...rest) {
        return fn.apply(target, rest);        
    }    
};

使用方法和原来的类似:

const obj = {};
const push = unCurry(Array.prototype.push);
push(obj, 1, 2, 3);
console.log(obj); // { 0: 1, 1: 2, 2: 3}

简单理解,柯里化就是对高阶函数进行降阶处理,而反柯里化增加反过来扩大使用范围。

// 柯里化
function(a)(b) -> function(a)(b)
// 反柯里化
target.func(a, b) -> unCurry(func)(target, a, b)

反柯里化的好处就是将原本只有 target 能使用的方法借了出来,可以给更多对象来使用。
我们在开发中,经常会借用 Object.prototype.toString 来检测一个变量的类型,这也是反柯里化的用法之一。

const num = 1, 
    str = '2', 
    obj = {}, 
    arr = [],
    nul = null;
const toString = unCurry(Object.prototype.toString.call);
toString.call(nul); // "[object Null]"
toString.call(num); // "[object Number]"
toString.call(str); // "[object String]"
toString.call(arr); // "[object Array]"

5. 推荐阅读

  1. 高阶函数
  2. 偏函数
  3. Javascript偏函数与柯里化

mock server实践

前言

最近在做管理台重构的时候,由于后端没有人力支持,刚哥让我自己用mock数据来模拟接口数据,到时候把方案在组里面分享一下。
于是我调查了现在常用的三种mock方案,最后选择了RAP2来做数据mock。

为什么需要mock?

前后端同时开发的时候,后端接口数据没有出来,甚至接口还没发到测试环境,这个时候前端可以mock接口和假数据,模拟开发;

常见的几种mock方式

  1. 代码里面写死mock数据,等接口好了后,改成动态请求接口,但后期修改量太大
  2. 本地写好一个json文件模拟数据,通过请求json文件获得数据后渲染到页面
  3. 搭建mock服务,定义好请求url和请求字段、响应字段等等,直接请求接口获取模拟数据。

常用的mock方案

  1. Easy-mock
  2. Rap2
  3. yapi

easy-mock

easy-mock 是一个在线模拟后台的数据平台,通过官网注册账户之后,你就可以生成一个在线的API接口,然后通过ajax或者axios就可以访问这个接口了。
Easy-mock允许我们使用mockjs的语法,通过编写模板来生成随机数据实现数据模拟。
mock语法:
image_1dlbphc5u10p0omq101dv481ldi9.png-79.5kB
生成mock数据:
image_1dlbphpoa14e0184k1pgu11rvcf1m.png-219.7kB

甚至支持编写函数实现更加灵活的数据mock。
编写函数:
image_1dlbpi57kqipgr3qhq14rm1ffd13.png-87.8kB
生成mock数据:
image_1dlbpiefas4og1pjboapr1ila1g.png-156kB

mockjs

  1. Mockjs语法是由三部分组成,分别是属性名、生成规则和属性值
  2. 属性名即字段名,属性名和生成规则之间用竖线 | 分隔。
  3. 属性值中可以含有 @占位符。
  4. 属性值还指定了最终值的初始值和类型。
    更多mockjs语法请看官网:mockjs语法

rap2

rap2 是一个可视化接口管理工具 通过分析接口结构,动态生成模拟数据,校验真实接口正确性, 围绕接口定义,通过一系列自动化工具提升我们的协作效率。

  1. 支持mock.js语法:rap2本身基于mock.js
  2. 支持接口管理:可管理url地址,不同模块分类。
  3. 支持团队协作:拥有团队仓库
  4. 支持历史修改操作查看:可查看接口修改情况,但不支持操作回溯。
    5.接口共享:不需要重复编写接口

查看成员活动:
image_1dlbpoab54a4fgo12bt169p14ig2d.png-127.8kB
接口管理:
image_1dlbpopc51k481ukfgpu7cj1ud92q.png-172kB

yapi

yapi是去哪儿团队出品的在线模拟后台的数据平台,它拥有最丰富的界面操作以及最强大的mock定制功能。
相比easy-mock和rap2,yapi的优势在于:

  1. 更丰富的界面化操作,甚至不需要编写mockjs语法
  2. 支持mockjs规则导入
  3. 支持编写脚本运行更复杂的规则
  4. 支持数据导入与导出

数据导入与导出:

image_1dlbq12rhk0v4s2v71n4v13ei37.png-103.5kB
界面化规则:

image_1dlbq1s8t1besptraktmg01dto3k.png-73.3kB
支持编写脚本与期望:

image_1dlbq2fkf1d0576b4m11n6p11mk41.png-57.3kB

在项目中使用

由于这三种mock方案都支持本地部署,所以可以将其部署到内网中。如果不方便进行本地部署,那么也可以直接使用在线服务(yapi的在线服务非常不稳定)。
我们可以在项目中使用nginx或者whistle等进行接口转发,比如将以/mock开头的接口转发到rap2的在线服务上面,这样在项目中可以直接调用rap2的在线服务(注意:yapi的在线服务不支持跨域)。

总结

这三种mock方案,easy-mock和rap2依然需要掌握mockjs的语法,尤其是easy-mock缺少界面化操作,rap2拥有不错的界面操作,但仍需要我们记住部分mockjs语法规则。
而yapi则拥有最为完整的界面,可以让我们摆脱mockjs各种晦涩的语法,完全在界面上填写规则。同时yapi还支持高度定制化脚本,无疑是三种mock服务中功能最强大的一个。

实现一个bind函数


目前的打算还是继续深入前端基础知识,所以打算从polyfill开始做起。

bind函数

bind函数最常见的用法是绑定函数的上下文,比如在setTimeout中的this一般都是指向window,如果我们想改变上下文,这里可以使用bind函数来实现。

var a = 10;
var test = function() {
    console.log(this.a);
}
// 如果直接执行test,最终打印的是10.
var bindTest = test.bind({a: "111"})
bindTest(); // 111

从上面这个例子可以看出来,bind函数改变了test函数中this的指向。
除此之外,bind函数还有两个特殊的用法,一个是柯里化,一个是绑定构造函数无效。

柯里化

bind函数的柯里化其实是不完全的,其实只做了一次柯里化,看过MDN的polyfill实现后也就理解了。

var test = function(b) {
    return this.a + b;
}
// 如果直接执行test,最终打印的是10.
var bindTest1 = test.bind({a: 20});
bindTest1(10); // 30
// 这里的bind是个柯里化的函数
var bindTest2 = test.bind({a: 20}, 10);
bindTest2(); // 30;

构造函数无效

其实准确的来说,bind并不是对构造函数无效,只是对new的时候无效,如果直接执行构造函数,那么还是有效的。

var a = 10;
var Test = function(a) {
    console.log(this.a);
}
var bindTest = Test.bind({a: 20});
bindTest(); // 20
// 在new的时候,Test中的this并没有指向bind中的对象
new bindTest(); // undefined

实现一个bind

我们可以先实现一个简易版本的bind,再不断完善。由于是在函数上调用bind,所以bind方法肯定存在于Function.prototype上面,其次bind函数要有改变上下文的作用,我们想一想,怎么才能改变上下文?没错,就是call和apply方法。

然后还要可以柯里化,还好这里只是简单的柯里化,我们只要在bind中返回一个新的函数,并且将前后两次的参数收集起来就可以做到了。

Function.prototype.bind = function() {
    var args = arguments;
    // 获取到新的上下文
    var context = args[0];
    // 保存当前的函数
    var func = this;
    // 获取其他的参数
    var thisArgs = Array.prototype.slice.call(args, 1);
    var returnFunc = function() {
        // 将两次获取到的参数合并
        Array.prototype.push.apply(thisArgs, arguments)
        // 使用apply改变上下文
        return func.apply(context, thisArgs);
    }
    return returnFunc;
}

这里实现了一个简单的bind函数,可以支持简单的柯里化,也可以改变上下文作用域,但是在new一个构造函数的时候还是会改变上下文。

这里我们需要考虑一下,怎么做才能让在new的时候无效,而其他时候有效?

所以我们需要在returnFunc里面的apply第一个参数进行判断,如果是用new调用构造函数的时候应该传入函数本身,否则才应该传入context,那么该怎么判断是new调用呢?

关于在new一个构造函数的时候,这中间做了什么,建议参考这个问题:在js里面当new了一个对象时,这中间发生了什么?

所以我们很容易得出,由于最终返回的是returnFunc,所以最终是new的这个函数,而在new的过程中,会执行一遍这个函数,所以这个过程中returnFunc里面的this指向new的时候创建的那个对象,而那个新对象指向returnFunc函数。

但是我们希望调用后的结果只是new的func函数,和我们正常new func一样,所以这里猜想,在returnFunc中,一定会将其this传入func函数中执行,这样才能满足这几个条件。

Function.prototype.bind = function() {
    var args = arguments || [];
    var context = args[0];
    var func = this;
    var thisArgs = Array.prototype.slice.call(args, 1);
  	var returnFunc = function() {
      Array.prototype.push.apply(thisArgs, arguments);
      // 最关键的一步,this是new returnFunc中创建的那个新对象,此时将其传给func函数,其实相当于做了new操作最后一步(执行构造函数)
      return func.apply(this instanceof returnFunc ? this : context, thisArgs);
    }
    return returnFunc
}
function foo(c) {
    this.b = 100;
    console.log(c);
    return this.a;
}

var func =  foo.bind({a:1});
var newFunc = new func() // undefined

但是这样还是不够的,如果foo函数原型上面还有更多的方法和属性,这里的newFunc是没法获取到的,因为foo.prototype不在newFunc的原型链上面。
所以这里我们需要做一些改动,由于传入apply的是returnFunc的一个实例(this),所以我们应该让returnFunc继承func函数,最终版是这样的。

Function.prototype.bind = function() {
    var args = arguments || [];
    var context = args[0];
    var func = this;
    var thisArgs = Array.prototype.slice.call(args, 1);
    var returnFunc = function() {
      Array.prototype.push.apply(thisArgs, arguments);
      // 最关键的一步,this是new returnFunc中创建的那个新对象,此时将其传给func函数,其实相当于做了new操作最后一步(执行构造函数)
      return func.apply(this instanceof func ? this : context, thisArgs);
    }
    returnFunc.prototype = new func()
    return returnFunc
}

这样我们就完成了一个bind函数,这与MDN上面的polyfill实现方式大同小异,这里可以参考一下MDN的实现:Function.prototype.bind()

参考链接:
1. MDN:Function.prototype.bind()

2. 手写bind()函数,理解MDN上的标准Polyfill

underscore throttle节流函数分析

这是underscore源码剖析系列第五篇,今天来聊一下throttle和debounce两个函数。

throttle节流函数

Javascript中的函数大多数情况下都是用户调用执行的,但是在某些场景下不是用户直接控制的,在这些场景下,函数会被频繁调用,容易造成性能问题。
比如在window.onresize事件和window.onScroll事件中,由于用户可以不断地触发,这会导致函数短时间内频繁调用,如果函数中有复杂的计算,很容易就造成性能的问题。
这些场景下最主要的问题是触发频率太高,1s内可以触发数次,但是大多数情况下我们并不需要那么高的触发频率,可能只要在500ms内触发一次,这样其实我们可以用setTimeout来解决,在这期间的触发都忽略掉。
我们可以先尝试着自己实现一个节流函数:

  // 自己实现的简单节流函数
function throttle (func, time) {
	var timeout = null,
		context = null,
		args = null
	return function() {
	    context = this
		args = arguments
		// 只要timeout函数存在,所有调用都无视
		if(timeout) return;
		timeout = setTimeout(function() {
			func.apply(context, args)
			clearTimeout(timeout)
			timeout = null
		}, time||500)
	}
}

我们实现了一个简单的节流函数,但是还不够完整,如果我想在第一次触发的时候立即执行怎么办?如果我想禁用掉最后一次执行怎么办?underscore中实现了一个比较完整的节流函数。

// options是一个对象,如果options.leading为false,就是禁用第一次触发立即调用
// 如果options.trailing为false,则是禁用第一次执行
_.throttle = function (func, wait, options) {
		// 一些初始化操作
		var context, args, result;
		var timeout = null;
		var previous = 0;
		if (!options) options = {};
		var later = function () {
			// 如果禁用第一次首先执行,返回0否则就用previous保存当前时间戳
			previous = options.leading === false ? 0 : _.now();
			// 解除引用
			timeout = null;
			result = func.apply(context, args);
			// 看到一种说法是在func函数里面重新给timeout赋值,会导致timeout依然存在,所以这里会判断!timeout
			if (!timeout) context = args = null;
		};
		return function () {
		    // 获取当前调用时的时间(ms)
			var now = _.now();
			// 如果previous为0并且禁用了第一次执行,那么将previous设置为当前时间
			// 这里用全等来避免undefined的情况
			if (!previous && options.leading === false) previous = now;
			// 还要wait时间才会触发下一次func
			var remaining = wait - (now - previous);
			context = this;
			args = arguments;
			// remaining小于0有两种情况,一种是上次调用后到现在已经到了wait时间
			// 一种情况是第一次触发的时候并且options.leading不为false,previous为0,因为now记录的是unix时间戳,所以会远远大于wait
			// remaining大于wait的情况我自己不清楚,但看到一种说法是客户端系统时间被调整过,可能会出现now小于previous的情况
			// 这两种情形下会立即执行func函数,并把previous设置为now
			if (remaining <= 0 || remaining > wait) {
				if (timeout) {
				    // 清除定时器
					clearTimeout(timeout);
					timeout = null;
				}
				// previous保存当前触发的时间戳
				previous = now;
				result = func.apply(context, args);
				if (!timeout) context = args = null;
			// 如果timeout不存在(当前定时器还存在)
			// 并且options.trailing不为false,这个时候会重新设置定时器,remaining时间后执行later函数
			} else if (!timeout && options.trailing !== false) {
				timeout = setTimeout(later, remaining);
			}
			return result;
		};
	};

这段代码看着不多,但是让我纠结了很久,运行的时候主要会有以下几种情况。

没有传leading和trailing

  1. 第一次触发函数的时候,由于previous为0,而now又非常大,所以会导致remaining为负值,满足下面第一个if判断,所以会立即执行func函数(第一次触发时立即调用)并且用previous记录当前时间戳
  2. 第二次触发的时候由于previous记录了前一次的时间戳,所以now - previous几乎为0,这个时候满足else if里面的判断,会设置一个定时器,这个定时器在remaining时间后执行,所以只要在remaining时间内不管我们再怎么频繁触发,由于不会满足两个if里面的条件,所以都不会执行func,一直到remaining后才会执行func
  3. 之后每次触发都会重复走2的流程

options.leading: false

这种情况和上面情况类似,不过区别在于第一次触发的时候。
由于满足!previous && options.leading === false这个条件,所以previous会被设置为now,这个时候remaining等于wait,所以会走else if的分支,这样就会重复前一种情况下步骤2的流程

options.trailing: false

  1. 由于没有设置leading为false,所以第一次触发就会立即执行一次func
  2. 第二次触发的时候,由于previous保存了上次时间戳,所以remaining <= wait,但是又因为options.trailing为false,这样就不会走if的任何一个分支,一直到now-previous大于wait的时候(也就是过了wait时间后),这样会满足if第一个分支的条件,func会立即被执行一次
  3. 之后重复步骤2

trailing和leading都为false

最好不要这么写,因为会导致一个bug的出现,如果我们在一段时间内频繁触发,这个是没什么问题,但如果我们最后一次触发后停止等待ait时间后再重新开始触发,这时候的第一次触发就会立即执行func,leading为false并没有生效。

不知道有没有人和我一样有这两个疑问,leading为false的时候,真的只是在第一次调用的时候有区别吗?trailing是怎么做到禁用最后一次执行的?
这两个问题让我昨晚睡觉前都还在纠结,还好今天在segmentfault上面有热心的用户帮我解答了。
请直接看第一个回答以及下面的评论区:关于underscore源码中throttle函数的疑惑?

leading带来的不同表现

GDUTxxZ大神给了一段代码,执行后不同的表现让我印象深刻。

var _now = new Date().getTime()
var throttle = function(func, wait, options) {
  var context, args, result;
  var timeout = null;
  var previous = 0;
  if (!options) options = {};
  var later = function() {
    previous = options.leading === false ? 0 : new Date().getTime();
    timeout = null;
    result = func.apply(context, args);
    if (!timeout) context = args = null;
  };
  return function() {
    console.log(`函数${++i}在${new Date().getTime() - _now}调用`)
    var now = new Date().getTime();
    if (!previous && options.leading === false) previous = now;
    var remaining = wait - (now - previous);
    context = this;
    args = arguments;
    // 如果超过了wait时间,那么就立即执行
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }
      previous = now;
      result = func.apply(context, args);
      if (!timeout) context = args = null;
    } else if (!timeout && options.trailing !== false) {
      timeout = setTimeout(later, remaining);
    }
    return result;
  };
};
var i = 0
var test = throttle(() => {
  console.log(`函数${i}在${new Date().getTime() - _now}执行`)
}, 1000, {leading: false})

setInterval(test, 3000)

我将传入leading和没传入leading的情况作了以下比较。
leading为false时:
leading为false
没有传入leading时:
leading为true
当两次触发间隔时间大于wait时间的时候,很明显leading为false的时候总会在调用后延迟wait后执行func,而不传leading的时候两者是同时的,调用test的时候就直接运行了func。原本应该是callback => wait => callback

一般情况下当然不会有这种极端情况存在,但是可能出现这种情况。如果在scroll事件中,我们滚动一段距离后停止了,等wait ms后再开始滚动,这个时候如果leading为false,依然会延迟wait时间后执行,而不是立即执行,这也是为什么同时设置leading和trailing为false的时候会出现问题。

为什么是禁用最后一次调用

trailing为false时到底是怎么禁用了最后一次调用?这个也一直让我很纠结。同样的,我也写了一段代码,比较了一下两次运行后的不同结果。

var _now = new Date().getTime()
var throttle = function(func, wait, options) {
var context, args, result;
var timeout = null;
var previous = 0;
if (!options) options = {};
var later = function() {
previous = options.leading === false ? 0 : new Date().getTime();
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
return function() {
console.log(`函数${++i}在${new Date().getTime() - _now}调用`)
var now = new Date().getTime();
if (!previous && options.leading === false) previous = now;
var remaining = wait - (now - previous);
context = this;
args = arguments;
// 如果超过了wait时间,那么就立即执行
if (remaining <= 0 || remaining > wait) {
  if (timeout) {
    clearTimeout(timeout);
    timeout = null;
  }
  previous = now;
  result = func.apply(context, args);
  if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
  timeout = setTimeout(later, remaining);
}
return result;
};
};
var i = 0
var test = throttle(() => {
console.log(函数${i}在${new Date().getTime() - _now}执行)
}, 1000, {trailing: false})
window.addEventListener("scroll", test)

trailing为false时:
trailing为false

没有设置trailing时:
没有设置trailing

这两张图很明显的不同就是设置了trailing的时候,最后一次总是"执行",而未设置trailing最后一次总是"调用",少了一次执行。

我们可以假设在一种临界的场景下,比如在倒数第二次执行func后的 (wait-1) 的时间内。
如果设置了trailing,因为无法走setTimeout,所以只能等待wait时间后才能立即调用func,所以在(wait-1)的时间内无论我们触发了多少次都不会执行func函数。
如果没有设置trailing,那么肯定会走setTimeout,在这个期间触发的第一次就会设置一个定时器,等到wait时间后自动执行func函数,到(wait-1)的这段时间内不管我们触发了多少次,反正第一次触发的时候就已经设置了定时器,所以到最后一定会执行一次func函数。

总结

很久以前就使用过throttle函数,自己也实现过简单的,但是看到underscore源码后才发现原来还会有这么多令人充满想象的场景,自己所学的这点知识真的是皮毛。
我知道自己平时叙述比较罗嗦,语言又比较无聊,希望大家可以理解,如果看完还不懂,建议结合下面的参考链接。
本文有错误和不足之处,也希望大家能够指出。

参考链接:##

  1. 关于underscore源码中throttle函数的疑惑?
  2. underscore 函数节流的实现
  3. Underscore之throttle函数源码分析以及使用注意事项
  4. 浅谈 Underscore.js 中 _.throttle 和 _.debounce 的差异

深入理解 webpack 模块

前言

在上篇讲 Nuxt 同构问题的时候,我有提到过 NodeJS 和 webpack 的模块化实现。今天主要来讲解 webpack 中的模块化。

如果你有观察过 webpack 转换后的代码,一定会发现,不管是 import 还是 require 都会被转换成 webpack_require 这种形式。

image

webpack 自己实现了一套模块化的规范,使用 webpack_require 来导入模块,将其挂载到 module.exports 上面,有点儿类似 CommonJS 的模块化规范。

一个来自 QQ 群的提问

某天晚上,我的 QQ 群有个童鞋问了这么一个问题:

image

image

我也比较好奇为什么 require 引入的图片还需要在后面加个 default 呢?为什么 import 引入的却不需要?是否和 file-loader 处理图片文件有关?

带着这个疑问,于是我写了一个简单的 DEMO 来验证了一下,代码如下:

image

在执行了 webpack 命令后,可以看到编译后的精简代码是这样的:

image

webpack 模块源码分析

首先,我们可以看出来这个编译后的 js 文件就是一个立即执行函数,他接收了当前文件引入的外部模块作为一个参数,所有的外部模块被放到了一个对象当中,以当前 src 目录下的绝对路径作为 key 值,value 这是一个方法,这个方法注入了 webpack_requirewebpack_exports 作为参数,简单来说就是类似于:

(function(modules) {
})({
 "./src/logo.png": function(module, __webpack_exports__, __webpack_require__) {
    eval("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = (__webpack_require__.p + \"a218f2cb12bf56dd2a68003790d1e986.png\");\n\n//# sourceURL=webpack:///./src/logo.png?");
  }
})

我们可以明显看到,这个图片在导出的时候,实际上是在 __webpack_exports__["default"] 里面的,那么在使用 require 引入的时候又是什么样的呢?
我们来看一下 index.tsx 被编译后的代码:

/***/ "./src/pages/home/index.jsx":
/*!**********************************!*\
  !*** ./src/pages/home/index.jsx ***!
  \**********************************/
/*! exports provided: default */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);__webpack_require__.d(__webpack_exports__, \"default\", function() { return Index; }); var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(\"./node_modules/[email protected]@react/index.js\"); var react__WEBPACK_IMPORTED_MODULE_0___default = __webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__); var _constants__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(\"./src/pages/home/constants.js\");\n\n\n\nvar logo = __webpack_require__( \"./src/logo.png\");\n\nfunction Index(props) {\n  return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(\"img\", {\n    src: logo\n  });\n}\n\n//# sourceURL=webpack:///./src/pages/home/index.jsx?");

很明显可以看到,这里在引入 logo 这个图片的时候,是直接使用 __webpack_require__ 来导入的,我们前面看到过 __webpack_require__ 的实现。

/******/ 	function __webpack_require__(moduleId) {
/******/
/******/ 		// Check if module is in cache
/******/ 		if(installedModules[moduleId]) {
/******/ 			return installedModules[moduleId].exports;
/******/ 		}
/******/ 		// Create a new module (and put it into the cache)
/******/ 		var module = installedModules[moduleId] = {
/******/ 			i: moduleId,
/******/ 			l: false,
/******/ 			exports: {},
/******/ 			hot: hotCreateModule(moduleId),
/******/ 			parents: (hotCurrentParentsTemp = hotCurrentParents, hotCurrentParents = [], hotCurrentParentsTemp),
/******/ 			children: []
/******/ 		};
/******/
/******/ 		// Execute the module function
/******/ 		modules[moduleId].call(module.exports, module, module.exports, hotCreateRequire(moduleId));
/******/
/******/ 		// Flag the module as loaded
/******/ 		module.l = true;
/******/
/******/ 		// Return the exports of the module
/******/ 		return module.exports;
/******/ 	}

这里只是返回了 module.exports,并没有 default,所以如果是直接用 require 来引入图片的话,那就肯定不会生效。
如果我们使用 import 来导入的话会怎么样呢?

/***/ "./src/pages/home/index.jsx":
/*!**********************************!*\
  !*** ./src/pages/home/index.jsx ***!
  \**********************************/
/*! exports provided: default */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);__webpack_require__.d(__webpack_exports__, \"default\", function() { return Index; }); var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(\"./node_modules/[email protected]@react/index.js\"); var react__WEBPACK_IMPORTED_MODULE_0___default = __webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__); var _constants__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(\"./src/pages/home/constants.js\"); var _logo_png__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__( \"./src/logo.png\");\n\n\n\n\nvar constants = __webpack_require__( \"./src/pages/home/constants.js\");\n\nfunction Index(props) {\n  return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(\"img\", {\n    src: _logo_png__WEBPACK_IMPORTED_MODULE_2__[\"default\"]\n  });\n}\n\n//# sourceURL=webpack:///./src/pages/home/index.jsx?");

我们明显可以看到,虽然导入的时候也没有带上一个 default,但是 React 在创建 img 标签的时候,给它带上了一个 default,关键点在于这句 return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(\"img\", {\n src: _logo_png__WEBPACK_IMPORTED_MODULE_2__[\"default\"]\n }),所以直接用 import 也是可以导入的。

es module 和 commonjs

实际上,如果你在 NodeJS 里面使用过一些 npm 上面第三方的模块,会发现导入的时候都是要求我们使用 require('').default 的,比如大名鼎鼎的 node-xlsx

import xlsx from 'node-xlsx';
// Or var xlsx = require('node-xlsx').default;

const data = [[1, 2, 3], [true, false, null, 'sheetjs'], ['foo', 'bar', new Date('2014-02-19T14:30Z'), '0.3'], ['baz', null, 'qux']];
var buffer = xlsx.build([{name: "mySheetName", data: data}]); // Returns a buffer

相信看了前面的分析后,你也能猜到这是为什么了吧?node-xlsx 是直接使用 export default 导出的,而 webpack 为了抹平这种差异,导致我们不得不使用 require.default 来导入它。

编写可读代码的艺术

这是机械工业出版社的《编写可读代码的艺术》的读书笔记。

第一章:

  • 好的名字、好的注释以及好的代码格式

第二章:

  • 选择专业的词,比如从网上获取到数据,用fetch比用get要好得多
    image
  • 避免空泛的名字。
    比如在for循环里面,我们直接用i、j、k这种命名在嵌套复杂的时候并不适合,比如我们遍历一个users,我们可以用ui来代替。
  • 用具体的名字代替抽象的名字。
  • 为名字附带更多信息。
    比如一个变量保存了ID,但是是十六进制的,用hex_id就比id好。
    如果变量是一个度量的话,可以给名字带上单位,比如start_ms比start好。
    image
  • 把握住名字长度
    在小的作用域里面可以使用短的名字,但是在更大的作用域甚至全局作用域最好就别这样。
    有时候,别用首字母缩写的方式来命名变量,但是可以用eval代替evaluation,doc代替document。
  • 拿掉没用的词
    比如convertToString就不如用ToString这个名字,这样没有丢失任何有用的信息。
    在html里面,可以用下划线给id命名,但是用-来分开class里面的单词

第三章:

  • 用max和min表示极限,用first和last表示包含的范围
  • is、has、can、should之类的词表示布尔值就更清晰

第四章:

  • 使用整洁的代码格式和注释,相同的注释可以合并到一起,可以把重复的代码封装起来,这样会让代码结构变得更漂亮,代码应该根据相似性来分类,这样不会造成一大坨代码的情况。

第五章:

  • 不要为了不好的变量名加注释,应该把名字改好。
  • 有时候可以在注释中记录你对代码价值的见解,比如这段代码这样写可以让速度快20%。
  • 为代码中的瑕疵写注释,通常写的几种标记,甚至可以把将来代码应该如何改动的想法用注释记录下来,给读者和以后的开发者提供宝贵的意见。有可能的话,可以给读者解释一下这段代码的作用
    image
  • 给总结性的注释,比如可以在一个负责缓存的页面最上面注释一下,这个文件是处理缓存的。可以根据情况考虑注释这段代码是"做什么的"“怎么做”“为什么”。
  • 把心里想的写出来,再读一下,看看语言方面有什么可以改进的地方,并不断地改进。

第六章:

  • 让注释紧凑,不要写多余的注释。
  • 避免使用不明确的代词,比如你处理缓存,你用it或者this明显不容易理解,不如用cache
    image

第七章:

  • 写比较式的时候,一般左边是需要比较的变量,右边是常量。
  • 谨慎使用三目运算符。三目运算符只在最简单的情况下使用。
  • 避免使用do while循环,可以使用while来代替。
  • 最小化嵌套(避免花括号嵌套很深的代码)

再见,携程

前言

从16年校招就开始与携程结缘,17年毕业后加入携程至今。在这短暂的一年多时间里,我受益匪浅,很感谢携程的培养,希望携程能越做越好。

初次相遇

时间回到2016年9月,我正在为校招找工作的事情急得团团转。由于对面试缺少准备,也错过了内推,又一直挂在笔试上,每天压力大到睡不着。

恰好那个时候,我收到了携程的面试电话,虽然对携程这家公司没有太多好感,但还是抱着试试的心态去参加了面试。

面试官是个温柔、和蔼的人,问了我一些问题,给我耐心地讲了这边的业务和技术栈。

后来我才知道,这位面试官是度假BU的前端老大 —— 路总。

热恋期

刚毕业的时候,我总是带着一股学生气,心理不够强大,也过于任性,总是被需求折腾的崩溃了,还好有同事包容着。

记得刚来这边的时候,我们做的还是老自由行项目,可以说是历史遗留技术债了,对于刚毕业的我来说,阅读前人代码非常吃力。原本很简单的需求,我总是要花很多天才能完成,对自己打击也比较大。

后来老大让我智伟带一下我,我还记得在这边写的第一个新页面是门票详情页。是的,那一个页面我写了整整一个多月,智伟总会指出我哪里写的不好,我也会和他讨论哪种实现更好,很感激他的耐心。

大概在工作三个月后,这边的技术栈全面更新,从原来的lizard+react换成了映杰大佬的react-imvc,这是一个集成了react、状态管理、路由和同构的框架,功能很强大,也很实用。

得益于react-imvc这个新框架,我们也随便将原来的项目迁移到新的技术栈上。DP2.0一期的时候我几乎每天都在加班,每周末都在加班,一边赶工,一边思考怎么实现功能,累并快乐着。(现在回头看,当时的代码写的不忍直视)

那时候的我,对这一切都感觉很新鲜,每天都在不断地吸收新的知识。

平淡期

转眼间,2017年就过去了,我也从一个刚毕业时懵懂的学生变成了一个稍微有些经验的社会人了。

没有了刚毕业时的繁忙,也没有了对新框架的新鲜感,我也开始把工作当做完成任务,每天都想着早点儿下班回家看看书,刷刷github,很渴望提升技术。

由于后来一直在做重复工作,我也对工作越来越没有兴趣。工作对于我来说就是打打卡而已,我也懒得关心其他的事情。这也导致了我年中的时候,就有离职跳槽的想法,但是又觉得,应该是我对工作关心太少了,只沉浸在自己的世界里面,也许应该再观察半年看看?

真正的转折点在11月份团建去三亚,每天都躺在酒店里面,无聊的时候就去游泳,简直就是神仙日子。在那段日子里,我开始思考,工作和生活的意义是什么?

我意识到自己一心沉迷于技术,却忽悠了业务的重要性。和我同时进来的小伙伴们都能独挡一面了,我却还像个襁褓中的孩子一样,需要别人把需求喂到我的嘴边,我也开始对自己的能力产生了怀疑。

至于意识怎么觉醒的呢?也许是从看到映杰开发imvc框架的时候种子就已经埋在了心里,亦或许是看到了雨飞的微信打包机器人。是啊,如果技术不能服务于产品,那又有什么用呢?

纠结良久,我胆怯地找了领导沟通,聊了聊自己这一年的状态,聊了聊自己以后想做什么,也表示自己愿意去做更多重要的、核心的业务,领导也表示赞许。

年终自评的时候,我果断地写下了,我想像映杰那样,成为一个内外兼修的技术大神,可以用自己的技术来推动业务发展。

离别

离开的想法一旦产生,就会一直埋在心底,发芽,生长。

在携程的最后两三个月,我变得更加积极、主动,也变得更乐于分享,我很清楚自己的问题在哪里,即使是亡羊补牢,我也希望能够改正自己的缺点。

领导也履行了自己的诺言,让我负责更多的业务,让我尝试去带新人。我带着新人去开会,给他们讲需求,自己也慢慢变得可以独挡一面。

可我还是会觉得无聊,没有热情。真的是我不愿意去了解业务吗?我思考了很久,我对这边的业务和产品确实不感兴趣。

重复的工作和低效率的沟通让我疲于奔命,我也知道自己是时候离开了。

终于,在年后的第一周,拒绝了领导的各种挽留后,我毅然选择提了离职。

结语

虽然很多人说,同事和朋友是两回事,可我还是要说,我在这边一年多来交到了很多不错的朋友。

我从智伟、雨飞和映杰身上学习到了很多东西,关于技术,关于业务,关于生活,这些东西也改变了我的思维方式。

最后,感谢一直和我斗嘴的智康,感谢对我很宽容照顾的卓予小姐姐,感谢能撩又调皮的秋玉小姐姐,感谢经常和我谈论人生未来的桢哥,也感谢组里面其他的小伙伴们,谢谢你们给了团队一个良好的氛围。

虽有百般不舍,但终究难免一别,希望大家以后还能再遇见。

image_1d5ovu38e82phbgvo5itdetm16.png-431.2kB
image_1d5pbhpsuqhg1jbh141h1mg06gpr.png-11278.4kB
image_1d5pbk661139j30tiaf7a74ld1h.png-1290.9kB

移动端开发技术详解

前言

之前上家公司主要是做移动端 H5 开发的,但相关技术和配套体系已经很成熟了,很难接触到背后的这套体系。

在现在的公司也做了一些零散的 H5 页面,有一些相关实践。反而因为基础设施和体系不完善,接触到了更多东西。

刚好要搞 RN,于是恶补了一些相关的知识,顺便把 H5 开发中的一些东西也温习记录了一遍。

Native App

在说 Hybrid App 之前不得不先讲到 Native App,这是最为传统的一种移动端开发技术。

在 iOS 和安卓中官方的开发语言是 oc/swift、java/kotlin,使用这些开发出来的 App 一般称之为原生应用。

image.png-50.9kB

优点

原生应用一般体验较好,性能比较高,可以提前把资源下载到本地,打开速度快。

除此之外,原生应用可以直接调用系统摄像头、通讯录、相册等功能,也可以访问到本地资源,功能强大。

一般需要开发 App,原生应用应该是首选。

缺点

原生应用最大的缺点就是不支持动态化更新,这也是很多大厂不完全使用原生开发的原因。

考虑一下,如果线上出现严重问题,那该怎么办呢?

首先客户端开发修复了 bug 之后,就需要重新发版、提交应用商店审核,这一流程走下来往往需要好几天的时间。

如果发布了新版 App,用户该怎么去更新呢?答案是没法更新。他们只能重新去下载整个 App,但实际上可能只更新了一行文案,这样就得不偿失了。

除此之外,最麻烦的地方在于要兼容老版本的 App。比如我们有个列表页原本是分页加载的,接口返回分页数据。产品说这样体验不好,我们需要换成全量加载,那接口就需要做成全量的。

但接口一旦换成了全量的,老版本的客户端里面依然是分页请求接口的,这样就会出现问题。因此,接口不得不根据不同版本进行兼容。

Web App

Web App 就是借助于前端 HTML5 技术实现的在浏览器里面跑的 App,简单来说就是一个 Web 网站。

因为是在浏览器里面运行,所以天然支持跨平台,一套代码甚至很容易支持移动端和 PC 端不需要安装到手机里面,上线发版也比较容易。

image.png-297.4kB
缺点也很明显,那就是只能使用浏览器提供的功能,无法使用手机上的一些功能。比如摄像头、通讯录、相册等等,局限性很大。

也由于依赖于网络,加载页面速度会受到限制,体验较差。受限于浏览器 DOM 的性能,导致对一些场景很难做到原生的体验,比如长列表。
同时,也因为不像客户端一样在手机上有固定入口,会导致用户黏性比较低。

Hybrid App

Hybrid App 是介于 Native 和 Web 之间的一些开发模式,一般称作混合开发。

简单来说 Hybrid 就是套壳 App,整个 App 还是原生的,也需要下载安装到手机,但是 App 里面打开的页面既可以是 Web 的,又可以是原生的。

H5 页面会跑在 Native 的一个叫做 WebView 的容器里面,我们可以简单理解为在 App 里面打开了一个 Chrome 浏览器,在这个浏览器里面打开一个 Tab 去加载线上或者本地的 H5 页面,这样还可以实现打开多 WebView 来加载多个页面。

image.png-120.6kB

优势

Hybrid App 同时拥有 Native 和 Web 的优点,开发模式比较灵活。既可以做到动态化更新,有 bug 直接更新线上 H5 页面就行了。
也可以使用桥接(JS Bridge)来调用系统的摄像头、相册等功能,功能就不仅仅局限于浏览器了。

由于 H5 的优势,Hybrid 也支持跨平台,只要有 WebView,一套代码可以很容易跨iOS、安卓、Web、小程序、快应用多个平台。

缺点

缺点主要还是 Web App 的那些缺点,加载速度比较慢。

同时,因为受制于 Web 的性能,在长列表等场景依然无法做到和原生一样的体验。

当然加载速度是可以优化的,比如离线包。可以提前下载打包好的 zip 文件(包括 JS、CSS、图片等资源文件)到 App 里面,App 自己解压出来 JS 和 CSS 等文件。这样每次访问的是 App 本地的资源,加载速度可以得到质的提升。

如果文件有更新,那么客户端就去拉取远程版本,和本地版本进行对比,如果版本有更新,那就去拉取差量部分的文件,用二进制 diff 算法 patch 到原来的文件中,这样可以做到热更新。

但是成本也比较高,不仅需要在服务端进行一次文件差分,还需要公司内部提供一套热更新发布平台。

WebKit

WebView 是安卓中展示界面的一个控件,一般是用来展示 Web 界面。前面我们说过,可以把 WebView 理解为你正在使用的 Chrome 浏览器。

那么浏览器又是怎么去解析渲染 HTML 和 CSS,最终渲染到页面上面的呢?

这也是一道经典面试题里面的一环:从URL输入到页面展现到底发生什么?

简单来说就是浏览器拿到响应的 HTML 文本后会解析 HTML 成一个 DOM 树,解析 CSS 为 CSSOM 树,两者结合生成渲染树。在 Chrome 中使用 Skia 图形库来渲染界面,Skia 也是 Flutter 的渲染引擎。

可以参考这张经典图:

image.png-111.4kB

PS:使用 Skia 去绘制界面,而非编译成 Native 组件让系统去渲染,也是 Flutter 区别于 React Native 的一个地方。

除了解析 HTML,浏览器还需要提供 JavaScript 的运行时,我们知道的 V8 引擎就是做这件事的。

WebKit 内核

从上面我们可以得知,一个浏览器至少离不开一个渲染 HTML 的引擎和一个运行 JavaScript 的引擎。

当然,上面的这些操作都是浏览器由内核来完成的。现在主流的浏览器都使用了 WebKit 内核。

WebKit 诞生于苹果发布的 Safari 浏览器。后来谷歌基于 WebKit 创建了 Chromium 项目,在此基础上发布了我们熟悉的 Chrome 浏览器。

WebKit 内核的结构如下图所示。

image.png-258.6kB

我们依次从上往下看,WebKit 嵌入式接口就是提供给浏览器调用的,不同浏览器实现可能有所差异。

其中解析 HTML 和 CSS 这部分是 WebCore 做的,WebCore 是 WebKit 最核心的渲染引擎,也是各大浏览器保持一致的部分,一般包括 HTML 和 CSS 解释器、DOM、渲染树等功能。

WebKit 默认使用 JavaScriptCore 作为 JS 引擎,这部分是可以替换的。JavaScriptCore 也是 React Native 里面默认的引擎。

由于 JavaScriptCore 前期性能低下,于是谷歌在 Chrome 里面选用了 V8 作为 JS 引擎。

WebKit Ports 则是非共享的部分,由于平台差异、依赖的库不同,这部分就变成了可移植部分。主要涉及到网络、视频解码、图片解码、音频解码等功能。

WebView 自然也使用了 WebKit 内核。只是在安卓里面以 V8 作为 JS 引擎,在 iOS 里面以 JavaScriptCore 作为 JS 引擎。

由于渲染 DOM 和操作 JS 的是两个引擎,因此当我们用 JS 去操作 DOM 的时候,JS 引擎通过调用桥接方法来获取访问 DOM 的能力。这里就会涉及到两个引擎通信带来的性能损失,这也是为什么频繁操作 DOM 会导致性能低下。

React 和 Vue 这些框架都是在这一层面进行了优化,只去修改差异部分的 DOM,而非全量替换 DOM。

iOS 中的 JavaScriptCore

JavaScriptCore 是 WebKit 内核默认使用的 JS 引擎。既然是讲解 WebView,那么就来介绍一下 iOS 里面的 JavaScriptCore 框架吧。

iOS 中的 JavaScriptCore 框架是基于 OC 封装的 JavaScriptCore 框架。

它提供了调用 JS 运行环境以及 OC 和 JS 互相调用的能力,主要包含了 JSVM、JSContext、JSValue、JSExport 四个部分(其实只是想讲 JSVM)。

JSVM

JSVM 全称是 JSVirtualMachine,简单来说就是 JS 的虚拟机。那么什么是虚拟机呢?

我们以 JVM 为例,一般来说想要运行一个 Java 程序要经过这么几步:

  1. 把 Java 源文件(.java文件)编译成字节码文件(.class文件,是二进制字节码文件),这种字节码就是 JVM 的“机器语言”。javac.exe 就可以看做是 Java 编译器。
  2. Java 解释器用来解释执行 Java 编译器编译后的程序。java.exe可以简单看成是 Java 解释器。

所以 JVM 是一种能够运行 Java 字节码的虚拟机。除了运行 Java 字节码,它还会做内存管理、GC 等方面的事情。

而 JSVM 则提供了 JS 的运行环境,也提供了内存管理。每个 JSVM 只有一个线程,如果想执行多个线程,就要创建多个 JSVM,它们都自己独立的 GC,所以多个 JSVM 之间的对象无法传递。

JS 源代码经过了词法分析和语法分析这两个步骤,转成了字节码,这一步就是编译。

但是不同于我们编译运行 Java 代码,JS 编译结束之后,并不会生成存放在内存或者硬盘之中的目标代码或可执行文件。生成的指令字节码,会被立即被 JSVM 进行逐行解释执行。

字节码

字节码是已经经过编译,但与特定机器码无关,需要解释器转译后才能成为机器码的中间代码。

在 v8 中前期没有引入字节码,而是简单粗暴地直接把源程序编译成机器码去运行,因为他们觉得先生成字节码再去执行字节码会降低执行速度。

但后期 v8 又再一次将字节码引入进来,这是为什么呢?

早期 v8 将 JS 编译成为二进制机器码,但是编译会占用很大一部分时间。如果是同样的页面,每次打开都要重新编译一次,这样就会大大降低了效率。

于是在 chrome 中引入了二进制缓存,将二进制代码保存到内存或者硬盘里面,这样方便下次打开浏览器的时候直接使用。

但二进制代码的内存占用特别高,大概是 JS 代码的数千倍,这样就导致了如果在移动设备(手机)上使用,本来容量就不大的内存还会被进一步占用,造成性能下降。

然而字节码占用空间就比机器码实在少太多了。因此,v8 团队不得不再次引入字节码。

JIT

除此之外,还有一个大家都很熟悉的概念,那就是 JIT(即时编译)。

即时编译(Just-in-time compilation: JIT):又叫实时编译、及时编译。是指一种在运行时期把字节码编译成原生机器码的技术,一句一句翻译源代码,但是会将翻译过的代码缓存起来以降低性能耗损。这项技术是被用来改善虚拟机的性能的。

简单来说就是某段代码要被执行之前才进行编译。还是以 JVM 为例子。

  1. JVM 的解释过程:

java 代码 -> 编译字节码 -> 解释器解释执行

  1. JIT 的编译过程:

java 代码 -> 编译字节码 -> 编译机器码 -> 执行

image.png-38.7kB

所以 Java 是一种半编译半解释语言。之所以说 JIT 快,是指执行机器码效率比解释字节码效率更高,而非编译比解释更快。因此,JIT 编译还是会比解释慢一些。

同样,编译成机器码还是会遇到上面空间占用大的问题。所以在 JIT 中只对频繁执行的代码就行编译,一般包括下面两种:

  1. 被多次调用的方法。
  2. 被多次执行的循环体。

在编译热点代码的时候,这部分就会被缓存起来。等下次运行的时候就不需要再次进行编译,效率会大大提升。这也是为什么很多 JVM 都是用解释器+JIT的形式。

小丁哥如是说:
image.png-81.7kB

JSContext

JSContext 就是 JS 运行的上下文,我们想要在 WebView 上面运行 JS 代码,就需要 JSContext 这个运行环境。以下面这段代码为例:

JSContext *context = [[JSContext alloc] init];
[context evaluateScript:@"var i = 4"];
NSNumber *number = [context[@"i"] toNumber];

上面的 JSContext 调用 evaluateScript 来执行一段 JS 代码,通过 context 可以拿到对应的 JSValue 对象。

JSValue

JS 和 OC 交换数据的时候 JSCore 帮我们做了类型转换,JSCore 提供了10种类型转换:

   Objective-C type  |   JavaScript type
 --------------------+---------------------
         nil         |     undefined
        NSNull       |        null
       NSString      |       string
       NSNumber      |   number, boolean
     NSDictionary    |   Object object
       NSArray       |    Array object
        NSDate       |     Date object
       NSBlock            |   Function object 
          id         |   Wrapper object 
        Class        | Constructor object

JSExport

JSExport 支持把 Native 对象暴露给 JS 环境。例如:

@protocol NativeObjectExport <JSExport>

@property (nonatomic, assign) BOOL property1;

- (void)method1:(JSValue *)arguments;

@end

@interface NativeObject : NSObject<NativeObjectExport>

@property (nonatomic, assign) BOOL property1;

- (void)method1:(JSValue *)arguments;


@end

上面的 NativeObject 只要实现了 JSExport,就可以被 JS 直接调用。我们需要在 Context 里面注入一个对象,就可以在 JS 环境调用 Native 了。

context[@"nativeMethods"] = [NativeObject new];

JS 中调用:

nativeMethods.method1({
    callback(data) {
    }
])

React Native

Hybrid 中的 H5 始终是 WebView 中运行的,WebKit 负责绘制的。因为浏览器渲染的性能瓶颈,Facebook 基于 React 发布了 React Native 这个框架。

由于 React 中 Virtual DOM 和平台无关的优势,理论上 Virtual DOM 可以映射到不同平台。在浏览器上就是 DOM,在 Native 里面就是一些原生的组件。

受制于浏览器渲染的性能,React Native 吸取经验将渲染这部分交给 Native 来做,大大提高了体验。个人认为 React Native 也算是 Hybrid 技术的一种。

image.png-116.7kB

RN 中直接使用 JavaScriptCore 来提供 JS 的运行环境,通过 Bridge 去通知 Native 绘制界面,最终还是 Native 渲染的。

所以性能上比 Hybrid 更好,但受限于 JS 和 Native 通信的性能消耗,性能上依然不及 Native。

image.png-35.7kB

JS 和 Native 通信原理

在 JS 和 Native 通信的时候往往要经过 Bridge,这一步是异步的。

在 App 启动的时候,Native 会创建一个 Module 配置表,这个表里面包括了所有模块和模块方法的信息。

{
    "remoteModuleConfig": {
        "XXXManager": {
            "methods": {
                "xxx": {
                    "type": "remote",
                    "methodID": 0
                }
            },
            "moduleID": 4
        },
        ...
     },
}

由于在 OC 里面每个提供给 JS 调用的模块类都实现了 RCTBridgeModule 接口,所以通过 objc_getClassListobjc_copyClassList 获取项目中所有的类,然后判断每个类是否实现了 RCTBridgeModule,就可以确定是否需要添加到配置表中。

然后 Native 会将这个配置表信息注入到 JS 里面,这样在 JS 里面就可以拿到 OC 里面的模块信息。

其实如果你写过 JS Bridge,会发现这个流程和 WebViewJavaScriptBridge 有些类似。主要是这么几个步骤:

  1. JS 调用某个 OC 模块的方法
  2. 把这个调用转换为 ModuleName、MethodName、arguments,交给 MessageQueue 处理。
  3. 将 JS 的 Callback 函数放到 MessageQueue 里面,用 CallbackID 来匹配。再根据配置表将 ModuleName、MethodName映射为 ModuleID 和 MethodID。
  4. 然后把上面的 ID 都传给 OC
  5. OC 根据这几个 ID 去配置表中拿到对应的模块和方法
  6. RCTModuleMethod 对 JS 传来的参数进行处理,主要是将 JS 数据类型转换为 OC 的数据类型
  7. 调用 OC 模块方法
  8. 通过 CallbackID 拿到对应的 Callback 方法执行并传参

热更新

相比 Native,RN 的一大优势就是热更新。我们将 RN 项目最后打包成一个 Bundle 文件提供给客户端加载。在 App 启动的时候去加载这个 Bundle 文件,最后由 JavaScriptCore 来执行。

如果有新版本该怎么更新?这个其实很简单,重新打包一个 Bundle 文件,用 BS Diff 算法对不同版本的文件进行二进制差分。

客户端会比较本地版本和远程版本,如果本地版本落后了,那就去下载差量文件,同样使用 BS Diff 算法 patch 进 Bundle 里面,这样就实现了热更新。

这种方式也适用于 H5 的离线包更新,可以很大程度上解决 H5 加载慢的问题。

RN 新架构

在 RN 老架构中,性能瓶颈主要体现在 JS 和 Native 通信上面,也就是 Bridge 这里。

我们写的 RN 代码会通过 JS Thread 进行序列化,然后通过 Bridge 传给 shadow Thread 反序列化获得原生布局信息。

之后又通过 Bridge 传给 UI Thread,UI Thread 反序列化之后会根据布局信息进行绘制。这里就有三个线程通过 Bridge 来通信。

image.png-42.2kB

由于多次序列化/反序列化以及 Bridge 通信,这样就造成了一些性能损耗。

尤其是在快速滑动列表的时候容易造成白屏,然而浏览器里面快速滑动却没有白屏,这又是为什么呢?

主要还是浏览器中,JS 可以持有 C++ 对象的引用,所以这里其实是同步调用。

image.png-936.7kB

由于受到 Flutter 的冲击,RN 团体提出了新的架构来解决这些问题。为了解决 Bridge 通信的问题,RN 团队在 JavaScriptCore 之上抽象了一层 JSI(JavaScript Interface),允许底层更换成不同的 JavaScript 引擎。

image.png-413.3kB

除此之外,JS 还可以拿到 C++ 的引用,这样就可以直接和 Native 通信,不需要反复序列化对象,也节省了 Bridge 通信的开支。

这里解释一下,为啥拿到 C++ 引用就可以和 Native 通信。由于 OC 本身就是 C 语言的扩展,所以可以直接调用 C/C++ 的方法。Java 虽然不能 C 语言扩展,但它可以通过 JNI 来调用。

JNI 就是 Java Native Interface,它是 JVM 提供的一套能够使运行在 JVM 上的 Java 代码调用 C++ 程序、以及被 C++ 程序调用的编程框架。

image.png-50.7kB

相信新架构的到来会解决 RN 原有的一些痛点,以及会带来性能上的飞跃。

Flutter

传统的跨端有两种,一种是 Hybrid 那种实现 JS 跑在 WebView 上面的,这种性能瓶颈取决于浏览器渲染。

另一种是将 JS 组件映射为 Native 组件的,例如 React Native、Weex,缺点就是依然需要 JS Bridge 来进行通信(老架构)。

Flutter 则是在吸取了 RN 的教训之后,不再去做 Native 的映射,而是自己用 Skia 渲染引擎来绘制页面,而 Skia 就是前面说过的 Chrome 底层的二维图形库,它是 C/C++ 实现的,调用 CPU 或者 GPU 来完成绘制。所以说 Flutter 像个游戏引擎。

Flutter 在语法上深受 React 的影响,使用 setState 来更新界面,使用类似 Redux 的**来管理状态。从早期的 WPF,到后面的 React,再到后来的 SwiftUI 都使用了声明式 UI 的**。

Flutter 架构图如下:

image.png-720.9kB

Framework 是用 Dart 实现的 SDK,封装了一些基础库,比如动画、手势等。还实现了一套 UI 组件库,有 Material 和 Cupertino 两种风格。Material 适用于安卓,Cupertino 适用于 iOS。

Engine 是 C/C++ 实现的 SDK,主要包括了 Skia 引擎、Dart 运行时、文本渲染等。

Embedder 是一个嵌入层,支持把 Flutter 嵌入各个平台。

Flutter 使用 Dart,支持 AOT 编译成 ARM Code,这样拥有更高的性能。在 Debug 模式下还支持 JIT。

在 Flutter 中,Widgets 是界面的基本构成单位,和 React Component 有些类似。而 StatelessWidget 类似 React Functional Component。

class Echo extends StatelessWidget {
  const Echo({
    Key key,  
    @required this.text,
    this.backgroundColor:Colors.grey,
  }):super(key:key);

  final String text;
  final Color backgroundColor;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        color: backgroundColor,
        child: Text(text),
      ),
    );
  }
}

在 Flutter 渲染过程中有三棵树,分别是 Widgets 树、Element 树、RenderObject 树。

如果你有写过 React,会发现真的和 React 很类似。

image.png-7.4kB

当初始化的时候, Widgets 通过 build 方法来生成 Element,这类似于 React.createElement 生成虚拟 DOM(Virtual DOM)。

Element 重新创建的开销会比较大,所以每次重新渲染它并不会重新构建。从 Element Tree 到 RenderObject Tree 之间一般也会有一个 Diff 的环境,计算最小需要重绘的区域。

这里也和 React 渲染流程比较相似,虚拟 DOM 会和真实 DOM 进行一次 Diff 对比,最后将差异部分渲染到浏览器上。

image.png-188.1kB

浏览器渲染

在前面我们讲过浏览器的渲染流程。一般是将 HTML 解析成 DOM 树,将 CSS 解析为 CSSOM 树,两者合并成一颗 RenderObject 树。

一个 RenderObject 对象保存了绘制 DOM 节点需要的各种信息,它知道怎么绘制自己。不同的 RenderObject 对象构成一棵树,就叫做 RenderObject 树。它是基于 DOM 树创建的一棵树。

然后 WebKit 会为这些 RenderObject 对象创建新的 RenderLayer 对象,最后形成一棵 RenderLayer 树。一般来说,RenderLayer 和 RenderObject 是一对多的关系。每个 RenderLayer 包括的是 RenderObect 树的子树。

什么情况下会创建 RenderLayer 对象呢?比如 Video 节点、Document 节点、透明的 RenderObject 节点、指定了位置的节点等等,这些都会创建一个 RenderLayer 对象。

一个 RenderLayer 可以看做 PS 里面的一个图层,各个图层组成了一个图像。

Flutter 渲染

Flutter 渲染和浏览器渲染类似,Widget 通过 createElement 生成 Element,而 Element 通过 createRenderObject 创建了 RenderObject 对象,最终生成 Layer。

一般来说,RenderObject 上面存着布局信息,所以布局和绘制都是在 RenderObject 中完成。Flutter 通过深度遍历渲染 RenderObject 树,确定每个对象的位置和大小,绘制到不同的图层中。绘制结束后,由 Skia 来完成合成和渲染。

所以 Flutter 的更新流程如下:

image.png-320.9kB

通信

Flutter 没办法完成 Native 所有的功能,比如调用摄像头等,所以需要我们开发插件,而插件开发的基础还是 Flutter 和 Native 之间进行通信。

Flutter 和 Native 之间的通信是通过 Channel 完成的,一般有下面几种通信场景:

  1. Native 发送数据给 Dart
  2. Dart 发送数据给 Native
  3. Dart 发送数据给 Native,Native 回传数据给 Dart

image.png-39.3kB

Flutter 实现通信有下面三种方式:

  1. EventChannel:一种 Native 向 Flutter 发送数据的单向通信方式,Flutter 无法返回任何数据给 Native。一般用于 Native 向 Flutter发送手机电量、网络连接等。
  2. MethodChannel:Native 和 Flutter之间的双向通信方式。通过 MethodChannel 调用 Native 和 Flutter 中相对应的方法,该种方式有返回值。
  3. BasicMessageChannel:Native 和 Flutter之间的双向通信方式。支持数据类型最多,使用范围最广。该方式有返回值。

BinaryMessenger 是 Flutter 和 Channel 通信的工具。它在安卓中是一个接口,使用二进制格式数据通信。

在 FlutterView 中实现,它可以通过 JNI 来和系统底层通信。因此,基本上和原生调用差不多,不像 RN 中 Bridge 调用需要进行数据转化。

所以,如果想开发插件,还是需要实现安卓和 iOS 的功能,以及封装 plugin 的 api,总体上还是无法脱离 Native 来运作。

对比 React Native

  1. Flutter 官方暂时不支持热更新,RN 有成熟的 Code Push 方案
  2. Flutter 放弃了 Web 生态,RN 拥有 Web 成熟的生态体系,工具链更加强大。
  3. Flutter 将 Dart 代码 AOT 编译为本地代码,通信接近原生。RN 不仅需要多次序列化,不同线程之间还需要通过 Bridge 来通信,效率低下。

更多细节对比可以参考知乎这个问题:开发跨平台 App 推荐 React Native 还是 Flutter?

参考

  1. Understanding WebViews
  2. What is WebKit and how is it related to CSS?
  3. 在Chrome中的GPU加速合成(一)
  4. JavaScriptCore
  5. JIT 为什么能大幅度提升性能?
  6. 深入浅出 JIT 编译器
  7. How Flutter renders Widgets
  8. Flutter原理及经验分享
  9. WebKit 技术内幕
  10. React Native's New Architecture

有必要使用服务端渲染(SSR)吗?

前几天在知乎看到了这个问题,刚好前阵子做过,就回答了一下。下面的是原回答。

前言

前阵子有搞了 React 服务端渲染的项目,是否应该用这个主要还是看场景吧。

比较适用于大家常说的 SEO 和首屏渲染这些,一般都是 toc 的业务才会需要用到。

同构

现代框架的服务端渲染和 jsp、php 这些还是有不少区别的。因为 nextjs 和 nuxtjs 这种不仅仅是服务端渲染,它们还是同构框架。

什么是同构呢?就是一份代码既可以跑在浏览器端,也可以跑在服务端。这得益于 NodeJS 在服务端的流行。

传统 jsp、php、django 这些服务端渲染框架都是返回 html 字符串,类似于传统的 MPA 多页面模式。所以切换页面的时候就会刷新,重新请求 css 和 js 文件,用户体验比较差。

而现在流行的前端开发模式都是 SPA 单页面,基于 H5 的 History 来实现切换页面无刷新,这样可以带来更好的用户体验。

所以 nextjs 和 nuxtjs 不仅支持服务端渲染,还支持 SPA,常用的是对首页进行服务端渲染,其他页面依然保持 SPA 的无刷新访问模式。

我们这边就有使用 Django 来编写的页面,维护起来很痛苦。因为无法说清楚哪些是前端负责的,哪些是后端负责的。所以为了维护这个,前端和后端都去要学习 Python 和 Django,大大提高了维护成本。

实际应用场景的话,我们这里有几种场景就比较适合用服务端渲染。

支持 Post 请求

一个是重构的 h5 页面,项目以前是新加坡团队用 Python + Django 写的,所以有些页面是第三方网站 Post 提交表单打开的。

我们重构后的 H5 页面都挂在腾讯云 CDN 上面,不支持用 Post 打开的。为什么不改成 Get 呢?因为这是以前他们协定的,然后银行都是爸爸,他们不会为了我们去改协议的。

页面功能都是比较简单的,所以为了赶上重构的时间线,当时旁边的小伙伴用 Express + EJS 实现了一版,只支持 ES5 的语法。

后续需求经历几次变更,想在原来的页面上加功能都比较麻烦。比如我想实现 JS Bridge,我只能用 microbundle 把现有的 npm 包打成一个 umd 文件,然后用 script 标签引入。

动态渲染标题

前阵子遇到了另一个需求,我需要为多家银行实现同样的 H5 页面,功能基本上都是一样的,但 App 头部需要展示不同银行的名字。

在我们 AirPay App 里面,客户端在打开 webview 的时候会去读取我们 HTML 里面的 title,将其设置为原生头部的标题。

如果我在代码里面使用 document.title 的方式动态设置就不会生效,只能通过 JS Bridge 来动态设置头部。

但这个页面不仅会提供给 AirPay 使用,还会提供給 Shopee 使用,需要兼容两套 JS Bridge,有点儿得不偿失。

但如果使用服务端直出的形式,就可以在服务端直接判断好需要渲染的标题,设置到 HTML 的 title 里面。这就是另一种适合的业务场景了。

所以在之前项目基础上添加了 React 服务端渲染的功能,支持用 React 开发同构应用。这里也没有用 Next,只是自己实现的一套同构。

大致实现思路是用 isomorphic-style-loader 和 universal-router 来处理样式和路由的同构,服务端获取到数据后输出到 window._INITIAL_STATE__ 里面,在浏览器获取这个初始化数据实现数据同构的。

同时也保留了原来的 EJS 模板,都是基于 Express 路由分发的,既可以渲染用 EJS 渲染,也可以用 React 服务端直出。

一期的这个页面是挂在腾讯云 CDN 上面的,二期使用了服务端渲染,可以明显感觉到加载速度变快了很多,毕竟以前还是会展示一个 loading 图。

在进程守护方面,整个部门的 Node 服务都是用大佬写的 Node Agent 来做,也经受住了各种大促的考验。

性能测试

这次测试是用 h5-pay 和 h5-ssr 里面的 mbanking 做的一个对比,打开浏览器隐身模式,disable cache 之后,测试了弱网和正常网速下的比较。

h5-ssr 是我们这边用于服务端渲染的项目,静态资源挂载到了腾讯云的 CDN 上面。h5-pay 是我们这边的一个静态项目,html 和静态资源都挂载到了 CDN 上面。

指标

页面加载速度上面的差距是显而易见的。这里使用了浏览器自带的 Performance API 来做一个对比,具体就是加载完页面后执行 performance.getEntites() 来查看各项指标。

这次主要对比的是 DNS 解析、TCP 连接、请求响应、DOM 解析等等。

image

DNS 解析

首先我们可以看到 DNS 解析时间,一般是 domainLookupEnd - domainLookupStart,很显然 h5pay 的解析速度很慢,达到了惊人的 94ms,这个域名是我们关联在腾讯云上面的。

反而 SSR 的 DNS 解析只花了 3ms,这里是否可以说明我们自己解析速度远远快过腾讯云的解析速度呢?

TCP 连接

TCP 连接这个两者差距不大,一般是 connectEnd - conntectStart 来查看,SSR 耗时大概 40ms,h5pay 耗时 60ms。

首次请求响应

对比完 DNS 之后,我们再来对比一下首次请求的响应耗时。这个首次请求是指我们在浏览器输入 URL 之后,等浏览器三次握手建立 TCP 连接之后,请求获取我们的 HTML 文件的耗时。

这里使用 responseEnd - requestStart 来比较耗时。需要注意的是,走 SSR 的页面一般都需要在服务端请求接口,获取数据后将数据一起返回给浏览器,所以理论上 SSR 耗时肯定比非 SSR 更多。

这里可以看到差距非常大,h5-ssr 只花了 102 ms,但 h5-pay 耗时居然到了 390ms,不是说好的 SSR 耗时会更多吗!

那么只有一种解释,就是腾讯云 CDN 实在过于垃跨了,请求响应速度实在是太慢了,以至于別人顺便请求了一个接口都只花了100ms,你反而花了快400ms。

DOM 解析

因为 SSR 是已经在服务端渲染好了数据,所以它返回的 DOM 就是全量的。但 h5-pay 不一样,它是单页应用,大部分逻辑都在 JS 里面,反而 HTML 本身很小,只是个空壳。所以这里必然是 h5-pay 解析比 SSR 更快。

我们用 domInteractive - responseEnd 来比较一下,发现 SSR 解析 DOM 耗时达到了 800ms,而 h5-pay 耗时只有 340ms,这是 h5-pay 的一次重大胜利。

整体耗时

最上面的 duration 可以看到整体耗时,SSR 耗时在 1000ms 左右,而 h5-pay 耗时在 5817 ms,当然这个数据不是非常准确的,因为 5817ms 算上了加载完所有的静态资源,而 SSR 只要 DOM 解析完就算加载完成了。

如果是从 DNS 解析到 DOM 渲染耗时来看,h5-pay 更胜一筹,但这个并没有什么用。因为 SSR 这个耗时就已经把整个页面直出了,而 h5-pay 还需要去下载 JS 文件,然后调接口获取数据,再进行一次重绘。

如果只算 H5-PAY 的关键资源加载以及调接口、重绘的耗时,大概在 3s 左右,而走了 SSR 的相同页面仅仅花了 1s,性能差距大概 2倍。

缺点

当然了,服务端渲染也不应该滥用。

比如我们的内部后台管理系统就上了 Nuxt,现在每次本地构建要花10分钟,非常影响开发效率。

Nuxt 功能还是非常强大的,比如会根据路由动态拆分构建文件、鼠标放到 Nuxt-link 路由组件上面就会预加载 JS 文件等等。

但实际上带来的收益几乎为零,因为我们不需要 SEO,也不需要提高首屏加载速度。

几乎组里面每个人都有尝试用各种手段去优化构建,但效果不是很明显。直到最近开始做微前端拆分,才曲线解决这个问题。

除此之外,服务端渲染在写法上也和客户端渲染有一些区别,容易导致 bug。

比如下面在 Vuex 的 state 文件里面的这段代码:

const date = moment().format('YYYY-MM-DD')

export default () => ({
  date
})

打开页面的时候,时间应该展示的是今天。哪怕页面放置刚好跨天了,打开再刷新也应该是当天时间。

但在 Nuxt 里面,这个展示的日期就是你服务启动那天的日期,不管你怎么刷新,它永远不会变化。

因为 Nuxt 初始化的时候会把这些数据存到 store 里面,后续再怎么刷新,这个文件也不会在服务端重新加载,因为模块会被 Node 缓存起来,所以日期就不会更新。

但在客户端渲染里面,由于页面刷新会导致浏览器端重新加载 JS 文件,这个日期也会重新计算。

underscore debounce防抖动函数分析

本文是underscore源码剖析系列第六篇文章,上节我们介绍了throttle节流函数的实现,这节将会介绍一下节流函数的兄弟 —— debounce防抖动函数。
throttle函数是在高频率触发的情况下,为了防止函数的频繁调用,将其限制在一段时间内只会调用一次。而debounce函数则是在频繁触发的情况下,只在触发的最后一次调用一次,想像一下如果我们用手按住一个弹簧,那么只有等到我们把手松开,弹簧才会弹起来,下面我用一个电梯的例子来介绍debounce函数。

电梯

假如我下班的时候去坐电梯,等了一段时间后,电梯正准备关上门下降,这个时候一个同事走了过来,电梯门被打开,这样电梯就会继续等一段时间,如果中间一直有人进来,那么电梯就一直不会下降,直到最后一个人进来后过了一定时间后还没有下一个人进来,这时电梯才会下降。

应用场景

除了电梯,事实上我们还有很多应用场景,比如我用键盘不断输入文字,我希望等最后一次输入结束后才会调用接口来请求展示联想词,如果每次输入一个字的时候就会调用接口,这样调用未免太过于频繁了。

未使用debounce的输入:
未使用debounce
使用debounce的输入:
使用debounce

简单的debounce

知道debounce的工作原理了,我们可以先自己实现一个比较简单的debounce函数。

function debounce(func, wait) {
	var timeout,
		args, 
		context
	var later = function() {
		func.apply(context, args)
		timeout = context = args = null
	}
	return function() {
		context = this
		args = arguments
		// 每次触发都清理掉前一次的定时器
		clearTimeout(timeout)
		// 只有最后一次触发后才会调用later
		timeout = setTimeout(later, wait)
	}
}

麻雀虽小,五脏俱全。
不过这个函数还是有很多问题,比如每次触发都要反复的清除和设置定时器,我们来看一下underscore的实现。

underscore debounce

// debounce函数传入三个参数,分别是要执行的函数func,延迟时间wait,是否立即执行immediate
// 如果immediate为true,那么就会在wait时间段一开始就执行一次func,之后不管触发多少次都不会再执行func
// 在类似不小心点了提交按钮两下而提交了两次的情况下很有用
_.debounce = function (func, wait, immediate) {
		var timeout, args, context, timestamp, result;

		var later = function () {
		    // 这个是最关键的一步,因为每次触发的时候都要记录当前timestamp
		    // 但是later是第一次触发后wait时间后执行的,_now()减去第一次触发时的时间当然是等于wait的
		    // 但是如果后续继续触发,那么_.now() - timestamp肯定会小于wait
		    // last是执行later的时间和上一次触发的时间差
			var last = _.now() - timestamp;
            // 如果在later执行前还有其他触发,那么就会重新设置定时器
            // last >= 0应该是防止客户端系统时间被调整
			if (last < wait && last >= 0) {
				timeout = setTimeout(later, wait - last);
			// 如果last大于等于wait,也就是说设置timeout定时器后没有再触发过
			} else {
				timeout = null;
				// 这个时候如果immediate不为true,就会立即执行func函数,这也是为什么immediate为true的时候只会执行第一次触发
				if (!immediate) {
					result = func.apply(context, args);
					// 解除引用
					if (!timeout) context = args = null;
				}
			}
		};

		return function () {
			context = this;
			args = arguments;
			// 每次触发都用timestamp记录时间戳
			timestamp = _.now();
			// 第一次进来的时候,如果immediate为true,那么会立即执行func
			var callNow = immediate && !timeout;
			// 第一次进来的时候会设置一个定时器
			if (!timeout) timeout = setTimeout(later, wait);
			if (callNow) {
				result = func.apply(context, args);
				context = args = null;
			}

			return result;
		};
	};

underscore的实现比较巧妙,为了防止出现我们上面那种不停地清除、设置定时器的情况,underscore则会在每次触发时都记录时间戳,在wait时间后定时器触发执行later函数时计算当前时间和时间戳之差,如果小于wait时间,那么在之后肯定还有其他触发,这时再重新设置定时器,这样不仅解决了上面的问题,也保证了最后一次触发后wait时间才会执行func。

同时,在我们的基础之上,underscore加入了immediate参数。如果传入的immediate为true,那么只会在第一次进来的时候立即执行。很明显在上面代码中func执行只有两处,一个是callNow判断里面,一个是!immediate判断里面,所以这样保证了后续触发不会再执行func。

参考链接:
1、浅谈throttle以及debounce的原理和实现

浅谈 React 组件设计

前言

前端组件化一直是老生常谈的话题,在前面介绍 React 的时候我们已经提到过 React 的一些优势,今天则是带大家了解一下组件设计原则。

jQuery 插件

在开始讲 React 组件之前,我们还是要先来聊聊 jQuery。在我看来,jQuery 插件就已经具备了组件化的雏形。

在 jQuery 还大行其道的时代,我们在网上可以到处一些 jQuery 插件,里面有各种丰富的插件,比如轮播图、表单、选项卡等等。

组件?插件?

组件和插件的区别是什么呢?插件是集成到某个平台上的,比如 Jenkins 插件、Chrome 插件等等,jQuery 插件也类似。平台只提供基础能力,插件则提供一些定制化的能力。
而组件则是偏向于 ui 层面的,将 ui 和业务逻辑封装起来,供其他人使用。

封装 DOM 结构

在一些最简单无脑的 jQuery 插件中,它们一般会将 DOM 结构直接写死到插件中,这样的插件拿来即用,但限制也比较大,我们无法修改插件的 DOM 结构。

// 轮播图插件
$("#slider").slider({
    config: {
        showDot: true, // 是否展示小圆点
        showArrow: true // 是否展示左右小箭头
    }, // 一些配置
    data: [] // 数据
})

还有另一种极端的插件,它们完全不把 DOM 放到插件中,但会要求使用者按照某种固定格式的结构来组织代码。
一旦结构不准确,就可能会造成插件内部获取 DOM 出错。但这种插件的好处在于可以由使用者自定义具体的 DOM 结构和样式。

<div id="slider">
    <ul class="list">
        <li data-index="0"><img src="" /></li>
        <li data-index="1"><img src="" /></li>
        <li data-index="2"><img src="" /></li>
    </ul>
    <a href="javascript:;" class="left-arrow"><</a>
    <a href="javascript:;" class="left-arrow">></a>
    <div class="dot">
        <span data-index="0"></span>
        <span data-index="1"></span>
        <span data-index="2"></span>
    </div>
</div>

$("#slider").slider({
    config: {} // 配置
})

当然,你也可以选择将 DOM 通过配置传给插件,插件内部去做这些渲染的工作,这样的插件比较灵活。有没有发现?这和 render props 模式非常相似。

$("#slider").slider({
    config: {}, // 配置
    components: {
        dot: (item, index) => `<span data-index=${index}></span>`,
        item: (item, index) => `<li data-index=${index}><img src=${item.src} /></li>`
    }
})

React 组件设计

前面讲了几种 jQuery 插件的设计模式,其实万变不离其宗,不管是 jQuery 还是 React,组件设计**都是一样的。

image_1e5jp360218jbi4amj918oin89m.png-39.4kB

个人觉得,组件设计应该遵循以下几个原则:

  1. 适当的组件粒度:一个组件尽量只做一件事。
  2. 复用相同部分:尽量复用不同组件相同的部分。
  3. 松耦合:组件不应当依赖另一个组件。
  4. 数据解耦:组件不应该依赖特定结构的数据。
  5. 结构自由:组件不应该封闭固定的结构。

容器组件与展示组件

顾名思义,容器组件就是类似于“容器”的组件,它可以拥有状态,会做一些网络请求之类的副作用处理,一般是一个业务模块的入口,比如某个路由指向的组件。我们最常见的就是 Redux 中被 connect 包裹的组件。
容器组件有这么几个特点:

  1. 容器组件常常是和业务相关的。
  2. 统一的数据管理,可以作为数据源给子组件提供数据。
  3. 统一的通信管理,实现子组件之间的通信。

展示组件就比较简单的多,在 React 中组件的设计理念是 view = f(data),展示组件只接收外部传来的 props,一般内部没有状态,只有一个渲染的作用。

image_1e5813mbgbmvc623215qo6pf9.png-29.5kB

适当的组件粒度

在项目开发中,可能你会看到懒同事一个几千行的文件,却只有一个组件,render 函数里面又臭又长,让人实在没有读下去的欲望。
在写 React 组件中,我见过最恐怖的代码是这样的:

function App() {
    let renderHeader,
        renderBody,
        renderHTML
    if (xxxxx) {
        renderHeader = <h1>xxxxx</h1>
    } else {
        renderHeader = <header>xxxxx</header>
    }
    if (yyyyy) {
        renderBody = (
            <div className="main">
                yyyyy
            </div>
        )
    } else {
        ...
    }
    if (...) {
        renderHTML = ...
    } else {
        ...
    }
    return renderHTML
}

当我看到这个组件的时候,我想要搞清楚他最终都渲染了什么。看到 return 的时候发现只返回了 renderHTML,而这个 renderHTML 却是经过一系列的判断得来的,相信没人愿意去读这样的代码。

拆分 render

我们可以将 render 方法进行一系列的拆分,创建一系列的子 render 方法,将原来大的 render 进行分割。

class App extends Component {
	renderHeader() {}
	renderBody() {}
	render() {
		return (
			<>
				{this.renderHeader()}
				{this.renderBody()}
			</>
		)
	}
}

当然最好的方式还是拆分为更细粒度的组件,这样不仅方便测试,也可以配合 memo/PureComponent/shouldComponentUpdate 做进一步性能优化。

const Header = () => {}
const Body = () => {}
const App = () => (
	<>
		<Header />
		<Body />
	</>
)

复用相同部分

对于可复用的组件部分,我们要尽量做到复用。这部分可以是状态逻辑,也可以是 HTML 结构。
以下面这个组件为例,这样写看上去的确没有大问题。

class App extends Component {
    state = {
        on: props.initial
    }
    toggle = () => {
        this.setState({
            on: !this.state.on
        })
    }
    render() {
        <>
            <Button type="primary" onClick={this.toggle}> {this.on ? "Close" : "Open"} Modal </Button>
            <Modal visible={this.state.on} onOk={this.toggle} onCancel={this.toggle}/>
        </>
    }
}

但如果我们有个 checkbox 的按钮,它也会有开关两种状态,完全可以复用上面的 this.state.onthis.toggle,那该怎么办呢?

timg.gif-85.7kB

就像上一节讲的一样,我们可以利用 render props 来实现状态逻辑复用。

// 状态提取到 Toggle 组件里面
class Toggle extends Component {
    constructor(props) {
        this.state = {
            on: props.initial
        }
    }
    toggle = () => {
        this.setState({
            on: !this.state.on
        })
    }
    render() {
        return this.props.children({
            on: this.state.on,
            toggle: this.toggle
        })
    }
}
// Toggle 结合 Modal
function App() {
    return (
        <Toggle initial={false}>
            {({ on, toggle }) => (
                <>
                    <Button type="primary" onClick={toggle}> Open Modal </Button>
                    <Modal visible={on} onOk={toggle} onCancel={toggle}/>
                </>
            )}
        </Toggle>
    )
}
// Toggle 结合 CheckBox
function App() {
    return (
        <Toggle initial={false}>
            {({ on, toggle }) => (
                <CheckBox visible={on} toggle={toggle} />
            )}
        </Toggle>
    )
}

或者我们可以用上节讲过的 React Hooks 来抽离这个通用状态和方法。

const useToggle = (initialState) => {
    const [state, setState] = useState(initialState);
    const toggle = () => setState(!state);
    return [state, toggle]
}

除了这种状态逻辑复用外,还有一种 HTML 结构复用。比如有两个页面,他们都有头部、轮播图、底部按钮,大体上的样式和布局也一致。如果我们对每个页面都写一遍,难免会有一些重复,像这种情况我们就可以利用高阶组件来复用相同部分的 HTML 结构。

const PageLayoutHoC = (WrappedComponent) => {
    return class extends Component {
        render() {
            const {
                title,
                sliderData,
                onSubmit,
                submitText
                ...props
            } = this.props
            return (
                <div className="main">
                    <Header title={title} />
                    <Slider dataList={sliderData} />
                    <WrappedComponent {...props} />
                    <Button onClick={onSubmit}>{submitText}</Button>
                </div>
            )
        }
    }
}

组件松耦合

松耦合一般是和紧耦合相对立的,两者的区别在于:

  1. 多个组件之间互相了解、依赖彼此的实现和方法,破坏组件的独立性,这种叫做紧耦合。

  2. 多个组件之间很少、甚至没有依赖彼此的实现,一个组件的改动不会影响到其他组件,这种叫做松耦合。

    很明显,我们在开发中应当使用松耦合的方式来设计组件,这样不仅提供了复用性,还方便了测试。

    我们来看一下简单的紧耦合反面例子:

    class App extends Component {  
      state = { count: 0 }
    	increment = () => {
    		this.setState({
    			count: this.state.count + 1
    		})
    	}
    	decrement = () => {
    		this.setState({
    			count: this.state.count - 1
    		})
    	}
      render() {
        return <Counter count={this.state.count} parent={this} />
      }
    }
    
    class Counter extends Component {
      render() {
        return (
          <div className="counter">
            <button onClick={this.props.parent.increment}>
              Increase
            </button> 
            <div className="count">{this.props.count}</div>
            <button onClick={this.props.parent.decrement}>
              Decrease
            </button>
          </div>
        )
      }
    }
    

    可以看到上面的 Counter 依赖了父组件的两个方法,一旦父组件的 incrementdecrement 改了名字呢?那 Counter 组件只能跟着来修改,破坏了 Counter 的独立性,也不好拿去复用。

    所以正确的方式就是,组件之间的耦合数据我们应该通过 props 来传递,而非传递一个父组件的引用过来。

    class App extends Component {  
      state = { count: 0 }
    	increment = () => {
    		this.setState({
    			count: this.state.count + 1
    		})
    	}
    	decrement = () => {
    		this.setState({
    			count: this.state.count - 1
    		})
    	}
      render() {
        return <Counter count={this.state.count} increment={this.increment} decrement={this.decrement}/>
      }
    }
    
    class Counter extends Component {
      render() {
        return (
          <div className="counter">
            <button onClick={this.props.increment}>
              Increase
            </button> 
            <div className="count">{this.props.count}</div>
            <button onClick={this.props.decrement}>
              Decrease
            </button>
          </div>
        )
      }
    }
    

数据解耦

我们的组件不应该依赖于特定格式的数据,组件中避免出现 data.xxx 这种数据。你可以通过 render props 的模式将要处理的对象传到外面,让使用者自行操作。
举个栗子:
我设计了一个 Tabs 组件,我需要别人给我传入这样的结构:

[
    {
        key: 'Tab1',
        content: '这是 Tab 1',
        title: 'Tab1'
    },
    {},
    {}
]

这个 key 是我们用来关联所有 Tab 和当前选中的 Tab 关系的。比如我选中了 Tab1,当前的 Tab1 会有高亮显示,就通过 key 来关联。
而我们的组件可能会这样设计:

<Tabs data={data} currentTab={'Tab1'} />

这样的设计不够灵活,一个是耦合了数据的结构,大多数时候,接口不会返回上图中的 key 这种字段,title 也很可能没有,这就需要我们自己做一下数据格式化。
另一个是封装了 DOM 结构,如果我们想定制化传入的 Tab 结构就会变得非常困难。
我们不妨转换一下思路,当设计一个通用组件的时候,一定要只有一个组件吗?一定要把数据传给组件吗?
那么来一起看看业界知名的组件库 Ant Design 是如何设计 Tabs 组件的。

<Tabs defaultActiveKey="1" onChange={callback}>
    <TabPane tab="Tab 1" key="1">
        Content of Tab Pane 1
    </TabPane>
    <TabPane tab="Tab 2" key="2">
        Content of Tab Pane 2
    </TabPane>
    <TabPane tab="Tab 3" key="3">
        Content of Tab Pane 3
    </TabPane>
</Tabs>

Ant Design 将数据和结构进行了解耦,我们不再传列表数据给 Tabs 组件,而是自行在外部渲染了所有的 TabPane,再将其作为 Children 传给 Tabs,这样的好处就是组件的结构更加灵活,TabPane 里面随便传什么结构都可以。

结构自由

一个好的组件,结构应当是灵活自由的,不应该对其内部结构做过度封装。我们上面讲的 Tabs 组件其实就是结构自由的一种代表。

考虑到这样一种业务场景,我们页面上有多个输入框,但这些输入框前面的 Icon 都是不一样的,代表着不同的含义。我相信肯定不会有人会对每个 Icon 都实现一个 Input 组件。

image_1e5jq2o13qj0qmele81aahh6l13.png-10.7kB

你可能会想到我们可以把图片的地址当做 props 传给组件,这样不就行了吗?但万一前面不是 Icon 呢?而是一个文字、一个符号呢?

那我们是不是可以把元素当做 props 传给组件呢?组件来负责渲染,但渲染后长什么样还是使用者来控制的。这就是 Ant Design 的实现思路。

code.png-111.5kB

在前面数据解耦中我们就讲过了类似的思路,实际上数据解耦和结构自由是相辅相成的。在设计一个组件的时候,很多人往往会陷入一种怪圈,那就是我该怎么才能封装更多功能?怎么才能兼容不同的渲染?

这时候我们就不妨换一种思路,如果将渲染交给使用者来控制呢?渲染成什么样都由用户来决定,这样的组件结构是非常灵活自由的。

当然,如果你把什么都交给用户来渲染,这个组件的使用复杂度就大大提高了,所以我们也应当提供一些默认的渲染,即使用户什么都不传也可以渲染默认的结构。

总结

组件设计是一项重要的工作,好的组件我们直接拿来复用可以大大提高效率,不好的组件只会增加我们的复杂度。

在组件设计的学习中,你需要多探索、实践,多去参考社区知名的组件库,比如 Ant Design、Element UI、iview 等等,去思考他们为什么会这样设计,有没有更好的设计?如果是自己来设计会怎么样?

记一个同构问题

问题描述

ATM BillPayment 上线后,local 在 live 环境发现输入框的日期默认是 07-22 的,于是报了问题。

我看了一下代码,进来页面的时候,我默认设置的是当天的日期,不可能是 07-22 的。在本地跑一下发现是正常的,但是打开 live 后不管怎么刷新页面,日期就是停留在 07.22 这天。

问题定位

于是,我先怀疑这里是否有问题。问了一下 QA,上次发布 live 的日期是不是 07.22,他说是的。

我打开 UAT 环境的看了一下,发现停留在了 07.23,也刚好是那天发布了 UAT。

看起来时间都停留在了发布的那天。

image

第一步,肯定是先翻自己的代码。这段逻辑是放到 vuex 的 state 里面的。

虽然是个闭包,但正常情况下,我们刷新浏览器也会重新计算 date。可是这里没有重新计算。

问题思考

为什么页面上的日期会停留在发布服务的那天呢?

页面上的日期和服务有什么关系呢?

…...

是不是服务端渲染的问题呢?

这就不得不讲一下模块和同构了。

闭包 & 模块

当我们在不同模块中引入同一个模块 m,那么这个 m 被执行了几次呢?

一次?还是引入几次就执行几次?

答案是一次。为什么只执行了一次呢?

如果你用立即执行函数做过简单的模块,那么下面这段代码就豁然开朗。

image

在 webpack 中,加载模块也是进行了缓存,可以查看 webpack 将文件编译之后,他们是如何实现 require 的。

image

很显然,在 webpack 中对第一次引入的模块进行了缓存,后续读取都是从缓存中来的。webpack 中解析模块代码使用的还是 eval

而在 Node 中,CommonJS 的原理也是类似,只是解析代码这块儿是用 C++ 来实现的。这里是源码地址:https://github.com/nodejs/node/blob/a6a3684984/lib/internal/modules/cjs/loader.js

image

这说明了什么呢?我们的 state 模块,不管是被引入多少次,那么都只会被加载一次。

同构

在 Nuxt 中,服务端渲染是通过 vue-server-renderer 的 renderToString 方法来实现的。

一般情况下,SPA 应用对 SEO 不友好,首页加载比较慢,所以才出现了服务端渲染。

但服务端渲染带来的是无法保留 SPA 无刷新切换页面的体验。

而在实际场景下,往往只需要对首页进行服务端渲染,其他页面保持 SPA 的体验,所以有了同构应用的诞生。

同构的意思是,一份代码,既可以跑在浏览器端,又可以跑在服务端。

image

同构的一个核心是在于实现路由同构,比如我们使用 express 作为服务端框架,那么我们需要一份代码来将 express-router 和 react-router/vue-router 的差异抹平。

当我们编写了一份 router 文件,这份文件既可以作为 express-router 的配置,也可以作为 react-router/vue-router 的配置。

比如在客户端我们需要根据 router 来拿到 component 和 store 进行客户端渲染,在服务端我们依然要根据 router 来拿到 component 和 store 做服务端渲染。

这一步往往在路由中间件里面去初始化 store,拿到组件,将其渲染成字符串给浏览器。

在 nuxt 中,也是使用了路由中间件的形式。

为什么刷新不生效?

因为在服务启动的时候,就已经加载了 state 文件,后续不管怎么刷新页面,都只是在路由中间件里面去使用初始化模块来创建 Store,这个时候日期肯定就停留在发布的那天了。

这里我写了一个简单的 Demo 来帮助了解:

image

那么如何解决这个问题呢?其实也很简单,我们只需要将日期放到 state 函数里面就行了,每次创建 Store 的时候都会执行 State 函数重新计算。

image

为什么在浏览器刷新生效?

在浏览器端,刷新的时候 JS 文件都会被重新加载,模块也会被重新执行,日期自然是当前最新的日期。而在 Node 端,这些模块都被加载到了内存里面,所以不会重新执行了。

runInNewContext

但是在本地 dev 模式下,即使把日期放到外面,刷新后也会起效。这是因为本地开发模式开启了 runInNewContext

nuxt 会从 nuxt.config.js 里面读取了 runInNewContext 判断是否在新的上下文中运行。

image

这个 runInNewContext 是什么呢?这个 Node 提供的 vm 模块中的一个方法,它可以提供一个 JS 沙盒,允许你在新的上下文中运行代码,从而实现和当前上下文的隔离。有点儿类似于 eval

Nuxt 底层依然使用了 vue-server-renderer 中的 createBundleRenderer 方法,里面有个 runInNewContext 选项,会判断是否创建一个新的上下文去运行。

image

官方的解释是,当访问页面的时候,会判断是否使用 runInNewContext 来执行路由 Bundle 文件(Page、Store 等等),这也是为什么开启了这个选项之后就可以生效。

更新另一个问题

最近在给小课加 SSR 的时候,在 Nuxt 里面还遇到一个问题。
那就是在使用 vue-property-decorator 的时候,组件内部使用 fetch 在服务端获取数据后直出,fetch 方法却一直不执行,最后才发现需要用 nuxt-property-decorator,并且在 nuxt.config.js 的 build 选项里面写上这个:

babel: {
      presets({ isServer }) {
        return [
          [
            "@nuxt/babel-preset-app", { loose: true }
          ]
        ]
      }
    },

2019展望

2018年过得浑浑噩噩,在舒适区呆了一整年,没什么值得总结的,还是希望在新的一年能够做得更好吧。

技术

对我来说,工作也是提高技术的一部分,所以这两个放到一块说。
技术上,在2018年进步不小,但技术栈依然局限在react上,对服务端和http懂得依然很少。所以在2019年,我的主要精力会放到nodejs和webpack、http上面,技术栈慢慢往全栈上面转。
学习之余,希望自己能够参与一些开源项目,或者自己搞一个开源项目,不管是UI组件库还是工具库都可以。

工作上自然没啥好说的啦~希望能够保持对工作的热情,做自己喜欢做的事情,希望能够升职加薪,毕竟现在已经被一起毕业的同学甩在身后了。

生活

厨艺

还记得去年年底回家的时候,我信心满满地要给大家做个大盘鸡,结果最后失败的非常尴尬哈哈哈。
18年在厨艺上面有了很显著的进步,对鸡精、盐、糖、五香粉等调味剂有了进一步的认识,但是味道和卖相还是达不到餐馆里面的水平,希望今年在厨艺上面有进一步的突破。

健康

临近年末,天气越来越冷,胃痛也越来越多,希望明年能多注意一下饮食,保持好的健康,不想再被病痛折磨了。
关于健身,不做强制要求了,只希望自己能够自觉的保持锻炼的习惯,稍微把体重减下去一些,不想当个小胖子了。

写作

最近一个月天天加班,身心俱惫,也让我不得不中断学习和阅读。
在19年要继续看一些写作方面的书,多看一些散文和小说,争取能写出几篇短篇小说来。培养写作的爱好,为以后的人生增加更多地选择。

理财

17年看过《富爸爸穷爸爸》后就没有看过关于理财方面的书了。18年炒虚拟货币损失惨重,亏了3w左右吧,我也一直不敢和父母说这个,毕竟这也是我交的学费。
这个社会是非常真实的,没有钱什么都做不成。所以19年除了学习,最重要的就是赚钱赚钱赚钱,一方面希望我能和艾老师、叶佳他们取取经,学习一下流量变现,另一方面就是控制支出,多攒一些前期的资本。

书籍

虽说不应该给自己立flag,但还是要有个小目标。

技术

  1. 图解http
  2. 计算机网络:自顶向下方法
  3. 大话数据结构
  4. 剑指offer
  5. js设计模式与实践
  6. 代码大全2

散文和小说

  1. 给青年的二十七堂文学课
  2. 文心
  3. 文章自在
  4. 文章例话
  5. 完全写作指南
  6. 朱自清散文集
  7. 梁实秋散文集
  8. 沈从文散文
  9. 月亮与六便士

理财

  1. 富爸爸财务自由之路
  2. 财务自由之路
  3. 门口的野蛮人
  4. 区块链:从数字货币到信用社会

前端开发中的 AOP 和 IoC

前言

很多前端开发都知道面向对象编程(OOP),却比较少了解 AOP(面向切面编程)这个概念。如果你有使用过 Spring 或者 Nestjs 之类的框架,那就已经接触过 AOP 了。
AOP 是 OOP 的一种补充,前面介绍过的装饰器和 Proxy 都是可以实现 AOP 的一种方式,这也是为什么我把这节放到后面才来讲。
本节课将会重点介绍 AOP(面向切面编程)、IOC(控制反转)和 DI(依赖注入)、Middleware(中间件)相关的概念与实践。

什么是 AOP?

面向切面编程

AOP 是通过预编译方式和运行期动态代理实现在不修改源代码的情况下给程序动态统一添加功能的一种技术。
AOP 实际是 GoF 设计模式的延续,设计模式孜孜不倦追求的是调用者和被调用者之间的解耦,提高代码的灵活性和可扩展性,AOP可以说也是这种目标的一种实现。

在面向对象中,我们强调单一职责原则和封装,于是我们用不同的类来设计不同的方法,这样代码就分散到一个个类中,降低了复杂度,也提高了类的可重用。

class Cat {
    eat() {}
}
class Dog {
    eat() {}
}
class Duck {
    eat() {}
}

这样看起来很完美,只是有一个问题。那就是如果有一个功能,在所有的类中都需要用到该怎么办?这种设计方式是不是增加了代码的重复性呢?比如我们需要打印出不同动物觅食的信息。
你可能会想到,我再把这部分功能提出来,放到一个新的类 class Logger 里面,在其他类需要的时候调用不就好了吗?

class Logger {
    log() {}
}
class Cat {
    eat() {
        new Logger().log()
    }
}
class Dog {
    eat() {
        new Logger().log()
    }
}

可是这样会让不同的类耦合到一起啊,增加了类之间的耦合度。比如哪天删掉了 Logger 类的 log 方法,那么耦合了 Logger 类的所有类岂不是也要跟着一起修改?
好的软件设计不仅需要降低代码复杂度,也应该减少模块之间的耦合度。
那么有没有一种好的办法解决上面的问题呢?怎么才能让我们随心所欲的在代码中增加新的功能呢?
这就要面向切面(AOP)编程登场了。
切面,可以看做是横切进去的一个平面。通常可以将一些与主业务逻辑无关的代码抽离出来,比如日志、鉴权等等,做成一个个切面,每次调用对应方法的时候都要经过这些切面,如下图所示:

image_1ds2fukae3pbraadfo7l1fg69.png-26.9kB

关于如何实现 AOP 没有一种特定的方式,你可以用你喜欢的方式来。

  • 用代理模式可以吗?
  • 可以。
  • 用 Proxy 可以吗?
  • 当然可以。
  • 用装饰器呢?
  • 也可以。

对于面向切面编程,需要关注如下几点:

  1. 切面不是 OOP 的替代,而是对 OOP 的一种补充,用于改进 OOP。
  2. 切面是主业务之外的、分散在不同类和模块中的横切关注点,即公共部分。
  3. 如何从业务中提取出横切关注点是面向切面编程的重要核心。

经典的 before 和 after

针对上面这个例子,可以修改函数的原型,这也是比较常用的一种方式,增加 before 和 after 两个方法。

Function.prototype.after = function (action) {
    const func = this;
    return function () {
        const result = func.apply(this, arguments);
        action.apply(this, arguments);
        return result;
    };
};
Function.prototype.before = function (action) {
    const func = this;
    return function () {
        action.apply(this, arguments);
        return func.apply(this, arguments);
    };
};

因此,上面在执行 eat 方法的时候也可以对其进行改造。

const log = () => {}
const cat = new Cat();
cat.eat = cat.eat.after(log);
cat.eat();

代理模式

第一种方法,可以使用代理模式可以解决上面的问题。只需要创建一个代理类,在这个类里面去执行 eat 方法就好了。

class ProxyLogger {
    constructor(animal) {
        this.animal = animal;
    }
    log() {}
    eat() {
        this.target.eat();
        this.log();
    }
}
const proxyCat = new ProxyLogger(new Cat());
proxyCat.eat();

即使这个 ProxyLogger 类的 log 方法之后修改了,也不会影响其他几个类的内容。

Proxy

你可能会觉得使用代理模式需要增加新的类,而且每次都要去 new 这个类,不如用 Proxy 试试吧。
通过 Proxy 来代理类上面的 eat 方法,在执行 eat 方法之后去插入执行 log 方法。

const ProxyLog = (targetClass) => {
    const log = () => {} // log 方法
    const prototype = targetClass.prototype;
    Object.getOwnPropertyNames(prototype).forEach((name) => {
    if (name === 'eat') {
        prototype[name] = new Proxy(prototype[name], {
        apply(target, context, args) {
            target.apply(context, args)
            log()
        }
    })
    }
 })
}
ProxyLog(Cat);
new Cat().eat();

可能你会说,我不想只打印 eat 方法的参数啊,我还想打印其他的方法呢?其实这个可以用高阶函数来扩展一下。

const ProxyLog = (targetClass) => (targetFunc) => {
    const log = () => {} // log 方法
    const prototype = targetClass.prototype;
    Object.getOwnPropertyNames(prototype).forEach((name) => {
    if (name === targetFunc) {
        prototype[name] = new Proxy(prototype[name], {
        apply(target, context, args) {
            target.apply(context, args)
            log()
        }
    })
    }
 })
}
ProxyLog(Cat)('eat');
ProxyLog(Cat)('meow');

装饰器

上节课我们讲过 Python 中的登录鉴权的实现,那是使用装饰器来实现 AOP 的一种方式。

def auth(func):
    def inner(request,*args,**kwargs):
        v = request.COOKIES.get('user')
        if not v:
            return redirect('/login')
        return func(request, *args,**kwargs)
    return inner

@auth
def index(request):
    user = request.COOKIES.get("user")
    return render(request,"index.html",{"current_user": user})

装饰器有个好处就是可以使得代码看起来更加简洁、易读。上面的例子用装饰器来实现后就会更加优雅。

const logger = (target, name, descriptor) => {
    const log = () => {} // 打印信息
    const func = descriptor.value;
    if (typeof func === 'function') {
        descriptor.value = function(...args) {
            const results = func.apply(this, args);
            log();
            return results;
        }
    }
}
class Cat {
    @logger eat() {}
}
class Dog {
    @logger eat() {}
}

中间件

如果你有使用过 express/koa 或者 redux 这些技术,对中间件这个概念一定不会陌生吧。
前端的中间件技术类似于可以自由组合、自由插拔的插件机制,你可以使用多个中间件去帮完成一些与主业务无关的任务。
图示为 koa 的洋葱模型:

image_1ds4403e9fno2nd1499164udi19.png-133.4kB

请求进来的时候,会经过一个个中间件方法,这些中间件方法会对请求进行一些处理,然后返回最终的结果,这一点儿和 AOP 很类似。

koa 中间件

以 koa 中的中间件为例子。
一般在初始化 koa 实例后,我们使用 use 方法去加载中间件(middleware),使用数组来保存中间件,中间件的执行顺序决定于 use 的调用顺序。
而每个中间件方法都有 ctx 和 next 两个参数。ctx 代表上下文对象,而 next 则是 koa-compose 定义的中间件方法。
在中间件方法中通过 next 方法可以去执行下一个中间件方法。每个请求进来的时候都会经过 use 中的这两个方法,最终打印出来 1、2、3。

const Koa = require('koa')
const app = new Koa()
app.use((ctx, next) => {
    console.log(1)
    next()
    console.log(3)
})
app.use((ctx) => {
    console.log(2)
})
app.listen(3000)

这种形式其实和前面讲过的 generator 有一些类似,都是需要去调用 next 才能执行下一步。

redux 中间件

redux 中也提供了中间件的形式,允许你在创建 store 的时候添加中间件去处理每次 dispatch 进来的 action。
比如大名鼎鼎的 redux-logger 和 redux-thunk,前者打印每次 action 的相关信息,后者则是改造了 action,使其支持异步请求。
我们都知道,redux 原本只支持同步的 action。以一个请求为例子,每次请求前设置 loading 状态为 true,请求结束后设置为 false,即:

const open = () => {
    return {
        type: 'OPEN',
        payload: true
    }
}
const close = () => {
    return {
        type: 'CLOSE',
        payload: false
    }
}

如果不依赖中间件,该怎么去调用这两个方法呢?可以直接在 react 组件中去调用。

class App extends React.Component {
    componentDidMount() {
        this.fetchList();
    }
    fetchList = () => {
        dispatch(open());
        await fetch('/list');
        dispatch(close());
    }
}

但是如果应用中很多组件都会用到这个请求,那该怎么办呢?当然可以将这个方法从组件中剥离出来放到公共方法中,但是 redux 提供了中间件去处理这种场景。

// actions.js
const fetchList = (dispatch) => async () => {
    dispatch(open());
    await fetch('/list');
    dispatch(close());
}
// App.jsx
class App extends React.Component {
    componentDidMount() {
        this.props.fetchList();
    }
}

上面用到的就是 redux-thunk 这个中间件,它对 dispatch 传入的参数进行了处理,使其支持返回一个函数。
redux-thunk 的主要源码如下:

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }
    return next(action);
  };
}

如果传给 dispatch 的是一个函数,那么就将 dispatch、getState 等参数传入并执行。否则就不做处理,直接将 action 传给下一个中间件。

控制反转

在开始之前,先从一个猫吃老鼠的例子说起。

class Cat {
    constructor() {
        this.food = new Mice();
    }
    eat() {
       console.log('The cat eats', this.food); 
    }
}

上面乍一看是没问题的,这么简单的几行代码会有啥问题呢?
但仔细分析一下,主要问题有这两个:

  1. 如果想要修改 food 的生成方式,就要到 Cat 类里面进行修改。
  2. 如果 Mice 类初始化比较耗时,那么也会导致 Cat 类初始化耗时。
    简单来说,就是 Cat 和 Mice 这两个类互相依赖,耦合到了一起,需要想办法将其分开。
class Cat {
    constructor(mice) {
        this.food = mice;
    }
    eat() {
       console.log('The cat eats', this.food); 
    }
}

所以可以将实例化的过程放到类外面,传给构造函数,这样两个类就不会耦合到一起了。
IoC(Inversion of Control),中文含义是“控制反转”,它是面向对象编程中的一种设计原则。在软件开发中,IoC 意味着将你设计好的对象交给容器控制,而不是传统的在你的对象内部直接控制。

Promise

前面讲解过 Promise 内容的时候,有提到过信任问题。由于回调函数调用是依赖于第三方模块的,我们无法知道它会被调多少次,这样就可能会出现问题。

// 无法得知 callCamera 的实现
callCamera(function() {
    console.log("调用摄像头成功")
})

而 Promise 则是将控制权交到了使用者手里,这样就可以避免受制于第三方模块的实现,我可以自己规定调用几次。

callCamera().then(() => {
    console.log("调用摄像头成功")
})

render props

在 React 里面也有类似控制反转的概念,大名鼎鼎的 render props 就是其中一种。
以轮播图为例子,轮播图主要是渲染其中的图片列表,所以可以这样设计。

<Swiper>
    <img src="x1" />
    <img src="x2" />
    <img src="x3" />
</Swiper>

但是你无法得知 Swiper 组件会对你传入的 img 怎么做渲染,除非去看源码。但通过 render props 的形式,就可以由你自己控制来怎么渲染这些图片。

<Swiper list={list} 
    renderList={(item, index) => (
        <img src={item.src} key={item.id} />
    )} 
/>

依赖注入

依赖注入和控制反转的关系

来重温一下前面的《Javascript 面向对象精读》这篇文章,我们有提到过“依赖倒置原则(Dependence Inversion Principle)”。
依赖倒置原则要求程序依赖于抽象接口,不要依赖于具体实现。简单的说就是要求对抽象进行编程,不要对实现进行编程,这样就降低了客户与实现模块间的耦合。
从字面意思来理解,控制反转意味着控制权反转,就是将控制权交出去。
那么是哪方面的控制被反转了呢?一个叫 Martin Fowler 的人经过分析得出,获取依赖对象的过程被反转了。
比如上面例子中的 food,原本控制权在 Cat 类里面,修改之后 Cat 将控制权交了出去,获得依赖对象的过程从自身管理变为了由 IOC 容器主动注入。
他给控制反转起了另一个名字,即依赖注入(Dependency Injection)。依赖注入就是在 IoC 容器运行期间将依赖动态的注入到对象中。

redux 中的依赖注入

在 mobx 和 redux 中都有注入的概念,通过 inject/connect 方法,将组件需要的状态和方法都注入进去。

// redux
@connect(mapStateToProps, mapDispatchToProps)
class App extends React.Component {}

// mobx
@inject(({ store }) => ({
    // ...
}))
class App extends React.Component {}

依赖注入的好处是对外部依赖比较清晰,便于维护。在 nestjs 中到处都能看到依赖注入的实例。

@Module({
    controllers: [ UsersController ],
    components: [ UsersService, ChatGateway ],
})
export class UsersModule implements NestModule {
    constructor(private usersService: UsersService) {}
}

依赖注入的好处就是将两个对象解耦,通过一个 IoC 容器来将依赖的数据注入到另一个对象之中,这个 IoC 容器就起了桥接的作用。

推荐阅读

  1. 依赖注入那些事儿
  2. 深度理解依赖注入
  3. 前端需要知道的 依赖注入(Dependency Injection, DI)

浅谈一种新的状态管理实现

rex

基于redux改进后的状态管理库 —— rex

设计**

在使用了mobx之后,我深深爱上了mobx那种面向对象创建store的**,对redux的view -> dispatch -> action -> reducer -> store -> view的形式深恶痛绝,于是就开始思考如果用class来创建store的话会怎么样?
于是,rex就诞生了。
image_1d8ncob8d1hki1gtmma21ol21hh99.png-36.4kB

state、action

装饰器state用来包裹需要监听的状态,而action则是包裹改变state的函数,在外部调用action包裹方法的时候,内部会自动调用dispatch函数,以确保通知view层状态发生了变化。
如果不用state包裹的属性,那么就会当做一个私有属性来处理,不会出现在store中。

BaseStore

BaseStore是一个基础类,在BaseStore中封装了很多私有函数,用于处理一些底层的逻辑。创建store的时候一定要继承这个基础的BaseStore类,类与类之间可以互相组合。

使用方法

// 如何创建一个store
import {
    action,
    state,
    BaseStore
} from 'rex';
class Counter extends BaseStore {
    @state count = 0;
    @action
    increase() {
        this.count += 1;
    }
    @action
    decrease() {
        this.count -= 1;
    }
}
const counter = Counter.createStore();
export default counter;

// 如何结合react-redux使用
ReactDOM.render(
  <Provider store={counter}>
    <App />
  </Provider>,
  document.getElementById('app')
)

export default connect(state => ({ 
    count: state.count,
    increase: state.increase,
    decrease: state.decrease
}))(props => 
  <div>
    <button onClick={() => props.increase()}>+</button>
    {props.count}
    <button onClick={() => props.decrease()}>-</button>
  </div>
)

和redux的区别

rex的思路和redux并没有本质区别,只是在用法上有一些细微差异。
store的创建必须通过class,而不是reducer。通过action装饰器包裹的函数在执行时会自动使用dispatch,不用手动调用dispatch,将dispatch -> action -> reducer简化为了一步。

结论

这个库在一定程度上借鉴了redux和mobx的**,将其两者做了一个结合,使用面向对象的形式来写redux,各方面和redux保持一致,但是操作却简化了不少。

JS Bridge 通信原理与实践

前言

上一篇介绍了移动端开发的相关技术,这一篇主要是从 Hybrid 开发的 JS Bridge 通信讲起。

顾名思义,JS Bridge 的意思就是桥,这是一个连接 JS 和 Native 的桥接,也是 Hybrid App 里面的核心。一般分为 JS 调用 Native 和 Native 主动调用 JS 两种形式。

URL Scheme

URL Scheme 是一种特殊的 URL,一般用于在 Web 端唤醒 App,甚至跳转到 App 的某个页面,比如在某个手机网站上付款的时候,可以直接拉起支付宝支付页面。

这里有个小例子,你可以在浏览器里面直接输入 weixin://,系统就会提示你是否要打开微信。输入 mqq:// 就会帮你唤起手机 QQ。

image

这里有个常用 App URL Scheme 汇总:URL Schemes 收集整理

在手机里面打开这个页面后点击这里,就会提示你是否要打开微信。

我们常说的 Deeplink 一般也是基于 URL Scheme 来实现的。一个 URI 的组成结构如下:

image

URI = scheme:[//authority]path[?query][#fragment]
// scheme     = http
// authority  = www.baidu.com
// path       = /link
// query      = url=xxxxx
authority = [userinfo@]host[:port]

除了 http/https 这两个常见的协议,还可以自定义协议。借用维基百科的一张图:

image

通常情况下,App 安装后会在手机系统上注册一个 Scheme,比如 weixin:// 这种,所以我们在手机浏览器里面访问这个 scheme 地址,系统就会唤起我们的 App。

一般在 Android 里面需要到 AndroidManifest.xml 文件中去注册 Scheme:

<activity
    android:name=".login.dispatch.DispatchActivity"
    android:launchMode="singleTask"
    android:theme="@style/AppDispatchTheme">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="taobao" />
        <data android:host="xxx" />
        <data android:path="/goods" />
    </intent-filter>
</activity>

在 iOS 中需要在 Xcode 里面注册,有一些已经是系统使用的不应该使用,比如 Maps、YouTube、Music。具体可以参考苹果开发者官网文档:Defining a Custom URL Scheme for Your App

image

JS 调用 Native

在 iOS 里面又需要区分 UIWebView 和 WKWebView 两种 WebView:

image

WKWebView 是 iOS8 之后出现的,目的是取代笨重的 UIWebView,它占用内存更少,大概是 UIWebView 的 1/3,支持更好的 HTML5 特性,性能更加强大。
但也有一些缺点,比如不支持缓存,需要自己注入 Cookie,发送 POST 请求的时候带不了参数,拦截 POST 请求的时候无法解析参数等等。

JS 调用 Native 通信大致有三种方法:

  1. 拦截 Scheme
  2. 弹窗拦截
  3. 注入 JS 上下文

这三种方式总体上各有利弊,下面会一一介绍。

拦截 Scheme

仔细思考一下,如果是 JS 和 Java 之间传递数据,我们该怎么做呢?
对于前端开发来说,调 Ajax 请求接口是最常见的需求了。不管对方是 Java 还是 Python,我们都可以通过 http/https 接口来获取数据。实际上这个流程和 JSONP 更加类似。

已知客户端是可以拦截请求的,那么可不可以在这个上面做文章呢?

如果我们请求一个不存在的地址,上面带了一些参数,通过参数告诉客户端我们需要调用的功能呢?

比如我要调用扫码功能:

axios.get('http://xxxx?func=scan&callback_id=yyyy')

客户端可以拦截这个请求,去解析参数上面的 func 来判断当前需要调起哪个功能。客户端调起扫码功能之后,会获取 WebView 上面的 callbacks 对象,根据 callback_id 回调它。

所以基于上面的例子,我们可以把域名和路径当做通信标识,参数里面的 func 当做指令,callback_id 当做回调函数,其他参数当做数据传递。对于不满足条件的 http 请求不应该拦截。

当然了,现在主流的方式是前面我们看到的自定义 Scheme 协议,以这个为通信标识,域名和路径当做指令。

这种方式的好处就是 iOS6 以前只支持这种方式,兼容性比较好。

JS 侧

我们有很多种方法可以发起请求,目前使用最广泛的是 iframe 跳转:

  1. 使用 a 标签跳转
<a href="taobao://">点击我打开淘宝</a>
  1. 重定向
location.href = "taobao://"
  1. iframe 跳转
const iframe = document.createElement("iframe");
iframe.src = "taobao://"
iframe.style.display = "none"
document.body.appendChild(iframe)

Android 侧

在 Android 侧可以用 shouldOverrideUrlLoading 来拦截 url 请求。

@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
    if (url.startsWith("taobao")) {
        // 拿到调用路径后解析调用的指令和参数,根据这些去调用 Native 方法
        return true;
    }
}

iOS 侧

在 iOS 侧需要区分 UIWebView 和 WKWebView 两种方式。
在 UIWebView 中:

- (BOOL)shouldStartLoadWithRequest:(NSURLRequest *)request
                    navigationType:(BPWebViewNavigationType)navigationType
{

    if (xxx) {
        // 拿到调用路径后解析调用的指令和参数,根据这些去调用 Native 方法
        return NO;
    }

    return [super shouldStartLoadWithRequest:request navigationType:navigationType];
}

在 WKWebView 中:

- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(nonnull WKNavigationAction *)navigationAction decisionHandler:(nonnull void (^)(WKNavigationActionPolicy))decisionHandler
{
    if(xxx) {
        // 拿到调用路径后解析调用的指令和参数,根据这些去调用 Native 方法
        BLOCK_EXEC(decisionHandler, WKNavigationActionPolicyCancel);
    } else {
        BLOCK_EXEC(decisionHandler, WKNavigationActionPolicyAllow);
    }

    [self.webView.URLLoader webView:webView decidedPolicy:policy forNavigationAction:navigationAction];
}

目前不建议只使用拦截 URL Scheme 解析参数的形式,主要存在几个问题。

  1. 连续续调用 location.href 会出现消息丢失,因为 WebView 限制了连续跳转,会过滤掉后续的请求。
  2. URL 会有长度限制,一旦过长就会出现信息丢失
    因此,类似 WebViewJavaScriptBridge 这类库,就结合了注入 API 的形式一起使用,这也是我们这边目前使用的方式,后面会介绍一下。

弹窗拦截

Android 实现

这种方式是利用弹窗会触发 WebView 相应事件来拦截的。一般是在 setWebChromeClient 里面的 onJsAlertonJsConfirmonJsPrompt 方法拦截并解析他们传来的消息。

// 拦截 Prompt
@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
       if (xxx) {
        // 解析 message 的值,调用对应方法
       }
       return super.onJsPrompt(view, url, message, defaultValue, result);
   }
// 拦截 Confirm
@Override
public boolean onJsConfirm(WebView view, String url, String message, JsResult result) {
       return super.onJsConfirm(view, url, message, result);
   }
// 拦截 Alert
@Override
public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
       return super.onJsAlert(view, url, message, result);
   }

iOS 实现

我们以 WKWebView 为例:

+ (void)webViewRunJavaScriptTextInputPanelWithPrompt:(NSString *)prompt
    defaultText:(NSString *)defaultText
    completionHandler:(void (^)(NSString * _Nullable))completionHandler
{
    /** Triggered by JS:
    var person = prompt("Please enter your name", "Harry Potter");
    if (person == null || person == "") {
       txt = "User cancelled the prompt.";
    } else {
       txt = "Hello " + person + "! How are you today?";
    }
    */
    if (xxx) {
        BLOCK_EXEC(completionHandler, text);
    } else {
        BLOCK_EXEC(completionHandler, nil);
    }
 }

这种方式的缺点就是在 iOS 上面 UIWebView 不支持,但是 WKWebView 又有更好的 scriptMessageHandler,比较尴尬。

注入上下文

前面我们有讲过在 iOS 中内置了 JavaScriptCore 这个框架,可以实现执行 JS 以及注入 Native 对象等功能。

这种方式不依赖拦截,主要是通过 WebView 向 JS 的上下文注入对象和方法,可以让 JS 直接调用原生。

PS:iOS 中的 Block 是 OC 对于闭包的实现,它本质上是个对象,定义 JS 里面的函数。

iOS UIWebView

iOS 侧代码:

// 获取 JS 上下文
JSContext *context = [webview valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
// 注入 Block
context[@"callHandler"] = ^(JSValue * data) {
    // 处理调用方法和参数
    // 调用 Native 功能
    // 回调 JS Callback
}

JS 代码:

window.callHandler({
    type: "scan",
    data: "",
    callback: function(data) {
    }
});

这种方式的牛逼之处在于,JS 调用是同步的,可以立马拿到返回值。

我们也不再需要像拦截方式一样,每次传值都要把对象做 JSON.stringify,可以直接传 JSON 过去,也支持直接传一个函数过去。

iOS WKWebView

WKWebView 里面通过 addScriptMessageHandler 来注入对象到 JS 上下文,可以在 WebView 销毁的时候调用 removeScriptMessageHandler 来销毁这个对象。
前端调用注入的原生方法之后,可以通过 didReceiveScriptMessage 来接收前端传过来的参数。

WKWebView *wkWebView = [[WKWebView alloc] init];
WKWebViewConfiguration *configuration = wkWebView.configuration;
WKUserContentController *userCC = configuration.userContentController;

// 注入对象
[userCC addScriptMessageHandler:self name:@"nativeObj"];
// 清除对象
[userCC removeScriptMessageHandler:self name:@"nativeObj"];

// 客户端处理前端调用
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
{
    // 获取前端传来的参数
    NSDictionary *msgBody = message.body;
    // 如果是 nativeObj 就进行相应处理
    if (![message.name isEqualToString:@"nativeObj"]) {
        // 
        return;
    }
}

使用 addScriptMessageHandler 注入的对象实际上只有一个 postMessage 方法,无法调用更多自定义方法。前端的调用方式如下:

window.webkit.messageHandlers.nativeObj.postMessage(data);

需要注意的是,这种方式要求 iOS8 及以上,而且返回不是同步的。和 UIWebView 一样的是,也支持直接传 JSON 对象,不需要 stringify。

Android addJavascriptInterface

安卓4.2之前注入 JS 一般是使用 addJavascriptInterface ,和前面的 addScriptMessageHandler 有一些类似,但又没有它的限制。

public void addJavascriptInterface() {
        mWebView.addJavascriptInterface(new DatePickerJSBridge(), "DatePickerBridge");
    }
private class PickerJSBridge {
    public void _pick(...) {
    }
}

在 JS 里面调用:

window.DatePickerBridge._pick(...)

但这种方案有一定风险,可以参考这篇文章:WebView中接口隐患与手机挂马利用
在 Android4.2 之后提供了 @JavascriptInterface 注解,暴露给 JS 的方法必须要带上这个。
所以前面的 _pick 方法需要带上这个注解。

private class PickerJSBridge {
    @JavascriptInterface
    public void _pick(...) {
    }
}

Native 调用 JS

Native 调用 JS 一般就是直接 JS 代码字符串,有些类似我们调用 JS 中的 eval 去执行一串代码。一般有 loadUrlevaluateJavascript 等几种方法,这里逐一介绍。
但是不管哪种方式,客户端都只能拿到挂载到 window 对象上面的属性和方法。

Android

在 Android 里面需要区分版本,在安卓4.4之前的版本支持 loadUrl,使用方式类似我们在 a 标签的 href 里面写 JS 脚本一样,都是javascript:xxx 的形式。这种方式无法直接获取返回值。

webView.loadUrl("javascript:foo()")

在安卓4.4以上的版本一般使用 evaluateJavascript 这个 API 来调用。这里需要判断一下版本。

if (Build.VERSION.SDK_INT > 19) //see what wrapper we have
{
    webView.evaluateJavascript("javascript:foo()", null);
} else {
    webView.loadUrl("javascript:foo()");
}

UIWebView

在 iOS 的 UIWebView 里面使用 stringByEvaluatingJavaScriptFromString 来调用 JS 代码。这种方式是同步的,会阻塞线程。

results = [self.webView stringByEvaluatingJavaScriptFromString:"foo()"];

WKWebView

WKWebView 可以使用 evaluateJavaScript 方法来调用 JS 代码。

[self.webView evaluateJavaScript:@"document.body.offsetHeight;" completionHandler:^(id _Nullable response, NSError * _Nullable error) {
    // 获取返回值 response
    }];

JS Bridge 设计

前面讲完了 JS 和 Native 互调的所有方法,这里来介绍一下我们这边 JS Bridge 的设计吧。
我们这边的 JS Bridge 通信是基于 WebViewJavascriptBridge 这个库来实现的。主要是结合 Scheme 协议+上下文注入来做。考虑到 Android 和 iOS 不一样的通信方式,这里进行了封装,保证提供给外部的 API 一致。
具体功能的调用我们封装成了 npm 包,下面的是几个基础 API:

  1. callHandler(name, params, callback):这个是调用 Native 功能的方法,传模块名、参数、回调函数给 Native。
  2. hasHandler(name):这个是检查客户端是否支持某个功能的调用。
  3. registerHandler(name):这个是提前注册一个函数,等待 Native 回调,比如 pageDidBack 这种场景。

那么这几个 API 又是如何实现的呢?这里 Android 和 iOS 封装不一致,应当分开来说。

Android Bridge

前面我们有说过安卓可以通过 @JavascriptInterface 注解来将对象和方法暴露给 JS。所以这里的几个方法都是通过注解暴露给 JS 来调用的,在 JS 层面做了一些兼容处理。

hasHandler

首先最简单的是这个 hasHandler,就是在客户端里面维护一张表(其实我们是写死的),里面有支持的 Bridge 模块信息,只需要用 switch...case 判断一下就行了。

@JavascriptInterface
public boolean hasHandler(String cmd) {
        switch (cmd) {
            case xxx:
            case yyy:
            case zzz:
                return true;
        }
        return false;
    }

callHandler

然后我们来看 callHandler 这个方法,它是提供 JS 调用 Native 功能的方法。在调用这个方法之前,我们一般需要先判断一下 Native 是否支持这个功能。

function callHandler(name, params, callback) {
    if (!window.WebViewJavascriptBridge.hasHandler(name)) {
    }
}

如果 Native 没有支持这个 Bridge,我们就需要对回调进行兼容性处理。这个兼容性处理包括两个方面,一个是功能方面,一个是 callback 的默认回参。

比如我们调用 Native 的弹窗功能,如果客户端没支持这个 Bridge,或者我们是在浏览器里面打开的这个页面,此时应该退出到使用 Web 的 alert 弹窗。对于 callback,我们可以默认给传个 0,表示当前不支持这个功能。

假设这个 alert 的 bridge 接收两个参数,分别是 titlecontent,那么此时就应该使用浏览器自带的 alert 展示出来。

function fallback(params, callback) {
    let content = `${params.title}\n{params.content}`
    window.alert(content);
    callback && callback(0)
}

这个 fallback 函数我们希望能够更加通用,每个调用方法都应该有自己的 fallback 函数,所以前面的 callHandler 应该设计成这样:

function callHandler(name, params, fallback) {
    return function(...rest, callback) {
        const paramsList = {};
        for (let i = 0; i < params.length; i++) {
            paramsList[params] = rest[i];
        }
        if (!callback) {
            callback = function(result) {};
        }
        if (fallback && !window.WebViewJavascriptBridge.hasHandler(name))) {
            fallback(paramsList, callback);
        } else {
            window.WebViewJavascriptBridge.callHandler(name, params, callback);
        }
    }
}

我们可以基于这个函数封装一些功能方法,比如前面的 alert:

function fallback(params, callback) {
    let content = `${params.title}\n{params.content}`
    window.alert(content);
    callback && callback(0)
}

function alert(
  title,
  content,
  cb: any
) {
  return callHandler(
    'alert',
    ['title', 'content'],
    fallback
  )(title, content, cb);
}
alert(`this is title`, `hahaha`, function() {
    console.log('success')
})

具体效果类似下面这种,这是从 Google 上随便找的一张图(侵删):

image

那么客户端又如何实现回调 callback 函数的呢?前面说过,客户端想调用 JS 方法,只能调用挂载到 window 对象上面的。

因此,这里使用了一种很巧妙的方法,实际上 callback 函数依然是 JS 执行的。在调用 Native 之前,我们可以先将 callback 函数和一个 uniqueId 映射起来,然后存在 JS 本地。我们只需要将 callbackId 传给 Native 就行了。

function callHandler(name, data, callback) {
    const id = `cb_${uniqueId++}_${new Date().getTime()}`;
    callbacks[id] = callback;
    window.bridge.send(name, JSON.stringify(data), id)
}

在客户端这里,当 send 方法接收到参数之后,会执行相应功能,然后使用 webView.loadUrl 主动调用前端的一个接收函数。

@JavascriptInterface
public void send(final String cmd, String data, final String callbackId) {
    // 获取数据,根据 cmd 来调用对应功能
    // 调用结束后,回调前端 callback
    String js = String.format("javascript: window.bridge.onReceive(\'%1$s\', \'%2$s\');", callbackId, result.toDataString());
    webView.loadUrl(js);
}

所以 JS 需要事前定义好这个 onReceive 方法,它接收一个 callbackId 和一个 result。

window.bridge.onReceive = function(callbackId, result) {
    let params = {};
    try {
        params = JSON.parse(result)
    } catch (err) {
        //
    }
    if (callbackId) {
        const callback = callbacks[callbackId];
        callback(params)
        delete callbacks[callbackId];
    }
}

大致流程如下:

image

registerHandler

注册的流程比较简单,也是我们把 callback 函数事先存到一个 messageHandler 对象里面,不过这次的 key 不再是一个随机的 id,而是 name

function registerHandler(handlerName, callback) {
    if (!messageHandlers[handlerName]) {
      messageHandlers[handlerName] = [handler];
    } else {
      // 支持注册多个 handler
      messageHandlers[handlerName].push(handler);
    }
}
// 检查是否有这个注册可以直接检查 messageHandlers 里面是否有
function hasRegisteredHandler(handlerName) {
    let has = false;
    try {
      has = !!messageHandlers[handlerName];
    } catch (exception) {}
      return has;
    }

这里不像 callHandler 需要主动调用 window.bridge.send 去通知客户端,只需要等客户端到了相应的时机来调用 window.bridge.onReceive 就行了。
所以这里还需要改造一下 onReceive 方法。由于不再会有 callbackId 了,所以客户端可以传个空值,然后将 handlerName 放到 result 里面。

window.bridge.onReceive = function(callbackId, result) {
    let params = {};
    try {
        params = JSON.parse(result)
    } catch (err) {
        //
    }
    if (callbackId) {
        const callback = callbacks[callbackId];
        callback(params)
        delete callbacks[callbackId];
    } else if (params.handlerName)(
        // 可能注册了多个
        const handlers =  messageHandlers[params.handlerName];
        for (let i = 0; i < handlers.length; i++) {
            try {
                delete params.handlerName;
                handlers[i](params);
            } catch (exception) {
            }
        }
    )
}

这种情况下的流程如下,可以发现完全不需要 JS 调用 Native:

image

iOS Bridge

讲完了 Android,我们再来讲讲 iOS,原本 iOS 可以和 Android 设计一致,可是由于种种原因导致有不少差异。

iOS 和 Android 中最显著的差异就在于这个 window.bridge.send 方法的实现,Android 里面是直接调用 Native 的方法,iOS 中是通过 URL Scheme 的形式调用。

协议依然是 WebViewJavaScriptBridge 里面的协议,URL Scheme 本身不会传递数据,只是告诉 Native 有新的调用。

然后 Native 会去调用 JS 的方法,获取队列里面所有需要执行的方法。

所以我们需要事先创建好一个 iframe,插入到 DOM 里面,方便后续使用。

const CUSTOM_PROTOCOL_SCHEME = 'wvjbscheme';
const QUEUE_HAS_MESSAGE = '__WVJB_QUEUE_MESSAGE__';
function _createQueueReadyIframe(doc) {
    messagingIframe = doc.createElement('iframe');
    messagingIframe.style.display = 'none';
    messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
    doc.documentElement.appendChild(messagingIframe);
  }

callHandler

每次调用的时候只需要复用这个 iframe 就行了。这里是处理 callback 并通知 Native 的代码:

function callHandler(handlerName, data, responseCallback) {
    _doSend({ handlerName: handlerName, data: data }, responseCallback);
  }
function _doSend(
    message,
    callback
  ) {
    if (responseCallback) {
      const callbackId = `cb_${uniqueId++}_${new Date().getTime()}`;
      callbacks[callbackId] = callback;
      message['callbackId'] = callbackId;
    }
    sendMessageQueue.push(message);
    messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
  }

通知 Native 之后,它怎么拿到我们的 handlerNamedata 呢?我们可以实现一个 fetchQueue 的方法。

  function _fetchQueue() {
    const messageQueueString = JSON.stringify(sendMessageQueue);
    sendMessageQueue = [];
    return messageQueueString;
  }

然后将其挂载到 window.WebViewJavascriptBridge 对象上面。

  window.WebViewJavascriptBridge = {
    _fetchQueue: _fetchQueue
  };

这样 iOS 就可以使用 evaluateJavaScript 轻松拿到这个 messageQueue

- (void)flushMessageQueue
{
    [_webView evaluateJavaScript:@"WebViewJavascriptBridge._fetchQueue();" completionHandler:^(id _Nullable result, NSError * _Nullable error) {
        [self _flushMessageQueue:result];
    }];
}
- (void)_flushMessageQueue:(id)messageQueueObj
{
    // 解析 messageQueueString
    // 根据传入的 handlerName 执行对应操作
}

那么 iOS 又是如何回调 JS 的 callback 函数呢?这个其实和 Android 的 onReceive 是同样的原理。这里可以实现一个 _handleMessageFromObjC 方法,同样挂载到 window.WebViewJavascriptBridge 对象上面,等待 iOS 回调。

function _dispatchMessageFromObjC(messageJSON) {
    const message = JSON.parse(messageJSON);
    if (message.responseId) {
        var responseCallback = callbacks[message.responseId];
        if (!responseCallback) {
          return;
        }
        responseCallback(message.responseData);
        delete callbacks[message.responseId];
     }
}

流程如下:

image

registerHandler

registerHandler 和 Android 原理是一模一样的,都是提前注册一个事件,等待 iOS 调用,具体就不多讲了,这里直接放代码:

// 注册
function registerHandler(handlerName, handler) {
    if (typeof messageHandlers[handlerName] === 'undefined') {
      messageHandlers[handlerName] = [handler];
    } else {
      messageHandlers[handlerName].push(handler);
    }
  }
// 回调
function _dispatchMessageFromObjC(messageJSON) {
    const message = JSON.parse(messageJSON);
    if (message.responseId) {
        var responseCallback = callbacks[message.responseId];
        if (!responseCallback) {
          return;
        }
        responseCallback(message.responseData);
        delete callbacks[message.responseId];
     } else if (message.handlerName){
        handlers = messageHandlers[message.handlerName];
        for (let i = 0; i < handlers.length; i++) {
          try {
            handlers[i](message.data, responseCallback);
          } catch (exception) {
          }
        }
     }
}

总结

这些就是 Hybrid 里面 JS 和 Native 交互的大致原理,忽略了不少细节,比如初始化 WebViewJavascriptBridge 对象等等,感兴趣的也可以参考一下这个库:JsBridge

都2020年了,你还不会JavaScript 装饰器?

俗话说,人靠衣装,佛靠金装。大街上的小姐姐都喜欢把自己打扮得美美的,让你忍不住多看几眼,这就是装饰的作用。

1. 前言

装饰器是最新的 ECMA 中的一个提案,是一种与类(class)相关的语法,用来注释或修改类和类方法。装饰器在 Python 和 Java 等语言中也被大量使用。装饰器是实现 AOP(面向切面)编程的一种重要方式。

code.png-98.4kB

下面是一个使用装饰器的简单例子,这个 @readonly 可以将 count 属性设置为只读。可以看出来,装饰器大大提高了代码的简洁性和可读性。

class Person {
    @readonly count = 0;
}

由于浏览器还未支持装饰器,为了让大家能够正常看到效果,这里我使用 Parcel 进行了一下简单的配置,可以去 clone 这个仓库后再来运行本文涉及到的所有例子,仓库地址:learn es6

本文涉及到 Object.defineProperty、高阶函数等知识,如果之前没有了解过相关概念,建议先了解后再来阅读本文。

2. 装饰器模式

在开始讲解装饰器之前,先从经典的装饰器模式说起。装饰器模式是一种结构型设计模式,它允许向一个现有的对象添加新的功能,同时又不改变其结构,是作为对现有类的一个包装。

一般来说,在代码设计中,我们应当遵循「多用组合,少用继承」的原则。通过装饰器模式动态地给一个对象添加一些额外的职责。就增加功能来说,装饰器模式相比生成子类更为灵活。

2.1 一个英雄联盟的例子

下班回去和朋友愉快地开黑,当我正在用亚索「面对疾风吧」的时候,突然想到,如果让我设计亚索英雄,我该怎么实现呢?

image_1e41h3e4e62nnd0hv0f02q5136.png-242.1kB

我灵光一闪,那肯定会先设计一个英雄的类。

class Hero {
    attack() {}
}

然后,再实现一个 Yasuo 的类来继承这个 Hero 类。

class Yasuo extends Hero {
    attack() {
        console.log("斩钢闪");
    }
}

我还在想这个问题的时候,队友已经打了大龙,我的亚索身上就出现了大龙 buff 的印记。我突然想到,那该怎么给英雄增加大龙 buff 呢?那增加个大龙 buff 的属性不行吗?

当然不太行,要知道,英雄联盟里面的大龙 buff 是会增加收益的。

嗯,聪明的我已经想到办法了,再继承一次不就好了吗?

class BaronYasuo extends Yasuo {}

厉害了,但是如果亚索身上还有其他 buff 呢?毕竟 LOL 里面是有红 buff、蓝 buff、大龙 buff 等等存在,那岂不是有多少种就要增加多少个类吗?

image_1e3brvbln129jcn7bo111jfal09.png-37.6kB

可以换种思路来思考这个问题,如果把 buff 当做我们身上的衣服。在不同的季节,我们会换上不同的衣服,到了冬天,甚至还会叠加多件衣服。当 buff 消失了,就相当于把这件衣服脱了下来。如下图所示:

image.png-27.3kB

衣服对人来说起到装饰的作用,buff 对于亚索来说也只是增强效果。那么,你是不是有思路了呢?
没错,可以创建 Buff 类,传入英雄类后获得一个新的增强后的英雄类。

class RedBuff extends Buff {
    constructor(hero) {
        this.hero = hero;
    }
    // 红buff造成额外伤害
    extraDamage() {
    }
    attack() {
        return this.hero.attack() + this.extraDamage();
    }
}
class BlueBuff extends Buff {
    constructor(hero) {
        this.hero = hero;
    }
    // 技能CD(减10%)
    CDR() {
        return this.hero.CDR() * 0.9;
    }
}
class BaronBuff extends Buff {
    constructor(hero) {
        this.hero = hero;
    }
    // 回城速度缩短一半
    backSpeed() {
        return this.hero.backSpeed * 0.5;
    }
}

定义好所有的 buff 类之后,就可以直接套用到英雄身上,这样看起来是不是清爽了很多呢?这种写法看起来很像函数组合。

const yasuo = new Yasuo();
const redYasuo = new RedBuff(yasuo); // 红buff亚索
const blueYasuo = new BlueBuff(yasuo); // 蓝buff亚索
const redBlueYasuo = new BlueBuff(redYasuo); // 红蓝buff亚索

image_1e41h45cn12r220all5mos1pet3j.png-324.3kB

3. ES7 装饰器

decorator(装饰器)是 ES7 中的一个提案,目前处于 stage-2 阶段,提案地址:JavaScript Decorators

装饰器与之前讲过的函数组合(compose)以及高阶函数很相似。装饰器使用 @ 作为标识符,被放置在被装饰代码前面。在其他语言中,早就已经有了比较成熟的装饰器方案。

3.1 Python 中的装饰器

先来看一下 Python 中的一个装饰器的例子:

def auth(func):
    def inner(request,*args,**kwargs):
        v = request.COOKIES.get('user')
        if not v:
            return redirect('/login')
        return func(request, *args,**kwargs)
    return inner

@auth
def index(request):
    v = request.COOKIES.get("user")
    return render(request,"index.html",{"current_user":v})

image_1e3c0hva03om1v6im6gjne5lj9.png-32.2kB

这个 auth 装饰器是通过检查 cookie 来判断用户是否登录的。auth 函数是一个高阶函数,它接收了一个 func 函数作为参数,返回了一个新的 inner 函数。

inner 函数中进行 cookie 的检查,由此来判断是跳回登录页面还是继续执行 func 函数。

在所有需要权限验证的函数上,都可以使用这个 auth 装饰器,很简洁明了且无侵入。

3.2 JavaScript 装饰器

JavaScript 中的装饰器和 Python 的装饰器类似,依赖于 Object.defineProperty,一般是用来装饰类、类属性、类方法。

使用装饰器可以做到不直接修改代码,就实现某些功能,做到真正的面向切面编程。这在一定程度上和 Proxy 很相似,但使用起来比 Proxy 会更加简洁。

注意:装饰器目前还处于 stage-2,意味着语法之后也许会有变动。装饰器用于函数、对象等等已经有一些规划,请看:Future built-in decorators

3.3 类装饰器

装饰类的时候,装饰器方法一般会接收一个目标类作为参数。下面是一个给目标类增加静态属性 test 的例子:

const decoratorClass = (targetClass) => {
    targetClass.test = '123'
}
@decoratorClass
class Test {}
Test.test; // '123'

除了可以修改类本身,还可以通过修改原型,给实例增加新属性。下面是给目标类增加 speak 方法的例子:

const withSpeak = (targetClass) => {
    const prototype = targetClass.prototype;
    prototype.speak = function() {
        console.log('I can speak ', this.language);
    }
}
@withSpeak
class Student {
    constructor(language) {
        this.language = language;
    }
}
const student1 = new Student('Chinese');
const student2 = new Student('English');
student1.speak(); // I can speak  Chinese

student2.speak(); // I can speak  Englist

利用高阶函数的属性,还可以给装饰器传参,通过参数来判断对类进行什么处理。

const withLanguage = (language) => (targetClass) => {
    targetClass.prototype.language = language;
}
@withLanguage('Chinese')
class Student {
}
const student = new Student();
student.language; // 'Chinese'

如果你经常编写 react-redux 的代码,那么也会遇到需要将 store 中的数据映射到组件中的情况。connect 是一个高阶组件,它接收了两个函数 mapStateToPropsmapDispatchToProps 以及一个组件 App,最终返回了一个增强版的组件。

class App extends React.Component {
}
connect(mapStateToProps, mapDispatchToProps)(App)

有了装饰器之后,connect 的写法可以变得更加优雅。

@connect(mapStateToProps, mapDispatchToProps)
class App extends React.Component {
}

3.4 类属性装饰器

类属性装饰器可以用在类的属性、方法、get/set 函数中,一般会接收三个参数:

  1. target:被修饰的类
  2. name:类成员的名字
  3. descriptor:属性描述符,对象会将这个参数传给 Object.defineProperty

使用类属性装饰器可以做到很多有意思的事情,比如最开始举的那个 readonly 的例子:

function readonly(target, name, descriptor) {
  descriptor.writable = false;
  return descriptor;
}
class Person {
    @readonly name = 'person'
}
const person = new Person();
person.name = 'tom'; 

还可以用来统计一个函数的执行时间,以便于后期做一些性能优化。

function time(target, name, descriptor) {
    const func = descriptor.value;
    if (typeof func === 'function') {
        descriptor.value = function(...args) {
            console.time();
            const results = func.apply(this, args);
            console.timeEnd();
            return results;
        }
    }
}
class Person {
    @time
    say() {
        console.log('hello')
    }
}
const person = new Person();
person.say();

在 react 知名的状态管理库 mobx 中,也通过装饰器来将类属性置为可观察属性,以此来实现响应式编程。

import {
    observable,
    action,
    autorun
} from 'mobx'

class Store {
    @observable count = 1;
    @action
    changeCount(count) {
        this.count = count;
    }
}

const store = new Store();
autorun(() => {
    console.log('count is ', store.count);
})
store.changeCount(10); // 修改 count 的值,会引起 autorun 中的函数自动执行。

3.5 装饰器组合

如果你想要使用多个装饰器,那么该怎么办呢?装饰器是可以叠加的,根据离被装饰类/属性的距离来依次执行。

class Person {
    @time
    @log
    say() {}
}

除此之外,在装饰器的提案中,还出现了一种组合了多种装饰器的装饰器例子。目前还没见到被使用。

通过使用 decorator 来声明一个组合装饰器 xyz,这个装饰器组合了多种装饰器。

decorator @xyz(arg, arg2 {
  @foo @bar(arg) @baz(arg2)
}
@xyz(1, 2) class C { }

和下面这种写法是一样的。

@foo @bar(1) @baz(2)
class C { }

4. 装饰器可以做哪些有意思的事情?

4.1 多重继承

在实现 JavaScript 多重继承的时候,可以使用 mixin 的方式,这里结合装饰器甚至还能更进一步简化 mixin 的使用。

mixin 方法将会接收一个父类列表,用其装饰目标类。我们理想中的用法应该是这样:

@mixin(Parent1, Parent2, Parent3)
class Child {}

和之前实现多重继承的时候实现原理一致,只需要拷贝父类的原型属性和实例属性就可以实现了。

这里创建了一个新的 Mixin 类,来将 mixinstargetClass 上面的所有属性都拷贝过去。

const mixin = (...mixins) => (targetClass) => {
  mixins = [targetClass, ...mixins];
  function copyProperties(target, source) {
    for (let key of Reflect.ownKeys(source)) {
      if (key !== 'constructor'
        && key !== 'prototype'
        && key !== 'name'
      ) {
        let desc = Object.getOwnPropertyDescriptor(source, key);
        Object.defineProperty(target, key, desc);
      }
    }
  }
  class Mixin {
    constructor(...args) {
      for (let mixin of mixins) {
        copyProperties(this, new mixin(...args)); // 拷贝实例属性
      }
    }
  }

  for (let mixin of mixins) {
    copyProperties(Mixin, mixin); // 拷贝静态属性
    copyProperties(Mixin.prototype, mixin.prototype); // 拷贝原型属性
  }
  return Mixin;
}

export default mixin

我们来测试一下这个 mixin 方法是否能够正常工作吧。

class Parent1 {
    p1() {
        console.log('this is parent1')
    }
}
class Parent2 {
    p2() {
        console.log('this is parent2')
    }
}
class Parent3 {
    p3() {
        console.log('this is parent3')
    }
}
@mixin(Parent1, Parent2, Parent3)
class Child {
    c1 = () => {
        console.log('this is child')
    }
}
const child = new Child();
console.log(child);

最终在浏览器中打印出来的 child 对象是这样的,证明了这个 mixin 是可以正常工作的。

注意:这里的 Child 类就是前面的 Mixin 类。

image.png-69.4kB

也许你会问,为什么还要多创建一个多余的 Mixin 类呢?为什么不能直接修改 targetClassconstructor 呢?前面不是讲过 Proxy 可以拦截 constructor 吗?

恭喜你,你已经想到了 Proxy 的一种使用场景。没错,这里用 Proxy 的确会更加优雅。

const mixin = (...mixins) => (targetClass) => {
    function copyProperties(target, source) {
        for (let key of Reflect.ownKeys(source)) {
          if ( key !== 'constructor'
            && key !== 'prototype'
            && key !== 'name'
          ) {
            let desc = Object.getOwnPropertyDescriptor(source, key);
            Object.defineProperty(target, key, desc);
          }
        }
      }
    
      for (let mixin of mixins) {
        copyProperties(targetClass, mixin); // 拷贝静态属性
        copyProperties(targetClass.prototype, mixin.prototype); // 拷贝原型属性
      }
      // 拦截 construct 方法,进行实例属性的拷贝
      return new Proxy(targetClass, {
        construct(target, args) {
          const obj = new target(...args);
          for (let mixin of mixins) {
              copyProperties(obj, new mixin()); // 拷贝实例属性
          }
          return obj;
        }
      });
}

4.2 防抖和节流

以往我们在频繁触发的场景下,为了优化性能,经常会使用到节流函数。下面以 React 组件绑定滚动事件为例子:

class App extends React.Component {
    componentDidMount() {   
        this.handleScroll = _.throttle(this.scroll, 500);
        window.addEveneListener('scroll', this.handleScroll);
    }
    componentWillUnmount() {
        window.removeEveneListener('scroll', this.handleScroll);
    }
    scroll() {}
}

在组件中绑定事件需要注意应当在组件销毁的时候进行解绑。而由于节流函数返回了一个新的匿名函数,所以为了之后能够有效解绑,不得不将这个匿名函数存起来,以便于之后使用。

但是在有了装饰器之后,我们就不必在每个绑定事件的地方都手动设置 throttle 方法,只需要在 scroll 函数添加一个 throttle 的装饰器就行了。

const throttle = (time) => {
    let prev = new Date();
    return (target, name, descriptor) => {
        const func = descriptor.value;
        if (typeof func === 'function') {
            descriptor.value = function(...args) {
		        const now = new Date();
		        if (now - prev > wait) {
			        fn.apply(this, args);
			        prev = new Date();
		        }
            }
        }
    }
}

使用起来比原来要简洁很多。

class App extends React.Component {
    componentDidMount() {
        window.addEveneListener('scroll', this.scroll);
    }
    componentWillUnmount() {
        window.removeEveneListener('scroll', this.scroll);
    }
    @throttle(50)
    scroll() {}
}

而实现防抖(debounce)函数装饰器和节流函数类似,这里也不再多说。

const debounce = (time) => {
    let timer;
    return (target, name, descriptor) => {
        const func = descriptor.value;
        if (typeof func === 'function') {
            descriptor.value = function(...args) {
                if(timer) clearTimeout(timer)
                timer = setTimeout(()=> {
                    fn.apply(this, args)
                }, wait)
            }
        }
    }
}

如果对节流和防抖函数比较感兴趣,那么可以去阅读一下这篇文章:函数节流与函数防抖

4.3 数据格式验证

通过类属性装饰器来对类的属性进行类型的校验。

const validate = (type) => (target, name) => {
    if (typeof target[name] !== type) {
        throw new Error(`attribute ${name} must be ${type} type`)
    }
}
class Form {
    @validate('string')
    static name = 111 // Error: attribute name must be ${type} type
}

如果你觉得对属性一个个手动去校验太过麻烦,也可以通过编写校验规则,来对整个类进行校验。

image_1e3c1khlm169r8ei7j4s25qjfm.png-44.7kB

const rules = {
    name: 'string',
    password: 'string',
    age: 'number'
}
const validator = rules => targetClass => {
    return new Proxy(targetClass, {
        construct(target, args) {
            const obj = new target(...args);
            for (let [name, type] of Object.entries(rules)) {
                if (typeof obj[name] !== type) {
                    throw new Error(`${name} must be ${type}`)
                }
            }
            return obj;
        }
    })
}

@validator(rules)
class Person {
    name = 'tom'
    password = '123'
    age = '21'
}
const person = new Person();

4.4 core-decorators.js

core-decorators 是一个封装了常用装饰器的 JS 库,它归纳了下面这些装饰器(只列举了部分)。

  1. autobind:自动绑定 this,告别箭头函数和 bind
  2. readonly:将类属性设置为只读
  3. override:检查子类的方法是否正确覆盖了父类的同名方法
  4. debounce:防抖函数
  5. throttle:节流函数
  6. enumerable:让一个类方法变得可枚举
  7. nonenumerable:让一个类属性变得不可枚举
  8. time:打印函数执行耗时
  9. mixin:将多个对象混入类(和我们上面的 mixin 不太一样)

5. 总结

装饰器虽然还属于不稳定的语法,但在很多框架中都已经广泛使用,例如 Angular、Nestjs 等等,和 Java 中的注解用法非常相似。
装饰器在 TypeScript 中结合反射后还有一些更高级的应用,下篇文章会进行深入讲解。

推荐阅读:

  1. 装饰器 —— 阮一峰
  2. JS 装饰器(Decorator)场景实战
  3. 探索JavaScript中的装饰器模式
  4. 王者荣耀之「装饰者模式」

怎样用 React Hooks 实现 Vue3 Composition API?

1. 前言

前几天在知乎看到了一个问题,React 的 Hooks 是否可以改为用类似 vue3 composition api 的方式实现?

关于 React Hooks 和 Vue3 Composition API 的热烈讨论一直都存在,虽然两者本质上都是实现状态逻辑复用,但在实现上却代表了两个社区的不同发展方向。

我想说,小孩子才分好坏,成年人表示我全都要。

image_1e49c6pov12uu1tnirjl1vl3o6ql.png-64kB

2. 你不知道的 Object.defineProperty

那今天我们来讨论一下怎么用 React Hooks 来实现 Vue3 Composition 的效果。

先来看一下我们最终要实现的效果。

composition.gif-2420.4kB

看到这个 API 的用法你会联想到什么?没错,很明显这里借用了 Proxy 或者 Object.defineProperty

在《你不知道的 Proxy:ES6 Proxy 能做哪些有意思的事情?》一文中,我们已经对比过两者的用法了。

Proxy vs Object.defineProperty

其实这里还有一个不为人知的区别,那就是可以通过 Object.defineProperty 给对象添加一个新属性。

const person = {}
Object.defineProperty(person, "name", {
    enumerable: true,
    get() {
        return "sh22n"
    }
})

打印出来的效果是这样的:

image_1e4bp7a55168380160oc84kcg12.png-23.3kB

这就很有意思了,意味着我们可以把某个对象 A 上所有属性都挂载到对象 B 上,这样我们不必对 A 进行任何监听,即不会污染 A。

const state = { count: 0 }
Object.defineProperty({}, "count", {
    get() {
        return state.count
    }
})

3. React Hooks + Object.defineProperty = ?

如果将上面的代码结合 React Hooks,那会出现什么效果呢?没错,我们的 Hooks 变得更加 reactive 了。

const [state, setState] = useState({ count: 0 })
const proxyState = Object.defineProperty({}, "count", {
    get() {
        return state.count
    },
    set(newVal) {
        setState({ ...state, count: newVal })
    }
})
return (
    <h1 onClick={() => proxyState.count++}>
        { proxyState.count }
    </h1>
)

将这段代码进一步封装,可以得到一个 Custom Hook,也就是我们今天要说的 Composition API。

const ref = (value) => {
    const [state, setState] = useState(value)
    return Object.defineProperty({}, "count", {
        get() {
            return state.count
        },
        set(newVal) {
            setState({ ...state, count: newVal })
        }
    })
}
function Counter() {
    const count = ref({ value: 0 })
    return (
        <h1 onClick={() => count.value++}>
            { count.value }
        </h1>
    )
}

当然,这段代码还存在很多问题,依赖了对象的结构、不支持更深层的 getter/setter 等等,我们接下来就一起来优化一下。

4. 实现 Composition

4.1 递归劫持属性

对于有多个属性的对象来说,我们可以遍历,配合 Object.defineProperties 来劫持它的所有属性。

const descriptors = Object.keys(state).reduce((handles, key) => {
    return {
        ...handles,
        [key]: {
            get() {
                return state[key]
            },
            set(newVal) {
                setState({ ...state, [key]: newVal })
            }
        }
    }
}, {})
Object.defineProperty({}, descriptors)

而对于更深层的对象来说,不仅要做递归,还要考虑 setState 这里应该根据访问路径来设置。
首先,我们来对深层对象做一次递归。

const descriptors = (obj) => {
    return Object.keys(obj).reduce((handles, key) => {
        let value = obj[key];
        // 如果 value 是个对象,那就递归其属性进行 `setter/getter`
        if (Object.prototype.toString.call(obj) === "[object Object]") {
            value = Object.defineProperty({}, descriptors(value));
        }
        return {
            ...handles,
            [key]: {
                get() {
                    return value
                },
                set(newVal) {
                    setState({ ...state, [key]: newVal })
                }
            }
        }
    }, {})
}

如果你仔细观察了这段代码,会发现有个非常致命的问题。那就是在做递归的时候,set(newVal) 里面的代码并不对,state 是个深层对象,不能这么简单地对其外层进行赋值。
这意味着,我们需要将访问这个对象深层属性的一整条路径保存下来,以便于 set 到正确的值,可以用一个数组来收集路径上的 key 值。
这里用使用 lodash 的 set 和 get 来做一下演示。

const descriptors = (obj, path) => {
    return Object.keys(obj).reduce((handles, key) => {
        // 收集当前路径的 key 
        let newPath = [...path, key],
            value = _.get(state, newPath);

        // 如果 value 是个对象,那就递归其属性进行 `setter/getter`
        if (Object.prototype.toString.call(obj) === "[object Object]") {
            value = Object.defineProperty({}, descriptors(value, newPath));
        }
        return {
            ...handles,
            [key]: {
                get() {
                    return value
                },
                set(newVal) {
                    _.set(state, newPath, newVal)
                    setState({ ...state })
                }
            }
        }
    }, {})
}

但是,如果传入的是个数组,这里就会有问题了。因为我们只是对 Object 进行了拦截,没有对 Array 进行处理。

const isArray = arr => Object.prototype.toString.call(arr) === '[object Array]'
const isObject = obj => Object.prototype.toString.call(arr) === '[object Object]'

const descriptors = (obj, path) => {
    return Object.keys(obj).reduce((handles, key) => {
        // 收集当前路径的 key 
        let newPath = [...path, key],
            value = _.get(state, newPath);

        // 如果 value 是个对象,那就递归其属性进行 `setter/getter`
        if (isObject(value)) {
            value = Object.defineProperties({}, descriptors(value, newPath));
        }
        if (isArray(value)) {
            value = Object.defineProperties([], descriptors(value, newPath));
        }
        return {
            ...handles,
            [key]: {
                get() {
                    return value
                },
                set(newVal) {
                    _.set(state, newPath, newVal)
                    setState({ ...state })
                }
            }
        }
    }, {})
}

5. 完整版

这样,我们就实现了一个完整版的 ref,我将代码和示例都放到了 codesandbox 上面:Compostion API

const ref = (value) => {
  if (typeof value !== "object") {
    value = {
      value
    };
  }
  const [state, setState] = useState(value);
  const descriptors = (obj, path) => {
    return Object.keys(obj).reduce((result, key) => {
        let newPath = [...path, key];
        let v = _.get(state, newPath);
        if (isObject(v)) {
                v = Object.defineProperties({}, descriptors(v, newPath));
            } else if (isArray(v)) {
                v = Object.defineProperties([], descriptors(v, newPath));
            }
            
        return {
            ...result,
            [key]: {
                enumerable: true,
                get() {
                    return v;
                },
                set(newVal) {
                    setState(
                        _.set(state, newPath, newVal)
                        setState({ ...state })
                    );
                }
            }
        };
        }, {});
    };
    return Object.defineProperties(isArray(value) ? [] : {}, descriptors(state, []));
};

❤️ 看完三件事

如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  1. 点赞,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)
  2. 关注公众号「前端小馆」,或者加我个人微信号「testygy」拉你进群,不定期分享原创知识。
  3. 也看看其它文章

关于编程学习

本文来自工业聚在 React China 论坛的分享。

问答

  1. 工业聚大大您好。前一阶段参加了几次面试,有几个公司面试反馈说前端掌握的深度不够,这个问题我感觉很令人费解,但是也是我需要加深的方向。
    所以我想请问工业聚大大,向我们这种毕业1-3年的前端,该怎么做去加强自己在前端领域的深度呢,同时又能保持基本的技术广度呢?感谢您的回答!

    不管是技术深度还是广度,都依赖于一个扎实的技术基础。如果还没有通读过《JavaScript权威指南》、《JavaScript 高级程序设计》、《JavaScript DOM编程艺术》等经典书籍,可以开始啃起来了。

    然后,可以开始实现常见的 UI 和交互效果,比如轮播图,移动端的 swiper,Tab 切换,下拉刷新等等。每一种效果,都有从低到高的完成度。比如。轮播图里,有无小圆点切换,有无左右箭头,是否支持定时轮播,是否可以无限循环地轮播,是否支持移动端 touch 事件等等,都反映了你写的效果的完备性和完成度。不断地提高它们,同时也就打磨了 JS 编程能力。

    其他还有模板引擎(templte engine),lodash/underscore,jquery/zepto,react/vue 等不同类型的库或框架,先不看它们的源码,按照你对其 API 的理解,仿写一个最小可运行版本。再去看它们源码,印证你的实现跟原版实现的差异。

    技术的深度和广度,都是积累而来的,无法一蹴而就。如果没有相对严酷的历练,难以沉淀下来深刻的知识和领悟。

  2. 你好,工业聚。我想问下作为一个刚毕业半年的前端开发,有必要系统的去学习css3基础,JavaScript基础吗?意思是每当学习我一些东西,就做好记录,比如我会写博客。但感觉节奏很慢,比如3月打算将css3基础系统学习,一忙就基本把这个计划又推到下个月了,只做到一小部分。
    总结了一下,发现自己确实有点拖延症,而且容易被一些环境左右。我想问下对于初出茅庐的前端,如何克服这些问题。比如如何做一个好的规划,如何不要被一些环境所左右?谢谢。

    你的问题跟二楼的有点类似,可以参考对二楼的回答。

    这里可以再总结一下,我们可以回顾自己现在掌握的知识和技能,可以发现,绝大多数都来自于曾经的严酷训练(如高中彻夜复习、暑假集中培训等)。那是一段当时并不轻松,但回想起来很充实的状态。有人称之为「学习区」,在「学习区」我们不轻松但成长最快。在「舒适区」我们很轻松,但成长较慢;在「恐慌区」,我们无所适从,只想逃离。

    现在让你直接看 react/vue 的源码,可能直接进入了你的「恐慌区」,觉得自己水平很差、人很蠢。我们需要找到自己的「学习区」,每个阶段我们的学习区都是不同的。如果没有打好基础,那么「学习区」会一直停留在基础知识的层面,一进入更高领域,就会感到不适和恐慌。基础知识是每个学习者无法逃避的一个学习阶段,一定要通过某种方式突破它,并且这个方式一定不是轻松的(不是舒适区)。

    「拖延现象」是逃避有难度的事情,沉迷自己舒适区的一种行为表现。在现在信息碎片化和信息娱乐化的时代,让人立刻得到短暂愉悦(如看抖音、刷微博、微信等)的事物太多了。需要鼓起很大的意志,刻意让自己去做有难度的事情,并坚持去做。最后会有显著成长。

    先明确自己有要去改变的意愿,再强迫自己有尝试去改变的行为,并开始对自己的不作为,感到愤怒。从内心形成自趋力,最后形成习惯。初期必定痛苦,习惯之后可以体会到乐趣。

  3. 工业聚你好,我想问一下为什么 React 会废弃掉 componentWill* ?他们跟 fiber 和 suspend 有关系吗?谢谢!

    是的,主要跟 suspend 特性有关。

    react suspend 特性的实现机制是,重复执行 render 流程。第一次执行 render 时,fetcher.read 方法里的 promise 如果还没有 resolve,就 throw promise,终止此次渲染。然后进入 componentDidCatch 的阶段,catch 到 promise 后,在 promise.then 时,再次触发渲染流程。

    这个机制要求async-safe,类似于 restful api 里两次 get 请求预期拿到一样的结果,虽然第一次 get 被 abort 了,但不应该影响第二次 get 到的结果。

    上面说的是「重复执行 render 流程」,而非「重复执行 render 方法」,「render 流程」里包含 componentWillMount、componentWillReceiveProps 和 componentWillUpdate 等生命周期,如果在这些生命周期里操作了 component 实例的属性或调用了有副作用的方法,那么两次渲染流程就无法满足「幂等」要求。

    所以,干掉 will*,把 willReceiveProps 挪出到静态方法 getDerivedStateFromProps,可以防止开发者操作 component 实例,增加 async-safe 的安全性。

  4. 工业聚你好,我想了解一下,你是如何平衡事业、家庭、个人娱乐、个人能力提升、知识输出这几部分的精力和时间的?(你上次好像提到通过原始积累形成系统以后,可以让后续提升和知识输出的效率大幅提高,但是这个原始积累也是很花时间精力的啊,所以你那时候还是单身?)

    在前端领域我的技术积累,是由我的老婆(当时的女朋友)推动的。我之前对 IT 技术没什么了解,她认为我在这方面骨骼清奇,怂恿我去学习。

    原始积累的方式主要是两个:1)做 web 特效 demo 向她表达爱意;2)在她公司的楼下,等她加班完毕的间隙,看 web 领域的电子书,打下扎实的基础。

    我老婆比我优秀得多,我在平衡事业、家庭、娱乐等方面很弱,全靠她敲打。

  5. 您好!我想请问一下,非计算机相关专业自学的前端,现在大四在实习,想工作几年有机会跳槽去大厂,需要系统的学习一些后端的知识吗,比如数据库,算法等等,还有在工作项目中要多积累哪些方面,可以在简历上有亮点

    对前端来说,系统地学习后端知识,在求职阶段不是必要的;但一点都不了解,也不合适。适当了解一些。

    不必等工作几年再去考虑大厂,可以现在就去投,去面试,去考虑。即便面试被刷掉了,你也可以知道自己要满足大厂的要求,应该加强哪些方面。

  6. 你好,想请问在工作之外是如何安排时间去学习技术的?
    对你而言,工作内容和个人项目带给你的技术上的成长分别占比多少呢?
    感谢。

    在学习技术上,我是兴趣驱动的。学习技术,对我而言,跟玩撸啊撸,守望先锋等游戏有相似的乐趣,并且更不容易腻,成就感也更强。这是一个很好的状态,我不知道怎么进入了它,我希望后面可以一直保持住这种热情。

    我的成长 80% 发生在工作内容,个人项目只有 20% 的增益。但恰恰是个人项目,使我得到机会去付诸工作实践。所以从重要性来讲,个人项目里我的积累,也很重要。目前可以说是工作和业余的技术学习,对技术成长而言,同等重要。

  7. 你好,其实我一开始还想了几个问题去问你,不过转念一想都是 Google 能解决的,就不强问技术或者学习能力方面的了。
    就是闲聊一下,有没有什么技术或者知识对你来说只是 Just for fun 所以才去掌握的。

    这个问题非常好。在进入技术领域之前,我对数学、物理学、哲学、心理学以及演化论都很感兴趣,我认为它们是理解世界的不同维度,缺了任何一个,都缺少了一个重要的、不可替代的视角。

    我对技术的兴趣,也延续了上面的特征。计算机是另一个庞大的、重要的世界的体现,如果不懂计算机及编程,就像一个麻瓜,所见皆如魔法。

    仅仅掌握编程语言,也不足够,还需要对编程语言如何被创造有所了解。这就进入编程语言的设计和实现的领域。除此之外,很多问题,无法手写规则解决,必须通过特殊途径,间接解决。这就进入了人工智能/机器学习的领域。而在很多领域,最后都会触及到数学跟编程的结合,目前最贴近数学的是函数式编程语言,又是一个新领域。

    正是对世界和自我的反思与好奇,不断引领我去了解不同的领域。虽然难以成为每个上述领域的专家,但光是得到一些知识的理解,都足够让我兴奋很久。

    在现代,没有人可以掌握人类创造的所有知识。我们需要根据自身兴趣,对知识做一个优先级的划分。我的划分方式是先去掌握纲领性的知识,它们可能是所有知识的核心部分,可以创造新知识,或者推导出旧知识。数学、物理、哲学、心理学、演化论、计算机编程、编程语言实现、人工智能/机器学习、函数式编程等,都属于我认为的纲领性知识的范畴。

  8. 目前公司比较大,大公司的通病就是可能每个人都是螺丝钉,只在自己的岗位上去做事情,但是带来的问题就是自己可能会很长时间只钻一个地方,比如我是前端,可能就一直写页面,用RN,就一直用RN,我希望强化自己的后端或者其它框架,但是却没有办法,时间久了担心自己其它方面会越来越弱,也许某一块会比较熟练,但是毕竟范围有限,请问如何解决这一现状呢?或者说从自身角度出发,如何去改变它呢?

    工作不是技术成长的全部,工作之外,可以有基于兴趣的技术探索和积累。而且,工作内容,其实也有很多可挖掘和拓展的地方。靠外界环境去提供一个开发而广阔的学习,基本上不太可能。也不符合公司用人的专业性要求。内心的自我驱动力更可靠。

  9. 你好,请问对于其他的框架,如Vue, Angular, Ember, Backbone等等了解多不多?为何当初选择深入钻研React?你认为对于常用React的程序员来说,学习其他前端框架的时候有什么需要特别注意的吗?

    对 Backbone 和 Vue 的了解较多,Angular 和 Ember 不是很了解。当初选择 React 是恰巧当时它比较火,所以去学习和使用,然后有了更深的理解,开始在工作里使用,就这样上了这个车。

    不管是学习其他框架还是其他语言,我认为,都要以腾空自己的心态去感受;不要刻意去想,这不就是 React 里的 xxx 吗?这不就是 C# 里的函数重载吗?虽然我们第一次在 React 或 C# 里发现了某个语言特性或者框架特性,但并非这个特性的内部机制和用途,就跟我们第一次接触的框架和语言绑定了。

    我们应该去理解脱离框架和语言表象的背后的抽象事物,而不是用这个具体事物去理解另一个具有相似性质的具体事物。用水果去理解苹果和香蕉,而不是用苹果去理解香蕉。

  10. 工业聚你好。我是一个什么都接触的二傻搬砖,有以下问题想请教一下:
    我主要想应用一下机器学习,这样学习伊始是否需要一些大学数学基础(全忘了,如果需要还要复习一下),下手建议从传统机器学习算法还是直接上深度学习的框架
    React / Vue / Angular 这类框架已经极大的方便了前端工程师的开发,您觉得未来的前端框架演化会接着朝一个怎么样的方向发展,是否还会有 jQuery -> MVVM 的跳跃式改变。
    想看看您对未来前端职责和后端职责的看法,因为目前有许多项目的后端其实也是由我们「前端」来执行完成的,前后端的界限似乎越来越模糊。
    对于目前业界「知识付费」你有什么看法吗?或者对于现在前端圈造网红的现象有什么看法。您接触过这样经常「知识付费」的人吗?是否会影响正常的工作和知识积累。
    对于「前端门槛低」的说法您有什么看法,前端门槛低到底是一种好现象还是一种泥石流?
    在社招招聘开发时,您更看重学习能力还是之前所做的项目的匹配度。

    1、关于机器学习,我前面有个回答是相关的,可以参考一下。我建议从传统机器学习算法学起,就像卡马克 的做法(虽然不必那么硬核,但手写算法还是有很大帮助)。数学、编程和机器学习建模等,是并驾齐驱,互相增益的关系,在学习过程中,自然而然地会发现需要补充的数学部分,编程部分和建模部分是什么。从数学开始学起,离做出一个东西会隔很远很远,而且,数学是学不完的。不必在手写算法上停留过长的时间,积累了一定的经验和体会之后,应该是时候去玩框架了。目前的资料和教程也越发丰富。比如 Google 的机器学习速成

    2、React、Vue 和 Angular 目前只是在数据和视图的绑定以及状态管理上,提供了一些便利。但在 css/style 布局,UI 动画等方面,还有很大的发展空间,等到 houdini 落地,或许会有一个兼顾布局、动效、数据、视图和状态管理的新范式,FRP 是其中一个方向。另外,js 语言的发展,也可能改善一些写法,如 pipeline operator, partial application ,pattern-matching 等。以及 elm, reasonml 等编译到 js 的语言,和 wasm.js 开启的新的竞争领域的冲击,也在推动前端的发展。jQuery -> MVC| MVVM | Flux 这个转变,只是开始,远未到终点。

    3、前端和后端,本来就是人为划分,是之前或现在的分工需求。随着 Backbend、OPS 、 GUI开发乃至人工智能等技术和工具的发展,分工的模式被改变是意料之内、情理之中。做技术的人,要保持「终身学习」的决心和习惯,去迎接更多的挑战,并从中感到乐趣。

    4、目前的「知识付费」处于一个野蛮生长的萌芽阶段,绝大多数知识,并没有进入付费者的大脑。不过,如果把「知识付费」看成是一个像「密室逃脱」的娱乐来看,去掉知识的神圣感后,其实也算不错的体验。我们愿意让肉体去游乐场付费玩过山车,也可以让大脑在网络上玩「知识体验的过山车」。在现阶段,想要获得有竞争力的长进不能只寄希望于「知识付费」里。

    5、前端门槛低是事实。是好是坏,要看有什么角度或者处于什么情境。门槛低,意味着竞争者多,如果是一个不求进步,只图安稳的人,会感觉到竞争者很多,到处都是想抢饭碗的人。但另一方面,如果是一个对技术有追求,有热情,学习的方法论正确的人,他更容易脱颖而出。

    6、社招时的判断因素很多,对不同工作年限的候选人,衡量的方式也不同。对于初入社会的人,主要看对方的学习能力、学习的方法论、获取知识的媒介以及当前的技术基础等。对于资深的候选人,主要考察对流行框架和编程范式的认知和实践经验,对疑难咋整的处理办法,对团队协作的管理策略,以及对软件开发架构和设计模式的个人体悟。

从 ECMA 规范看 JavaScript 类型转换

前言

JavaScript 中的类型转换一直都是让前端开发者最头疼的问题。前阵子,推特上有个人专门发了一张图说 JavaScript 让人不可思议。

image_1dm5s9qr814dvnsi96laugvg9.png-51.4kB

除了这个,还有很多经典的、让 JavaScript 开发者摸不着头脑的类型转换,譬如下面这些,你是否知道结果都是多少?

1 + {} === ?
{} + 1 === ?
1 + [] === ?
1 + '2' === ?

本文将带领你从 ECMA 规范开始,去深入理解 JavaScript 中的类型转换,让类型转换不再成为前端开发中的拦路虎。

数据类型

JS 中有六种简单数据类型:undefinednullbooleanstringnumbersymbol,以及一种复杂类型:object
但是 JavaScript 在声明时只有一种类型,只有到运行期间才会确定当前类型。在运行期间,由于 JavaScript 没有对类型做严格限制,导致不同类型之间可以进行运算,这样就需要允许类型之间互相转换。

类型转换

显式类型转换

显式类型转换就是手动地将一种值转换为另一种值。一般来说,显式类型转换也是严格按照上面的表格来进行类型转换的。

常用的显式类型转换方法有 NumberStringBooleanparseIntparseFloattoString 等等。
这里需要注意一下 parseInt,有一道题偶尔会在面试中遇到。

问:为什么 [1, 2, 3].map(parseInt) 返回 [1,NaN,NaN]?
答:parseInt函数的第二个参数表示要解析的数字的基数。该值介于 2 ~ 36 之间。

如果省略该参数或其值为 0,则数字将以 10 为基础来解析。如果它以 “0x” 或 “0X” 开头,将以 16 为基数。

如果该参数小于 2 或者大于 36,则 parseInt() 将返回 NaN。
一般来说,类型转换主要是基本类型转基本类型、复杂类型转基本类型两种。
转换的目标类型主要分为以下几种:

  1. 转换为 string
  2. 转换为 number
  3. 转换为 boolean

我参考了 ECMA-262 的官方文档来总结一下这几种类型转换。ECMA 文档链接:ECMA-262

ToNumber

其他类型转换到 number 类型的规则见下方表格:

原始值 转换结果
Undefined NaN
Null 0
true 1
false 0
String 根据语法和转换规则来转换
Symbol Throw a TypeError exception
Object 先调用toPrimitive,再调用toNumber
String 转换为 Number 类型的规则:
  1. 如果字符串中只包含数字,那么就转换为对应的数字。
  2. 如果字符串中只包含十六进制格式,那么就转换为对应的十进制数字。
  3. 如果字符串为空,那么转换为0。
  4. 如果字符串包含上述之外的字符,那么转换为 NaN。

使用+可以将其他类型转为 number 类型,我们用下面的例子来验证一下。

+undefined // NaN
+null // 0
+true // 1
+false // 0
+'111' // 111
+'0x100F' // 4111
+'' // 0
'b' + 'a' + + 'a' + 'a' // 'baNaNa'
+Symbol() // Uncaught TypeError: Cannot convert a Symbol value to a number

ToBoolean

原始值 转换结果
Undefined false
Boolean true or false
Number 0和NaN返回false,其他返回true
Symbol true
Object true
我们也可以使用 Boolean 构造函数来手动将其他类型转为 boolean 类型。
Boolean(undefined) // false
Boolean(1) // true
Boolean(0) // false
Boolean(NaN) // false
Boolean(Symbol()) // true
Boolean({}) // true

ToString

原始值 转换结果
Undefined 'Undefined'
Boolean 'true' or 'false'
Number 对应的字符串类型
String String
Symbol Throw a TypeError exception
Object 先调用toPrimitive,再调用toNumber
转换到 string 类型可以用模板字符串来实现。
`${undefined}` // 'undefined'
`${true}` // 'true'
`${false}` // 'false'
`${11}` // '11'
`${Symbol()}` // Cannot convert a Symbol value to a string
`${{}}`

隐式类型转换

隐式类型转换一般是在涉及到运算符的时候才会出现的情况,比如我们将两个变量相加,或者比较两个变量是否相等。
隐式类型转换其实在我们上面的例子中已经有所体现。对于对象转原始类型的转换,也会遵守 ToPrimitive 的规则,下面会进行细说。

从ES规范来看类型转换

ToPrimitive

在对象转原始类型的时候,一般会调用内置的 ToPrimitive 方法,而 ToPrimitive 方法则会调用 OrdinaryToPrimitive 方法,我们可以看一下 ECMA 的官方文档。

image_1dard6av87ir24p140nv5d1vq9.png-182.5kB

我来翻译一下这段话。

ToPrimitive 方法接受两个参数,一个是输入的值 input,一个是期望转换的类型 PreferredType

  1. 如果没有传入 PreferredType 参数,让 hint 等于"default"
  2. 如果 PreferredTypehint String,让 hint 等于"string"
  3. 如果 PreferredTypehint Number,让 hint 等于"number"
  4. exoticToPrim 等于 GetMethod(input, @@toPrimitive),意思就是获取参数 input@@toPrimitive 方法
  5. 如果 exoticToPrim 不是 Undefined,那么就让 result 等于 Call(exoticToPrim, input, « hint »),意思就是执行 exoticToPrim(hint),如果执行后的结果 result 是原始数据类型,返回 result,否则就抛出类型错误的异常
  6. 如果 hint 是"default",让 hint 等于"number"
  7. 返回 OrdinaryToPrimitive(input, hint) 抽象操作的结果

OrdinaryToPrimitive

OrdinaryToPrimitive 方法也接受两个参数,一个是输入的值O,一个也是期望转换的类型 hint

  1. 如果输入的值是个对象
  2. 如果 hint 是个字符串并且值为'string'或者'number'
  3. 如果 hint 是'string',那么就将 methodNames 设置为 toStringvalueOf
  4. 如果 hint 是'number',那么就将 methodNames 设置为 valueOftoString
  5. 遍历 methodNames 拿到当前循环中的值 name,将 method 设置为 O[name](即拿到 valueOftoString 两个方法)
  6. 如果 method 可以被调用,那么就让 result 等于 method 执行后的结果,如果 result 不是对象就返回 result,否则就抛出一个类型错误的报错。

ToPrimitive 的代码实现

如果只用文字来描述,你肯定会觉得过于晦涩难懂,所以这里我就自己用代码来实现这两个方法帮助你的理解。

// 获取类型
const getType = (obj) => {
    return Object.prototype.toString.call(obj).slice(8,-1);
}
// 是否为原始类型
const isPrimitive = (obj) => {
    const types = ['String','Undefined','Null','Boolean','Number'];
      return types.indexOf(getType(obj)) !== -1;
}
const ToPrimitive = (input, preferredType) => {
    // 如果input是原始类型,那么不需要转换,直接返回
    if (isPrimitive(input)) {
        return input;
    }
    let hint = '', 
        exoticToPrim = null,
        methodNames = [];
    // 当没有提供可选参数preferredType的时候,hint会默认为"default";
    if (!preferredType) {
        hint = 'default'
    } else if (preferredType === 'string') {
        hint = 'string'
    } else if (preferredType === 'number') {
        hint = 'number'
    }
    exoticToPrim = input.@@toPrimitive;
    // 如果有toPrimitive方法
    if (exoticToPrim) {
        // 如果exoticToPrim执行后返回的是原始类型
        if (typeof (result = exoticToPrim.call(O, hint)) !== 'object') {
            return result;
        // 如果exoticToPrim执行后返回的是object类型
        } else {
            throw new TypeError('TypeError exception')
        }
    }
    // 这里给了默认hint值为number,Symbol和Date通过定义@@toPrimitive方法来修改默认值
    if (hint === 'default') {
        hint = 'number'
    }
    return OrdinaryToPrimitive(input, hint)
}
const OrdinaryToPrimitive = (O, hint) => {
    let methodNames = null,
        result = null;
    if (typeof O !== 'object') {
        return;
    }
    // 这里决定了先调用toString还是valueOf
    if (hint === 'string') {
        methodNames = [input.toString, input.valueOf]
    } else {
        methodNames = [input.valueOf, input.toString]
    }
    for (let name in methodNames) {
        if (O[name]) {
            result = O[name]()
            if (typeof result !== 'object') {
                return result
            }
        }
    }
    throw new TypeError('TypeError exception')
}

总结一下,在进行类型转换的时候,一般是通过 ToPrimitive 方法将引用类型转为原始类型。如果引用类型上有 @@toPrimitive 方法,就调用 @@toPrimitive 方法,执行后的返回值为原始类型就直接返回,如果依然是对象,那么就抛出报错。

如果对象上没有 toPrimitive 方法,那么就根据转换的目标类型来判断先调用 toString 还是 valueOf 方法,如果执行这两个方法后得到了原始类型的值,那么就返回。否则,将会抛出错误。

Symbol.toPrimitive

在 ES6 之后提供了 Symbol.toPrimitive 方法,该方法在类型转换的时候优先级最高。

const obj = {
  toString() {
    return '1111'
  },
  valueOf() {
    return 222
  },
  [Symbol.toPrimitive]() {
    return 666
  }
}
const num = 1 + obj; // 667
const str = '1' + obj; // '1666'

例子

也许上面关于 ToPrimitive 的代码讲解你还是会觉得晦涩难懂,那我接下来就举几个例子来说明对象的类型转换。

var a = 1, 
    b = '2';
var c = a + b; // '12'

也许你会好奇,为什么不是将后面的 b 转换为 number 类型,最后得到3?
我们还是要先看文档对加号的定义。

image_1davvk6ij3lnsisjsk1i8djf8p.png-243.3kB

首先会分别执行两个值的 toPrimitive 方法,因为 ab 都是原始类型,所以还是得到了1和'2'。
从图上看到如果转换后的两个值的 Type 有一个是 String 类型,那么就将两个值经过 toString 转换后串起来。因此最后得到了'12',而不是3。

我们还可以再看一个例子。

var a = 'hello ', b = {};
var c = a + b; // "hello [object Object]"

这里还会分别执行两个值的 toPrimitive 方法,a 还是得到了'hello ',而b由于没有指定preferredType,所以会默认被转为 number 类型,先调用 valueOf,但 valueOf 还是返回了一个空对象,不是原始类型,所以再调用 toString,得到了 '[object Object]',最后将两者连接起来就成了 "hello [object Object]"
如果我们想返回 'hello world',那该怎么改呢?只需要修改 bvalueOf 方法就好了。

b.valueOf = function() {
    return 'world'
}
var c = a + b; // 'hello world'

也许你在面试题中看到过这个例子。

var a = [], b = [];
var c = a + b; // ''

这里为什么 c 最后是''呢?因为 ab 在执行 valueOf 之后,得到的依然是个 [] ,这并非原始类型,因此会继续执行 toString,最后得到'',两个''相加又得到了''。
我们再看一个指定了 preferredType 的例子。

var a = [1, 2, 3], b = {
    [a]: 111
}

由于 a 是作为了 b 的键值,所以 preferredTypestring,这时会调用 a.toString 方法,最后得到了'1,2,3'

总结

类型转换一直是学 JS 的时候很难搞明白的一个概念,因为转换规则比较复杂,经常让人觉得莫名其妙。
但是如果从 ECMA 的规范去理解这些转换规则的原理,那么就会很容易知道为什么最后会得到那些结果。

浅谈react diff实现

浅谈react diff实现

这是一篇硬核文,因此不会用生动幽默的语言来讲述,这篇文章大概更像是自己心血来潮的总结吧哈哈哈哈。
有很多文章讲过 react 的 diff 算法,但要么是晦涩难懂的源码分析,让人很难读进去,要么就是流于表面的简单讲解,实际上大家看完后还是一头雾水,因此我将 react-lite(基于 react v15) 中的 diff 算法实现稍微整理了一下,希望能够帮助大家解惑。
对于 react diff,我们已知的有两点,一个是会通过 key 来做比较,另一个是 react 默认是同级节点做diff,不会考虑到跨层级节点的 diff(事实是前端开发中很少有DOM节点跨层级移动的)。
image

递归更新

首先,抛给我们一个问题,那就是 react 怎么对那么深层次的 DOM 做的 diff?实际上 react 是对 DOM 进行递归来做的,遍历所有子节点,对子节点再做递归,这一过程类似深度优先遍历。

// 超简单代码实现
const updateVNode = (vnode, node) => {
    updateVChildren(vnode, node)
}
const updateVChildren = (vnode, node) => {
    for (let i = 0; i < node.children.length; i++) {
        updateVNode(vnode.children[i], node.children[i])
    }
}

因此,我们这里以其中一层节点来讲解diff是如何做到更新的。

状态收集

假设我们的 react 组件渲染成功后,在浏览器中显示的真实 DOM 节点是A、B、C、D,我们更新后的虚拟DOM是B、A、E、D。
那我们这里需要做的操作就是,将原来 DOM 中已经存在的A、B、D进行更新,将原来 DOM 中原本存在,而现在不存在的C移除掉,再创建新的E节点。
这样一来,问题就简化了很多,我们只需要收集到需要 create、remove和update 的节点信息就行了。

// oldDoms是真实DOM,newDoms是最新的虚拟DOM
const oldDoms = [A, B, C, D],
    newDoms = [B, A, E, D],
    updates = [],
    removes = [],
    creates = [];
// 进行两层遍历,获取到哪些节点需要更新,哪些节点需要移除。
for (let i = 0; i < oldDoms.length; i++) {
    const oldDom = oldDoms[i]
    let shouldRemove = true
    for (let j = 0; j < newDoms.length; j++) {
        const newDom = newDoms[j];
        if (
            oldDom.key === newDom.key &&
            oldDom.type === newDom.type
        ) {
            updates[j] = {
                index: j,
                node: oldDom,
                parentNode: parentNode // 这里真实DOM的父节点
            }
            shouldRemove = false
        }
    }
    if (shouldRemove) {
        removes.push({
            node: oldDom
        })
    }
}
// 从虚拟 DOM 节点来取出不要更新的节点,这就是需要新创建的节点。
for (let j = 0; j < newDoms.length; j++) {
    if (!updates[j]) {
        creates.push({
            index: j,
            vnode: newDoms[j],
            parentNode: parentNode // 这里真实DOM的父节点
        })
    }
}

这样,我们便拿到了想要的状态信息。

diff

在得到需要 create、update 和 remove 的节点后,我们这时就可以开始进行渲染了。
node | 状态 | index
:-: | :-: | :-: | :-: | :-:
A | update | 1
B | update| 0
C | remove
D | update | 3
E | create | 2

首先,我们遍历所有需要 remove 的节点,将其从真实DOM中 remove 掉。因此这里需要 remove 掉C节点,最后渲染结果是A、B、D。

const remove = (removes) => {
    removes.forEach(remove => {
        const node = remove.node
        node.parentNode.removeChild(node)
    })
}

其次,我们再遍历需要更新的节点,将其插入到对应的位置中。所以这里最后渲染结果是B、A、D。

const update = (updates) => {
    updates.forEach(update => {
        const index = update.index,
            parentNode = update.parentNode,
            node = update.node,
            curNode = parentNode.children[index];
        if (curNode !== node) {
            parentNode.insertBefore(node, curNode)
        }
    })
}

最后一步,我们需要创建新的 DOM 节点,并插入到正确的位置中,最后渲染结果为B、A、E、D。

const create = (creates) => {
    creates.forEach(create => {
        const index = create.index,
            parentNode = create.parentNode,
            vnode = create.vnode,
            curNode = parentNode.children[index],
            node = createNode(vnode); // 创建DOM节点
        parentNode.insertBefore(node, curNode)
    })
}

虽然这篇文章写的比较简单,但是一个完整的diff流程就是这样了,可以加深对react的一些理解。当然了,还有一些对 DOM 节点属性之类的比较,这里不做讲解。
image
image

react状态管理

react作为现今最火热的前端框架,很多项目都采用了react构建界面,但是react自身也有许多不足,比如状态管理和组件通信等等,随着项目越来越大,只靠react很难满足需求,所以很多状态管理库由此诞生。

全局state

如果是比较小的个人项目,完全可以不使用任何状态库,可以在容器(顶层)组件里面使用一个类似redux store的大state,将这个state通过props和context传给子组件,可以将setState方法从容器组件传下去,或者将子组件的修改state方法放到容器组件中,这样就可以轻松实现状态管理,但是会造成容器组件过于臃肿。

redux

redux是我们公司老项目中最常用的状态库,redux遵守dispatch -> action -> reducer -> store -> view的一套操作流程,好处是具有时间回溯,这在错误调试的时候非常适用。数据流动清晰,组件之间通信也更加方便,缺点就是需要在多个文件里面写很多action和reducer,在小项目中就会得不偿失,但是在大型项目中有利于以后维护。

relite

relite是我们部门的工业聚大神写的一个类redux库,总体**和redux一致,区别只是在于写法上的不同,relite中剔除了reducer的概念,将action和reducer合一,以原来的action.type来命名reducer函数,每个函数最终都会返回整个store,这样无需写一串长长的switch case语句,但也带来了一系列问题,比如由于返回整个store,很难配合PureComponent做性能优化。

mobx

mobx和redux走了完全不同的一条路,mobx更偏向vue等响应式库,通过getter、setter(Proxy)来实现对数据的监听,而mobx-react会提供observer方法来对组件依赖的响应式数据进行收集,这样带来的好处就是可以做到更细粒度的控制组件渲染,可以带来更高效的性能,能够做到只渲染子组件而不渲染父组件,甚至可以用组件内部可观察的数据来代替state,避免由于state的‘异步’特性导致的问题,这些在react和redux里面是无法做到的。

rematch

由于redux写法过于繁琐,所以出现了很多对redux重新思考后而设计的库,其中rematch是比较优秀的一个。
我们都知道redux的写法很繁琐,比如createStore的时候,为什么要写成createStore(reducers, initialStore, applyMiddleware(thunk, logger))这种形式,写成可配置的不好吗?const store = {middleware: [thunk, logger], initialStore: {}, reducers: {count}}这种不是更好?为什么reducer里面还要用switch这么繁琐的条件语句?表驱动不是更好?
基于这种考虑,rematch应然而生。rematch将reducer和action合并到一个对象中,提供了effects来承载异步操作(请求接口等),这样的好处就是用户触发的action实际上就是reducer,这和relite的**基本一致。

如果是小型项目,也许你不需要使用状态管理库,如果实在操作不好,那么可以考虑一下relite,如果是中大型项目,可以考虑使用mobx,如果是业务很复杂的项目,那么建议使用redux和rematch。

深入理解 JavaScript 中的类与继承

前言

JavaScript 在设计之初,是一门具有函数式、面向对象( OOP )等风格的多范式语言。
虽然 JavaScript 中到处都是“对象”,但如果想要 JavaScript 中使用类,在 ES5 之前需要使用构造函数和原型( prototype )来模拟类,而涉及到继承的时候,实现方式之多更是让人眼花缭乱。
因此,在 ES6 之后,JavaScript 增加了 class 和 extends 关键字,这让JavaScript更加接近传统的面向对象语言,也方便了开发。

本文比较基础,如果对知识点已经比较了解,可以跳过本文。如果觉得不够了解,那么大家可以来一起复习一下。
本文涉及到对象、构造函数、原型( prototype )等知识,如果有不懂的知识点,建议先去熟悉《JavaScript高级程序设计》第六章相关概念后再来阅读。

1. 类

什么是类呢?这里引用一下维基百科的解释:

类(英语:class)在面向对象编程中是一种面向对象计算机编程语言的构造,是创建对象的蓝图,描述了所创建的对象共同的属性和方法。

类是对现实生活中一类具有共同特征的事物的抽象,像老鼠、猫、人类等都可以作为类。但具体的个体就是对象,比如一只叫汤姆的猫,一只叫杰瑞的老鼠。

在 JavaScript 中每个对象都可以都是基于引用类型创建的,这个引用类型可以是原生类型( Date、String、Boolean 等),也可以是自定义的类型。因此,类也可以被理解为是描述数据和行为的一种复杂类型。

1.1 ES5 中的类

在开始之前,我们先基于上述的描述来实现一个简单的类。
由于类是一种自定义的复杂类型,封装了一类事物的共同特性,而对象只是类返回的一个实例,那么我们完全可以考虑使用函数来创建,最后返回一个对象。
以下面这个人的类为例:

function person(name, age) {
    return {
        name,
        age,
        say() {
            console.log('my name is ', name);
        }
    }
}
person('tom', 23); // { name: 'tom', age: 23, say: f }
person('jerry', 24); // { name: 'jerry', age: 24, say: f }

注意:这段代码充分体现了,在 JavaScript 中到处都是“对象”,只要使用对象字面量就能轻松创建一个对象。

但是这个类的实现方式也有一定问题,比如没有办法设置原型,这样也就无法实现继承。
因此,在 JavaScript 中,一般使用具有 this 的构造函数和 new 操作符来模拟类和对象,这样也是更符合传统面向对象语言开发者直觉的设计。

function Person(name, age) {
    this.name = name;
    this.age = age;
}
Person.prototype.say = function() {
    console.log('my name is ', name);
}
const tom = new Person('tom', 23);
const jerry = new Person('jerry', 24);

当然,你也许会困惑,当使用 new 的时候,它到底做了什么呢?
其实 new 操作符做的事情很简单,大致就是以下几步:

  1. 创建一个空对象 obj
  2. 执行构造函数,将 this 指向 obj
  3. 设置 obj[[Prototype]] 指针指向构造函数的原型 prototype
  4. 返回这个对象

按照上面这四步,可以自己手动实现一个 new 操作符。

function myNew() {
    const Constructor = arguments[0],
        args = [...arguments].slice(1), // arguments是类数组,因此需要转换为数组才能使用slice方法
        obj = {};
        
    Constructor.apply(obj, args);
    // 设置[[Prototype]]指针(不推荐)
    obj.__proto__ = Constructor.prototype;
    // 设置[[prototype]]指针(推荐)
    Object.setPrototypeOf(obj, Constructor.prototype);
    return obj;
}

调用方式也比较简单:

function Person(name, age) {
    this.name = name;
    this.age = age;
}
myNew(Person, 'tom', 23);
new Person('tom', 23);

通过myNew和new最后得到的两个实例,两者表现是一致的,[[Prototype]]也都指向了同一地址。

image_1dmgu1orh19111d1j138gt711pbt55.png-153.9kB

注意:

  1. [[Prototype]] 是存在于实例对象上的一个指针,它指向构造函数的原型( prototype ),通过 [[Prototype]] 连接起一个个对象,最终形成一条原型链,原型链的终点是 Object.prototype.__proto__,也就是null。
  2. 为什么不推荐使用 __proto__ ,而建议用 Object.setPrototypeOf 呢?
    这是因为 __proto__ 并非官方标准,而是浏览器自己实现的,可能会出现兼容性问题,而 Object.setPrototypeOf 则是 ES6 中标准,未来所有浏览器都会支持。

上述代码原型链关系图:

原型链关系图

1.2 ES6 class

在 ES6 中新加入了 class 的语法糖,从此和手动模拟类的时代说拜拜。

class Person {
    static name = 'person';
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    say() {
        console.log('my name is ', name);
    }
}

注意:如果 say 使用箭头函数来定义,那么 babel 编译后的结果是在构造函数中的,不使用箭头函数则只是绑定到原型( prototype )上。

可以明显地看到,原来的 Person 构造函数,现在变成了constructor。而在 Person 类中直接定义的方法,会被绑定到原型上,我们也不需要再用 Person.prototype.say 这种复杂方式来定义原型方法。
用新增加的 static 关键字定义的属性代表着类上面的静态属性,也取代了原有的 Person.name 的形式(当然,在 ES6 中依然可以用 Person.name 来定义静态属性)。

除以之外,使用 class 定义的类,无法像原来的构造函数一样直接调用,否则会报错。

image_1dmgmin1n1bvr18n41j6f3hgs7o1g.png-18.1kB

2. 继承

继承是指可以在不编写更多代码的情况下,一个类可以使用另一个类上的属性或者方法。甚至父类可以只提供接口,让子类去实现。

一般来说,继承的类叫做「子类」或者「派生类」,而被继承的类叫做「父类」或者「超类」。

2.1 extend

前面我们说过,在 JavaScript 中到处都是“对象”,而对象也是基于引用类型创建的。聪明的孩子也许会想到,能不能将不同的对象进行混合,从而实现类似继承的效果呢?

在 jQuery、underscore/lodash、Backbone 等框架和库中都提供了类似扩展( extend )的功能,直接将对象的属性合并到目标对象中。

image_1dmgnvcqkbv5sij9a48q17rd1t.png-131.2kB

这里使用了 jQuery 中的 extend 方法来将 map 对象分别合并到 baiduMapgoogleMap 上,最终两者都具有了前者的方法。

在 ES6 中也提供了新的方法 Object.assign 来实现对象的合并,用法也和 $.extend 几乎一致。

但不管 extend 还是 Object.assign 都只是浅拷贝,如果将引用类型属性合并到不同的目标对象中,一旦其中一个目标对象修改了这个属性,就会造成其他目标对象也跟着变化。

image_1dmgq0mbl1epr2in1daj17k21mdt4b.png-174.5kB

2.2 mixin

而在 Vue 和 早期的 React 中都支持一种名为 mixin 的方式,mixin的作用在于将不同组件的共同部分抽取出来,实现逻辑的复用。

注意:React 在后期移除了这个特性,官方更提倡使用高阶组件或 hooks 来实现代码复用。

Vue 中的 mixin(将 toggle 提取出来可以供不同组件使用):

const toggle = {
    data() {
        return {
            isShowing: false
        }
    },
    methods: {
        toggleShow() {
            this.isShowing = !this.isShowing;
        }
    }
}

const Modal = {
    template: '#modal',
    mixins: [toggle],
    components: {
        appChild: Child
    }
};

const Tooltip = {
    template: '#tooltip',
    mixins: [toggle],
    components: {
        appChild: Child
    }
};

React 早期的 mixin(将设置默认的props提取出来):

var DefaultNameMixin = {
    getDefaultProps: function () {
        return {name: "Skippy"};
    }
};
var ComponentOne = React.createClass({
    mixins: [DefaultNameMixin],
    render: function() {
        return <h2>Hello {this.props.name}</h2>;
    }
});
var ComponentTwo = React.createClass({
    mixins: [DefaultNameMixin],
    render: function () {
        return (
            <div>
                <h4>{this.props.name}</h4>
                <p>Favorite food: {this.props.food}</p>
            </div>
        );
    }
});

甚至在 sass 和 less 这些 css 预处理器中也支持 mixin 的形式,允许我们灵活复用相同的 css 代码。

scss 中的 mixin(封装了 border-radius ):

@mixin border-radius($radius) {
    -webkit-border-radius: $radius;
    -moz-border-radius: $radius;
    -ms-border-radius: $radius;
    border-radius: $radius;
}

aside { 
    border: 1px solid orange;
    @include border-radius(10px); 
}

2.3 基于原型链的继承

extendmixin 在很多框架和库中被使用,但两者本质上都是做了对象的合并,适用范围有限。
在 ES6 出现之前,如何用 ES5 实现继承也也是前端面试中的常见题型之一,这里将重点介绍组合继承和寄生组合继承。

2.3.1 组合继承

通过原型链的机制,将父类的实例赋值给子类的原型链来实现子类继承父类的属性。
同时,又将父类的构造函数在子类的构造函数中执行,来实现绑定父类构造函数中的属性。

function Child(name, age) {
		Parent.call(this, name, age);
    this.name = name;
    this.age = age;
}
function Parent(name, age) {
    this.name = name;
    this.age = age;
}
Parent.prototype.getName = function() {
    console.log('parent', this.name);
}
Parent.prototype.getAge = function() {
    console.log('parent', this.age);
}
Child.prototype = new Parent();
Child.prototype.getName = function() {
    console.log('child', this.name);
}
var child = new Child('tom', 23);
child.getName();
child.getAge();

注意:父构造函数的执行一定要放到子构造函数属性定义之前,这样可以避免父构造函数上的属性覆盖子构造函数的属性。

原型链关系图:

image_1dmibosjn17g211pbfi9pc1nbg9.png-23.7kB

但是组合继承有个问题,就是在设置 Child 的原型时,需要实例化一次 Parent 构造函数,导致了 Parent 构造函数被调用了两次。

2.3.2 寄生式继承

寄生式继承的思路和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数内部以某种方式来增强对象,最后返回这个对象。

寄生式继承更像是将对象与对象衔接起来,形成一条原型链。

function createAnother(origin) {
	const clone = Object.create(origin);
	clone.say = function() {
		console.log(this.name);
	};
	return clone;
}
const person = {
	name: 'tom',
	age: 23
}
const anotherPerson = createAnother(person);
anotherPerson.say(); // 'tom'

注意:Object.create 接收一个对象 origin ,以这个对象为原型( prototype ),创建一个新的对象,这个新对象的[[Prototype]]指向 origin 对象。

2.3.2 寄生组合式继承

寄生组合式继承是将寄生式继承与组合继承结合起来的一种继承方式,主要是用 Object.create 来代替原来实例化父构造函数,它解决了组合继承中调用两次父构造函数的弊端,也是最理想的继承范式。

function Child(name, age) {
    Parent.call(this, name, age);
    this.name = name;
    this.age = age;
}
function Parent(name, age) {
    this.name = name;
    this.age = age;
}
Parent.prototype.getName = function() {
    console.log('parent', this.name);
}
Parent.prototype.getAge = function() {
    console.log('parent', this.age);
}
Child.prototype = Object.create(Parent.prototype);
Child.prototype.getName = function() {
    console.log('child', this.name);
}
const child = new Child('tom', 23);
child.getName();
child.getAge();

注意:给 Child.prototype 添加新属性一定要放到赋值之后,不然原来添加的属性会被替换。

原型链关系图:

image_1dmighodq13fb12p716uc9iumq11g.png-39.4kB

《JavaScript高级程序设计》中对寄生组合式继承的评价是这样的:

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

2.4 ES6 继承

在 ES6 中,新增加了 extends 的语法糖,在定义子类的时候可以直接继承父类,这样统一了继承的方式,让大家不再被各种各样的继承方式困扰。

class Parent {
	constructor(name, age) {
		this.name = name;
	}
	say() {
		console.log('my name is', this.name);
	}
}
class Child extends Parent {
	constructor(name, age) {
		super(name);
		this.name = name;
		this.age = age;
	}
}
const child = new Child('tom', 23);
child.say();

注意:super关键字作为函数时,只能在构造函数中调用,代表父类的构造函数。作为对象时,在普通方法中,指向父类的原型对象;在静态方法中,指向父类。

2.5 多重继承

多重继承是指一个子类同时继承多个父类,拥有这多个类的属性和方法。由于 JavaScript 的继承是基于原型链的,原型链一般只有一条链,无法同时指向多个不同的对象,因此 JavaScript 中是无法实现传统的多重继承。

注意:原型链的一般实现是单链表,以 [[prototype]] 指针指向下一个对象,直到最终指向 null

image_1dmik99mf1b15n49q1p1fj1s2q2a.png-22kB但是可以让父类分别互相继承,子类继承最后那个父类来实现多重继承。这种实现方式的缺点就是要在每个父类定义的时候继承另一个父类。

class Parent1 extends Parent2 {}
class Parent2 extends Parent3 {}
class Child extends Parent1 {}

另一种实现方式,就是我们前面提到过的 mixinmixin 不仅在各大框架中被广泛使用,也可以将多个父类进行混合,从而实现多重继承的效果。

function mixin(...mixins) {
    class Mixin {
        constructor(...args) {
            mixins.forEach(
                mixin => copyProperties(this, new mixin(...args)) // 拷贝实例属性
        		) 
        }
    }
    mixins.forEach(
        mixin => {
            copyProperties(Mixin, mixin); // 拷贝静态属性
            copyProperties(Mixin.prototype, mixin.prototype); // 拷贝原型属性
        }
    )

    return Mixin;
}

function copyProperties(target, source) {
    for (let key of Reflect.ownKeys(source)) {
      	if (['constructor', 'prototype', 'name'].indexOf(key) < 0) {
           	let desc = Object.getOwnPropertyDescriptor(source, key);
            Object.defineProperty(target, key, desc);
        }
    }
}

注意:Reflect 是 ES6 中的新 API,Reflect.ownKeys 是获取对象自身的属性,和 Object.keys 不同点在于还会返回不可枚举属性。

使用方式:

class Child extends mixin(Parent1, Parent2, Parent3) {

}

推荐阅读

  1. class 的基本语法
  2. class 的继承
  3. 继承与原型链

underscore源码剖析之数组遍历函数分析(一)

这是underscore源码剖析系列第三篇文章,主要介绍underscore中each、map、filter、every、reduce等我们常用的一些遍历数组的方法。

each

在underscore中我们最常用的就是each和map两个方法了,这两个方法一般接收三个参数,分别是数组/对象、函数、上下文。

// iteratee函数有三个参数,分别是item、index、array或者value、key、obj
_.each = _.forEach = function(obj, iteratee, context) {
    // 如果不传context,那么each方法里面的this就会指向window
    iteratee = optimizeCb(iteratee, context);
    var i, length;
    // 如果是类数组,一般来说包括数组、arguments、DOM集合等等
    if (isArrayLike(obj)) {
        for (i = 0, length = obj.length; i < length; i++) {
            iteratee(obj[i], i, obj);
        }
    // 一般是指对象
    } else {
        var keys = _.keys(obj);
        for (i = 0, length = keys.length; i < length; i++) {
            iteratee(obj[keys[i]], keys[i], obj);
        }
    }
    return obj;
};

each函数的源码很简单,函数内部会使用isArrayLike方法来判断当前传入的第一个参数是类数组或者对象,如果是类数组,直接使用访问下标的方式来遍历,并将数组的项和index传给iteratee函数,如果是对象,则先获取到对象的keys,再进行遍历后将对象的value和key传给iteratee函数

不过在这里,我们主要分析optimizeCb和isArrayLike两个函数。

optimizeCb

    // 这个函数主要是给传进来的func函数绑定context作用域。
	var optimizeCb = function (func, context, argCount) {
	    // 如果没有传context,那就直接返回func函数
		if (context === void 0) return func;
		// 如果没有传入argCount,那就默认是3。这里是根据第二次传入的参数个数来给call函数传入不同数量的参数
		switch (argCount == null ? 3 : argCount) {
			case 1: return function (value) {
				return func.call(context, value);
			};
			case 2: return function (value, other) {
				return func.call(context, value, other);
			};
			// 一般是each、map等
			case 3: return function (value, index, collection) {
				return func.call(context, value, index, collection);
			};
			// 一般是reduce等
			case 4: return function (accumulator, value, index, collection) {
				return func.call(context, accumulator, value, index, collection);
			};
		}
		// 如果参数数量大于4
		return function () {
			return func.apply(context, arguments);
		};
	};

其实我们很容易就看出来optimizeCb函数只是帮func函数绑定context的,如果不存在context,那么直接返回func,否则则会根据第二次传给func函数的参数数量来判断给call函数传几个值。
这里有个重点,为什么要用这么麻烦的方式,而不直接用apply来将arguments全部传进去?
原因是call方法的速度要比apply方法更快,因为apply会对数组参数进行检验和拷贝,所以这里就对常用的几种形式使用了call,其他情况下使用了apply,详情可以看这里:call和apply

isArrayLike

关于isArrayLike方法,我们来看underscore的实现。(这个延伸比较多,如果没兴趣,可以跳过)

// 一个高阶函数,返回对象上某个具体属性的值
var property = function (key) {
	return function (obj) {
		return obj == null ? void 0 : obj[key];
	};
};

// 这里有个ios8上面的bug,会导致类似var pbj = {1: "a", 2: "b", 3: "c"}这种对象的obj.length = 4; jQuery中也有这个bug。
// https://github.com/jashkenas/underscore/issues/2081 
// https://github.com/jquery/jquery/issues/2145
// MAX_SAFE_INTEGER is 9007199254740991 (Math.pow(2, 53) - 1).
// http://ecma-international.org/ecma-262/6.0/#sec-number.max_safe_integer
var MAX_ARRAY_INDEX = Math.pow(2, 53) - 1;

// 据说用obj["length"]就可以解决?我没有ios8的环境,有兴趣的可以试试
var getLength = property('length');

// 判断是否是类数组,如果有length属性并且值为number类型即可视作类数组
var isArrayLike = function (collection) {
	var length = getLength(collection);
	return typeof length == 'number' && length >= 0 && length <= MAX_ARRAY_INDEX;
};

在underscore中,只要带有length属性,都可以被认为是类数组,所以即使是{length: 10}这种情况也会被归为类数组。
我个人感觉这样写其实太过片面,我还是更喜欢jQuery里面isArrayLike方法的实现。

function isArrayLike(obj) {
	// Support: real iOS 8.2 only (not reproducible in simulator)
	// `in` check used to prevent JIT error (gh-2145)
	// hasOwn isn't used here due to false negatives
	// regarding Nodelist length in IE
	var length = !!obj && "length" in obj && obj.length,
		type = toType(obj);
	// 排除了obj为function和全局中有length变量的情况
	if (isFunction(obj) || isWindow(obj)) {
		return false;
	}
	return type === "array" || length === 0 ||
		typeof length === "number" && length > 0 && (length - 1) in obj;
}

jQuery中使用in来解决ios8下面那个JIT的错误,同时还会排除obj是函数和window的情况,因为如果obj是函数,那么obj.length则是这个函数参数的个数,而如果obj是window,那么我在全局中定义一个var length = 10,这个同样也能获取到length。

最后的三个判断分别是:

  1. 如果obj的类型是数组,那么返回true
  2. 如果obj的length是0,也返回true。即使是{length: 0}这种情况,因为在调用isArrayLike的each和map等方法中会在for循环里面判断length,所以也不会造成影响。
  3. 最后这个(length - 1) in obj我个人理解就是为了排除{length: 10}这种情况,因为这个可以满足length>0和length==="number"的情况,但是一般情况下是无法满足最后(length - 1) in obj的,但是NodeList和arguments这些却可以满足这个条件。

map

说完了each,我们再来说说map,map函数其实和each的实现很类似,不过不一样的一个地方在于,map函数的第二个参数不一定是函数,我们可以什么都不传,甚至还可以传个对象。

var arr = [{name:'Kevin'}, {name: 'Daisy', age: 18}]
var result1 = _.map(arr); // [{name:'Kevin'}, {name: 'Daisy', age: 18}]
var result2 = _.map(arr, {name: 'Daisy'}) // [false, true]

所以这里就会对传入map的第二个参数进行判断,整体来说map函数的实现比each更加简洁。

_.map = _.collect = function (obj, iteratee, context) {
		// 因为在map中,第二个参数可能不是函数,所以用cb,这点和each的实现不一样。
		iteratee = cb(iteratee, context);
		// 如果不是类数组(是对象),则获取到keys
		var keys = !isArrayLike(obj) && _.keys(obj),
			length = (keys || obj).length,
			results = Array(length);
		// 这里根据keys是否存在来判断传给iteratee是key还是index
		for (var index = 0; index < length; index++) {
			var currentKey = keys ? keys[index] : index;
			results[index] = iteratee(obj[currentKey], currentKey, obj);
		}
		return results;
	};

cb

我们来看看map函数中这个cb函数到底是什么来历?

_.identity = function (value) {
	return value;
};
var cb = function (value, context, argCount) {
    // 如果value不存在
	if (value == null) return _.identity;
	// 如果传入的是个函数
	if (_.isFunction(value)) return optimizeCb(value, context, argCount);
	// 如果传入的是个对象
	if (_.isObject(value)) return _.matcher(value);
	return _.property(value);
};

cb函数在underscore中一般是用在遍历方法中,大多数情况下value都是一个函数,我们结合上面map的源码和例子来看。

  1. 如果value不存在,那就对应上面的_.map(obj)的情况,map中的iteratee就是_.identity函数,他会将后面接收到的obj[currentKey]直接返回。
  2. 如果value是一个函数,就对应_.map(obj, func)这种情况,那么会再调用optimizeCb方法,这里就和each的实现是一样的
  3. 如果value是个对象,对应_.map(obj, arrts)的情况,就会比较obj中的属性是否在arr里面,这个时候会调用_.matcher函数
  4. 这种情况一般是用在_.iteratee函数中,用来访问对象的某个属性,具体看这里:iteratee函数

matcher

那么我们再来看matcher函数,matcher函数内部对两个对象做了浅比较。

_.matcher = _.matches = function (attrs) {
    // 将attrs和{}合并为一个对象(避免attrs为undefined)
	attrs = _.extendOwn({}, attrs);
	return function (obj) {
		return _.isMatch(obj, attrs);
	};
};
// isMatch方法会对接收到的attrs对象进行遍历,同时比较obj中是否有这一项
_.isMatch = function (object, attrs) {
	var keys = _.keys(attrs), length = keys.length;
	// 如果object和attr都是空,那么返回true,否则object为空时返回false
	if (object == null) return !length;
	// 这一步没懂是为了做什么?
	var obj = Object(object);
	for (var i = 0; i < length; i++) {
		var key = keys[i];
		if (attrs[key] !== obj[key] || !(key in obj)) return false;
	}
	return true;
};

matcher是个高阶方法,他会将两次接收到的对象传给isMatch函数来进行判断。首先是以attrs为被遍历的对象,通过对比obj[key]和attrs[key]的值,只要obj中的值和attrs中的不想等,就会返回false。
这里还会排除一种情况,如果attrs中对应key的value正好是undefined,而且obj中并没有key这个属性,这样obj[key]和attrs[key]其实都是undefined,这里使用!==来比较必然会返回false,实际上两者应该是不想等的。
所以使用in来判断obj上到底有没有key这个属性,如果没有,也会返回false。如果attrs上面所有属性在obj中都能找到,并且两者的值正好相等,那么就会返回true。
这也就是为什么_.map([{name:'Kevin'}, {name: 'Daisy', age: 18}], {name: 'Daisy'}); 会返回 [false, true]。

重写each

each和map实现原理基本上一样,不过map更加简洁,这里可以用map的形式重写一下each

_.each = _.forEach = function (obj, iteratee, context) {
		iteratee = optimizeCb(iteratee, context);
		var keys = !isArrayLike(obj) && _.keys(obj),
			length = (keys || obj).length,
			results = Array(length);
		for (var index = 0; index < length; index++) {
			var currentKey = keys ? keys[index] : index;
			iteratee(obj[currentKey], currentKey, obj);
		}
		return obj;
	};

filter、every、some、reject

这几种方法的实现和上面的each、map类似,这里就不多做解释了,有兴趣的可以自己去看一下。

mobx实践

由于redux需要写很多繁琐的action和reducer,大部分项目也没有复杂到需要用到redux的程度,导致不少人对redux深恶痛绝。mobx是另一种状态管理方案,这里分享一下我最近使用mobx的经验。

更响应式

我最喜欢mobx的地方就是和vue一样的数据监听,框架底层通过Object.defineProperty或Proxy来劫持数据,对组件可以进行更细粒度的渲染。在react中反而把更新组件的操作(setState)交给了使用者,由于setState的"异步"特性导致了没法立刻拿到更新后的state。

computed

想像一下,在redux中,如果一个值A是由另外几个值B、C、D计算出来的,在store中该怎么实现?

如果要实现这么一个功能,最麻烦的做法是在所有B、C、D变化的地方重新计算得出A,最后存入store。

当然我也可以在组件渲染A的地方根据B、C、D计算出A,但是这样会把逻辑和组件耦合到一起,如果我需要在其他地方用到A怎么办?

我甚至还可以在所有connect的地方计算A,最后传入组件。但由于redux监听的是整个store的变化,所以无法准确的监听到B、C、D变化后才重新计算A。

但是mobx中提供了computed来解决这个问题。正如mobx官方介绍的一样,computed是基于现有状态或计算值衍生出的值,如下面todoList的例子,一旦已完成事项数量改变,那么completedCount会自动更新。

class TodoStore {
    @observable todos = []
    @computed get completedCount() {
		return (this.todos.filter(todo => todo.isCompleted) || []).length
	}
}

reaction

reaction则是和autorun功能类似,但是autorun会立即执行一次,而reaction不会,使用reaction可以在监听到指定数据变化的时候执行一些操作,有利于和副作用代码解耦。

// 当todos改变的时候将其存入缓存
reaction(
    () => toJS(this.todos),
    (todos) =>  localStorage.setItem('mobx-react-todomvc-todos', JSON.stringify({ todos }))
)

拆分store

mobx中的store的创建偏向于面向对象的形式,mobx官方给出的例子todomvc中的store更接近于mvc中的model。

但是这样也会带来一个问题,业务逻辑我们应该放到哪里?如果也放到store里面很容易造成不同store之间数据的耦合,因为业务代码必然会耦合不同的数据。

我参考了dobjs后,推荐将store拆分为action和dataModel两种。

action和dataModel一起组合成了页面的总store,dataModel只存放UI数据以及只涉及自身数据变化的action操作(在mobx严格模式中,修改数据一定要用action或flow)。

action store则是负责存放一些需要使用来自不同store数据的action操作。

我个人理解,dataModel更像MVC中的model,action store是controller,react components则是view,三者构成了mvc的结构。

- stores
    - actions
        - hotelListAction.js
    - dataModel
        - globalStatus.js
        - hotelList.js
    - index.js
// globalStatus
class GlobalStatus {
    @observable isShowLoading = false;
    @action showLoading = () => {
        this.isShowLoading = true
    }
    @action hideLoading = () => {
        this.isShowLoading = false
    }
}
// hotelList
class HotelList {
    @observable hotels = []
    @action addHotels = (hotels) => {
        this.hotels = [...toJS(this.hotels), ...hotels];
    }
}
// hotelListAction
class HotelListAction {
    fetchHotelList = flow(function *() {
        const {
            globalStatus,
            hotelList
        } = this.rootStore
        globalStatus.showLoading();
        try {
            const res = yield fetch('/hoteList', params);
            hotelList.addHotels(res.hotels);
        } catch (err) {
        } finally {
            globalStatus.hideLoading();
        }
    }).bind(this)
}

store结构

细粒度的渲染

observer可以给组件增加订阅功能,一旦收到数据变化的通知就会将组件重新渲染,从而做到更细粒度的更新,这是redux和react很难做到的,因为react中组件重新渲染基本是依赖于setState和接收到新的props,子组件的渲染几乎一定会伴随着父组件的渲染。
也许很多人没有注意到,mobx-react中还提供了一个Observer组件,这个组件接收一个render方法或者render props。

const App = () => <h1>hello, world</h1>;
<Observer>{() => <App />}</Observer>
<Observer render={() => <App />} />

也许你要问这个和observer有什么区别?还写的更加复杂了,下面这个例子对比起来会比较明显。

import { observer, Observer, observable } from 'mobx-react'
const App = observer(
    (props) => <h1>hello, {props.name}</h1>
)
const Header = (props) => <h1>this is header</h1>
const Footer = (props) => <h1>this is footer</h1>
const Container = observer(
    (props) => {
        return (
            <>
                <Header />
                <App name={props.person.name} />
                <Footer />
            </>
        )
    }
)
const person = observable({name: "gyyin"});
render(<Container person={person} />, document.getElementById("app"));
person.name = "world";

上面这个代码,Container组件监听到person.name改变的时候会重新渲染,这样就导致了原本不需要重新渲染的Header和Footer也跟着渲染了,如果使用Observer就可以做到更细粒度的渲染。

const App = (props) => <h1>hello, {props.name}</h1>
const Header = (props) => <h1>this is header</h1>
const Footer = (props) => <h1>this is footer</h1>
const Container = (props) => {
    return (
        <>
            <Header />
            <Observer render={
                () => <App name={props.person.name} />
            }>
            <Footer />
        </>
    )
}
const person = observable({name: "gyyin"});
render(<Container person={person} />, document.getElementById("app"));
person.name = "world";

如果在Header和Footer里面做console.log,你会发现只有被Observer包裹的App组件进行了重新渲染,由于Container没有订阅数据变化,所以也不会重新渲染。

但如果不是对性能有极致的追求,observer已经足够了,大量的Observer会花费你很多精力来管理渲染问题。

参考链接:

  1. 如何组织Mobx中的Store之一:构建State、拆分Action
  2. 面向未来的前端数据流框架 - dob
  3. 为什么我们需要reselect

underscore整体架构分析

此系列文章同步发表于知乎专栏 晚起的鸟儿 和我的个人网站 世界变了样

最近打算好好看看underscore源码,一个是因为自己确实荒废了基础,另一个是underscore源码比较简单,比较易读。
本系列打算对underscore1.8.3中关键函数源码进行分析,希望做到最详细的源码分析。
今天是underscore源码剖析系列第一篇,主要对underscore整体架构和基础函数进行分析。

基础模块

首先,我们先来简单的看一下整体的代码:

// 这里是一个立即调用函数,使用call绑定了外层的this(全局对象)
(function() {

  var root = this;
  
  // 保存当前环境中已经存在的_变量(在noConflict中用到)
  var previousUnderscore = root._;
  
  // 用变量保存原生方法的引用,以防止这些方法被重写,也便于压缩
  var ArrayProto = Array.prototype, ObjProto = Object.prototype, FuncProto = Function.prototype;

  var
    push             = ArrayProto.push,
    slice            = ArrayProto.slice,
    toString         = ObjProto.toString,
    hasOwnProperty   = ObjProto.hasOwnProperty;

  var
    nativeIsArray      = Array.isArray,
    nativeKeys         = Object.keys,
    nativeBind         = FuncProto.bind,
    nativeCreate       = Object.create;

  var Ctor = function(){};
  // 内部实现省略
  var _ = function(obj) {};
  
    // 这里是各种方法的实现(省略)
    
  // 导出underscore方法,如果有exports则用exports导出,如果    没有,则将其设为全局变量
  if (typeof exports !== 'undefined') {
    if (typeof module !== 'undefined' && module.exports) {
      exports = module.exports = _;
    }
    exports._ = _;
  } else {
    root._ = _;
  }
  
  // 版本号
  _.VERSION = '1.8.3';
  
  // 用amd的形式导出
  if (typeof define === 'function' && define.amd) {
    define('underscore', [], function() {
      return _;
    });
  }
}.call(this))

全局对象

这段代码整体比较简单,不过我看后来的underscore版本有一些小改动,主要是将var root = this;替换为下面这句:

var root = typeof self == 'object' && self.self === self && self || typeof global == 'object' && global.global === global && global || this;

这里增加了对self和global的判断,self属性可以返回对窗口自身的引用,等价于window,这里主要是为了兼容web worker,因为web worker中是没有window的,global则是为了兼容node,而且在严格模式下,立即执行函数内部的this是undefined。

void(0) ? undefined

扫一眼源码,我们会发现在源码中并没有见到undefined的出现,反而是用void(0)或者void 0来代替的,那么这个void到底是什么?为什么不能直接用undefined呢?
关于void的解释,我们可以看这里:MDN
void 运算符通常只用于获取 undefined的原始值,一般使用void(0),因为undefined不是保留字,在低版本浏览器或者局部作用域中是可以被当做变量赋值的,这样就会导致我们拿不到正确的undefined值,在很多压缩工具中都是将undefined用void 0来代替掉了。
其实这里不仅是void 0可以拿到undefined,还有其他很多方法也可以拿到,比如0["ygy"]、Object._undefined_、Object._ygy_,这些原理都是访问一个不存在的属性,所以最后一定会返回undefined

noConflict

也许有时候我们会碰到这样一种情况,_已经被当做一个变量声明了,我们引入underscore后会覆盖这个变量,但是又不想这个变量被覆盖,还好underscore提供了noConflict这个方法。

_.noConflict = function() {
    root._ = previousUnderscore;
    return this;
};
var underscore = _.noConflict();

显而易见,这里正常保留原来的_变量,并返回了underscore这个方法(this就是_方法)

_

接下来讲到了本文的重点,关于_方法的分析,在看源码之前,我们先熟悉一下_的用法。
这里总结的是我日常的用法,如果有遗漏,希望大家补充。
一种是直接调用_上的方法,比如_.map([1, 2, 3]),另一种是通过实例访问原型上的方法,比如_([1, 2, 3]).map(),这里和jQuery的用法很像,$.extend调用jQuery对象上的方法,而$("body").click()则是调用jQuery原型上的方法。

既然_可以使用原型上面的方法,那么说明执行_函数的时候肯定会返回一个实例。

这里来看源码:

// instanceof 运算符用来测试一个对象在其原型链中是否存在一个构造函数的 prototype 属性。
// 我这里有个不够准确但容易理解的说法,就是检查一个对象是否为另一个构造函数的实例,为了更容易理解,下面将全部以XXX是XXX的实例的方式来说。
var _ = function(obj) {
    // 如果obj是_的实例(这种情况我真的没碰到过)
    if (obj instanceof _) return obj;
    // 如果this不是_构造函数的实例,那就以obj为参数 new一个实例(相等于修改了_函数)
    if (!(this instanceof _)) return new _(obj);
    // 对应_([1,2,3])这种情况
    this._wrapped = obj;
  };

我先从源码上来解释,这里可以看出来_是一个构造函数,我们都知道,我既可以在构造函数上面增加方法,还可以在原型上面增加方法,前者只能通过构造函数本身访问到,后者由于原型链的存在,可以在构造函数的实例上面访问到。

var Person = function() {
    this.name = "ygy";
    this.age = 22;
}
Person.say = function() {
    console.log("hello")
}
Person.prototype.say = function() {
    console.log("world")
}
var ygy = new Person();
Person.say(); // hello
ygy.say(); // world

所以我们平时用的_.map就是Person.say()这种用法,而_([1, 2, 3]).map则是ygy.say()这种用法。

在继续讲这个之前,我们再来复习一下原型的知识,当我们new一个实例的时候到处发生了什么?

首先,这里会先创建一个空对象,这个空对象继承了构造函数的原型(或者理解为空对象上增加一个指向构造函数原型的指针_proto_),之后会根据实例传入的参数执行一遍构造函数,将构造函数内部的this绑定到这个新对象中,最后返回这个对象,过程和如下类似:

var ygy = {};
ygy.__proto__ = Person.prototype 
// 或者var ygy = Object.create(Person.prototype)
Person.call(ygy);

这样就很好理解了,要是想调用原型上面的方法,必须先new一个实例出来。我们再来分析_方法的源码:
_接收一个对象作为参数,如果这个对象是_的一个实例,那么直接返回这个对象。(这种情况我倒是没见过)

如果this不是_的实例,那么就会返回一个新的实例new _(obj),这个该怎么理解?
我们需要结合例子来看这句话,在_([1, 2, 3])中,obj肯定是指[1, 2, 3]这个数组,那么this是指什么呢?我觉得this是指window,不信你直接执行一下上面例子中的Person()?你会发现在全局作用域中是可以拿到name和age两个属性的。

那么既然this指向window,那么this肯定不是_的实例,所以this instanceof _必然会返回false,这样的话就会return一个new _([1, 2, 3]),所以_([1, 2, 3])就是new _([1, 2, 3]),从我们前面对new的解释来看,这个过程表现如下:

var obj = {}
obj.__proto__ = _.prototype
// 此时_函数中this的是new _(obj),this instanceof _是true,所以就不会重新return一个new _(obj),这样避免了循环调用
_.call(obj) // 实际上做了这一步: obj._wrapped = [1, 2, 3]

这样我们就理解了为什么_([1, 2, 3]).map中map是原型上的方法,因为_([1, 2, 3])是一个实例。

我这里再提供一个自己实现的_思路,和jQuery的实现类似,这里就不作解释了:

var _ = function(obj) {
    return new _.prototype.init(obj)
}
_.prototype = {
    init: function(obj) {
    	this.__wrapped = obj
    	return this
    },
    name: function(name) {
        console.log(name)
    }
}
_.prototype.init.prototype = _.prototype;
var a = _([1, 2, 3])
a.name("ygy"); // ygy

underscore中所有方法都是在_方法上面直接挂载的,并且用mixin方法将这些方法再一次挂载到了原型上面。不过,由于篇幅有限,mixin方法的实现会在后文中给大家讲解。
如果本文有错误和不足之处,希望大家指出。

Redux 原理:从零实现 redux

1. 前言

在前端圈子有这样一种说法,Vue 入门最简单,React 学习曲线太陡,Angular...我还是选择狗带吧。
在 React 诞生之初,Facebook 宣传这是一个用于前端开发的界面库,仅仅是一个 View 层。前面我们也介绍过 React 的组件通信,在大型应用中,处理好 React 组件通信和状态管理就显得非常重要。
为了解决这一问题,Facebook 最先提出了单向数据流的 Flux 架构,弥补了使用 React 开发大型网站的不足。

Flux:

image_1dvghf5eo1b121baocp1dbek119.png-10.5kB

随后,Dan Abramov 受到 Flux 和函数式编程语言 Elm 启发,开发了 Redux 这个状态管理库。
Redux 源码非常精简,实现也很巧妙,这篇文章将带你从零手写一个 Redux 和 react-redux 库,以及告诉你该如何设计 Redux 中的 store。
在开始前,我已经将这篇文章的完整代码都整理到 GitHub 上,大家可以参考一下。
Redux:simple-redux
React-redux:simple-react-redux

2. 状态管理

2.1 理解数据驱动

在开始讲解状态管理前,我们先来了解一下现代前端框架都做了些什么。
以 Vue 为例子,在刚开始的时候,Vue 官网首页写的卖点是数据驱动、组件化、MVVM 等等(现在首页已经改版了)。
那么数据驱动的意思是什么呢?不管是原生 JS 还是 jQuery,他们都是通过直接修改 DOM 的形式来实现页面刷新的。
而 Vue/React 之类的框架不是粗暴地直接修改 DOM,而是通过修改 data/state 中的数据,实现了组件的重新渲染。也就是说,他们封装了从数据变化到组件渲染这一个过程。

image_1e3c2ig3oj2m1d9d10m31ugf1tmo9.png-32.2kB

原本我们用 jQuery 开发应用,除了要实现业务逻辑,还要操作 DOM 来手动实现页面的更新。尤其是涉及到渲染列表的时候,更新起来非常麻烦。

var ul = document.getElementById("todo-list");
$.each(todos, function(index, todo) {
    var li = document.createElement('li');
    li.innerHTML = todo.content;
    li.dataset.id = todo.id;
    li.className = "todo-item";
    ul.appendChild(li);
})

所以后来出现了 jQuery.tpl 和 Underscore.template 之类的模板,这些让操作 DOM 变得容易起来,有了数据驱动和组件化的雏形,可惜我们还是要手动去渲染一遍。

<script type="text/template" id="tpl">
    <ul id="todo-list">
        <% _.each(todos, function(todo){ %>
            <li data-id="<%=todo.id%>" class="todo-item">
                <%= todo.content %>
            </li>
        <% }); %>
    </ul>
</script>

如果说用纯原生 JS 或者 jQuery 开发页面是原始农耕时代,那么 React/Vue 等现代化框架则是自动化的时代。
有了前端框架之后,我们不需要再去关注怎么生成和修改 DOM,只需要关心页面上的这些数据以及流动。
所以如何管理好这些数据流动就成了重中之重,这也是我们常说的“状态管理”。

2.2 什么状态需要管理?

前面讲了很多例子,可状态管理到底要管理什么呢?在我看来,状态管理的一般就是这两种数据。

  1. Domain State
    Domain State 就是服务端的状态,这个一般是指通过网络请求来从服务端获取到的数据,比如列表数据,通常是为了和服务端数据保持一致。
{
    "data": {
        "hotels": [
            {
                "id": "31231231",
                "name": "希尔顿",
                "price": "1300"
            }
        ]
    }
}
  1. UI State
    UI State 常常和交互相关。例如模态框的开关状态、页面的 loading 状态、单(多)选项的选中状态等等,这些状态常常分散在不同的组件里面。
{
    "isLoading": true,
    "isShowModal": false,
    "isSelected": false
}

2.3 全局状态管理

我们用 React 写组件的时候,如果需要涉及到兄弟组件通信,经常需要将状态提升到两者父组件里面。一旦这种组件通信多了起来,数据管理就是个问题。
结合上面的例子,如果想要对应用的数据流进行管理,那是不是可以将所有的状态放到顶层组件中呢?
将数据按照功能或者组件来划分,将多个组件共享的数据单独放置,这样就形成了一个大的树形 store。这里更建议按照功能来划分。

image_1e3c34ubd19i0edh191pdit1tp3m.png-34.9kB

这个大的 store 可以放到顶层组件中维护,也可以放到顶层组件之外来维护,这个顶层组件我们一般称之为“容器组件”。
容器组件可以将组件依赖的数据以及修改数据的方法一层层传给子组件。
我们可以将容器组件的 state 按照组件来划分,现在这个 state 就是整个应用的 store。将修改 state 的方法放到 actions 里面,按照和 state 一样的结构来组织,最后将其传入各自对应的子组件中。

class App extends Component {
    constructor(props) {
        this.state = {
            common: {},
            headerProps: {},
            bodyProps: {
                sidebarProps: {},
                cardProps: {},
                tableProps: {},
                modalProps: {}
            },
            footerProps: {}
        }
        this.actions = {
            header: {
                changeHeaderProps: this.changeHeaderProps
            },
            footer: {
                changeFooterProps: this.changeFooterProps
            },
            body: {
                sidebar: {
                    changeSiderbarProps: this.changeSiderbarProps
                }
            }
        }
    }
    
    changeHeaderProps(props) {
        this.setState({
            headerProps: props
        })
    }
    changeFooterProps() {}
    changeSiderbarProps() {}
    ...
    
    render() {
        const {
            headerProps,
            bodyProps,
            footerProps
        } = this.state;
        const {
            header,
            body,
            footer
        } = this.actions;
        return (
            <div className="main">
                <Header {...headerProps} {...header} />
                <Body {...bodyProps} {...body} />
                <Footer {...footerProps} {...footer} />
            </div>
        )
    }
}

我们可以看到,这种方式可以很完美地解决子组件之间的通信问题。只需要修改对应的 state 就行了,App 组件会在 state 变化后重新渲染,子组件接收新的 props 后也跟着渲染。

image_1e3c3kn7n17tg1q54g6j4op74k13.png-70.2kB

这种模式还可以继续做一些优化,比如结合 Context 来实现向深层子组件传递数据。

const Context = createContext(null);
class App extends Component {
    ...
    render() {
        return (
            <div className="main">
                <Context.Provider value={...this.state, ...this.events}>
                    <Header />
                    <Body />
                    <Footer />
                </Context.Provider>
            </div>
        )
    }
}
const Header = () => {
    // 获取到 Context 数据
    const context = useContext(Context);
}

如果你已经接触过 Redux 这个状态管理库,你会惊奇地发现,如果我们把 App 组件中的 state 移到外面,这不就是 Redux 了吗?
没错,Redux 的核心原理也是这样,在组件外部维护一个 store,在 store 修改的时候会通知所有被 connect 包裹的组件进行更新。这个例子可以看做 Redux 的一个雏形。

3. 实现一个 Redux

根据前面的介绍我们已经知道了,Redux 是一个状态管理库,它并非绑定于 React 使用,你还可以将其和其他框架甚至原生 JS 一起使用,比如这篇文章:如何在非 React 项目中使用 Redux

Redux 工作原理:

image_1e3bj67tdkstv46np1bgfcibm.png-40.8kB

在学习 Redux 之前需要先理解其工作原理,一般来说流程是这样的:

  1. 用户触发页面上的某种操作,通过 dispatch 发送一个 action。
  2. Redux 接收到这个 action 后通过 reducer 函数获取到下一个状态。
  3. 将新状态更新进 store,store 更新后通知页面重新渲染。

从这个流程中不难看出,Redux 的核心就是一个 发布-订阅 模式。一旦 store 发生了变化就会通知所有的订阅者,view 接收到通知之后会进行重新渲染。

Redux 有三大原则:

  • 单一数据源

    前面的那个例子,最终将所有的状态放到了顶层组件的 state 中,这个 state 形成了一棵状态树。在 Redux 中,这个 state 则是 store,一个应用中一般只有一个 store。

  • State 是只读的

    在 Redux 中,唯一改变 state 的方法是触发 action,action 描述了这次修改行为的相关信息。只允许通过 action 修改可以使应用中的每个状态修改都很清晰,便于后期的调试和回放。

  • 通过纯函数来修改

    为了描述 action 使状态如何修改,需要你编写 reducer 函数来修改状态。reducer 函数接收前一次的 state 和 action,返回新的 state。无论被调用多少次,只要传入相同的 state 和 action,那么就一定返回同样的结果。

关于 Redux 的用法,这里不做详细讲解,建议参考阮一峰老师的《Redux 入门》系列的教程:Redux 入门教程

3.1 实现 store

在 Redux 中,store 一般通过 createStore 来创建。

import { createStore } from 'redux'; 
const store = createStore(rootReducer, initalStore, middleware);

先看一下 Redux 中暴露出来的几个方法。

image_1e3bjfhpfplfnea198bj7lf5e13.png-40.9kB

其中 createStore 返回的方法主要有 subscribedispatchreplaceReducergetState

createStore 接收三个参数,分别是 reducers 函数、初始值 initalStore、中间件 middleware。

store 上挂载了 getStatedispatchsubscribe 三个方法。

getState 是获取到 store 的方法,可以通过 store.getState() 获取到 store

dispatch 是发送 action 的方法,它接收一个 action 对象,通知 store 去执行 reducer 函数。

subscribe 则是一个监听方法,它可以监听到 store 的变化,所以可以通过 subscribe 将 Redux 和其他框架结合起来。

replaceReducer 用来异步注入 reducer 的方法,可以传入新的 reducer 来代替当前的 reducer。

3.2 实现 getState

store 的实现原理比较简单,就是根据传入的初始值来创建一个对象。利用闭包的特性来保留这个 store,允许通过 getState 来获取到 store。
之所以通过 getState 来获取 store 是为了获取到当前 store 的快照,这样便于打印日志以对比前后两次 store 变化,方便调试。

const createStore = (reducers, initialState, enhancer) => {
    let store = initialState;
    const getState = () => store;
    return {
        getState
    }
}

当然,现在这个 store 实现的比较简单,毕竟 createStore 还有两个参数没用到呢。
先别急,这俩参数后面会用到的。

3.3 实现 subscribe && unsubscribe

既然 Redux 本质上是一个 发布-订阅 模式,那么就一定会有一个监听方法,类似 jQuery 中的 $.on,在 Redux 中提供了监听和解除监听的两个方法。
实现方式也比较简单,使用一个数组来保存所有监听的方法。

const createStore = (...) => {
    ...
    let listeners = [];
    const subscribe = (listener) => {
        listeners.push(listener);
    }
    const unsubscribe = (listener) => {
        const index = listeners.indexOf(listener)
        listeners.splice(index, 1)
    }
}

3.4 实现 dispatch

dispatch 和 action 是息息相关的,只有通过 dispatch 才能发送 action。而发送 action 之后才会执行 subscribe 监听到的那些方法。
所以 dispatch 做的事情就是将 action 传给 reducer 函数,将执行后的结果设置为新的 store,然后执行 listeners 中的方法。

const createStore = (reducers, initialState) => {
    ...
    let store = initialState;
    const dispatch = (action) => {
        store = reducers(store, action);
        listeners.forEach(listener => listener())
    }
}

这样就行了吗?当然还不够。如果有多个 action 同时发送,这样很难说清楚最后的 store 到底是什么样的,所以需要加锁。在 Redux 中 dispatch 执行后的返回值也是当前的 action。

const createStore = (reducers, initialState) => {
    ...
    let store = initialState;
    let isDispatch = false;
    const dispatch = (action) => {
        if (isDispatch) return action
        // dispatch必须一个个来
        isDispatch = true
        store = reducers(store, action);
        isDispatch = false
        listeners.forEach(listener => listener())
        return action;
    }
}

至此为止,Redux 工作流程的原理就已经实现了。但你可能还会有很多疑问,如果没有传 initialState,那么 store 的默认值是什么呢?如果传入了中间件,那么又是什么工作原理呢?

3.5 实现 combineReducers

在刚开始接触 Redux 的 store 的时候,我们都会有一种疑问,store 的结构究竟是怎么定的?combineReducers 会揭开这个谜底。
现在来分析 createStore 接收的第一个参数,这个参数有两种形式,一种直接是一个 reducer 函数,另一个是用 combineReducers 把多个 reducer 函数合并到一起。

image_1e3c3pvnlknf7f5139n1fuqu7j1g.png-48.3kB

可以猜测 combineReducers 是一个高阶函数,接收一个对象作为参数,返回了一个新的函数。这个新的函数应当和普通的 reducer 函数传参保持一致。

const combineReducers = (reducers) => {
    return function combination(state = {}, action) {
    }
}

那么 combineReducers 做了什么工作呢?主要是下面几步:

  1. 收集所有传入的 reducer 函数
  2. 在 dispatch 中执行 combination 函数时,遍历执行所有 reducer 函数。如果某个 reducer 函数返回了新的 state,那么 combination 就返回这个 state,否则就返回传入的 state。
const combineReducers = reducers => {
    const finalReducers = {},
        nativeKeys = Object.keys
    // 收集所有的 reducer 函数
    nativeKeys(reducers).forEach(reducerKey => {
        if(typeof reducers[reducerKey] === "function") {
            finalReducers[reducerKey] = reducers[reducerKey]
        }
    })
    return function combination(state, action) {
        let hasChanged = false;
        const store = {};
        // 遍历执行 reducer 函数
        nativeKeys(finalReducers).forEach(key => {
            const reducer = finalReducers[key];
            // 很明显,store 的 key 来源于 reducers 的 key 值
            const nextState = reducer(state[key], action)
            store[key] = nextState
            hasChanged = hasChanged || nextState !== state[key];
        })
        return hasChanged ? nextState : state;
    }
}

细心的童鞋一定会发现,每次调用 dispatch 都会执行这个 combination 的话,那岂不是不管我发送什么类型的 action,所有的 reducer 函数都会被执行一遍?
如果 reducer 函数很多,那这个执行效率不会很低吗?但不执行貌似又无法完全匹配到 switch...case 中的 action.type
如果能通过键值对的形式来匹配 action.type 和 reducer 是不是效率更高一些?类似这样:

// redux
const count = (state = 0, action) => {
    switch(action.type) {
        case 'increment':
            return state + action.payload;
        case 'decrement':
            return state - action.payload;
        default:
            return state;
    }
}
// 改进后的
const count = {
    state: 0, // 初始 state
    reducers: {
        increment: (state, payload) => state + payload,
        decrement: (state, payload) => state - payload
    }
}

这样每次发送新的 action 的时候,可以直接用 reducers 下面的 key 值来匹配了,无需进行暴力的遍历。
天啊,你实在太聪明了。小声告诉你,社区中一些类 Redux 的方案就是这样做的。以 rematch 和 relite 为例:
rematch:

import { init, dispatch } from "@rematch/core";
import delay from "./makeMeWait";

const count = {
  state: 0,
  reducers: {
    increment: (state, payload) => state + payload,
    decrement: (state, payload) => state - payload
  },
  effects: {
    async incrementAsync(payload) {
      await delay();
      this.increment(payload);
    }
  }
};

const store = init({
  models: { count }
});

dispatch.count.incrementAsync(1);

relite:

const increment = (state, payload) => {
    state.count = state.count + payload;
    return state;
}
const decrement = (state, payload) => {
    state.count = state.count - payload;
    return state;
}

3.6 中间件 和 Store Enhancer

考虑到这样的情况,我想要打印每次 action 的相关信息以及 store 前后的变化,那我只能到每个 dispatch 处手动打印信息,这样繁琐且重复。
createStore 中提供的第三个参数,可以实现对 dispatch 函数的增强,我们称之为 Store Enhancer
Store Enhancer 是一个高阶函数,它的结构一般是这样的:

const enhancer = () => {
    return (createStore) => (reducer, initState, enhancer) => {
        ...
    }
}

enhancer 接收 createStore 作为参数,最后返回的是一个加强版的 store,本质上是对 dispatch 函数进行了扩展。
logger:

const logger = () => {
    return (createStore) => (reducer, initState, enhancer) => {
        const store = createStore(reducer, initState, enhancer);
        const dispatch = (action) => {
            console.log(`action=${JSON.stringify(action)}`);
            const result = store.dispatch(action);
            const state = store.getState();
            console.log(`state=${JSON.stringify(state)}`);
            return result;
        }
        return {
            ...state,
            dispatch
        }
    }
}

createStore 中如何使用呢?一般在参数的时候,会直接返回。

const createStore = (reducer, initialState, enhancer) => {
    if (enhancer && typeof enhancer === "function") {
        return enhancer(createStore)(reducer, initialState)
    }
}

如果你有看过 applyMiddleware 的源码,会发现这两者实现方式很相似。applyMiddleware 本质上就是一个 Store Enhancer

3.7 实现 applyMiddleware

在创建 store 的时候,经常会使用很多中间件,通过 applyMiddleware 将多个中间件注入到 store 之中。

const store = createStore(reducers, initialStore, applyMiddleware(thunk, logger, reselect));

applyMiddleware 的实现类似上面的 Store Enhancer。由于多个中间件可以串行使用,因此最终会像洋葱模型一样,action 传递需要经过一个个中间件处理,所以中间件做的事情就是增强 dispatch 的能力,将 action 传递给下一个中间件。
那么关键就是将新的 store 和 dispatch 函数传给下一个中间件。

image_1e3c4b74o17qbbkg1l10e7t1p8h1t.png-15.1kB

来看一下 applyMiddleware 的源码实现:

const applyMiddleware = (...middlewares) => {
    return (createStore) => (reducer, initState, enhancer) => {
        const store = createStore(reducer, initState, enhancer)
        const middlewareAPI = {
            getState: store.getState,
            dispatch: (action) => dispatch(action)
        }
        let chain = middlewares.map(middleware => middleware(middlewareAPI))
        store.dispatch = compose(...chain)(store.dispatch)
        return {
          ...store,
          dispatch
        }
      }
}

这里用到了一个 compose 函数,compose 函数类似管道,可以将多个函数组合起来。compose(m1, m2)(dispatch) 等价于 m1(m2(dispatch))
使用 reduce 函数可以实现函数组合。

const compose = (...funcs) => {
    if (!funcs) {
        return args => args
    }
    if (funcs.length === 1) {
        return funcs[0]
    }
    return funcs.reduce((f1, f2) => (...args) => f1(f2(...args)))
}

再来看一下 redux-logger 中间件的精简实现,会发现两者恰好能匹配到一起。

function logger(middlewareAPI) {
  return function (next) { // next 即 dispatch
    return function (action) {
      console.log('dispatch 前:', middlewareAPI.getState());
      var returnValue = next(action);
      console.log('dispatch 后:', middlewareAPI.getState(), '\n');
      return returnValue;
    };
  };
}

至此为止,Redux 的基本原理就很清晰了,最后整理一个精简版的 Redux 源码实现。

// 这里需要对参数为0或1的情况进行判断
const compose = (...funcs) => {
    if (!funcs) {
        return args => args
    }
    if (funcs.length === 1) {
        return funcs[0]
    }
    return funcs.reduce((f1, f2) => (...args) => f1(f2(...args)))
}

const bindActionCreator = (action, dispatch) => {
    return (...args) => dispatch(action(...args))
}

const createStore = (reducer, initState, enhancer) => {
    if (!enhancer && typeof initState === "function") {
        enhancer = initState
        initState = null
    }
    if (enhancer && typeof enhancer === "function") {
        return enhancer(createStore)(reducer, initState)
    }
    let store = initState, 
        listeners = [],
        isDispatch = false;
    const getState = () => store
    const dispatch = (action) => {
        if (isDispatch) return action
        // dispatch必须一个个来
        isDispatch = true
        store = reducer(store, action)
        isDispatch = false
        listeners.forEach(listener => listener())
        return action
    }
    const subscribe = (listener) => {
        if (typeof listener === "function") {
            listeners.push(listener)
        }
        return () => unsubscribe(listener)
    }
    const unsubscribe = (listener) => {
        const index = listeners.indexOf(listener)
        listeners.splice(index, 1)
    }
    return {
        getState,
        dispatch,
        subscribe,
        unsubscribe
    }
}

const applyMiddleware = (...middlewares) => {
    return (createStore) => (reducer, initState, enhancer) => {
        const store = createStore(reducer, initState, enhancer);
        const middlewareAPI = {
            getState: store.getState,
            dispatch: (action) => dispatch(action)
        }
        let chain = middlewares.map(middleware => middleware(middlewareAPI))
        store.dispatch = compose(...chain)(store.dispatch)
        return {
          ...store
        }
      }
}

const combineReducers = reducers => {
    const finalReducers = {},
        nativeKeys = Object.keys
    nativeKeys(reducers).forEach(reducerKey => {
        if(typeof reducers[reducerKey] === "function") {
            finalReducers[reducerKey] = reducers[reducerKey]
        }
    })
    return (state, action) => {
        const store = {}
        nativeKeys(finalReducers).forEach(key => {
            const reducer = finalReducers[key]
            const nextState = reducer(state[key], action)
            store[key] = nextState
        })
        return store
    }
}

4. 实现一个 react-redux

如果想要将 Redux 结合 React 使用的话,通常可以使用 react-redux 这个库。
看过前面 Redux 的原理后,相信你也知道 react-redux 是如何实现的了吧。
react-redux 一共提供了两个 API,分别是 connect 和 Provider,前者是一个 React 高阶组件,后者是一个普通的 React 组件。react-redux 实现了一个简单的发布-订阅库,来监听当前 store 的变化。
两者的作用如下:

  1. Provider:将 store 通过 Context 传给后代组件,注册对 store 的监听。
  2. connect:一旦 store 变化就会执行 mapStateToProps 和 mapDispatchToProps 获取最新的 props 后,将其传给子组件。

image_1e3bk2sig1tt81eid19d61h4koso1t.png-36.9kB

使用方式:

// Provider
ReactDOM.render({
    <Provider store={store}></Provider>,
    document.getElementById('app')
})
// connect
@connect(mapStateToProps, mapDispatchToProps)
class App extends Component {}

4.1 实现 Provider

先来实现简单的 Provider,已知 Provider 会使用 Context 来传递 store,所以 Provider 直接通过 Context.Provider 将 store 给子组件。

// Context.js
const ReactReduxContext = createContext(null);

// Provider.js
const Provider = ({ store, children }) => {
    return (
        <ReactReduxContext.Provider value={store}>
            {children}
        </ReactReduxContext.Provider>
    )
}

Provider 里面还需要一个发布-订阅器

class Subscription {
    constructor(store) {
        this.store = store;
        this.listeners = [this.handleChangeWrapper];
    }
    notify = () => {
        this.listeners.forEach(listener => {
            listener()
        });
    }
    addListener(listener) {
        this.listeners.push(listener);
    }
    // 监听 store
    trySubscribe() {
        this.unsubscribe = this.store.subscribe(this.notify);
    }
    // onStateChange 需要在组件中设置
    handleChangeWrapper = () => {
        if (this.onStateChange) {
          this.onStateChange()
        }
    }
    unsubscribe() {
        this.listeners = null;
        this.unsubscribe();
    }
}

将 Provider 和 Subscription 结合到一起,在 useEffect 里面注册监听。

// Provider.js
const Provider = ({ store, children }) => {
    const contextValue = useMemo(() => {
        const subscription = new Subscription(store);
        return {
            store,
            subscription
        }
    }, [store]);
    // 监听 store 变化
    useEffect(() => {
        const { subscription } = contextValue;
        subscription.trySubscribe();
        return () => {
            subscription.unsubscribe();
        }
    }, [contextValue]);
    return (
        <ReactReduxContext.Provider value={contextValue}>
            {children}
        </ReactReduxContext.Provider>
    )
}

4.2 实现 connect

再来看 connect 的实现,这里主要有三步:

  1. 使用 useContext 获取到传入的 store 和 subscription。
  2. 对 subscription 添加一个 listener,这个 listener 的作用就是一旦 store 变化就重新渲染组件。
  3. store 变化之后,执行 mapStateToProps 和 mapDispatchToProps 两个函数,将其和传入的 props 进行合并,最终传给 WrappedComponent。

image_1e3c4uqjk1gvc2n148g24q1tgk2a.png-52.3kB

先来实现简单的获取 Context。

const connect = (mapStateToProps, mapDispatchToProps) => (WrappedComponent) => {
    return function Connect(props) {
        const { store, subscription } = useContext(ReactReduxContext);
        return <WrappedComponent {...props} />
    }
}

接下来就要来实现如何在 store 变化的时候更新这个组件。
我们都知道在 React 中想实现更新组件只有手动设置 state 和调用 forceUpdate 两种方法,这里使用 useState 每次设置一个 count 来触发更新。

const connect = (mapStateToProps, mapDispatchToProps) => {
    return (WrappedComponent) => {
        return (props) => {
            const { store, subscription } = useContext(ReactReduxContext);
            const [count, setCount] = useState(0)
            useEffect(() => {
                subscription.onStateChange = () => setCount(count + 1)
            }, [count])
            const newProps = useMemo(() => {
                const stateProps = mapStateToProps(store.getState()),
                    dispatchProps = mapDispatchToProps(store.dispatch);
                return {
                    ...stateProps,
                    ...dispatchProps,
                    ...props
                }
            }, [props, store, count])
            return <WrappedComponent {...newProps} />
        }
    }
}

react-redux 的原理和上面比较类似,这里只作为学习原理的一个例子,不建议用到生产环境中。

5. 如何设计 store

在开发中,如果想要查看当前页面的 store 结构,可以使用 Redux-DevTools 或者 React Developer Tools 这两个 chrome 插件来查看。
前者一般用于开发环境中,可以将 store 及其变化可视化展示出来。后者主要用于 React,也可以查看 store。
关于 Redux 中 store 如何设计对初学者来说一直都是难题,在我看来这不仅是 Redux 的问题,在任何前端 store 设计中应该都是一样的。

5.1 store 设计误区

这里以知乎的问题页 store 设计为例。在开始之前,先安装 React Developer Tools,在 RDT 的 Tab 选中根节点。

image_1dvtb7mgm1d8rrsi3kfuh3lrpm.png-107.3kB

然后在 Console 里面输入 $r.state.store.getState(),将 store 打印出来。

image_1dvt9vggc1kntcuh18uq4fd1bn99.png-276.9kB

可以看到 store 中有一个 entities 属性,这个属性中分别有 users、questions、answer 等等。

这是一个问题页,自然包括问题、回答、回答下面的评论 等等。

image_1dvtbehkr1cbrivcm1splb1ttv13.png-147.9kB

一般情况下,这里应该是当进入页面的时候,根据 question_id 来分批从后端获取到所有的回答。点开评论的时候,会根据 answer_id 来分批从后端获取到所有的评论。
所以你可能会想到 store 结构应当这样设计,就像俄罗斯套娃一样,一层套着一套。

{
    questions: [
        {
            content: 'LOL中哪个英雄最能表达出你对刺客的想象?',
            question_id: '1',
            answers: [
                {   
                    answer_id: '1-1',
                    content: '我就是来提名一个已经式微的英雄的。没错,就是提莫队长...'
                    comments: [
                        {  
                            comment_id: '1-1-1',
                            content: '言语精炼,每一句话都是一幅画面,一组镜头'
                        }
                    ]
                }
            ]
        }
    ]
}

看图可以更直观感受数据结构:

image_1e3bjm5kc1hl11d7r1onc10d01c9d1g.png-25.3kB

这是初学者经常进入的一个误区,按照 API 来设计 store 结构,这种方法是错误的。
以评论区回复为例子,如何将评论和回复的评论关联起来呢?也许你会想,把回复的评论当做评论的子评论不就行了吗?

{
    comments: [
        {
            comment_id: '1-1-1',
            content: '言语精炼,每一句话都是一幅画面,一组镜头',
            children: [
                {
                    comment_id: '1-1-2',
                    content: '我感觉是好多画面,一部电影。。。'
                }
            ]
        },
        {
            comment_id: '1-1-2',
            content: '我感觉是好多画面,一部电影。。。'
        }
    ]
}

这样挺好的,满足了我们的需求,但 children 中的评论和 comments 中的评论数据亢余了。

5.2 扁平化 store

聪明的你一定会想到,如果 children 中只保存 comment_id 不就好了吗?展示的时候只要根据 comment_id 从 comments 中查询就行了。
这就是设计 store 的精髓所在了。我们可以将 store 当做一个数据库,store 中的状态按照领域(domain)来划分成一张张数据表。不同的数据表之间以主键来关联。
因此上面的 store 可以设计成三张表,分别是 questions、answers、comments,以它们的 id 作为 key,增加一个新的字段来关联子级。

{
    questions: {
        '1': {
            id: '1',
            content: 'LOL中哪个英雄最能表达出你对刺客的想象?',
            answers: ['1-1']
        }
    },
    answers: {
        '1-1': {
            id: '1-1',
            content: '我就是来提名一个已经式微的英雄的。没错,就是提莫队长...',
            comments: ['1-1-1', '1-1-2']
        }
    },
    comments: {
        '1-1-1': {
            id: '1-1-1',
            content: '言语精炼,每一句话都是一幅画面,一组镜头',
            children: ['1-1-2']
        },
        '1-1-2': {
            id: '1-1-2',
            content: '我感觉是好多画面,一部电影。。。'
        }
    }
}

你会发现数据结构变得非常扁平化,避免了数据亢余以及嵌套过深的问题。在查找的时候也可以直接通过 id 来查找,避免了通过索引来查找某一具体项。

6. 推荐阅读

  1. 解析Twitter前端架构 学习复杂场景数据设计
  2. JSON数据范式化(normalizr)
  3. React+Redux打造“NEWS EARLY”单页应用

underscore查找索引函数分析

这是underscore源码剖析系列第七篇文章,这篇文章主要介绍underscore中查找数组中某一项索引或者对象中某个key的函数。

find

// find函数接受三个参数,分别是集合obj、检测函数predicate和上下文context
_.find = _.detect = function (obj, predicate, context) {
    var key;
    // 判断是否是类数组,如果是类数组就查找索引,是对象就查找key
    if (isArrayLike(obj)) {
    	key = _.findIndex(obj, predicate, context);
    } else {
    	key = _.findKey(obj, predicate, context);
    }
    // 如果查不到,findIndex会返回-1,而findKey会什么都不返回(默认undefined)
    // 所以这里对没有查找到的情况做了判断
    if (key !== void 0 && key !== -1) return obj[key];
};

findKey和findIndex方法的实现也都比较简单。

_.findKey = function (obj, predicate, context) {
	predicate = cb(predicate, context);
	var keys = _.keys(obj), key;
	for (var i = 0, length = keys.length; i < length; i++) {
		key = keys[i];
		// 直接return出key意味着只返回第一个通过predicate检测的值
		if (predicate(obj[key], key, obj)) return key;
	}
};
// 根据传入dir的正负来判断是findIndex还是findLastIndex
function createPredicateIndexFinder(dir) {
	return function (array, predicate, context) {
	    // cb中对predicate绑定作用域
		predicate = cb(predicate, context);
		var length = getLength(array);
        // 根据dir判断是从头遍历还是从尾遍历
		var index = dir > 0 ? 0 : length - 1;
		// 这里需要判断两个临界条件
		for (; index >= 0 && index < length; index += dir) {
			// 遍历数组,并将每一项和key都传到函数中进行运算,返回结果为true的index值
			if (predicate(array[index], index, array)) return index;
		}
		// 查找不到就返回-1
		return -1;
	};
}

// Returns the first index on an array-like that passes a predicate test
_.findIndex = createPredicateIndexFinder(1);
_.findLastIndex = createPredicateIndexFinder(-1);

sortedIndex二分查找

sortedIndex函数是返回一个值在数组中的位置(注意:这个值不一定在数组中)

var arr = [10, 20, 30, 40]
_.sortedIndex(arr, 25) // 返回2

由于sortedIndex是二分查找的实现,所以必须保证传入的数组是升序的(或者数组对象中按某属性升序排列)。
这里是维基百科对二分查找的定义:

在计算机科学中,二分搜索(英语:binary search),也称折半搜索(英语:half-interval
search)、对数搜索(英语:logarithmicsearch),是一种在有序数组中查找某一特定元素的搜索算法。搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。这种搜索算法每一次比较都使搜索范围缩小一半。

// 二分法,可以理解为以中间元素为基准,将数组分成两个
// 首先获取到数组中间的元素,比较传入的obj和中间元素的大小
// 如果obj大于这个元素,那么就说明这个obj应该是在数组右半段
// 反过来,就是在数组左半段
_.sortedIndex = function (array, obj, iteratee, context) {
    // 不理解cb函数的建议去看我之前的文章
	iteratee = cb(iteratee, context, 1);
	// obj有可能是一个对象,iteratee(obj)会返回当前obj中的某个值
	// 如果obj是{age: 20, name: "ygy"},iteratee是age,那么这个就是根据age来获取到index的
	var value = iteratee(obj);
	var low = 0, high = getLength(array);
	// 通过while循环来不断重复上述过程,直到找到obj的位置
	while (low < high) {
		var mid = Math.floor((low + high) / 2);
		if (iteratee(array[mid]) < value) low = mid + 1; else high = mid;
	}
	return low;
};

indexOf

indexOf函数接收array、value和isSorted三个参数。
indexOf返回value在该 array 中的索引值,如果value不存在 array中就返回-1。
使用原生的indexOf 函数,除非它失效。如果您正在使用一个大数组,你知道数组已经排序,传递true给isSorted将更快的用sortedIndex二分查找..,或者,传递一个数字作为第三个参数,为了在给定的索引的数组中寻找第一个匹配值。
lastIndexOf接收array、value和fromIndex三个参数。
lastIndexOf返回value在该 array 中的从最后开始的索引值,如果value不存在 array中就返回-1。如果支持原生的lastIndexOf,将使用原生的lastIndexOf函数。 传递fromIndex将从你给定的索性值开始搜索。
给下面返回值编上号,以便后面可以直接拿来讲。

var arr = [1, 2, 3, 2, 4, 5]
var index1 = _.indexOf(arr, 2) // 1
// 如果传了索引值为2,那就是从索引为2的地方开始搜索
// 如果不传第三个参数,可以理解为默认是从0开始搜索
var index2 = _.indexOf(arr, 2, 2) // 3
// 从索引为-1的地方查找意思就是从length-2的索引开始查找
var index3 = _.indexOf(arr, 2, -1)

// lastIndexOf是从数组末尾反向查询,返回第一个查询到的索引值
var index4 = _.lastIndexOf(arr, 2) // 3

// 如果传了索引值(比如下面的4),意味着将截取数组里面0-4的部分
// 将这部分当作新数组,从数组末尾反向查询,返回第一个查询到的索引值
// 当然这个查询到的索引值还是按照原来数组来看的,以下面这个为例,传入了4
// 意味着从1,2,3,2,4中反向查询2,查询到的第一个2在原arr数组中索引为3
//其实个人理解类似于如果我不传第三个参数,那就默认的是6(arr的长度)
var index5 = _.lastIndexOf(arr, 2, 4) // 3

// 这种情况下传入-1类似于传入5,因为如果不传第三个参数,其实类似于从index为6开始反向查的,传入负值就是从fromIndex+arr.length开始搜索
var index6 = _.lastIndexOf(arr, 2, -1)

图示
可能上面有点绕,这里我们再看一下源码:

_.indexOf = createIndexFinder(1, _.findIndex, _.sortedIndex);
_.lastIndexOf = createIndexFinder(-1, _.findLastIndex);
function createIndexFinder(dir, predicateFind, sortedIndex) {
    // idx有可能是布尔类型(isSorted)和数字类型
	return function (array, item, idx) {
		var i = 0, length = getLength(array);
		// 如果idx是数字类型,那就是从某个索引开始搜索
		if (typeof idx == 'number') {
		    // dir大于0的时候是从左到右查询(indexOf)
			if (dir > 0) {
				// 这里对idx进行了处理,如果idx为负数,那就从加上length的索引开始算
				// idx为负数对应上面的index3的情况
				i = idx >= 0 ? idx : Math.max(idx + length, i);
			// dir小于0的时候是从右到左查询(lastIndexOf)
			} else {
				// 从index开始反向查找,如果idx为正,那么不能超过数组长度
				// 如果idx为负,那就取idx+length+1(这里之所以+1是为了兼容后面for循环里面的-1,因为如果什么都不传的情况下,循环肯定是从0到length-1)
				length = idx >= 0 ? Math.min(idx + 1, length) : Math.max(idx, idx + length + 1);
			}
		// 如果idx为布尔类型,那么就使用sortedIndex二分查找
		} else if (sortedIndex && idx && length) {
			// 使用二分法来查找
			idx = sortedIndex(array, item);
			return array[idx] === item ? idx : -1;
		}
		// 如果item是NaN
		if (item !== item) {
		    // 数组里面有可能有NaN,因为NaN不和自身相等,所以这里传入了isNaN的方法来检测
		    // 其实这里面对array.slice(i, length)的每一项都用isNaN来检测了
			idx = predicateFind(slice.call(array, i, length), _.isNaN);
			// 这里之所以加上i,是因为predicateFind函数里面是根据array.slice(i, length)的长度来循环最终获取到index值,
			// 这样其实少算了一段长度为i的数组,这个可以去看createPredicateIndexFinder函数
			return idx >= 0 ? idx + i : -1;
		}
		// 根据上面计算出来的i和length来进行循环
		// 这里不用加上i是因为一开始就是从i或者length-1开始遍历的
		for (idx = dir > 0 ? i : length - 1; idx >= 0 && idx < length; idx += dir) {
			if (array[idx] === item) return idx;
		}
		return -1;
	};
}

原本indexOf只是个简单的方法,只不过这里面考虑到lastIndexOf可以传入负值以及indexOf使用二分查找优化才让人那么难懂。
这篇文章如果有什么错误和不足之处,希望大家可以指出,有疑惑也可以直接在评论区提出来。

深入理解 JavaScript Proxy

image_1e4411le1meo5qg1v8nllo7ov1g.png-1150.8kB

只听空相大声道:“请道长立即禀报张真人,事在紧急,片刻延缓不得!”那道人道:“大师来得不巧,敝师祖自去岁坐关,至今一年有余,本派弟子亦已久不见他老人家慈范。”

前言

在武侠小说中,经常看到这样的桥段。某位武林人士前来拜访德高望重的帮派掌门,往往需要经过手下弟子的通报。如果掌门外出或者不想见来人,就会让弟子婉拒。

今天要讲的 Proxy 和这个有异曲同工之妙。顾名思义,Proxy 的意思是代理,作用是为其他对象提供一种代理以控制对这个对象的访问。

本文会涉及到 ProxyReflectFunction扩展运算符 等知识,主要以实践为主,对语法不会进行详细地讲解,建议配合阮一峰的 ES6入门 中相关章节服用。

code.png-74.3kB

1. Proxy 提供了哪些拦截方式?

Proxy 一般是用来架设在目标对象之上的一层拦截,来实现对目标对象访问和修改的控制。Proxy 是一个构造函数,使用的时候需要配合 new 操作符,直接调用会报错。

image.png-21.9kB

Proxy 构造函数接收两个参数,第一个参数是需要拦截的目标对象,这个对象只可以是对象、数组或者函数;

第二个参数则是一个配置对象,提供了拦截方法。

const person = {
    name: 'tom'
}
// 如果第二个参数为空对象
const proxy = new Proxy(person, {});
proxy === person; // false

// 第二个参数不为空
const proxy = new Proxy(person, {
    get(target, prop) {
        console.log(`${prop} is ${target[prop]}`);
        return target[prop];
    }
})
proxy.name // 'name is tom'

Proxy 支持13种拦截操作,本文将会重点介绍其中四种。

image_1e3br0h5t2kvb5q5mo1r8t79c9.png-127.3kB

  1. get(target, prop, receiver):拦截对象属性的访问。
  2. set(target, prop, value, receiver):拦截对象属性的设置,最后返回一个布尔值。
  3. apply(target, object, args):用于拦截函数的调用,比如 proxy()
  4. construct(target, args):方法用于拦截 new 操作符,比如 new proxy()。为了使 new操作符在生成的Proxy对象上生效,用于初始化代理的目标对象自身必须具有 [[Construct]] 内部方法(即 new target 必须是有效的)。
  5. has(target, prop):拦截例如 prop in proxy的操作,返回一个布尔值。
  6. deleteProperty(target, prop):拦截例如 delete proxy[prop] 的操作,返回一个布尔值。
  7. ownKeys(target):拦截 Object.getOwnPropertyNames(proxy)Object.keys(proxy)for in 循环等等操作,最终会返回一个数组。
  8. getOwnPropertyDescriptor(target, prop):拦截 Object.getOwnPropertyDescriptor(proxy, propKey),返回属性的描述对象。
  9. defineProperty(target, propKey, propDesc):拦截 Object.defineProperty(proxy, propKey, propDesc)、Object.defineProperties(proxy, propDescs),返回一个布尔值。
  10. preventExtensions(target):拦截 Object.preventExtensions(proxy),返回一个布尔值。
  11. getPrototypeOf(target):拦截 Object.getPrototypeOf(proxy),返回一个对象。
  12. isExtensible(target):拦截 Object.isExtensible(proxy),返回一个布尔值。
  13. setPrototypeOf(target, proto):拦截 Object.setPrototypeOf(proxy, proto),返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。

2. Proxy vs Object.defineProperty

在 Proxy 出现之前,JavaScript 中就提供过 Object.defineProperty,允许对对象的 getter/setter 进行拦截,那么两者的区别在哪里呢?

image_1e442ld7g6legdo15362m1ke733.png-109.3kB

2.1 Object.defineProperty 不能监听所有属性

Object.defineProperty 无法一次性监听对象所有属性,必须遍历或者递归来实现。

   let girl = {
     name: "marry",
     age: 22
   }
   /* Proxy 监听整个对象*/
   girl = new Proxy(girl, {
     get() {}
     set() {}
   })
   /* Object.defineProperty */
   Object.keys(girl).forEach(key => {
     Object.defineProperty(girl, key, {
       set() {},
       get() {}
     })
   })

2.2 Object.defineProperty 无法监听新增加的属性

Proxy 可以监听到新增加的属性,而 Object.defineProperty 不可以,需要你手动再去做一次监听。因此,在 Vue 中想动态监听属性,一般用 Vue.set(girl, "hobby", "game") 这种形式来添加。

   let girl = {
     name: "marry",
     age: 22
   }
   /* Proxy 监听整个对象*/
   girl = new Proxy(girl, {
     get() {}
     set() {}
   })
   /* Object.defineProperty */
   Object.keys(girl).forEach(key => {
     Object.defineProperty(girl, key, {
       set() {},
       get() {}
     })
   });
   /* Proxy 生效,Object.defineProperty 不生效 */
   girl.hobby = "game"; 

2.3. Object.defineProperty 无法响应数组操作

Object.defineProperty 可以监听数组的变化,Object.defineProperty 无法对 pushshiftpopunshift 等方法进行响应。

   const arr = [1, 2, 3];
   /* Proxy 监听数组*/
   arr = new Proxy(arr, {
     get() {},
     set() {}
   })
   /* Object.defineProperty */
   arr.forEach((item, index) => {
     Object.defineProperty(arr, `${index}`, {
       set() {},
       get() {}
     })
   })
   
   arr[0] = 10; // 都生效
   arr[3] = 10; // 只有 Proxy 生效
   arr.push(10); // 只有 Proxy 生效

对于新增加的数组项,Object.defineProperty 依旧无法监听到。因此,在 Mobx 中为了监听数组的变化,默认将数组长度设置为1000,监听 0-999 的属性变化。

   /* mobx 的实现 */
   const arr = [1, 2, 3];
   /* Object.defineProperty */
   [...Array(1000)].forEach((item, index) => {
     Object.defineProperty(arr, `${index}`, {
       set() {},
       get() {}
     })
   });
   arr[3] = 10; // 生效
   arr[4] = 10; // 生效

如果想要监听到 pushshiftpopunshift 等方法,该怎么做呢?在 Vue 和 Mobx 中都是通过重写原型实现的。

在定义变量的时候,判断其是否为数组,如果是数组,那么就修改它的 __proto__,将其指向 subArrProto,从而实现重写原型链。

   const arrayProto = Array.prototype;
   const subArrProto = Object.create(arrayProto);
   const methods = ['pop', 'shift', 'unshift', 'sort', 'reverse', 'splice', 'push'];
   methods.forEach(method => {
     /* 重写原型方法 */
     subArrProto[method] = function() {
       arrayProto[method].call(this, ...arguments);
     };
     /* 监听这些方法 */
     Object.defineProperty(subArrProto, method, {
       set() {},
       get() {}
     })
   })

2.4 Proxy 拦截方式更多

Proxy 提供了13种拦截方法,包括拦截 constructorapplydeleteProperty 等等,而 Object.defineProperty 只有 getset

2.5. Object.defineProperty 兼容性更好

Proxy 是新出的 API,兼容性还不够好,不支持 IE 全系列。

3. 语法

3.1 get

get 方法用来拦截对目标对象属性的读取,它接收三个参数,分别是目标对象、属性名和 Proxy 实例本身。
基于 get 方法的特性,可以实现很多实用的功能,比如在对象里面设置私有属性(一般定义属性我们以 _ 开头表明是私有属性) ,实现禁止访问私有属性的功能。

const person = {
    name: 'tom',
    age: 20,
    _sex: 'male'
}
const proxy = new Proxy(person, {
    get(target, prop) {
        if (prop[0] === '_') {
            throw new Error(`${prop} is private attribute`);
        }
        return target[prop]
    }
})
proxy.name; // 'tom'
proxy._sex; // _sex is private attribute

还可以给对象中未定义的属性设置默认值。通过拦截对属性的访问,如果是 undefined,那就返回最开始设置的默认值。

let person = {
    name: 'tom',
    age: 20
}
const defaults = (obj, initial) => {
    return new Proxy(obj, {
        get(target, prop) {
            if (prop in target) {
                return target[prop]
            }
            return initial
        }
    })
}
person = defaults(person, 0);
person.name // 'tom'
person.sex // 0
person = defaults(person, null);
person.sex // null

3.2 set

set 方法可以拦截对属性的赋值操作,一般来说接收四个参数,分别是目标对象、属性名、属性值、Proxy 实例。
下面是一个 set 方法的用法,在对属性进行赋值的时候打印出当前状态。

const proxy = new Proxy({}, {
    set(target, key, value, receiver) {
        console.log(`${key} has been set to ${value}`);
        Reflect.set(target, key, value);
    }
})
proxy.name = 'tom'; // name has been setted ygy

第四个参数 receiver 则是指当前的 Proxy 实例,在下例中指代 proxy

const proxy = new Proxy({}, {
    set(target, key, value, receiver) {
        if (key === 'self') {
            Reflect.set(target, key, receiver);
        } else {
            Reflect.set(target, key, value);
        }
    }
})
proxy.self === proxy; // true

如果你写过表单验证,也许会被各种验证规则搞得很头疼。使用 Proxy 可以在填写表单的时候,拦截其中的字段进行格式校验。
通常来说,大家都会用一个对象来保存验证规则,这样会更容易对规则进行扩展。

// 验证规则
const validators = {
    name: {
        validate(value) {
            return value.length > 6;
        },
        message: '用户名长度不能小于六'
    },
    password: {
        validate(value) {
            return value.length > 10;
        },
        message: '密码长度不能小于十'
    },
    moblie: {
        validate(value) {
            return /^1(3|5|7|8|9)[0-9]{9}$/.test(value);
        },
        message: '手机号格式错误'
    }
}

然后编写验证方法,用 set 方法对 form 表单对象设置属性进行拦截,拦截的时候用上面的验证规则对属性值进行校验,如果校验失败,则弹窗提示。

// 验证方法
function validator(obj, validators) {
    return new Proxy(obj, {
        set(target, key, value) {
            const validator = validators[key]
            if (!validator) {
                target[key] = value;
            } else if (validator.validate(value)) {
                target[key] = value;
            } else {
                alert(validator.message || "");
            }
        }
    })
}
let form = {};
form = validator(form, validators);
form.name = '666'; // 用户名长度不能小于六
form.password = '113123123123123';

但是,如果这个属性已经设置为不可写,那么 set 将不会生效(但 set 方法依然会执行)。

const person = {
    name: 'tom'
}
Object.defineProperty(person, 'name', {
    writable: false
})
const proxy = new Proxy(person, {  
    set(target, key, value) {
        console.log(666)
        target[key] = 'jerry'
    }
})
proxy.name = '';

3.3. apply

apply 一般是用来拦截函数的调用,它接收三个参数,分别是目标对象、上下文对象(this)、参数数组。

function test() {
    console.log('this is a test function');
}
const func = new Proxy(test, {
    apply(target, context, args) {
        console.log('hello, world');
        target.apply(context, args);
    }
})
func();

通过 apply 方法可以获取到函数的执行次数,也可以打印出函数执行消耗的时间,常常可以用来做性能分析。

function log() {}
const func = new Proxy(log, {
    _count: 0,
    apply(target, context, args) {
        target.apply(context, args);
        console.log(`this function has been called ${++this._count} times`);
    }
})
func()

3.4. construct

construct 方法用来拦截 new 操作符。它接收三个参数,分别是目标对象、构造函数的参数列表、Proxy 对象,最后需要返回一个对象。
使用方式可以参考下面这么一个例子:

function Person(name, age) {
    this.name = name;
    this.age = age;
}
const P = new Proxy(Person, {
    construct(target, args, newTarget) {
        console.log('construct');
        return new target(...args);
    }
})
const p = new P('tom', 21); // 'construct'

我们知道,如果构造函数没有返回任何值或者返回了原始类型的值,那么默认返回的就是 this,如果返回的是一个引用类型的值,那么最终 new 出来的就是这个值。
因此,你可以代理一个空函数,然后返回一个新的对象。

function noop() {}
const Person = new Proxy(noop, {
    construct(target, args, newTarget) {
        return {
            name: args[0],
            age: args[1]
        }
    }
})
const person = new Person('tom', 21); // { name: 'tom', age: 21 }

4. Proxy 可以做哪些有意思的事情?

Proxy 的使用场景非常广泛,可以用来拦截对象的 set/get 从而实现数据响应。在 Vue3 和 Mobx5 中都使用了 Proxy 代替 Object.defineProperty。那么接下来就来看看 Proxy 都可以做哪些事情吧。

4.1 *操作:代理类

使用 construct 可以代理类,你可能会好奇,Proxy 不是只能代理 Object 类型吗?类该怎么代理呢?

image_1e440c3701ds37061o8goo316pk13.png-36kB

其实类的本质也是构造函数和原型(对象)组成的,完全可以对其进行代理。
考虑有这么一个需求,需要拦截对属性的访问,以及计算原型上函数的执行时间,这样该怎么去做就比较清晰了。可以对属性设置 get 拦截,对原型函数设置 apply 拦截。

先考虑对下面的 Person 类的原型函数进行拦截。使用 Object.getOwnPropertyNames 来获取原型上面所有的函数,遍历这些函数并对其使用 apply 拦截。

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
  say() {
    console.log(`my name is ${this.name}, and my age is ${this.age}`)
  }
}
const prototype = Person.prototype;
// 获取 prototype 上所有的属性名
Object.getOwnPropertyNames(prototype).forEach((name) => {
    Person.prototype[name] = new Proxy(prototype[name], {
        apply(target, context, args) {
            console.time();
            target.apply(context, args);
            console.timeEnd();
        }
    })
 })

拦截了原型函数后,开始考虑拦截对属性的访问。前面刚刚讲过 construct 方法的作用,那么是不是可以在 new 的时候对所有属性的访问设置拦截呢?
没错,由于 new 出来的实例也是个对象,那么完全可以对这个对象进行拦截。


new Proxy(Person, {
    // 拦截 construct 方法
    construct(target, args) {
        const obj = new target(...args);
        // 返回一个代理过的对象
        return new Proxy(obj, {
            get(target, prop) {
      		    console.log(`${target.name}.${prop} is being getting`);
      		    return target[prop]
    	    }
        })
    }
})       

所以,最后完整的代码如下:

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
  say() {
    console.log(`my name is ${this.name}, and my age is ${this.age}`)
  }
}
const proxyTrack = (targetClass) => {
  const prototype = targetClass.prototype;
  Object.getOwnPropertyNames(prototype).forEach((name) => {
        targetClass.prototype[name] = new Proxy(prototype[name], {
            apply(target, context, args) {
                console.time();
                target.apply(context, args);
                console.timeEnd();
            }
        })
  })
  
  return new Proxy(targetClass, {
    construct(target, args) {
      const obj = new target(...args);
      return new Proxy(obj, {
        get(target, prop) {
      		console.log(`${target.name}.${prop} is being getting`);
      		return target[prop]
    	}
      })
    }
  })       
}

const MyClass = proxyTrack(Person);
const myClass = new MyClass('tom', 21);
myClass.say();
myClass.name;

4.2 等不及可选链:深层取值(get

平时取数据的时候,经常会遇到深层数据结构,如果不做任何处理,很容易造成 JS 报错。
为了避免这个问题,也许你会用多个 && 进行处理:

const country = {
    name: 'china',
    province: {
        name: 'guangdong',
        city: {
            name: 'shenzhen'
        }
    }
}
const cityName = country.province
    && country.province.city
    && country.province.city.name;

但这样还是过于繁琐了,于是 Lodash 提供了 get 方法帮处理这个问题:

_.get(country, 'province.city.name');

虽然看起来似乎还不错,但总觉得哪里不太对(好是好,就是太丑了)。
最新的 ES 提案中提供了可选链的语法糖,支持我们用下面的语法来深层取值。

country?.province?.city?.name

但是这个特性只是处于 stage3 阶段,还没有被正式纳入 ES 规范中,更没有浏览器已经支持了这个特性。
所以,我们只能另辟蹊径。这时你可能会想到如果使用 Proxy 的 get 方法拦截对属性的访问,这样是不是就可以实现深层取值了呢?

code.png-92.3kB

接下来,我将会带着你一步步实现下面的这个 get 方法。

const obj = {
    person: {}
}
// 预期结果(这里为什么要当做函数执行呢?)
get(obj)() === obj;
get(obj).person(); // {}
get(obj).person.name(); // undefined
get(obj).person.name.xxx.yyy.zzz(); // undefined

首先,创建一个 get 方法,使用 Proxy 中的 get 对传入的对象进行拦截。

function get (obj) {
    return new Proxy(obj, {
        get(target, prop) {
            return target[prop];
        }
    })
}

来运行一下上面的三个例子,看一下结果如何:

get(obj).person; // {}
get(obj).person.name; // undefined
get(obj).person.name.xxx.yyy.zzz; // Cannot read property 'xxx' of undefined

前两个测试用例是成功了,但第三个还是不行,因为 get(obj).person.nameundefined,所以接下来的重点是处理属性为 undefined 的情况。
对这个 get 方法进行一下简单的改造,这次不再直接返回 target[prop],而是返回一个代理对象,让第三个例子不再报错。

function get (obj) {
    return new Proxy(obj, {
        get(target, prop) {
            return get(target[prop]);
        }
    })
}

嗯,看起来有点儿高大上了,但是 target[prop]undefined 的时候,传给 get 方法的就是 undefined 了,而 Proxy 第一个参数必须为对象,这样岂不是会报错?
所以,需要对 objundefined 的时候进行特殊处理,为了能够深层取值,只能对值为 undefined 的属性设置默认值为空对象。

function get (obj = {}) {
    return new Proxy(obj, {
        get(target, prop) {
            return get(target[prop]);
        }
    })
}
get(obj).person; // {}
get(obj).person.name; // {}
get(obj).person.name.xxx.yyy.zzz; // {}

虽然不报错了,可是后两个返回值却不对了。不设置默认值为空对象就无法继续访问,设置默认值为空对象就会改变返回值。这可该怎么办呢?
仔细看一下上面的预期设计,是不是发现少了一个括号,这就是为什么每个属性都被当做函数来执行。
所以需要对这个函数稍加修改,让其支持 apply 拦截的方式。

function noop() {}
function get (obj) {
    // 注意这里拦截的是 noop 函数
    return new Proxy(noop, {
        // 这里支持返回执行的时候传入的参数
        apply(target, context, [arg]) {
            return obj;
        },
        get(target, prop) {
            return get(obj[prop]);
        }
    })
}

所以这个 get 方法已经可以这样使用了。

get(obj)() === obj; // true
get(obj).person.name(); // undefined
get(obj).person.name.xxx.yyy.zzz(); // Cannot read property 'xxx' of undefined

我们理想中的应该是,如果属性为 undefined 就返回 undefined,但仍要支持访问下级属性,而不是抛出错误。顺着这个思路来的话,很明显当属性为 undefined 的时候也需要用 Proxy 进行特殊处理。
所以我们需要一个具有下面特性的 get 方法:

get(undefined)() === undefined; // true
get(undefined).xxx.yyy.zzz() // undefined

和前面的困扰不一样的地方是,这里完全不需要注意 get(undefined).xxx 是否为正确的值,因为想获取值必须要执行才能拿到。那么只需要对所有 undefined 后面访问的属性都默认为 undefined 就好了。

function noop() {}
function get (obj) {
    if (obj === undefined) {
        return proxyVoid;
    }
    // 注意这里拦截的是 noop 函数
    return new Proxy(noop, {
        // 这里支持返回执行的时候传入的参数
        apply(target, context, [arg]) {
            return obj === undefined ? arg : obj;
        },
        get(target, prop) {
            if (
                obj !== undefined &&
                obj !== null &&
                obj.hasOwnProperty(prop)
            ) {
                return get(obj[prop]);
            }
            return proxyVoid;
        }
    })
}

接下来思考一下这个 proxyVoid 函数该如何实现呢?很明显它应该是一个代理了 undefined 后返回的对象。直接这样好不好?

const proxyVoid = get(undefined);

但是这样很明显会造成死循环了,那么就需要判断临界值了,让 get 方法第一次接收 undefined 的时候不会死循环。

let isFirst = true;
function noop() {}
let proxyVoid = get(undefined);
function get(obj) {
    if (obj === undefined && !isFirst) {
        return proxyVoid;
    }
    if (obj === undefined && isFirst) {
        isFirst = false;
    }
    // 注意这里拦截的是 noop 函数
    return new Proxy(noop, {
        // 这里支持返回执行的时候传入的参数
        apply(target, context, [arg]) {
            return obj === undefined ? arg : obj;
        },
        get(target, prop) {
            if (
                obj !== undefined &&
                obj !== null &&
                obj.hasOwnProperty(prop)
            ) {
                return get(obj[prop]);
            }
            return proxyVoid;
        }
    })
}

我们再来验证一下,这种方式是否可行:

get(obj)() === obj; // true
get(obj).person.name(); // undefined
get(obj).person.name.xxx.yyy.zzz(); // undefined

bingo,这个方法完全实现了我们的需求。最后,完整的代码如下:

    let isFirst = true;
    function noop() {}
    let proxyVoid = get(undefined);
    function get(obj) {
        if (obj === undefined) {
            if (!isFirst) {
                return proxyVoid;
            }
            isFirst = false;
        }
        // 注意这里拦截的是 noop 函数
        return new Proxy(noop, {
            // 这里支持返回执行的时候传入的参数
            apply(target, context, [arg]) {
                return obj === undefined ? arg : obj;
            },
            get(target, prop) {
                if (
                    obj !== undefined &&
                    obj !== null &&
                    obj.hasOwnProperty(prop)
                ) {
                    return get(obj[prop]);
                }
                return proxyVoid;
            }
        })
    }
    this.get = get;

这个基于 Proxy 的 get 方法的灵感来自于 Github 上的一个名为 safe-touch 的库,感兴趣的可以去看一下它的源码实现:safe-touch

4.3 管道

在最新的 ECMA 提案中,出现了原生的管道操作符 |>,在 RxJS 和 NodeJS 中都有类似的 pipe 概念。

image_1e445iuci168nj1m1stvkbm1jhg4a.png-13.7kB

使用 Proxy 也可以实现 pipe 功能,只要使用 get 对属性访问进行拦截就能轻易实现,将访问的方法都放到 stack 数组里面,一旦最后访问了 execute 就返回结果。

const pipe = (value) => {
    const stack = [];
    const proxy = new Proxy({}, {
        get(target, prop) {
            if (prop === 'execute') {
                return stack.reduce(function (val, fn) {
                    return fn(val);
                }, value);
            }
            stack.push(window[prop]);
            return proxy;
        }
    })
    return proxy;
}
var double = n => n * 2;
var pow = n => n * n;
pipe(3).double.pow.execute;

注意:这里为了在 stack 存入方法,使用了 window[prop] 的形式,是为了获取到对应的方法。也可以将 doublepow 方法挂载到一个对象里面,用这个对象替换 window

周报(2018-12-07)

这周正式开始做IBU项目,虽然之前用mobx和typescript写过demo,但毕竟没有在项目中用过。

关于RN

由于我们这边是CSS和JS分岗的形式,所以RN的样式也是由CSS组写好组件一起给我们,这和以前开发H5,他们直接给我们coding稿很不一样。
以前我们可以在chrome里面查看coding稿的html结构和样式,可以自己去封装react组件。
现在都是他们封装了组件,但是由于CSS组不是专门写JS的,他们封装的组件会有一些不合理,其次我们无法看到页面的整体结构,没法很容易地知道某个组件对应了页面上哪一部分,这样极大的增加了工作量和沟通成本。

关于TypeScript

做IBU之前打算上TypeScript,我只是觉得很新鲜,一直想学新技术。在研究了一段时间后发现这个东西对代码的日后维护和重构有很大帮助,增加了静态类型,但又允许你使用any,保证类型检查的同时又保证了灵活度。
除了类型之外,我觉得TypeScript在一定程度上改变了我的编程思维。老实说,我以前写的代码既不是面向对象,又不是函数式,最多只能叫面向过程,我也不懂为什么要用面向对象。
因为这期用到了Mobx和TypeScript,我才思考面向对象的意义和重要性,因此我也去接触了不少的设计模式,遇到老代码里面的某些场景,我会思考这个适合哪种设计模式,我可以来重构和解耦。
由于TypeScript的语法和c#、java比较像,这也让我可以去阅读一些java相关的文章,以前看不懂那些语法,现在可以大致看懂语法后理解一些后端编程的**,并且运用到前端上面。

关于FSM

今天在做酒店列表页下拉加载的时候,突发奇想,酒店列表页下拉加载的时候一共有loading、fail、finish三种状态,其中对应关系只有loading -> fail,fail -> loading,loading -> finish,这不就是很符合状态机的**吗?
于是我想起了javascript-state-machine这个库,后来翻阅了一下github上的用法,但是发现很难和Mobx结合到一起,与其舍近求远强行用上这个库,还不如不用。
后来我想了想,既然不用这个库,但是这个**还是很值得借鉴的,我完全可以基于自己的业务封装一下这个功能,但是暂时也没有发现这个解决了什么问题,价值还是待定。

总结

总之,这周虽然写的进度很慢,目前只把页面store和组件设计好,但是也学到了不少的东西。与其低质量的赶工,我更愿意花更多时间来高质量的完成任务。

画一朵樱花

樱花

这是基于 React + Canvas 画的一朵樱花。

canvas

首先需要了解一些 canvas 的概念。使用 <canvas></canvas> 会创建一块画布,我们可以在这个上面绘制内容。

var canvas = document.getElementById('tutorial');
//获得 2d 上下文对象
var ctx = canvas.getContext('2d');

绘制路径

一般来说,canvas 创建的画布以左上角作为原点(0, 0)。

我们使用 beginPath 来创建一条路径。然后用 moveTo 移动到起始点坐标,用 closePath 闭合路径。

可以使用 stroke 来绘制图形轮廓,用 fill 来绘制填充内容。

function draw(){
    var canvas = document.getElementById('tutorial');
    if (!canvas.getContext) return;
    var ctx = canvas.getContext("2d");
    ctx.beginPath(); //新建一条path
    ctx.moveTo(50, 50); //把画笔移动到指定的坐标
    ctx.lineTo(200, 50);  //绘制一条从当前位置到指定坐标(200, 50)的直线.
    //闭合路径。会拉一条从当前点到path起始点的直线。如果当前点与起始点重合,则什么都不做
    ctx.closePath();
    ctx.stroke(); //绘制路径。
}
draw();

绘制圆形

可以通过 arc 来绘制一个圆形,它接受四个参数,分别是圆形坐标、半径、开始弧度、结束弧度、顺逆时针。
Math.PI 就是数学上的圆周率π,一般是 3.1415926...

function draw(){
    var canvas = document.getElementById('tutorial');
    if (!canvas.getContext) return;
    var ctx = canvas.getContext("2d");
    ctx.beginPath();
    ctx.arc(50, 50, 40, 0, Math.PI * 2, false);
    ctx.stroke();
}
draw();

弧度

弧度(rad)是数学上面的概念,一般是指从圆心拉了两条半径,这俩半径中间的圆弧,如果它的长度和半径相等,那么这个角度就是一弧度。

一般来说,一个圆有 2 * π 个弧度,也是因为圆周长是 2πR

const rad = 180 / π

cos 和 sin

以前初中就学过这俩知识,对于一个直角三角形来说,cos 就是较长的直角边除以斜边,sin 则是较短的直角边除以斜边。

在 JavaScript 里面会接收弧度作为参数,所以需要手动转换度数为弧度。

const cos = Math.cos(2 * rad)
const sin = Math.sin(2 * rad)

贝塞尔曲线

一般我们绘制贝塞尔曲线都是用的二次贝塞尔曲线,它有一个起始点、控制点、结束点三个坐标来决定的。

感兴趣的可以看一下这篇文章:怎么理解贝塞尔曲线?

在 canvas 里面也提供了 quadraticCurveTo(cp1x, cp1y, x, y) 方法来绘制曲线。

开始绘制

了解完上面的知识后,开始绘制我们的樱花。首先要知道,樱花包含花瓣和花蕊两部分,花蕊在花瓣正中间。

我们考虑用粉红色来绘制花瓣,用白色绘制花蕊。

image

樱花有五瓣,所以一瓣的夹角是 75°,也就是 75 / rad 弧度。

首先我们需要声明一个樱花类,它有半径、圆心坐标、颜色等属性。接着开始绘制。

class Flower {
  r = r;
  color = color;
  cx = 800;
  cy = 500;
}

花瓣

绘制最麻烦的一步就是花瓣的弧度,这是个贝塞尔曲线。观察图片,我们可以以花瓣凹进去的三角形(剪刀形状)到圆心距离作为半径,以剪刀两边的作为一个贝塞尔曲线的控制点。

image

那么这个控制点的坐标是什么呢?如上图所示,其实我们的控制点p1在分割线上,和原点距离是半径的长度,而终点在p2上面,长度大概是半径的1.2-1.4倍。p0p1 和 p0p2 大概构成了 25 °的角。

所以这里也很容易进行计算。首先计算出控制点 p1 的位置,肯定是 cx + R * Math.cos(a * part / rad),这里的 a 就是循环生成的,a * part / rad 就是指的是第几瓣的角度。

 const x0 = cx + R * Math.cos((a * part) / rad);
 const y0 = cy + R * Math.sin((a * part) / rad);

然后我们找到 1/3 (25°)的坐标。设置 R1 为 1.3 倍半径。

const x1 = cx + R1 * Math.cos((a * part + 2 * part / 6) / rad);
 const y1 = cy + R1 * Math.sin((a * part + 2 * part / 6) / rad);

这样关键的两个点就画了出来,接着生成贝塞尔曲线。

ctx.moveTo(cx, cy);
ctx.quadraticCurveTo(x0, y0, x1, y1);

然后我们绘制出剩下的一半花瓣。最终代码如下:

const x0 = cx + R * Math.cos((a * part) / rad);
    const y0 = cy + R * Math.sin((a * part) / rad);
  
    const x1 = cx + R1 * Math.cos((a * part + 2 * part / 6) / rad);
    const y1 = cy + R1 * Math.sin((a * part + 2 * part / 6) / rad);
    
    // 这个点其实在中点,也就是 37.5°的地方
    const x2 = cx + R * Math.cos((a * part + 3 * part / 6) / rad); 
    const y2 = cy + R * Math.sin((a * part + 3 * part / 6) / rad);
  
    const x3 = cx + R1 * Math.cos((a * part + 4 * part / 6) / rad);
    const y3 = cy + R1 * Math.sin((a * part + 4 * part / 6) / rad);
  
    const x4 = cx + R * Math.cos((a * part + part) / rad);
    const y4 = cy + R * Math.sin((a * part + part) / rad);
  
    // petal
    ctx.beginPath();
    ctx.moveTo(cx, cy);
    ctx.quadraticCurveTo(x0, y0, x1, y1);
    ctx.lineTo(x2, y2);
    ctx.lineTo(x3, y3);
    ctx.quadraticCurveTo(x4, y4, cx, cy);
    ctx.fill();
    ctx.stroke();

花蕊

接着绘制花蕊,其实花蕊很容易绘制,因为它们分别处于 1/3、1/2、2/3 处。

const ax0 = cx + R / 3 * Math.cos((a * part + 2 * part / 6) / rad);
    const ay0 = cy + R / 3 * Math.sin((a * part + 2 * part / 6) / rad);
    const ax1 = cx + R / 2 * Math.cos((a * part + 3 * part / 6) / rad);
    const ay1 = cy + R / 2 * Math.sin((a * part + 3 * part / 6) / rad);
    const ax2 = cx + R / 3 * Math.cos((a * part + 4 * part / 6) / rad);
    const ay2 = cy + R / 3 * Math.sin((a * part + 4 * part / 6) / rad);

这几个坐标点都找好了,但是不要忘了在终点绘制一个小圆点,这个更像花蕊上面的蕊头。

 ctx.arc(ax0, ay0, 2, 0, 2 * Math.PI)

最终的代码如下:

const { ctx, cx, cy, r: R } = this
    ctx.save();
    ctx.strokeStyle = "#fff";

    const ax0 = cx + R / 3 * Math.cos((a * part + 2 * part / 6) / rad);
    const ay0 = cy + R / 3 * Math.sin((a * part + 2 * part / 6) / rad);
    const ax1 = cx + R / 2 * Math.cos((a * part + 3 * part / 6) / rad);
    const ay1 = cy + R / 2 * Math.sin((a * part + 3 * part / 6) / rad);
    const ax2 = cx + R / 3 * Math.cos((a * part + 4 * part / 6) / rad);
    const ay2 = cy + R / 3 * Math.sin((a * part + 4 * part / 6) / rad);
    let ary = []
    // 如果半径大于40
    if (R > 40) {
      ary = [{
        x: ax0,
        y: ay0
      }, {
        x: ax1,
        y: ay1
      }, {
        x: ax2,
        y: ay2
      }];
    } else {
      ary = [{
        x: ax1,
        y: ay1
      }];
    }

    ctx.beginPath();
    for (let i = 0; i < ary.length; i++) {
      ctx.moveTo(cx, cy);
      ctx.lineTo(ary[i].x, ary[i].y);
      ctx.arc(ary[i].x, ary[i].y, 2, 0, 2 * Math.PI)
    }
    ctx.stroke();
    ctx.restore();

总结

最后,我把这个项目部署到了线上,可以访问 http://sakura.gyyin.top 来访问到。

javascript模式与实践

一、面向对象编程

1. 鸭子类型

如果它走路像鸭子,叫声也像鸭子,那么它就可以认为是鸭子。
以代码为例:

var duck = {
	duckSinging: function () {
		console.log('嘎嘎嘎');
	}
};
var chicken = {
	duckSinging: function () {
		console.log('嘎嘎嘎');
	}
};
var choir = []; // 合唱团
var joinChoir = function (animal) {
	if (animal && typeof animal.duckSinging === 'function') {
		choir.push(animal);
		console.log('恭喜加入合唱团');
		console.log('合唱团已有成员数量:' + choir.length);
	}
};
joinChoir(duck); // 恭喜加入合唱团
joinChoir(chicken); // 恭喜加入合唱团

我们根本无需关注对象是不是鸭子,只要有duckSinging这个方法就行了,利用鸭子类型的**,我们可以在动态语言中实现一个原则:面对接口编程,而不是面向实现编程。

2. 多态

给不同的对象发送同一个消息,这些对象会根据这个消息做出不同的反馈。
多态背后的**是将“做什么”和“谁去做以及怎样去做”分离开来,也就是将“不变的事
物”与 “可能改变的事物”分离开来。
image

多态的**是把"做什么"和"谁去做"给分开,这需要消除类型之间的耦合关系。

3. 封装

把系统中稳定不变的部分和容易变化的部分隔离开来,在系统演变中,我们只需要替换容易变化的部分,这些如果已经封装好了,替换起来也很容易。

4. 函数节流

在部分场景,比如window.onresize和mousemove这些事件中,往往会被高频率触发,可以将要执行的函数用setTimeout延迟一段时间,如果延迟执行还没有完成,就忽略接下来的调用请求。
image

5. 分时函数

在某些场景下,可以将操作分时来进行处理,比如一次生成1000个DOM,可以分时为几秒产生200个。

6. 惰性加载函数

image

这样可以避免每次调用addEvent的时候都要通过if的判断,在用户调用addEvent的时候也不需要用if来判断。

二、设计模式

1. 单例模式

JS是一门没有类的语言,所以不需要每次都用类的**来创建单例对象。
创建对象与管理单例的职责分到不同方法中,这样符合单一职责原理。
+1.1 惰性单例
假如有个场景,我们访问一个网站,但是并不想登陆,只是想浏览一些东西,那么你的登陆弹窗该如何处理?以前我都是直接在html里面写好,点击登陆的时候再让显示出来,然而这种场景下就并不需要你显示登陆弹窗,完全可以在点击的时候再创建。

let getSingle = (fn) => {
	let result;
	return result || (fn.call(this, arguments));
}

使用惰性单例可以很大程度上优化性能。

2. 策略模式

如何写好前端业务代码?

前言

如何写出可维护和可读性高的代码,这一直是一个困扰很多人的问题。关于变量如何起名、如何优化if else之类的小技巧,这里就不做介绍了,推荐去看《代码大全2》,千书万书,都不如一本《代码大全2》。

工作以来,我一直在写一些重复且交互复杂的页面,也没有整理过自己的思路,这篇文章是我工作一年半来在项目中总结出来的一些经验。

分层

对于业务代码来说,大部分的前端应用都还是以展示数据为主,无非是从接口拿到数据,进行一系列数据格式化后,显示在页面当中。

首先,应当尽可能的进行分层,传统的mvc分层很适用于前端开发,但对于复杂页面来说,随着业务逻辑增加,往往会造成controller臃肿的问题。因此,在此之上,可以将controller其分成formatter、service等等。


下面这是一些分层后简单的目录结构。

    + pages
        + hotelList
            + components
                + Header.jsx
            + formatter
                + index.js
            + share
                + constants.js
                + utils.js
            + view.js
            + controller.js
            + model.js

Service

统一管理所有请求路径,并且将页面中涉及到的网络请求封装为class。

// api.js
export default {
    HOTELLIST: '/hotelList',
    HOTELDETAIL: '/hotelDetail'
}

// Service.js
class Service {
    fetchHotelList = (params) => {
        return fetch(HOTELLIST, params);
    }
    fetchHotelDetail = (params) => {
        return fetch(HOTELLIST, params);
    }
}
export default new Service

这样带来的好处就是,很清楚的知道页面中涉及了哪些请求,如果使用了TypeScript,后续某个请求方法名修改了后,在所有调用的地方也会提示错误,非常方便。

formatter

formatter层储存一些格式化数据的方法,这些方法接收数据,返回新的数据,不应该再涉及到其他的逻辑,这样有利于单元测试。单个format函数也不应该格式化过多数据,函数应该根据功能进行适当拆分,合理复用。

mvc

顾名思义,controller就是mvc中的c,controller应该是处理各种副作用操作(网络请求、缓存、事件响应等等)的地方。

当处理一个请求的时候,controller会调用service里面对应的方法,拿到数据后再调用formatter的方法,将格式化后的数据存入store中,展示到页面上。

class Controller {
    fetchHotelList = () => async (dispatch) => {
        const params = {}
        this.showLoading();
        try {
            const res = await Service.fetchHotelList(params)
            const hotelList = formatHotelList(res.Data && res.Data.HotelList)
            dispatch({
                type: 'UPDATE_HOTELLIST',
                hotelList
            })
        } catch (err) {
            this.showError(err);
        } finally {
            this.hideLoading();
        }
    }
}

view则是指react组件,建议尽量用纯函数组件,有了hooks之后,react也会变得更加纯粹(实际上有状态组件也可以看做一个mvc的结构,state是model,render是view,各种handler方法是controller)。

对于react来说,最外层的一般称作容器组件,我们会在容器组件里面进行网络请求等副作用的操作。

在这里,容器组件里面的一些逻辑也可以剥离出来放到controller中(react-imvc就是这种做法),这样就可以给controller赋予生命周期,容器组件只用于纯展示。

我们将容器组件的生命周期放到wrapper这个高阶组件中,并在里面调用controller里面封装的生命周期,这样我们可以就编写更加纯粹的view,例如:

wrapper.js

// wrapper.js(伪代码)
const Wrapper = (components) => {
    return class extends Component {
        constructor(props) {
            super(props)
        }
        componentWillMount() {
            this.props.pageWillMount && this.props.pageWillMount()
        }
        componentDidMount() {
                this.props.pageDidMount && this.props.pageDidMount()
            }
        }
        componentWillUnmount() {
            this.props.pageWillLeave && this.props.pageWillLeave()
        }
        render() {
            const {
                store: state,
                actions
            } = this.props
            return view({state, actions})
        }
    }
}

view.js

// view.js
function view({
    state,
    actions
}) {
    
    return (
        <>
            <Header 
                title={state.title} 
                handleBack={actions.goBackPage}
            />
            <Body />
            <Footer />
        </>
    )
}
export default Wrapper(view)

controller.js

// controller.js
class Controller {
    pageDidMount() {
        this.bindScrollEvent('on')
        console.log('page did  mount')
    }
    pageWillLeave() {
        this.bindScrollEvent('off')
        console.log('page will leave')
    }
    bindScrollEvent(status) {
        if (status === 'on') {
            this.bindScrollEvent('off');
            window.addEventListener('scroll', this.handleScroll);
        } else if (status === 'off') {
            window.removeEventListener('scroll', this.handleScroll);
        }
    }
    // 滚动事件
    handleScroll() {
        
    }
}

其他

对于埋点来说,原本也应该放到controller中,但也是可以独立出来一个tracelog层,至于tracelog层如何实现和调用,还是看个人爱好,我比较喜欢用发布订阅的形式。

如果还涉及到缓存,那我们也可以再分出来一个storage层,这里存放对缓存进行增删查改的各种操作。

对于一些常用的固定不变的值,也可以放到constants.js,通过引入constants来获取值,这样便于后续维护。

// constants.js
export const cityMapping = {
    '1': '北京',
    '2': '上海'
}
export const traceKey = {
    'loading': 'PAGE_LOADING'
}
// tracelog.js
class TraceLog {
    traceLoading = (params) => {
        tracelog(traceKey.loading, params);
    }
}
export default new TraceLog

// storage.js
export default class Storage {
    static get instance() {
        // 
    }
    setName(name) {
        //
    }
    getName() {
        //
    }
}

数据与交互

不过也不代表着这样写就够了,分层只能够保证代码结构上的清晰,真正想写出好的业务代码,最重要的还是你对业务逻辑足够清晰,页面上的数据流动是怎样的?数据结构怎么设计更加合理?页面上有哪些交互?这些交互会带来哪些影响?

以如下酒店列表页为例,这个页面看似简单,实际上包含了很多复杂的交互。

上方的是四个筛选项菜单,点开后里面包含了很多子类筛选项,比如筛选里面包括了双床、大床、三床,价格/星级里面包含了高档/豪华、¥150-¥300等等。

下方是快捷筛选项,对应了部分筛选项菜单里面的子类筛选项。

image_1d6dgvgio1hfg82u1kmo1u141tv69.png-230.3kB

当我们选中筛选里面的双床后,下方的双床也会被默认选中,反之当我们选中下方的双床后,筛选类别里面的双床也会被选中,名称还会回显到原来的筛选上。

image_1d6dgvscipuea6nsnhl6a1ijam.png-231.3kB

image_1d6dhiup11mnj12351f0fl36msh1g.png-57.7kB

除此之外,我们点击搜索框后,输入'双床',联想词会出现双床,并表示这是个筛选项,如果用户选中了这个双床,我们依然需要筛选项和快捷筛选项默认选中。

这三个地方都涉及到了筛选项,并且修改一个,其他两个地方就要跟着改变,更何况三者的数据来自于三个不同的接口数据,这是多么蛋疼的一件事情!

image_1d6dhc833375eo118vq1od61j6u13.png-40.4kB

我借助这个例子来说明,在开始写页面之前,一定要对页面中的隐藏交互和数据流动很熟悉,也需要去设计更加合理的数据结构。

对于深层次的列表结构,键值对会比数组查询速度更快,通过key也会更容易和其他数据进行联动,但是却不能保证顺序,有时候可能就需要牺牲空间来换时间。

// 假设筛选项床型type为1,大床id为1,双床id为2.
const bed = {
    '1-1': {
        name: '大床',
        id: 1,
        type: 1
    },
    '1-2': {
        name: '双床',
        id: 2,
        type: 1
    }
}
const bedSort = ['1-1', '1-2'] // 保证展示顺序

当我们选中大床的时候,只需要保存'1-1'这个key,再和store中快捷筛选项列表里面的key进行mapping(快捷筛选项里面的项也应该格式化为{'type-id': filterItem}的键值对格式),这样从时间复杂度上说,比直接遍历两个数组更高效。

总结

在开始写业务之前,理应先想清楚需求和业务逻辑,设计出合理的数据结构,对代码进行好的分层,这样在一定程度上可以写出可维护性更高的代码。

PS:欢迎大家关注我的公众号【前端小馆】,大家一起来讨论技术。

怎么解决跨域问题

最近在和后端联调的时候遇到了问题,由于后端那边没有创建数据表的权限(相关的新加坡同事也刚好休假),就导致没法把接口发布测试环境,不得不和他本机联调,这样就遇到了跨域的问题。

原来这边的前后端通信,前端都会请求网关(一个nodejs的中间层),网关对接口进行分发,这样是没有跨域问题的。但这次是前端直接请求后端本机的接口,就出现了跨域的问题。

代理

其实有挺多方法可以解决这个问题,比如使用 whistle 或者 charles 进行代理,这里以 whistle 为例,whistle拥有非常强大的功能,可以自定义规则:

/admin-api\.test\.airpay\.in\.th\/debug\/(.+)$/ http://10.12.160.249:8099/btcod/v1/$1

比如上面这句规则,就是将以 admin-api.test.airpay.in.th.debug 开头的请求转发到 http://10.12.160.249:8099/btcod/v1/ 上面。

但是呢,这边希望在本机联通后,给测试先在本地测试,所以为了不让测试再去配置一大堆麻烦的代理,我就想着在代码里面做一下修改。

OPTIONS

于是,我和后端商量,让他去设置一下 access-control-allow-origin,但这边的后端缺少解决跨域的经验,我也不清楚服务端怎么设置 CORS 才是正确的,导致我再次请求的时候,出现了 OPTIONS 请求,且响应码为301,可以说非常神奇了。

不管怎么样都无法绕过这个301,让我很头疼,后来刚哥让我想办法去绕过 OPTIONS 或者拦截 OPTIONS 请求。

对,这就涉及到跨域请求中的预检请求了。
跨域请求分为两种,一种是简单请求,一种是复杂请求。只有复杂请求会在正式发送请求之前,先发送一次 OPTIONS 请求,检查当前服务器是否允许该跨域请求,如果是则再发送正式请求。
满足以下情况的都是复杂请求:

image.png-112.8kB

为什么我的请求是复杂请求呢?很明显满足了最后一条。
一般来说,axios 将 JavaScript 对象序列化为 JSON 来发送,也就是说会使用 application/json作为Content-Type

于是我就手动设置了请求的 Content-Type,将其设置为 application/x-www-form-urlencoded
一般来说,表单提交的时候不会跨域,这是为什么呢?因为表单提交到另一个域名后,原来的页面就无法拿到新页面的数据了。
但是我们现在传给接口的数据格式还是 JSON,该怎么办呢?
这里有两种方式:

  1. 使用 URLSearchParams 转换为 FormData 格式
  2. 使用 qs 库来处理

当然了,还需要后端对传入的 Form 数据进行处理,毕竟原来的是 JSON。

修改响应头

除了上面说的这些,还有一个最简单的方式,那就是通过 whistle 代理来修改接口的响应头,给它设置 Access-Control-Allow-Origin 就可以了,例如在 whistle 里面可以这样设置。

https://giro.test.airpay.in.th/ibanking/v1/gateway/mbanking_init/ resHeaders://{corsHeader.json}

// corsHeader.json
{
"Access-Control-Allow-Headers": "Content-Type, Access-Control-Allow-Headers, Authorization, token",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Credentials": "true",
"content-type": "application/json",
"status": 200
}

总结

经过这么一通处理,终于解决了问题,对跨域的理解也更深一步了。当然,原本很简单的问题为什么会搞得这么复杂呢?主要还是自己缺乏服务端开发的知识,后端也比较忙,不太愿意帮我去解决这个问题,只能自己硬着头皮去探索了。

underscore源码剖析之数组遍历函数分析(二)

用法

上一篇主要介绍了each和map这些函数的实现,本篇继续分析reduce的源码。
在underscore中有reduce和reduceRight两个方法,reduce是从数组或对象第一项开始遍历,reduceRight则是从最后一项开始遍历。

var arr = [1, 2, 3, 4];
_.reduce(arr, function(result, item) {
    result += item;
    console.log(result); // 1, 3, 6, 10
    return result;
}, 0)
_.reduceRight(arr, function(result, item) {
    result += item;
    console.log(result); // 4, 7, 9, 10
    return result;
}, 0)

reduce函数有四个参数,分别是list(数组或对象)、iteratee(函数)、memo(初始值)、context(上下文),func函数则会接收四个参数,分别是memo执行后的结果、数组项/对象value、index/key,list,具体用法可以看这里:reduce的用法

createReduce实现

_.reduce = _.foldl = _.inject = createReduce(1);
_.reduceRight = _.foldr = createReduce(-1);
// createReduce会根据dir的值来控制遍历方向
function createReduce(dir) {
	function iterator(obj, iteratee, memo, keys, index, length) {
		// 从第index个值开始迭代,dir为1的时候从左往右跌代,dir为-1的时候从右往左跌代        
		// 将每次返回的值赋给memo,以便于下次继续调用,直到走完这个循环,最后返回memo
		for (; index >= 0 && index < length; index += dir) {
			var currentKey = keys ? keys[index] : index;
			memo = iteratee(memo, obj[currentKey], currentKey, obj);
		}
		return memo;
	}
	// _.reduce传参分别是对象/数组, 函数,初始值,上下文
	// 根据dir的值来判断index的值(从第一个元素还是最后一个元素开始迭代)
	// 如果memo有值,那么就从obj第一个元素迭代
	// 如果没有传入memo和context两个值的时候,需要对memo赋个初始值
	// 也就是说将第一个元素作为初始值给memo,从obj第二个元素开始迭代
	// 或者将最后一个元素作为初始值给memo,从obj倒数第二个元素开始迭代
	return function (obj, iteratee, memo, context) {
	    // 调用optimizeCb,返回一个绑定了context上下文的iteratee
		iteratee = optimizeCb(iteratee, context, 4);
		
		var keys = !isArrayLike(obj) && _.keys(obj),
			length = (keys || obj).length,
			index = dir > 0 ? 0 : length - 1;
		// 如果没有memo和context
		if (arguments.length < 3) {
			// 如果这里没有传入第三个初始值,那么会把obj里面第一个或最后一个值赋过去
			memo = obj[keys ? keys[index] : index];
			index += dir;
		}
		return iterator(obj, iteratee, memo, keys, index, length);
	};
}

createReduce函数是一个高阶函数,接收了一个dir,通过dir来判断是从list是从左往右遍历还是从右往左遍历,之所以用1和-1是为了后续遍历的时候,直接让index加上dir来实现循环,如果是从左往右遍历,那么dir为1,在for循环中index每次加1,通过访问index或key[index]就可以实现从左往右遍历。如果是从右往左遍历,那么dir为-1,在for循环中index每次减1,这样就实现了从右往左遍历。

如果没有给reduce传memo这个初始值,则会将list中的第一项或者最后一项赋给memo,之后的迭代从第二项或者倒数第二项开始。

一共就是下面四种情况:

  1. 如果dir为1,也就是调用reduce的时候,如果传入了memo,那么会从list的第一项开始遍历,将memo和循环中当前的项传给iteratee函数执行,并且将执行结束后的结果重新赋值给memo,这样一直迭代下去,得到最后的memo值。
  2. 如果dir为1,并且没有传入memo,那么会将list中的第一项赋给memo,之后从第二项再开始遍历list。
  3. 如果dir为-1,也就是调用reduceRight的时候,如果传入了memo,那么会从list的最后一项开始遍历。
  4. 如果dir为-1,并且没有传入memo,那么会将list中的最后一项赋给memo,之后从倒数第二项再开始遍历list。

应用场景

reduce可以有很多很巧妙的用法,某些情况下代替each、map循环也会更加方便,比如计算一个数组中所有项之和的时候,那么reduce还有其他什么用法呢?

深层取值

我们在平时写代码的时候经常会遇到给对象内部某个属性赋值,但是这个属性在很深的层级里面,如果直接使用.来赋值,只要前面有一个是undefined,后面必报错。

var obj = {
    info1: {
        age: 20
    }
}
var age = obj.info1.age
var name = obj.info2.name // Cannot read property 'name' of undefined

我们想获取obj下面info2属性的name,但是因为obj里面没有info2,所以obj.info2是undefined,我们访问的是undefined.name,这个时候肯定会报错的,我们只能用obj.info2 && obj.info2.name的丑陋形式来取值,如果是更深的层级,就需要写更多的&&,所以我们可以使用reduce来封装一个get方法来优雅的深层取值。

var get = function(obj, attrArr) {
    return _.reduce(attrArr, function(obj, attr) {
        return obj && obj[attr]
    }, obj)
}

我们找个例子来试试看:

var obj = {
	country: {
		name: "china",
		city: {
			name: "shanghai",
			street: {
				name: "changning"
			}
		}

	}
}

get(obj, ["name", "name"]) // undefined
get(obj, ["country", "city", "name"]) // "shanghai"
get(obj, ["country", "city", "street", "name"]) // "changning"

数组扁平化

这是之前在一篇博客里面看到的面试题,怎么将下面数组拍平?

    var arr = [1, [2, [3, [4]]]]
    var arr2 = [[1, 2, 3], [4], [5 ,6 , [7, 8]]]

我们可以用reduce这样写。

    var flatten = function(arr) {  
        return _.reduce(arr, function(originArr, item) {
            return originArr.concat(Array.isArray(item) ? flatten(item) : item)
        }, [])
    }

这里主要是对数组里面的项进行判断,如果是数组,那么就进行递归,一直到返回一个非数组的值,这样时候将这些值用一个空数组concat起来,最后就得到了一个新的扁平的数组。

redux源码分析

前几天和小伙伴讨论一个问题,就是在reducer方法里面拿到state后,如果直接对state对象做修改,但是还没有return出来,到这一步的时候会不会已经修改了store里的数据,我当时觉得应该不会,但是实在也想不明白,如果不做深拷贝,那还有其他办法能避免修改原始数据吗?但是redux中又不可能对数据进行深拷贝,这样做的代价太大了,于是带着一点好奇心,加上今天重感冒卧病在床,就抽出时间看了看redux源码。

createStore

redux源码还是比想象中的要精炼很多的,除去注释和打印错误信息,核心代码大概在两三百行左右,我们就先从createStore这个入口函数一探究竟。

redux入口.png-41.8kB

从返回参数来看,createStore函数主要返回了这四个我们常用的函数。
createStore.png-251.1kB

进入到create函数里面看,第一个判断应该是当用户没有给初始化的数据,直接将enhancer函数(一般是applyMiddleware)当第二个参数传进来的时候做的一些默认处理,如果enhancer是一个函数,那么就会把reducer和preloadedState传给enhancer,我们知道enhancer一般是一些中间件函数,这里的reducer一般是combineReducers这个函数的返回值,我们再来看看combineReducers和applyMiddleware这两个函数。

combineReducers

删除掉多余的注释和打印信息后,完整的combineReducers函数是这样的:
combineReducers.png-309.2kB

参数reducers就是我们最后传给combineReducers的那个对象,一般是我们reducer函数的对象集合。
这里代码也很清晰了,将reducer函数以键值对的形式赋给finalReducers对象,并且返回一个combination函数,这个函数可以拿到当前的状态和要执行的action,这两个参数肯定是在createStore里面调用的时候传入的,我们先不用管。
这里使用for循环来遍历每一个reducer函数,这也就意味着,我们每次触发一个action,redux都会遍历并执行一遍所有的reducer函数,直到找到匹配的那个action.type(这里我不得不说react-imvc应该是做了一些优化的,它以action.type作为reducer函数名,这样就不需要去遍历查找了,可以减少很多不必要的工作量)。
并且将执行后的结果nextStateForKey和前一个状态做比较,最后根据判断是return新值还是老值,这也是为什么在reducer函数里面最后一定要return出来一个新的对象or数组才会刷新store,而不能简单的修改一下当前的state,并将其直接返回,因为这里比较的是引用。

applyMiddleware

Middleware这个概念是Redux从其他框架借鉴过来的,本意如下:

middleware是指可以被嵌入在框架接收请求到产生响应过程之中的代码。例如,Express 或者 Koa 的 middleware
可以完成添加 CORS headers、记录日志、内容压缩等工作。

而在Redux中:

middleware被用于解决不同的问题,但其中的概念是类似的。它提供的是位于 action 被发起之后,到达 reducer
之前的扩展点。 你可以利用 Redux middleware 来进行日志记录、创建崩溃报告、调用异步接口或者路由等等。

看完了combineReducers函数,我们继续分析applyMiddleware函数:

applyMi.png-172kB

applyMiddleware函数就更加简练了一些,我们一般会把redux-thunk、redux-logger这种中间件当做参数传给applyMiddleware。
redux的中间件是在action触发后执行的,所以中间件内部必须拿到完整的state、dispatch和action,这里使用compose包裹了中间件方法,最终返回了一个新的dispatch,可以理解为这个dispatch是经过中间件加强后的dispatch。

compose

compose.png-80.9kB

这里是compose的源码,我们可以明显看出来这里是将dispatch再次作为参数放进去的,最后得到一个强化的dispatch,结合redux-logger来理解,大概是传入dispatch后对其加了打印的功能,之后再返回出来。

dispatch

再回头来看我们的createStore函数,我们关键来看一下对应的几个函数:
dispatch.png-152.4kB
首先是我们的dispatch函数,dispatch函数主要做了两件事,一个是执行reducer函数拿到最新的state,另一个是执行subscribe的事件。
dispatch接收了一个action当参数,通过isDispatching来判断是否执行reducer,这也就不可能出现多个dispatch同时执行的情况了,因为这样会干扰store的值。这里看到会把currentState传到reducer里面,更新后得到了新的currentState,之后还执行了一下listener函数,这个函数是从nextListeners里面拿到的。

subscribe

这里我们看一下subscribe函数:
subscribe.png-128kB

subscribe会传入一个回调函数,这个函数一般是监听redux中状态变化后执行的,nextListeners里面保存着所有需要执行的回调,如果subscribe函数执行两次,那就是卸载当前加载上的listener。
这样的话,其实还是有一个问题,如果我们用subscribe监听了ReactDOM.render,这样我们每次发送dispatch,即使最后state没有变化,页面也是会重新render。

重写

这里是自己重写的简练版redux:

/// 这里需要对参数为0或1的情况进行判断
const compose = (...funcs) => {
    if (!funcs) {
        return args => args
    }
    if (funcs.length === 1) {
        return funcs[0]
    }
    return funcs.reduce((f1, f2) => (...args) => f1(f2(...args)))
}

const bindActionCreator = (action, dispatch) => {
    return (...args) => dispatch(action(...args))
}

const createStore = (reducer, initState, enhancer) => {
    if (!enhancer && typeof initState === "function") {
        enhancer = initState
        initState = null
    }
    if (enhancer && typeof enhancer === "function") {
        return enhancer(createStore)(reducer, initState)
    }
    let store = initState, 
        listeners = [],
        isDispatch = false;
    const getState = () => store
    const dispatch = (action) => {
        if (isDispatch) return action
        // dispatch必须一个个来
        isDispatch = true
        store = reducer(store, action)
        isDispatch = false
        listeners.forEach(listener => listener())
        return action
    }
    const subscribe = (listener) => {
        if (typeof listener === "function") {
            listeners.push(listener)
        }
        return () => unsubscribe(listener)
    }
    const unsubscribe = (listener) => {
        const index = listeners.indexOf(listener)
        listeners.splice(index, 1)
    }
    return {
        getState,
        dispatch,
        subscribe,
        unsubscribe
    }
}

const applyMiddleware = (...middlewares) => {
    return (createStore) => (reducer, initState, enhancer) => {
        const store = createStore(reducer, initState, enhancer)
        let chain = middlewares.map(middleware => middleware(store))
        store.dispatch = compose(...chain)(store.dispatch)
        return {
          ...store
        }
      }
}

const combineReducers = reducers => {
    const finalReducers = {},
        nativeKeys = Object.keys
    nativeKeys(reducers).forEach(reducerKey => {
        if(typeof reducers[reducerKey] === "function") {
            finalReducers[reducerKey] = reducers[reducerKey]
        }
    })
    return (state, action) => {
        const store = {}
        nativeKeys(finalReducers).forEach(key => {
            const reducer = finalReducers[key]
            const nextState = reducer(state[key], action)
            store[key] = nextState
        })
        return store
    }
}

富爸爸,穷爸爸

前言

去年在知乎上看到很多人推荐《富爸爸,穷爸爸》这本书,我就利用中午在公司食堂排队时间看完了这本书,这才算是理财入了门。

笔记

这本书虽然很不错,但是中间废话较多,我这里总结了几个重要的点:

  • 穷人和中产阶级无法分清楚资产和负债的区别,经常把房子等负债误当做资产,这里我的理解是在你能力范围之内的才叫资产,在你能力范围之外的叫负债。
  • 穷人和中产阶级一般只有工资的收入,又由于房子等负债的拖累,导致收入全部流向支出和负债,当有宝贵的机会来临的时候,这些人没有闲钱导致无法抓住,他们过于追求安稳。
  • 只买入可以带来收入的资产,并且注意降低负债和支出,这让你有更多的钱投入资产项。
  • 学校只是教你成为一个伟大的雇员,而不是雇主,为别人打工实际上给自己带来的收益很小,甚至永远不够你的负债。
  • 净资产包括非现金资产,财富则衡量着你的钱能够挣多少钱,以及你的财务生存能力。
  • 通过投资等方式让你的钱不断生钱,这样可以让自己的财富源源不断的增加,如果想支出更多,就要想办法让自己的钱生更多的钱。
  • 关于自己的事业,而不是职业,比如我的职业是程序员,但我的事业是什么?我拥有一个公司吗?不,并没有。

2

3

知乎大V温酒有更简单的总结: 《富爸爸,穷爸爸》这本书有何价值?

老鼠赛跑

前几天参加了互动吧上的老鼠赛跑现金流游戏,去年看完了《富爸爸穷爸爸》后一直很想参加这样的线下活动,但是苦于一直找不到途径,终于让我在互动吧找到了。

活动在上海啤酒高速的一个小酒吧里面,游戏规则和大富翁很类似,但是比大富翁更公平一些,玩过大富翁的人都知道先走的人相对于后走的人先天优势非常巨大,大到你几乎没办法追上来,这个确实和当今社会比较像,家庭条件好的人确实比家庭条件不好的人起点更高。

这个游戏的目标是实现财务自由,从而从老鼠赛跑的跑道走到快车道,实现人生理想。

怎么才能实现财务自由呢?只要让你的被动收入超过总支出,那你就已经实现财务自由了,看起来很简单,但是实际上自己的抉择和运气都非常重要。

游戏一开始,大家会抽卡片选择一个职业,不同的职业工资不一样。比如我是飞行员,月薪9000多,尧尧是小学教师,工资只有1000多。虽然收入差距巨大,但是我每个月支出高达6900多块,他每个月支出只有几百块,我想实现财务自由就需要被动收入达到6900,而他只要达到几百块就ok了。

这个游戏中可以投资的东西只有股票和房子,房子有很多种,有的收益率很低,有的收益率很高,我们一开始都有一个月的工资,这部分钱可以拿来买房地产和股票,把握住什么时候买入,什么时候卖出才是这里面最难的。

如果一开始就花很多钱,这样会导致自己手里没有流动现金,如果后面遇到更好的机会,那就没法抓住。银行贷款太多的话也会导致自己负债很多,负债多意味着你离实现财务自由就更远一步。

游戏心得

我个人觉得这个游戏的玩法应该是这样的,一开始大家需要通过买卖股票和房地产来增加自己手里的钱,有足够的资金才能买更多的房子,一般来说,越贵的房子能给你带来的月现金流就会越多。

游戏中我印象比较深刻的一个场景是,我卖掉了一个每月能给我带来950块现金流的房子,之后我才恍然大悟,我手里明明已经有很多钱了,为什么我还要卖掉这个?我才意识到这个游戏的终极目的是增加自己的被动收入。但是塞翁失马,焉知非福,正是因为我卖了这个房子,我才能在后面遇到一家披萨店的时候有足够的钱买下来,这个店每个月给我带来了5000块的现金流。

还有一个就是我和尧尧竞价争夺一处房产的时候,当时我来不及思考,直接出高价竞争到了,但是在我思考了几分钟后我又低价转手卖了。当时大家都很不理解,因为我已经快实现财务自由了,只要购入这个房子,离实现目标就很近了。可是我自己算了一下,如果我把自己所有的钱都买了这个房产,我每个月是可以获得一些收入,但是我手里没有流动资金了,甚至还要背负银行贷款,这样我后面就很难和别人竞争了,毕竟我离财务自由还有一段距离。还好和我想的一样,后来我又找到机会廉价买下了两处房产,最终实现了财务自由。

总结

两位老师让我们最后总结了一下,为什么这个游戏强调被动收入呢?

之前尧尧一不小心失业了,但是失业的时候还要继续支付那些支出项,这个让我想起来那个因为失业而跳楼的中兴程序员,负债太多会给未来带来很多不确定的风险和因素,股票可以增加你的现金流,而房产可以增加你的被动收入。

不管是为了应对突发状况还是通货膨胀,多种收入来源都是很重要的。如果收入来源只是很单一的来自工资,真的很难实现财务自由,就像我最后总结的时候说的一样,个人所得税和房价实在太高了,除非在一个行业做到真正的顶尖(前提是也要有人赏识啊),否则靠工资是很难翻身的,程序员随着年龄下降竞争力也会下降,一旦失业后果就不堪设想。

所以我觉得我们年轻人应该多去学习一些理财和投资的知识,减少不必要的支出,好好攒钱,在职业中不断提高自己,增加工资收入(相同工作强度下),主动去寻找机会,不要盲目的涉足自己不了解的领域,早点儿从老鼠赛跑中走出来。

前端跨域页面通信的终极方案

背景

最近开发中遇到了这么一个需求。在我们这边,有一种快捷支付的场景。但是呢,准备支付的时候可能会遇到用户没有绑定银行卡的情况,这样就需要我们帮用户定向到银行的页面,让用户填完绑卡信息后,再回到我们自己的页面。

对于 PC 端来说,这些倒还好,只是产品提了一个需求,就是在银行页面填完信息后,就关闭当前银行页面,并且通知前一个打开的页面更新绑卡状态。

对于 APP 端来说,由于银行页面不是我们能控制的,所以我们会在跳到银行页面之前给银行带一个回调页面地址,填完绑卡信息后银行会自己打开这个地址。

所以这样就很明确了,我们是无法决定银行该怎么跳,由于对接了很多家银行,所以就需要提供给银行一个统一的中间页来做后续处理。

绑卡页面 --> 选择要绑定的银行卡(带给银行一个回调地址) -->  银行页面 --> 打开回调地址 --> 跳转回绑卡页面

其实 APP 里面的通知倒是好做,客户端给提供 JS Bridge 就行了,我调用 bridge 回退到之前的页面。难点在于 PC 端怎么通知上一个页面更新状态,毕竟中间页(兼容H5和PC)和 PC 页面是跨域的。

苦思冥想,想到了几种方案。

websocket 和 EventSource

这个没啥好说的,需要我这边开个服务和 PC 页面通信,负责开发 PC 页面的我同事也觉得不太行,直接 PASS。

监听 storage 事件

其实我们也不清楚这两个项目最后会不会发到同域名下面,但感觉大概率不是同一个域名。如果是同一个域名下面的话,可以在我的中间页修改 localStorage,在他的 PC 页面监听 localStorage 变化的事件,一旦变化了就判断是否有某个字段,然后解析这个字段,在 PC 页面做响应。代码大概如下。

window.addEventListener('storage', () => {
  if (localStorage.getItem('flag')) {
    console.log(JSON.parse(window.localStorage.getItem('flag')));    
  }
});

但我们查了查,这个 storage 事件不支持 IE 浏览器,我们需要支持到 IE10,所以直接 PASS。

跨域共享 storage

这个听着你会觉得不可能吧?localStorage明明不支持跨域,怎么共享?其实这里还真的有办法实现共享。

postMessage + iframe 跨域通信

其实吧,就算是跨域,也可以实现 localStorage 共享,只不过麻烦了那么一点儿。
假如我有两个页面 P 和 T,通过 iframe + postMessage 也完全可以实现跨域共享 storage。

这个原理是什么呢?首先,将 P 页面当做一个 iframe 嵌入到 T 页面中,在 iframe 的 onload 事件中,通过 postMessage 的形式,将数据传给 P 页面。当然 P 页面收到这个数据后怎么展示也不会影响到我们已经在浏览器中打开的 P 页面,只会影响到 iframe 里面的 P 页面。

但是呢,如果你在 P 页面里面设置 localStorage 呢?这样 iframe 的 P 页面和已经打开的 P 页面 localStorage 就同步了。

image.png-19800.1kB

所以我这里用 create-react-app 创建了两个项目,分别让页面 T 和 P 监听了 localhost:3000localhost:3001
T 页面代码(react):

function App() {
  const iframeLoaded = () => {
    let origin = 'http://localhost:3001';
    const target = document.querySelector('#target').contentWindow;
    target.postMessage('success', origin); // 发送信息
  }
  return (
    <div className="App">
        <iframe src="http://localhost:3001" name="hello, world"frameBorder="0" id="b" style={{'display': 'none'}} id="target" onLoad={iframeLoaded}></iframe>
    </div>
  );
}

P 页面代码:

function App() {
  useEffect(() => {
    window.addEventListener("message", function(event) {
      this.localStorage.setItem('flag', event.data); // 获取到状态后设置 localStorage
    }, false);
  }, []);
  return (
    <div className="App">
    </div>
  );
}

这样就实现了跨域通信,当然真正的跨域 storage 共享不仅是这样的,还需要从 P 页面发送消息给 T 页面,在 T 页面手动设置自己的 localStorage,这样就能保持两端 localStorage 一致,表面上实现了 localStorage 共享。
当然了,前提是 http 头别设置 X-Frame-Options

轮询 storage

当然,你会说,共享了有什么用啊?P 页面又不知道 localStorage 变化了。
所以就回到了上一个问题,在浏览器兼容的情况下可以监听 storage 事件,但像现在这种不兼容的情况下该怎么办呢?

其实我也没有太好的办法,我和同事说,不如使用 Web Worker 来轮询 localStorage 吧,判断是否有 flag 属性,如果有的话就是已经通知了。

为什么用 Web Worker 呢?因为轮询是比较消耗性能和时间的操作,需要一直在后台跑 setInterval,使用 Web Worker 就能保持占用主线程(虽说是异步,可任务队列早晚还是要执行的,是不是?)

visibilitychange

我同事告诉我说,可以监听 Tab 切换,比如 visibilitychange 事件,当前页面出现的时候然后他会去调用后台的接口,来判断是否成功了。我想了想,这还真的是个好主意。

document.addEventListener('visibilitychange',function(){ //浏览器tab切换监听事件
    if(document.visibilityState == 'visible') { //状态判断:显示(切换到当前页面)
        // 切换到页面执行事件
        fetch('/bank_account');
    }else if(document.visibilityState == 'hidden'){//状态判断:隐藏(离开当前页面)
         // 离开页面执行事件
    }
});

后来和成熙讨论的时候,我们都觉得这种方案是比较好的一种,就决定使用这个方案。

visibilitychange + 共享 storage

后来,我在回家路上想,为什么还要调用一次后端接口呢?如果在切换的时候去读取已经设置好的 localStorage 怎么样?貌似也是一种不错的办法。

对,这个思路是这样的,基本上延续了上面的共享 storage 思路。
当用户在银行页面填完信息之后,银行会跳转到中间页,中间页 T 会设置 P 为 iframe,然后用 postMessage 通信,此时 P 获取到数据后设置到本地的 localStorage 中。

当然,在一开始的时候 P 页面也会监听 visibilitychange 事件,在回调里面判断 localStorage 中是否有 flag 属性,如果有,就默认是银行绑卡消息通知。
代码如下:

function App() {
    useEffect(() => {
        const visibilitychange = function(){             //浏览器tab切换监听事件
            const flag = localStorage.getItem('flag');
            if (flag !== undefined) {
                // 更新页面
                localStorage.removeItem('flag'); // 获取到数据后需要销毁 localStorage 中的
            }
        };
        const messageHandler = function(event) {
            this.localStorage.setItem('flag', event.data); // 获取到状态后设置           localStorage
        }
        document.addEventListener('visibilitychange', visibilitychange);
        window.addEventListener("message", messageHandler);
        return () => {
            window.removeEventListener('message', messageHandler);
            document.removeEventListener('visibilitychange', visibilitychange);
        }
    }, [])
}

总结

这个需求其实最大的难点并不是这个,而是和客户端之间的各种 js bridge 通信,但是这个技术点呢,是我以前一直都没认真研究过的,这次去好好补了一下关于 postMessage 和 iframe 等相关知识,也算是一种收获了。
已经很久没时间写文章了,其实最近学到了很多东西,jenkins、react hooks、vue、node 等等,有空了可以整理一些文章。

深入理解react

对于常用的框架,如果仅限于会用,我觉得还是远远不够,至少要理解它的**,这样才不会掉入各种坑里面,这篇文章是基于react-lite源码来写的。

createElement和component

在react里面,经过babel的解析后,jsx会变成createElement执行后的结果。

const Test = (props) => <h1>hello, {props.name}</h1>;
<Test name="world" />

<Test name="world" />经过babel解析后会变为createElement(Test, {name: "world}),这里的Test就是上面的Test方法,name就是Test方法里面接受的props中的name。
实际上当我们从开始加载到渲染的时候做了下面几步:

// 1. babel解析jsx
<Test name="world"> -> createElement(Test, {name: "world"})
// 2. 对函数组件和class组件进行处理
// 如果是类组件,不做处理,如果是函数组件,增加render方法
const props = {name: world};
const newTest = new Component(props);
newTest.render = function() {
    return Test(props);
}
// 3. 执行render方法
newTest.render();

这样也很容易理解,const Test = <div>hello, world</div>和const Test = () => <div>hello, world</div>的区别了。

key

react中的diff会根据子组件的key来对比前后两次virtual dom(即使前后两次子组件顺序打乱),所以这里的key最好使用不会变化的值,比如id之类的,最好别用index,如果有两个子组件互换了位置,那么index改变就会导致diff失效。

cloneElement

原来对cloneElement的理解就是类似cloneElement(App, {})这种写法,现在看了实现之后才理解。原来第一个参数应该是一个reactElement,而不是一个reactComponent,应该是<App />,而不是App,这个也确实是我没有好好看文档。

shouldComponentUpdate

当shouldComponentUpdate返回false的时候,组件没有重新渲染,但是更新后的state和props已经挂载到了组件上面,这个时候如果打印state和props,会发现拿到的已经是更新后的了。

setState

react里面setState后不会立即更新,但在某些场景下也会立即更新,下面这几种情况打印的值你都能回答的上来吗?

class App extends React.Component {
    state = {
        count: 0;
    }
    test() {
        this.setState({
            count: this.state.count + 1
        }); 
        console.log(this.state.count); // 此时为0
        this.setState({
            count: this.state.count + 1
        });
        console.log(this.state.count); // 此时为0
    }
    test2() {
        setTimeout(() => {
            this.setState({
                count: this.state.count + 1
            });
            console.log(this.state.count); // 此时为1
            this.setState({
                count: this.state.count + 1
            });
            console.log(this.state.count); // 此时为2
        })
    }
    test3() {
        Promise.resolve().then(() => {
            this.setState({
                count: this.state.count + 1
            });
            console.log(this.state.count); // 此时为1
            this.setState({
                count: this.state.count + 1
            });
            console.log(this.state.count); // 此时为2
        })
    }
    test4() {
        this.setState(prevState => {
            console.log(prevState.count); // 0
        return {
            count: prevState.count + 1
        };
        });
        this.setState(prevState => {
            console.log(prevState.count); // 1
            return {
                count: prevState.count + 1
            };
        });
    }
    async test4() {
        await 0;
        this.setState({
            count: this.state.count + 1
        });
        console.log(this.state.count); // 此时为1
        this.setState({
            count: this.state.count + 1
        });
        console.log(this.state.count); // 此时为2
    }
}

在react中为了防止多次setState导致多次渲染带来不必要的性能开销,会将待更新的state放到队列中,等到合适的时机(生命周期钩子和事件)后进行batchUpdate,所以在setState后无法立即拿到更新后的state。所以很多人说setState是异步的,setState表现确实是异步,但是里面没有用异步代码实现。而且不是等主线程代码执行结束后才执行的,而是需要手动触发。
如果是给setState传入一个函数,这个函数是执行前一个setState后才被调用的,所以函数返回的参数可以拿到更新后的state。
但是如果将setState在异步方法中(setTimeout、Promise等等)调用,由于这些方法是异步的,会导致生命周期钩子或者事件方法先执行,执行完这些后会将更新队列的pending状态置为false,这个时候在执行setState后会导致组件立即更新。从这里也能说明setState本质并不是异步的,只是模拟了异步的表现。

ref

ref用到原生的标签上,可以直接在组件内部用this.refs.xxx的方法获取到真实DOM。
ref用到组件上,需要用ReactDOM.findDOMNode(this.refs.xxx)的方式来获取到这个组件对应的DOM节点,this.refs.xxx获取到的是虚拟DOM。

合成事件

react里面将可以冒泡的事件委托到了document上,通过向上遍历父节点模拟了冒泡的机制。
比如当触发onClick事件时,会先执行target元素的onClick事件回调函数,如果回调函数里面阻止了冒泡,就不会继续向上查找父元素。否则,就会继续向上查找父元素,并执行其onClick的回调函数。
当跳出循环的时候,就会开始进行组件的批量更新(如果没有收到新的props或者state队列为空就不会进行更新)。

前端学习资源整理

webpack

  1. webpack进阶构建项目(一)
  2. Webpack 4 配置最佳实践

react

  1. Redux状态管理之痛点、分析与改良
  2. Web开发中所谓状态浅析:Domain State&UI State
  3. 从时间旅行的乌托邦,看状态管理的设计误区
  4. 使用Mobx更好地处理React数据
  5. Airbnb 爱彼迎房源详情页中的 React 性能优化
  6. 从零开始,在 Redux 中构建时间旅行式调试
  7. 用FSM轻松管理复杂状态
  8. 如何把业务逻辑这个故事讲好
  9. react和fsm
  10. 解析Twitter前端架构 学习复杂场景数据设计
  11. Mobx **的实现原理,及与 Redux 对比
  12. 如何在非 React 项目中使用 Redux
  13. 如何组织Mobx中的Store之一:构建State、拆分Action
  14. 简洁的 React 状态管理库 - Stamen
  15. 精读《React 的多态性》

TypeScript

  1. 来玩TypeScript啊,机都给你开好了!
  2. 浅谈 TypeScript
  3. TypeScript 实践分享
  4. JavaScript Reflect Metadata 详解
  5. TypeScript 中的 Decorator & 元数据反射:从小白到专家
  6. TypeScript 实现依赖注入
  7. 精读《Typescript2.0 - 2.9》

es6

  1. 如何优雅的编写 JavaScript 代码
  2. Promise的队列与setTimeout的队列有何关联?
  3. 深层属性,轻松提取
  4. 从零开始用 proxy 实现 Mobx
  5. 抱歉,学会 Proxy 真的可以为所欲为
  6. 双向绑定的简单实现--构建利用Proxy和Reflect实现的微框架(基于ES6)
  7. 深入 Promise(一)——Promise 实现详解
  8. ES6 系列之我们来聊聊 Async
  9. 精读《async/await 是把双刃剑》

node

  1. koa2进阶学习笔记
  2. 一起学Nodejs

设计模式

  1. 设计模式-菜鸟教程
  2. 深入理解JS系列
  3. 抽象工厂模式和工厂模式的区别?
  4. 浅析Typescript设计模式
  5. 最易懂的设计模式解析

HTTP

  1. HTTP请求行、请求头、请求体详解
  2. HTTP消息头(HTTP headers)-常用的HTTP请求头与响应头

工程化

  1. 组件化设计与开发
  2. 前端工程——基础篇
  3. 一个程序员的成长之路

移动端

  1. 浅谈Hybrid技术的设计与实现

其他

  1. 理解javascript中实现MVC的原理
  2. 为什么认为Backbone是现代前端框架的基石
  3. 什么是面向切面编程AOP?
  4. 如何管理好10万行代码的前端单页面应用
  5. 代码结构中Dao,Service,Controller,Util,Model是什么意思,为什么划分
  6. 我接触过的前端数据结构与算法
  7. 面向对象**的三种通俗解释
  8. 编程范式:命令式编程(Imperative)、声明式编程(Declarative)和函数式编程(Functional)
  9. 现代 Web 开发,现代 Web 开发导论 | 基础篇 | 进阶篇 | 架构优化篇 | React 篇 | Vue 篇
  10. Shell编程极简入门实践
  11. 使用MVC模式拆分复杂的业务页面,组件化编程
  12. JavaScript状态模式及状态机模型及javascript-state-machine用法和源码解析
  13. 精读《手写 SQL 编译器 - 词法分析》
  14. 每天一道大厂面试题

60行代码实现简单模板引擎

不久前看过一篇不错的文章,作者用了15行代码就实现了一个简单的模板引擎,我觉得很有趣,建议在读这篇文章之前先看一下这个,这里是传送门:只有20行的Javascript模板引擎
这个模板引擎实现的核心点是利用正则表达式来匹配到模板语法里面的变量和JS语句,再将这些匹配到的字段push到一个数组中,最后连接起来,用Function来解析字符串,最后将执行后的结果放到指定DOM节点的innerHTML里面。
但是这个模板引擎还是有很多不足,比如不支持取余运算,不支持自定义模板语法,也不支持if、for、switch之外的JS语句,缺少HTML实体编码。
恰好我这阵子也在看underscore源码,于是就参考了一下underscore中template方法的实现。
这个是我参考template后实现的模板,一共只有60行代码。

(function () {
    var root = this;
    // 将字符串中的HTML实体字符转义,可以有效减少xss风险
    var html2Entity = (function () {
        var escapeMap = {
            '&': '&amp;',
            '<': '&lt;',
            '>': '&gt;',
            '"': '&quot;',
            "'": '&#x27;',
            '`': '&#x60;'
        };
        var escaper = function (match) {
            return escapeMap[match];
        };
        return function (string) {
            var source = "(" + Object.keys(escapeMap).join("|") + ")";
            var regexp = RegExp(source), regexpAll = RegExp(source, "g");
            return regexp.test(string) ? string.replace(regexpAll, escaper) : string;
        }
    }())
    // 字符串中的转义字符
    var escapes = {
        '"': '"',
        "'": "'",
        "\\": "\\",
        '\n': 'n',
        '\r': 'r',
        '\u2028': 'u2028',
        '\u2029': 'u2029'
    }
    var escaper = /\\|'|"|\r|\n|\u2028|\u2029/g;
    var convertEscapes = function (match) {
        return "\\" + escapes[match];
    }
    var template = function (tpl, settings) {
        // 可以在外部修改template.templateSettings来自定义语法
        // 一定要保证evaluate在最后,不然会匹配到<%=%>和<%-%>
        var templateSettings = Object.assign({}, {
            interpolate: /<%=([\s\S]+?)%>/g,
            escape: /<%-([\s\S]+?)%>/g,
            evaluate: /<%([\s\S]+?)%>/g,
        }, template.templateSettings);
        settings = Object.assign({}, settings);
        // /<%=([\s\S]+?)%>|<%-([\s\S]+?)%>|<%([\s\S]+?)%>|$/g
        // 其中$是为了匹配字符串的最后一个字符
        var matcher = RegExp(Object.keys(templateSettings).map(function (key) {
            return templateSettings[key].source
        }).join("|") + "|$", "g")
        var source = "", index = 0;
        // 字符串拼接,要拼接上没有匹配到的字符串和替换匹配到的字符串
        tpl.replace(matcher, function (match, interpolate, escape, evaluate, offset) {
            source += "__p += '" + tpl.slice(index, offset).replace(escaper, convertEscapes) + "'\n";
            index = offset + match.length;
            if (evaluate) {
                source += evaluate + "\n"
            } else if (interpolate) {
                source += "__p += (" + interpolate + ") == null ? '' : " + interpolate + ";\n"
            } else if (escape) {
                source += "__p += (" + escape + ") == null ? '' : " + html2Entity(escape) + ";\n"
            }
            return match;
        })
        source = "var __p = '';" + source + 'return __p;'
        // 使用with可以修改作用域
        if (!settings.variable) source = "with(obj||{}) {\n" + source + "\n}"
        var render = new Function(settings.variable || "obj", source);
        return render
    }
    // 将templateY导出到全局
    root.templateY = template
}.call(this))

转义

我们知道,在字符串中有一些特殊字符是需要转义的,比如"'", '"',不然就会和预期展示不一致,甚至是报错,所以我们一般会用反斜杠来表示转义,常见的转义字符有\n, \t, \r等等。
但是这里的convertEscapes里面我们为什么要多加一个反斜杠呢?
这是因为在执行new Function里面的语句时,也需要对字符进行一次转义,可以看一下下面这行代码:

var log = new Function("var a = '1\n23';console.log(a)");
log() // Uncaught SyntaxError: Invalid or unexpected token

这是因为Function函数在执行的时候,里面的内容被解析成了这样。

var a = '1
23';console.log(a)

在JS里面是不允许字符串换行出现的,只能使用转义字符\n。

正则表达式

underscore中摒弃了用正则表达式匹配for/if/switch/{/}等语句的做法,而是使用了不同的模板语法(<%=%>和<%%>)来区分当前是变量还是JS语句,这样虽然需要用户自己区分语法,但是给开发者减少了很多不必要的麻烦,因为如果用正则来匹配,那么后面就无法使用类似{##}的语法了。
这里正则表达式的重点是+?,+?是惰性匹配,表示以最少的次数匹配到[\s\S],所以我们/<%=([\s\S]+?)%>/g是不会匹配到类似<%=name<%=age%>%>这种语法的,只会匹配到<%=name%>语法。

replace

这里我们用到了replace第二个参数是函数的情况。

var pattern = /([a-z]+)\s([a-z]+)/;
var str = "hello world";
str.replace(pattern, function(match, p1, p2, offset) {
    // p1 is "hello"
    // p2 is "world"
    return match;
})

在JS正则表达式中,使用()包起来的叫着捕获性分组,而使用(?:)的叫着非捕获性分组,在replace的第二个参数是函数时,每次匹配都会执行一次这个函数,这个函数第一个参数是pattern匹配到的字符串,在这个里面是"hello world"。
p1是第一个分组([a-z]+)匹配到的字符串,p2是第二个分组([a-z]+)匹配到的字符串,如果有更多的分组,那还会有更多参数p3, p4, p5等等,offset是最后一个参数,指的是在第几个索引处匹配到了,这里的offset是0,因为是从一开始就刚好匹配到了hello world。

字符串拼接

underscore中使用+=字符串拼接的方式代替了数组push的方式,这样是因为+=相比push的性能会更高。
我这里进行了一下测试,在新版chrome中,下面这段代码中,push的效率要远远好于+=,但是在v8中结果却是相反。

var arr = [], str = "";
var i = 0, j = 0
console.time();
for(;i<100000;i++) {
  arr.push(i);
}
arr.join("");
console.timeEnd()

console.time();
for(;j<100000;j++) {
  str+= j
}
console.timeEnd()

setting.variable

underscore这里使用with来改变了作用域,但是with会导致性能比较差,关于with的弊端可以参考一下这篇文章: Javascript中的with关键字
你还可以在variable设置里指定一个变量名,这样能显著提升模板的渲染速度。不过语法也和之前有一些不同,模板里面必须要用你指定的变量名来访问,而不能直接用answer这种形式,这种形式下没有使用with实现,所以性能会高很多。

_.template("Using 'with': <%= data.answer %>", {variable: 'data'})({answer: 'no'});

参考链接:

  1. js正则进阶
  2. JavaScript函数replace揭秘
  3. JavaScript正则表达式分组模式:捕获性分组与非捕获性分组及前瞻后顾
  4. underscore 系列之字符实体与 _.escape
  5. Javascript中的with关键字
  6. 高性能JavaScript模板引擎原理解析

Nuxt.js 登录页优化

Nuxt.js 登录页性能优化

前言

最近有测试和 local 投诉,我们管理系统的登录页面经常加载很久,常常会有页面已经出来了,但是点击登录毫无反应,直到全部加载后才能登录。于是,他们提出让我们去优化。
这是一个好问题,登录页虽然不是移动端那种首页,但也是最先呈现给内部用户的。

定位耗时

遇到这种问题,首先需要找出耗时都花在了哪里,然后再去想具体办法去解决。首先,打开登录页面控制面板,Disable Cache 之后查看一下每个资源的耗时。

image.png-296.1kB

从图上可以明显看出来,有一个 2.2m 的文件足足耗时5s之久,文件的耗时主要在下载上面,看来主要的性能瓶颈就在这里了。
由于 JS 文件在腾讯云 CDN 上面配置了协商缓存(etag),所以在第二次加载的时候速度提升非常大,基本上不到 1s 就可以加载出来了。

image

那么这个大文件是什么文件呢?我去 Jenkins 上看一下构建记录,在 build 的时候看到这个文件就是基于第三方包打出来的 vendors 文件。

image.png-96.2kB

webpack4 splitChunks

既然知道这个是 vendors 文件了,那就来分析一下 webpack 构建。在 webpack4 里面出现了 splitChunk 来拆分 chunk 文件,webpack4 会有一个默认的 vendors chunk,它会把 node_modules 都给打成一个包,类似于:

optimization: {
    splitChunks: {
      chunks: 'initial',
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        }
      }
    }
  }

只不过,Nuxt 在这个基础上又拆分出了一个 commons ,配置规则如下:

optimization.splitChunks.cacheGroups.commons = {
  test: /node_modules[\\/](vue|vue-loader|vue-router|vuex|vue-meta|core-js|@babel\/runtime|axios|webpack|setimmediate|timers-browserify|process|regenerator-runtime|cookie|js-cookie|is-buffer|dotprop|nuxt\.js)[\\/]/,
  chunks: 'all',
  priority: 10,
  name: true
}

priority 代表优先级,如果两个 cacheGroups 里面都引用了同一个库,那么就根据优先级来判断优先把这个库打进哪个 chunk 里面。
很明显 commons 的优先级要高于 vendors,所以会把 test 规则匹配到的第三方包优先拆分出来,这几个主要是 Nuxt 中依赖的一些库。
本地执行了一次 analyze 后,得到的构建图是这样的,可以看出来 vendors 明显远比其他的包都要大,尤其是 xlsx、iview、moment、lodash 这几个库,几乎占了一大半体积。

image

优化

生成多 HTML

既然知道 vendors 包里面都是一些第三方库了,那么是否可以只打出登录页依赖的第三方库,然后只去加载这个 chunk 文件呢?我看了一下登录页逻辑很简单,不需要 lodash、moment,甚至连 iview 都不需要,完全可以自己去实现样式,这样就不必去加载体积这么大的 vendors chunk 了。

真是个好主意,可是问题来了,怎么才能不去加载 vendors 呢?如果是在 webpack 里面,这个很容易,我们可以通过 html-webpack-plugin 来加载多个 HTML 文件,针对登录页生成一个 HTML 文件,让它只去加载自身依赖的 chunk 文件。

于是我去看了一下 Nuxt 源码,发现这里还是暴露了配置给我们去定义一个新的 HTML 模板的。当然,到最后我也没去尝试这种方法,只是觉得应该可以实现。

image

从 HTML 模板中删除

Nuxt 会暴露给我们一个 app.html 模板文件,它会在服务端渲染出来数据,最后替换到这个文件里面。

<!DOCTYPE html>
<html {{ HTML_ATTRS }}>
  <head {{ HEAD_ATTRS }}>
    {{ HEAD }}
  </head>
  <body {{ BODY_ATTRS }}>
    {{ APP }}
  </body>
</html>

那么我们有没有可能在 Nuxt 替换这些占位符之前先去除掉不需要加载的 chunk 文件呢?其实也是可以的,只是需要修改到 Nuxt 的源码。修改了源码之后,还需要用 patch-package 去打一个补丁,这样就可以做到修改 node_modules 里面的代码。

打开项目的 node_modules 文件夹,找到 @nuxt/vue-renderer/dist/vue-renderer.js,在 SSRRenderer 这个类里面的 render 方法中,我们可以看到如下代码:

image

m.script.text({ body: true }) 这句代码拿到的就是最后页面上渲染出来的 script 标签,如果在这里匹配到 vendors 包,把它给排除掉,之后在页面上就不会加载这个 JS 文件了。

我这里的方案是这样的,首先把登录页不需要且体积很大的几个包(iview、moment、lodash)给单独打了一个 my-vendors 的包,在 Nuxt 源码中用正则表达式去匹配这个文件名,然后手动 replace 掉(记得要把 link 标签里面预加载的也一起替换掉)

// nuxt.config.js
config.optimization.splitChunks.cacheGroups.myVendors = {
          test: /node_modules[\\/](view-design|moment|moment-timezone|dayjs|crypto-js|simple-uploader\.js|vue2-google-maps|vuex-class|axios)[\\/]/,
          // cacheGroupKey here is `commons` as the key of the cacheGroup
          automaticNamePrefix: 'my-vendors', // 文件名以 my-vendors 为前缀
          name: true,
          chunks: 'all',
          priority: 10
          reuseExistingChunk: true
}

// vue-renderer.js
const scripts = APP.match(/(\<script[\s\S]*?>[\s\S]*?\<\/script\>)/g) || []
const script = scripts.find(s => s.indexOf('my-vendors') > -1);
APP = APP.replace(script, '')
const links = HEAD.match(/(\<link rel="preload" [\s\S]*?>)/g) || []
const link = links.find(s => s.indexOf('my-vendors') > -1);
HEAD = HEAD.replace(link || "", '')

最终的效果的确是不会加载这个文件了,但是点击事件失效了,对比前后两次加载的文件,差别只有这个 my-vendors.js,不清楚为什么点击事件失效,所以最终为了赶时间也就没使用这个方法。

服务端直出

除了上面两种方式之外,还有一种比较简单的方式。由于 Nuxt 本身就会启动一个服务,官方也支持我们使用 express\koa 等等来实现服务端的路由,所以我们可以把登录页面直接用纯服务端渲染,去掉所有不必要的第三方库和文件。

涉及到图片之类的,我事先把他们上传到了 CDN 上面,然后根据环境变量去加载不同的 CDN 地址。

// login/template.ts
export default (config) => {
  return `
    <html>
      <head>
        <title data-n-head="true">AirPay Admin</title>
        <meta data-n-head="true" charset="utf-8">
        <meta data-n-head="true" name="viewport" content="width=device-width, initial-scale=1">
        <meta data-n-head="true" data-hid="description" name="description" content="Admin">
        <link data-n-head="true" rel="icon" type="images/x-icon" href="/favicon.ico">
        <link data-n-head="true" rel="preconnect" href="${
          config.cdnServer.staticUrl
        }" crossorigin="true">
        <link data-n-head="true" rel="preconnect" href="${
          config.apiServer.baseUrl
        }" crossorigin="true">
        <style>
          article, aside, blockquote, body, button, dd, details, div, dl, dt, fieldset, figcaption, figure, footer, form, h1, h2, h3, h4, h5, h6, header, hgroup, hr, input, legend, li, menu, nav, ol, p, section, td, textarea, th, ul {
            margin: 0;
            padding: 0;
          }
          img {
            border-style: none;
          }
        </style>
        <script src="https://cdn.jsdelivr.net/npm/[email protected]/particles.min.js"></script>
      </head>
      <body>
        <div id="particles" class="particles"></div>
        <div onclick="login()" class="login">
          <div class="login-content">
            <div style="width:350px" class="login-wrapper">
              <div class="login-title">
                <p
                  slot="title"
                  style="text-transform: capitalize; color: #595d65; font-size: 16px; display: flex; height: 25px;"
                >
                  <img src="${
                    config.cdnServer.staticUrl
                  }/static/admin-website/logo.png" alt="logo" class="login-logo" />
                </p>
              </div>
              <div class="login-body">
                <div class="login-button" onclick="login()">
                  <div class="login-icon"></div>
                  <span class="login-text">Sign in with Google</span>
                </div>
              </div>
            </div>
          </div>
        </div>
        <script>
          particlesJS('particles', {
            "particles": {},
            "interactivity": {}
            }
          }, function() {
            console.log('callback - particles.js config loaded');
          });
        </script>
        <script>
          function login() {}
        </script>
      </body>
    </html>
  `
}

然后,在 /login 路由下面引入这个模块,传入必要的配置后直接输出,记得设置 Content-Typetext/html

// login/index.ts
module.exports = function(
  fastify: Fastify.FastifyInstance,
  opts: Fastify.RouteShorthandOptions,
  next: Function
) {
  fastify.get('/login', async (request, reply) => {
    reply
      .code(200)
      .header('Content-Type', 'text/html; charset=utf-8')
      .send(login(Config))
  })
  next()
}

// server/index.ts
fastify.register(require('./routes/login'), { prefix: '/' })

最后优化的效果也是非常明显的,不使用缓存的情况下耗时只有几百毫秒。

image

在开启了缓存之后,几乎是秒开,耗时只有短短 100ms,可以说性能得到了几十倍的提升。

image

总结

很多时候我们总会抱怨现在的工作都是重复劳动,找不到可以提升自己的地方,但如果用心去找,还是能发现团队开发中的不少痛点的,自己在解决这些痛点的时候也能学习到很多新知识。

一文搞懂 Dynamic Import 和 Top-level await 提案

1. 前言

随着 ES6 的发布,JavaScript 语法也越来越趋于成熟,新的提案也在不断地提出。

ECMA 提案一共有四个阶段,处于 Stage3 的都需要我们持续关注,以后很可能就会被纳入新标准中。

今天主要来深入讲解一下动态 import 和 Top-level await。

动态import

1. Dynamic Import

如果你写过 Node,会发现和原生的 import/export 有个不一样的地方就是 Node 支持就近加载。

Node 允许你可以在用到的时候再去加载这个模块,而不用全部放到顶部加载。

而 ES Module 的语法是静态的,会自动提升到代码的顶层。

以下面这个 Node 模块为例子,最后依次打印出来的是 mainnoop

// noop.js
console.log('noop');
module.exports = function() {}
// main.js
console.log('main')
const noop = require('./noop')

如果换成 import/export,不管你将 import 放到哪里,打印结果都是相反的。比如下面依次打印的是 noopmain

// noop.js
console.log('noop');
export default function() {}
// main.js
console.log('main')
import noop from './noop'

在我们前端开发中,为了优化用户体验,往往需要对页面资源按需加载。

如果只想在用户进入某个页面的时候再去加载这个页面的资源,那么就可以配合路由去动态加载资源。

1.1 React Suspense

在很久很久之前,我们都是用 webpack 提供的 require.ensure() 来实现 React 路由切割。

const rootRoute = {
  path: '/',
  indexRoute: {
    getComponent(nextState, cb) {
      require.ensure([], (require) => {
        cb(null, require('pages/Home'))
      }, 'Home')
    },
  },
  getComponent(nextState, cb) {
    require.ensure([], (require) => {
      cb(null, require('pages/Login'))
    }, 'Login')
  }
}

ReactDOM.render(
  (
    <Router
      history={browserHistory}
      routes={rootRoute}
      />
  ), document.getElementById('app')
);

在 React16 中,已经提供了 Suspense/lazy 支持了按需加载。我们可以通过 Dynamic Import 来加载页面,配合 Suspense 实现路由分割。

import react, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

const Home = lazy(() => import('./pages/home'))
const Login = lazy(() => import('./pages/login'))
function Routes() { 
    return (
        <Router>
            <Suspense fallback={<div>loading</div>}>
                <Switch>
                    <Route exact path="/" component={Home} />
                     <Route path="/login" component={Login} />
                </Switch>
            </Suspense>
        </Router>
    )
}

1.2 动态 import 提案

由于各种历史原因,一个动态 import 的提案就被提了出来,这个提案目前已经走到了 Stage4 阶段。

通过动态 import 允许我们按需加载 JavaScript 模块,而不会在最开始的时候就将全部模块加载。

const router = new Router({
    routes: [{
        path: '/home',
        name: 'Home',
        component: () =>
            import('./pages/Home.vue')
    }]
})

动态 import 返回了一个 Promise 对象,这也意味着可以在 then 中等模块加载成功后去做一些操作。

<nav>
  <a href="books.html" data-entry-module="books">Books</a>
  <a href="movies.html" data-entry-module="movies">Movies</a>
  <a href="video-games.html" data-entry-module="video-games">Video Games</a>
</nav>

<main>Content will load here!</main>

<script>
  const main = document.querySelector("main");
  for (const link of document.querySelectorAll("nav > a")) {
    link.addEventListener("click", e => {
      e.preventDefault();

      import(`./section-modules/${link.dataset.entryModule}.js`)
        .then(module => {
          module.loadPageInto(main);
        })
        .catch(err => {
          main.textContent = err.message;
        });
    });
  }
</script>

1.3 手动实现一个动态 import 函数

其实我们自己也完全可以通过 Promise 来封装这样一个 api,核心在于动态生成 script 标签,在 script 中导入需要懒加载的模块,将其挂载到 window 上面。

function importModule(url) {
    return new Promise((resolve, reject) => {
        const script = document.createElement("script");
        script.type = "module";
        script.textContent = `import * as m from "${url}"; window.tempModule = m;`;
    })
}

当 script 的 onload 事件触发之时,就把 tempModule 给 resolve 出去,同时删除 window 上面的 tempModule

function importModule(url) {
  return new Promise((resolve, reject) => {
    const script = document.createElement("script");
    const tempGlobal = "__tempModuleLoadingVariable" + Math.random().toString(32).substring(2);
    script.type = "module";
    script.textContent = `import * as m from "${url}"; window.${tempGlobal} = m;`;

    script.onload = () => {
      resolve(window[tempGlobal]);
      delete window[tempGlobal];
      script.remove();
    };

    script.onerror = () => {
      reject(new Error("Failed to load module script with URL " + url));
      delete window[tempGlobal];
      script.remove();
    };

    document.documentElement.appendChild(script);
  });
}

这个 importModule 也是官方推荐的在不支持动态 import 的浏览器环境中的一种实现。

2. Top-level await

前面讲了动态 import,但是如果想在动态引入某个模块之后再导出当前模块的数据,那么该怎么办呢?

如果在模块中我依赖了某个需要异步获取的数据之后再导出数据怎么办?

2.1 ES Module 的缺陷

如果你认真研究过 ES Module 和 CommonJS,会发现两者在导出值的时候还有一个区别。

可以简单地理解为,CommonJS 导出的是快照,而 ES Module 导出的是引用。

举个栗子:

我们在模块 A 里面定义一个变量 count,将其导出,同时在这个模块中设置 1000ms 之后修改 count 值。

// moduleA.js
export let count = 0;
setTimeout(() => {
    count = 10;
}, 1000)

// moduleB.js
import { count } from 'moduleA'

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

你会觉得这两次输出会有什么不一样吗?这个 count 怎么看都是一个基本类型,难道 2000ms 之后输出还会变化不成?

没错,在 2000ms 后再去打印 count 的确是会变化,你会发现 count 变成了 10,这也意味着 ES Module 导出的时候并不会用快照,而是从引用中来获取值。

而在 CommonJS 中则完全相反,CommonJS 中两次都输出了 0,这意味着 CommonJS 导出的是快照。

2.2 IIAFEs 的局限性

已知在 JS 中使用 await 都要在外面套一个 async 函数,如果想要导出一个异步获取之后的值,传统的做法如下:

// awaiting.mjs
import { process } from "./some-module.mjs";
let output;
async function main() {
  const dynamic = await import(computedModuleSpecifier);
  const data = await fetch(url);
  output = process(dynamic.default, data);
}
main();
export { output };

或者使用 IIAFE,由于这种模式和 IFEE 比较像,所以被叫做 Immediately Invoked Async Function Expression,简称 IIAFE。

// awaiting.mjs
import { process } from "./some-module.mjs";
let output;
(async () => {
  const dynamic = await import(computedModuleSpecifier);
  const data = await fetch(url);
  output = process(dynamic.default, data);
})();
export { output };

但是这两种做法有一个问题,如果导入这个模块后立即使用 output,那么拿到的是个 undefined,因为异步加载的数据还没有获取到。一直到异步加载的数据拿到了之后,才能导入正确的值。

想要拿到异步加载之后的数据,最粗暴的方式就是在一段时间之后再去获取这个 output,例如:

import { output } from './awaiting'
setTimeout(() => {
    console.log(output)
}, 2000)

2.3 升级版的 IIAFEs

当然上面的这种做法也很不靠谱,毕竟谁也不知道异步加载要经过多少秒才返回,所以就诞生了另外一种写法,直接导出整个 async 函数 和 output 变量。

// awaiting.mjs
import { process } from "./some-module.mjs";
let output;
export default (async () => {
  const dynamic = await import(computedModuleSpecifier);
  const data = await fetch(url);
  output = process(dynamic.default, data);
})();
export { output };

导入 async 函数之后,在 then 方法里面再去使用我们导入的 output 变量,这样就确保了数据一定是动态加载之后的。

// usage.mjs
import promise, { output } from "./awaiting.mjs";
export function outputPlusValue(value) { return output + value }

promise.then(() => {
  console.log(outputPlusValue(100));
  setTimeout(() => console.log(outputPlusValue(100), 1000);
});

2.4 Top-level await

Top-level await 允许你将整个 JS 模块视为一个巨大的 async 函数,这样就可以直接在顶层使用 await,而不必用 async 函数包一层。
那么来重写上面的例子吧。

// awaiting.mjs
import { process } from "./some-module.mjs";
const dynamic = import(computedModuleSpecifier);
const data = fetch(url);
export const output = process((await dynamic).default, await data);

可以看到,直接在外层 使用 await 关键字来获取 dynamic 这个 Promise 的返回值,这种写法解决了原来因为 async 函数导致的各种问题。

Top-level await 现在处于 Stage3 阶段。

❤️ 看完三件事

如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  1. 点赞,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)
  2. 关注公众号「前端小馆」,或者加我个人微信号「testygy」拉你进群,不定期分享原创知识。
  3. 也看看其它文章

underscore源码剖析之基础方法

本文是underscore源码剖析系列的第二篇,主要介绍underscore中一些基础方法的实现。

mixin

在上篇文章underscore整体架构分析中,我们讲过**_上面的方法有两种挂载方式,一个是挂载到_构造函数上以_.map(arr)的形式直接调用(在后文上统称构造函数调用),另一种则是挂到_.prototype上以_(arr).map()的形式被实例调用(在后文上统称原型调用)**。

翻一遍underscore源码你会发现underscore中的方法都是直接挂到_构造函数上实现的,但是会通过mixin方法来将_上面的方法扩展到_.prototype上面,这样这些方法既可以直接调用,又可以通过实例来调用。

_.mixin = function(obj) {
    // 遍历obj上所有的方法
    _.each(_.functions(obj), function(name) {
        // 保存方法的引用
        var func = _[name] = obj[name];
        _.prototype[name] = function() {
            // 将一开始传入的值放到数组中
            var args = [this._wrapped];
            // 将方法的参数一起push到数组中(这里处理的很好,保证了func方法参数的顺序)
            push.apply(args, arguments);
            // 这里先用apply方法执行了func,并将结果传给了result
            return result(this, func.apply(_, args));
        };
    });
};

_.mixin(_);

从这段代码中我们可以看出,mixin方法将_上的所有方法通过遍历的形式挂载到了_.prototype上面。

细心观察一下,构造函数调用和原型调用的区别在哪里?
没错,区别就在于调用方式和传参,构造函数调用时一般会把要处理的值当做第一个参数传入,而原型调用的时候会把要处理的值传入_构造函数来创建一个实例。

var arr = [1, 2, 3]
var func = function(item) {
    console.log(item);
}
// 构造函数调用时arr被传入第一个参数
_.each(arr, func)
// 原型调用的时候,arr被当做参数传给_方法来创建一个实例
_(arr).each(func)
// 链式调用,和上面类似
_.chain(arr).each(func)

从上一节中我们知道,在创建一个_的实例时,会用this._wrapped将传入的值保存起来,所以在mixin里面这一句:var args = [this._wrapped];是将我们传给_的值放到args数组第一项中,之后再将arguments也放入args数组中,借助apply方法执行当前遍历的方法(在这个例子中是each),这个时候传给each方法的是arr和func,正好和原来直接_.each调用each传入参数的顺序是一样的(underscore中的方法第一项基本上都是要处理的数据)。

链式调用

那么上面最后return result(this, func.apply(_, args)),result又是做什么的呢?

首先来看result源码:

var result = function(instance, obj) {
    // 首先判断是否使用链式调用,如果是,那就继续将刚刚执行后返回的结果链式调用一下,如果不是,则直接返回执行后的结果
    return instance._chain ? _(obj).chain() : obj;
};
_.chain = function(obj) {
    // 创建一个实例
    var instance = _(obj);
    // 给这个实例加个_chain属性来表明这是链式调用
    instance._chain = true;
    return instance;
};

我们知道underscore中也是有和jQuery类似的链式调用,来看一下链式调用的例子:

var arr = [1, 2, 3]
var newArr = _.chain(a).map(function(item) {
    return item + 1
}).filter(function(item) {
    return item > 2
}).value()

链式调用的关键在于每次执行方法后都需要返回一个实例,以确保能够继续调用其他方法。
chain方法会用传入的obj创建一个_的实例,这个实例可以调用原型上的方法。从上面mixin的实现来看,每次调用原型方法后会将执行后的结果传给result方法,在result内部会判断你是否使用了链式调用(chain),如果是链式的,那么就会将返回结果链式化(传入chain中创建新的实例)。
链式调用一定要在结尾执行value方法,不然最后返回的是一个对象(最后一次创建的_实例)

数组函数

underscore构造方法上面并没有直接对push、pop、shift等数组方法进行实现,但是链式调用的时候往往需要用到这些方法,所以在原型上对这些方法做了一些封装,实现方法和mixin类似,这里不再多做解释。

_.each(['pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'], function(name) {
    var method = ArrayProto[name];
    _.prototype[name] = function() {
    var obj = this._wrapped;
    method.apply(obj, arguments);
    // 这句是好像是为了解决ie上的bug?
    if ((name === 'shift' || name === 'splice') && obj.length === 0) delete obj[0];
        return result(this, obj);
    };
});

_.each(['concat', 'join', 'slice'], function(name) {
    var method = ArrayProto[name];
    _.prototype[name] = function() {
        return result(this, method.apply(this._wrapped, arguments));
    };
});

策略模式和表驱动优化你的条件语句

在我们平时的开发中,if else是最常用的条件判断语句。在一些简单的场景下,if else用起来很爽,但是在稍微复杂一点儿的逻辑中,大量的if else就会让别人看的一脸蒙逼。
如果别人要修改或者新增一个条件,那就要在这个上面继续增加条件。这样恶性循环下去,原本只有几个if else最后就有可能变成十几个,甚至几十个。
别说不可能,我就见过有人在React组件里面用了大量的if else,可读性和可维护性非常差。(当然,这个不算if else的锅,主要是组件设计的问题)

这篇文章主要参与自《代码大全2》,原书中使用vb和java实现,这里我是基于TypeScript的实现,对书中内容加入了一些自己的理解。

从一个例子说起

日历

假如我们要做一个日历组件,那我们肯定要知道一年12个月中每个月都多少天,这个我们要怎么判断呢?
最笨的方法当然是用if else啊。

if (month === 1) {
    return 31;
}
if (month === 2) {
    return 28;
}
...
if (month === 12) {
    return 31;
}

这样一下子就要写12次if,白白浪费了那么多时间,效率也很低。
这个时候就会有人想到用switch/case来做这个了,但是switch/case也不会比if简化很多,依然要写12个case啊!!!甚至如果还要考虑闰年呢?岂不是更麻烦?
我们不妨转换一下思维,每个月份对应一个数字,月份都是按顺序的,我们是否可以用一个数组来储存天数?到时候用下标来访问?

const month: number = new Date().getMonth(),
    year: number = new Date().getFullYear(),
    isLeapYear: boolean = year % 4 == 0 && year % 100 != 0 || year % 400 == 0;

const monthDays: number[] = [31, isLeapYear ? 29 : 28, 31, ... , 31];
const days: number = monthDays[month];

概念

看完上面的例子,相信你对表驱动法有了一定地认识。这里引用一下《代码大全》中的总结。

表驱动法就是一种编程模式,从表里面查找信息而不使用逻辑语句。事实上,凡是能通过逻辑语句来选择的事物,都可以通过查表来选择。对简单的情况而言,使用逻辑语句更为容易和直白。但随着逻辑链的越来越复杂,查表法也就愈发显得更具吸引力。

使用表驱动法前需要思考两个问题,一个是如何从表中查询,毕竟不是所有场景都像上面那么简单的,如果if判断的是不同的范围,这该怎么查?
另一个则是你需要在表里面查询什么,是数据?还是动作?亦或是索引?
基于这两个问题,这里将查询分为以下三种:

  1. 直接访问
  2. 索引访问
  3. 阶梯访问

直接访问表

我们上面介绍的那个日历就是一个很好的直接访问表的例子,但是很多情况并没有这么简单。

统计保险费率

假设你在写一个保险费率的程序,这个费率会根据年龄、性别、婚姻状态等不同情况变化,如果你用逻辑控制结构(if、switch)来表示不同费率,那么会非常麻烦。

if (gender === 'female') {
    if (hasMarried) {
        if (age < 18) {
            //
        } else if (age < 65) {
            //
        } else {
            // 
        }
    } else if (age < 18) {
        //
    } else if (age < 65) {
        //
    } else if {
        //
    }
} else {
    ...
}

但是从上面的日历例子来看,这个年龄却是个范围,不是个固定的值,没法用数组或者对象来做映射,那么该怎么办呢?这里涉及到了上面说的问题,如何从表中查询?
这个问题可以用阶梯访问表和直接访问表两种方法来解决,阶梯访问这个后续会介绍,这里只说直接访问表。
有两种解决方法:
1、复制信息从而能够直接使用键值
我们可以给1-17年龄范围的每个年龄都复制一份信息,然后直接用age来访问,同理对其他年龄段的也都一样。这种方法在于操作很简单,表的结构也很简单。但有个缺点就是会浪费空间,毕竟生成了很多冗余信息。
2、转换键值
我们不妨再换种思路,如果我们把年龄范围转换成键呢?这样就可以直接来访问了,唯一需要考虑的问题就是年龄如何转换为键值。
我们当然可以继续用if else完成这种转换。前面已经说过,简单的if else是没什么问题的,表驱动只是为了优化复杂的逻辑判断,使其变得更灵活、易扩展。

enum genders {
    lessThan18 = '<18',
    between18And56 = '18-65',
    moreThan56 = '>65'
}
enum genders {
    female = 0,
    male = 1
}
enum marry = {
    unmarried = 0,
    married = 1
}
const age2key = (age: number): string => {
    if (age < 18) {
        return genders.lessThan18
    }
    if (age < 65) {
        return genders.between18And56
    }
    return genders.moreThan56
}
const premiumRate: {
    [genders: string]: {
        [marry: string]: {
            rate: number
        }
    }
} = {
    [genders.lessThan18]: {
        [genders.female]: {
            [marry.unmarried]: {
                rate: 0.1
            },
            [marry.married]: {
                rate: 0.2
            }
        },
        [genders.male]: {
            [marry.unmarried]: {
                rate: 0.3
            },
            [marry.married]: {
                rate: 0.4
            }
        }
    },
    [genders.between18And56]: {
        [genders.female]: {
            [marry.unmarried]: {
                rate: 0.5
            },
            [marry.married]: {
                rate: 0.6
            }
        },
        [genders.male]: {
            [marry.unmarried]: {
                rate: 0.7
            },
            [marry.married]: {
                rate: 0.8
            }
        }
    },
    [genders.moreThan56]: {
        [genders.female]: {
            [marry.unmarried]: {
                rate: 0.5
            },
            [marry.married]: {
                rate: 0.6
            }
        },
        [genders.male]: {
            [marry.unmarried]: {
                rate: 0.7
            },
            [marry.married]: {
                rate: 0.8
            }
    }
}
const getRate = (age: number, hasMarried: 0 | 1, gender: 0 | 1) => {
     const ageKey: string = age2key(age);
     return premiumRate[ageKey]
        && premiumRate[ageKey][gender]
        && premiumRate[ageKey][gender][hasMarried]
}

索引访问表

我们前面那个保险费率问题,在处理年龄范围的时候很头疼,这种范围往往不像上面那么容易得到key。
我们当时提到了复制信息从而能够直接使用键值,但是这种方法浪费了很多空间,因为每个年龄都会保存着一份数据,但是如果我们只是保存索引,通过这个索引来查询数据呢?
假设人刚出生是0岁,最多能活到100岁,那么我们需要创建一个长度为101的数组,数组的下标对应着人的年龄,这样在0-17的每个年龄我们都储存'<18',在18-65储存'18-65', 在65以上储存'>65'。
这样我们通过年龄就可以拿到对应的索引,再通过索引来查询对应的数据。
看起来这种方法要比上面的直接访问表更复杂,但是在一些很难通过转换键值、数据占用空间很大的场景下可以试试通过索引来访问。

const ages: string[] = ['<18', '<18', '<18', '<18', ... , '18-65', '18-65', '18-65', '18-65', ... , '>65', '>65', '>65', '>65']
const ageKey: string = ages[age];

阶梯访问表

同样是为了解决上面那个年龄范围的问题,阶梯访问没有索引访问直接,但是会更节省空间。
为了使用阶梯方法,你需要把每个区间的上限写入一张表中,然后通过循环来检查年龄所在的区间,所以在使用阶梯访问的时候一定要注意检查区间的端点。

const ageRanges: number[] = [17, 65, 100],
  keys: string[] = ['<18', '18-65', '>65'],
  len: number = keys.length;
const getKey = (age: number): string => {
  for (let i = 0; i < len; i++) {
    console.log('i', i)
    console.log('ageRanges', ageRanges[i])
    if (age <= ageRanges[i]) {
      return keys[i]
    }
  }
  return keys[len-1];
}

阶梯访问适合在索引访问无法适用的场景,比如如果是浮点数,就无法用索引访问创建一个数组来拿到索引。
在数据量比较大的情况下,考虑用二分查找来代替顺序查找,。
在大多数情况下,优先使用直接访问和索引访问,除非两者实在无法处理,才考虑使用阶梯访问。

从这三种访问表来看,主要是为了解决如何从表中查询,在不同的场景应该使用合适的访问表。

参考资料:

  1. 代码大全(第2版)

周报(2018-12-14)

这周的主要任务都是在做IBU酒店列表页,因为大家都是第一次用Mobx,所以我一直在探索Mobx的最佳实践,这里是自己总结的一些经验。

更响应式

我最喜欢mobx的地方就是和vue一样的数据监听,框架底层通过Object.defineProperty或Proxy来劫持数据,对组件可以进行更细粒度的渲染。在react中反而把更新组件的操作(setState)交给了使用者,由于setState的"异步"特性导致了没法立刻拿到更新后的state。

computed

想像一下,在redux中,如果一个值A是由另外几个值B、C、D计算出来的,在store中该怎么实现?
如果要实现这么一个功能,最麻烦的做法是在所有B、C、D变化的地方重新计算得出A,最后存入store。
当然我也可以在组件渲染A的地方根据B、C、D计算出A,但是这样会把逻辑和组件耦合到一起,如果我需要在其他地方用到A怎么办?
我甚至还可以在所有connect的地方计算A,最后传入组件。但由于redux监听的是整个store的变化,所以无法准确的监听到B、C、D变化后才重新计算A。
但是mobx中提供了computed来解决这个问题。正如mobx官方介绍的一样,computed是基于现有状态或计算值衍生出的值,如下面todoList的例子,一旦已完成事项数量改变,那么completedCount会自动更新。

class TodoStore {
    @observable todos = []
    @computed get completedCount() {
		return (this.todos.filter(todo => todo.isCompleted) || []).length
	}
}

reaction

reaction则是和autorun功能类似,但是autorun会立即执行一次,而reaction不会,使用reaction可以在监听到指定数据变化的时候执行一些操作,有利于和副作用代码解耦。

// 当todos改变的时候将其存入缓存
reaction(
    () => toJS(this.todos),
    (todos) =>  localStorage.setItem('mobx-react-todomvc-todos', JSON.stringify({ todos }))
)

拆分store

mobx中的store的创建偏向于面向对象的形式,mobx官方给出的例子todomvc中的store更接近于mvc中的model。
但是这样也会带来一个问题,业务逻辑我们应该放到哪里?如果也放到store里面很容易造成不同store之间数据的耦合,因为业务代码必然会耦合不同的数据。
我参考了dobjs后,推荐将store拆分为action和dataModel两种。
action和dataModel一起组合成了页面的总store,dataModel只存放UI数据以及只涉及自身数据变化的action操作(在mobx严格模式中,修改数据一定要用action或flow)。
action store则是负责存放一些需要使用来自不同store数据的action操作。
我个人理解,dataModel更像MVC中的model,action store是controller,react components则是view,三者构成了mvc的结构。

- stores
    - actions
        - hotelListAction.js
    - dataModel
        - globalStatus.js
        - hotelList.js
    - index.js
// globalStatus
class GlobalStatus {
    @observable isShowLoading = false;
    @action showLoading = () => {
        this.isShowLoading = true
    }
    @action hideLoading = () => {
        this.isShowLoading = false
    }
}
// hotelList
class HotelList {
    @observable hotels = []
    @action addHotels = (hotels) => {
        this.hotels = [...toJS(this.hotels), ...hotels];
    }
}
// hotelListAction
class HotelListAction {
    fetchHotelList = flow(function *() {
        const {
            globalStatus,
            hotelList
        } = this.rootStore
        globalStatus.showLoading();
        try {
            const res = yield fetch('/hoteList', params);
            hotelList.addHotels(res.hotels);
        } catch (err) {
        } finally {
            globalStatus.hideLoading();
        }
    }).bind(this)
}

store结构

细粒度的渲染

observer可以给组件增加订阅功能,一旦收到数据变化的通知就会将组件重新渲染,从而做到更细粒度的更新,这是redux和react很难做到的,因为react中组件重新渲染基本是依赖于setState和接收到新的props,子组件的渲染几乎一定会伴随着父组件的渲染。
也许很多人没有注意到,mobx-react中还提供了一个Observer组件,这个组件接收一个render方法或者render props。

const App = () => <h1>hello, world</h1>;
<Observer>{() => <App />}</Observer>
<Observer render={() => <App />} />

也许你要问这个和observer有什么区别?还写的更加复杂了,下面这个例子对比起来会比较明显。

import { observer, Observer, observable } from 'mobx-react'
const App = observer(
    (props) => <h1>hello, {props.name}</h1>
)
const Header = (props) => <h1>this is header</h1>
const Footer = (props) => <h1>this is footer</h1>
const Container = observer(
    (props) => {
        return (
            <>
                <Header />
                <App name={props.person.name} />
                <Footer />
            </>
        )
    }
)
const person = observable({name: "gyyin"});
render(<Container person={person} />, document.getElementById("app"));
person.name = "world";

上面这个代码,Container组件监听到person.name改变的时候会重新渲染,这样就导致了原本不需要重新渲染的Header和Footer也跟着渲染了,如果使用Observer就可以做到更细粒度的渲染。

const App = (props) => <h1>hello, {props.name}</h1>
const Header = (props) => <h1>this is header</h1>
const Footer = (props) => <h1>this is footer</h1>
const Container = (props) => {
    return (
        <>
            <Header />
            <Observer render={
                () => <App name={props.person.name} />
            }>
            <Footer />
        </>
    )
}
const person = observable({name: "gyyin"});
render(<Container person={person} />, document.getElementById("app"));
person.name = "world";

如果在Header和Footer里面做console.log,你会发现只有被Observer包裹的App组件进行了重新渲染,由于Container没有订阅数据变化,所以也不会重新渲染。
但如果不是对性能有极致的追求,observer已经足够了,大量的Observer会花费你很多精力来管理渲染问题。

参考链接:

  1. 如何组织Mobx中的Store之一:构建State、拆分Action
  2. 面向未来的前端数据流框架 - dob
  3. 为什么我们需要reselect

技术和方案,不能拆开看

技术和方案,不能拆开看

前言

对于很多前端工程师来说,前端技术变化日新月异,很多人热衷于研究新技术。常常出现为了用上新技术,将其用在不适合的场景,导致事倍功半。
比如我们要做个管理系统,你偏要上服务端渲染,我们要做个 h5 项目,你偏要搞微前端,这就属于牛头不对马嘴。
今天,我就从最近遇到的一个例子来聊聊技术和方案的关系。

背景

在开发 APP 的时候,我们常常会遇到发版的问题。不仅需要通过应用商店的审核,还要考虑兼容老的版本。
尤其是对国际化的 APP 来说,翻译往往是很重要的一环。如果不小心翻译错误,那么想立马修复线上的问题就比较麻烦。所以动态化加载翻译文案就比较重要。
我们公司有一个 Transify 平台,这个平台是越南或者泰国团队的同事做翻译用的。我们每次提出需求,由他们来翻译成对应的泰文和越南文。
刚好隔壁组做了一个 ccms 配置中心的项目,允许我们在上面创建应用以及子业务,大概就是 Ant Design 这种树状结构。他们希望以后能推广到整个公司,将每个团队动态配置的东西放到上面,避免 hard code

image.png-58.6kB

客户端团队原本也是在应用启动的时候,直接去调 Transify 的接口,但这样不够灵活。他们也无法感知谁修改了某个 key,会不会有一些问题。
于是,产品给我们提需求,希望能够接入配置中心,将翻译文案做成动态配置的,可以按照版本进行差量更新。

在 ccms 上面我们可以创建一个大的应用,以及下面对应的子应用,在我们这边常常是按照 部门 -> 业务 -> 子应用 -> 地区 进行的分级,每个叶子节点存储每个子应用的配置信息。

PS:为什么不直接在 Transify 上做呢?因为 Transify 是新加坡团队做的,我们很难去推动新加坡那边帮我们做这些。

一期方案

为了做到差量更新,那么翻译 key 的粒度就一定要细,细化到每个 key 的维度,每次进行一次 diff,比较出来新老版本的差异,将差异部分返回给客户端。

于是,在 ccms 这一侧,安卓和 iOS 会创建不同的 branch,每个 branch 下面会根据地区区分创建 en、th 或者 en、vn 的子 branch,每个子 branch 下面的叶子节点就是我们的翻译 key,每个 key 的 value 就是翻译文案。

image.png-243.6kB

那么怎么把 Transify 的数据同步到 ccms 上面呢?定时调接口去拉?这样理论上也是可行的。但 ccms 团队希望他们能够做得更通用,以后是服务于整个公司的,不应该去接入业务层的东西。

那为什么不直接把翻译放到 ccms 上面呢?这样客户端只需要和 ccms 对接就行了。因为越南和泰国的同事都是使用 Transify 更新翻译的,他们根本不会用我们内部的 ccms 平台。

所以他们决定在我们的 Admin 系统新增一个页面,每次点击 Search 的时候就去拉 Transify 平台的翻译数据和 ccms 平台的翻译数据,diff 对比出 ccms 上面需要添加、更新、删除的 key 展示在页面上,然后允许手动 submit 到 ccms 上面。

image

数据存入 ccms 后,也需要在他们那边手动对有更新的 branch 发布一个新的版本,打上 version 的标签。

ccms 侧提供接口给客户端,在客户端启动的时候调用 ccms 的接口告诉他们本地的 branch 版本,然后 ccms 对两边的 version 进行对比。如果不一致,那就返回当前 branch 下面所有 key 的 version(只返回 version 有利于减少传输数据的体积)。

客户端拿到新的 version 后,和本地 key 的 version 进行对比。将需要更新的 key 再传给 ccms 接口来获取这些 key 的数据。

大致的流程如下:

image

缺陷

第一期按照这个方案做了出来,每次点击搜索的时候,Admin 侧去从 Transify 和 ccms 两边获取数据,然后进行一次 diff 的对比,对比出来 ccms 上面的哪些 key 是需要添加、更新、删除的,然后再调用接口存入 ccms 的数据库里面。

问题就在于从两边拉数据这步,每次点击搜索之后,页面加载肉眼可见的慢。分析了一下,diff 耗时 100 ms 左右,Transify 平台上面接口耗时 4-5s,而 ccms 上面达到了惊人的 10s。即使我们把 ccms 接口耗时优化到了 1s,但性能还是受限于 Transify 平台的 4-5s,毕竟我们很难推动新加坡团队帮我们优化。

那为什么耗时会这么多呢?主要原因是请求的数据量过大,每次都是传输了 8000 个 key-value 的 json 对象,大小差不多有 1m 多。

除此之外,由于翻译 key 过多,导致在 ccms 上面创建了上万个叶子结点,这已经违反了 ccms 平台创建的初衷。他们不应该为了兼容一些特殊的业务,把业务逻辑也接入进来。

技术固然重要,可再新再好的技术在这种场景下也无能为力,方案的重要性就体现出来了。

PS:为什么一期的方案设计这么糟糕呢?其实在做一期的时候,原本我们没有想过做翻译 key 的差量更新,只是打算在 Admin 上每次把从 Transify 获取到的翻译 key 全部存入 ccms 里面,ccms 也每次下发给客户端全量的翻译 key,但客户端觉得性能太差,不同意这种做法。最终我们三方达成了妥协让步,使用了一期的方案,却也没料到性能那么差。

二期优化

在一期的基础上,我们针对这两个痛点提出了几个优化方向。

  1. ccms 不再存储翻译 key,所有的翻译应当存在 cdn 文件里面,ccms 只存 cdn 的地址。这样上万的叶子节点就可以优化成一个。在 Admin 提交发布的时候,会把差量数据写入到 cdn 文件里面。而客户端消费的时候,会使用 bs diff 算法进行二进制文件的差分,比较出新老两个文件的差异,将差量 patch 进去。
  2. Admin 侧不再等 Search 的时候实时进行 diff,而是增加一个 NodeJS 服务,每10分钟拉一下 Transify 和 ccms 的数据,进行 diff 操作,然后将 diff 后的结果保存起来。当在 Admin 点击 Search 的时候,实际上获取到的是已经 diff 后的数据,大大减少了用户的等待时间。
  3. 如果线上已经出问题了,等待10分钟就会变得很严重。为了避免每10分钟拉一次不够实时,在 Admin 上提供 Sync 按钮,支持实时拉取两端数据做 diff。

image

当然,这个二期优化在技术实现上没有任何难度,但确实完美解决了这个问题,每次 Search 的时候速度降低到了 1s,可以说提升了9倍之多。

除此之外,使用 bs diff 做二进制文件的差分,避免了 ccms 和客户端繁琐的交互,降低了风险,也保持了 ccms 的通用性。

实际上,这个也是我们这边的 RN 团队做增量更新、H5 游戏团队做离线包优化都是通过 bs diff 进行二进制差分实现的,技术原理都是一致的,但能不能用对场景就显得尤为重要。

总结

刚工作的时候,我们尚且年轻,对于很多问题的看法比较片面,很难看清每个方案背后隐藏的坑,也不知道怎么去不断优化,寻找最优解。
技术应该服务于业务,可我们常常陷入技术的深坑中,执着于技术栈的选型。在我们部门这边,大家并不怎么 care 你使用的技术,只要你可以 hold 住,用啥技术都行。
当然,我们这边也会有用错技术栈的时候,比如管理系统用了 Nuxt 服务端渲染,导致构建速度特别慢,使得开发体验大打折扣。也会有一些让人眼前一亮的 idea,比如使用 monorepo 来管理一些分散的 H5 页面,大大提高了开发效率。

深入理解 ES6 Proxy

image_1e4411le1meo5qg1v8nllo7ov1g.png-1150.8kB

只听空相大声道:“请道长立即禀报张真人,事在紧急,片刻延缓不得!”那道人道:“大师来得不巧,敝师祖自去岁坐关,至今一年有余,本派弟子亦已久不见他老人家慈范。”

前言

在武侠小说中,经常看到这样的桥段。某位武林人士前来拜访德高望重的帮派掌门,往往需要经过手下弟子的通报。如果掌门外出或者不想见来人,就会让弟子婉拒。

今天要讲的 Proxy 和这个有异曲同工之妙。顾名思义,Proxy 的意思是代理,作用是为其他对象提供一种代理以控制对这个对象的访问。

本文会涉及到 ProxyReflectFunction扩展运算符 等知识,主要以实践为主,对语法不会进行详细地讲解,建议配合阮一峰的 ES6入门 中相关章节服用。

code.png-74.3kB

1. Proxy 提供了哪些拦截方式?

Proxy 一般是用来架设在目标对象之上的一层拦截,来实现对目标对象访问和修改的控制。Proxy 是一个构造函数,使用的时候需要配合 new 操作符,直接调用会报错。

image.png-21.9kB

Proxy 构造函数接收两个参数,第一个参数是需要拦截的目标对象,这个对象只可以是对象、数组或者函数;

第二个参数则是一个配置对象,提供了拦截方法。

const person = {
    name: 'tom'
}
// 如果第二个参数为空对象
const proxy = new Proxy(person, {});
proxy === person; // false

// 第二个参数不为空
const proxy = new Proxy(person, {
    get(target, prop) {
        console.log(`${prop} is ${target[prop]}`);
        return target[prop];
    }
})
proxy.name // 'name is tom'

Proxy 支持13种拦截操作,本文将会重点介绍其中四种。

image_1e3br0h5t2kvb5q5mo1r8t79c9.png-127.3kB

  1. get(target, prop, receiver):拦截对象属性的访问。
  2. set(target, prop, value, receiver):拦截对象属性的设置,最后返回一个布尔值。
  3. apply(target, object, args):用于拦截函数的调用,比如 proxy()
  4. construct(target, args):方法用于拦截 new 操作符,比如 new proxy()。为了使 new操作符在生成的Proxy对象上生效,用于初始化代理的目标对象自身必须具有 [[Construct]] 内部方法(即 new target 必须是有效的)。
  5. has(target, prop):拦截例如 prop in proxy的操作,返回一个布尔值。
  6. deleteProperty(target, prop):拦截例如 delete proxy[prop] 的操作,返回一个布尔值。
  7. ownKeys(target):拦截 Object.getOwnPropertyNames(proxy)Object.keys(proxy)for in 循环等等操作,最终会返回一个数组。
  8. getOwnPropertyDescriptor(target, prop):拦截 Object.getOwnPropertyDescriptor(proxy, propKey),返回属性的描述对象。
  9. defineProperty(target, propKey, propDesc):拦截 Object.defineProperty(proxy, propKey, propDesc)、Object.defineProperties(proxy, propDescs),返回一个布尔值。
  10. preventExtensions(target):拦截 Object.preventExtensions(proxy),返回一个布尔值。
  11. getPrototypeOf(target):拦截 Object.getPrototypeOf(proxy),返回一个对象。
  12. isExtensible(target):拦截 Object.isExtensible(proxy),返回一个布尔值。
  13. setPrototypeOf(target, proto):拦截 Object.setPrototypeOf(proxy, proto),返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。

2. Proxy vs Object.defineProperty

在 Proxy 出现之前,JavaScript 中就提供过 Object.defineProperty,允许对对象的 getter/setter 进行拦截,那么两者的区别在哪里呢?

image_1e442ld7g6legdo15362m1ke733.png-109.3kB

2.1 Object.defineProperty 不能监听所有属性

Object.defineProperty 无法一次性监听对象所有属性,必须遍历或者递归来实现。

   let girl = {
     name: "marry",
     age: 22
   }
   /* Proxy 监听整个对象*/
   girl = new Proxy(girl, {
     get() {}
     set() {}
   })
   /* Object.defineProperty */
   Object.keys(girl).forEach(key => {
     Object.defineProperty(girl, key, {
       set() {},
       get() {}
     })
   })

2.2 Object.defineProperty 无法监听新增加的属性

Proxy 可以监听到新增加的属性,而 Object.defineProperty 不可以,需要你手动再去做一次监听。因此,在 Vue 中想动态监听属性,一般用 Vue.set(girl, "hobby", "game") 这种形式来添加。

   let girl = {
     name: "marry",
     age: 22
   }
   /* Proxy 监听整个对象*/
   girl = new Proxy(girl, {
     get() {}
     set() {}
   })
   /* Object.defineProperty */
   Object.keys(girl).forEach(key => {
     Object.defineProperty(girl, key, {
       set() {},
       get() {}
     })
   });
   /* Proxy 生效,Object.defineProperty 不生效 */
   girl.hobby = "game"; 

2.3. Object.defineProperty 无法响应数组操作

Object.defineProperty 可以监听数组的变化,Object.defineProperty 无法对 pushshiftpopunshift 等方法进行响应。

   const arr = [1, 2, 3];
   /* Proxy 监听数组*/
   arr = new Proxy(arr, {
     get() {},
     set() {}
   })
   /* Object.defineProperty */
   arr.forEach((item, index) => {
     Object.defineProperty(arr, `${index}`, {
       set() {},
       get() {}
     })
   })
   
   arr[0] = 10; // 都生效
   arr[3] = 10; // 只有 Proxy 生效
   arr.push(10); // 只有 Proxy 生效

对于新增加的数组项,Object.defineProperty 依旧无法监听到。因此,在 Mobx 中为了监听数组的变化,默认将数组长度设置为1000,监听 0-999 的属性变化。

   /* mobx 的实现 */
   const arr = [1, 2, 3];
   /* Object.defineProperty */
   [...Array(1000)].forEach((item, index) => {
     Object.defineProperty(arr, `${index}`, {
       set() {},
       get() {}
     })
   });
   arr[3] = 10; // 生效
   arr[4] = 10; // 生效

如果想要监听到 pushshiftpopunshift 等方法,该怎么做呢?在 Vue 和 Mobx 中都是通过重写原型实现的。

在定义变量的时候,判断其是否为数组,如果是数组,那么就修改它的 __proto__,将其指向 subArrProto,从而实现重写原型链。

   const arrayProto = Array.prototype;
   const subArrProto = Object.create(arrayProto);
   const methods = ['pop', 'shift', 'unshift', 'sort', 'reverse', 'splice', 'push'];
   methods.forEach(method => {
     /* 重写原型方法 */
     subArrProto[method] = function() {
       arrayProto[method].call(this, ...arguments);
     };
     /* 监听这些方法 */
     Object.defineProperty(subArrProto, method, {
       set() {},
       get() {}
     })
   })

2.4 Proxy 拦截方式更多

Proxy 提供了13种拦截方法,包括拦截 constructorapplydeleteProperty 等等,而 Object.defineProperty 只有 getset

2.5. Object.defineProperty 兼容性更好

Proxy 是新出的 API,兼容性还不够好,不支持 IE 全系列。

3. 语法

3.1 get

get 方法用来拦截对目标对象属性的读取,它接收三个参数,分别是目标对象、属性名和 Proxy 实例本身。
基于 get 方法的特性,可以实现很多实用的功能,比如在对象里面设置私有属性(一般定义属性我们以 _ 开头表明是私有属性) ,实现禁止访问私有属性的功能。

const person = {
    name: 'tom',
    age: 20,
    _sex: 'male'
}
const proxy = new Proxy(person, {
    get(target, prop) {
        if (prop[0] === '_') {
            throw new Error(`${prop} is private attribute`);
        }
        return target[prop]
    }
})
proxy.name; // 'tom'
proxy._sex; // _sex is private attribute

还可以给对象中未定义的属性设置默认值。通过拦截对属性的访问,如果是 undefined,那就返回最开始设置的默认值。

let person = {
    name: 'tom',
    age: 20
}
const defaults = (obj, initial) => {
    return new Proxy(obj, {
        get(target, prop) {
            if (prop in target) {
                return target[prop]
            }
            return initial
        }
    })
}
person = defaults(person, 0);
person.name // 'tom'
person.sex // 0
person = defaults(person, null);
person.sex // null

3.2 set

set 方法可以拦截对属性的赋值操作,一般来说接收四个参数,分别是目标对象、属性名、属性值、Proxy 实例。
下面是一个 set 方法的用法,在对属性进行赋值的时候打印出当前状态。

const proxy = new Proxy({}, {
    set(target, key, value, receiver) {
        console.log(`${key} has been set to ${value}`);
        Reflect.set(target, key, value);
    }
})
proxy.name = 'tom'; // name has been setted ygy

第四个参数 receiver 则是指当前的 Proxy 实例,在下例中指代 proxy

const proxy = new Proxy({}, {
    set(target, key, value, receiver) {
        if (key === 'self') {
            Reflect.set(target, key, receiver);
        } else {
            Reflect.set(target, key, value);
        }
    }
})
proxy.self === proxy; // true

如果你写过表单验证,也许会被各种验证规则搞得很头疼。使用 Proxy 可以在填写表单的时候,拦截其中的字段进行格式校验。
通常来说,大家都会用一个对象来保存验证规则,这样会更容易对规则进行扩展。

// 验证规则
const validators = {
    name: {
        validate(value) {
            return value.length > 6;
        },
        message: '用户名长度不能小于六'
    },
    password: {
        validate(value) {
            return value.length > 10;
        },
        message: '密码长度不能小于十'
    },
    moblie: {
        validate(value) {
            return /^1(3|5|7|8|9)[0-9]{9}$/.test(value);
        },
        message: '手机号格式错误'
    }
}

然后编写验证方法,用 set 方法对 form 表单对象设置属性进行拦截,拦截的时候用上面的验证规则对属性值进行校验,如果校验失败,则弹窗提示。

// 验证方法
function validator(obj, validators) {
    return new Proxy(obj, {
        set(target, key, value) {
            const validator = validators[key]
            if (!validator) {
                target[key] = value;
            } else if (validator.validate(value)) {
                target[key] = value;
            } else {
                alert(validator.message || "");
            }
        }
    })
}
let form = {};
form = validator(form, validators);
form.name = '666'; // 用户名长度不能小于六
form.password = '113123123123123';

但是,如果这个属性已经设置为不可写,那么 set 将不会生效(但 set 方法依然会执行)。

const person = {
    name: 'tom'
}
Object.defineProperty(person, 'name', {
    writable: false
})
const proxy = new Proxy(person, {  
    set(target, key, value) {
        console.log(666)
        target[key] = 'jerry'
    }
})
proxy.name = '';

3.3. apply

apply 一般是用来拦截函数的调用,它接收三个参数,分别是目标对象、上下文对象(this)、参数数组。

function test() {
    console.log('this is a test function');
}
const func = new Proxy(test, {
    apply(target, context, args) {
        console.log('hello, world');
        target.apply(context, args);
    }
})
func();

通过 apply 方法可以获取到函数的执行次数,也可以打印出函数执行消耗的时间,常常可以用来做性能分析。

function log() {}
const func = new Proxy(log, {
    _count: 0,
    apply(target, context, args) {
        target.apply(context, args);
        console.log(`this function has been called ${++this._count} times`);
    }
})
func()

3.4. construct

construct 方法用来拦截 new 操作符。它接收三个参数,分别是目标对象、构造函数的参数列表、Proxy 对象,最后需要返回一个对象。
使用方式可以参考下面这么一个例子:

function Person(name, age) {
    this.name = name;
    this.age = age;
}
const P = new Proxy(Person, {
    construct(target, args, newTarget) {
        console.log('construct');
        return new target(...args);
    }
})
const p = new P('tom', 21); // 'construct'

我们知道,如果构造函数没有返回任何值或者返回了原始类型的值,那么默认返回的就是 this,如果返回的是一个引用类型的值,那么最终 new 出来的就是这个值。
因此,你可以代理一个空函数,然后返回一个新的对象。

function noop() {}
const Person = new Proxy(noop, {
    construct(target, args, newTarget) {
        return {
            name: args[0],
            age: args[1]
        }
    }
})
const person = new Person('tom', 21); // { name: 'tom', age: 21 }

4. Proxy 可以做哪些有意思的事情?

Proxy 的使用场景非常广泛,可以用来拦截对象的 set/get 从而实现数据响应。在 Vue3 和 Mobx5 中都使用了 Proxy 代替 Object.defineProperty。那么接下来就来看看 Proxy 都可以做哪些事情吧。

4.1 *操作:代理类

使用 construct 可以代理类,你可能会好奇,Proxy 不是只能代理 Object 类型吗?类该怎么代理呢?

image_1e440c3701ds37061o8goo316pk13.png-36kB

其实类的本质也是构造函数和原型(对象)组成的,完全可以对其进行代理。
考虑有这么一个需求,需要拦截对属性的访问,以及计算原型上函数的执行时间,这样该怎么去做就比较清晰了。可以对属性设置 get 拦截,对原型函数设置 apply 拦截。

先考虑对下面的 Person 类的原型函数进行拦截。使用 Object.getOwnPropertyNames 来获取原型上面所有的函数,遍历这些函数并对其使用 apply 拦截。

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
  say() {
    console.log(`my name is ${this.name}, and my age is ${this.age}`)
  }
}
const prototype = Person.prototype;
// 获取 prototype 上所有的属性名
Object.getOwnPropertyNames(prototype).forEach((name) => {
    Person.prototype[name] = new Proxy(prototype[name], {
        apply(target, context, args) {
            console.time();
            target.apply(context, args);
            console.timeEnd();
        }
    })
 })

拦截了原型函数后,开始考虑拦截对属性的访问。前面刚刚讲过 construct 方法的作用,那么是不是可以在 new 的时候对所有属性的访问设置拦截呢?
没错,由于 new 出来的实例也是个对象,那么完全可以对这个对象进行拦截。


new Proxy(Person, {
    // 拦截 construct 方法
    construct(target, args) {
        const obj = new target(...args);
        // 返回一个代理过的对象
        return new Proxy(obj, {
            get(target, prop) {
      		    console.log(`${target.name}.${prop} is being getting`);
      		    return target[prop]
    	    }
        })
    }
})       

所以,最后完整的代码如下:

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
  say() {
    console.log(`my name is ${this.name}, and my age is ${this.age}`)
  }
}
const proxyTrack = (targetClass) => {
  const prototype = targetClass.prototype;
  Object.getOwnPropertyNames(prototype).forEach((name) => {
        targetClass.prototype[name] = new Proxy(prototype[name], {
            apply(target, context, args) {
                console.time();
                target.apply(context, args);
                console.timeEnd();
            }
        })
  })
  
  return new Proxy(targetClass, {
    construct(target, args) {
      const obj = new target(...args);
      return new Proxy(obj, {
        get(target, prop) {
      		console.log(`${target.name}.${prop} is being getting`);
      		return target[prop]
    	}
      })
    }
  })       
}

const MyClass = proxyTrack(Person);
const myClass = new MyClass('tom', 21);
myClass.say();
myClass.name;

4.2 等不及可选链:深层取值(get

平时取数据的时候,经常会遇到深层数据结构,如果不做任何处理,很容易造成 JS 报错。
为了避免这个问题,也许你会用多个 && 进行处理:

const country = {
    name: 'china',
    province: {
        name: 'guangdong',
        city: {
            name: 'shenzhen'
        }
    }
}
const cityName = country.province
    && country.province.city
    && country.province.city.name;

但这样还是过于繁琐了,于是 Lodash 提供了 get 方法帮处理这个问题:

_.get(country, 'province.city.name');

虽然看起来似乎还不错,但总觉得哪里不太对(好是好,就是太丑了)。
最新的 ES 提案中提供了可选链的语法糖,支持我们用下面的语法来深层取值。

country?.province?.city?.name

但是这个特性只是处于 stage3 阶段,还没有被正式纳入 ES 规范中,更没有浏览器已经支持了这个特性。
所以,我们只能另辟蹊径。这时你可能会想到如果使用 Proxy 的 get 方法拦截对属性的访问,这样是不是就可以实现深层取值了呢?

code.png-92.3kB

接下来,我将会带着你一步步实现下面的这个 get 方法。

const obj = {
    person: {}
}
// 预期结果(这里为什么要当做函数执行呢?)
get(obj)() === obj;
get(obj).person(); // {}
get(obj).person.name(); // undefined
get(obj).person.name.xxx.yyy.zzz(); // undefined

首先,创建一个 get 方法,使用 Proxy 中的 get 对传入的对象进行拦截。

function get (obj) {
    return new Proxy(obj, {
        get(target, prop) {
            return target[prop];
        }
    })
}

来运行一下上面的三个例子,看一下结果如何:

get(obj).person; // {}
get(obj).person.name; // undefined
get(obj).person.name.xxx.yyy.zzz; // Cannot read property 'xxx' of undefined

前两个测试用例是成功了,但第三个还是不行,因为 get(obj).person.nameundefined,所以接下来的重点是处理属性为 undefined 的情况。
对这个 get 方法进行一下简单的改造,这次不再直接返回 target[prop],而是返回一个代理对象,让第三个例子不再报错。

function get (obj) {
    return new Proxy(obj, {
        get(target, prop) {
            return get(target[prop]);
        }
    })
}

嗯,看起来有点儿高大上了,但是 target[prop]undefined 的时候,传给 get 方法的就是 undefined 了,而 Proxy 第一个参数必须为对象,这样岂不是会报错?
所以,需要对 objundefined 的时候进行特殊处理,为了能够深层取值,只能对值为 undefined 的属性设置默认值为空对象。

function get (obj = {}) {
    return new Proxy(obj, {
        get(target, prop) {
            return get(target[prop]);
        }
    })
}
get(obj).person; // {}
get(obj).person.name; // {}
get(obj).person.name.xxx.yyy.zzz; // {}

虽然不报错了,可是后两个返回值却不对了。不设置默认值为空对象就无法继续访问,设置默认值为空对象就会改变返回值。这可该怎么办呢?
仔细看一下上面的预期设计,是不是发现少了一个括号,这就是为什么每个属性都被当做函数来执行。
所以需要对这个函数稍加修改,让其支持 apply 拦截的方式。

function noop() {}
function get (obj) {
    // 注意这里拦截的是 noop 函数
    return new Proxy(noop, {
        // 这里支持返回执行的时候传入的参数
        apply(target, context, [arg]) {
            return obj;
        },
        get(target, prop) {
            return get(obj[prop]);
        }
    })
}

所以这个 get 方法已经可以这样使用了。

get(obj)() === obj; // true
get(obj).person.name(); // undefined
get(obj).person.name.xxx.yyy.zzz(); // Cannot read property 'xxx' of undefined

我们理想中的应该是,如果属性为 undefined 就返回 undefined,但仍要支持访问下级属性,而不是抛出错误。顺着这个思路来的话,很明显当属性为 undefined 的时候也需要用 Proxy 进行特殊处理。
所以我们需要一个具有下面特性的 get 方法:

get(undefined)() === undefined; // true
get(undefined).xxx.yyy.zzz() // undefined

和前面的困扰不一样的地方是,这里完全不需要注意 get(undefined).xxx 是否为正确的值,因为想获取值必须要执行才能拿到。那么只需要对所有 undefined 后面访问的属性都默认为 undefined 就好了。

function noop() {}
function get (obj) {
    if (obj === undefined) {
        return proxyVoid;
    }
    // 注意这里拦截的是 noop 函数
    return new Proxy(noop, {
        // 这里支持返回执行的时候传入的参数
        apply(target, context, [arg]) {
            return obj === undefined ? arg : obj;
        },
        get(target, prop) {
            if (
                obj !== undefined &&
                obj !== null &&
                obj.hasOwnProperty(prop)
            ) {
                return get(obj[prop]);
            }
            return proxyVoid;
        }
    })
}

接下来思考一下这个 proxyVoid 函数该如何实现呢?很明显它应该是一个代理了 undefined 后返回的对象。直接这样好不好?

const proxyVoid = get(undefined);

但是这样很明显会造成死循环了,那么就需要判断临界值了,让 get 方法第一次接收 undefined 的时候不会死循环。

let isFirst = true;
function noop() {}
let proxyVoid = get(undefined);
function get(obj) {
    if (obj === undefined && !isFirst) {
        return proxyVoid;
    }
    if (obj === undefined && isFirst) {
        isFirst = false;
    }
    // 注意这里拦截的是 noop 函数
    return new Proxy(noop, {
        // 这里支持返回执行的时候传入的参数
        apply(target, context, [arg]) {
            return obj === undefined ? arg : obj;
        },
        get(target, prop) {
            if (
                obj !== undefined &&
                obj !== null &&
                obj.hasOwnProperty(prop)
            ) {
                return get(obj[prop]);
            }
            return proxyVoid;
        }
    })
}

我们再来验证一下,这种方式是否可行:

get(obj)() === obj; // true
get(obj).person.name(); // undefined
get(obj).person.name.xxx.yyy.zzz(); // undefined

bingo,这个方法完全实现了我们的需求。最后,完整的代码如下:

    let isFirst = true;
    function noop() {}
    let proxyVoid = get(undefined);
    function get(obj) {
        if (obj === undefined) {
            if (!isFirst) {
                return proxyVoid;
            }
            isFirst = false;
        }
        // 注意这里拦截的是 noop 函数
        return new Proxy(noop, {
            // 这里支持返回执行的时候传入的参数
            apply(target, context, [arg]) {
                return obj === undefined ? arg : obj;
            },
            get(target, prop) {
                if (
                    obj !== undefined &&
                    obj !== null &&
                    obj.hasOwnProperty(prop)
                ) {
                    return get(obj[prop]);
                }
                return proxyVoid;
            }
        })
    }
    this.get = get;

这个基于 Proxy 的 get 方法的灵感来自于 Github 上的一个名为 safe-touch 的库,感兴趣的可以去看一下它的源码实现:safe-touch

4.3 管道

在最新的 ECMA 提案中,出现了原生的管道操作符 |>,在 RxJS 和 NodeJS 中都有类似的 pipe 概念。

image_1e445iuci168nj1m1stvkbm1jhg4a.png-13.7kB

使用 Proxy 也可以实现 pipe 功能,只要使用 get 对属性访问进行拦截就能轻易实现,将访问的方法都放到 stack 数组里面,一旦最后访问了 execute 就返回结果。

const pipe = (value) => {
    const stack = [];
    const proxy = new Proxy({}, {
        get(target, prop) {
            if (prop === 'execute') {
                return stack.reduce(function (val, fn) {
                    return fn(val);
                }, value);
            }
            stack.push(window[prop]);
            return proxy;
        }
    })
    return proxy;
}
var double = n => n * 2;
var pow = n => n * n;
pipe(3).double.pow.execute;

注意:这里为了在 stack 存入方法,使用了 window[prop] 的形式,是为了获取到对应的方法。也可以将 doublepow 方法挂载到一个对象里面,用这个对象替换 window

❤️ 看完三件事

如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  1. 点赞,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)
  2. 关注公众号「前端小馆」,或者加我个人微信号「testygy」拉你进群,不定期分享原创知识。
  3. 也看看其它文章

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.