Code Monkey home page Code Monkey logo

blog's People

Contributors

logan70 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

语法和API - 理解ECMAScript和JavaScript的关系

理解ECMAScript和JavaScript的关系

ECMA

ECMA国际(Ecma International)是一家国际性会员制度的信息和电信标准组织。1994年之前,名为欧洲计算机制造商协会(European Computer Manufacturers Association)。因为计算机的国际化,组织的标准牵涉到很多其他国家,因此组织决定改名表明其国际性。现名称已不属于首字母缩略字。

ECMA国际负责了很多标准的制定:

  • CD-ROM格式(之后被国际标准化组织批准为ISO 9660)
  • C#语言规范
  • C++/CLI语言规范
  • 通用语言架构(CLI)
  • ECMAScript语言规范(JavaScript)
  • Eiffel语言
  • 电子产品环境化设计要素
  • Universal 3D标准
  • OOXML
  • Dart语言规范

ECMAScript

1994年,Netscape 发布了 Navigator0.9,但是因为那个时候的浏览器缺乏和用户有良好交互的能力。所以 Netscape 急切渴望一门可以在浏览器中运行,可以提供一定用户交互的语言。

1995年,Netscape 公司的 Brendan Eich开发了 JavaScript 语言。

1996 年 11 月, Netscape 公司决定将 JavaScript 提交给标准化组织 ECMA,希望这种语言能够成为国际标准。

1997 年 7 月,ECMA 发布 262 号标准文件(ECMA-262)的第一版,规定了浏览器脚本语言的标准,并将这种语言称为 ECMAScript,这个版本就是 1.0 版。

所以说ECMAScript是一门脚本程序设计语言标准,当然其实现也不只有JavaScript这一种,比如以下语言也是ECMAScript标准的实现:

  • Ejscript
  • JScript .NET
  • ActionScript
  • DMDScript
  • CriScript
  • InScript

TC39

TC(Technical Committees),代表技术委员会。在ECMA国际,每个标准都会有一个 TC 来负责,而负责 ECMA262标准,即 ECMAScript 的,就是 TC39。

关于ECMAScript的提案可以在TC39的Github仓库中查看:https://github.com/tc39/proposals

ECMAScript6

2015年6月,ECMAScript 6,也就是 ECMAScript 2015 发布。

从 ECMAScript 6 开始,标准有了新的,更加规范化和快速的制定流程。面对着每年一次的,频繁的标准更替,再采用1234的版本号来标注规范显得不太合适。所以从 ECMAScript 6 开始,就开始采用年号来做版本。即 ECMAScript 2015。

现在所提到的ECMAScript6则是泛指ECMAScript 2015及之后的版本。

规范制定流程

在新的规范制定流程中,要求成文标准要从事实标准中诞生,实现先于标准存在。

具体流程规范详见 https://tc39.es/process-document/

每个新特性,从开始到完成一共要经历5个阶段。

stage0 - strawman(稻草人)阶段

任何人都可以提交pull request到 GitHub - tc39/ecma262: Status, process, and documents for ECMA262,可以是一个提议,想法,初步描述。

stage1 - proposal(提案)阶段

  • TC39制定成员作为 champion
  • TC39审阅通过
  • 有实现的 Demo 或者 Polyfill
  • 初步描写标准的语义语法算法复杂度解决的问题等

stage2 - Draft(草案)阶段

  • 至少2个实现,可以为实验性实现
  • ECMAScript spec editor 通过审核
  • TC39 review 通过
  • 文本编写完成

stage3 - Candidate(候选)阶段

  • 至少2个实现,可以为实验性实现
  • ECMAScript spec editor 通过审核
  • TC39 review 通过
  • 文本编写完成

stage4 - Finished(完成)阶段

  • 编写 test 262 测试用例
  • 通过两个实现该特性的内核测试
  • ECMAScript spec editor 通过审核
  • 开发者表示支持和认可

JavaScript

  • JavaScript 是 ECMAScript 语言标准的一种实现
  • JavaScript 有浏览器、Node.js 等多种宿主环境,是一种日常的通称,各种宿主所扩充的 API 有差异,比如浏览器有 各种Dom APIWeb API,而 Node.js 有 process,这些在统一语法规范 ECMAScript 中没有规定

参考文章

变量和类型 - Null 与 Undefined

Null 与 Undefined

  • null表示已被赋值,但值为空,即将一个变量显式赋值为null是正常的
  • undefined表示已声明还未定义的变量 或 对象上不存在的属性,故将变量或属性显式赋值为undefined是不正常的
  • nullundefined转换为布尔值均为false
  • null转换为数值为0undefined转换为数值为NaN
  • null == undefined,因为ECMAScript定义如此,并没有发生隐式类型转换

    ECMAScript中定义 "If x is null and y is undefined, return true."
    详见ECMAScript#abstract-equality-comparison

  • null !=== undefined,二者不是同一数据类型
// bad
let a = undefined

// good
let a

变量和类型 - 数字精度丢失(0.1 + 0.2 = 0.30000000000000004)

数字精度丢失(0.1 + 0.2 = 0.30000000000000004)

IEEE754

JavaScript中的Number类型是基于 IEEE 754 标准的双精度 64 位二进制格式的值。

  • 符号位:1位,标识数值正负,0为正,1为负
  • 指数部分:11位,表示范围为0~2047,减去偏移常数bias为1023,即实际范围为-1023~1024
    • 展开指数部分详细讲解

      指数偏移常量:计算指数时要减去的常量。
      指数位数若为e,指数偏移常量bias则为 2e-1,双精度64位浮点数中,指数位数为11位,故偏移常量为1023,指数最终取值为 0 - 1023 ~ 2047 - 1023,即-1023~1024

      特殊指数:指数全0或全1有特殊含义,不算正常指数。

      • 指数全0,尾数全0,表示0。根据符号位不同可以分为+0-0
      • 指数全0,尾数不为全0,这些数是非规范数,即尾数部分假设前面为0,而不是1。此时指数取最后一位为1时的值,64位双精度浮点数格式中为-1022
      • 指数全1,尾数全0,表示无穷大,即Infinity。根据符号位不同可以分为+Infinity-Infinity
      • 指数全1,尾数不为全0,表示NaN,即Not a Number,不是数。
  • 尾数部分:52位,二进制只有0和1,一个数值最终都可用 1.xxx * 2 e 表示,故尾数部分表示小数点后的部分,小数点前默认有1。

0.1 + 0.2 = 0.30000000000000004

JS使用双精度 64 位二进制格式存储数值,所以先要将0.1和0.2转换为二进制。

0.1和0.2转换为二进制后是无限循环的,但是存储位是有限的,所以超出的部分要作“零舍一入”,这是第一步精度丢失

相加时,由于两数指数级不相等,要进行“对位”操作,而且相加后产生了进位,这两个原因导致存储位不够用,超出部分又要“零舍一入”,这是第二次精度丢失,导致计算结果出现偏差。

具体计算分析过程参考 IEEE754 浮点数格式 与 Javascript number 的特性

Number上各个静态常量的理解

指数不取全0或全1的原因详见文章上方指数部分详细讲解

Number.MAX_VALUENumber.MIN_VALUE

Number.MAX_VALUE:可表示的最大正值: 当符号位为0、指数位除最后一位全为1、尾数位全为1时,为可表示的最大正值。
Number.MIN_VALUE:可表示的最小正值: 当符号位为0、指数位全为0、尾数最后一位为1时,为可表示的最小正值。

// Number.MAX_VALUE
expect((2 - Math.pow(2, -52)) * Math.pow(2, 1023)).toBe(Number.MAX_VALUE)
// Number.MIN_VALUE
expect(Math.pow(2, -52) * Math.pow(2, -1022)).toBe(Number.MIN_VALUE)

Number.MAX_SAFE_INTEGERNumber.MIN_SAFE_INTEGER

Number.MAX_SAFE_INTEGER:可表示的最大准确整数: 当符号位为0、指数实际值为52、尾数位全为1,即尾数每一位都表示整数时,为可表示的最大准确整数。
Number.MIN_SAFE_INTEGER:可表示的最小准确整数: 当符号位为1、指数实际值为52、尾数位全为1,即尾数每一位都表示整数时,为可表示的最小准确整数。

// Number.MAX_SAFE_INTEGER
expect((2 - Math.pow(2, -52)) * Math.pow(2, 52)).toBe(Number.MAX_SAFE_INTEGER)
// Number.MIN_SAFE_INTEGER
expect((-1) * (2 - Math.pow(2, -52)) * Math.pow(2, 52)).toBe(Number.MIN_SAFE_INTEGER)

Number.EPSILON

大于1的最小可表示数与1的差:符号位为0,指数实际值为0,尾数最后一位为1时的数减去1,为Number.EPSILON。即 2-52

// Number.EPSILON
expect((1 + Math.pow(2, -52)) - 1).toBe(Number.EPSILON)

变量和类型 - Symbol的应用及实现

Symbol的应用及实现

Symbol

Symbol([description])函数会返回symbol类型的值。

  • description:可选的字符串。symbol的描述,可用于调试但不能访问symbol本身。
  • 每个从Symbol()返回的symbol值都是唯一的;
  • symbol是一种基本数据类型;
  • symbol类型唯一目的:作为对象属性的标识符。
const symbol1 = Symbol()
const symbol2 = Symbol('foo')

全局共享的Symbol

Symbol.for(key):全局symbol注册表中有与key对应的symbol则返回,否则在全局symbol注册表新建与key关联的symbol并返回。

Symbol.keyFor(symbol):获取全局symbol注册表中与某个 symbol 关联的键,没有则返回undefined

const globalSym = Symbol.for('foo')
expect(Symbol.keyFor(globalSym)).toBe('foo')

const localeSym = Symbol('bar')
expect(Symbol.keyFor(localeSym)).toBeUndefined()

Symbol特性

symbol的创建

  • 不能通过new关键字调用Symbol函数,因为禁止创建显式的 Symbol 包装器对象
expect(() => new Symbol('foo')).toThrowError(new TypeError('Symbol is not a constructor'))

expect(() => Symbol('foo')).not.toThrow()

symbol类型的识别

  • 使用typeof运算符来识别symbol类型
  • symbol是原始类型,无法使用instanceof进行识别
  • 如果想得到一个Symbol包装器对象,可以使用Object()函数。
const sym = Symbol('foo')

expect(typeof sym).toBe('symbol')
expect(sym instanceof Symbol).toBe(false)

const symObj = Object(sym)
expect(symObj instanceof Symbol).toBe(true)

symbol的类型转换

symbol类型值可显式转string类型或者boolean类型, 不能转number类型。

const sym = Symbol('foo')
expect(String(sym)).toBe('Symbol(foo)')
expect(Boolean(sym)).toBe(true)
expect(() => Number(sym))
  .toThrowError(new TypeError('Cannot convert a Symbol value to a number'))

对象symbol属性的获取

  • 对象的symbol属性在for...in迭代中不可枚举,也无法通过Object.keys/Object.getOwnPropertyNames获得。
  • 可以使用Object.getOwnPropertySymbols()对象自身的所有 Symbol 属性的数组。
  • Reflect.ownKeys()可以获取对象自身的所有可枚举、不可枚举及Symbol属性的数组。
const obj = {
  [Symbol('foo')]: 'foo',
  bar: 'bar'
}
const isSymbol = s => typeof s === 'symbol'
const hasSymbol = arr => arr.some(isSymbol)

let canGetSymbolByForIn = false
for (k in obj) {
  if (isSymbol(k)) {
    canGetSymbolByForIn = true
    break
  }
}
expect(canGetSymbolByForIn).toBe(false)

expect(hasSymbol(Object.keys(obj))).toBe(false)
expect(hasSymbol(Object.getOwnPropertyNames(obj))).toBe(false)
expect(Object.getOwnPropertySymbols(obj).map(String)).toEqual(['Symbol(foo)'])
expect(Reflect.ownKeys(obj).map(String)).toEqual(['bar', 'Symbol(foo)'])

Symbol的应用

使用Symbol作为对象属性名

对象次要的元信息属性或者不想被迭代的属性,可以使用Symbol来作为属性名,相较Object.defineProperty去指定enumerable: false比较简洁。

const META_PROP = Symbol('meta')
const obj = {
  [META_PROP]: '次要信息',
  name: 'logan',
  age: 18,
}

expect(Object.keys(obj)).toEqual(['name', 'age'])

使用Symbol代替常量

好处是不用考虑常量值重复,常量较多时比较有用。

// before
const TYPE_AUDIO = 'AUDIO'
const TYPE_VIDEO = 'VIDEO'

// after
const TYPE_AUDIO = Symbol()
const TYPE_VIDEO = Symbol()

使用Symbol模拟私有属性/方法

注意: 仅用作模拟,不要尝试使用 Symbol 存储对象中需要真正私有化的值,如密码等属性,对象上所有的 Symbols 都可以直接通过 Object.getOwnPropertySymbols() 获得!

// lady.js
const AGE = Symbol('age')
const GET_AGE = Symbol('getAge')
export class Lady {
  constructor(username, age) {
    this.username = username
    this[AGE] = age
  }

  [GET_AGE]() {
    return this[AGE]
  }
}

// foo.js
import { Lady } from './lady'
const lady = new Lady('lucy', 18)

expect(lady[Symbol('age')]).toBeUndefined()
expect(() => lady[Symbol('getAge')]()).toThrowError('is not a function')

const ladyAgeKey = Object.getOwnPropertySymbols(lady)[0]
const ladyAge = lady[ladyAgeKey]
expect(ladyAge).toBe(18)

内置Symbols

内置的Symbols被用作数组、字符串等原生对象以及 JavaScript 引擎内部的方法名,这样就避免了被意外重写的可能。

介绍几个常用的内置Symbol,其余的可前往MDN-Symbol了解

Symbol.iterator

用于定义对象的迭代器,,可被for...of循环及数组展开操作符使用。

const myIterable = {
  *[Symbol.iterator]() {
    yield 1
    yield 2
    yield 3

  }
}
expect([...myIterable]).toEqual([1, 2, 3])

Symbol.hasInstance

构造函数用来识别一个对象是否为它的实例。被 instanceof 使用。

class MyArray {  
  static [Symbol.hasInstance](instance) {
    return Array.isArray(instance)
  }
}

expect([] instanceof MyArray).toBe(true)

Symbol.toPrimitive

用于定义将对象转换为原始值时的行为。

  • 执行 +obj ,会调用 obj[Symbol.toPrimitive]('number')
  • 执行 `${obj}` ,会调用 obj[Symbol.toPrimive]('string')
  • 执行 字符串连接,如'' + obj,会调用 obj[Symbol.toPrimitive]('default')
const obj = {
  [Symbol.toPrimitive](hint) {
    console.log(hint)
    return hint === 'number'
      ? 10
      : `hint is ${hint}`
  }
}

expect(+obj).toBe(10)
expect(`${obj}`).toBe('hint is string')
expect(obj + '').toBe('hint is default')

Symbol.toStringTag

用于对象的默认描述的字符串值。被Object.prototype.toString()使用。

class Person {
  get [Symbol.toStringTag]() {
    return 'Person'
  }
}

expect(Object.prototype.toString.call(new Person)).toBe('[object Person]')

实现Symbol

typeof Symbol() === 'symbol'、对象symbol属性不可迭代等特性无法模拟。

我们围绕最重要的特性,也是symbol类型的唯一目的--作为对象属性的标识符来进行模拟。

// 自定义symbol对象的原型
const symbolProto = {}

// 设置对象属性时会调用toString,返回__name__属性
Object.defineProperties(symbolProto, {
  toString: generatePrivateDescriptor(function() { return this.__name__ }),
})

export default function SymbolPolyfill(description) {
  // 实现禁止使用new操作符生成Symbol
  if (this instanceof SymbolPolyfill) throw new TypeError('Symbol is not a constructor')
  // symbol描述为undefined时为空,其他情况均强制转换为字符串
  description = description === undefined ? '' : String(description)
  symbol = Object.create(symbolProto)
  return Object.defineProperties(symbol, {
    __name__: generatePrivateDescriptor(generateName(description)),
  })
}

// 生成唯一的字符串
const nameRecorder = {}
function generateName(desc) {
  let postfix = 0
  while (nameRecorder[desc + postfix]) postfix++
  nameRecorder[desc + postfix] = true
  return '@@' + desc + postfix
}

// 生成Object.defineProperty的描述对象
function generatePrivateDescriptor(value) {
  return {
    value,
    configruable: false,
    enumerable: false,
    writable: false
  }
}
// 测试
import SymbolPolyfill from './SymbolPolyfill'

const sym1 = SymbolPolyfill('foo')
const sym2 = SymbolPolyfill('foo')

const obj = {}
obj[sym1] = 1
obj[sym2] = 2

expect(sym1 in obj).toBe(true)
expect(sym2 in obj).toBe(true)
expect(obj[sym1]).not.toBe(true)

语法和API - 彻底搞懂数组reduce方法

彻底搞懂数组reduce方法

语法

arr.reduce(callback(accumulator, currentValue[, index[, array]])[, initialValue])

参数

  • callback: 执行数组中每个值 (如果没有提供 initialValue则第一个值除外)的函数,包含四个参数:
    • accumulator: 累计器累计回调的返回值; 它是上一次调用回调时返回的累积值,或initialValue(见于下方)。
    • currentValue: 数组中正在处理的元素。
    • index(可选): 数组中正在处理的当前元素的索引。 如果提供了initialValue,则起始索引号为0,否则从索引1起始。
    • array(可选): 调用reduce()的数组
  • initialValue(可选): 作为第一次调用 callback函数时的第一个参数的值。 如果没有提供初始值,则将使用数组中的第一个元素。 在没有初始值的空数组上调用 reduce 将报错。

基础使用

单看概念有点绕,reduce到底有什么用呢?只要记住reduce方法的核心作用就是聚合即可。

何谓聚合操作一个已知数组来获取另一个值就叫做聚合,这种情况下用reduce准没错,下面来看几个实际应用:

聚合为数字:数组元素求和、求积、求平均数等

// 求总分
const sum = arr => arr.reduce((total, { score }) => total + score, 0)
// 求平均分
const average = arr => arr.reduce((total, { score }, i, array) => {
  // 第n项之前均求和、第n项求和后除以数组长度得出平均分
  const isLastElement = i === array.length - 1
  return isLastElement
    ? (total + score) / array.length 
    : total + score
}, 0)
const arr = [
  { name: 'Logan', score: 89 },
  { name: 'Emma', score: 93 },
]
expect(sum(arr)).toBe(182)
expect(average(arr)).toBe(91)

用伪代码解析求总分执行顺序如下:

const arr = [
  { name: 'Logan', score: 89 },
  { name: 'Emma', score: 93 },
]
const initialValue = 0
let total = initialValue
for (let i = 0; i < arr.length; i++) {
  const { score } = arr[i]
  total += score
}
expect(total).toBe(182)

通过上方例子大家应该基本了解了reduce的执行机制,下面就来看下其他实际应用场景。

聚合为字符串

const getIntro = arr => arr.reduce((str, {
  name,
  score,
}) => `${str}${name}'s score is ${score};`, '')

const arr = [
  { name: 'Logan', score: 89 },
  { name: 'Emma', score: 93 },
]
expect(getIntro(arr))
  .toBe('Logan\'s score is 89;Emma\'s score is 93;')

聚合为对象

下方代码生成一个key为分数,value为对应分数的姓名数组的对象。

const scoreToNameList = arr => arr.reduce((map, { name, score }) => {
  (map[score] || (map[score] = [])).push(name)
  return map
}, {})

const arr = [
  { name: 'Logan', score: 89 },
  { name: 'Emma', score: 93 },
  { name: 'Jason', score: 89 },
]
expect(scoreToNameList(arr)).toEqual({
  89: ['Logan', 'Jason'],
  93: ['Emma'],
})

深入理解

何为未传入初始值

大部分实现reduce的文章中,通过检测第二个参数是否为undefined来判定是否传入初始值,这是错误的。

未传入就是严格的未传入,只要传入了值,哪怕是undefined或者null,也会将其作为初始值。

const arr = [1]

// 未传入初始值,将数组第一项作为初始值
expect(arr.reduce(initialVal => initialVal)).toBe(1)
// 传入 undefined 作为初始值,将 undefined 作为初始值
expect(arr.reduce(initialVal => initialVal, undefined)).toBeUndefined()
// 传入 null 作为初始值,将 null 作为初始值
expect(arr.reduce(initialVal => initialVal, null)).toBeNull()

所以自己实现reduce时可以通过arguments.length判断是否传入了第二个参数,来正确判定是否传入了初始值。

未传入初始值时的初始值

大部分实现reduce的文章中,reduce方法未传入初始值时,直接使用数组的第一项作为初始值,这也是错误的。

未传入初始值时应使用数组的第一个不为空的项作为初始值

不为空是什么意思呢,就是并未显式赋值过的数组项,该项不包含任何实际的元素,不是undefined,也不是null,在控制台中打印表现为empty,我目前想到的有三种情况:

  • new Array(n),数组内均为空项;
  • 字面量数组中逗号间不赋值产生空项;
  • 显式赋值数组length属性至数组长度增加,增加项均为空项。

测试代码如下:

const arr1 = new Array(3)
console.log(arr1) // [empty × 3]
arr1[2] = 0
console.log(arr1) // [empty × 2, 0]
// 第一、第二项为空项,取第三项作为初始值
expect(arr1.reduce(initialVal => initialVal)).toBe(0)

const arr2 = [ , , true]
console.log(arr2) // [empty × 2, true]
// 第一、第二项为空项,取第三项作为初始值
expect(arr2.reduce(initialVal => initialVal)).toBe(true)

const arr3 = []
arr3.length = 3 // 修改length属性,产生空
console.log(arr3) // [empty × 3]
arr3[2] = 'string'
console.log(arr3) // [empty × 2, "string"]
// 第一、第二项为空项,取第三项作为初始值
expect(arr3.reduce(initialVal => initialVal)).toBe('string')

空项跳过迭代

大部分实现reduce的文章中,都是直接用for循环一把梭,将数组每项代入callback中执行,这还是错的。

错误一

未传入初始值的情况下,获取初始值时会跳过数组第一个非空项前的空项,这些被跳过的空项及第一个非空项均不参与迭代

const arr = new Array(4)
arr[2] = 'Logan'
arr[3] = 'Emma'

let count = 0 // 记录传入reduce的回调的执行次数
const initialVal = arr.reduce((initialValue, cur) => {
  count++
  return initialValue
})
// 未传入初始值,跳过数组空项,取数组第一个非空项作为初始值
expect(initialVal).toBe('Logan')
// 被跳过的空项及第一个非空项均不参与迭代,只有第四项'Emma'进行迭代,故count为1
expect(count).toBe(1)

错误二

迭代过程中,空项也会跳过迭代

const arr = [1, 2, 3]
arr.length = 10
console.log(arr) // [1, 2, 3, empty × 7]

let count = 0 // 记录传入reduce的回调的执行次数
arr.reduce((acc, cur) => {
  count++
  return acc + cur
}, 0)

// arr中第三项之后项均为空项,跳过迭代,故count为3
expect(count).toBe(3)

自己实现reduce时,可以通过i in array判断数组第i项是否为空项。

实现reduce

说完了一些坑点,下面就来实现一个reduce

Array.prototype._reduce = function(callback) {
  // 省略参数校验,如this是否是数组等
  const len = this.length
  let i = 0
  let accumulator

  // 传入初始值则使用
  if (arguments.length >= 2) {
    accumulator = arguments[1]
  } else {
    // 未传入初始值则从数组中获取
    // 寻找数组中第一个非空项
    while (i < len && !(i in this)) {
      i++
    }
    
    // 未传入初始值,且数组无非空项,报错
    if (i >= len) {
      throw new TypeError( 'Reduce of empty array with no initial value' )
    }
    // 此处 i++ ,先返回i,即将数组第一个非空项作为初始值
    // 再+1,即数组第一个非空项跳过迭代
    accumulator = this[i++]
  }

  while (i < len) {
    // 数组中空项不参与迭代
    if (i in this) {
      accumulator = callback(accumulator, this[i], i, this)
    }

    i++
  }
  return accumulator
}

reduce拓展用法

扁平化数组

Array.prototype._flat = function(depth = 1) {
  const flatBase = (arr, curDepth = 1) => {
    return arr.reduce((acc, cur) => {
      // 当前项为数组,且当前扁平化深度小于指定扁平化深度时,递归扁平化
      if (Array.isArray(cur) && curDepth < depth) {
        return acc.concat(flatBase(cur, ++curDepth))
      }
      return acc.concat(cur)
    }, [])
  }
  return flatBase(this)
}

合并多次数组迭代操作

相信大家平时会遇到多次迭代操作一个数组,比如将一个数组内的值求平方,然后筛选出大于10的值,可以这样写:

function test(arr) {
  return arr
    .map(x => x ** 2)
    .filter(x => x > 10)
}

只要是多个数组迭代操作的情况,都可以使用reduce代替:

function test(arr) {
  return arr.reduce((arr, cur) => {
    const square = cur ** 2
    return square > 10 ? [...arr, square] : arr
  }, [])
}

串行执行Promises

function runPromisesSerially(tasks) {
  return tasks.reduce((p, cur) => p.then(cur), Promise.resolve())
}

执行函数组合

function compose(...fns) {
  // 初始值args => args,兼容未传入参数的情况
  return fns.reduce((a, b) => (...args) => a(b(...args)), args => args)
}

万物皆可reduce

reduce方法是真的越用越香,其他类型的值也可转为数组后使用reduce

  • 字符串[...str].reduce()
  • 数字Array.from({ length: num }).reduce()
  • 对象
    • Object.keys(obj).reduce()
    • Object.values(obj).reduce()
    • Object.entries(obj).reduce()

reduce的更多使用姿势期待大家一起发掘。

参考文章

MDN - Array.prototype.reduce()

ECMAScript - Array.prototype.reduce()

扩展一下使用reduce的思路

作用域与闭包 - 了解模块化及其典型方案

了解模块化及其典型方案

模块化解决的问题

  • 命名空间污染:如果变量都挂载在全局对象上,容易命名冲突,变量覆盖,也就是我们常说的“全局变量污染”;
  • 维护性差:模块化将各部分功能分割开来,高内聚低耦合,提升代码的可维护性,降低维护成本;
  • 复用性差:没有模块化时,复用全靠复制粘贴,模块化后,需要时只需引入相应依赖即可,一处定义、多处使用。

各模块化方案

ES Module

ES Module是ES6提出的模块化标准,属于编译时加载(静态加载),只能存在于顶层作用域。

动态import已通过提案,列入最新 ECMA标准 中。

/* ----- Export Syntax ---------- */
// default exports
export default 42
export default {}
export default []
export default (1 + 2)
export default foo
export default function () {}
export default class {}
export default function foo () {}
export default class foo {}

// variables exports
export var foo = 1
export var foo = function () {}
export var bar
export let foo = 2
export let bar
export const foo = 3
export function foo () {}
export class foo {}

// named exports
export {}
export {foo}
export {foo, bar}
export {foo as bar}
export {foo as default}
export {foo as default, bar}

// exports from
export * from 'foo'
export {} from 'foo'
export {foo} from 'foo'
export {foo, bar} from 'foo'
export {foo as bar} from 'foo'
export {foo as default} from 'foo'
export {foo as default, bar} from 'foo'
export {default} from 'foo'
export {default as foo} from 'foo'

/* ----- Import Syntax ---------- */

// default imports
import foo from 'foo'
import {default as foo} from 'foo'

// named imports
import {} from 'foo'
import {bar} from 'foo'
import {bar, baz} from 'foo'
import {bar as baz} from 'foo'
import {bar as baz, xyz} from 'foo'

// glob imports
import * as foo from 'foo'

// mixing imports
import foo, {baz as xyz} from 'foo'
import foo, * as bar from 'foo'

// just import
import 'foo'

CommonJS

常用于Node.js中,属于运行时加载(动态加载)。

/* ----- Export Syntax ---------- */
// default exports
module.exports = function() {}
module.exports = {}

// named exports
exports.foo = function() {}
exports.bar = 1
exports.baz = class Baz {}

/* ----- Import Syntax ---------- */

// default imports
const foo = require('./foo')

// named imports
const { bar, baz } = require('./foo')
const { bar, baz: xyz } = require('./foo')

AMD & CMD

二者核心原理一致:解析模块依赖,通过插入script标签的方式去加载依赖文件。

区别在于AMD推崇依赖前置,CMD推崇依赖就近,可结合下方示例代码理解,具体分析见 前端模块化之AMD与CMD原理

// AMD
define(['./a','./b'], function (moduleA, moduleB) {
  // 依赖前置
  moduleA.mehodA();
  console.log(moduleB.dataB);
  // 导出数据
  return {};
});
 
// CMD
define(function (requie, exports, module) {
  // 依赖就近
  var moduleA = require('./a');
  moduleA.mehodA();     

  // 按需载入
  if (needModuleB) {
    var moduleB = requie('./b');
    moduleB.methodB();
  }
  // 导出数据
  exports = {};
});

IIFE + 闭包

刀耕火种的年代采用的方式,主要运用了闭包的特性,稍作了解即可。

var moduleA = (function ($, doc) {
  var methodA = function() {};
  var dataA = {};
  return {
    methodA: methodA,
    dataA: dataA
  };
})(jQuery, document);

变量和类型 - 对象的底层数据结构

对象的底层数据结构

JavaScript中的对象是基于哈希表结构的。

哈希表(Hash table)、哈希函数(Hash Function)与哈希碰撞(Hash Collision)

哈希表 又称散列表,根据键直接访问在内存存储位置的数据结构。通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。

哈希函数 又称散列函数散列算法,上述映射函数即为哈希函数,是一个表示键和内存存储值位置的映射关系的函数。常见的构造哈希函数的方法有直接定址法除留余数法随机数法等。

哈希碰撞 又称哈希冲突,指不同键经过哈希函数计算后得到相同哈希地址的情况。具有相同函数值的关键字对该哈希函数来说称做同义词

处理哈希碰撞

开放定址法

当关键字key的哈希地址p=H(key)出现冲突时,以p为基础,产生另一个哈希地址p1...,直到找出不冲突的哈希地址pi,将相应元素存入其中。

优点:储空间更加紧凑,利用率高。
缺点:冲突元素间产生关联,无法直接删除,会破坏寻址链,只能在删除节点上作删除标记。

开放定址法处理哈希碰撞

拉链法

又称链地址法,将散列到同一个存储位置的所有元素保存在一个链表中。

优点:处理冲突简单,非同义词决不会发生冲突,因此平均查找长度较短。
缺点:指针需要额外的空间,降低构建哈希表时的效率。

拉链法处理哈希碰撞

查找效率

  • 二分查找: 复杂度为O(log2n),但只能用于有序列表。
  • 遍历查找: 复杂度为O(n)
  • 哈希表: 理想的哈希函数实现的哈希表,对其任意元素的查找速度始终为常数级,即O(1)

如果遭到恶意哈希碰撞攻击,拉链法会导致哈希表退化为链表,即所有元素都被存储在同一个节点的链表中,此时哈希表的查找速度=链表遍历查找速度=O(n)。

原型与原型链 - 实现继承的方式及优缺点

实现继承的方式及优缺点

面向对象编程

面向对象编程(Object Oriented Programming),简称OOP,是一种程序设计**。OOP把对象作为程序的基本单元,一个对象包含了数据和操作数据的函数。

面向对象编程的三大基本特性:封装,继承,多态

封装

将一段逻辑/概念抽象出来做到“相对独立”。封装的概念并不是OOP独有的,而是长久以来一直被广泛采用的方法。主要目的总结为两点:

  • 封装数据和实现细节。达到保护私有内容、使用者无需关心内部实现、且内部变化对使用者透明的目的。
  • 封装变化。将不变和可变部分隔离,提升程序稳定性、复用性和可扩展性。

JavaScript中典型的封装就是模块化,实现方法有闭包ES ModuleAMDCMDCommonJS等。

多态

多态的概念也不是OOP独有的。所谓多态就是同一操作作用于不同的对象上面,可以产生不同的解释和不同的执行结果。

多态的目的就是用对象的多态性消除条件分支语句,提升程序的可拓展性。

JavaScript是一门弱类型多态语言,具有与生俱来的多态性。

继承

两大概念:

  • 类(Class):抽象的模板;
  • 实例(Instance):根据类创建的具体对象。

JavaScript中没有类的概念,使用构造函数作为对象模板,通过原型链来实现继承(ES6 中的Class只是语法糖)。

原型及原型链相关知识详见深入JavaScript系列(六):原型与原型链

类式继承

将父类实例赋值给子类原型。缺点如下:

  • 父类实例过早创建,无法接受子类的动态参数;
  • 子类所有实例原型为同一父类实例,修改父类实例属性会影响所有子类实例。
function SupClass() {...}
function SubClass() {...}
SubClass.prototype = new SupClass()

构造函数式继承

子类构造函数中执行父类构造函数。缺点如下:

  • 无法继承父类原型上的属性和方法。
function SupClass() {...}
function SubClass() {
  SupClass.call(this, arguments)
}

组合式继承

类式继承+构造函数继承。缺点如下:

  • 父类构造函数需调用两次。
function SupClass() {...}
function SubClass() {
  SupClass.call(this, arguments)
}
SubClass.prototype = new SupClass()

原型式继承

对类式继承的封装,功能类似Object.create,缺点如下:

  • 若每次传入同一个原型,还是存在修改后影响其他子类实例的问题。
function createObj(o) {
  function F() {}
  F.prototype = o
  return new F()
}

寄生式继承

拓展原型式继承创建的对象并返回。

function createObj(o) {
  const obj = Object.create(o)
  obj.name = 'Logan'
  return obj
}

寄生组合式继承

寄生式继承+构造函数式继承。

function inherit(child, parent) {
  const p = Object.create(parent.prototype)
  child.prototype = p
  p.constructor = child
  return child
}

function SupClass() {...}
function SubClass() {
  SupClass.call(this)
}

SubClass = inherit(SubClass, SupClass)

作用域与闭包 - 如何处理循环的异步操作

如何处理循环的异步操作

示例代码中tasks为异步操作队列,代码忽略任务执行结果的记录和错误处理

并行异步操作

Promise.all

Promise.all(tasks.map(task => task()))
  .then(() => doSomethingAfter())

// 也可结合 async/await使用
;(async () => {
  await Promise.all(tasks.map(task => task()))
  doSomethingAfter()
})()

检测完成个数

let finishedNum = 0
let taskNum = tasks.length
tasks.forEach((task, i) => {
  task(() => {
    // 完成任务个数记录
    finishedNum++

    // 检测是否全部完成
    if (finishedNum === taskNum) {
      doSomethingAfter()
    }
  })
})

串行异步操作

promise.then

使用promise.then循环拼接任务,最后拼接完成之后的操作

let taskPromise = Promise.resolve()
tasks.forEach((task, i) => {
  taskPromise = taskPromise.then(() => task())
})

// 完成所有任务后
taskPromise.then(() => doSomethingAfter())

一个比较抖机灵的写法,通过Array.prototype.reduce实现,原理和上例相同:

const taskPromise = tasks
  .reduce((promise, task) => {
    promise = promise.then(() => task)
    return promise
  }, Promise.resolve())
  .then(() => doSomethingAfter())

递归调用

const runTasksSerially = tasks => {
  if (tasks.length <= 0) {
    return doSomethingAfter()
  }
  const taskToRun = tasks.shift() // 取出首个任务
  // 执行完成后,回调内将剩余任务传入runTasksSerially执行,实现串行
  taskToRun(() => runTasksSerially(tasks))
}

runTasksSerially(tasks)

控制并发数

即将任务按并发数分组,组内并行执行,组间串行执行。

// Promise.all并行执行
const runTasksConcurrently = tasks => Promise.all(tasks.map(task => task()))

// Promise.then串行执行
const runTasksSerially = tasks => tasks.reduce((p, task) => p.then(() => task()), Promise.resolve())

const runTasks = (tasks, concurrency) => {
  // 分割任务组,组个数为 Math.ceil(tasks.length / concurrency),组内任务个数为concurrency
  const dividedTasks = Array.from({ length: Math.ceil(tasks.length / concurrency) }, (_, i) => {
    return tasks.slice(i * concurrency, i * concurrency + concurrency)
  })

  // 生成组内并行执行,组间串行执行的任务组
  const serialTasks = dividedTasks.map(concurrentTasks => {
    return () => runTasksConcurrently(concurrentTasks)
  })

  // 串行执行生成的任务组(注:单个组内的各个任务并行执行)
  return runTasksSerially(serialTasks)
}

runTasks(tasks, 3).then(() => doSomethingAfter())

变量和类型 - 基本类型的装箱与拆箱

基本类型的装箱与拆箱

包装类型

为了便于操作基本类型值,JavaScript定义了 BooleanNumberStringSymbolBigInt 几种包装类型(属于引用类型),每种包装类型都有一种对应的基本类型。

操作基本类型时,JS引擎会自动创建基本类型对应的包装类型。

展开查看模拟代码
const name = 'Logan Lee'
const firstName = name.substr(6)

// 执行时相当于
const name = 'Logan Lee'
const nameObj = new String(name)
const firstName = nameObj.substr(6)
nameObj = null

当然我们也可以自己通过 new 操作符来创建包装类型。

SymbolBigInt 不能作构造函数用,可以配合Object构造函数来创建对应的包装类型。

expect(new Boolean(true)).toBeInstanceOf(Boolean)
expect(new Number(1)).toBeInstanceOf(Number)
expect(new String('Logan')).toBeInstanceOf(String)
expect(Object(Symbol('foo'))).toBeInstanceOf(Symbol)
expect(Object(BigInt(30))).toBeInstanceOf(BigInt)

装箱操作

定义

通过val.propval[expression]的格式进行属性访问时,如果val为基本类型变量,则会将其转换为对应的内置对象类型。

上述过程叫做基本数据类型的装箱操作,各类型变量装箱结果见下表。装箱标准定义见 ECMAScript#ToObject

变量类型 装箱结果
Undefined 抛出 TypeError 异常
Null 抛出 TypeError 异常
Boolean 返回对应的 Boolean对象
Number 返回对应的 Number对象
String 返回对应的 String对象
Symbol 返回对应的 Symbol对象
BigInt 返回对应的 BigInt对象
Object 返回对象本身

示例

expect(() => (undefined).x).toThrowError()
expect(() => (null).x).toThrowError()

expect(() => (true).toString()).toBe('true')
expect(() => (1).toFixed(1)).toBe('1.0')
expect(() => ('abc').substr(1)).toBe('bc')
expect(() => (Symbol('foo')).description).toBe('foo')

拆箱操作

在对引用类型(包括)变量进行 数学运算字符串拼接模板字符串内计算 等操作时,JS引擎会尝试将其转换为基本类型,这个过程叫做拆箱。

ES6之前,从引用类型到基本类型的转换会调用引用类型的toStringvalueOf两个方法,调用顺序根据场景不同而不同,不作赘述。

ES6之后,统一使用 [Symbol.toPrimitive] 来定义将对象转换为原始值时的行为,函数接收一个字符串参数 hint ,表示要转换到的原始值的预期类型。

const obj1 = {}
const obj2 = {
  [Symbol.toPrimitive](hint) {
    if (hint === 'number') return 10
    if (hint === 'string') return 'Logan'
    return 'default'
  }
}

expect(+obj1).toBeNaN()
expect(+obj2).toBe(10)

expect(`${obj1}`).toBe('[object Object]')
expect(`${obj2}`).toBe('Logan')

expect('' + obj1).toBe('[object Object]')
expect('' + obj2).toBe('default')

原型与原型链 - ES6 Class的底层实现原理

ES6 Class的底层实现原理

ES6 中的类Class,仅仅只是基于现有的原型继承的一种语法糖,我们一起来看一下Class的底层实现原理。

Class的底层实现要素

  1. 只能使用new操作符调用Class
  2. Class可定义实例属性方法和静态属性方法;
  3. Class的实例可继承父Class上的实例属性方法、子Class可继承父Class上的静态属性方法。

只能使用new操作符调用Class

实现思路:�使用instanceof操作符检测实例是否为指定类的实例。

function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError('Cannot call a class as a function')
  }
}

定义实例属性方法和静态属性方法

实现思路

  • 在构造函数的原型上定义属性方法,即为实例属性方法;
  • 在构造函数本身定义属性方法,即为静态属性方法。
function _createClass(Constructor, protoProps = [], staticProps = []) {
  // 在构造函数的原型上定义实例属性方法
  _defineProperties(Constructor.prototype, protoProps)
  // 在构造函数本身定义静态属性方法
  _defineProperties(Constructor, staticProps)
}

// 实现公用的批量给对象添加属性方法的方法
function _defineProperties(target, props) {
  props.forEach(prop => {
    Object.defineProperty(target, prop.key, prop)
  })
}

继承实例属性方法和静态属性方法

实现思路:借用原型链继承实现。

function _inherits(subClass, superClass) {
  // 子类实例继承父类的实例属性方法
  subClass.prototype = Object.create(superClass.prototype)
  // 修正constructor属性
  subClass.prototype.constructor = subClass

  // 子类继承父类的静态属性方法
  Object.setPrototypeOf(subClass, superClass)
}

模拟编译

了解了Class的底层实现要素,我们就来将Class模拟编译为使用原型继承实现的代码。

源代码

class Person {
  constructor(options) {
    this.name = options.name
    this.age = options.age
  }
  eat() {
    return 'eating'
  }
  static isPerson(instance) {
    return instance instanceof Person
  }
}

class Student extends Person {
  constructor(options) {
    super(options)
    this.grade = options.grade
  }
  study() {
    return 'studying'
  }
  static isStudent(instance) {
    return instance instanceof Student
  }
}

编译后代码

var Person = (function() {
  function Person(options) {
    // 确保使用new调用
    _classCallCheck(this, Person)
    this.name = options.name
    this.age = options.age
  }

  _createClass(
    Person,
    // 实例属性方法
    [{
      key: 'eat',
      value: function eat() {
        return 'eating'
      }
    }],
    // 静态属性方法
    [{
      key: 'isPerson',
      value: function isPerson(instance) {
        return instance instanceof Person
      }
    }]
  )
  return Person
})();
var Student = (function (_Person) {
  // 继承父类实例属性方法和静态属性方法
  _inherits(Student, _Person)

  function Student(options) {
    // 确保使用new调用
    _classCallCheck(this, Student)

    // 执行父类构造函数
    _Person.call(this, options)

    this.grade = options.grade
  }

  _createClass(Student,
    // 实例属性方法
    [{
      key: 'study',
      value: function study() {
        return 'studying'
      }
    }],
    // 静态属性方法
    [{
      key: 'isStudent',
      value: function isStudent(instance) {
        return instance instanceof Student
      }
    }]
  )

  return Student
})(Person);

测试代码

const person = new Person({ name: 'Logan', age: 18 })
const student = new Student({ name: 'Logan', age: 18, grade: 9 })

expect(person.eat()).toBe('eating')
expect(student.eat()).toBe('eating') // 继承实例方法
expect(student.study()).toBe('studying')

expect(Student.isStudent(student)).toBe(true)
expect(Person.isPerson(person)).toBe(true)
expect(Student.isStudent(person)).toBe(false)
expect(Student.isPerson(student)).toBe(true) // 继承静态方法

公有字段和私有字段(提案中)

公有(public)和私有(private)字段声明目前在JavaScript标准委员会TC39的 试验性功能 (第3阶段),下面进行模拟实现。

静态公有字段

使用

class ClassWithStaticField {
  static staticField1 = 'static field' // 设定初始值
  static staticField2 // 不设定初始值
}

polyfill

function ClassWithStaticField() {}
// @babel/plugin-proposal-class-properties 中使用Object.defineProperty实现,此处简便起见直接赋值
ClassWithStaticField.staticField1 = 'static field' // 设定初始值
ClassWithStaticField.staticField2 = undefined // 不设定初始值

公有实例字段

使用

class ClassWithInstanceField {
  instanceField1 = 'instance field' // 设定初始值
  instanceField2 // 不设定初始值
}

polyfill

function ClassWithInstanceField() {
  // @babel/plugin-proposal-class-properties 中使用Object.defineProperty实现,此处简便起见直接赋值
  this.instanceField1 = 'instance field' // 设定初始值
  this.instanceField2 = undefined // 不设定初始值
}

静态私有字段

静态私有字段只能在静态方法内访问,且只能通过类的属性进行访问,不能通过this进行访问。

使用

class ClassWithPrivateStaticField {
  static #PRIVATE_STATIC_FIELD

  static publicStaticMethod() {
    ClassWithPrivateStaticField.#PRIVATE_STATIC_FIELD = 42
    return ClassWithPrivateStaticField.#PRIVATE_STATIC_FIELD
  }
}

polyfill

通过闭包实现静态私有字段

var ClassWithPrivateStaticField = (function() {
  var _PRIVATE_STATIC_FIELD
  function ClassWithPrivateStaticField() {}

  ClassWithPrivateStaticField.publicStaticMethod = function() {
    _PRIVATE_STATIC_FIELD = 42
    return _PRIVATE_STATIC_FIELD
  }
  return ClassWithPrivateStaticField
})();

私有实例字段

使用

class ClassWithPrivateField {
  #privateField;
  
  constructor() {
    this.#privateField = 42;
    console.log(this.$privateField)
  }
}

polyfill

通过WeakMap结合实例本身为key实现

var ClassWithPrivateField = (function() {
  var _privateField = new WeakMap()
  function ClassWithPrivateField() {
    _privateField.set(this, undefined)
    _privateField.set(this, 42)
    console.log(_privateField.get(this))
  }
})();

执行机制 - Node与浏览器Event Loop的差异

Node与浏览器Event Loop的差异

Node.js 运行机制

Node.js运行机制

Node.js采用V8作为JS的解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,Node.js内的事件循环机制也由其实现。

Node.js运行机制简化如下:

  1. V8引擎将JavaScript代码解析为机器码。
  2. 解析后的代码,调用Node API。
  3. libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
  4. V8引擎再将结果返回给用户。

Node.js 事件循环

Node.js内的事件循环机制由libuv实现,分6个阶段反复进行。

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘
  • timers 阶段:这个阶段执行timer(setTimeout、setInterval)的回调
  • I/O callbacks 阶段:处理一些上一轮循环中的少数未执行的 I/O 回调
  • idle, prepare 阶段:仅node内部使用
  • poll 阶段:获取新的I/O事件, 适当的条件下node将阻塞在这里
  • check 阶段:执行 setImmediate() 的回调
  • close callbacks 阶段:执行 socket 的 close 事件回调

Node.js中也存在微任务队列,包括process.nextTickpromises等。

需要注意的两点:

  • process.nextTick优先级高于promises
  • 事件循环每个阶段之间都会检查微任务队列并执行。

Node与浏览器Event Loop的差异

  • Node.js内,microtask 在事件循环的各个阶段之间执行;浏览器端,microtask 在事件循环的 macrotask 执行完之后执行。
  • Node 10及之前版本,timers阶段若有多个定时器回调,则全部执行之后再去检查微任务队列;而Node11及之后版本,则是执行一个宏任务就去检查微任务队列,跟浏览器端表现趋于一致。
setTimeout(()=>{
  console.log('timer1')
  Promise.resolve().then(() => console.log('promise1'))
}, 0)
setTimeout(()=>{
  console.log('timer2')
  Promise.resolve().then(() => console.log('promise2'))
}, 0)

Node 10及之前版本打印顺序:timer1,timer2,promise1,promise2

Node 11及之后版本打印顺序:timer1,promise1,timer2,promise2

深入JavaScript系列(四):彻底搞懂this

一、函数的调用

全局环境的this指向全局对象,在浏览器中就是我们熟知的window对象

说到this的种种情况,就离不开函数的调用,一般我们调用函数,无外乎以下四种方式:

  1. 普通调用,例如foo()
  2. 作为对象方法调用,例如obj.foo()
  3. 构造函数调用,例如new foo()
  4. 使用callapplybind等方法。

除箭头函数外的其他函数被调用时,会在其词法环境上绑定this的值,我们可以通过一些方法来指定this的值。

  1. 使用callapplybind等方法来显式指定this的值。
    function foo() {
        console.log(this.a)
    }
    foo.call({a: 1}) // 输出: 1
    foo.apply({a: 2}) // 输出: 2
    // bind方法返回一个函数,需要手动进行调用
    foo.bind({a: 3})() // 输出: 3
  2. 当函数作为对象的方法调用时,this的值将被隐式指定为这个对象。
    let obj = {
        a: 4,
        foo: function() {
            console.log(this.a)
        }
    }
    obj.foo() // 输出: 4
  3. 当函数配合new操作符作为构造函数调用时,this的值将被隐式指定新构造出来的对象。

二、ECMAScript规范解读this

上面讲了几种比较容易记忆和理解this的情况,我们来根据ECMAScript规范来简单分析一下,这里只说重点,一些规范内具体的实现就不讲了,反而容易混淆。

其实当我们调用函数时,内部是调用函数的一个内置[[Call]](thisArgument, argumentsList)方法,此方法接收两个参数,第一个参数提供this的绑定值,第二个参数就是函数的参数列表。

ECMAScript规范: 严格模式时,函数内的this绑定严格指向传入的thisArgument。非严格模式时,若传入的thisArgument不为undefinednull时,函数内的this绑定指向传入的thisArgument;为undefinednull时,函数内的this绑定指向全局的this

所以第一点中讲的三种情况都是显式或隐式的传入了thisArgument来作为this的绑定值。我们来用伪代码模拟一下:

function foo() {
    console.log(this.a)
}

/* -------显式指定this------- */
foo.call({a: 1})
foo.apply({a: 1})
foo.bind({a: 1})()
// 内部均执行
foo[[Call]]({a: 1})

/* -------函数构造调用------- */
new foo()
// 内部执行
let obj = {}
obj.__proto__ = foo.prototype
foo[[Call]](obj)
// 最后将这个obj返回,关于构造函数的详细内容可翻阅我之前关于原型和原型链的文章

/* -------作为对象方法调用------- */
let obj = {
    a: 4,
    foo: function() {
        console.log(this.a)
    }
}
obj.foo()
// 内部执行
foo[[Call]]({
    a: 1,
    foo: Function foo
})

那么当函数普通调用时,thisArgument的值并没有传入,即为undefined,根据上面的ECMAScript规范,若非严格模式,函数内this指向全局this,在浏览器内就是window。

伪代码模拟:

window.a = 10
function foo() {
    console.log(this.a)
}
foo() // 输出: 10
foo.call(undefined) // 输出: 10
// 内部均执行
foo[[Call]](undefined) // 非严格模式,this指向全局对象

foo.call(null) // 输出: 10
// 内部执行
foo[[Call]](null) // 非严格模式,this指向全局对象

根据上面的ECMAScript规范,严格模式下,函数内的this绑定严格指向传入的thisArgument。所以有以下表现。

function foo() {
    'use strict'
    console.log(this)
}
foo() // 输出:undefined
foo.call(null) // 输出:null

需要注意的是,这里所说的严格模式是函数被创建时是否为严格模式,并非函数被调用时是否为严格模式:

window.a = 10
function foo() {
    console.log(this.a)
}
function bar() {
    'use strict'
    foo()
}
bar() // 输出:10

三、箭头函数中的this

ES6新增的箭头函数在被调用时不会绑定this,所以它需要去词法环境链上寻找this

function foo() {
    return () => {
        console.log(this)
    }
}
const arrowFn1 = foo()
arrowFn1() // 输出:window
           // 箭头函数没有this绑定,往外层词法环境寻找
           // 在foo的词法环境上找到this绑定,指向全局对象window
           // 在foo的词法环境上找到,并非是在全局找到的
const arrowFn2 = foo.call({a: 1})
arrowFn2() // 输出 {a: 1}

切记,箭头函数中不会绑定this,由于JS采用词法作用域,所以箭头函数中的this只取决于其定义时的环境。

window.a = 10
const foo = () => {
    console.log(this.a)
}
foo.call({a: 20}) // 输出: 10

let obj = {
    a: 20,
    foo: foo
}
obj.foo() // 输出: 10

function bar() {
    foo()
}
bar.call({a: 20}) // 输出: 10

四、回调函数中的this

当函数作为回调函数时会产生一些怪异的现象:

window.a = 10
let obj = {
    a: 20,
    foo: function() {
        console.log(this.a)
    }
}

setTimeout(obj.foo, 0) // 输出: 10

我觉得这么解释比较好理解:obj.foo作为回调函数,我们其实在传递函数的具体值,而并非函数名,也就是说回调函数会记录传入的函数的函数体,达到触发条件后进行执行,伪代码如下:

setTimeout(obj.foo, 0)
//等同于,先将传入回调函数记录下来
let callback = obj.foo
// 达到触发条件后执行回调
callback()
// 所以foo函数并非作为对象方法调用,而是作为函数普通调用

要想避免这种情况,有三种方法,第一种方法是使用bind返回的指定好this绑定的函数作为回调函数传入:

setTimeout(obj.foo.bind({a: 20}), 0) // 输出: 20

第二种方法是储存我们想要的this值,就是常见的,具体命名视个人习惯而定。

let _this = this
let self = this
let me = this

第三种方法就是使用箭头函数

window.a = 10
function foo() {
    return () => {
        console.log(this.a)
    }
}
const arrowFn = foo.call({a: 20})
arrowFn() // 输出:20
setTimeout(arrowFn, 0) // 输出:20

五、总结

  1. 箭头函数中没有this绑定,this的值取决于其创建时所在词法环境链中最近的this绑定
  2. 非严格模式下,函数普通调用,this指向全局对象
  3. 严格模式下,函数普通调用,thisundefined
  4. 函数作为对象方法调用,this指向该对象
  5. 函数作为构造函数配合new调用,this指向构造出的新对象
  6. 非严格模式下,函数通过callapplybind等间接调用,this指向传入的第一个参数

    这里注意两点:

    1. bind返回一个函数,需要手动调用,callapply会自动调用
    2. 传入的第一个参数若为undefinednullthis指向全局对象
  7. 严格模式下函数通过callapplybind等间接调用,this严格指向传入的第一个参数

有时候文字的表述是苍白无力的,真正理解之后会发现:this不过如此。

六、小练习

例子来自南波的JavaScript之例题中彻底理解this

// 例1
var name = 'window'

var person1 = {
  name: 'person1',
  show1: function () {
    console.log(this.name)
  },
  show2: () => console.log(this.name),
  show3: function () {
    return function () {
      console.log(this.name)
    }
  },
  show4: function () {
    return () => console.log(this.name)
  }
}
var person2 = { name: 'person2' }

person1.show1()  // ?
person1.show1.call(person2)  // ?

person1.show2()  // ?
person1.show2.call(person2)  // ?

person1.show3()()  // ?
person1.show3().call(person2)  // ?
person1.show3.call(person2)()  // ?

person1.show4()()  // ?
person1.show4().call(person2)  // ?
person1.show4.call(person2)()  // ?







person1 // 函数作为对象方法调用,this指向对象

person2 // 使用call间接调用函数,this指向传入的person2

window // 箭头函数无this绑定,在全局环境找到this,指向window

window // 间接调用改变this指向对箭头函数无效

window // person1.show3()返回普通函数,相当于普通函数调用,this指向window

person2 // 使用call间接调用函数,this指向传入的person2

window // person1.show3.call(person2)仍然返回普通函数

person1 // person1.show4调用对象方法,this指向person1,返回箭头函数,this在person1.show4调用时的词法环境中找到,指向person1

person1 // 间接调用改变this指向对箭头函数无效

person2 // 改变了person1.show4调用时this的指向,所以返回的箭头函数的内this解析改变

系列文章

深入ECMAScript系列目录地址(持续更新中...)

欢迎前往阅读系列文章,如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

菜鸟一枚,如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误,与大家共同进步。

作用域与闭包 - JavaScript的执行上下文栈

JavaScript的执行上下文栈

执行上下文(Exexution Contexts)

JavaScript可执行代码有以下四种类型,每个类型的代码均在其自己的执行上下文内运行:

模块代码 和 eval代码 在本文中不作讨论

  • 全局代码
  • 函数代码
  • 模块代码(即ES Module,平常用的importexport即属于此类型的关键字)
  • eval代码

执行上下文 是一个描述代码运行时所在环境的抽象概念。

JS引擎再开始执行代码前,会创建 全局执行上下文 ,全局代码(不属于任何函数的代码)在全局执行上下文中执行。 全局执行上下文 在每个JS程序中只有一个。

当执行全局代码时,可能会碰到函数调用,这时JS引擎会创建 函数执行上下文 并执行函数代码,也就是说在一个执行上下文中可以创建另一个执行上下文。函数中再调用函数或函数中调用自身也是如此。这些执行上下文就构成了 执行上下文栈

执行上下文栈(Execution Context Stack)

执行上下文栈 是一种后进先出(last-in-first-out, LIFO)的栈式数据结构,运行时执行上下文(Running Excution Context)永远处于栈顶位置。

当要创建新的执行上下文时,会将当前执行上下文挂起,新的执行上下文被创建并压入栈中,成为运行时执行上下文。当对应代码执行完毕后,该执行上下文出栈,上一个执行上下文又成为运行时执行上下文。

图片出自 JavaScript Internals: Execution Context

作用域与闭包 - 词法作用域和动态作用域

词法作用域和动态作用域

什么是作用域

作用域是指程序源代码中定义变量的区域,规定了如何查找变量,也就是确定了当前执行代码对变量的访问权限。

词法作用域

词法作用域 也叫静态作用域,即变量的作用范围在代码编写时就已确定,JavaScript使用词法作用域。

通过下面JavaScript代码例子来理解词法作用域(引自 冴羽的博客 ):

const scope = 'global scope'

function checkscope1(){
    const scope = 'local scope 1'
    function f(){
        return scope
    }
    return f()
}
checkscope1()
// <- 'local scope 1'

function checkscope2(){
    const scope = 'local scope 2'
    function f(){
        return scope
    }
    return f
}
checkscope2()()
// <- 'local scope 2'

动态作用域

动态作用域 即变量的作用范围在代码执行时才能确定,bash是使用动态作用域的语言,可复制前往命令行粘贴查看结果。

value=1
function foo () {
    echo $value;
}
function bar () {
    local value=2;
    foo;
}
bar
# <- 2

原型与原型链 - 开源项目中应用原型继承的案例

开源项目中应用原型继承的案例

jQuery

jQuery源码

var jQuery = function(selector, context) {
  return new jQuery.fn.init(selector, context)
}

jQuery.fn = jQuery.prototype = {
	constructor: jQuery,
  ... // 各种原型方法
}

jQuery.fn.init = function(selector, context, root) { ... }
jQuery.fn.init.prototype = jQuery.fn // 校正实例的原型

Vue

Vue源码

function Vue(options) {
  this._init(options)
}

// initMixin
Vue.prototype._init = function (options) { ... }

// stateMixin
Object.defineProperty(Vue.prototype, '$data', {...})
Object.defineProperty(Vue.prototype, '$props', {...})
Vue.prototype.$set = function() {...}
Vue.prototype.$delete = function() {...}
Vue.prototype.$watch = function() {...}

// eventMixin
Vue.prototype.$on = function() {...}
Vue.prototype.$once = function() {...}
Vue.prototype.$off = function() {...}
Vue.prototype.$emit = function() {...}

// lifecycleMixin
Vue.prototype._update = function() {...}
Vue.prototype.$forceUpdate = function() {...}
Vue.prototype.$destory = function() {...}

// renderMixin
Vue.prototype.$nextTick = function() {...}
Vue.prototype._render = function() {...}

语法和API - 实现数组方法(下)

实现数组方法(下)

所有数组方法的实现均忽略参数校验、边界条件判断,主要关注核心逻辑的实现。

部分数组方法会基于Array.prototype.reduce方法来实现,关于reduce方法的讲解及实现详见彻底搞懂数组reduce方法

Array.prototype.push()

MDN - Array.prototype.push()

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

push()方法可用于类数组对象,需要注意的是length不存在或无法转为数值时,将被设置为0,并从索引0开始添加元素。

Array.prototype._push = function(...args) {
  const len = Number(this.length) || 0
  this.length = len
  for (arg of args) {
    this[this.length++] = arg
  }
  return this.length
}

Array.prototype.reduceRight()

MDN - Array.prototype.reduceRight()

reduceRight()方法接受一个函数作为累加器(accumulator)和数组的每个值(从右到左)将其减少为单个值。

之前已实现过reduce()方法,详情见文章开头,我们使用其来实现reduceRight()方法。

Array.prototype._reduceRight = function(callback) {
  const len = this.length
  if (arguments.length >= 2) {
    return [...this].reverse().reduce((acc, cur, i) => {
      return callback(acc, cur, len - 1 - i, this)
    }, arguments[1])
  } else {
    return [...this].reverse().reduce((acc, cur, i) => {
      return callback(acc, cur, len - 1 - i, this)
    })
  }
}

Array.prototype.reverse()

MDN - Array.prototype.reverse()

reverse()方法将数组中元素的位置颠倒,并返回该数组。数组的第一个元素会变成最后一个,数组的最后一个元素变成第一个。该方法会改变原数组。

通过交换数组左右对应位置的值实现,这样只用迭代Math.floor(this.length / 2)次。

Array.prototype._reverse = function() {
  const len = this.length
  for (let i = 0; i < Math.floor(len / 2); i++) {
    const correspondIndex = len - 1 - i;
    [this[i], this[correspondIndex]] = [this[correspondIndex], this[i]]
  }
  return this
}

Array.prototype.shift()

MDN - Array.prototype.shift()

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

Array.prototype._shift = function() {
  if (!this.length) return undefined
  // 记录第一项并删除
  const valToDel = this[0]
  delete this[0]
  // 之后项依次左移
  for (let i = 0; i < this.length - 1; i++) {
    this[i] = this[i + 1]
  }
  // 删除最后一项
  delete this[this.length - 1]
  // 修正数组长度
  this.length--
  return valToDel
}

Array.prototype.slice()

MDN - Array.prototype.slice()

slice()方法返回一个新的数组对象,这一对象是一个由 begin 和 end 决定的原数组的浅拷贝(包括 begin,不包括end)。原始数组不会被改变。

Array.prototype._slice = function(beginIndex = 0, endIndex = this.length) {
  const begin = beginIndex < 0 ?
    Math.max(this.length + beginIndex, 0) :
    Math.min(beginIndex, this.length)
  const end = endIndex < 0 ?
    Math.max(this.length + endIndex, 0) :
    Math.min(endIndex, this.length)
  
  const count = end - begin
  if (count <= 0) return []
  
  const result = new Array(count)
  for (let i = 0; i < count; i++) {
    if (i in this) {
      result[i] = this[begin + i]
    }
  }
  return result
}

Array.prototype.some()

MDN - Array.prototype.some()

some()方法测试数组中是不是至少有1个元素通过了被提供的函数测试。它返回的是一个Boolean类型的值。

some()方法具有短路特性,且会跳过数组稀疏项。

Array.prototype._some = function(callback, thisArg) {
  for (let i = 0; i < this.length; i++) {
    if (i in this && callback.call(thisArg, this[i], i, this)) {
      return true
    }
  }
  return false
}

Array.prototype.splice()

MDN - Array.prototype.splice()

splice()方法通过删除或替换现有元素或者原地添加新的元素来修改数组,并以数组形式返回被修改的内容。此方法会改变原数组。

Array.prototype._splice = function(startIndex, deleteCount = this.length - startIndex, ...items) {
  const len = this.length
  const start = startIndex < 0 ?
    Math.max(len + startIndex, 0) :
    Math.min(len, startIndex)
  
  const realDeleteCount = deleteCount <= 0 ?
    0 :
    Math.min(len - start, deleteCount)
  
  const deleteVals = this.slice(start, start + realDeleteCount)
  const right = [...items, ...this.slice(start + realDeleteCount)]

  for (let i = 0; i < right.length; i++) {
    this[start + i] = right[i]
  }
  // 修正length属性,自动删除多余项
  this.length = start + right.length
  return deleteVals
}

Array.prototype.unshift()

MDN - Array.prototype.unshift()

unshift()方法将一个或多个元素添加到数组的开头,并返回该数组的新长度(该方法修改原有数组)。

Array.prototype._unshift = function(...items) {
  const addCount = items.length
  if (addCount === 0) return this.length
  this.length += items.length
  for (let i = this.length - 1; i >= 0; i--) {
    if (i >= addCount) {
      this[i] = this[i - addCount]
    } else {
      this[i] = items[i]
    }
  }
  return this.length
}

Array.prototype.values()

MDN - Array.prototype.values()

values()方法返回一个新的 Array Iterator 对象,该对象包含数组每个索引的值,实现方法与keys()entries()相似。

Array.prototype._values = function(...items) {
  function *gen() {
    for (let i = 0; i < this.length; i++) {
      yield this[i]
    }
  }
  return gen.call(this)
}

变量和类型 - JavaScript 数据类型

JavaScript 数据类型

7种原始类型(Primitive data type)

原始类型的值本身都是不可变的(immutable)

布尔类型

true / false

Null类型

null,特指对象的值未设置,是一个字面量,不是全局属性。

Undefined类型

undefined,是全局属性,不是保留字,可使用void操作符代替。

数字类型

基于IEEE754标准的双精度64位二进制格式的值。

  • 展开查看数字类型特殊常量
    • 检查值是否大于或小于+-Infinity,可使用常量Number.MAX_VALUENumber.MIN_VALUE
    • 双精度浮点数的取值范围是Number.MIN_SAFE_INTEGERNumber.MAX_SAFE_INTEGER

BigInt类型

可以用任意精度表示整数。目的是为了安全地存储和操作大整数,甚至可以超过数字的安全整数限制。

  • 展开查看BigInt类型创建方式

    通过整数后加n或调用BigInt函数创建。

    const bigNum1 = 123n          // 123n
    const bigNum2 = BigInt(456)   // 456n

字符串类型

  • 由一组16位的无符号整数值(即UTF-16)构成。

  • 每个字符固定为2个字节。对于那些需要4个字节储存的字符(Unicode 码点大于0xFFFF的字符),JavaScript 会认为它们是两个字符。

符号类型

Symbol,唯一的并且是不可修改的, 并且也可以用来作为Object的key的值。

Object类型

对象是指内存中的可以被标识符引用的一块区域。

  • 展开查看对象数据属性的特性(Attributes of a data property)
    特性 数据类型 描述 默认值
    [[Value]] 任何JS类型 包含这个属性的数据值。 undefined
    [[Writable]] Boolean 如果该值为 false,则该属性的 [[Value]] 特性 不能被改变。 true
    [[Enumerable]] Boolean 如果该值为 true,则该属性可以用 for...in 循环来枚举。 true
    [[Configurable]] Boolean 如果该值为 false,则该属性不能被删除,并且不能被转变成一个数据属性。 true
  • 展开查看对象访问器属性的特性
    特性 数据类型 描述 默认值
    [[Get]] 函数对象或者 undefined 该函数使用一个空的参数列表,能够在有权访问的情况下读取属性值。 undefined
    [[Set]] 函数对象或者 undefined 该函数有一个参数,用来写入属性值。 undefined
    [[Enumerable]] Boolean 如果该值为 true,则该属性可以用 for...in 循环来枚举。 true
    [[Configurable]] Boolean 如果该值为 false,则该属性不能被删除,并且 除了 [[Value]] 和 [[Writable]] 以外的特性都不能被改变。 true

“标准”对象

即键和值之间的映射,键是字符串或者Symbol,值是任意JS类型。

其他对象

  • Function对象: 函数,附带可被调用功能的常规对象。

  • Promise对象: 代表了未来将要发生的事件,用来传递异步操作的消息。

  • Proxy对象: 用于定义基本操作的自定义行为。

  • Reflect对象: 提供拦截 JavaScript 操作的方法。

  • Date对象: 日期对象构造函数,也有静态属性和方法。

  • Array对象: 数组,使用整数作为键(integer-key-ed)属性和长度(length)属性之间关联的常规对象。

  • TypedArray对象: 类型数组,提供了基本二进制数据缓冲区的类数组视图的对象。包括Int8ArrayUint8ArrayUint8ClampedArrayInt16ArrayUint16ArrayInt32ArrayUint32ArrayFloat32ArrayFload64ArrayBigInt64ArrayBigUint64Array

  • 键控集: 包括MapWeakMapSetWeakSet

  • JSON(JavaScript Object Notation)对象: 用于结构化数据。

  • Math对象: 数学相关属性方法的集合

  • RegExp对象: 正则表达式构造函数

  • 各错误对象: 包括ErrorEvalErrorRangeErrorReferenceErrorSyntaxErrorTypeErrorURIError

原型与原型链 - new的详细过程及其模拟实现

new的详细过程及其模拟实现

new一个对象的详细过程

  1. 创建一个全新对象,并将该对象原型指向构造函数的原型对象;
  2. 将构造函数调用的this指向这个新对象,并执行构造函数;
  3. 如果构造函数执行结果为对象类型(包含Object,Functoin, Array, Date, RegExg, Error等),则返回执行结果,否则返回创建的新对象。

模拟实现new

function newOperator(Ctor, ...args) {
  if (typeof Ctor !== 'function') {
    throw new TypeError('First argument is not a constructor')
  }
  // 1. 创建一个全新对象,并将该对象原型指向构造函数的原型对象
  const obj = Object.create(Ctor.prototype)

  // 2. 将构造函数调用的this指向这个新对象,并执行构造函数;
  const result = Ctor.apply(obj, args)

  // 3. 如果构造函数执行结果为对象类型,则返回执行结果,否则返回创建的新对象
  return (result instanceof Object) ? result : obj
}

语法和API - 手写Promise

const PENDING = 'pending'
const FULFILLED = 'fulfilled'
const REJECTED = 'rejected'

const queueMicrotask = typeof window !== 'undefined' ? window.queueMicrotask : process.nextTick

class Promise {
  constructor(excutor) {
    this.state = PENDING
    this.onFulfilledCallbacks = []
    this.onRejectedCallbacks = []
    excutor(this._resolve.bind(this), this._reject.bind(this))
  }

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v
    onRejected = typeof onRejected === 'function' ? onRejected : e => { throw e }
    const promise2 = new Promise((resolve, reject) => {
      const next = () => queueMicrotask(() => {
        try {
          const x = this.state === FULFILLED ?
            onFulfilled(this.value) :
            onRejected(this.reason)
          resolvePromise(promise2, x, resolve, reject)
        } catch (e) {
          reject(e)
        }
      })

      if (this.state !== PENDING) return next()
      
      this.onFulfilledCallbacks.push(next)
      this.onRejectedCallbacks.push(next)
    })
    return promise2
  }

  static resolve(value) {
    return new Promise(resolve => resolve(value))
  }

  static reject(reason) {
    return new Promise((_, reject) => reject(reason))
  }

  static race(promises) {
    return new Promise((resolve, reject) => {
      promises.forEach(p => p.then(resolve, reject))
    })
  }

  static all(promises) {
    return new Promise((resolve, reject) => {
      const count = promises.length
      let resultArr = new Array(count)
      let fulfilledCount = 0
      const check = (result, i) => {
        resultArr[i] = result
        fulfilledCount++
        if (fulfilledCount === promises.length) {
          resolve(resultArr)
        }
      }
      promises.forEach((p, i) => {
        p.then(result => check(result, i), reject)
      })
    })
  }

  catch(handler) {
    return this.then(v => v, e => handler(e))
  }

  finally(handler) {
    return this.then(() => handler(), () => handler())
  }

  _resolve(value) {
    if (this.state !== PENDING) return
    this.state = FULFILLED
    this.value = value
    this.onFulfilledCallbacks.forEach(cb => cb(this.value))
  }

  _reject(reason) {
    if (this.state !== PENDING) return
    this.state = REJECTED
    this.reason = reason
    this.onRejectedCallbacks.forEach(cb => cb(this.reason))
  }
}

function resolvePromise(promise2, x, resolve, reject){
  if(x === promise2){
    return reject(new TypeError('Chaining cycle detected for promise'))
  }
  let called
  if (x != null && (typeof x === 'object' || typeof x === 'function')) {
    try {
      let then = x.then
      if (typeof then === 'function') { 
        then.call(x, y => {
          if (called) return
          called = true
          resolvePromise(promise2, y, resolve, reject)
        }, err => {
          if (called) return
          called = true
          reject(err)
        })
      } else {
        resolve(x)
      }
    } catch (e) {
      if (called) return
      called = true
      reject(e)
    }
  } else {
    resolve(x)
  }
}

变量和类型 - 判断JavaScript数据类型

判断JavaScript数据类型

实现判断数据类型功能

function getType(v) {
  // `null`
  if (v === null) return 'null'
  
  const baseType = typeof v
  // `undefined`、`number`、`string`、`boolean`、`symbol`、`bigInt`、`function`
  if (baseType !== 'object') return baseType

  // `arguments`、`error`、`date`、`regExp`、`object`
  // `map`、`set`、`weakmap`、`weakset`
  // 基本类型的包装类型按照其基本类型返回
  const builtinType = Object.prototype.toString.call(v)
    .slice(8, -1).toLocaleLowerCase()

  return builtinType
}

typeof

可判断类型UndefinedNumberStringBooleanSymbolBigIntFunction

坑点typeof null === 'object',JS历史Bug,修改后造成大量兼容问题,故遗留至今。

expect(typeof undefined).toBe('undefined')
expect(typeof 1).toBe('number')
expect(typeof 'Logan').toBe('string')
expect(typeof true).toBe('boolean')
expect(typeof Symbol()).toBe('symbol')
expect(typeof BigInt(123)).toBe('bigint')
expect(typeof (() => {})).toBe('function')

instanceof

instanceof 操作符左侧为引用类型,右侧为构造函数,作用是检测右侧构造函数的原型是否在左侧对象的原型链上出现过,故不太适合用作数据类型判断。

更多有关原型与原型链的内容,可前往 深入JavaScript系列(六):原型与原型链 了解。

expect([] instanceof Array).toBe(true)
expect([] instanceof Object).toBe(true)

expect(null instanceof Object).toBe(false)

Object.prototype.toString

由于基本类型的包装对象以及除Object外的其他引用类型大都重写了toString方法,导致我们无法得到预期的效果,故使用Object.prototype.toString,通过call调用将this指向我们想判断类型的变量或值。ECMAScript相关描述详见 ECMAScript#Object.prototype.toString

可以通过定义对象的[Symbol.toStringTag]属性来自定义Object.prototype.toString.call()时的表现,详见Symbol的应用及实现

将各个类型传入Object.prototype.toString.call()的表现如下表:

*Error代表所有错误类对象

传入变量类型 返回结果
Undefined '[object Undefined]'
Null '[object Null]'
Boolean '[object Boolean]'
Number '[object Number]'
String '[object String]'
Symbol '[object Symbol]'
BigInt '[object BigInt]'
Object '[object Object]'
Array '[object Array]'
Function '[object Function]'
Arguments '[object Arguments]'
Set '[object Set]'
Map '[object Map]'
WeakSet '[object WeakSet]'
WeakMap '[object WeakMap]'
Date '[object Date]'
RegExp '[object RegExp]'
*Error '[object Error]'

执行机制 - JavaScript异步编程及Event Loop

JavaScript异步编程及Event Loop

异步编程

为何需要异步编程

JavaScript是单线程语言,也就是说同一时间只能运行一个任务。一般来说这没什么问题,但是如果运行耗时过长的任务,将会阻塞后续任务的执行,包括UI渲染。

所以一些非密集计算的任务,比如文件I/O,HTTP Request,定时器等任务,完全没必要在主线程等待其完成,而是应该在创建任务后交出主线程控制权,去执行其他任务,待其完成后再处理。这就引出了异步编程

需要注意的是,ECMAScript(JavsScript的语言规范)并没有定义这些异步特性,所以异步特性的实现都需要依赖于JavaScript运行环境,例如浏览器、Node等。

如何实现异步编程

浏览器提供了JS引擎不具备的特性,我们称之为Web API,例如我们常见的DOM事件监听、Ajax、定时器等。通过这些特性可以实现异步、非阻塞的行为。其执行机制会在下个部分 Event Loop 中详细讲解。

异步编程语法

JavaScript发展至今,异步编程语法主要有以下几种:

  • 回调函数Callback
  • Promise
  • Generator
  • Async / Await
/* ------------------- Callback ---------------- */
function asyncFn(callback) {
  setTimeout(() => {
    callback()
  }, 1000)
}

const callbackFn = () => console.log('Callback has been invoked')

asyncFn(callbackFn)

/* ------------------- Promise ---------------- */
function asyncFn() {
  return new Promise(resolve => {
    setTimeout(() => resolve(), 1000)
  })
}

asyncFn().then(name => console.log('Promise fulfilled')

/* ------------------- Generator ---------------- */
// Generator生成器函数执行时会返回一个Generator迭代器
// 也就是说Generator本身只是一个状态机,需要由调用者来改变它的状态
function *fetchUser () {
  const user = yield ajax()
  console.log(user)
}

const f = fetchUser()

// 加入的控制代码
const result = f.next()
result.value.then((d) => {
  f.next(d)
})

/* ------------------- Async/Await ---------------- */
// Async/Await 可以理解为是 Generator + Promise 的语法糖
// async 对应 *,await 对应 yield,然后自动实现Generator的流程控制
async function getUser() {
  const user = await ajax()
  return user
}

// getUser用Generator + Promise表示并执行
function *getUser() {
  const user = yeild ajax()
  return user
}

const g = getUser()
const result = g.next()
result.value.then(res => {
  g.next(res)
})

Event Loop

首先推荐一个Event Loop可视化执行的网站。

基本概念

  • 调用栈(Call Stack):也叫执行栈,用于执行任务,是一个栈数据结构,具有后进先出的特点(Last in, first out. LIFO)。
  • 宏任务:包括Script标签中的直接运行代码Web API加入任务队列的回调函数。
  • 微任务:包括Promiseprocess.nextTickMutaionObserverqueueMicrotask的回调函数。
  • 任务队列(Task Queue):用于存放等待执行的宏任务,具有先进先出的特点(First in, first out. FIFO),调用栈为空时会取出任务队列中第一个任务执行(若存在)。
  • 微任务队列(MicroTask Queue):用于存放等待执行的微任务,具有先进先出的特点(First in, first out. FIFO),每个宏任务执行完结时,会依次执行微任务队列中的微任务。
  • Web API:包括定时器xhrDOM Event等,提供了处理异步任务的能力,异步任务完成后,将回调函数放入对应的任务队列(即微任务会放入微任务队列)。

执行机制

Event Loop

  1. 执行同步代码,碰到异步代码交由Web API处理(异步任务完成后Web API将回调函数加入相应任务队列/微任务队列);
  2. 同步代码执行完成后,检查微任务队列中是否存在待执行微任务,存在则取出第一个微任务执行,执行完成后再次检查执行,直至微任务队列为空;

    需要注意的是,若微任务执行过程中往微任务队列加入了新的微任务,也会在本步骤内被执行。

  3. 此时调用栈为空,检查任务队列中是否存在待执行宏任务,存在则取出第一个宏任务执行,也就是回调了第1步循环进行。

巩固练习

// 题目出自https://juejin.im/post/5a6155126fb9a01cb64edb45#heading-2
console.log(1)

setTimeout(() => {
    console.log(2)
    new Promise(resolve => {
        console.log(3)
        resolve()
    }).then(() => {
        console.log(4)
    })
})

new Promise(resolve => {
    console.log(5)
    resolve()
}).then(() => {
    console.log(6)
})
  1. 执行同步代码,打印1
  2. 执行setTimeout,交由Web API处理,Web API将其加入任务队列;
  3. 执行new Promise,其参数函数为同步执行,打印5,然后该Promise变为fulfilled状态,将then内回调加入微任务队列;
  4. 当前任务执行完毕,查看微任务队列,有一个待执行微任务,取出执行,打印6
  5. 微任务队列为空,查看任务队列,有一个待执行宏任务,及setTimeout的回调,取出执行;
  6. 打印2,同步执行new Promise中函数,打印3,Promise置为fulfilled,将then内回调加入为任务队列;
  7. 当前任务执行完毕,查看微任务队列,有一个待执行微任务,取出执行,打印4
  8. 所有任务执行完毕,打印顺序为1 5 6 2 3 4

加强练习

理解了JS的执行机制,碰到类似题目就可以轻松应对:

// 题目出自https://juejin.im/post/5a6155126fb9a01cb64edb45
console.log(1)

setTimeout(() => {
    console.log(2)
    new Promise(resolve => {
        console.log(3)
        resolve()
    }).then(() => {
        console.log(4)
    })
})

new Promise(resolve => {
    console.log(5)
    resolve()
}).then(() => {
    console.log(6)
}).then(() => {
    console.log(7)
})

new Promise(resolve => {
    console.log(8)
    resolve()
}).then(() => {
    console.log(9)
}).then(() => {
    console.log(10)
})

答案为1 5 8 6 9 7 10 2 3 4

执行机制 - 复杂异步嵌套逻辑分析

复杂异步嵌套逻辑分析

Async/Await 在事件循环中的表现

在分析之前,有必要了解一下Async/Await在事件循环中的表现,先看如下代码。

async function async1() {
  console.log('a')
  await async2()
  console.log('b')
}
async function async2() {
  console.log('c')
}

async1()

new Promise((resolve) => {
  console.log('d')
  resolve()
}).then(() => {
  console.log('e')
})

不同chrome版本表现不同,有以下两种情况:

  • a c d b e
  • a c d e b

首先说明:最新ECMAScript规范下,第一种为正确表现,下面解释原因。

最新ECMAScript规范

最新ECMAScript规范中,await直接使用Promise.resolve()相同语义,也就是说,如果await后跟的是一个Promise,则直接返回Promise本身,如果不是,则使用Promise.resolve包裹后返回,上述代码执行过程可以简化理解为:

console.log('a')
new Promise(resolve => {
  console.log('c')
  resolve()
}).then(() => {
  console.log('b')
})
new Promise((resolve) => {
  console.log('d')
  resolve()
}).then(() => {
  console.log('e')
})

console.log('b')在第一轮事件循环时就加入微任务队列,然后console.log('e')才加入微任务队列,故b的打印顺序在先。

老版ECMAScript规范

await后不论是否为Promise,都会产生一个新的Promise,再将后面跟的内容resolve出去。

其实最初关于async/await的相关规范和上述最新规范中行为是一致的,但是中间有一段时间ECMA规范有一些变化,只不过最后又变了回来

根据老版规范,上述代码执行过程可以简化理解为:

console.log('a')
new Promise((resolve1) => {
  resolve1(new Promise(resolve2 => {
    console.log('c')
    resolve2()
  }))
}).then(() => {
  console.log('b')
})
new Promise((resolve) => {
  console.log('d')
  resolve()
}).then(() => {
  console.log('e')
})

由于resolve1内又resolve了一个Promise,所以在这里已经是异步任务了,而不是立即变为fulfilled的状态,所以console.log('b')并不是在第一轮事件循环中被加入微任务队列,而console.log('e')仍然是在第一轮事件循环中就被加入微任务队列,所以e先于b打印,最终打印顺序为a c d e b

更多详细探讨可参考这篇文章

复杂异步嵌套分析

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')
}, 0)
 
async1()
 
new Promise(function(resolve) {
  console.log('promise1')
  resolve()
}).then(function() {
  console.log('promise2')
})
 
console.log('script end')
  1. 定义函数async1async2打印script start
  2. 执行setTimeout,回调交由Web API处理,Web API将其加入宏任务队列;
  3. 执行async1打印async1 start
  4. 执行async2打印async2,由于左边有await,将console.log('async1 end')放入微任务队列;
  5. 执行new Promise,同步执行传入构造函数的函数,打印promise1
  6. promise完成,将console.log('promise2')所在函数放入微任务队列;
  7. 打印script end,当前任务执行完毕;
  8. 检查微任务队列并依次取出执行,打印async1 end打印promise2
  9. 微任务队列为空,执行栈为空,检查宏任务队列,取出任务执行,打印setTimeout
  10. 执行完毕。

故打印顺序为:

  • script start
  • async1 start
  • async2
  • promise1
  • script end
  • async1 end
  • promise2
  • setTimeout

作用域与闭包 - this的原理以及几种不同使用场景的取值

this的原理以及几种不同使用场景的取值

了解函数

在具体谈论this取值的各种情况前,我们先来看看一个函数从创建到执行的过程中对我们了解this有帮助的一些规范信息。

函数的this模式

函数创建阶段会标记该函数的this模式,有以下三种模式,由上往下进行判定,详见 ECMAScript#FunctionInitialize

  1. lexical :箭头函数的this模式标记为 lexical
  2. strict :严格模式下函数的this模式标记为 strict
  3. global :其他情况的函数的this模式标记为 global

函数的执行

无论函数通过何种方式调用,最终JS引擎都会通过函数对象内部的 [[Call]] ( thisArgument, argumentsList ) 方法来调用函数并执行。第一个参数为指定this的值,不传则为undefined,第二个参数为函数被调用时的参数列表,详见 ECMAScript#[[Call]]

函数的this绑定

函数调用的初始阶段,会进行this的绑定,具体表现为以下步骤,详见 ECMAScript#BindThis 。:

  1. 如果函数的this模式为lexical,不进行绑定;
  2. 如果函数的this模式为strict,则this绑定值严格等于传入的thisArgument
  3. 如果函数的this模式为globalthisArgumentnullundefined,则this绑定值为全局对象;
  4. 如果函数的this模式为globalthisArgument不为nullundefined,则this绑定值为thisArgument

    这一步中如果传入的thisArgument为基本类型值,会进行装箱操作

不同使用场景的this取值

JavaScript函数中this取值主要区分以下几个情况:

  1. 函数的普通调用
  2. 函数作为对象方法调用
  3. 函数作为构造函数调用
  4. 函数通过callapplybind间接调用
  5. 箭头函数的调用

函数的普通调用

函数普通调用时,未指定this的值,thisArgumentundefined,this的值分两种情况:

  • 非严格模式:this为全局对象;
  • 严格模式:this为undefined
function looseFn() {
  console.log(this)
}

function strictFn() {
  'use strict'
  console.log(this)
}

looseFn()  // <- window
strictFn() // <- undefined

函数作为对象方法调用

函数作为对象方法调用时,会将该对象作为thisArgument,所以this为函数所在对象。

ECMA定义规范 -> Abstract operation Call on Objects

var myName = 'global'
const obj = {
  myName: 'obj',
  getMyName() {
    console.log(this.myName)
  }
}

obj.getMyName() // <- 'obj'

函数作为构造函数调用

函数作为构造函数调用时,会将构造的对象作为thisArgument,所以this为构造的对象。

ECMA定义规范 -> Abstract operation Construct

function Person(name) {
  this.name = name
  console.log(this)
}

const person = new Person('Logan')
// <- Person {name: "Logan"}

函数通过callapplybind间接调用

这个不难理解,即通过指定thisArgument的值来改变this的指向。

var name = 'global'
function logName() {
  console.log(this.name)
}

logName() // <- 'global'
logName.call({ name: 'call' }) // <- 'call'
logName.apply({ name: 'apply' }) // <- 'apply'
// 注意bind返回一个函数,而不是直接调用
logName.bind({ name: 'bind' })() // <- 'bind'

箭头函数的调用

箭头函数this模式为lexical,执行时不进行this绑定,所以箭头函数中this的值取决于作用域链上最近的this值。

需要注意的是,箭头函数没有this绑定,所以使用callapplybind无法改变箭头函数内this的指向。

function genArrowFn() {
    return () => {
        console.log(this)
    }
}

const arrowFn1 = genArrowFn()
arrowFn1()                  // <- window

const arrowFn2 = genArrowFn.call({ a: 1 })
arrowFn2()                  // <- { a: 1 }

// `call`、`apply`、`bind`无法改变箭头函数内this的指向,仍然在作用域链上寻找
arrowFn1.call({ a: 2 })     // <- window
arrowFn2.apply({ a: 2 })    // <- { a: 1 }
arrowFn2.bind({ a: 2 })()   // <- { a: 1 }

测试练习

var name = 'window'

const person1 = {
  name: 'person1',
  show1: function () {
    console.log(this.name)
  },
  show2: () => console.log(this.name),
  show3: function () {
    return function () {
      console.log(this.name)
    }
  },
  show4: function () {
    return () => console.log(this.name)
  }
}
const person2 = { name: 'person2' }

person1.show1()                     // person1 函数作为对象方法调用,this指向对象
person1.show1.call(person2)         // person2 使用call间接调用函数,this指向传入的person2

person1.show2()                     // window  箭头函数无this绑定,在全局环境找到this,指向window
person1.show2.call(person2)         // window  间接调用改变this指向对箭头函数无效

person1.show3()()                   // window  person1.show3()返回普通函数,相当于普通函数调用,this指向window
person1.show3().call(person2)       // person2 使用call间接调用函数,this指向传入的person2
person1.show3.call(person2)()       // window  person1.show3.call(person2)仍然返回普通函数

person1.show4()()                   // person1 person1.show4调用对象方法,this指向person1,返回箭头函数,this在person1.show4调用时的词法环境中找到,指向person1
person1.show4().call(person2)       // person1  间接调用改变this指向对箭头函数无效
person1.show4.call(person2)()       // person2  改变了person1.show4调用时this的指向,所以返回的箭头函数的内this解析改变

变量和类型 - 隐式类型转换

隐式类型转换

    1. 在加法运算时
    • 1-1 若两操作数中有字符串,则优先转字符串;
    • 1-2 若两操作数都不是字符串,则优先转数字,若是引用类型转换得到字符串,则回到1-1。
    1. 其他运算及==,均优先转数字,若是转换得到字符串,再尝试转数字,转成则计算,转不成则NaN
    1. null转数字为0,undefined转数字为NaN
    1. 引用类型转数字valueOf优先级大于toString,转字符串toString优先级大于valueOf,先调用优先级高的,得不到基本类型再调用优先级低的。
    1. +varible强制转数字,失败则为NaN
    1. 多个加号从左到右依次计算。
// 满足1-1,左边的1转字符串'1',相加为'11'
1 + '1' // '11'

// 首先![]优先计算为false,本题实质是 [] == false
// 满足规则1-2,[]尝试转数字,用valueOf,还是[],不行
// 再用toString,得到空字符串,此时为 '' == false
// ''转数字为0,false转数字为0,所以此题为true
[] == ![] // true

// true + true 符合1-2,即1 + 1 = 2
// 2 + false 符合1-2, 即 2 + 0 = 2
// 2 + '100' 符合1-1,即 '2' + '100' = '2100'
true + true + false + '100' // '2100'

// 符合1-2,数组转数字,调用valueOf失败
// 调用toString得到 '1,2,3,4'
// 4 + '1,2,3,4' 符合1-1,最终为 '41,2,3,4' 
4 + [1, 2, 3, 4]

// 符合1-2,对象转数字,调用valueOf失败
// 调用toString得到 '[object Object]'
// 1 + '[object Object]',符合1-1,最终结果为'1[object Object]'
1 + {}

// +'b'视为整体,强制转数字,失败,返回NaN
// 'a' + NaN,符合1-1,返回'aNaN'
'a' + + 'b'

深入JavaScript系列(二):执行上下文

一、执行上下文(Exexution Contexts)

执行上下文(Exexution Contexts):用来通过ECMAScript编译器来追踪代码运行时计算的一种规范策略。

执行上下文简单理解就是代码执行时所在环境的抽象。

执行上下文同时包含变量环境组件(VariableEnvironment)和词法环境组件(LexicalEnvironment),这两个组件多数情况下都指向相同的词法环境(Lexical Environment),那为什么还要存在两个环境组件呢?我们稍后将进行详细讨论。如果不太了解词法环境的可以看下我的上一篇文章深入ECMAScript系列(一):词法环境

ExecutionContext = {
    VariableEnvironment: { ... },
    LexicalEnvironment: { ... },
}

二、执行上下文栈

执行上下文栈(Execution Context Stack):是一个后进先出的栈式结构(LIFO),用来跟踪维护执行上下文。运行执行上下文(running execution context) 始终位于执行上下文栈的顶层。那么什么时候会创建新的执行上下文呢?

ECMAScript可执行代码有四种类型:全局代码,函数代码,模块代码和eval。每当从当前执行代码运行至其他可执行代码时,会创建新的执行上下文,将其压入执行上下文栈并成为正在运行的执行上下文。当相关代码执行完毕返回后,将正在运行的执行上下文从执行上下文栈删除,之前的执行上下文又成为了正在运行的执行上下文。

我们通过一个动图来看一下执行上下文栈的工作过程

  1. 开始执行任何JavaScript代码前,会创建全局上下文并压入栈,所以全局上下文一直在栈底。
  2. 每次调用函数都会创建新的执行上下文(即便在函数内部调用自身),并压入栈。
  3. 函数执行完毕返回,其执行上下文出栈。
  4. 所有代码运行完毕,执行上下文栈只剩全局执行上下文。

三、执行上下文的创建、入栈及出栈

上面提到过ECMAScript可执行代码有四种类型:全局代码,函数代码,模块代码和eval

这里虽然说是全局代码,但是JavaScript引擎其实是按照script标签来解析执行的,也就是说script标签按照它们出现的顺序解析执行,这也就是为什么我们平时要将项目依赖js库放在前面引入的原因。

JavaScript引擎是按可执行代码块来执行代码的,在任意的JavaScript可执行代码被执行时,执行步骤可按如下理解:

  1. 创建一个新的执行上下文(Execution Context)
  2. 创建一个新的词法环境(Lexical Environment)
  3. 将该执行上下文的 变量环境组件(VariableEnvironment)词法环境组件(LexicalEnvironment) 都指向新创建的词法环境
  4. 将该执行上下文 推入执行上下文栈 并成为 正在运行的执行上下文
  5. 对代码块内的 标识符进行实例化及初始化
  6. 运行代码
  7. 运行完毕后执行上下文出栈

变量提升(Hoisting)及暂时性死区(temporal dead zone,TDZ)

我们平常所说的变量提升就发生在上述执行步骤的第四步,对代码块内的标识符进行实例化及初始化的具体表现如下:

  1. 执行代码块内的letconstclass声明的标识符合集记录为lexNames
  2. 执行代码块内的varfunction声明的标识符合集记录为varNames
  3. 如果lexNames内的任何标识符在varNameslexNames内出现过,则报错SyntaxError

    这就是为什么可以用varfunction声明多个同名变量,但是不能用letconstclass声明多个同名变量。

  4. varNames内的var声明的标识符实例化并初始化赋值undefined,如果有同名标识符则跳过

    这就是所谓的变量提升,我们用var声明的变量,在声明位置之前访问并不会报错,而是返回undefined

  5. lexNames内的标识符实例化,但并不会进行初始化,在运行至其声明处代码时才会进行初始化,在初始化前访问都会报错。

    这就是我们所说的暂时性死区letconstclass声明的变量其实也提升了,只不过没有被初始化,初始化之前不可访问。

  6. 最后将varNames内的函数声明实例化并初始化赋值对应的函数体,如果有同名函数声明,则前面的都会忽略,只有最后一个声明的函数会被初始化赋值。

    函数声明会被直接赋值,所有我们在函数声明位置之前也可以调用函数。

四、为什么需要两个环境组件

首先明确这两个环境组件的作用,变量环境组件(VariableEnvironment)用于记录var声明的绑定,词法环境组件(LexicalEnvironment)用于记录其他声明的绑定(如letconstclass等)。

一般情况下一个Exexution Contexts内的VariableEnvironmentLexicalEnvironment指向同一个词法环境,之所以要区分两个组件,主要是为了实现块级作用域的同时不影响var声明及函数声明

众所周知,ES6之前并没有块级作用域的概念,但是ES6及之后我们可以通过新增的letconst等命令来实现块级作用域,并且不影响var声明的变量和函数声明,那么这是怎么实现的呢?

  1. 首先在一个正在运行的执行上下文(running Execution Context)内,词法环境由VariableEnvironmentLexicalEnvironment构成,此执行上下文内的所有标识符的绑定都记录在两个组件的环境记录内。
  2. 当运行至块级代码时,会将LexicalEnvironment记录下来,我们将其记录为oldEnv
  3. 然后创建一个新的LexicalEnvironment(外部词法环境outer指向oldEnv),我们将其记录为newEnv,并将newEnv设置为running Execution ContextLexicalEnvironment
  4. 然后块级代码内的letconst等声明就会绑定在这个newEnv上面,但是var声明和函数声明还是绑定在原来的VariableEnvironment上面。

    块级代码内的函数声明会被当做var声明,会被提升至外部环境,块级代码运行前其值为初始值undefined

    console.log(foo) // 输出:undefined
    {
        function foo() {console.log('hello')}
    }
    console.log(foo) // 输出: ƒ foo() {console.log('hello')}
  5. 块级代码运行完毕后,又将oldEnv还原为running Execution ContextLexicalEnvironment

目前包括块级代码(在一对大括号内的代码)、for循环语句、switch语句、TryCatch语句中的catch从句以及with语句(with语句创建的新环境为对象式环境,其他皆为声明式环境)都是这样来实现块级作用域的。

系列文章

准备将之前写的部分深入ECMAScript文章重写,加深自己理解,使内容更有干货,目录结构也更合理。

深入ECMAScript系列目录地址(持续更新中...)

欢迎前往阅读系列文章,如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

菜鸟一枚,如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误,与大家共同进步。

作用域与闭包 - 堆栈溢出和内存泄漏的原理及防范

堆栈溢出和内存泄漏的原理及防范

堆栈溢出

产生原因

  • 上溢:栈满时再做进栈必定产生空间溢出
  • 下溢:栈空时再做退栈也产生空间溢出

最常见的就是无限递归递归层级过深,导致调用栈空间不足,从而导致栈上溢。

// 阶乘,若用递归实现,层级不能过深
const factorial = n => n <=1 ? 1 : n * factorial(n - 1)
// 斐波那契数列也一样
const fibonacci = n => n <= 1 ? 1 : fibonacci(n - 1) + fibonacci(n - 2)

解决方案

递归改循环

优化原理:所有运算均在一个执行上下文中执行,不用生成额外的上下文。

const factorial = n => {
  let result = 1
  while (n > 1) {
    result *= n--
  }
  return result
}

const fibonacci = n => {
  let tmp = 1
  let result = 1
  while (n > 1) {
    [tmp, result] = [result, tmp + result]
    n--
  }
  return result
}

尾调用优化

优化原理:函数返回回溯时不需要做任何额外的计算,故可以不用保存函数的入口环境。

尾调用的优化依赖于语言实现,其本质还是将尾调用优化为循环的实现方式。ES6之前没有对尾调用进行优化,还是会导致调用栈增长。

const factorial = (n, result = 1) => n <= 1 ? result : factorial(n - 1, n * result)

const fibonacci = (n, prev = 1, cur = 1) => n <= 1 ? cur : fibonacci(n - 1, cur, prev + cur)`

内存泄漏

内存泄漏主要

产生原因

由于疏忽或错误造成程序未能释放已经不再使用的内存。

例如不再需要的闭包、定时器及全局变量等未能及时解除引用。

解决方案

  • 及时解除不再需要的引用,如闭包、定时器及全局变量等;
  • 使用WeakSetWeakMap,它们对于值的引用是弱引用,只要外部的引用消失,内部的引用就会自动被垃圾回收清除。

深入JavaScript系列(六):原型与原型链

说到JavaScript的原型和原型链,相关文章已有不少,但是大都晦涩难懂。本文将换一个角度出发,先理解原型和原型链是什么,有什么作用,再去分析那些令人头疼的关系。

一、引用类型皆为对象

原型和原型链都是来源于对象而服务于对象的概念,所以我们要先明确一点:

JavaScript中一切引用类型都是对象,对象就是属性的集合。

Array类型Function类型Object类型Date类型RegExp类型等都是引用类型。

也就是说 数组是对象、函数是对象、正则是对象、对象还是对象。

二、原型和原型链是什么

上面我们说到对象就是属性(property)的集合,有人可能要问不是还有方法吗?其实方法也是一种属性,因为它也是键值对的表现形式,具体见下图。

可以看到obj上确实多了一个sayHello的属性,值为一个函数,但是问题来了,obj上面并没有hasOwnProperty这个方法,为什么我们可以调用呢?这就引出了 原型

每一个对象从被创建开始就和另一个对象关联,从另一个对象上继承其属性,这个另一个对象就是 原型

当访问一个对象的属性时,先在对象的本身找,找不到就去对象的原型上找,如果还是找不到,就去对象的原型(原型也是对象,也有它自己的原型)的原型上找,如此继续,直到找到为止,或者查找到最顶层的原型对象中也没有找到,就结束查找,返回undefined

这条由对象及其原型组成的链就叫做原型链。

现在我们已经初步理解了原型和原型链,到现在大家明白为什么数组都可以使用pushslice等方法,函数可以使用callbind等方法了吧,因为在它们的原型链上找到了对应的方法。

OK,总结一下

  1. 原型存在的意义就是组成原型链:引用类型皆对象,每个对象都有原型,原型也是对象,也有它自己的原型,一层一层,组成原型链。
  2. 原型链存在的意义就是继承:访问对象属性时,在对象本身找不到,就在原型链上一层一层找。说白了就是一个对象可以访问其他对象的属性。
  3. 继承存在的意义就是属性共享:好处有二:一是代码重用,字面意思;二是可扩展,不同对象可能继承相同的属性,也可以定义只属于自己的属性。

三、创建对象

对象的创建方式主要有两种,一种是new操作符后跟函数调用,另一种是字面量表示法。

目前我们现在可以理解为:所有对象都是由new操作符后跟函数调用来创建的,字面量表示法只是语法糖(即本质也是new,功能不变,使用更简洁)。

// new操作符后跟函数调用
let obj = new Object()
let arr = new Array()

// 字面量表示法
let obj = { a: 1}
// 等同于
let obj = new Object()
obj.a = 1

let arr = [1,2]
// 等同于
let arr = new Array()
arr[0] = 1
arr[1] = 2

ObjectArray等称为构造函数,不要怕这个概念,构造函数和普通函数并没有什么不同,只是由于这些函数常被用来跟在new后面创建对象。new后面调用一个空函数也会返回一个对象,任何一个函数都可以当做构造函数

所以构造函数更合理的理解应该是函数的构造调用

NumberStringBooleanArrayObjectFunctionDateRegExpError这些都是函数,而且是原生构造函数,在运行时会自动出现在执行环境中。

构造函数是为了创建特定类型的对象,这些通过同一构造函数创建的对象有相同原型,共享某些方法。举个例子,所有的数组都可以调用push方法,因为它们有相同原型。

我们来自己实现一个构造函数:

// 惯例,构造函数应以大写字母开头
function Person(name) {
  // 函数内this指向构造的对象
  // 构造一个name属性
  this.name = name
  // 构造一个sayName方法
  this.sayName = function() {
    console.log(this.name)
  }
}

// 使用自定义构造函数Person创建对象
let person = new Person('logan')
person.sayName() // 输出:logan

总结一下构造函数用来创建对象,同一构造函数创建的对象,其原型相同。

四、__proto__prototype

万物逃不开真香定律,初步了解了相关知识,我们也要试着来理解一下这些头疼的单词,并且看一下指来指去的箭头了。

上面总结过,每个对象都有原型,那么我们怎么获取到一个对象的原型呢?那就是对象的__proto__属性,指向对象的原型。

上面也总结过,引用类型皆对象,所以引用类型都有__proto__属性,对象有__proto__属性,函数有__proto__属性,数组也有__proto__属性,只要是引用类型,就有__proto__属性,都指向它们各自的原型对象。

__proto__属性虽然在ECMAScript 6语言规范中标准化,但是不推荐被使用,现在更推荐使用Object.getPrototypeOfObject.getPrototypeOf(obj)也可以获取到obj对象的原型。本文中使用__proto__只是为了便于理解。

Object.getPrototypeOf(person) === person.__proto__ // true

上面说过,构造函数是为了创建特定类型的对象,那如果我想让Person这个构造函数创建的对象都共享一个方法,总不能像下面这样吧:

错误示范

// 调用构造函数Person创建一个新对象personA
let personA = new Person('张三')
// 在personA的原型上添加一个方法,以供之后Person创建的对象所共享
personA.__proto__.eat = function() {
    console.log('吃东西')
}
let personB = new Person('李四')
personB.eat() // 输出:吃东西

但是每次要修改一类对象的原型对象,都去创建一个新的对象实例,然后访问其原型对象并添加or修改属性总觉得多此一举。既然构造函数创建的对象实例的原型对象都是同一个,那么构造函数和其构造出的对象实例的原型对象之间有联系就完美了。

这个联系就是prototype。每个函数拥有prototype属性,指向使用new操作符和该函数创建的对象实例的原型对象。

Person.prototype === person.__proto__ // true

看到这里我们就明白了,如果想让Person创建出的对象实例共享属性,应该这样写:

正确示范

Person.prototype.drink = function() {
    console.log('喝东西')
}

let personA = new Person('张三')
personB.drink() // 输出:喝东西

OK,惯例,总结一下

  1. 对象有__proto__属性,函数有__proto__属性,数组也有__proto__属性,只要是引用类型,就有__proto__属性,指向其原型。
  2. 只有函数有prototype属性,只有函数有prototype属性,只有函数有prototype属性,指向new操作符加调用该函数创建的对象实例的原型对象。

五、原型链顶层

原型链之所以叫原型链,而不叫原型环,说明它是有始有终的,那么原型链的顶层是什么呢?

拿我们的person对象来看,它的原型对象,很简单

// 1. person的原型对象
person.__proto__ === Person.prototype

接着往上找,Person.prototype也是一个普通对象,可以理解为Object构造函数创建的,所以得出下面结论,

// 2. Person.prototype的原型对象
Person.prototype.__proto__ === Object.prototype

Object.prototype也是一个对象,那么它的原型呢?这里比较特殊,切记!!!

Object.prototype.__proto__ === null

我们就可以换个方式描述下 原型链 :由对象的__proto__属性串连起来的直到Object.prototype.__proto__(为null)的链就是原型链。

在上面内容的基础之上,我们来模拟一下js引擎读取对象属性:

function getProperty(obj, propName) {
    // 在对象本身查找
    if (obj.hasOwnProperty(propName)) {
        return obj[propName]
    } else if (obj.__proto__ !== null) {
    // 如果对象有原型,则在原型上递归查找
        return getProperty(obj.__proto__, propName)
    } else {
    // 直到找到Object.prototype,Object.prototype.__proto__为null,返回undefined
        return undefined
    }
}

六、constructor

回忆一下之前的描述,构造函数都有一个prototype属性,指向使用这个构造函数创建的对象实例的原型对象

这个原型对象中默认有一个constructor属性,指回该构造函数。

Person.prototype.constructor === Person // true

之所以开头不说,是因为这个属性对我们理解原型及原型链并无太大帮助,反而容易混淆。

七、函数对象的原型链

之前提到过引用类型皆对象,函数也是对象,那么函数对象的原型链是怎么样的呢?

对象都是被构造函数创建的,函数对象的构造函数就是Function,注意这里F是大写。

let fn = function() {}
// 函数(包括原生构造函数)的原型对象为Function.prototype
fn.__proto__ === Function.prototype // true
Array.__proto__ === Function.prototype // true
Object.__proto__ === Function.prototype // true

Function.prototype也是一个普通对象,所以Function.prototype.__proto__ === Object.prototype

这里有一个特例,Function__proto__属性指向Function.prototype

总结一下:函数都是由Function原生构造函数创建的,所以函数的__proto__属性指向Functionprototype属性

八、小试牛刀

真香警告!

有点乱?没事,我们先将之前的知识都总结一下,然后慢慢分析此图:

知识点

  1. 引用类型都是对象,每个对象都有原型对象。
  2. 对象都是由构造函数创建,对象的__proto__属性指向其原型对象,构造函数的prototype属性指向其创建的对象实例的原型对象,所以对象的__proto__属性等于创建它的构造函数的prototype属性。
  3. 所有通过字面量表示法创建的普通对象的构造函数为Object
  4. 所有原型对象都是普通对象,构造函数为Object
  5. 所有函数的构造函数是Function
  6. Object.prototype没有原型对象

OK,我们根据以上六点总结来分析上图,先从左上角的f1f2入手:

// f1、f2都是通过new Foo()创建的对象,构造函数为Foo,所以有
f1.__proto__ === Foo.prototype
// Foo.prototype为普通对象,构造函数为Object,所以有
Foo.prototype.__proto === Object.prototype
// Object.prototype没有原型对象
Object.prototype.__proto__ === null

然后对构造函数Foo下手:

// Foo是个函数对象,构造函数为Function
Foo.__proto__ === Function.prototype
// Function.prototype为普通对象,构造函数为Object,所以有
Function.prototype.__proto__ === Object.prototype

接着对原生构造函数Object创建的o1o2下手:

// o1、o2构造函数为Object
o1.__proto__ === Object.prototype

最后对原生构造函数ObjectFunction下手:

// 原生构造函数也是函数对象,其构造函数为Function
Object.__proto__ === Function.prototype
// 特例
Function.__proto__ === Function.prototype

分析完毕,也没有想象中那么复杂是吧。

如果有内容引起不适,建议从头看一遍,或者去看看参考文章内的文章。

九、举一反三

1. instanceof操作符

平常我们判断一个变量的类型会使用typeof运算符,但是引用类型并不适用,除了函数对象会返回function外,其他都返回object。我们想要知道一个对象的具体类型,就需要使用到instanceof

let fn = function() {}
let arr = []
fn instanceof Function // true
arr instanceof Array // true
fn instanceof Object // true
arr instanceof Object // true

为什么fn instanceof Objectarr instanceof Object都返回true呢?我们来看一下MDN上对于instanceof运算符的描述:

instanceof运算符用于测试构造函数的prototype属性是否出现在对象的原型链中的任何位置

也就是说instanceof操作符左边是一个对象,右边是一个构造函数,在左边对象的原型链上查找,知道找到右边构造函数的prototype属性就返回true,或者查找到顶层null(也就是Object.prototype.__proto__),就返回false
我们模拟实现一下:

function instanceOf(obj, Constructor) { // obj 表示左边的对象,Constructor表示右边的构造函数
    let rightP = Constructor.prototype // 取构造函数显示原型
    let leftP = obj.__proto__ // 取对象隐式原型
    // 到达原型链顶层还未找到则返回false
    if (leftP === null) {
        return false
    }
    // 对象实例的隐式原型等于构造函数显示原型则返回true
    if (leftP === rightP) {
        return true
    }
    // 查找原型链上一层
    return instanceOf(obj.__proto__, Constructor)
}

现在就可以解释一些比较令人费解的结果了:

fn instanceof Object //true
// 1. fn.__proto__ === Function.prototype
// 2. fn.__proto__.__proto__ === Function.prototype.__proto__ === Object.prototype
arr instanceof Object //true
// 1. arr.__proto__ === Array.prototype
// 2. arr.__proto__.__proto__ === Array.prototype.__proto__ === Object.prototype
Object instanceof Object // true
// 1. Object.__proto__ === Function.prototype
// 2. Object.__proto__.__proto__ === Function.prototype.__proto__ === Object.prototype
Function instanceof Function // true
// Function.__proto__ === Function.prototype

总结一下:instanceof运算符用于检查右边构造函数的prototype属性是否出现在左边对象的原型链中的任何位置。其实它表示的是一种原型链继承的关系。

2. Object.create

之前说对象的创建方式主要有两种,一种是new操作符后跟函数调用,另一种是字面量表示法。

其实还有第三种就是ES5提供的Object.create()方法,会创建一个新对象,第一个参数接收一个对象,将会作为新创建对象的原型对象,第二个可选参数是属性描述符(不常用,默认是undefined)。具体请查看Object.create()

我们来模拟一个简易版的Object.create

function createObj(proto) {
    function F() {}
    F.prototype = proto
    return new F()
}

我们平常所说的空对象,其实并不是严格意义上的空对象,它的原型对象指向Object.prototype,还可以继承hasOwnPropertytoStringvalueOf等方法。

如果想要生成一个不继承任何属性的对象,可以使用Object.create(null)

如果想要生成一个平常字面量方法生成的对象,需要将其原型对象指向Object.prototype

let obj = Object.create(Object.prototype)
// 等价于
let obj = {}

3. new操作符

当我们使用new时,做了些什么?

  1. 创建一个全新对象,并将其__proto__属性指向构造函数的prototype属性。
  2. 将构造函数调用的this指向这个新对象,并执行构造函数。
  3. 如果构造函数返回对象类型Object(包含Functoin, Array, Date, RegExg, Error等),则正常返回,否则返回这个新的对象。

依然来模拟实现一下:

function newOperator(func, ...args) {
    if (typeof func !== 'function') {
        console.error('第一个参数必须为函数,您传入的参数为', func)
        return
    }
    // 创建一个全新对象,并将其`__proto__`属性指向构造函数的`prototype`属性
    let newObj = Object.create(func.prototype)
    // 将构造函数调用的this指向这个新对象,并执行构造函数
    let result = func.apply(newObj, args)
    // 如果构造函数返回对象类型Object,则正常返回,否则返回这个新的对象
    return (result instanceof Object) ? result : newObj
}

4. Function.__proto__ === Function.prototype

其实这里完全没必要去纠结鸡生蛋还是蛋生鸡的问题,我自己的理解是:Function是原生构造函数,自动出现在运行环境中,所以不存在自己生成自己。之所以Function.__proto__ === Function.prototype,是为了表明Function作为一个原生构造函数,本身也是一个函数对象,仅此而已。

5. 真的是继承吗?

前面我们讲到每一个对象都会从原型“继承”属性,实际上,继承是一个十分具有迷惑性的说法,引用《你不知道的JavaScript》中的话,就是:

继承意味着复制操作,然而 JavaScript 默认并不会复制对象的属性,相反,JavaScript 只是在两个对象之间创建一个关联,这样,一个对象就可以通过委托访问另一个对象的属性,所以与其叫继承,委托的说法反而更准确些。

十、参考文章

深入理解javascript原型和闭包(完结)- 王福朋

JavaScript深入之从原型到原型链

系列文章

深入ECMAScript系列目录地址(持续更新中...)

欢迎前往阅读系列文章,如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

菜鸟一枚,如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误,与大家共同进步。

执行机制 - 如何在保证页面运行流畅的情况下处理海量数据

如何在保证页面运行流畅的情况下处理海量数据

如何保证流畅

从用户的输入,再到显示器在视觉上给用户的输出,这一过程如果超过100ms,那么用户会察觉到网页的卡顿。

由于JS是单线程的,并且JS线程和UI渲染线程是互斥的,所以保证页面流畅的关键在于避免长耗时任务阻塞主线程

W3C性能工作组在 LongTask规范 中也将超过50ms的任务定义为长任务。50ms这个阈值标准来源于 《RAIL Model》

避免长任务的一种方案是使用Web Worker,将长任务放在Worker线程中执行,缺点是无法访问DOM,另一种方案就是下面要讲的时间切片

时间切片及基础实现

时间切片是一种概念,也可以理解为一种技术方案,核心**是:如果任务不能在规定时间内执行完,那么为了不阻塞主线程,这个任务应该让出主线程的控制权。

我们可以利用Generator 函数可以暂停执行和恢复执行的特性来实现时间切片。

// 任务列表
const tasks = [
  () => 'task1',
  () => 'task2',
  () => 'task3',
]

// Generator函数
function *gen() {
  for (const i in tasks) {
    yield tasks[i]()
  }
}

// 生成迭代器
const g = gen()

// 依次执行任务
g.next() // {value: "task1", done: false}
g.next() // {value: "task2", done: false}
g.next() // {value: "task3", done: false}
g.next() // {value: undefined, done: true}

当然我们也可以只用一只循环来执行任务,但是如果我们要将任务分批执行,还需要手动记录任务执行到了哪一个,一遍下次继续上次的进度执行。使用Generator函数,生成的迭代器内部会记录状态,省去了我们自己记录的麻烦。

使用Generator函数的另一个好处是如果碰到另一个 Generator 函数(假设函数名为foo),可以使用yield* foo()将其融入我们的迭代队列。

function *foo() {
  yield 'foo'
  yield 'bar'
}

function *baz() {
  yield* foo()
  yield 'baz'
}

const g = baz()

g.next() // {value: "foo", done: false}
g.next() // {value: "bar", done: false}
g.next() // {value: "baz", done: false}
g.next() // {value: undefined, done: true}

最终实现

下面是一个基于GeneratorrequestAnimationFrame的通用时间切片函数。

function timeSlice(tasks, during = 50) {
  const g = gen(tasks) // 生成迭代器
  const next = () => {
    const startTime = performance.now()
    let res

    // 未执行完成且执行时间小于单次执行最大时间时,执行下一个任务
    // 否则放入requestAnimationFrame,下次渲染前执行
    // 
    do {
      res = g.next()
    } while (!res.done && performance.now() - startTime < during)

    if (res.done) return
    window.requestAnimationFrame(next)
  }
  window.requestAnimationFrame(next)
}

function *gen(tasks) {
  for (const task of tasks) {
    if (Object.prototype.toString.call(task) === '[object GeneratorFunction]') {
      // `yield`的作用是:当task为Generator函数时,将其执行生成的迭代器嵌套展开
      yield* task()
    } else {
      yield task()
    }
  }
}

优化对比

同步执行

我们先来看一看一段同步代码的执行效果及表现图:

function task() {
  const start = performance.now()
  while (performance.now() - start < 1000) {
    const p = document.createElement('p')
    p.innerText = 'time slicing'
    document.body.appendChild(p)
  }
}
task()

可以清楚的看到,所有的js都执行完毕后才进行渲染,会给用户造成卡顿感。

时间切片

然后看下将任务进行时间切片后的效果:

function *task() {
  const start = performance.now()
  while (performance.now() - start < 1000) {
    const p = document.createElement('p')
    p.innerText = 'time slicing'
    document.body.appendChild(p)
    yield
  }
}

timeSlice([ task ])

可以清楚看到将任务时间切片后,分为多段进行执行渲染,这样可以提升页面响应速度。

海量数据处理也可以采用时间分片的处理方式,可以将执行间隔可以设定得更小(以60帧为准,可设定为16ms),这样就可以基本保证不阻塞主线程、不影响页面流畅性。

其实时间切片并不是将页面总执行/渲染时间减少(相反会增加),而是通过更快地使用户看到变化、更快地响应用户输入来达优化效果。

原型和原型链 - 原型及JavaScript中的原型规则

原型及JavaScript中的原型规则

原型与原型链

原型

JavaScript中,一个对象从被创建开始就和另一个对象关联,从另一个对象上继承其属性,这个另一个对象就是原型

获取原型的方法

  1. 可以通过Object.getPrototypeOf(obj)来获取obj的原型。
  2. 当然对象都是通过构造函数new出来的(字面量对象也可以这么理解),也可以通过访问对应构造函数的prototype属性来获取其原型。
const obj = {}
expect(Object.getPrototypeOf(obj) === Object.prototype).toBe(true)

原型链

当访问一个对象的属性时,先在对象的本身找,找不到就去对象的原型上找,如果还是找不到,就去对象的原型(原型也是对象,也有它自己的原型)的原型上找,如此继续,直到找到为止,或者查找到最顶层的原型对象中也没有找到,就结束查找,返回undefined。这条由对象及其原型组成的链就叫做原型链

特殊原型规则

原型链顶层

普通对象可以理解为Object构造函数创建的,即普通对象的原型都指向Object.prototypeObject.prototype也是个对象,但是其原型比较特殊,为null,是原型链的顶层,切记!!!

expect(Object.getPrototypeOf({})).toBe(Object.prototype)
expect(Object.getPrototypeOf(Object.prototype)).toBe(null)

构造函数的原型

函数,包括构造函数都可理解为由构造函数Function创建,Function本身也不例外。

const getProto = Object.getPrototypeOf
const FuncProto = Function.prototype
expect(getProto(Function)).toBe(FuncProto)
expect(getProto(Object)).toBe(FuncProto)
expect(getProto(Number)).toBe(FuncProto)
expect(getProto(Symbol)).toBe(FuncProto)
expect(getProto(Array)).toBe(FuncProto)

执行机制 - try-catch-finally执行机制

try-catch-finally执行机制

ECMA标准定义

执行过程

  1. 执行 try 中代码,将执行结果标记为Result
  2. try 中执行代码报错,则执行 catch 中代码,并用执行结果更新 Result
  3. 执行 finally 中代码,若有返回值,则用返回值更新 Result,完成执行过程。

主要有以下几个坑点要注意避免:

  • trycatch 中的return语句不影响 finally中代码的执行
  • finally 中有 return 语句,会覆盖 trycatch 中的返回值
/* ------ `finally`中代码一定会执行 ------ */
function test1() {
  try {
    console.log('try')
    throw Error()
  } catch (e) {
    console.log('catch')
    return
  } finally {
    console.log('finally')
  }
}
test1()
// <- try
// <- catch
// <- finally

/* ------ `finally`中代码执行完才算执行完成 ------ */
function test2() {
  try {
    console.log('try_log');
    return 'try_return'
  } finally {
    console.log('finally_log')
  }
}

console.log(test2())
// <- try_log
// <- finally_log
// <- try_return

/* ------ `finally`中的return会覆盖try/catch中的return ------ */
function test3() {
  try {
    return 'try_return'
  } finally {
    return 'finally_return'
  }
}
console.log(test3())
// <- finally_return

function test4() {
  try {
    throw Error()
  } catch (e) {
    return 'catch_return'
  } finally {
    return 'finally_return'
  }
}

console.log(test4())
// <- finally_return

从零到一开发你的专属JavaScript库

前言

本项目jslib-base是一个能让开发者轻松开发属于自己的JavaScript库的基础框架。

灵感来源于颜海镜8102年如何写一个现代的JavaScript库项目链接在此

需求简介

最近在项目中需要对内部一款历史悠久的js库进行改造升级,此库使用iife+ES5的方式编写,日后维护及开发存在诸多不便,随即萌生了搭建一个编写js库的基础框架的想法,正好又看到了颜大的文章,说干就干,最终达到的效果如下:

  • 编写源码支持ES6+和TypeScript
  • 打包后代码支持多环境(支持浏览器原生,支持AMD,CMD,支持Webpack,Rollup,fis等,支持Node)
  • 收敛项目相关配置,目录清晰,上手简单
  • Tree Shaking: 自动剔除第三方依赖无用代码
  • 一键初始化框架
  • 自动生成API文档
  • 集成代码风格校验
  • 集成commit信息校验及增量代码风格校验
  • 集成单元测试及测试覆盖率
  • 集成可持续构建工具与测试结果上报

使用说明

首先克隆仓库至本地并安装依赖:

$ git clone https://github.com/logan70/jslib-base.git
$ cd jslib-base
$ npm install

初始化框架,按照提示填写项目名、变量名及项目地址

$ npm run init

然后就可以在src/文件夹愉快地开发了(可监听变化构建,实时查看效果),开发完成后打包

# 监听构建
$ npm run dev
# 打包构建
$ npm run build

最后就是打包发布:

# 自动修改CHANGLOG及版本信息
$ npm run release
# 登录npm
$ npm login
# 发布npm包
$ npm publish

发布后就可以在各种环境内使用你自己的JavaScript库了:

// 首先npm安装你的js库
$ npm install yourLibName --save-dev

// 浏览器内使用
// 引入文件:<script src="path/to/index.aio.min.js"><script>
yourLibName.xxx(xxx)

// es6模块规范内使用
import yourLibName from 'yourLibName'
yourLibName.xxx(xxx)

// Node内使用
const yourLibName = require('yourLibName')
yourLibName.xxx(xxx)

是不是很简单,更多信息可往下阅读技术实现,也可前往Github项目查看(主要是欢迎Star,哈哈)。

jslib-base 传送门

技术实现

首先要明确的一点是,要做到源码支持ES6+和TypeScript,我们一开始就要做好规划,理想情况是使用者切换只需要修改一处即可,故为项目建立配置文件jslib.config.js

// jslib.config.js
module.exports = {
    srcType: 'js' // 源码类型,js或ts
}

编译打包工具

使用工具:Rollup + Babel + TypeScript

相关文档:

打包工具我选择Rollup,主要因为其强大的Tree Shaking能力以及构建体积优势。

  • Tree Shaking: Rollup仅支持ES6模块,在构建代码时,在使用ES6模块化的代码中,会对你的代码进行静态分析,只打包使用到的代码。
  • 构建体积: Webpack构建后除了业务逻辑代码,还包括代码执行引导及模块关系记录的代码,Rollup构建后则只有业务逻辑代码,构建体积占优,总结就是开发库或框架使用Rollup,应用开发时选择Webpack。

下面我们看看Rollup的使用:

首先安装Rollup及相关插件

$ npm install rollup -D

然后新建一个配置文件build/rollupConfig/rollup.config.aio.js

// build/rollupConfig/rollup.config.aio.js
const  { srcType } = require('../../jslib.config')
export default {
  input: `src/index.${srcType}`, // 入口文件,区分js|ts
  output: {
    file: 'dist/index.aio.js', // 构建文件
    format: 'umd', // 输出格式,umd格式支持浏览器直接引入、AMD、CMD、Node
    name: 'myLib', // umd模块名,在浏览器环境用作全局变量名
    banner: '/* https://github.com/logan70/jslib-base */' // 插入打包后文件的头部内容
  }
}

然后在src/index.js下编写源码:

// src/index.js
export function foo() {
    console.log('Hello world!')
}

然后运行命令进行打包构建:

$ npx rollup --config build/rollupConfig/rollup.config.aio.js

我们来看看打包后的文件dist/index.aio.js

/* https://github.com/logan70/jslib-base */
(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
  typeof define === 'function' && define.amd ? define(['exports'], factory) :
  (global = global || self, factory(global.myLib = {}));
}(this, function (exports) { 'use strict';

  function foo() {
    console.log('Hello world!');
  }

  exports.foo = foo;

  Object.defineProperty(exports, '__esModule', { value: true });

}));

非常完美有木有,我们继续编写:

// src/index.js
// ...
export const add = (num1, num2) => num1 + num2

打包后查看:

// dist/index.aio.js
// ...
function foo() {
  console.log('Hello world!');
}

const add = (num1, num2) => num1 + num2;

exports.foo = foo;
exports.add = add;
// ...

???几个意思小老弟,const和箭头函数什么鬼?

原来是忘记了编译,说到编译我就想到了今年下半年...

直接开花,说到编译当然是大名鼎鼎的Babel,Rollup有Babel的插件,直接安装Babel相关及插件使用:

点击了解更多Babel相关知识

$ npm install @babel/core @babel/preset-env @babel/plugin-transform-runtime   -D
$ npm install @babel/polyfill @babel/runtime -S
$ npm install rollup-plugin-babel rollup-plugin-node-resolve rollup-plugin-commonjs -D
名称 作用
@babel/core Babel核心
@babel/preset-env JS新语法转换
@babel/polyfill 为所有 API 增加兼容方法
@babel/plugin-transform-runtime & @babel/runtime 把帮助类方法从每次使用前定义改为统一 require,精简代码
rollup-plugin-babel Rollup的Babel插件
rollup-plugin-node-resolve Rollup解析外部依赖模块插件
rollup-plugin-commonjs Rollup仅支持ES6模块,此插件是将外部依赖CommonJS模块转换为ES6模块的插件

然后修改Rollup配置:

// build/rollupConfig/rollup.config.aio.js
const babel = require('rollup-plugin-babel')
const nodeResolve = require('rollup-plugin-node-resolve')
const commonjs = require('rollup-plugin-commonjs')
const { srcType } =  require('../../jslib.config')

export default {
  input: `src/index.${srcType}`, // 入口文件
  output: {
    // ...
  },
  plugins: [
    // Rollup解析外部依赖模块插件
    nodeResolve(),
    // Rollup仅支持ES6模块,此插件是将外部依赖CommonJS模块转换为ES6模块的插件
    commonjs({
      include: 'node_modules/**',
    }),
    babel({
      presets: [
        [
          '@babel/preset-env',
          {
            targets: {
              browsers: 'last 2 versions, > 1%, ie >= 6, Android >= 4, iOS >= 6, and_uc > 9',
              node: '0.10'
            },
            // 是否将ES6模块转为CommonJS模块,必须为false
            // 否则 Babel 会在 Rollup 有机会做处理之前,将我们的模块转成 CommonJS,导致 Rollup 的一些处理失败
            // 例如rollup-plugin-commonjs插件,将 CommonJS 转换成 ES6 模块
            modules: false,
            // 松散模式,源码不同时使用export和export default时可开启,更好兼容ie8以下
            loose: false,
            // 按需进行polyfill
            useBuiltIns: 'usage'
          }
        ]
      ],
      plugins: ['@babel/plugin-transform-runtime'],
      runtimeHelpers: true,
      exclude: 'node_modules/**'
    })
  ]
}

再次打包后查看:

// dist/index.aio.js
// ...
function foo() {
  console.log('Hello world!');
}
var add = function add(num1, num2) {
  return num1 + num2;
};

exports.foo = foo;
exports.add = add;
// ...

完事儿收工!接下来就是解决TypeScript的支持了,首先安装依赖:

$ npm install typescript rollup-plugin-typescript2 -D
名称 作用
typescript typescript核心
rollup-plugin-typescript2 rollup编译typeScript的插件

然后创建TypeScript编译配置文件tsconfig.json

// tsconfig.json
{
    "compilerOptions": {
        "target": "ES5",
        "module": "ES6",
        "lib": ["esnext", "dom"],
        "esModuleInterop": true
    },
    "include": [
        "src/**/*.ts"
    ],
    "exclude": [
        "node_modules",
        "**.d.ts"
    ]
}

由于Rollup编译插件会根据源码类型动态切换,所以我们创建文件build/rollupConfig/getCompiler.js用来动态导出Rollup编译插件:

// build/rollupConfig/getCompiler.js 
const babel = require('rollup-plugin-babel')
const typeScript = require('rollup-plugin-typescript2')
const { srcType } = require('../../jslib.config')

const jsCompiler = babel({
  // ...
})

const tsCompiler = typeScript({
  // 覆盖tsconfig.json的配置,rollup仅支持ES6模块
  tsconfigOverride: {
    compilerOptions : { module: 'ES6', target: 'ES5' }
  }
})

module.exports = () => srcType === 'js' ? jsCompiler : tsCompiler

然后修改Rollup配置文件:

const nodeResolve = require('rollup-plugin-node-resolve')
const commonjs = require('rollup-plugin-commonjs')
const getCompiler = require('./getCompiler')
const { srcType } =  require('./jslib.config')

export default {
  input: `src/index.${srcType}`, // 入口文件
  output: {
    // ...
  },
  plugins: [
    nodeResolve(),
    commonjs({
      include: 'node_modules/**',
    }),
    getCompiler()
  ]
}

然后创建src/index.ts编写源码:

export function foo(): void {
  console.log('Hello world!')
}

export const add: (num1: number, num2: number) => number
  = (num1: number, num2: number): number => num1 + num2

记得修改jslib.config.js中的源码类型为ts

module.exports = {
  srcType: 'ts' // 源码类型,js|ts
}

然后运行打包命令查看输出文件dist/index.aio.js,发现打包结果完全一样,大功告成!

多环境支持

使用工具:

  • semver: 检查Node版本工具 - semver
  • minimist: 解析命令行参数工具 - minimist

考虑到要支持多环境,所以要打包多种格式文件,但是如果使用npx rollup --config 1.js && npx rollup --config 2.js这种构建方式,其实是串行构建,效率低,所以使用Rollup提供的Node API结合Promise.all来充分利用js的异步特性,提升构建效率。

工欲善其事,必先利其器。考虑到之后别的命令可能也会使用Node来完成,所以我们先实现一个自己的CLI。

由于我们接下来编写时会用到JS新特性,所以要求版本大于8,我们使用semver工具:

$ npm install semver -D

然后新建build/index.js文件作为我们的Node运行入口:

// build/index.js
const semver = require('semver')
const requiredVersion = '>=8'
// check node version
if (!semver.satisfies(process.version, requiredVersion)) {
  console.error(
    `You are using Node ${process.version}, but @logan/jslib-base ` +
    `requires Node ${requiredVersion}.\nPlease upgrade your Node version.`
  )
  process.exit(1)
}

然后我们将Node版本切换到7进行测试:

测试OK,接下来需要实现根据命令行参数不同执行不同任务的功能,我们解析命令行用到minimist

$ npm install minimist -D

minimist的使用很简单:

const args = require('minimist')(process.argv.slice(2))
console.log(args)

我们来看看效果:

我想大家已经明白怎么使用了,下面继续编写Node入口:

// build/index.js
// ...
// 解析命令行参数
const args = require('minimist')(process.argv.slice(2))
// 取出第一个作为命令
const command = args._[0]
// 从args._中删除命令
args._.shift()

function run(command, args) {
  // 动态加载命令执行文件
  const runner = require(`./command-${command}/index`)
  // 将args作为参数传入并执行对应任务函数
  runner(args)
}

run(command, args)

之后如果我们想添加任务,只需要创建command-${任务名称}文件夹,在文件夹下的index.js中编写代码,然后在package.json添加对应的script命令即可。

我们先在package.json中添加构建命令:

// package.json
{
  "scripts": {
    ...
    "build": "node build/index.js build",
    ...
  }
}

然后创建build/command-build/index.js编写执行构建任务的代码:

其他输出格式的Rollup配置文件以及Rollup提供的Node API的使用不做详解,感兴趣的朋友自行了解

const path = require('path')
const rollup = require('rollup')

// 不同环境配置文件映射
const rollupConfigMap = {
  // UMD格式
  aio: 'rollup.config.aio.js',
  // UMD格式压缩版
  aioMin: 'rollup.config.aio.min.js',
  // ES6模块格式
  esm: 'rollup.config.esm.js',
  // CommonJS格式
  cjs: 'rollup.config.js'
}

// 单个rollup构建任务
function runRollup(configFile) {
  return new Promise(async (resolve) => {
    // 根据配置文件名引入rollup配置
    const options = require(path.resolve(__dirname, '../rollupConfig', configFile))
    // 创建rollup任务
    const bundle = await rollup.rollup(options.inputOption)
    // 构建文件
    await bundle.write(options.outputOption)
    console.log(`${options.outputOption.file} 构建成功`)
    resolve()
  })
}

module.exports = async (args = {}) => {
  // 要构建的格式数组
  const moduleTypes = args._

  // 目的在于支持选择要构建的类型
  // 例如 node build/index.js build esm cjs 则只构建es6模块格式和commonjs格式文件
  // 不传则全部构建
  const configFiles = moduleTypes && moduleTypes.length
    ? moduleTypes.map(moduleKey => rollupConfigMap[moduleKey])
    : Object.values(rollupConfigMap)

  try {
    // 并行构建(伪,JS单线程)
    await Promise.all(configFiles.map(file => runRollup(file)))
  } catch (e) {
    throw new Error(e)
  }
}

然后我们运行构建命令npm run build查看效果:

代码风格检查

使用工具:

首先安装依赖:

$ npm install eslint eslint-config-airbnb eslint-plugin-import -D

配置ESLint校验规则文件.eslintrc.js,详细过程略,详情请前往上方官网了解。

JavaScript代码使用Airbnb JavaScript 风格作为基础,配合无分号规则(可视个人/团队偏好修改)来校验代码风格。

配置TSLint校验规则文件tslint.json,详细过程略,详情请前往上方官网了解。

TypeScript代码使用默认规则,配合单引号、无分号规则(可视个人/团队偏好修改)来校验代码风格。

然后在package.json中添加校验命令:

// package.json
{
  "scripts": {
    ...
    "lint": "node build/index.js lint",
    "lint:fix": "node build/index.js lint --fix",
    ...
  }
}

然后创建build/command-jslint/index.js编写执行构建任务的代码:

// build/command-jslint/index.js
// Node自带子进程方法
const { spawn } = require('child_process')
const { srcType } = require('../../jslib.config')

module.exports = async (args = {}) => {
  const options = [
    // 要校验的文件,glob匹配
    `src/**/*.${srcType}`,
    // 错误输出格式,个人喜欢codeframe风格,信息比较详细
    '--format', 'codeframe'
  ]
  // 是否需要自动修复,npm run lint:fix 启用
  if (args.fix) {
    options.push('--fix')
  }
  // 要使用的lint工具
  const linter = srcType === 'js' ? 'eslint' : 'tslint'
  // 开启子进程
  spawn(
    linter,
    options,
    // 信息输出至主进程
    { stdio: 'inherit' }
  )
}

然后我们来测试一下:

JavaScript代码风格检查及修复:

TypeScript代码风格检查及修复:

自动生成API文档

使用工具

首先安装依赖:

$ npm install jsdoc typedoc typedoc-plugin-external-module-name -D

配置JSDoc文件build/command-doc/jsdocConf.js,详细过程略,详情请前往上方官网了解。

配置TypeDoc文件build/command-doc/tsdocConf.js,详细过程略,详情请前往上方官网了解。

然后在package.json中添加生成API文档的命令:

// package.json
{
  "scripts": {
    ...
    "doc": "node build/index.js doc",
    ...
  }
}

然后创建build/command-doc/index.js编写执行生成API文档任务的代码:

// build/command-jslint/index.js
// Node自带子进程方法
const { spawn } = require('child_process')
const path = require('path')
const TypeDoc = require('typedoc')
const { srcType } = require('../../jslib.config')

module.exports = async (args = {}) => {
  if (srcType === 'js') {
    spawn('jsdoc', ['-c', path.resolve(__dirname, './jsdocConf.js')], { stdio: 'inherit' })
    resolve()
  } else {
    // 引入tsdoc配置
    const tsdocConf = require(path.resolve(__dirname, './tsdocConf'))
    // 初始化任务,详见typedoc官网
    const app = new TypeDoc.Application(tsdocConf)
    const project = app.convert(app.expandInputFiles(['src']))
    if (project) {
      const outputDir = tsdocConf.outputDir
      // 输出文档
      app.generateDocs(project, outputDir)
    }
  }
}

然后我们在源码内添加JavaScript规范化注释,相关注释标准也可前往JSDoc官网查看:

// src/index.js
/**
 * @module umdName
 * @description JavaScript库 - umdName
 * @see https://github.com/logan70/jslib-base
 * @example
 * // 浏览器内使用
 * // 引入文件:<script src="path/to/index.aio.min.js"><script>
 * window.umdName.add(1, 2)
 *
 * // es6模块规范内使用
 * import umdName from '@logan/jslib-base'
 * umdName.add(1, 2)
 *
 * // Node内使用
 * const umdName = require('@logan/jslib-base')
 * umdName.add(1, 2)
 */
/**
 * @description 加法函数
 * @method add
 * @memberof module:umdName
 * @param {Number} num1 - 加数
 * @param {Number} num2 - 被加数
 * @return {Number} - 两数相加结果
 * @example
 * umdName.add('Hello World!')
 */
export const add = (num1, num2) => num1 + num2

然后将jslib.config.js中的源码类型修改为js,运行命令npm run doc,打开docs/index.html查看效果:

效果如上图所示,然后我们在源码内添加TypeScript规范化注释,相关注释标准也可前往TypeDoc官网查看:

注意:TypeDoc不支持@example标签,但是支持MarkDown语法,所以我们可以将代码实例写在md标签内

/**
 * @module umdName
 * @description JavaScript库 - umdName
 * @see https://github.com/logan70/jslib-base
 * @example
 * ```js
 *
 * // 浏览器内使用
 * // 引入文件:<script src="path/to/index.aio.min.js"><script>
 * window.umdName.add(1, 2)
 *
 * // es6模块规范内使用
 * import umdName from '@logan/jslib-base'
 * umdName.add(1, 2)
 *
 * // Node内使用
 * const umdName = require('@logan/jslib-base')
 * umdName.add(1, 2)
 * ```
 */

/**
 * @description 加法函数
 * @param num1 - 加数
 * @param num2 - 被加数
 * @returns 两数相加结果
 * @example
 * ```js
 *
 * umdName.add(1, 2)
 * ```
 */
export const add: (num1: number, num2: number) => number
  = (num1: number, num2: number): number => num1 + num2

然后将jslib.config.js中的源码类型修改为ts,运行命令npm run doc,打开docs/index.html查看效果:

单元测试及测试覆盖率

使用工具

首先安装依赖:

$ npm install jest babel-jest ts-jest @type/jest -D

编写jest文件build/command-test/jest.config.js

// build/command-test/jest.config.js
const path = require('path')

module.exports = {
  // 根路径,指向项目根路径
  rootDir: path.resolve(__dirname, '../../'),
  // jest寻找的路径数组,添加项目根路径
  "roots": [
    path.resolve(__dirname, '../../')
  ],
  // ts-jest用于支持typescript, babel-jest用于支持ES6模块化语法
  "transform": {
    "^.+\\.tsx?$": "ts-jest",
    "^.+\\.jsx?$": "babel-jest"
  },
  // 测试文件匹配正则
  "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$",
  // 测试文件内可省略的文件后缀
  "moduleFileExtensions": ["ts", "js"],
  // 显示测试内容
  "verbose": true
}

然后在package.json中添加生成API文档的命令,npm run test:coverage命令为单元测试并收集测试覆盖信息的命令,测试覆盖信息后面会讲到:

// package.json
{
  "scripts": {
    ...
    "test": "node build/index.js test",
    "test:coverage": "node build/index.js test --coverage",
    ...
  }
}

由于JS新语法特性支持需要Babel编译,我们创建并编写Babel配置文件.babelrc:

// .babelrc
{
  "presets": ["@babel/preset-env"]
}

然后创建build/command-test/index.js编写执行单元测试任务的代码:

// build/command-test/index.js
const { spawnSync } = require('child_process')

module.exports = (args = {}) => {
  return new Promise(resolve => {
    // 指定jest配置文件
    const cliOptions = ['--config', 'build/command-test/jest.config.js']
    // 是否收集测试覆盖率信息
    if (args.coverage) {
      cliOptions.push('--collectCoverage')
    }
    spawnSync('jest', cliOptions, {
      stdio: 'inherit'
    })
    resolve()
  })
}

然后我们在项目根目录下新建__tests__文件夹编写单元测试用例,更多单元测试知识请前往Jest中文文档学习:

// __tests__/index.test.js
import { add } from '../src/index.js'

describe('单元测试(js)', () => {
  it('1加2等于3', () => {
    expect(add(1, 2)).toEqual(3)
  })
})

// __tests__/index.test.ts
import { add } from '../src/index'

describe('单元测试(ts)', () => {
  it('1加2等于3', () => {
    expect(add(1, 2)).toEqual(3)
  })
})

然后运行命令npm run test查看效果:

然后我们来运行命令npm run test:coverage查看测试覆盖率信息:

在浏览器中打开coverage/lcov-report/index.html也可查看测试覆盖率信息:

帮助信息

使用工具

  • chalk: 命令行着色工具 - chalk
  • ascii-art: 字符换生成工具 - ascii-art

首先安装依赖:

$ npm install chalk ascii-art -D

然后在package.json中添加显示帮助信息的命令:

// package.json
{
  "scripts": {
    ...
    "help": "node build/index.js help",
    ...
  }
}

然后创建build/command-help/index.js编写执行输出帮助信息任务的代码:

const art = require('ascii-art')
const chalk = require('chalk')

module.exports = () => {
  return new Promise(resolve => {
    // 生成字符画
    art.font('@logan\/jslib\-base', 'Doom', data => {
      console.log(chalk.cyan(('-').repeat(104)))
      console.log(chalk.cyan(data))
      console.log(chalk.cyan(('-').repeat(104)))
      console.log()
      console.log('Usage: npm run <command>')
      console.log()
      console.log('A good JavaScript library scaffold.')
      console.log()
      console.log('Commands:')
      console.log('  npm run init, initialize this scaffold.')
      console.log('  npm run build, output bundle files of three different types(UMD, ES6, CommonJs).')
      console.log('  npm run dev, select a type of output to watch and rebuild on change.')
      console.log('  npm run lint, lint your code with ESLint/TSLint.')
      console.log('  npm run lint:fix, lint your code and fix errors and warnings that can be auto-fixed.')
      console.log('  npm run doc, generate API documents based on good documentation comments in source code.')
      console.log('  npm run test, test your code with Jest.')
      console.log('  npm run test:coverage, test your code and collect coverage information with Jest.')
      console.log('  npm run help, output usage information.')
      console.log()
      console.log(`See more details at ${chalk.cyan('https://github.com/logan70/jslib-base')}`)
      resolve()
    })
  })
}

然后我们运行命令npm run help查看效果:

一键重命名

实现一键重命名的思路是:获取用户输入的信息,然后把相关文件内占位符替换为用户输入信息即可。

使用工具

  • inquirer: 交互式命令行工具 - inquirer

首先安装依赖:

$ npm install inquirer -D

然后在package.json中添加初始化脚手架的命令:

// package.json
{
  "scripts": {
    ...
    "init": "node build/index.js init",
    ...
  }
}

inquirer使用方法前往inquirer文档学习,这里不做赘述,直接上代码。

然后创建build/command-init/index.js编写执行初始化脚手架任务的代码:

// build/command-init/index.js
const fs = require('fs')
const path = require('path')
const inquirer = require('inquirer')
// 显示帮助信息
const runHelp = require('../command-help/index')

// inquirer要执行的任务队列
const promptArr = []
// 获取UMD格式输出名
promptArr.push({
  type: 'input',
  name: 'umdName',
  // 提示信息
  message: 'Enter the name for umd export (used as global varible name in browsers):',
  // 校验用户输入
  validate(name) {
    if (/^[a-zA-Z][\w\.]*$/.test(name)) {
      return true
    } else {
      // 校验失败提示信息
      return `Invalid varible name: ${name}!`
    }
  }
})
// 获取项目名
promptArr.push({
  type: 'input',
  name: 'libName',
  message: 'Enter the name of your project (used as npm package name):',
  validate(name) {
    if (/^[a-zA-Z@][\w-]*\/?[\w-]*$/.test(name)) {
      return true
    } else {
      return `Invalid project name: ${name}!`
    }
  }
})
// 获取项目地址
promptArr.push({
  type: 'input',
  name: 'repoUrl',
  default: 'https://github.com/logan70/jslib-base',
  message: 'Enter the url of your repository:',
  validate(url) {
    if (/^https?\:\/\/[0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*(:(0-9)*)*(\/?)([a-zA-Z0-9\-\.\?\,\'\/\\\+&amp;%\$#_]*)?$/.test(url)) {
      return true
    } else {
      return `Invalid repository url: ${url}!`
    }
  }
})

module.exports = (args = {}) => {
  return new Promise(async (resolve, reject) => {
    // 获取用户输入
    const { umdName, libName, repoUrl } = await inquirer.prompt(promptArr)

    // 需要修改的文件
    let files = [
      'jslib.config.js',
      'package.json',
      'package-lock.json',
      'README.md'
    ]

    try {
      await Promise.all(files.map((file) => new Promise((resolve, reject) => {
        const filePath = path.resolve(__dirname, '../../', file)
        // 读取文件
        fs.readFile(filePath, 'utf8', function (err, data) {
          if (err) {
            reject(err)
            return
          }
          // 替换占位符
          const result = data
            .replace(/umdName/g, umdName)
            .replace(/@logan\/jslib\-base/g, libName)
            .replace(/https:\/\/github\.com\/logan70\/jslib/g, repoUrl)
        
          // 重写文件
          fs.writeFile(filePath, result, 'utf8', (err) => {
             if (err) {
               reject(err)
               return
             }
             resolve()
          })
        })
      })))
      // 显示帮助信息
      await runHelp()
    } catch (e) {
      throw new Error(e)
    }
  })
}

然后我们运行命令npm run init查看效果:

watch监听构建模式

实现监听构建的思路是:用户选择一种输出格式,使用Rollup提供的Node API开启watch模式。

首先在package.json中添加初始化脚手架的命令:

// package.json
{
  "scripts": {
    ...
    "dev": "node build/index.js watch",
    ...
  }
}

然后创建build/command-watch/index.js编写执行监听构建任务的代码:

const path = require('path')
const rollup = require('rollup')
const inquirer = require('inquirer')

const { srcType } = require('../../jslib.config')

// rollup 监听配置
const watchOption = {
  // 使用chokidar替换原生文件变化监听的工具
  chokidar: true,
  include: 'src/**',
  exclude: 'node_modules/**'
}

// 用户选择一种输出格式
const promptArr = [{
  type: 'list',
  name: 'configFile',
  message: 'Select an output type to watch and rebuild on change:',
  default: 'rollup.config.aio.js',
  choices: [{
    value: 'rollup.config.aio.js',
    name: 'UMD - dist/index.aio.js (Used in browsers, AMD, CMD.)'
  }, {
    value: 'rollup.config.esm.js',
    name: 'ES6 - dist/index.esm.js (Used in ES6 Modules)'
  }, {
    value: 'rollup.config.js',
    name: 'CommonJS - dist/index.js (Used in Node)'
  }]
}]

module.exports = (args = {}) => {
  return new Promise((resolve, reject) => {
    // 获取用户选择的输出格式
    inquirer.prompt(promptArr).then(({ configFile }) => {
      // 对应输出格式的rollup配置
      const customOptions = require(path.resolve(__dirname, '../rollConfig/', configFile))
      const options = {
        ...customOptions.inputOption,
        output: customOptions.outputOption,
        watch: watchOption
      }

      // 开始监听
      const watcher = rollup.watch(options)

      // 监听阶段时间处理
      watcher.on('event', async (event) => {
        if (event.code === 'START') {
          console.log('正在构建...')
        } else if (event.code === 'END') {
          console.log('构建完成。')
        }
      })
    })
  })
}

然后我们运行命令npm run dev查看效果:

规范Git提交信息

使用工具

  • husky: Git钩子工具 - husky
  • @commitlint/config-conventional 和 @commitlint/cli: Git commit信息校验工具 - commitlint
  • commitizen: 撰写合格 Commit message 的工具。 - commitizen
  • lint-staged: 增量校验代码风格工具 - lint-staged

具体使用方法前往上方文档自行学习。

首先安装依赖:

$ npm install husky @commitlint/config-conventional @commitlint/cli commitizen lint-staged -D

然后在package.json中添加以下信息:

// package.json
{
  "scripts": {
    ...
    "husky": {
        "hooks": {
          "pre-commit": "lint-staged",
          "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
        }
      },
      "lint-staged": {
        "src/**/*.js": [
          "eslint --fix",
          "git add"
        ],
        "src/**/*.ts": [
          "tslint --fix",
          "git add"
        ]
      },
      "commitlint": {
        "extends": [
          "@commitlint/config-conventional"
      ]
    },
    ...
  }
}

配置完成后我们来看看效果:

与预期相符,关于Git commit信息规范推荐阅读阮一峰老师的文章

这么多信息规范,记不住或者不想记怎么办,没关系,commitizen是一个撰写合格 Commit message 的工具。

之前我们已经安装过依赖,我们运行下面的命令使其支持 Angular 的 Commit message 格式。

$ commitizen init cz-conventional-changelog --save --save-exact

然后在package.json中添加Git提交的命令:

// package.json
{
  "scripts": {
    ...
    "commit": "npx git-cz",
    ...
  }
}

之后本项目凡是用到git commit命令,一律替换为npm run commit命令即可,我们看下效果:

这边还要介绍一个根据commit信息自动生成CHANGELOG并更新版本信息的工具 - standard-version

我们先安装依赖:

$ npm install standard-version

然后在package.json中添加release命令:

// package.json
{
  "scripts": {
    ...
    "release": "standard-version",
    ...
  }
}

之后要发布新版本时,可以运行命令npm run release来根据Git Commit历史自动更新CHANGELOG.md和版本信息。

持续集成

使用工具

  • travis-ci: 持续集成工具 - travis-ci
  • codecov: 测试结果分析工具 - codecov

使用方法非常简单,首先使用Github账号分别登录Travis-CI和Codecov,添加你的Github项目。

然后安装依赖:

$ npm install codecov -D

然后在package.json中添加codecov命令:

// package.json
{
  "scripts": {
    ...
    "codecov": "codecov",
    ...
  }
}

然后在项目跟目下下创建travis-ci配置文件.travis.yml:

language: node_js            # 指定运行环境为node

node_js:                     # 指定nodejs版本为8
  - "8"

cache:				               # 缓存 node_js 依赖,提升第二次构建的效率
  directories:
  - node_modules


script:                      # 运行的脚本命令
  - npm run test:coverage    # 单元测试并收集测试覆盖信息
  - npm run codecov          # 将单元测试结果上传到codecov

我们编写好源码及单元测试,推送到远程仓库,然后去查看结果:

Travis-CI:

Codecov:

README徽章

终于来到最后环节,README的编写没什么好说的,各有各的风格。

主要来说说README徽章,毕竟费了好大劲儿,不装个X怎么能行。

建议阅读GitHub 项目徽章的添加和设置

Tarvis-CI的徽章点击项目名称右侧徽章即可获得:

Codecov的徽章在项目Settings选项的Badge栏内:

拷贝Markdown格式的徽章内容,粘贴进README.md即可,效果如下:

总结

这段时间的辛苦总算是没有白费,过程中学到了很多东西,看到这里的帅哥美女们,就别吝啬了,给个Star呗!

Github项目传送门

Github博客传送门

[译] 图解Event Loop

原文《JavaScript Visualized: Event Loop》 - By Lydia Hallie
本文主要通过生动形象的动图讲解事件循环的一些基本概念,主要面向初学者,译者已取得原作者的同意。本文一些部分采用意译,以帮助大家更好地理解。

本文首发于个人博客 Logan's Blog,其他JS相关内容也可前往小弟博客共同学习探讨。

事件循环(Event Loop),是每个JS开发者都会接触到的概念,但是刚接触时可能会存在各种疑惑。我是一个视觉型学习者,所以打算通过gif动图的可视化形式帮助大家理解它。

首先我们来看看,什么是事件循环,我们为什么要了解它呢?

众所周知,JavaScript是 单线程(single-threaded) 的,也就是同一时间只能运行一个任务。一般情况下这并没有什么问题,但是假如我们要运行一个耗时30秒的任务,我们就得等待30秒后才能执行下一个任务(这30秒期间,JavaScript占用了主线程,我们什么都不能做,包括页面也是卡死状态)。这都9012年了,不带这么坑爹的吧?

好在浏览器向我们提供了JS引擎不具备的特性:Web APIWeb API包括DOM API定时器HTTP请求等特性,可以帮助我们实现异步、非阻塞的行为。

当我们调用一个函数时,函数会被放入一个叫做调用栈(call stack,也叫执行上下文栈)的地方。调用栈是JS引擎的一部分,并非浏览器特有的。调用栈是一个栈数据结构,具有后进先出的特点(Last in, first out. LIFO)。当函数执行完毕返回时,会被弹出调用栈。

图例中的respond函数返回一个setTimeout函数调用,setTimeout函数是Web API提供给我们的功能:它允许我们延迟执行一个任务而不用阻塞主线程。setTimeout被调用时,我们传入的回调函数,即箭头函数() => { return 'hey' }会被传递给Web API处理,然后setTimeoutrespond依次执行完毕出栈。

Web API中会执行定时器,定时间隔就是我们传入setTimeout的第二个参数,也就是1000ms。计时结束后回调函数并不会立即进入调用栈执行,而是会被加入一个叫做 任务队列(Task Queue) 的地方。

看到这里,有些人可能会疑惑:1000ms之后,回调竟然没有放入调用栈执行,而是被放入了任务队列,那什么时候被执行呢?不要急,既然是一个队列,那就要排排坐,吃果果。

接下来就是我们期待已久,万众瞩目的 事件循环(Event Loop) 闪亮登场的时刻了。Event Loop的工作就是连接任务队列和调用栈,当调用栈中的任务均执行完毕出栈,调用栈为空时,Event Loop会检查任务队列中是否存在等待执行的任务,如果存在,则取出队列中第一个任务,放入调用栈。

我们的回调函数被放入调用栈中,执行完毕,返回其返回值,然后被弹出调用栈。

阅读一时爽,但只有通过反复练习,将其变为自己的东西后才会一直爽。我们来做个小练习检测下学习成果,看看下面代码输出什么:

const foo = () => console.log('First');
const bar = () => setTimeout(() => console.log('Second'), 500);
const baz = () => console.log('Third');

bar();
foo();
baz();

相信大家都可以轻松给出正确答案。我们一起来看下这段代码运行时发生了什么:

  1. bar被调用,返回setTimeout的调用;
  2. 传入setTimeout的回调被传递给Web API处理,setTimeout执行完毕出栈,bar执行完毕出栈;
  3. 定时器开始运行,同时主线程中foo被调用,打印Firstfoo执行完毕出栈;
  4. baz被调用,打印Thirdbaz执行完毕出栈;
  5. 500ms后定时器运行完毕,回调函数被放入任务队列;
  6. Event Loop检测到调用栈为空,从任务队列中取出回调函数放入调用栈;
  7. 回调函数被执行,打印Second,执行完毕出栈。

希望本文能帮助你对事件循环有一个初步的了解,如果还有疑惑,可留言交流探讨。

作用域与闭包 - 闭包的实现原理和作用

闭包的实现原理和作用

闭包是什么

MDN对闭包描述如下:

A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment).

闭包是函数创建函数的环境的组合。

标准 可知,JavaScript中函数被创建时都会记录当前词法环境,所以说JavaScript中,每次创建函数,都会生成闭包。

闭包的作用

从技术层面说,静态作用域(词法作用域)是通过闭包实现的。

  1. 函数被创建时会记录所处的词法环境;
  2. 函数被调用时会创建新的词法环境(其中包含一个队外部词法环境的引用),并将外部词法环境引用指向函数创建时所记录的词法环境;
  3. 函数内使用自由变量(不在函数内定义的变量)时,沿着由词法环境组成的作用域链寻找解析。

这样就实现了静态作用域(词法作用域),也就是在编写代码时就能确定变量的解析过程。

我们常说的闭包是什么

我们常说的闭包,也可以说是“有意义”的闭包,具备以下两点特征:

  1. 函数创建时所在的上下文销毁后,该函数仍然存在;
  2. 函数内引用自由变量。

最常见的闭包就是父函数内返回一个函数,返回函数内引用了父函数内变量:

const scope = 'outer'
const genClosure = () => {
  const scope = 'local'
  return () => {
    console.log(scope)
  }
}

const closure = genClosure()
closure()
// <- 'local'

闭包的应用

任何关于闭包的应用总结起来都离不开以下两点:

  • 创建私有变量,隐藏实现细节
  • 延长变量声明周期

函数柯里化和偏函数

柯里化:把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数。

// 柯里化前
const getVolume = (l, w, h) => l * w * h
const volume1 = getVolume(100, 200, 100)
const volume2 = getVolume(100, 200, 300)

// 柯里化后
const getVolume = l => w => h => l * w * h
const getVolumeWithDefaultLW = getVolume(100)(200)
const volume1 = getVolumeWithDefaultLW(100)
const volume2 = getVolumeWithDefaultLW(300)

模块化

用于将内部实现封装,仅对外暴露接口,常见于工具库的开发中。

var counter = (function() {
  var privateCounter = 0
  function changeBy(val) {
    privateCounter += val
  }
  return {
    increment: function() {
      changeBy(1)
    },
    decrement: function() {
      changeBy(-1)
    },
    value: function() {
      return privateCounter
    }
  }
})()

模拟块级作用域

最典型的就是ES6之前for循环中使用定时器延迟打印的问题。

for (var i = 1; i <= 3; i++) {
	setTimeout(function() {
		console.log(i)
	}, i * 1000)
}
// <- 4
// <- 4
// <- 4

使用立即执行函数,将i作为参数传入,可保存变量i的实时值。

for(var i = 1; i <= 3; i++){
  (i => {
    setTimeout(() => {
      console.log(i)
    }, i * 1000)
  })(i)
}
// <- 1
// <- 2
// <- 3

语法和API - 实现数组方法(上)

实现数组方法(上)

所有数组方法的实现均忽略参数校验、边界条件判断,主要关注核心逻辑的实现。

部分数组方法会基于Array.prototype.reduce方法来实现,关于reduce方法的讲解及实现详见彻底搞懂数组reduce方法

Array.prototype.concat()

MDN - Array.prototype.concat()

concat() 方法用于合并两个或多个数组。此方法不会更改现有数组,而是返回一个新数组。

Array.prototype._concat = function(...arrs) {
  return arrs.reduce((newArr, cur) => {
    return Array.isArray(cur)
      ? [...newArr, ...cur] // 传入项为数组则展开后合并
      : [...newArr, cur] // 传入项非数组则直接合并
  }, [...this])
}

Array.prototype.copyWithin()

MDN - Array.prototype.copyWithin()

copyWithin() 方法浅复制数组的一部分到同一数组中的另一个位置,并返回它,不会改变原数组的长度。

Array.prototype._copyWithin = function(target, start = 0, end = this.length) {
  const len = this.length
  const iTarget = target < 0
    ? Math.max(target + len, 0) // target为负从末尾计算,小于0时取0
    : Math.min(target, len) // target不为负时,若大于数组长度,取数组长度
  const iStart = start < 0
    ? Math.max(start + len, 0) // start为负从末尾计算,小于0时取0
    : Math.min(start, len) // start不为负时,若大于数组长度,取数组长度
  const iEnd = end < 0
    ? Math.max(end + len, 0) // end为负从末尾计算,小于0时取0
    : Math.min(end, len) // end不为负时,若大于数组长度,取数组长度
  
  const count = Math.min(iEnd - iStart, len - iTarget)
  // 这样实现便于理解,不考虑性能
  if (count > 0) {
    const copy = this.slice(iStart, iStart + count)
    this.splice(iTarget, count, ...copy)
  }
  return this
}

Array.prototype.entries()

MDN - Array.prototype.entries()

entries() 方法返回一个新的Array Iterator对象,该对象包含数组中每个索引的键/值对。

Array.prototype._entries = function() {
  function *gen() {
    for (let i = 0; i < this.length; i++) {
      yield [i, this[i]]
    }
  }
  return gen.call(this)
}

Array.prototype.every()

MDN - Array.prototype.every()

every() 方法测试一个数组内的所有元素是否都能通过某个指定函数的测试。它返回一个布尔值。若收到一个空数组,此方法在一切情况下都会返回 true。

every具有短路特性,一旦某次迭代返回false,则跳过后序迭代,直接返回false

Array.prototype._every = function(callback, thisArg) {
  for (let i = 0; i < this.length; i++) {
    if (i in this && !callback.call(thisArg, this[i], i, this)) {
      return false
    }
  }
  return true
}

Array.prototype.fill()

MDN - Array.prototype.fill()

fill() 方法用一个固定值填充一个数组中从起始索引到终止索引内的全部元素。不包括终止索引。

Array.prototype._fill = function(value, start = 0, end = this.length) {
  const len = this.length
  // 开始/结束索引 为负/超出范围处理
  let iStart = start < 0 ?
    Math.max(len + start, 0) :
    Math.min(start, len)
  const iEnd = end < 0 ?
    Math.max(len + end, 0) :
    Math.min(end, len)
  
  while (iStart < iEnd) {
    this[iStart] = value
    iStart++
  }
  return this
}

Array.prototype.filter()

MDN - Array.prototype.filter()

filter() 方法创建一个新数组, 其包含通过所提供函数实现的测试的所有元素。 使用reduce实现。

Array.prototype._filter = function(callback, thisArg) {
  return this.reduce((acc, cur, i, arr) => {
    return callback.call(thisArg, cur, i, arr)
      ? [...acc, cur]
      : acc
  }, [])
}

Array.prototype.find()

MDN - Array.prototype.find()

find() 方法返回数组中满足提供的测试函数的第一个元素的值。否则返回 undefined

Array.prototype._find = function(callback, thisArg) {
  for (let i = 0; i < this.length; i++) {
    if(callback.call(thisArg, this[i], i, this)) {
      return this[i]
    }
  }
  return undefined
}

Array.prototype.findIndex()

MDN - Array.prototype.findIndex()

findIndex() 方法返回数组中满足提供的测试函数的第一个元素的索引。否则返回-1。

Array.prototype._findIndex = function(callback, thisArg) {
  for (let i = 0; i < this.length; i++) {
    if(callback.call(thisArg, this[i], i, this)) {
      return i
    }
  }
  return -1
}

Array.prototype.flat()

MDN - Array.prototype.flat()

flat() 方法会按照一个可指定的深度递归遍历数组,并将所有元素与遍历到的子数组中的元素合并为一个新数组返回。使用reduce实现。

Array.prototype._flat = function(depth = 1) {
  const flatBase = (arr, curDepth = 1) => {
    return arr.reduce((acc, cur) => {
      // 当前项为数组,且当前扁平化深度小于指定扁平化深度时,递归扁平化
      if (Array.isArray(cur) && curDepth < depth) {
        return acc.concat(flatBase(cur, ++curDepth))
      }
      return acc.concat(cur)
    }, [])
  }
  return flatBase(this)
}

Array.prototype.flatMap()

MDN - Array.prototype.flatMap()

flatMap() 方法首先使用映射函数映射每个元素,然后将结果压缩成一个新数组。它与 map 连着深度值为1的 flat 几乎相同,但 flatMap 通常在合并成一种方法的效率稍微高一些。

Array.prototype._flatMap = function(callback, thisArg) {
  return this.reduce((acc, cur, i, arr) => {
    return acc.concat(callback.call(thisArg, cur, i, arr))
  }, [])
}

Array.prototype.forEach()

MDN - Array.prototype.forEach()

forEach() 方法对数组的每个元素执行一次提供的函数,使用reduce来实现。

Array.prototype._forEach = function(callback, thisArg) {
  this.reduce((_, cur, i, arr) => {
    callback.call(thisArg, cur, i, arr)
  })
}

Array.prototype.includes()

MDN - Array.prototype.includes()

includes() 方法用来判断一个数组是否包含一个指定的值,根据情况,如果包含则返回 true,否则返回false。

includes()同样具有短路特性。

Array.prototype._includes = function(valToFind, fromIndex = 0) {
  const start = fromIndex < 0 ?
    Math.max(this.length + fromIndex, 0) :
    Math.min(fromIndex, this.length)
  for (let i = start; i < this.length; i++) {
    const cur = this[i]
    if (valToFind === cur) {
      return true
    }
    if (Number.isNaN(valToFind) && Number.isNaN(cur)) {
      return true
    }
  }
  return false
}

Array.prototype.indexOf()

MDN - Array.prototype.indexOf()

indexOf()方法返回在数组中可以找到一个给定元素的第一个索引,如果不存在,则返回-1。

indexOf()同样具有短路特性,与includes()不同的是,indexOf()会跳过数组空项,且不会判定NaN的特殊情况,即任何数组indexOf(NaN)都返回 -1。

includes()会将数组空项判定为undefined,且NaN会判定为相等,即任意含有NaN的数组includes(NaN)都返回true

Array.prototype._indexOf = function(valToFind, fromIndex = 0) {
  const start = fromIndex < 0 ?
    Math.max(this.length + fromIndex, 0) :
    Math.min(fromIndex, this.length)
  for (let i = start; i < this.length; i++) {
    if (i in this && valToFind === this[i]) {
      return i
    }
  }
  return -1
}

Array.prototype.join()

MDN - Array.prototype.join()

join()方法将一个数组(或一个类数组对象)的所有元素连接成一个字符串并返回这个字符串。如果数组只有一个项目,那么将返回该项目而不使用分隔符。

Array.prototype._join = function(separator = ',') {
  const len = this.length
  let str = ''
  for (let i = 0; i < len; i++) {
    const cur = this[i]
    const curStr = (cur === undefined || cur === null)
      ? ''
      : cur.toString()
    str += curStr
    if (i !== len - 1) {
      str += separator
    }
  }
  return str
}

Array.prototype.keys()

MDN - Array.prototype.keys()

keys()方法返回一个包含数组中每个索引键的Array Iterator对象。与entries()方法实现相似。

Array.prototype._keys = function() {
  function *gen() {
    for (let i = 0; i < this.length; i++) {
      yield i
    }
  }
  return gen.call(this)
}

Array.prototype.lastIndexOf()

MDN - Array.prototype.lastIndexOf()

lastIndexOf()方法返回指定元素(也即有效的 JavaScript 值或变量)在数组中的最后一个的索引,如果不存在则返回 -1。从数组的后面向前查找,从 fromIndex 处开始。与indexOf()实现相似。

lastIndexOf()也会跳过数组空项,同样也使用严格相等进行判定,所以任何数组lastIndexOf(NaN)都返回 -1。

Array.prototype._lastIndexOf = function(valToFind, fromIndex = this.length - 1) {
  const start = fromIndex < 0 ?
    Math.max(this.length + fromIndex, 0) :
    Math.min(fromIndex, this.length)
  for (let i = start; i >= 0; i--) {
    if (i in this && valToFind === this[i]) {
      return i
    }
  }
  return -1
}

Array.prototype.map()

MDN - Array.prototype.map()

map()方法创建一个新数组,其结果是该数组中的每个元素都调用一个提供的函数后返回的结果。

Array.prototype._map = function(callback, thisArg) {
  const len = this.length
  const arr = new Array(len)
  for (let i = 0; i < len; i++) {
    if (i in this) {
      arr[i] = callback.call(thisArg, this[i], i, this)
    }
  }
  return arr
}

Array.prototype.pop()

MDN - Array.prototype.pop()

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

Array.prototype._pop = function() {
  const valToDel = this[this.length - 1]
  delete this[this.length - 1]
  this.length--
}

原型与原型链 - instanceof的底层实现原理及手动实现

instanceof的底层实现原理及手动实现

作用

instanceof 用于检测右侧构造函数的原型是否存在于左侧对象的原型链上。

Symbol.hasInstance

ES6新增的内置Symbol,用作对象方法标识符,该方法用于检测任意对象是否为拥有该方法对象的实例。instanceof操作符优先使用该Symbol对应的属性。

这样一来instanceof右侧并非必须为函数,对象也可以的。示例代码如下:

const MyArray = {
    [Symbol.hasInstance](obj) {
        return Array.isArray(obj)
    }
}

expect([] instanceof MyArray).toBe(true)

手写实现

const isObj = obj => ((typeof obj === 'object') || (typeof obj === 'function')) && obj !== null
function myInstanceOf(instance, Ctor) {
    if (!isObj(Ctor)) // 右侧必须为对象
        throw new TypeError('Right-hand side of 'instanceof' is not an object')

    const instOfHandler = Ctor[Symbol.hasInstance]
    // 右侧有[Symbol.hasInstance]方法,则返回其执行结果
    if (typeof instOfHandler === 'function') return instOfHandler(instance)
        
    // 右侧无[Symbol.hasInstance]方法且不是函数的,返回false
    if (typeof Ctor !== 'function') return false
        
    // 左侧实例不是对象类型,返回false
    if (!isObj(instance)) return false
    
    // 右侧函数必须有原型
    const rightP = Ctor.prototype
    if (!isObj(rightP))
        throw new TypeError(`Function has non-object prototype '${String(rightP)}' in instanceof check`)
        
    // 在实例原型连上查找是否有Ctor原型,有则返回true
    // 知道找到原型链顶级还没有,则返回false
    while (instance !== null) {
        instance = Object.getPrototypeOf(instance)
        if (instance === null) return false
        
        if (instance === rightP) return true
    }
}

ECMAScript定义

标准出处 -> ECMAScript#instanceof

InstanceofOperator ( V, target )

  1. If Type(target) is not Object, throw a TypeError exception.
  2. Let instOfHandler be ? GetMethod(target, @@hasInstance).
  3. If instOfHandler is not undefined, then
  4. Return ToBoolean(? Call(instOfHandler, target, « V »)).
  5. If IsCallable(target) is false, throw a TypeError exception.
  6. Return ? OrdinaryHasInstance(target, V).

OrdinaryHasInstance ( C, O )

  1. If IsCallable(C) is false, return false.
  2. If C has a [[BoundTargetFunction]] internal slot, then
    • Let BC be C.[[BoundTargetFunction]].
    • Return ? InstanceofOperator(O, BC).
  3. If Type(O) is not Object, return false.
  4. Let P be ? Get(C, "prototype").
  5. If Type(P) is not Object, throw a TypeError exception.
  6. Repeat,
    • Set O to ? O.[[GetPrototypeOf]]().
    • If O is null, return false.
    • If SameValue(P, O) is true, return true.

作用域与闭包 - JavaScript的作用域和作用域链

JavaScript的作用域和作用域链

ES6之后作用域概念变为词法环境概念,标准定义详见 ECMAScript#Lexical Environment

词法环境(Lexical Environment)

词法环境由以下两部分组成:

  • 环境记录(Environment Record):记录相应代码块的标识符绑定,可理解为代码块内变量、函数等都绑定于此;
  • 对外部词法环境的引用(outer):用于形成多个词法环境在逻辑上的嵌套结构,以实现可以访问外部词法环境变量的能力。

作用域链

上一点所有的词法环境中的 对外部词法环境的引用(outer),可以实现内部词法环境访问外部词法环境,从而实现了一个嵌套结构,即所说的 作用域链

词法环境在ECMAScript定义中,也是构成 执行上下文 的一部分。众所周知执行上下文在函数执行时才会创建,那么为什么又说JS的作用域是静态作用域呢,下面一起来看一下:

  1. JS在定义函数时不仅会记录函数代码、形参等信息,还会将函数被定义时所处的词法环境记录下来;

    此处可参考 ECMAScript#functioninitialize

  2. 执行函数时创建执行上下文、创建词法环境(包括环境记录和外部引用),并将外部引用指向第一点中记录的函数被定义时所处的词法环境。

    此处可参考 ECMAScript#newfunctionenvironment

JavaScript通过上述步骤实现了动态创建函数执行上下文时,对外部词法环境的引用是该函数定义时所在的词法环境,从而实现了静态作用域。

变量和类型 - 变量在内存中的具体存储形式

变量在内存中的具体存储形式

基本类型变量的存储

  • 基本类型变量存储在栈内存中;
  • JS中基本类型值是不可变的,故基本类型变量改变时都会为变量重新分配内存并存储值。

下面例子说明了基本类型变量的声明、赋值及改变的过程。

// Step 1. `myNumber` -> Address: 0012CCGWH80 -> Value: 23
let myNumber = 23

// Step 2. `newVar` -> Address: 0012CCGWH80 -> Value: 23
let newVar = myNumber

// Step 3. `myNumber` -> Address: 0034AAAAH23 -> Value: 24
myNumber = myNumber + 1

基本类型变量的存储

基本类型变量的存储

基本类型变量的存储

栈内存 与 堆内存

JavaScript内存模型中,内存空间分为 栈内存(Stack)堆内存(Heap) 两种。

展开查看栈内存、堆内存的特点

栈内存特点

  • 存储的值大小固定
  • 空间较小
  • 可以直接操作其保存的变量,运行效率高
  • 由系统自动分配存储空间

堆内存特点

  • 存储的值大小不定,可动态调整
  • 空间较大,运行效率低
  • 无法直接操作其内部存储,使用引用地址读取
  • 通过代码进行分配空间

引用类型变量的存储

  • 引用类型变量(即Object类型)存储在堆内存中;
  • 堆内存中存储的引用类型变量值是可变的。

下面例子说明了引用类型变量的声明、赋值及改变的过程。

// Step 1. `myArray` -> HeapAddress: 22VVCX011 -> Value: []
let myArray = []

// Step 2. `myArray` -> HeapAddress: 22VVCX011 -> Value: ['first', 'second', 'third']
myArray.push('first')
myArray.push('second')
myArray.push('third')

引用类型变量的存储
引用类型变量的存储

变量的比较与传递

变量的比较,是按变量在栈内存中的值进行比较,

变量的拷贝与变量作为函数参数进行传递,也是按变量在栈内存中的值进行传递。

  • 对于基本类型变量来说,栈内存中的值即为其值本身;
  • 对于引用类型变量来说,栈内存中的值即为指向堆内存中引用类型值的地址。
// 变量比较
const num1 = 12
const num2 = 12

expect(num1 === num2).toBe(true)

const obj1 = { foo: 'foo' }
const obj2 = obj1
const obj3 = { foo: 'foo' }

expect(obj1 === obj2).toBe(true)
expect(obj1 === obj3).toBe(false)
// 变量传递
const changeNum = (num) => num++
const changeObj = (obj) => (obj = { foo: 'bar' })
const changeObjProp = (obj) => (obj.foo = 'bar')

const num = 1

changeNum(num)
expect(num).toBe(1)

const obj1 = { foo: 'foo' }
const obj2 = { foo: 'foo' }

changeObjProp(obj1)
expect(obj1).toEqual({ foo: 'bar' })

changeObj(obj2)
expect(obj2).toEqual({ foo: 'foo' })

深入JavaScript系列(一):词法环境

一、词法环境 (Lexical Environment)

ECMAScript规范中对词法环境的描述如下:词法环境是用来定义 基于词法嵌套结构的ECMAScript代码内的标识符与变量值和函数值之间的关联关系 的一种规范类型。一个词法环境由环境记录(Environment Record)和一个可能为null的对外部词法环境的引用(outer)组成。一般来说,词法环境都与特定的ECMAScript代码语法结构相关联,例如函数、代码块、TryCatch中的Catch从句,并且每次执行这类代码时都会创建新的词法环境。

简而言之,词法环境就是相应代码块内标识符与值的关联关系的体现。如果之前了解过作用域概念的话,和词法环境是类似的(ES6之后作用域概念变为词法环境概念)。

词法环境有两个组成部分:

  1. 环境记录(Environment Record):记录相应代码块的标识符绑定。

    可以理解为相应代码块内的所有变量声明、函数声明(代码块若为函数还包括其形参)都储存于此

    对应ES6之前的变量对象or活动对象,没了解过的可忽略

  2. 对外部词法环境的引用(outer):用于形成多个词法环境在逻辑上的嵌套结构,以实现可以访问外部词法环境变量的能力。

    词法环境在逻辑上的嵌套结构对应ES6之前的作用域,没了解过的可忽略

二、环境记录(Environment Record)

环境记录有三种类型,分别是声明式环境记录(Declarative Environment Record)对象式环境记录(Object Environment Record)全局环境记录(Global Environment Record)

1. 声明式环境记录(Declarative Environment Record)

声明式环境记录是用来定义那些直接将标识符与语言值绑定的ES语法元素,例如变量,常量,let,class,module,import以及函数声明等。

声明式环境记录有函数环境记录(Function Environment Record)和模块环境记录(Module Environment Record)两种特殊类型。

1.1 函数环境记录(Function Environment Record)

函数环境记录用于体现一个函数的顶级作用域,如果函数不是箭头函数,还会提供一个this的绑定。

1.2 模块环境记录(Module Environment Record)

模块环境记录用于体现一个模块的外部作用域(即模块export所在环境),除了正常绑定外,也提供了所有引入的其他模块的绑定(即import的所有模块,这些绑定只读),因此我们可以直接访问引入的模块。

2. 对象式环境记录(Object Environment Record)

每个对象式环境记录都与一个对象相关联,这个对象叫做对象式环境记录的binding object。可以理解为对象式环境记录就是基于这个binding object,以对象属性的形式进行标识符绑定,标识符与binding object的属性名一一对应。

是对象就可以动态添加或者删除属性,所以对象环境记录不存在不可变绑定。

对象式环境记录用来定义那些将标识符与某些对象属性相绑定的ES语法元素,例如with语句、全局var声明和函数声明。

3. 全局环境记录(Global Environment Record)

全局环境记录逻辑上来说是单个记录,但是实际上可以看作是对一个对象式环境记录组件和一个声明式环境记录组件的封装。

之前说过每个对象式环境记录都有一个binding object,全局环境记录的对象式环境记录binding object就是全局对象,在浏览器内,全局的thiswindow绑定都指向全局对象。

全局环境记录的对象式环境记录组件,绑定了所有内置全局属性、全局的函数声明以及全局的var声明。

所以这些绑定我们可以通过window.xxthis.xx获取到。

全局代码的其他声明(如let、const、class等)则绑定在声明式环境记录组件内,由于声明式环境记录组件并不是基于简单的对象形式来实现绑定,所以这些声明我们并不能通过全局对象的属性来访问

三、 外部词法环境的引用(outer)

首先要说明两点:

  1. 全局环境的外部词法环境引用为null
  2. 一个词法环境可以作为多个词法环境的外部环境。例如全局声明了多个函数,则这些函数词法环境的外部词法环境引用都指向全局环境。

外部词法环境的引用将一个词法环境和其外部词法环境链接起来,外部词法环境又拥有对其自身的外部词法环境的引用。这样就形成一个链式结构,这里我们称其为环境链(即ES6之前的作用域链),全局环境是这条链的顶端。

环境链的存在是为了标识符的解析,通俗的说就是查找变量。首先在当前环境查找变量,找不到就去外部环境找,还找不到就去外部环境的外部环境找,以此类推,直到找到,或者到环境链顶端(全局环境)还未找到则抛出ReferenceError

标识符解析:在环境链中解析变量(绑定)的过程,

我们使用伪代码来模拟一下标识符解析的过程。

ResolveBinding(name, LexicalEnvironment) {
    // 如果传入词法环境为null(即一直解析到全局环境还未找到变量),则抛出ReferenceError
    if (LexicalEnvironment === null) {
        throw ReferenceError(`${name} is not defined`)
    }
    // 首次查找,将当前词法环境设置为解析环境
    if (typeof LexicalEnvironment === 'undefined') {
        LexicalEnvironment = currentLexicalEnvironment
    }
    // 检查环境的环境记录中是否有此绑定
    let isExist = LexicalEnvironment.EnviromentRecord.HasBinding(name)
    // 如果有则返回绑定值,没有则去外层环境查找
    if (isExist) {
        return LexicalEnvironment.EnviromentRecord[name]
    } else {
        return ResolveBinding(name, LexicalEnvironment.outer)
    }
}

四、案例分析

上面讲了那么多理论知识,现在我们结合代码来复习,有以下全局代码:

var x = 10
let y = 20
const z = 30
class Person {}
function foo() {
    var a = 10
}
foo()

现在我们有了一个全局词法环境和foo函数词法环境(以下内容均为抽象伪代码):

// 全局词法环境
GlobalEnvironment = {
    outer: null, // 全局环境的外部环境引用为null
    // 全局环境记录,抽象为一个声明式环境记录和一个对象式环境记录的封装
    GlobalEnvironmentRecord: {
        // 全局this绑定值指向全局对象,即ObjectEnvironmentRecord的binding object
        [[GlobalThisValue]]: ObjectEnvironmentRecord[[BindingObject]],
        // 声明式环境记录,全局除了函数和var,其他声明绑定于此
        DeclarativeEnvironmentRecord: {
            y: 20,
            z: 30,
            Person: <<class>>
        },
        // 对象式环境记录的,绑定对象为全局对象,故其中的绑定可以通过访问全局对象的属性来获得
        ObjectEnvironmentRecord: {
            // 全局函数声明和var声明
            x: 10,
            foo: <<function>>,
            // 内置全局属性
            isNaN: <<function>>,
            isFinite: <<function>>,
            parseInt: <<function>>,
            parseFloat: <<function>>,
            Array: <<construct function>>,
            Object: <<construct function>>
            // 其他内置全局属性不一一列举
        }
    }
}

// foo函数词法环境
fooFunctionEnviroment = {
    outer: GlobalEnvironment, // 外部词法环境引用指向全局环境
    FunctionEnvironmentRecord: {
        [[ThisValue]]: GlobalEnvironment.ObjectEnvironmentRecord, // foo函数全局调用,故this绑定指向全局对象
        // 其他函数代码内的绑定
        a: 10
    }
}

五、全局标识符解析

由于全局环境记录是声明式环境记录和对象式环境记录的封装,所以全局标识符的解析与其他环境的标识符解析有所不同,下面介绍全局标识符解析的步骤(伪代码):

function GetGlobalBingingValue(name) {
    // 全局环境记录
    let rec = Global Environment Record
    // 全局环境记录的声明式环境记录
    let DecRec = rec.DeclarativeRecord
    // HasBinding用来检查环境记录上是否绑定给定标识符
    if (DecRec.HasBinding(name) === true) {
        return DecRec[name]
    }
    let ObjRec = rec.ObjectRecord
    if (ObjRec.HasBinding(name) === true) {
        return ObjRec[name]
    }
    throw ReferenceError(`${name} is not defined`)
}

可以看到读取全局变量时,先检索声明式环境记录,再检索对象式环境记录。这样就会出现一些有趣的现象:

letconstclass等声明的变量如果存在同名var变量或同名函数声明,就会报错(之后的文章中会具体介绍)。但是如果我们使用letconstclass声明变量,然后直接通过给全局对象添加一个同名属性,则可以绕过此类报错。

此时全局环境记录的声明式环境记录和对象式环境记录内都有此标识符的绑定,但是我们访问时由于先检索声明式环境记录,所以对象式环境记录内的绑定会被遮蔽,要想访问只能通过访问全局对象属性的方法访问。

系列文章

准备将之前写的部分深入ECMAScript重写,加深自己理解,使内容更有干货,目录结构也更合理。

深入ECMAScript系列目录地址:深入ECMAScript系列

欢迎前往阅读系列文章,如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

菜鸟一枚,如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误,与大家共同进步。

深入JavaScript系列(五):JS与内存

一、内存是什么

我们现在常用的计算机都属于 冯·诺依曼体系计算机, 计算机硬件由 控制器、运算器、存储器、输入设备、输出设备 五大部分组成。

我们通常所说的内存就是 存储器

常用的内存都是易失性存储器(需要通过不断加电刷新来保持数据,一旦断电就会导致数据丢失),所以需要一种容量大、低成本的非易失性存储器来进行数据的存储,这就是外存,例如磁带、软盘、硬盘、光盘、闪存卡、U盘等。可以将外存理解为输入输出设备,因为外存是需要通过I/O接口进行数据存取的,而内存是由CPU直接寻址的。外存中的程序需要通过I/O接口调入内存中才可以运行。

内存就是程序运行的地方,其实程序本质上就是指令和数据的集合。所以说内存是指令和数据的临时存储器,然后CPU对内存中的指令和数据进行处理。

二、内存的使用

不管什么程序语言,其运行都依赖内存,内存生命周期基本是一致的:

  1. 分配所需要的内存
  2. 使用分配到的内存(读、写)
  3. 不需要时将其释放\归还

在JavaScript中,第一步和第三步由js引擎完成的,对于编程人员是隐藏的。但是这并不意味着我们不需要了解JavaScript中的内存机制,了解内存机制有助于我们写出更优雅、性能更好的代码。

三、JavaScript的内存模型

JavaScript数据类型有基本类型和引用类型两大类,基本类型有Undefined、Null、Boolean、Number、String、Symbol六中,引用类型有Object,所有的JavaScript变量值将会是七种的其中之一。这些数据类型在内存中是怎样存储的?我们来看一下JavaScript的内存模型。

说是JavaScript的内存模型其实不太准确,只是便于理解。由于JavaScript中的内存分配是由js引擎完成的,所以更准确的描述是js引擎的内存模型

一个运行中的程序总是与内存中的一部分空间相对应。这部分空间叫做 Resident Set (驻留集)。V8(一种JS引擎) 组织内存的方式如下图:

各部分作用如下:

  • Code Segment : 存放正在被执行的代码
  • Stack : 栈内存,存放标识符、基本类型值及引用类型变量的堆地址
  • Heap : 堆内存,存放引用类型值

为什么内存要如此分配?

  • 基本类型变量:标识符与值都存放在栈内存中(数据大小固定,由系统自动分配内存空间)。
  • 引用类型变量:栈内存中存放标识符与指向堆内存中值的地址,堆内存中存放具体值(数据大小可变,例如对象可随意增删属性,分配内存的大小取决于代码)。

四、变量传递

看到有些文章中说基本类型变量复制按值传递,引用类型变量复制按引用传递,又有的说引用类型变量复制按共享传递。总之对新手不太友好,这里我们站在内存层面来解释就比较好解释了。

我们可以理解为JavaScript变量的拷贝都是按栈内存内的值传递,这里栈内存内的值对于基本类型变量来说就是其值,对于引用类型来说就是一个指向堆内存中实际值的地址。

我们来看一个简单的例子理解一下:

let p1 = {name: 'logan'}
let p2 = p1
// p1 和 p2 在栈内存中存放的引用地址相同,都指向堆内存中存放对象 {name: 'logan'}
// 但是这两个引用地址却是相互独立的,并不存在引用关系

// 本质上是对堆内存中的对象进行修改,所以会同时影响p1和p2
p2.name = 'jason'
console.log(p1) // 输出:{name: 'jason'}
console.log(p2) // 输出:{name: 'jason'}

// 这一步是直接修改了栈内存内标识符p2对应值,并不会影响p1
p2 = 3
console.log(p1) // 输出:{name: 'jason'}

函数的参数传递与变量复制传递表现一致,也是按栈内存内的值进行传递,因为本质上来说,函数传参就是把传入的实参拷贝赋值给形参。

五、垃圾回收

垃圾回收是一种内存管理机制,就是将不再用到的内存及时释放,以防内存占用越来越高,导致卡顿甚至进程崩溃。

在JavaScript中内存垃圾回收是由js引擎自动完成的。实现垃圾回收的关键在于如何确定内存不再使用,也就是确定对象是否无用。主要有两种方式:引用计数标记清除

1. 引用计数(reference counting)

这是IE6、7采用的一种比较老的垃圾回收机制。引用计数确定对象是否无用的方法是对象是否被引用。如果没有引用指向对象,对象就可以被回收。我们结合代码来理解:

// 堆内存创建了一个对象{a: 1},我们记为ObjA,变量obj1指向ObjA,ObjA引用次数为1
let obj1 = {
    a: 1
}
// obj2 拷贝 obj1 的地址,也指向ObjA,ObjA引用次数为2
let obj2 = obj1
// 解除obj1对ObjA的引用,ObjA引用次数减一,为1
obj1 = 3
// 解除obj2对ObjA的引用,ObjA引用次数减一,为0,可以被回收
obj2 = 'logan'

缺点:无法处理循环引用

什么意思呢,我们结合代码理解,先看正常情况下引用计数的工作:

function func() {
    // 堆内存创建对象{a: 1},记为ObjA,变量foo指向ObjA,ObjA引用次数为1
    let foo = {a: 1}
    // 堆内存创建空对象,记为ObjB,变量bar指向ObjB,ObjB引用次数为1
    let bar = {}
    // 其属性x指向ObjA,ObjA引用次数为2
    bar.x = foo
    
    // 当函数执行完毕返回时
    // 变量bar生命周期结束,ObjB引用次数减一,为0,可被回收,故对其内部进行回收
    // bar.x生命周期结束,ObjA引用次数减一,为1
    // 变量foo生命周期结束,ObjA引用次数减一,为0,可被回收
}

但是如果两个对象之间存在循环引用,引用计数就会无法处理:

function func() {
    // 堆内存创建对象{a: 1},记为ObjA,变量foo指向ObjA,ObjA引用次数为1
    let foo = {a: 1}
    // 堆内存创建空对象,记为ObjB,变量bar指向ObjB,ObjB引用次数为1
    let bar = {}
    // 变量foo属性x指向ObjB,ObjB引用次数为2
    foo.x = bar
    // 变量bar属性x指向ObjA,ObjA引用次数为2
    bar.x = foo
    
    // 当函数执行完毕返回时
    // 变量bar生命周期结束,ObjB引用次数减一,为1,不可被回收
    // 变量foo生命周期结束,ObjA引用次数减一,为1,不可被回收
}

优点:确定性

引用计数其实也是有优点的,那就是对象一定会在最后一个引用失效的时候销毁,也就是说垃圾回收的时机在代码内是可控的,所以对于对延时比较敏感的场合比较适用。

2. 标记清除(mark and sweep)

从 2012 年起,所有现代浏览器都使用了标记清除的垃圾回收方法。

标记清除的工作原理简化后就是:从垃圾收集根(root)对象(在JavaScript中为全局环境记录)开始,标记出所有可以获得的对象,然后清除掉所有未标记的不可获得的对象。

也就是说,标记清除确定对象是否无用的方法是对象是否可以被获得

现代浏览器对JavaScript垃圾回收算法的改进都是基于标记清除算法的改进,并没有改进标记清除算法本身和它对“对象是否可以被获得”的简化定义。

关于垃圾回收的更多内容,可阅读浅谈V8引擎中的垃圾回收机制

六、内存泄漏

内存泄漏(Memory Leak) 是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

说到内存泄漏,不得不提一些文章内说闭包会造成内存泄漏,要尽量少用。其实这个观点是错误的,我们运用闭包说到底就两点目的:一是变量私有化,二是延长变量生命周期。
所以说 闭包并不会造成内存泄漏,而是正常的内存使用。

如何避免内存泄漏?一句话:及时解除无用引用。 例如不再需要的闭包、定时器及全局变量等。说到底还是个人编程习惯的好坏,多说无益,列太多的条条框框反而显得繁琐。

识别内存泄漏

  1. 打开Chrome浏览器开发者工具的Performance面板
  2. 选项栏中勾选Memory选项
  3. 点击左上角录制按钮(实心圆状按钮)
  4. 在页面上进行正常操作
  5. 一段时间后,点击Stop,观察面板上的数据

如图所示,内存占用如果整体平稳,说明不存在内存泄漏。

如果内存占用只升不降,或者整体呈一直升高的趋势,说明存在内存泄漏。

内存泄漏定位

如果发现页面存在内存泄漏,我们可以在下方内存图点击对应的内存异常处,然后点击下方面板内的Event Log面板,可以查看代码内具体发生了什么,见下图:

我们发现原来是调用了grow函数

let x = []
function grow() {
    x.push(new Array(1000000).join('x'))
}
document.getElementsByClassName('title-h2')[0].addEventListener('click', grow)

当然,上面的代码只是为了模拟,究竟是否为内存泄漏要看变量x我们是否需要用到,一旦不需要,我们应该解除其引用。

系列文章

深入ECMAScript系列目录地址(持续更新中...)

欢迎前往阅读系列文章,如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

菜鸟一枚,如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误,与大家共同进步。

执行机制 - 使用Promise实现串行

使用Promise实现串行

Promise原型上的then方法以及Async/Await基本用法大家都熟悉,不作过多介绍。

下面的实现方法本质上也都是基于以上两种用法的拓展。

普通循环

理论上任何循环函数或语法都可实现。

let promise = Promise.resolve()
function runPromisesSerially(tasks) {
  tasks.forEach(task => {
    promise = promise.then(task)
  })
  return promise
}

runPromiseSerially([ task1, task2, ... ])
  .then(() => console.log('finished'))

Array.reduce

上面方法通过循环任务数组,不断在promise后使用.then(nextTask)拼接任务,仔细想想很适合用reduce来实现:

function runPromisesSerially(tasks) {
  return tasks
    .reduce((promise, curTask) => promise.then(curTask), Promise.resolve())
}

Async/Await + 循环

while循环也可实现。

async function runPromisesSerially(tasks) {
  for (const task of tasks) {
    await task()
  }
}

递归

function runPromisesSerially([curTask, ...restTasks]) {
  const p = Promise.resolve()
  if (!curTask) return p
  return p.then(curTask).then(() => runPromisesSerially(restTasks))
}

for await of

需要自己实现可异步迭代的对象供for await of调用。

async function runPromisesSerially([...tasks]) {
  const asyncIterable = {
    [Symbol.asyncIterator]() {
      return {
        i: 0,
        next() {
          const task = tasks[this.i++]
          return task
            ? task().then(value => ({ done: false, value }))
            : Promise.resolve({ done: true })
        }
      }
    }
  }

  for await (val of asyncIterable) {
    // do something
  }
}

for await of + Async Generator

本质上是异步生成器函数()执行会自动生成异步迭代器,然后异步迭代器可配合for await of实现串行运行promises

async function runPromisesSerially(tasks) {
  async function* asyncGenerator() {
    let i = 0
    while (i < tasks.length) {
      const val = await tasks[i]()
      i++
      yield val
    }
  }

  for await (val of asyncGenerator()) {
    // do something
  }
}

Generator

Generator本身只是一个状态机,需要通过调用promise.then()来改变它的状态,实现promises的串行执行。

function runPromisesSerially(tasks) {
  function *gen() {
    for (const task of tasks) {
      yield task()
    }
  }
  const g = gen()
  function next(val) {
    const result = g.next(val)
    if (result.done) return result.value
    result.value.then(val => next(val))
  }
  next()
}

深入JavaScript系列(三):闭包

词法环境执行上下文不太了解的朋友,建议先阅读系列文章的前两篇,有助于理解本文,链接 -> 深入ECMAScript系列目录地址(持续更新中...)

一、词法作用域

首先我们来看一个例子(来自冴羽大大的博客JavaScript深入之词法作用域和动态作用域):

var scope = 'global scope'
function checkscope(){
    var scope = 'local scope'
    function f(){
        return scope
    }
    return f()
}
checkscope()
var scope = 'global scope'
function checkscope(){
    var scope = 'local scope'
    function f(){
        return scope
    }
    return f
}
checkscope()()

这里就不卖关子了,两段代码的运行结果都是local scope。这是JavaScript作用域机制决定的。

作用域:指程序源代码中定义变量的区域。是规定代码对变量访问权限的规则。

大家可能听说过JavaScript采用的是词法作用域(静态作用域),没听说过也没有关系,很好理解,意思就是函数的作用域在函数定义的时候就确定了,也就是说函数的作用域取决于函数在哪里定义,和函数在哪里调用并无关系。

由之前的文章深入ECMAScript系列(二):执行上下文我们可知:任意的JavaScript可执行代码(包括函数)被执行时,会创建新的执行上下文及其词法环境

既然词法环境是在代码块运行时才创建的,那为什么又说函数的作用域在函数定义的时候就确定了呢?这就牵扯到了函数的声明及调用了。

二、函数的声明及调用

在之前的文章深入ECMAScript系列(二):执行上下文中说过,代码块内的函数声明在标识符实例化及初始化阶段就会被初始化并分配相应的函数体。

在这个阶段还会会给函数设置一个内置属性[[Environment]],指向函数声明时所在的执行上下文的词法环境。

当声明过的函数被调用时,会创建新的执行上下文和新的词法环境,这个新创建的词法环境的对外部词法环境的引用outer属性将会指向函数的[[Environment]]内置属性,也就是函数声明时所在的执行上下文的词法环境。

而变量的查找又是通过词法环境及其外部引用进行的,所以说函数的作用域取决于函数在哪里定义,和函数在哪里调用并无关系。

总结一下,两个关键点:

  1. 函数声明时会被赋予一个内置属性[[Environment]],指向函数声明时所在的执行上下文的词法环境。
  2. 函数无论在何时何地调用,创建的词法环境的外部词法环境引用outer都指向函数的内置属性[[Environment]]

所以说函数的作用域取决于函数在哪里定义,和函数在哪里调用并无关系。

我们回头看文章开头的两个例子:

var scope = 'global scope'
function checkscope(){
    var scope = 'local scope'
    function f(){
        return scope
    }
}

// function f
f: {
    [[ECMAScriptCode]]: ..., // 函数体代码
    [[Environment]]: { // 函数f 定义时所在执行上下文的词法环境,也就是函数checkscope运行时创建的词法环境
        EnvironmentRecord: { // 环境记录上绑定了变量scope和函数f
            scope: 'local scope',
            f: Function f
        },
        outer: { // 外部词法环境引用指向全局词法环境
            EnvironmentRecord: { // 全局环境记录上绑定了变量scope和函数checkscope
                scope: 'global scope',
                checkscope: Function checkscope
            },
            outer: null // 全局词法环境无外部词法环境引用
        }
    },
    ... // 其他属性
}

函数f定义在函数checkscope内部,所以函数f不论在函数checkscope的内部调用,还是作为返回值返回后在外部调用,其词法环境的外部引用永远是函数checkscope运行时创建的词法环境,变量scope也只用往外寻找一层词法环境,在函数checkscope运行时创建的词法环境中找到,值为'local scope',不用再往外查找。所以上面两个例子的运行结果都是local scope

三、闭包

首先看看MDN上对闭包的定义:

闭包:闭包是函数和声明该函数的词法环境的组合。

从理论角度来说:所有的JavaScript函数都是闭包。 因为函数声明时会设置一个内置属性[[Environment]]来记录当前执行上下文的词法环境。

从实践角度来说: 我们平时所说的闭包应该叫“有意义的闭包”:

Dmitry Soshnikov的文章中描述具有以下特点的函数叫做闭包:

  1. 函数创建时所在的上下文销毁后,该函数仍然存在
  2. 函数内引用自由变量

自由变量: 在函数中使用,但既不是函数参数也不是函数的局部变量的变量。

我自己的理解是以下两点:

  1. 函数创建时的词法环境已不存在于当前执行上下文的词法环境链上。(换句话说,函数创建时的词法环境内的变量已无法在当前执行上下文内直接访问)
  2. 函数内存在对函数创建时的词法环境内的变量的访问。

最简单的闭包就是父函数内返回一个函数,返回函数内引用了父函数内变量:

var scope = 'global scope'
function checkscope(){
    var scope = 'local scope'
    function f(){
        return scope
    }
    return f
}

var closure = checkscope()
closure()

将开头的第二个例子稍微变一下,调用checkscope会返回一个函数,我们将其赋值给closure,此时closure函数就是一个闭包,由于它是在调用checkscope时创建的,内置属性[[Environment]]指向调用checkscope时创建的词法环境,因此无论在何处调用closure函数,返回结果是'local scope'

四、闭包的应用

我理解闭包的本质作用就两点,任何闭包的应用都离不开这两点:

  1. 创建私有变量
  2. 延长变量的生命周期

关于延长变量的生命周期,本质其实是延长词法环境的生命周期,一般函数的词法环境在函数返回后就被销毁,但是闭包会保存对创建时所在词法环境的引用,即便创建时所在的执行上下文被销毁,但创建时所在词法环境依然存在,以达到延长变量的生命周期的目的。

1. 模拟块级作用域

通过闭包可以模拟块级作用域,很经典的例子就是for循环中使用定时器延迟打印的问题。

// ES6之前无块级作用域,多个定时器内的回调函数引用同一个i
// for循环为同步,定时器内函数为异步,循环结束后i已经变为4
// 定时期内函数触发时访问变量i都是4
// 理解的关键在于for循环内代码是同步的,包括setTimtout本身
// 但是setTimeout定时器内的回调函数是异步的
for (var i = 1; i <= 3; i++) {
	setTimeout(function() {
		console.log(i)
	}, i * 1000)
}
// 使用立即执行函数,将i作为参数传入,可保存变量i的实时值
for(var i = 1; i <= 3; i++){
    (i => {
        setTimeout(() => {
            console.log(i)
        }, i * 1000)
    })(i)
}
// 以下代码可达到相同效果
for(var i = 1; i <= 3; i++){
    (() => {
        var j = i
        setTimeout(() => {
            console.log(j)
        }, j * 1000)
    })()
}
// 以下代码也可达到相同效果
for(var i = 1; i <= 3; i++){
    var closure = (function() {
        var j = i
        return () => {
            console.log(j)
        }
    })()
    setTimeout(closure, i * 1000)
}

闭包模拟块级作用域了解即可,毕竟ES6之后我们有了let来实现块级作用域,实现块级作用域的具体原理详见深入ECMAScript系列(二):执行上下文

2. 实现JS模块模式

模块模式是指将所有的数据和功能都封装在一个函数内部(私有的),只向外暴露一个包含多个属性方法的对象或函数。

var counter = (function() {
    var privateCounter = 0
    function changeBy(val) {
        privateCounter += val
    }
    return {
        increment: function() {
            changeBy(1)
        },
        decrement: function() {
            changeBy(-1)
        },
        value: function() {
            return privateCounter;
        }
    }
})()

另外例如underscore等一些js库的实现也使用到了闭包。

(function(){
    var root = this;

    var _ = {};

    root._ = _;
    
    // 外部不可访问的方法
    function tool() {
        // ...
    }
    
    // 外部可访问的方法
    _.xxx = function() {
        tool()
        // ...
    }
})()

3. 函数的柯里化

柯里化的目的在于避免频繁调用具有相同参数函数的同时,又能够轻松的重用。

// 假设我们有一个求长方形面积的函数
function getArea(width, height) {
    return width * height
}
// 如果我们碰到的长方形的宽老是10
const area1 = getArea(10, 20)
const area2 = getArea(10, 30)
const area3 = getArea(10, 40)

// 我们可以使用闭包柯里化这个计算面积的函数
function getArea(width) {
    return height => {
        return width * height
    }
}

const getTenWidthArea = getArea(10)
// 之后碰到宽度为10的长方形就可以这样计算面积
const area1 = getTenWidthArea(20)

// 而且如果遇到宽度偶尔变化也可以轻松复用
const getTwentyWidthArea = getArea(20)

其他例如计数器、延迟调用、回调等闭包的应用这里就不做过多讲解,其核心**还是创建私有变量延长变量的生命周期

五、总结

  1. ECMAScript采用词法作用域(也称静态作用域),函数的作用域取决于函数在哪里定义,和函数在哪里调用并无关系。
  2. 闭包是函数和声明该函数的词法环境的组合。
  3. 理论角度来说所有JavaScript函数都是闭包,因为函数会记录其定义时所处执行上下文的词法环境。
  4. 实践角度来说,引用了定义时所处词法环境的变量,并且能够在除了定义时所在上下文的其他上下文被调用的函数,才叫闭包。
  5. 闭包的作用总结为两点,一是创建私有变量,二是延长变量的生命周期

六、小练习

function fun(n,o){
  console.log(o);
  return {
    fun: function(m){
      return fun(m,n);
    }
  };
}

var a = fun(0);                       // ?
a.fun(1);                             // ?        
a.fun(2);                             // ?
a.fun(3);                             // ?

var b = fun(0).fun(1).fun(2).fun(3);  // ?

var c = fun(0).fun(1);                // ?
c.fun(2);                             // ?
c.fun(3);                             // ?

运用我们之前总结的知识来分析一下:

function fun(n,o){
  console.log(o);
  return {
    fun: function(m){
      return fun(m,n);
    }
  };
}

// 运行fun(0),未传入第二个参数,故打印undefined,最后返回一个对象,内有一个fun方法
// (注意此方法与外部fun函数不同,下同)
var a = fun(0);                       // undefined
// 对象内fun方法为闭包,记录对fun(0)执行时的词法环境,内部绑定一个参数n,值为0
// 将返回对象赋值于a,执行a.fun(x)时,不管传入的第一个参数是什么
// 第二个参数n都将在之前fun(0)执行时的词法环境内找到,值为0
a.fun(1);                             // 0        
a.fun(2);                             // 0
a.fun(3);                             // 0

// 每次调用fun函数都会返回一个对象
// 对象内又一个fun方法,为闭包,记录创建该对象及对象方法时的词法环境
// 故每次调用对象的fun方法,内部执行fun函数时的第二个参数总会在创建该对象时的词法环境内找到
// 值即为创建该对象的函数的第一个参数
// 所以除了第一次打印值为undefined,其余皆为上次调用fun时传入的第一个参数
var b = fun(0).fun(1).fun(2).fun(3);  // undefined
                                      // 0
                                      // 1
                                      // 2

// 类似上面的分析,c为一个对象,有一个fun方法,为闭包
// 该闭包记录了创建它时的词法环境,上面有两个绑定,{n: 1, o: 0}
// 所以c.fun(x)类似调用时,不论传参是什么,都将打印1
// 需要注意fun(0)调用时打印了undefined,fun(0).fun(1)调用时打印了0
var c = fun(0).fun(1);                // undefined
                                      // 0
c.fun(2);                             // 1
c.fun(3);                             // 1

OK,本篇文章就写到这里,相信大家对于闭包也有了一定自己的理解。关于深入ECMAScript系列文章之后的主题大家也可以在评论区留言讨论。

系列文章

深入ECMAScript系列目录地址(持续更新中...)

欢迎前往阅读系列文章,如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

菜鸟一枚,如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误,与大家共同进步。

执行机制 - 宏任务和微任务分别有哪些

宏任务和微任务分别有哪些

为何区分宏任务和微任务

确保一致的执行顺序

假设有以下代码,对请求结果作缓存:

let data
function getData() {
  if (data) {
    console.log('loaded data')
  } else {
    ajax().then(data => {
      console.log('loaded data')
    })
  }
}

这样实现带来的问题是在有缓存和无缓存的情况下,执行顺序会出现不一致的情况:

console.log('get data start')
getData()
console.log('get data end')

上方代码在无缓存时调用,打印顺序为get data start -> get data end -> loaded data

有缓存时调用,打印顺序为get data start -> loaded data -> get data end

如果这些打印操作变为数据修改操作,就可能导致意料之外的情况发生,所以我们可以使用微任务来确保一致的执行顺序:

function getData() {
  if (data) {
    window.queueMicrotask(() => {
      console.log('loaded data')
    })
  } else {
    ajax().then(data => {
      console.log('loaded data')
    })
  }
}

这样无论有无缓存,打印顺序都为get data start -> get data end -> loaded data

批量操作

将批量操作统一处理,避免多次调用的开销。

下面的代码片段演示了如何对多个消息进行处理:通过一个微任务在当前宏任务退出时将这些消息作为单一的对象发送出去。

const messageQueue = []

let sendMessage = message => {
  messageQueue.push(message)

  if (messageQueue.length === 1) {
    queueMicrotask(() => {
      const json = JSON.stringify(messageQueue)
      messageQueue.length = 0
      fetch('url-of-receiver', json)
    })
  }
}

时效性

上面提到的两个场景大家可能认为可以使用setTimeout替代,但有一个容易忽略的点是,假设上述任务执行时,任务队列中有宏任务等待执行,且宏任务改变了数据或阻塞线程时间过长,此时若使用setTimeout实现,可能会和预期表现有出入。

所以在有数据时效性要求的场景下,使用微任务可以确保当前任务执行完后执行,例如实时数据分析等。

宏任务有哪些

  • <script>标签中的运行代码
  • 事件触发的回调函数,例如DOM EventsI/OrequestAnimationFrame
  • setTimeoutsetInterval的回调函数

微任务有哪些

  • promisesPromise.thenPromise.catchPromise.finally
  • MutationObserver使用方式
  • queueMicrotask使用方式
  • process.nextTick:Node独有

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.