打造属于自己的 js 库

94 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天,点击查看活动详情

前言

这篇文章从架构的角度出发,使用原生 typescript 建立 lib,你将会收货 ts 高级用法,同时也会深入了解 js 的基础,更重要的是培养底层封装库的能力

导读

本文将介绍 jquery 库的核心源码设计(class 库的建立,extend 怎么拓展我们建立的库),通过简单的代码表达 jquery 的代码思想,同时在讲解核心代码的时候也会着重分析代码设计的思路

为什么是 jquery

jquery 虽然脱离了当前前端开发的趋势,但是 jquery 的设计思想十分值得借鉴,我们在设计自己类库的时候,依然可以参考 jquery 的架构。

核心代码

$ 的建立

我们都知道在使用 jquery 获取 dom 元素的时候,我们可以直接 $('#app'),返回的是一个 jquery 的实例,也就是说我们也可以这样使用 new $('#app'),很明显,这里的 $ 就是一个构造函数,你可以不使用 new 也能获得 $ 的实例,因为在 $ 的构造函数内部帮你执行了 new 操作

// 这两种写法完全等效
$('#app');
new $('#app')

image.png

构建 $ 构造的架子:

注意这里是 js,ts 中不允许直接 new 一个 function,也不允许直接调用 class,但在后文会讲解解决办法

      const $ = function (selector) {
        return document.querySelector(selector);
      };

      $.prototype = {
        length: 1,
      };

     new $('#app').length; // undefined
     $('#app').length; // undefined

我们在类内返回一个 dom 元素,即使 new 了 构造函数,也不能获取到原型的方法和属性,对比jquery 通过 $ 返回的是一个类数组,因此我们要手动扩充返回的实例

更安全的模式:

      const $ = function (selector) {
        if (!(this instanceof $)) { // 注意这里的运算优先级,使用括号提高 instanceof 运算优先级
          return new $(selector);
        }
        this[0] = document.querySelector(selector);
        this.length = 1;
        return this;
      };

      $.prototype = {
        length: 1,
      };

      console.log(new $('#app'), $('#app'));

image.png

这样子做可以解决 new 和 非new 的兼容问题,但是和 jquery 的做法还是有很大区别的

jquery 的做法,将初始化的操作放在原型的 init 方法上

      const $ = function (selector) {
        return $.prototype.init(selector);
      };

      $.prototype = {
        init: function (selector) {
          this[0] = document.querySelector(selector);
          this.length = 1;
          return this;
        },
        getLen() {
          return this.length;
        },
      };
      
      $('#app') === $('test'); // true

但是,依然还会存在问题,如果我们调用了两次的 $() 方法,后一次的实例会覆盖之前的实例,这是因为在 initthis 来源于 $.prototype,而不是在 init 方法内部重新构造的,解决这个问题的办法很简单,只需要在 $ 这个构造函数中返回的是一个来源不同的 this,即:

      const $ = function (selector) {
        return new $.prototype.init(selector); // 添加了 new
      };

      $.prototype = {
        init: function (selector) {
          this[0] = document.querySelector(selector);
          this.length = 1;
          return this;
        },
        getLen() {
          return this.length;
        },
      };
      
     $('#app').getLen(); // TypeError

这样仍然会带来一个全新的问题,大家试着思考一下原因,有了方向后再看后面的解析

new 关键字返回值可以引用值被修改,所以我们这里也可以不返回 this(与返回 this 等效)。每个 new 构造函数产生的返回值 this 是对应的构造函数创建的,所以 this.__proto__ 的指向就是构造函数的原型,所以这里返回的 this __proto__ 指向的是 init.prototype,而不是 $.prototype,所以调用不了 getLen 方法。

解决办法

$.prototype.init.prototype = $.prototype;

值得提点的地方:

  1. jquery 在操作原型的时候并不是直接使用 prototype,而是让 $.fn = $.prototype,也就是说 $.prototype$.fn.init.prototype$.fn 这三个属性是全等的
  2. 为了返回的类数组更像数组,我们需要增强类数组,在 $ 原型上 $.prototype.splice = Array.prototype.splice

在讲述完了 $ 的主要建立过程,我们后面的内容都会用 ts 来写代码,首先我们对之前的代码,用 ts 进行重构

前置知识:在 ts 中怎么声明构造类型

1.构造函数

对于非 class 的构造函数,我们在 js 中即可以直接调用也可以 new,而在 ts 中是不被允许的,我们必须给函数添加 调用签名构造签名,在给函数赋值类型的时候必须 断言

interface Test_CONSTRUCTOR {
  new(...args: any[]): any;
  (...args: any[]): any;
}

const Test = function (...args: any[]) { } as Test_CONSTRUCTOR // 

new Test(); // success
    Test(); // success 

我们在上面的例子中使用了 as unknown as $_CONSTRUCTOR 的结构,这是一种不安全的做法,就像下面的例子一样

const num: number = 10;

num.split(''); // error
(num as string).split(''); // error

(num as any).split(''); // success,不推荐
(num as unknown as string).split('');   // success,这里和any一样完全可以通过静态检查

使用 as unknown as 可以强制类型转换,比 as any 更为合适,但是不安全,unknown 虽然可以被装换新的类型,但是骗过了编译器,将 number 强制转换为 string 是十分不合理的

  1. 区分静态变量
type Test_CONSTRUCTOR = {
  new(...args: any[]): any;
  (...args: any[]): any;
} & Test_STATIC;

interface Test_STATIC {
  show: () => void;
}

const Test = function (...args: any[]) { } as Test_CONSTRUCTOR

Test.show = () => { }
Test.show();

这里,我们将静态成员分离在单独的接口中,在实际使用的时候我们完全可以将两个接口合并成一个接口

  1. 定义原型类型
interface Test_CONSTRUCTOR {
  new(...args: any[]): Test; // 注意构造签名的返回值
  (...args: any[]): void;
  show: () => void;
}

interface Test {
  log: () => void;
}

const Test = function (...args: any[]) { } as Test_CONSTRUCTOR
Test.show = () => { }
Test.show();
Test.prototype = {
  log() { }
}

new Test().log(); // success

我们声明一个和构造函数重名的接口,并在这个接口定义原型方法,同时我们还得将构造签名的返回值改为实例类型

使用 ts 还原 jquery 中构造函数类型
interface $_CONSTRUCTOR {
  new(selector: string): $;
  (selector: string): $;
  fn: $;
}

interface $ {
  init: {
    new (selector: string): $
  }
  [key: number]: HTMLElement | null; // 返回的实例是伪数组,每一项都会存储对应的dom
  length: number;
  getLen: () => number;
  splice: <T>(start: number, deleteCount?: number | undefined) => T[];
}

const $ = function (selector: string) {
  return new $.fn.init(selector);
} as unknown as $_CONSTRUCTOR; // 更推荐 as $_CONSTRUCTOR

$.fn = $.prototype = {
  init: function (this: $, selector: string) {
    this[0] = document.querySelector(selector);
    this.length = 1;
    return this;
  } as unknown as $_CONSTRUCTOR, // 更推荐 as $_CONSTRUCTOR
  length: 2,
  getLen(this: $) {
    return this.length;
  },
  splice: Array.prototype.splice
};

$.fn.init.prototype = $.prototype;

测试

$('#app').init('#app').getLen(); // 1
$.fn.init('#app').getLen(); // 1
new $('#app').getLen(); // 1
$('#app').getLen(); // 1

以上的各种样例,都可以成功使用,并且得到正确的代码补全提示

$.extend

这边文章中我们不过多的讨论 jquery 每个方法的实现,因此我们也不会阐述怎么获取 dom 元素的代码,请读者自行改造

$.extend 在 jquery 中的使用

$.extend 既可以是实例方法也可以是静态方法,在接收参数不同时也会表现不同的特性

  • 只接收一个参数,扩展 jquery
$('#app').extend({ a: 1 }.a); // 1                 // 只有 app 实例才被扩展,被添加在实例身上上
$('test').a;                  // undefined 

$.fn.extend({ a: 1 })         // {a: 1, ...}       // 扩展到原型上
$('test').a                   // 1

$.extend({ a: 1 }).a          // 1                 // 扩展到 $ 这个函数对象本上
  • 接收多个对象类型参数,合并多个对象
$('#app').extend({ a: 1 }, { b: 2 })
$.extend({ a: 1 }, { b: 2 })
$.fn.extend({ a: 1 }, { b: 2 })

这三种方法等效,都会返回 { a: 1, b: 2 },实例方法和静态方法没有差异

使用 ts 还原 $.extend
  1. 声明 extend 类型

extend 可以作为实例方法和静态方法,所以我们得分别在两个接口中都声明 extend 类型

interface $_CONSTRUCTOR {
  extend: <T extends object>(...args: T[]) => $_CONSTRUCTOR & T;
}

interface $ {
  extend: <T extends object> (...args: T[]) => T & $;
}

我们可以使用 as unknown as$.prototype 进行断言,可以在类外进行重写方法

$.fn = $.prototype = {
  // ...
} as unknown as $;

$.fn.extend = function <T>(...args: T[]): T & $ { }

也可以直接在 $.prototype 中直接声明

extend<T extends object>(...args: T[]): T & $ { }
  1. extend 实现思路

在接收参数的同时我们首先要判断参数的个数,从而断定函数的功能,一般我们会这么写

function test(...args: any[]): any {
  if (args.length === 1) {
    // ...
  } else if (args.length > 1) {
    // ...
  }
}

这样的做法,本身没什么问题,但是这里因为在两个判断分支内部的逻辑都是遍历 args 数组,所以这种做法就相当冗余了,更好的做法:

function test(this: any, ...args: any[]): any {
  let i = 1;
  let target = args[0];

  if (args.length === 1) {
    target = this;
    i--;
  }

  for (; i < args.length; i++) {
    // ...
  }
}

这里在初始化 target 变量的时候,将 args 数组第一项作为残体,根据参数的不同,决定遍历的起始位置,从而复用代码

使用 ts 还原 $.extend 完整的代码

  extend<T extends object>(this: $, ...args: T[]): T & $ {
    let target: T | $ = args[0] as T;
    let i = 1;

    if (args.length === i) {
      target = this;
      i--;
    }

    for (; i < args.length; i++) {
      const item = args[i];
      for (const key in item) {
        target[key] = item[key];
      }
    }

    return target as T & $;
  }

最后别忘了也要给 $ 添加 静态方法 的实现

$.extend = $.fn.extend as (...args: IObj[]) => $_CONSTRUCTOR & IObj;

这里我们使用类型断言完成了逻辑的复用

全部代码

与讲解的内容并不完全相同

interface $_CONSTRUCTOR {
  new(selector: string): $;
  (selector: string): $;
  fn: $;
  extend: <T extends object>(...args: T[]) => $_CONSTRUCTOR & T;
  [key: number | string | symbol]: any;
}

interface $ {
  init: {
    new(selector: string): $; // 构造签名,只允许 new,不允许调用
  }
  [key: number]: HTMLElement | null;
  [key: string]: any;
  length: number;
  getLen: () => number;
  splice: <T>(start: number, deleteCount?: number | undefined) => T[];
  extend: <T extends object> (...args: T[]) => T & $;
}

const $ = function (selector: string) {
  return new $.fn.init(selector);
} as $_CONSTRUCTOR;

$.fn = $.prototype = {
  init: function (this: $, selector: string) {
    this[0] = document.querySelector(selector);
    this.length = 1;
    return this;
  } as $_CONSTRUCTOR,

  length: 2,

  getLen(this: $) {
    return this.length;
  },

  splice: Array.prototype.splice,

  extend<T extends object>(this: $, ...args: T[]): T & $ {
    let target: T | $ = args[0] as T;
    let i = 1;

    if (args.length === i) {
      target = this;
      i--;
    }

    for (; i < args.length; i++) {
      const item = args[i];
      for (const key in item) {
        target[key] = item[key];
      }
    }

    return target as T & $;
  }
};

$.fn.init.prototype = $.prototype;

$.extend = $.fn.extend as <T extends object>(...args: T[]) => $_CONSTRUCTOR & T;