持续创作,加速成长!这是我参与「掘金日新计划 · 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')
构建 $ 构造的架子:
注意这里是 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'));
这样子做可以解决 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
但是,依然还会存在问题,如果我们调用了两次的 $() 方法,后一次的实例会覆盖之前的实例,这是因为在 init 中 this 来源于 $.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;
值得提点的地方:
jquery在操作原型的时候并不是直接使用prototype,而是让$.fn = $.prototype,也就是说$.prototype和$.fn.init.prototype和$.fn这三个属性是全等的- 为了返回的类数组更像数组,我们需要增强类数组,在
$原型上$.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 是十分不合理的
- 区分静态变量
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();
这里,我们将静态成员分离在单独的接口中,在实际使用的时候我们完全可以将两个接口合并成一个接口
- 定义原型类型
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
- 声明 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 & $ { }
- 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;