笔者最近在对原生JS的知识做系统梳理,因为我觉得JS作为前端工程师的根本技术,学再多遍都不为过。打算来做一个系列,一共分三次发,以一系列的问题为驱动,当然也会有追问和扩展,内容系统且完整,对初中级选手会有很好的提升,高级选手也会得到复习和巩固。敬请大家关注!
在 JS 中,存在着 7 种原始值,分别是:
boolean
null
undefined
number
string
symbol
bigint
引用数据类型: 对象Object(包含普通对象-Object,数组对象-Array,正则对象-RegExp,日期对象-Date,数学函数-Math,函数对象-Function)
function test(person) {
person.age = 26
person = {
name: 'hzj',
age: 18
}
return person
}
const p1 = {
name: 'fyq',
age: 19
}
const p2 = test(p1)
console.log(p1) // -> ?
console.log(p2) // -> ?
结果:
p1:{name: “fyq”, age: 26}
p2:{name: “hzj”, age: 18}
原因: 在函数传参的时候传递的是对象在堆中的内存地址值,test函数中的实参person是p1对象的内存地址,通过调用person.age = 26确实改变了p1的值,但随后person变成了另一块内存空间的地址,并且在最后将这另外一份内存空间的地址返回,赋给了p2。
结论: null不是对象。
解释: 虽然 typeof null 会输出 object,但是这只是 JS 存在的一个悠久 Bug。在 JS 的最初版本中使用的是 32 位系统,为了性能考虑使用低位存储变量的类型信息,000 开头代表是对象然而 null 表示为全零,所以将它错误的判断为 object 。
其实在这个语句运行的过程中做了这样几件事情:
var s = new Object('1');
s.toString();
s = null;
第一步: 创建Object类实例。注意为什么不是String ?由于Symbol和BigInt的出现,对它们调用new都会报错,目前ES6规范也不建议用new来创建基本类型的包装类。
第二步: 调用实例方法。
第三步: 执行完方法立即销毁这个实例。
整个过程体现了 基本包装类型
的性质,而基本包装类型恰恰属于基本数据类型,包括Boolean, Number和String。
参考:《Javascript高级程序设计(第三版)》P118
0.1和0.2在转换成二进制后会无限循环,由于标准位数的限制后面多余的位数会被截掉,此时就已经出现了精度的损失,相加后因浮点数小数位的限制而截断的二进制数字在转换为十进制就会变成0.30000000000000004。
BigInt是一种新的数据类型,用于当整数值大于Number数据类型支持的范围时。这种数据类型允许我们安全地对
大整数
执行算术操作,表示高分辨率的时间戳,使用大整数id,等等,而不需要使用库。
在JS中,所有的数字都以双精度64位浮点格式表示,那这会带来什么问题呢?
这导致JS中的Number无法精确表示非常大的整数,它会将非常大的整数四舍五入,确切地说,JS中的Number类型只能安全地表示-9007199254740991(-(2^53-1))和9007199254740991((2^53-1)),任何超出此范围的整数值都可能失去精度。
console.log(999999999999999); //=>10000000000000000
同时也会有一定的安全性问题:
9007199254740992 === 9007199254740993; // → true 居然是true!
要创建BigInt,只需要在数字末尾追加n即可。
console.log( 9007199254740995n ); // → 9007199254740995n
console.log( 9007199254740995 ); // → 9007199254740996
另一种创建BigInt的方法是用BigInt()构造函数、
BigInt("9007199254740995"); // → 9007199254740995n
简单使用如下:
10n + 20n; // → 30n
10n - 20n; // → -10n
+10n; // → TypeError: Cannot convert a BigInt value to a number
-10n; // → -10n
10n * 20n; // → 200n
20n / 10n; // → 2n
23n % 10n; // → 3n
10n ** 3n; // → 1000n
const x = 10n;
++x; // → 11n
--x; // → 9n
console.log(typeof x); //"bigint"
BigInt不支持一元加号运算符, 这可能是某些程序可能依赖于 + 始终生成 Number 的不变量,或者抛出异常。另外,更改 + 的行为也会破坏 asm.js代码。
因为隐式类型转换可能丢失信息,所以不允许在bigint和 Number 之间进行混合操作。当混合使用大整数和浮点数时,结果值可能无法由BigInt或Number精确表示。
10 + 10n; // → TypeError
不能将BigInt传递给Web api和内置的 JS 函数,这些函数需要一个 Number 类型的数字。尝试这样做会报TypeError错误。
Math.max(2n, 4n, 6n); // → TypeError
当 Boolean 类型与 BigInt 类型相遇时,BigInt的处理方式与Number类似,换句话说,只要不是0n,BigInt就被视为truthy的值。
if(0n){//条件判断为false
}
if(3n){//条件为true
}
元素都为BigInt的数组可以进行sort。
BigInt可以正常地进行位运算&#xff0c;如|、&、<>和^
caniuse的结果:
其实现在的兼容性并不怎么好&#xff0c;只有chrome67、firefox、Opera这些主流实现&#xff0c;要正式成为规范&#xff0c;其实还有很长的路要走。
我们期待BigInt的光明前途&#xff01;
对于原始类型来说&#xff0c;除了 null 都可以调用typeof显示正确的类型。
typeof 1 // &#39;number&#39;
typeof &#39;1&#39; // &#39;string&#39;
typeof undefined // &#39;undefined&#39;
typeof true // &#39;boolean&#39;
typeof Symbol() // &#39;symbol&#39;
但对于引用数据类型&#xff0c;除了函数之外&#xff0c;都会显示"object"。
typeof [] // &#39;object&#39;
typeof {} // &#39;object&#39;
typeof console.log // &#39;function&#39;
因此采用typeof判断对象数据类型是不合适的&#xff0c;采用instanceof会更好&#xff0c;instanceof的原理是基于原型链的查询&#xff0c;只要处于原型链中&#xff0c;判断永远为true
const Person &#61; function() {}
const p1 &#61; new Person()
p1 instanceof Person // true
var str1 &#61; &#39;hello world&#39;
str1 instanceof String // false
var str2 &#61; new String(&#39;hello world&#39;)
str2 instanceof String // true
能。比如下面这种方式:
class PrimitiveNumber {
static [Symbol.hasInstance](x) {
return typeof x &#61;&#61;&#61; &#39;number&#39;
}
}
console.log(111 instanceof PrimitiveNumber) // true
如果你不知道Symbol&#xff0c;可以看看MDN上关于hasInstance的解释。
其实就是自定义instanceof行为的一种方式&#xff0c;这里将原有的instanceof方法重定义&#xff0c;换成了typeof&#xff0c;因此能够判断基本数据类型。
核心: 原型链的向上查找。
function myInstanceof(left, right) {
//基本数据类型直接返回false
if(typeof left !&#61;&#61; &#39;object&#39; || left &#61;&#61;&#61; null) return false;
//getProtypeOf是Object对象自带的一个方法&#xff0c;能够拿到参数的原型对象
let proto &#61; Object.getPrototypeOf(left);
while(true) {
//查找到尽头&#xff0c;还没找到
if(proto &#61;&#61; null) return false;
//找到相同的原型对象
if(proto &#61;&#61; right.prototype) return true;
proto &#61; Object.getPrototypeof(proto);
}
}
测试:
console.log(myInstanceof("111", String)); //false
console.log(myInstanceof(new String("111"), String));//true
Object在严格等于的基础上修复了一些特殊情况下的失误&#xff0c;具体来说就是&#43;0和-0&#xff0c;NaN和NaN。源码如下&#xff1a;
function is(x, y) {
if (x &#61;&#61;&#61; y) {
//运行到1/x &#61;&#61;&#61; 1/y的时候x和y都为0&#xff0c;但是1/&#43;0 &#61; &#43;Infinity&#xff0c; 1/-0 &#61; -Infinity, 是不一样的
return x !&#61;&#61; 0 || y !&#61;&#61; 0 || 1 / x &#61;&#61;&#61; 1 / y;
} else {
//NaN&#61;&#61;&#61;NaN是false,这是不对的&#xff0c;我们在这里做一个拦截&#xff0c;x !&#61;&#61; x&#xff0c;那么一定是 NaN, y 同理
//两个都是NaN的时候返回true
return x !&#61;&#61; x && y !&#61;&#61; y;
}
解析:
&#61;&#61; 中&#xff0c;左右两边都需要转换为数字然后进行比较。
[]转换为数字为0。
![] 首先是转换为布尔值&#xff0c;由于[]作为一个引用类型转换为布尔值为true,
因此![]为false&#xff0c;进而在转换成数字&#xff0c;变为0。
0 &#61;&#61; 0 &#xff0c; 结果为true
JS中&#xff0c;类型转换只有三种&#xff1a;
转换成数字
转换成布尔值
转换成字符串
转换具体规则如下:
注意"Boolean 转字符串"这行结果指的是 true 转字符串的例子
&#61;&#61;&#61;叫做严格相等&#xff0c;是指&#xff1a;左右两边不仅值要相等&#xff0c;类型也要相等&#xff0c;例如&#39;1&#39;&#61;&#61;&#61;1的结果是false&#xff0c;因为一边是string&#xff0c;另一边是number。
&#61;&#61;不像&#61;&#61;&#61;那样严格&#xff0c;对于一般情况&#xff0c;只要值相等&#xff0c;就返回true&#xff0c;但&#61;&#61;还涉及一些类型转换&#xff0c;它的转换规则如下&#xff1a;
两边的类型是否相同&#xff0c;相同的话就比较值的大小&#xff0c;例如1&#61;&#61;2&#xff0c;返回false
判断的是否是null和undefined&#xff0c;是的话就返回true
判断的类型是否是String和Number&#xff0c;是的话&#xff0c;把String类型转换成Number&#xff0c;再进行比较
判断其中一方是否是Boolean&#xff0c;是的话就把Boolean转换成Number&#xff0c;再进行比较
如果其中一方为Object&#xff0c;且另一方为String、Number或者Symbol&#xff0c;会将Object转换成字符串&#xff0c;再进行比较
console.log({a: 1} &#61;&#61; true);//false
console.log({a: 1} &#61;&#61; "[object Object]");//true
对象转原始类型&#xff0c;会调用内置的[ToPrimitive]函数&#xff0c;对于该函数而言&#xff0c;其逻辑如下&#xff1a;
如果Symbol.toPrimitive()方法&#xff0c;优先调用再返回
调用valueOf()&#xff0c;如果转换为原始类型&#xff0c;则返回
调用toString()&#xff0c;如果转换为原始类型&#xff0c;则返回
如果都没有返回原始类型&#xff0c;会报错
var obj &#61; {
value: 3,
valueOf() {
return 4;
},
toString() {
return &#39;5&#39;
},
[Symbol.toPrimitive]() {
return 6
}
}
console.log(obj &#43; 1); // 输出7
其实就是上一个问题的应用。
var a &#61; {
value: 0,
valueOf: function() {
this.value&#43;&#43;;
return this.value;
}
};
console.log(a &#61;&#61; 1 && a &#61;&#61; 2);//true
红宝书(p178)上对于闭包的定义&#xff1a;闭包是指有权访问另外一个函数作用域中的变量的函数&#xff0c;
MDN 对闭包的定义为&#xff1a;闭包是指那些能够访问自由变量的函数。
(其中自由变量&#xff0c;指在函数中使用的&#xff0c;但既不是函数参数arguments也不是函数的局部变量的变量&#xff0c;其实就是另外一个函数作用域中的变量。)
首先要明白作用域链的概念&#xff0c;其实很简单&#xff0c;在ES5中只存在两种作用域————全局作用域和函数作用域&#xff0c; 当访问一个变量时&#xff0c;解释器会首先在当前作用域查找标示符&#xff0c;如果没有找到&#xff0c;就去父作用域找&#xff0c;直到找到该变量的标示符或者不在父作用域中&#xff0c;这就是作用域链
&#xff0c;值得注意的是&#xff0c;每一个子函数都会拷贝上级的作用域&#xff0c;形成一个作用域的链条。比如:
var a &#61; 1;
function f1() {
var a &#61; 2
function f2() {
var a &#61; 3;
console.log(a);//3
}
}
在这段代码中&#xff0c;f1的作用域指向有全局作用域(window)和它本身&#xff0c;而f2的作用域指向全局作用域(window)、f1和它本身。而且作用域是从最底层向上找&#xff0c;直到找到全局作用域window为止&#xff0c;如果全局还没有的话就会报错。就这么简单一件事情&#xff01;
闭包产生的本质就是&#xff0c;当前环境中存在指向父级作用域的引用。还是举上面的例子:
function f1() {
var a &#61; 2
function f2() {
console.log(a);//2
}
return f2;
}
var x &#61; f1();
x();
这里x会拿到父级作用域中的变量&#xff0c;输出2。因为在当前环境中&#xff0c;含有对f2的引用&#xff0c;f2恰恰引用了window、f1和f2的作用域。因此f2可以访问到f1的作用域的变量。
那是不是只有返回函数才算是产生了闭包呢&#xff1f;、
回到闭包的本质&#xff0c;我们只需要让父级作用域的引用存在即可&#xff0c;因此我们还可以这么做&#xff1a;
var f3;
function f1() {
var a &#61; 2
f3 &#61; function() {
console.log(a);
}
}
f1();
f3();
让f1执行&#xff0c;给f3赋值后&#xff0c;等于说现在 f3拥有了window、f1和f3本身这几个作用域的访问权限
&#xff0c;还是自底向上查找&#xff0c; 最近是在f1
中找到了a,因此输出2。
在这里是外面的变量 f3存在着父级作用域的引用
&#xff0c;因此产生了闭包&#xff0c;形式变了&#xff0c;本质没有改变。
明白了本质之后&#xff0c;我们就来看看&#xff0c;在真实的场景中&#xff0c;究竟在哪些地方能体现闭包的存在&#xff1f;
返回一个函数。刚刚已经举例。
作为函数参数传递
var a &#61; 1;
function foo(){
var a &#61; 2;
function baz(){
console.log(a);
}
bar(baz);
}
function bar(fn){
// 这就是闭包
fn();
}
// 输出2&#xff0c;而不是1
foo();
在定时器、事件监听、Ajax请求、跨窗口通信、Web Workers或者任何异步中&#xff0c;只要使用了回调函数&#xff0c;实际上就是在使用闭包。
以下的闭包保存的仅仅是window和当前作用域。
// 定时器
setTimeout(function timeHandler(){
console.log(&#39;111&#39;);
}&#xff0c;100)
// 事件监听
$(&#39;#app&#39;).click(function(){
console.log(&#39;DOM Listener&#39;);
})
IIFE(立即执行函数表达式)创建闭包, 保存了 全局作用域window
和 当前函数的作用域
&#xff0c;因此可以全局的变量。
var a &#61; 2;
(function IIFE(){
// 输出2
console.log(a);
})();
for(var i &#61; 1; i <&#61; 5; i &#43;&#43;){
setTimeout(function timer(){
console.log(i)
}, 0)
}
为什么会全部输出6&#xff1f;如何改进&#xff0c;让它输出1&#xff0c;2&#xff0c;3&#xff0c;4&#xff0c;5&#xff1f;(方法越多越好)
因为setTimeout为宏任务&#xff0c;由于JS中单线程eventLoop机制&#xff0c;在主线程同步任务执行完后才去执行宏任务&#xff0c;因此循环结束后setTimeout中的回调才依次执行&#xff0c;但输出i的时候当前作用域没有&#xff0c;往上一级再找&#xff0c;发现了i,此时循环已经结束&#xff0c;i变成了6。因此会全部输出6。
解决方法&#xff1a;
1、利用IIFE(立即执行函数表达式)当每次for循环时&#xff0c;把此时的i变量传递到定时器中
for(var i &#61; 1;i <&#61; 5;i&#43;&#43;){
(function(j){
setTimeout(function timer(){
console.log(j)
}, 0)
})(i)
}
2、给定时器传入第三个参数, 作为timer函数的第一个函数参数
for(var i&#61;1;i<&#61;5;i&#43;&#43;){
setTimeout(function timer(j){
console.log(j)
}, 0, i)
}
3、使用ES6中的let
for(let i &#61; 1; i <&#61; 5; i&#43;&#43;){
setTimeout(function timer(){
console.log(i)
},0)
}
let使JS发生革命性的变化&#xff0c;让JS有函数作用域变为了块级作用域&#xff0c;用let后作用域链不复存在。代码的作用域以块级为单位&#xff0c;以上面代码为例:
// i &#61; 1
{
setTimeout(function timer(){
console.log(1)
},0)
}
// i &#61; 2
{
setTimeout(function timer(){
console.log(2)
},0)
}
// i &#61; 3
...
因此能输出正确的结果。
在Javascript中&#xff0c;每当定义一个函数数据类型(普通函数、类)时候&#xff0c;都会天生自带一个prototype属性&#xff0c;这个属性指向函数的原型对象。
当函数经过new调用时&#xff0c;这个函数就成为了构造函数&#xff0c;返回一个全新的实例对象&#xff0c;这个实例对象有一个proto属性&#xff0c;指向构造函数的原型对象。
Javascript对象通过prototype指向父类对象&#xff0c;直到指向Object对象为止&#xff0c;这样就形成了一个原型指向的链条, 即原型链。
对象的 hasOwnProperty() 来检查对象自身中是否含有该属性
使用 in 检查对象中是否含有某个属性时&#xff0c;如果对象中没有但是原型链中有&#xff0c;也会返回 true
function Parent1(){
this.name &#61; &#39;parent1&#39;;
}
function Child1(){
Parent1.call(this);
this.type &#61; &#39;child1&#39;
}
console.log(new Child1);
这样写的时候子类虽然能够拿到父类的属性值&#xff0c;但是问题是父类原型对象中一旦存在方法那么子类无法继承。那么引出下面的方法。
function Parent2() {
this.name &#61; &#39;parent2&#39;;
this.play &#61; [1, 2, 3]
}
function Child2() {
this.type &#61; &#39;child2&#39;;
}
Child2.prototype &#61; new Parent2();
console.log(new Child2());
看似没有问题&#xff0c;父类的方法和属性都能够访问&#xff0c;但实际上有一个潜在的不足。举个例子&#xff1a;
var s1 &#61; new Child2();
var s2 &#61; new Child2();
s1.play.push(4);
console.log(s1.play, s2.play);
可以看到控制台&#xff1a;
明明我只改变了s1的play属性&#xff0c;为什么s2也跟着变了呢&#xff1f;很简单&#xff0c;因为两个实例使用的是同一个原型对象。
那么还有更好的方式么&#xff1f;
function Parent3 () {
this.name &#61; &#39;parent3&#39;;
this.play &#61; [1, 2, 3];
}
function Child3() {
Parent3.call(this);
this.type &#61; &#39;child3&#39;;
}
Child3.prototype &#61; new Parent3();
var s3 &#61; new Child3();
var s4 &#61; new Child3();
s3.play.push(4);
console.log(s3.play, s4.play);
可以看到控制台&#xff1a;
之前的问题都得以解决。但是这里又徒增了一个新问题&#xff0c;那就是Parent3的构造函数会多执行了一次(Child3.prototype &#61; new Parent3();)。这是我们不愿看到的。那么如何解决这个问题&#xff1f;
function Parent4 () {
this.name &#61; &#39;parent4&#39;;
this.play &#61; [1, 2, 3];
}
function Child4() {
Parent4.call(this);
this.type &#61; &#39;child4&#39;;
}
Child4.prototype &#61; Parent4.prototype;
这里让将父类原型对象直接给到子类&#xff0c;父类构造函数只执行一次&#xff0c;而且父类属性和方法均能访问&#xff0c;但是我们来测试一下&#xff1a;
var s3 &#61; new Child4();
var s4 &#61; new Child4();
console.log(s3)
子类实例的构造函数是Parent4&#xff0c;显然这是不对的&#xff0c;应该是Child4。
function Parent5 () {
this.name &#61; &#39;parent5&#39;;
this.play &#61; [1, 2, 3];
}
function Child5() {
Parent5.call(this);
this.type &#61; &#39;child5&#39;;
}
Child5.prototype &#61; Object.create(Parent5.prototype);
Child5.prototype.constructor &#61; Child5;
这是最推荐的一种方式&#xff0c;接近完美的继承&#xff0c;它的名字也叫做寄生组合继承。
ES6的代码最后都是要在浏览器上能够跑起来的&#xff0c;这中间就利用了babel这个编译工具&#xff0c;将ES6的代码编译成ES5让一些不支持新语法的浏览器也能运行。
那最后编译成了什么样子呢&#xff1f;
function _possibleConstructorReturn (self, call) {
// ...
return call && (typeof call &#61;&#61;&#61; &#39;object&#39; || typeof call &#61;&#61;&#61; &#39;function&#39;) ? call : self;
}
function _inherits (subClass, superClass) {
// ...
//看到没有
subClass.prototype &#61; Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
enumerable: false,
writable: true,
configurable: true
}
});
if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ &#61; superClass;
}
var Parent &#61; function Parent () {
// 验证是否是 Parent 构造出来的 this
_classCallCheck(this, Parent);
};
var Child &#61; (function (_Parent) {
_inherits(Child, _Parent);
function Child () {
_classCallCheck(this, Child);
return _possibleConstructorReturn(this, (Child.__proto__ || Object.getPrototypeOf(Child)).apply(this, arguments));
}
return Child;
}(Parent));
核心是_inherits函数&#xff0c;可以看到它采用的依然也是第五种方式————寄生组合继承方式&#xff0c;同时证明了这种方式的成功。不过这里加了一个Object.setPrototypeOf(subClass, superClass)&#xff0c;这是用来干啥的呢&#xff1f;
答案是用来继承父类的静态方法。这也是原来的继承方式疏忽掉的地方。
追问: 面向对象的设计一定是好的设计吗&#xff1f;
不一定。从继承的角度说&#xff0c;这一设计是存在巨大隐患的。
假如现在有不同品牌的车&#xff0c;每辆车都有drive、music、addOil这三个方法。
class Car{
constructor(id) {
this.id &#61; id;
}
drive(){
console.log("wuwuwu!");
}
music(){
console.log("lalala!")
}
addOil(){
console.log("哦哟&#xff01;")
}
}
class otherCar extends Car{}
现在可以实现车的功能&#xff0c;并且以此去扩展不同的车。
但是问题来了&#xff0c;新能源汽车也是车&#xff0c;但是它并不需要addOil(加油)。
如果让新能源汽车的类继承Car的话&#xff0c;也是有问题的&#xff0c;俗称"大猩猩和香蕉"的问题。大猩猩手里有香蕉&#xff0c;但是我现在明明只需要香蕉&#xff0c;却拿到了一只大猩猩。也就是说加油这个方法&#xff0c;我现在是不需要的&#xff0c;但是由于继承的原因&#xff0c;也给到子类了。
继承的最大问题在于&#xff1a;无法决定继承哪些属性&#xff0c;所有属性都得继承。
当然你可能会说&#xff0c;可以再创建一个父类啊&#xff0c;把加油的方法给去掉&#xff0c;但是这也是有问题的&#xff0c;一方面父类是无法描述所有子类的细节情况的&#xff0c;为了不同的子类特性去增加不同的父类&#xff0c; 代码势必会大量重复
&#xff0c;另一方面一旦子类有所变动&#xff0c;父类也要进行相应的更新&#xff0c; 代码的耦合性太高
&#xff0c;维护性不好。
那如何来解决继承的诸多问题呢&#xff1f;
用组合&#xff0c;这也是当今编程语法发展的趋势&#xff0c;比如golang完全采用的是面向组合的设计方式。
顾名思义&#xff0c;面向组合就是先设计一系列零件&#xff0c;然后将这些零件进行拼装&#xff0c;来形成不同的实例或者类。
function drive(){
console.log("wuwuwu!");
}
function music(){
console.log("lalala!")
}
function addOil(){
console.log("哦哟&#xff01;")
}
let car &#61; compose(drive, music, addOil);
let newEnergyCar &#61; compose(drive, music);
代码干净&#xff0c;复用性也很好。这就是面向组合的设计方式。
参考出处:
ES5实现继承那些事
重学JS系列:聊聊继承
JS最新基本数据类型:BigInt(译)
yck前端面试之道