新人手册——JavaScript入门

457 阅读21分钟

前言

这是为组内新人所准备的新人手册中的JavaScript部分,大致整理了一些初级阶段所需要掌握的Js知识,很多地方有参考csdn和掘金社区文章,内容比较新人向,偏向基础,运行原理层面的东西比较少。

JS常用方法

DOM BOM

1.常用DOM api

// 获取DOM节点
document.getElementById() // 通过ID获取
document.getElementsByTagName() // 标签名
document.getElementsByClassName() // 类名
document.querySelector() // 通过选择器获取一个元素
document.querySelectorAll() // 通过选择器获取一组元素
document.body // 获取body的方法
document.getElementsByName(name) // 通过name属性查找元素节点
document.documentElement // 获取html的方法

// 节点类型
元素节点(标签) // 属性nodeType返回值1
属性节点(标签里的属性)// 返回值2
文本节点 // 返回值3
注释节点(comment) // 返回值8
document // 返回值9
DocumentFragment // 返回值11

// 节点接口
dom元素.parentNode // 返回当前元素的父节点
dom元素.childNodes // 子节点们
dom元素.firstChild // 第一个子节点
dom元素.lastChild // 最后一个子节点
dom元素.nextSibling // 后一个兄弟节点 previousSibling -> 前一个兄弟节点

// 元素节点接口
dom元素.parentElement // 返回当前元素的父元素节点
dom元素.children // 返回当前元素的元素子节点
dom元素.firstElementChild // 第一个元素子节点(IE不兼容)
dom元素.lastElementChild // 最后一个元素子节点(IE不兼容)
dom元素.nextElementSibling // 返回后一个兄弟元素节点
dom元素.previousElementSibling // 返回前一个兄弟元素节点

// 节点的四个属性和一个方法
节点.nodeName // 元素的标签名,以大写形式表示(只读)
节点.nodeValue // Text节点或者Comment节点的文本内容,(读写)
节点.nodeType // 该节点的类型(只读)
节点.attributes // Element节点的属性集合
节点.hasChildNodes() // 判断节点 是否有子节点

// Element节点的 属性和方法
dom元素.innerHtml
dom元素.innerText
dom元素.attributes // 获取元素身上所有属性构成的集合
dom元素.setAttribute("属性名","属性值")// 给元素设置属性和属性值
dom元素.getAttribute("属性名")// 获取属性值的方法
dom元素.removerAttribute("属性") // 删除属性

// ============== 操作接口 ==================

// 增
document.createElement() // 创建元素节点
document.createTextNode() // 创建文本节点
document.creaetComment() //创建注释节点
document.createDocumentFragment() // 创建文档碎片节点

// 插
父元素节点.appendChild(子元素对象) // 在元素节点的子元素最后插入子元素
父元素节点.insertBefore(父元素中的子元素a, 需要插入的子元素b) // 最后的结果是,父元素节点中 b元素插入到了 a的前面

// 删
元素节点.remove() // 删除DOM元素(自己)
父元素节点.removeChild(子元素节点) // 删除子元素

// 替换
父元素节点.replaceChild(新的节点, 需要被替换的子节点)

// 复制
元素节点.cloneChild() // 返回值是 复制出来的节点

// 元素节点赋值 示例
dom元素.style.width = ...
dom元素.style.backgroundColor = ...
dom元素.className = ...

虽然使用mv*框架一般尽量不要有dom操作,但也可能在某些特殊情况下需要使用,这时需要考虑尽量少的dom操作以提高性能,比如缓存dom元素,将操作整合到文档碎片再进行插入等。

2.存取LocalStorage

反序列化取,序列化存

const love = JSON.parse(localStorage.getItem("love"));
localStorage.setItem("love", JSON.stringify("I Love You"));

3.获取url中的参数

function getURLParameters(url) {
  const params = url.match(/([^?=&]+)(=([^&]*))/g)
  return params?params.reduce(
    (a, v) => (a[v.slice(0, v.indexOf('='))] = v.slice(v.indexOf('=') + 1), a), {}
  ):[]
}

getURLParameters('http://www.baidu.com/index?name=tyler') 
// {name: "tyler"}

字符串

1.常用api

// 1.concat 将两个或多个字符的文本组合起来,返回一个新的字符串。
var a = "hello";
var b = ",world";
var c = a.concat(b);
alert(c);
//c = "hello,world"

// 2.indexOf 返回字符串中一个子串第一处出现的索引(从左到右搜索)。如果没有匹配项,返回 -1 。
var index1 = a.indexOf("l");
//index1 = 2
var index2 = a.indexOf("l",3);
//index2 = 3

// 3.charAt 返回指定位置的字符。
var get_char = a.charAt(0);
//get_char = "h"

// 4.lastIndexOf 返回字符串中一个子串最后一处出现的索引(从右到左搜索),如果没有匹配项,返回 -1 。
var index1 = lastIndexOf('l');
//index1 = 3
var index2 = lastIndexOf('l',2)
//index2 = 2

// 5.match 检查一个字符串匹配一个正则表达式内容,如果么有匹配返回 null。
var re = new RegExp(/^\w+$/);
var is_alpha1 = a.match(re);
//is_alpha1 = "hello"
var is_alpha2 = b.match(re);
//is_alpha2 = null

// 6.substring 返回字符串的一个子串,传入参数是起始位置和结束位置。
var sub_string1 = a.substring(1);
//sub_string1 = "ello"
var sub_string2 = a.substring(1,4);
//sub_string2 = "ell"

// 7.substr 返回字符串的一个子串,传入参数是起始位置和长度
var sub_string1 = a.substr(1);
//sub_string1 = "ello"
var sub_string2 = a.substr(1,4);
//sub_string2 = "ello"

// 8.replace 用来查找匹配一个正则表达式的字符串,然后使用新字符串代替匹配的字符串。
var result1 = a.replace(re,"Hello");
//result1 = "Hello"
var result2 = b.replace(re,"Hello");
//result2 = ",world"

// 9.search 执行一个正则表达式匹配查找。如果查找成功,返回字符串中匹配的索引值。否则返回 -1 。
var index1 = a.search(re);
//index1 = 0
var index2 = b.search(re);
//index2 = -1

// 10.slice 提取字符串的一部分,并返回一个新字符串(与 substring 相同)。
var sub_string1 = a.slice(1);
//sub_string1 = "ello"
var sub_string2 = a.slice(1,4);
//sub_string2 = "ell"

// 11.split 通过将字符串划分成子串,将一个字符串做成一个字符串数组。
var arr1 = a.split("");
//arr1 = [h,e,l,l,o]

数组

1.数组去重

let arrs = [1,2,2,3,3,6,5,5];
// ES6
[...new Set(arr)] // [1,2,3,6,5]

2.合并数组

let arr1 = [1,2,3]
let arr2 = [4,5,6]

// ES6
[...arr1, ...arr2] // [1, 2, 3, 4, 5, 6]

// 方法2:concat方法(挂载Array原型链上)
let c = a.concat(b);
console.log(c); // [1, 2, 3, 4, 5, 6]
console.log(a); // [1, 2, 3]  不改变本身
// 备注:看似concat似乎是 数组对象的深拷贝,其实,concat 只是对数组的第一层进行深拷贝

// 方法3:apply方法
Array.prototype.push.apply(a, b);
console.log(a); // [1, 2, 3, 4, 5, 6] 改变原目标数组
console.log(b); // [4, 5, 6]

3.过滤数组

let json = [
  { id: 1, name: 'john', age: 24 },
  { id: 2, name: 'zkp', age: 21 },
  { id: 3, name: 'mike', age: 50 }
];

// ES6
json.filter( item => item.age > 22) 
// [{id: 1, name: 'john', age: 24}, { id: 3, name: 'mike', age: 50 }]

4.截断数组

const arr = [0, 1, 2];
arr.length = 2;
// arr => [0, 1]
arr.length = 0;
// arr => []

5.创建数组

// 创建指定长度数组
const arr = [...new Array(3).keys()];
// arr => [0, 1, 2]

// 创建指定长度且值相等的数组
const arr = new Array(3).fill(0);
// arr => [0, 0, 0]

6.获得数组最大最小值


// 使用 Math 中的 max/min 方法
let arr = [22,13,6,55,30];

// ES6
Math.max(...arr); // 55
Math.min(...arr); // 6

// ES5
Math.max.apply(null, arr); // 55
Math.min.apply(null, arr); // 6

7.在循环中缓存array.length

存在小部分移动端机型不支持数组的forEach方法,可以自己加垫片或者使用for循环,循环中缓存数组长度是个好习惯

var length = array.length; 
for(var i = 0; i < length; i++){ 
	console.log(array[i]); 
} 
为了更简洁,可以这么写 
for(var i = 0,length = array.length; i < length; i++){ 
	console.log(array[i]); 
} 

8.数组对象去重

let arr = [
  {id: 1, name: 'Jhon1'},
  {id: 2, name: 'sss'},
  {id: 3, name: 'Jhon2'},
  {id: 4, name: 'Jhon3'}
]

// ES6
const uniqueElementsBy = (arr, fn) => {
	arr.reduce((acc, v) => {
		if (!acc.some(x => fn(v, x))) {
			acc.push(v);
		}
		return acc;
	}, []);
};

// 下面的示例表示,去重依据是 id ,就是 id一样的,只留下一个
uniqueElementsBy(arr, (a, b) => a.id === b.id) 
// [{id: 1, name: 'Jhon1'}, {id: 2, name: 'sss'}]

9.数组API

// forEach() 遍历数组

// pop() 删除数组中最后一个元素,并返回该元素的值。此方法更改数组的长度

// shift() 删除数组中第一个元素,并返回该元素的值。此方法更改数组的长度

// push() 将一个或多个元素添加到数组的末尾,并返回该数组的新长度

// unshift() 将一个或多个元素添加到数组的开头,并返回该数组的新长度

// Array.prototype.filter() 创建一个新数组, 其包含通过所提供函数实现的测试的所有元素,不会改变原有值,如果没符合的返回[]
let arr = [1, 2, 3]
arr.filter( x => x > 1) // [2, 3]

// Array.prototype.join() 将一个数组(或一个类数组对象)的所有元素连接成一个字符串并返回这个字符串
['Fire', 'Air', 'Water'].join() // "Fire,Air,Water"

// Array.prototype.slice() 取出任意元素, | 参数一:从哪开始,参数二(可选)结束位置,不选的话 就节选到最后了
[1, 2, 3].slice(0, 1) // [1]

// Array.prototype.splice() 删除任意元素,操作任意元素 | 参数一:从哪开始 | 参数二:操作元素的个数 | 参数三:插入元素的值...(可以写多个参数三)
[1, 2, 3].splice(0, 1) // 删除 [2, 3]

// Array.prototype.includes() 用来判断一个数组是否包含一个指定的值,根据情况,如果包含则返回 true,否则返回false。
[1, 2, 3].includes(1) // true

// Array.prototype.reverse() 颠倒数组
[1, 2, 3].reverse() // [3, 2, 1]

对象

1.对象合并

// ES6
let obj1 = {
    a:1,
    b:{ 
        b1:2 
    }
}
let obj2 = { c:3, d:4 }

console.log({...obj1, ...obj2}) // {a: 1, b: {…}, c: 3, d: 4}
// 支持无限制合并,但如果对象之间存在相同属性,则后面属性会覆盖前面属性。请注意,这仅适用于浅层合并。

// Obj.assign():可以把任意多个的源对象自身的可枚举属性拷贝给目标对象,然后返回目标对象
let o1 = { a: 1 };
let o2 = { b: 2 };

let obj = Object.assign(o1, o2);
console.log(obj); // { a: 1, b: 2 }
console.log(o1);  // { a: 1, b: 2 }
// 备注:Object.assign() 拷贝的是属性值。假如源对象的属性值是一个指向对象的引用,它也只拷贝那个引用值

2.对象中属性的个数

let obj = {name: 'Jack', age: 18}
// ES6
Object.keys(obj).length // 2

// ES5
let attributeCount = obj => {
    let count = 0;
    for(let i in obj) {
        if(obj.hasOwnProperty(i)) {
            count++;
        }
    }
    return count;
}
attributeCount(obj) // 2
// 空对象也可以这样
let someObj = {};
if (JSON.stringify(someObj) === '{}') {
  console.log('空');
}

3.有条件的对象属性

不再需要根据一个条件创建两个不同的对象,可以使用扩展运算符来处理。

const getUser = (emailIncluded) => {
 return {
  name: 'John',
  surname: 'Doe',
  ...emailIncluded && { email : 'john@doe.com' }
 }
}
 
const user = getUser(true);
console.log(user); 
// { name: "John", surname: "Doe", email: "john@doe.com" }
 
const userWithoutEmail = getUser(false);
console.log(userWithoutEmail); 
// { name: "John", surname: "Doe" }

4.用解构赋值过滤对象属性

// 过滤掉对象中 inner 和 outer 属性
const { inner, outer, ...restProps } = {
  inner: 'This is inner',
  outer: 'This is outer',
  v1: '1',
  v2: '2',
  v4: '3'
};
console.log(restProps);
// {v1: "1", v2: "2", v4: "3"}

5.用对象代替switch

公共内容:
let a = 'VIP'

//case 1
if (a === 'VIP') {
  return 1
} else if (a === 'SVIP') {
  return 2
}

//case 2
switch(a) {
  case 'VIP'
    return 1
    break
  case 'SVIP'
    return 2
    break
}

let obj = {
  VIP: 1,
  SVIP: 2
}

在业务开发中,可用这种方法把状态映射到对应的value,object[key]的性能要优于switch

运算符

1.使用!!将变量转换成布尔类型

有时我们需要检查一些变量是否存在,或者他是否具有有效值,从而将他们的值视为true。对于做这样的检查,你可以使用!!(双重否定运算符),他能自动的将任何类型的数据转换为布尔值,只有false,0,null,"",undefined或NaN才会返回false,其他的值都返回true。

var x = null;
var y = "";
var str = "abcd";

console.log(!!x)      // false;
console.log(!!y)      // false;
console.log(!!str)    // true;

2.使用 || 设置默认值

在ES6中有默认参数这个功能。为了在旧版浏览器中模拟此功能,你可以使用 || (or运算符),并把默认值作为它的第二个参数。如果第一参数返回 false ,那么第二个参数将会作为默认值返回。看下面的例子:

function User(name,age){ 
this.name = name || “David”; 
this.age = age || “25”; 
} 
var user1 = new User(); 
console.log(user1.name);//David 
console.log(user1.age);//25 
var user2= new User(“Barry”,”10”): 
console.log(user2.name);//Barry 
console.log(user2.age);//10

3.使用 && 执行语句

用于条件执行语句

// 普通的if语句
if (test) {
  isTrue();
}
// 上面的语句可以使用 '&&' 写为:
( test && isTrue() );

防止变量param为 undefined 的时候还取其属性造成报错

var variable = param && param.prop;

用于判断后赋值

var add_level = 0; 
if (add_step == 5) {
    add_level = 1; 
} else if (add_step == 10) {
    add_level = 2; 
} else {
    add_level = 0;
}
//可以写成
var add_level = (add_step==5 && 1) || (add_step==10 && 2) || 0;

4.string强制转换为数字

可以用 *1 来转化为数字(实际上是调用 .valueOf 方法) 然后使用 Number.isNaN 来判断是否为 NaN,或者使用 a!==a 来判断是否为 NaN ,因为 NaN!==NaN

var a;
a = '32' * 1; // 32
a = 'ds' * 1; // NaN
a = null * 1; // 0
a = undefined * 1; // NaN
a = 1 * { valueOf: ()=>'3' }; // 3

也可以使用 + 来转换,如 +'123'

function toNumber(strNumber){ 
return +strNumber; 
} 
console.log("1234");//1234 
console.log("ABCD");//NaN 
// 这个操作也可以作用于date,在这钟情况下,它将返回时间戳: 
console.log(+ new Date());//1569209419663

5.object强制转换为string

可以使用 字符串+Object 的方式来转化对象为字符串(实际上是调用 .toString() 方法),当然也可以覆盖对象的 toStringvalueOf 方法来自定义对象的类型转换:

var a = 2 * { valueOf: ()=>'3' } // 6
a = 'J' + { toString: ()=>'S' } // JS

《Effective JavaScript》P11:当 + 用在连接字符串时,当一个对象既有 toString 方法又有 valueOf 方法时候,JS通过盲目使用 valueOf 方法来解决这种含糊。对象通过 valueOf 方法强制转换为数字,通过 toString 方法强制转换为字符串

var a = '' + {toString:()=>'S',valueOf:()=>'J'} // J

6.双位运算符 ~~

可以使用双位操作符来替代 Math.floor()。双否定位操作符的优势在于它执行相同的操作运行速度更快。

Math.floor(4.9) === 4  //true
// 简写为:
~~4.9 === 4  //true

不过要注意,对正数来说 ~~ 运算结果与 Math.floor() 运算结果相同,而对于负数来说不相同:

~~4.5  // 4
Math.floor(4.5) // 4
~~-4.5  // -4
Math.floor(-4.5) // -5

时间

1.Date常用api

new Date() // 创建一个时间对象 Fri Jul 12 2019 19:59:59 GMT+0800 (中国标准时间)

// 返回自1970年1月1日 00:00:00 UTC到当前时间的毫秒数。
Date.now(); // 1562932828164

// 解析一个表示某个日期的字符串,并返回从1970-1-1 00:00:00 UTC 到该日期对象(该日期对象的UTC时间)的毫秒数
Date.parse('2019.7.12') // 1562860800000

// 年月日时分秒 获取
let dateMe = new Date()

dateMe.getFullYear() // 2019 | 根据本地时间返回指定日期的年份
dateMe.getMonth() // 6 | 根据本地时间,返回一个指定的日期对象的月份,为基于0的值(0表示一年中的第一月)。
dateMe.getDate() // 12 | 根据本地时间,返回一个指定的日期对象为一个月中的哪一日(从1--31)
dateMe.getHours() // 20 |根据本地时间,返回一个指定的日期对象的小时。
dateMe.getMinutes() // 11 | 根据本地时间,返回一个指定的日期对象的分钟数。
dateMe.getSeconds() // 29 | 方法根据本地时间,返回一个指定的日期对象的秒数
dateMe.getMilliseconds() // 363 | 根据本地时间,返回一个指定的日期对象的毫秒数。

dateMe.toJSON() // "2019-07-12T12:05:15.363Z" | 返回 Date 对象的字符串形式
dateMe.getDay() // 5 | 根据本地时间,返回一个具体日期中一周的第几天,0 表示星期天(0 - 6)
dateMe.getTime() // 1562933115363 | 方法返回一个时间的格林威治时间数值。
dateMe.toString() // "Fri Jul 12 2019 20:05:15 GMT+0800 (中国标准时间)" | 返回一个字符串,表示该Date对象
dateMe.getTimezoneOffset() // -480(说明比正常时区慢480分钟,所以要加480分钟才对) | 返回协调世界时(UTC)相对于当前时区的时间差值,单位为分钟。
dateMe.toDateString() // "Fri Jul 12 2019" | 以美式英语和人类易读的形式返回一个日期对象日期部分的字符串。

2.将数组按照时间进行排序

let data = [
{
  id: 1,
  publishTime: "2019-05-14 18:10:29"
},
{
  id: 2,
  publishTime: "2019-05-14 18:17:29"
},
{
  id: 3,
  publishTime: "2019-05-14 15:09:25"
}]

data.sort((a, b) => b.publishTime - a.publishTime);
// 0: {id: 2, publishTime: "2019-05-14 18:17:29"}
// 1: {id: 1, publishTime: "2019-05-14 18:10:29"}
// 2: {id: 3, publishTime: "2019-05-14 15:09:25"}

其他

正则校验

在表单验证时,我们经常会需要去验证一些内容,举例几个常用的验证

function checkType (str, type) {
    switch (type) {
        case 'email':
            return /^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(str);
        case 'phone':
            return /^1[3|4|5|7|8][0-9]{9}$/.test(str);
        case 'tel':
            return /^(0\d{2,3}-\d{7,8})(-\d{1,4})?$/.test(str);
        case 'number':
            return /^[0-9]$/.test(str);
        case 'english':
            return /^[a-zA-Z]+$/.test(str);
        case 'chinese':
            return /^[\u4E00-\u9FA5]+$/.test(str);
        case 'lower':
            return /^[a-z]+$/.test(str);
        case 'upper':
            return /^[A-Z]+$/.test(str);
        default :
            return true;
    }
}
console.log(checkType ('hjkhjhT','lower'))   //false

JS深入

数据类型

javascript存在的数据类型有booleanstringnumberundefinednullsymbolobjectfunction,其中objectfunction为引用类型,其余为基本类型。使用虽然简单,但也需要注意几点。

number

number类型低层采用双精度浮点数存储,能够表示整数,也能表示小数,其能够表示的整数范围是-9007199254740991~9007199254740091,超出有可能丢失精度。如0.1+0.2,结果为0.30000000000000004。number类型还有个特殊值NaN,意为not a number

undefined

undefined 一般表示未赋值,变量或属性在声明后未赋值就为 undefined

object

object类型涵盖的东西非常广,ArrayRegExp这些内置类型的对象也是object类型。null是一个非常特殊的值,表示空的对象引用,typeof null会输出object,这是个历史遗留问题,可以自行了解。

typeof vs instanceof

typeof能够判断除了null以外的基本类型,而对于对象来说,除了函数会返回function以外,其他都会返回object。 要正确判断对象的类型,可以考虑使用instanceof,其内部机制是通过原型链进行判断的。 可以试着实现instanceof:

  • 首先获取类型的原型
  • 然后获得对象的原型
  • 然后一直循环判断对象的原型是否等于类型的原型,直到对象原型为null,因为原型链最终为null
function myInstanceof(left, right) { 
	let prototype = right.prototype 
	left = left.__proto__
	while (true) {
		if (left === null || left === undefined) 
			return false
	    if (prototype === left)
	      return true
	    left = left.__proto__
	}
}

作用域链和闭包

作用域

因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。在ES6之前,javascript里只有全局和函数作用域两种作用域,到ES6才引入了块级作用域。

  • javascript在运行时需要一个环境,这个环境便是我们所谓执行上下文,JavaScript 引擎创建了执行上下文栈来管理执行上下文。
  • 执行上下文决定了变量或者函数有权利访问其它数据,每个执行环境都有一个与之关联的变量对象,用于存储执行上下文中定义的变量或者函数
  • 一般情况下我们所处的全局执行上下文便是window对象,所以全局范围内创建的所有对象全部是window的属性或者方法
  • 函数的变量对象一般是其活动对象(activation Object
  • 每执行一个函数时,函数的执行上下文会被推入一个上下文栈中,函数若执行结束,这个上下文栈便会被弹出,控制权变回之前的执行上下文
  • 函数在定义时会有个[[scope]]的内部属性指向当时的作用域,一旦函数开始执行,它会创建一个新的作用域,并且用指针指向函数[[scope]]属性指向的内容,如此就形成了一条作用域链,作用域链的最外层是全局作用域

闭包

作用域链的作用就是做标示符解析。

  • 函数在执行时遇到变量标示符解析的时,首先从当前作用域查找,找不到则一直沿着作用域链向上搜索。
  • 作用域链会形成一个非常有意思的特性,叫闭包。就是外层函数执行完了(栈帧已经弹出),如果内层函数对象还没被回收,外层作用域的变量环境会一直保留着,当内层函数执行的时候,依然能访问外层作用域的变量环境。

简单来说:函数 A 内部有一个函数 B,函数 B 可以访 问到函数 A 中的变量,那么函数 B 就是闭包

原型和原型链

原型

先简单说说概念,每个函数都有一个 prototype 属性,指向了一个对象,这个对象就是该函数作为构造函数通过 new 创建的实例的原型,而被创建的实例有一个 __proto__ 属性,指向它的原型。而相应的,每个原型都有一个 constructor 属性指向关联的构造函数。 这一点可以从代码里看到:

function Person() {
    
}

var person = new Person();
console.log(person.__proto__ === Person.prototype); // true
console.log(Person.prototype.constructor === Person); // true
console.log(Object.getPrototypeOf(person) === Person.prototype); // true

看起来很简单,那么可以得到一个关系图:

enter image description here

原型链

当读取实例的属性时,如果找不到,就会去实例的原型中查找,如果还查不到,就去原型的原型中查找,一直到最顶层 Obejct.prototype 为止。 关系图可以更新为:

enter image description here
图中由相互关联的原型组成的链状结构就是原型链。 理解这些后,基本的一些原型关系我们都可以自己判断:

function Person() {
    
}

var person = new Person();
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__ === null); // true
console.log(person.constructor === Person); // true Person.prototype.constructor
console.log(Person.__proto__ === Function.prototype); // true
console.log(Object.__proto__ === Function.prototype); // true
// 特别地
console.log(Function.__proto__ === Function.prototype); // true

这里特别的情况是 Function 作为一个内置对象,在javascript的实现机制里,其原型指向了 Function.prototype 。 顺便提一个和原型有关的运算符 instanceofperson instanceof Person 其运行原理是判断 Person.prototype 是不是在 person 的原型链上。

任务队列

进程与线程

进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。线程是进程中的更小单位,描述了执行一段指令所需的时间。

  • 把这些概念拿到浏览器中来说,当你打开一个 Tab ⻚时,其实就是创建了一个进程。
  • 一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等等。
  • 当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。

上文说到了 JS 引擎线程和渲染线程,大家应该都知道,在 JS 运行的时候可能会阻止 UI 渲染,这说明了两个线程是互斥的。这其中的原因是因为 JS 可以修改 DOM,如果在 JS 执行的时候 UI 线程还在工作,就可能导致不能安全的渲染 UI。这其实也是一个单线程的好处,得益于 JS 是单线程运行的,可以达到节省内存,节约上下文切换时间的好处。

执行栈

可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。 当开始执行 JS 代码时,首先会执行一个 main 函数,然后执行我们的代码。根据先进后出的原则,后执行的函数会先弹出栈。 在开发中,可以从报错中找到执行栈的痕迹

Alt text
可以在上图清晰的看到报错在 foo 函数,foo 函数又是在 bar 函数中调用的。 当我们使用递归的时候,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈的问题。

事件循环

JavaScript 是单线程的,任务只能排着队一个一个进入执行栈执行,如果一个任务耗时过长,后面的任务也必须等着。这听起来不太合理,所以任务被分为同步任务和异步任务。

  • 同步任务进入主线程执行,异步任务进入 Event Table 并注册回调函数。
  • 在指定的事情完成时 Event Table 将回调函数移入 Event Queue
  • 引擎检查到主线程内任务执行完毕,去 Event Queue 中读取回调函数进入主线程执行。
  • 开启下一循环,重复上述过程,这就是 Event Loop 的运行机制。

如以下代码:

setTimeout(() => {
	console.log("1");
}, 0);

console.log("2");
// ...do something

setTimeout 中的代码需要在主线程所有代码执行完毕之后才会执行。

微任务宏任务

以上都很简单,下面进入正题,看看Promistprocess.nextTick 的表现。 除了异步任务和同步任务,任务还可以分为:

  • macro-task(宏任务):同步任务,setTimeoutsetInterval
  • micro-task(微任务):Promiseprocess.nextTick

那么事件循环的顺序就变成了:从整体代码(宏任务)开始第一次循环,执行完主线程内的任务后,接着执行所有的微任务。然后再从宏任务开始,找到 Event Queue 中的第一个任务执行完毕,再执行所有的微任务。 举个例子:

async function async1() {
   console.log('async1 start')
   await async2()
   console.log('async1 end')
}

async function async2() {
   console.log('async2')
}

console.log('script start')

setTimeout(function () {
   console.log('settimeout')
})

async1()

new Promise(function (resolve) {
   console.log('promise1')
   resolve()
}).then(function () {
   console.log('promise2')
})

console.log('script end')

简单分析一下:

  • 整段代码作为第一个宏任务进入主线程,显然,首先输出 script start
  • 执行到 setTimeout ,将其回调函数分发到宏任务 Event Queue 中。
  • 执行 async1() ,先输出 async1 start ,再往下执行 await async2() ,这里从右向左执行,输出 async2
  • await等到到是一个 promise 对象,阻塞 async 函数并先执行外部的同步代码,因此继续往下,输出promise1
  • 运行到 promise.then() ,将其回调函数分发到微任务 Event Queue 中,继续往下,输出 script end
  • 此时宏任务中 async 外的代码执行完毕,回到 await 表达式,拿到右侧结果,输出 async1 end (注:代码执行环境为babel编译后,chrome浏览器)。
  • 往下,执行全部微任务队列,输出 promise2
  • 微任务执行完毕,从宏任务队列中拿出之前放入的回调函数开始执行,输出 settimeout

javascript的执行在不同的环境中是有所不同的,但只要牢牢把握单线程和 Event Loop 这两点,对微任务和宏任务都有哪些有一定的认识,这方面的问题也就迎刃而解了。 需要注意到是Node 中的 process.nextTick,这个函数其实 是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数, 并且优先于其他 microtask 执行。

参数按值传递

函数调用时,参数是按值传递的,什么意思呢?在函数内部拿到的是传入参数的值,也就是说,把函数外部的值复制给函数内部的参数,就和把值从一个变量复制到另一个变量一样。 最简单的:

var value = 1;
function foo(v) {
    v = 2;
    console.log(v); //2
}
foo(value);
console.log(value) // 1

很好理解,当传递 value 到函数 foo 中,相当于拷贝了一份 value,假设拷贝的这份叫 _value,函数中修改的都是 _value 的值,而不会影响原来的 value 值。 当入参是复杂类型的时候,由于变量本质上存储的是对象的引用地址,所以函数内部复制的也是引用。这就相当于两者共享一份内存的引用地址。这样函数内可以通过引用地址访问到对象,改变对象的属性。

var obj = {
    value: 1
};
function foo(o) {
    o.value = 2;
    console.log(o.value); //2
}
foo(obj);
console.log(obj.value) // 2

而在函数内部直接对参数赋值,并不会改变入参变量。

var obj = {
    value: 1
};
function foo(o) {
    o = 2;
    console.log(o); //2
}
foo(obj);
console.log(obj.value) // 1

垃圾回收

js代码执行在V8引擎,V8实现了准确式GCGC 算法采用了分代式垃圾回收机制。因此, V8将内存(堆)分为新生代和老生代两部分。

新生代算法

新生代中的对象一般存活时间较短,使用 Scavenge GC 算法。

  • 在新生代空间中,内存空间分为两部分,分别为 From 空间和 To 空 间。
  • 在这两个空间中,必定有一个空间是使用的,另一个空间是空闲 的。
  • 新分配的对象会被放入 From 空间中,当 From 空间被占满时,新生代 GC 就会启动了。
  • 算法会检查 From 空间中存活的对象 并复制到 To 空间中,如果有失活的对象就会销毁。当复制完成后将 From 空间和 To 空间互换,这样 GC 就结束了。

老生代算法

仅有新生代空间是不够的,在以下情况下,对象会转移到老生代空间。

  • 新生代中的对象是否已经经历过一次 Scavenge 算法,如果经历过的话,会将对象从新生代空间移到老生代空间中。
  • To 空间的对象占比大小超过 25%。在这种情况下,为了不影响到内存分配,会将对象从新生代空间移到老生代空间中。

老生代中的对象一般存活时间较⻓且数量也多,使用了两个算法,分别是标记清除算法标记压缩算法。 老生代中的空间很复杂,有如下几个空间:

enum AllocationSpace {
	RO_SPACE,     // 不变的对象空间
	NEW_SPACE,    // 新生代用于 GC 复制算法的空间
	OLD_SPACE,    // 老生代常驻对象空间
	CODE_SPACE,   // 老生代代码对象空间
	MAP_SPACE,    // 老生代 map 对象
	LO_SPACE,     // 老生代大空间对象
	NEW_LO_SPACE, // 新生代大空间对象
	FIRST_SPACE = RO_SPACE,
	LAST_SPACE = NEW_LO_SPACE, 
	FIRST_GROWABLE_PAGED_SPACE = OLD_SPACE, 
	LAST_GROWABLE_PAGED_SPACE = MAP_SPACE
};

在老生代中,以下情况会先启动标记清除算法:

  • 某一个空间没有分块的时候
  • 空间中被对象超过一定限制
  • 空间不能保证新生代中的对象移动到老生代中

在这个阶段中

  • 会遍历堆中所有的对象,然后标记活的对象。
  • 在标记完成后,销毁所有没有被标记的对象。
  • 在标记大型对内存时,可能需要几百毫秒才能完成一次标记。

这就会导致一些性能上的问题。为了 解决这个问题,2011 年,V8stop-the-world 标记切换到增量标志。在增量标记期间,GC 将标记工作分解为更小的模块,可以让 JS 应用逻辑在模块间隙执行一会,从而不至于让应用出现停顿情况。

但在 2018 年,GC 技术又有了一个重大突破,这项技术名为并发标记。

  • 该技术可以让 GC 扫描和标记对象时,同时允许 JS 运行
  • 清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法
  • 在压缩过程中,将活的对象向一端移动,直到所有对象都移动完成然后清理掉不需要的内存。

类数组对象与arguments

类数组对象

在javascript中存在着一些对象,和数组一样有着length属性和若干索引属性,但又并不是数组,我们称之为类数组对象。 举个例子:

var array = ['name', 'age', 'sex'];

var arrayLike = {
    0: 'name',
    1: 'age',
    2: 'sex',
    length: 3
}

类数组对象可以像数组一样读写,获取长度,遍历,但没有Array原型上的方法,我们可以用 Function.call 间接调用。

var arrayLike = {0: 'name', 1: 'age', 2: 'sex', length: 3 }

Array.prototype.join.call(arrayLike, '&'); // name&age&sex

Array.prototype.slice.call(arrayLike, 0); // ["name", "age", "sex"] 
// slice可以做到类数组转数组

Array.prototype.map.call(arrayLike, function(item){
    return item.toUpperCase();
}); 
// ["NAME", "AGE", "SEX"]

我们看到调用slice方法后,类数组对象被转换成了数组,这样的方法还有几种:

var arrayLike = {0: 'name', 1: 'age', 2: 'sex', length: 3 }
// 1. slice
Array.prototype.slice.call(arrayLike); // ["name", "age", "sex"] 
// 2. splice
Array.prototype.splice.call(arrayLike, 0); // ["name", "age", "sex"] 
// 3. ES6 Array.from
Array.from(arrayLike); // ["name", "age", "sex"] 
// 4. apply
Array.prototype.concat.apply([], arrayLike)

Arguments

Arguments对象只在函数体中,包含了函数的入参和其他属性。 举个例子:

Alt text

  • length属性,表示实参的长度
  • callee 属性,通过它可以调用函数自身

我们可以使用arguments,将参数从一个函数传递到另一个函数

// 使用 apply 将 foo 的参数传递给 bar
function foo() {
    bar.apply(this, arguments);
}
function bar(a, b, c) {
   console.log(a, b, c);
}

foo(1, 2, 3)

使用ES6的 ... 运算符,我们可以轻松转成数组。

function func(...arguments) {
    console.log(arguments); // [1, 2, 3]
}

func(1, 2, 3);

new的过程

在调用 new 的过程中会发生以下四件事情:

  • 新生成了一个对象
  • 链接到原型
  • 绑定 this
  • 返回新对象

完全可以根据以上过程模拟new的实现:

function create() {
	let obj = {}
	let Con = [].shift.call(arguments)
	obj.__proto__ = Con.prototype
	let result = Con.apply(obj, arguments)
	return result instanceof Object ? result : obj
}

对于对象来说,其实都是通过 new 产生的,无论是function Foo() 还是 let a = { b : 1 } 。 对于创建一个对象来说,更推荐使用字面量的方式创建对象(无论性能上还是可读性)。因为你使用 new Object() 的方式创建对象需 要通过作用域链一层层找到 Object ,但是你使用字面量的方式就没这个问题。

call,apply,bind

js中的 call()apply()bind()Function.prototype 下的方法,都是用于改变函数运行时上下文,最终的返回值是你调用的方法的返回值,若该方法没有返回值,则返回 undefined 。 可以简单实现这几个方法:

call

函数的call方法,本质上是指定一个this指向和入参,并调用函数。

  • 不传入第一个参数,那么上下文默认为 window
  • 改变了 this 指向,让新的对象可以执行该函数,并能接受参数
Function.prototype.myCall = function(context) { 
	if (typeof this !== 'function') {
		throw new TypeError('Error') 
	}
	context = context || window context.fn = this
	const args = [...arguments].slice(1) 
	const result = context.fn(...args) 
	delete context.fn
	return result
}

apply

apply和call只在参数上面有所区别,apply传入的是参数数组。

Function.prototype.myApply = function(context) { 
	if (typeof this !== 'function') {
		throw new TypeError('Error') 
	}
	context = context || window context.fn = this
	let result
	// 处理参数和 call 有区别
	if (arguments[1]) {
		result = context.fn(...arguments[1])
	} else {
	    result = context.fn()
	}
	delete context.fn
	return result
}

bind

bind()的作用与call()apply()一样,都是可以改变函数运行时上下文,区别是call()apply()在调用函数之后会立即执行,而bind()方法调用并改变函数运行时上下文后,返回一个新的函数,供我们需要时再调用。

Function.prototype.myBind = function (context) { 
	if (typeof this !== 'function') {
		throw new TypeError('Error') 
	}
	const _this = this
	const args = [...arguments].slice(1) 
	// 返回一个函数
	return function F() {
		// 因为返回了一个函数,我们可以new F(),所以需要判断
		if (this instanceof F) {
			return new _this(...args, ...arguments)
		}
		return _this.apply(context, args.concat(...arguments))
	} 
}

防抖和节流

防抖

所谓防抖,就是指触发事件后在 n 秒内函数只能执行一次,如果在 n 秒内又触发了事件,则会重新计算函数执行时间。 非立即执行版:

function debounce(func, wait) {
    let timeout;
    return function () {
        let context = this;
        let args = arguments;

        if (timeout) clearTimeout(timeout);
        
        timeout = setTimeout(() => {
            func.apply(context, args)
        }, wait);
    }
}

非立即执行版的意思是触发事件后函数不会立即执行,而是在 n 秒后执行,如果在 n 秒内又触发了事件,则会重新计算函数执行时间。 立即执行版:

function debounce(func,wait) {
    let timeout;
    return function () {
        let context = this;
        let args = arguments;

        if (timeout) clearTimeout(timeout);

        let callNow = !timeout;
        timeout = setTimeout(() => {
            timeout = null;
        }, wait)

        if (callNow) func.apply(context, args)
    }
}

立即执行版的意思是触发事件后函数会立即执行,然后 n 秒内不触发事件才能继续执行函数的效果。

节流

所谓节流,就是指连续触发事件但是在 n 秒中只执行一次函数。 节流会稀释函数的执行频率。 定时器版:

function throttle(func, wait) {
    let timeout;
    return function() {
        let context = this;
        let args = arguments;
        if (!timeout) {
            timeout = setTimeout(() => {
                timeout = null;
                func.apply(context, args)
            }, wait)
        }
    }
}

时间戳版:

function throttle(func, wait) {
    var previous = 0;
    return function() {
        let now = Date.now();
        let context = this;
        let args = arguments;
        if (now - previous > wait) {
            func.apply(context, args);
            previous = now;
        }
    }
}

拷贝

浅拷贝

如果是数组,我们可以利用数组的一些方法比如:slice、concat 返回一个新数组的特性来实现拷贝。

var arr = [ 1, 2, null, {a:1}];
var new_arr = arr.slice();
var new_arr = arr.concat();

对象的浅拷贝也很简单,Object.assign或者通过展开运算符...,复制所有属性到新的对象:

let a = { 
	age: 1
}
let b = Object.assign({}, a)
let c = { ...a }

深拷贝

通常可以通过JSON.parse(JSON.stringify(object))来实现。 但是该方法也是有局限性的:

  • 会忽略 undefined
  • 会忽略 symbol
  • 不能序列化函数
  • 不能解决循环引用的对象

可以这样实现一个简易版的深拷贝:

function deepClone(obj) { 
	function isObject(o) {
		return (typeof o === 'object' || typeof o === 'function') && o !== null
	}
	if (!isObject(obj)) { 
		throw new Error('非对象')
	}
	let isArray = Array.isArray(obj)
	let newObj = isArray ? [...obj] : { ...obj } 
	Reflect.ownKeys(newObj).forEach(key => {
		newObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
	})
	return newObj
}