自学内容网 自学内容网

Jest项目实战(1):JavaScript 库设计原则及最佳实践

JavaScript库设计

在开始项目实战之前,我们先来介绍一下在设计开源库的时候有哪些原则以及最佳实践。

函数的设计

函数包含三要素:

  • 函数名
  • 参数
  • 返回值

函数名

函数通常表示做一件事情,因此函数名一般为一个动词或者表示动作的短语,我们希望通过函数名就能够传达这个函数是做什么的。哪怕整个函数单词用得多一些,整个函数名长一些也无所谓,只要能够传达当前函数的作用。

例如 react 源码中的一些函数:workLoopConcurrent、checkScheduledUpdateOrContext、bailoutOnAlreadyFinishedWork

另外就是函数名要力求准确,这里的准确还包含单词的拼写不要出错,因为作为一个开源库,代码一旦开源出去,就很难收回了,换句话说,一旦有这种拼写的错误,那么大概率后面就会一直延用这种错误的拼写。例如 HTTP 协议里面就有一个拼写的错误,请求头里面有一个 referer 字段实际上是错误的,正确的拼写为 referrer

函数参数

理论上来讲,关于函数参数的设计,函数参数的个数应该是越少越好,这样的话能够降低使用者的心智负担。如果你所设计的函数参数过多,那么也从侧面说明了函数的设计是有问题的,你在一个函数里面融入了过多的功能,因此你应该考虑对你当前的这个函数重新进行设计。

一般来讲,在设计函数的参数的时候,不要超过 3 个,如果可以的话,最好是 2 个参数,如果是 2 个参数,一般来讲,第一个参数代表必传参数,第二个一般代表可选参数。

例如:

getParams('?a=1&b=2', 'a'); // 输出1

// 我们先不管函数具体的实现,下面有两种参数设计
getParams(url, key, sep='&', eq='=')
getParams(url, key, opt = { sep: "&", eq: "=" })

在上面的示例中,第二种函数参数的设计要明显优于第一种,通过对象化的思路,减少后参数的数量,降低了用户的心智负担,其实还有一个好处,就是采用对象化的设计,未来在进行扩展的时候也更加容易一些。

返回值

如果你的返回值是针对函数传入的参数进行的查询或者某种操作,那么返回值的类型尽量和参数的类型保持一致,这样做是比较符合直觉的。

另外就是如果没有设置返回值,那么默认返回值为 undefined。

举个例子:

function getParams(url){
    if(url){
        // xxx
    }
}
// 这里有个默认的返回值为 undefined

假设用户想要在获取参数后将其转为十六进制:

getParams(url).toString(16);

但是上面的函数设计就会存在隐患,因此更好的方法就是像上面所说,保持返回值的类型一致。

function getParams(url){
    if(url){
        // xxx
    }
    
    return "";
}

提升健壮性

我们的开源库会被很多人使用,并且环境是未知的,即便我们在文档中规定了必须要传递什么类型的参数,但是使用者也有可能违反约定,甚至还有一些情况,数据来源于服务器、数据来源于各种逻辑计算之后的结果,传入到了我们的函数,所以这个时候我们就需要对我们的参数进行一个防御。

function trimStart(str){
    return str.replace(/^\s+/, '');
}

trimStart(111);

在上面的代码中,如果意外传入了非字符串类型的参数,那么就会出现异常,这个时候我们就可以采取一些防御性的措施:

function trimStart(str){
    return String(str).replace(/^\s+/, '');
}

trimStart(111);

在进行参数防御的时候,参数分为两种,一种是必传参数,另外一种是可选参数,针对不同的参数类型,有如下的校验和转换规则:

  • 如果参数是要传递给系统函数,则可以把校验这一步下沉给系统函数来处理
  • 对于 object、array、function 类型的参数,要做强制校验,如果校验失败,对于必传参数来讲就执行异常流程,对于可选参数来讲就设置默认值
  • 对于 number、string、boolean 类型的参数,要做自动转换
    • 数字使用 Number 函数进行转换
    • 整数使用 Math.round 函数进行转换
    • 字符串使用 String 函数进行转换
    • 布尔值使用 !! 进行转换
  • 对于 number 类型参数,如果转换出来是 NaN,那么对于必传参数来讲执行异步流程,对于可选参数来讲就设置默认值
  • 对于复合类型的内部数据,也要执行上述流程

异常捕获

在设计开源库的时候,异步捕获也是一个非常重要的步骤,也就是说,如果代码内部发生了错误,应该怎么办?

JSON.parse 方法可以将字符串转为 JS 对象,但是传入这个方法的字符串如果不符合 JSON 的语法, 那么最终转换的时候就会报错。那么如果你设计的函数内部用到了这个方法,那么就会有出错的可能性,因此我们需要考虑到这种情况

function safeParse(str, backupData){
    try {
        return JSON.parse(str);
    } catch (e) {
        return backupData;
    }
}

安全防护

在设计开源库的时候,由于我们所设计的函数在使用的时候环境未知,所以你不能够想当然的认为会发生什么,正确思路是需要防患于未然,各种意外的情况都需要考虑到,对你所设计的函数做一些安全上面的防护措施。

最小功能设计

开源库应该对外提供最小的功能,尽可能隐藏内部的实现细节,不相关的功能不要向外暴露,一旦某个接口决定向外暴露,那么这个接口就是对外的一种承诺,承诺暴露出来的接口会一直维护,并且永久向下兼容。

这里我们来看一个例子:

例如我这里有一个叫做 guid 的函数,该函数每调用一次,就会生成一个唯一的 ID,内部依赖一个计数器来实现

export let count = 1;
export function guid() {
    return count++;
}

在上面的例子中,count 就没有暴露的必要,它应该是属于模块内部的数据。

再例如:

class Guid {
    count = 1;
    guid() {
        return this.count++;
    }
}

const g = new Guid();
g.count = 'xxx'; // 直接修改了内部 count,导致代码报错

这里应该考虑将这个属性设置为私有属性。

关于类的私有属性,社区方面一直在进行探索,之前的方案是通过添加一个下划线前缀来表示这是一个私有属性,但是这种方式归根结底只是一种约定而已

class Guid {
    _count = 1;
}

目前更好的做法,是将私有属性放置到 constructor 里面:

class Guid {
    constructor(){
        let count = 1;
        this.guid = () => {
            return count++;
        }
    }
}

现在已经有了更好的做法,ES2022 里面正式提供了私有属性的标志符,通过一个 # 号表示私有属性。

class Guid {
    #count = 1;
    constructor(){
        this.guid = () => {
            return this.#count++;
        }
    }
}

最小参数设计

参数的个数不要太多,尽量保证在 3 个以内,参数的类型尽可能是简单类型,如果是复杂(引用)类型,尽量不要修改传入的参数,而是在传入的参数的基础上,复制一份,再进行操作。

举个例子,fill 函数可以实现用指定的值来填充数组:

function fill(arr, value){
    for(let i = 0; i < arr.length; i++){
        arr[i] = value;
    }
    return arr;
}

上面的这种设计,虽然也能够达到目的,但是对原来传入的数组进行了填充修改,这可能不是使用者所期望的。

更理想的方式,对传入的参数进行一个复制,例如:

function fill(arr, value){
    const newArr = clone(arr); // 假设这里的 clone 是一个深度克隆方法
    for(let i = 0; i < newArr.length; i++){
        newArr[i] = value;
    }
    return newArr;
}

对象的冻结

暴露出去的接口可能会被使用者有意或者无意的进行修改,导致开源库在开发阶段都是运行良好的,但是在某些情况下就出错了。

import $ from 'jquery';
$.version = undefined; // 外部代码修改 $ 对象的属性
$.version.split('.'); // 正常代码因为上面的那一行代码导致报错

这种时候,我们就可以对对象进行一个冻结,常见的冻结方法:

方法修改原型指向添加属性修改属性配置删除属性修改属性
Object.preventExtensions
Object.seal
Object.freeze

因此我们这边就可以采用这些方法来对对象进行一个冻结:

import $ from 'jquery';
Object.freeze($);
$.version = undefined;

在上面的代码中,我们针对 $ 进行了冻结,冻结的对象属性是无法修改的。如果尝试修改,那么在严格模式下会报错,在非严格模式下会静默失败。

避免原型入侵

JavaScript 是基于原型来实现的面向对象,因此我们在设计方法的时候要避免在标准库的对象原型上面(Array.prototype、Object.prototype…)添加方法,因为一旦你这么做,就会影响所有的对象。

Object.prototype.tree = function(){
    console.log(Object.keys(this));
}

const obj = {
    a : 1,
    b : 2
}

obj.tree(); // ['a', 'b']

一旦你这么做,就会给所有的对象都增加一个可枚举的方法,通过 for in 进行遍历的时候,就会多这么一个方法出来。

另外还会带来一个问题,可能会存在冲突的问题。不同的库可能会扩展同名的方法,一旦冲突的方法实现不一致,此时就会导致必然会有一个库的代码会失效。

这样的做法实际上也被称之为猴子补丁(monkey-patching),在社区里面已经达成了一个共识“不要耍流氓,不是你的对象你不要动手动脚的”。

更好的方式就是常见一个新的构造函数去继承你想要的修改的构造函数,例如:

class myNum extends Number {
    constructor(...args) {
        super(...args);
    }
    isEven() {
        return this % 2 === 0
    }
}
const i = new myNum(42);
// 无论是新类添加的方法还是 Number 类里面的方法都可以使用
console.log(i.isEven()); // true
console.log(i.toFixed(2)); // 42.00

以前前端在原型入侵上面是有先例的(bad case),前端库 Mootools 和 prototype.js 这两个库都对标准库对象的原型进行了扩展。这两个库当时都给数组扩展了一个名为 flatten 的方法(拍平多维数组),但是这两个库在方法的实现上面是不一致的,这就带来了冲突,冲突的结果就是如果你同时引入这两个库,就必然有一个库的代码会失效。

而且这种做法还影响到了 ES 规范,我们知道 ES6 目前提供了一个叫做 Array.prototype.flat 的方法(数组拍平),关于这个方法当时 ECMA 委员会实际上是想要叫做 flatten,但是由于和 Mootools 和 prototype.js 这两库的 flatten 方法重名了,又由于这两个库的使用者众多,因此最终被迫改了名字叫做 flat。

总结

本小节我们主要介绍了在设计 JavaScript 库时的一些注意点,我们从下面的 5 个点进行了介绍:

  • 函数的设计
    • 函数主要就是需要注意函数三要素:函数名、函数参数、返回值
    • 函数名要力求准确无误,最好能够通过函数名就知道该函数的作用
    • 参数的个数尽量控制在 1-3 个以内,如果是复杂类型的参数,最好不要修改原来的参数
    • 返回值尽量保持和传入的参数相同的类型
  • 提高健壮性
    • 需要对参数做一些防御性的措施,特别是在类型上面的判断
  • 异常捕获
    • 需要考虑到出错时的备选方案,特别是封装的函数中调用了其他函数时,尤其需要考虑出现异常的情况下应该怎么做
  • 安全防护
    • 不需要向外暴露的功能,就不要向外暴露
    • 一旦向外暴露了,就是对外的一种承诺,暴露的接口一般都是会持续维护并且永久向下兼容的
    • 目前 ES2022 开始已经正式支持私有属性了
  • 避免原型入侵
    • 避免“猴子补丁”的做法,“别耍流氓,不是你的对象别动手动脚”

原文地址:https://blog.csdn.net/weixin_53961451/article/details/143580389

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!