知识点梳理

原始类型、引用类型

原始类型:undefinednullbooleanstringnumbersymbol

typeof null === 'object' 是一个历史悠久的 bug,因为 000 开始表示对象, null 也是全零

原始类型存储的是值,引用类型存储的是指针

typeof 可以判断除了 null 以外的原始类型。 instanceof 可以判对象的正确类型,但是不能判断原始类型,因为它是通过原型链去判断的。

类型转换

略,详见掘金小册

this

  1. 定义一个函数 foo(),如果直接调用 foo ,不管 foo 函数放在了什么地方, this 一定是 window
  2. 对于 obj.foo() 等情形,谁调用了函数,谁就是 this 。这里 this 就是 obj 对象
  3. 对于 const f = new foo() 的方式来说, this 永远指向 f ,不会有任何改变
  4. 箭头函数是没有 this 的,箭头函数的 this 取决于包裹箭头函数的第一个普通函数的 this 。另外,箭头函数使用 bindnew 这类函数是无效的
  5. 对于 bind 这些改变上下文的 API, this 只取决于第一个参数,如果第一个参数为空,那么就是 window 。注意,一个函数无论我们 bind 几次, this 永远由第一次 bind 决定
const a = {}
const fn = function () {
  console.log(this)
}
fn.bind().bind(a)()

// 换种写法
const fn2 = function () {
  return function () {
    return fn.apply()
  }.apply(a)
}
  1. 综上: new 的方式优先级最高,其次 bind 等函数,然后是 obj.foo() 这种方式的调用,最后是 直接调用。同时,箭头函数的 this 一旦绑定,就不会再改变了

闭包

定义:函数和 声明 这个函数时的作用域结合起来,就是闭包

;(function () {
  var a = 1
  function add() {
    var b = 2
    var sum = b + a
    console.log(sum) // 3
  }
  add()
})()

add 函数本身,以及其内部可访问的变量,即 a = 1 ,这两个组合在⼀起就被称为闭包,仅此⽽已。

闭包最⼤的作⽤就是隐藏变量,闭包的⼀⼤特性就是内部函数总是可以访问其所在的外部函数中声明的参数和变量,即使在其外部函数被返回(寿命终结)了之后也可以访问。

基于此特性,JavaScript 可以实现私有变量特权变量储存变量

我们就以私有变量举例,私有变量的实现⽅法很多,有靠约定的(变量名前加_),有靠 Proxy 代理的,也有靠 Symbol 这种新数据类型的。

但是真正⼴泛流⾏的其实是使⽤闭包。

function Person() {
  var name = 'Jim'
  this.getName = function () {
    return name
  }
  this.setName = function (value) {
    name = value
  }
}
const Jim = new Person()
console.log(Jim.getName()) // Jim
Jim.setName('Tom')
console.log(Jim.getName()) // Tom
console.log(name) //name is not defined

其他例子

// 第一题
;(function () {
  function createIncrement() {
    let count = 0
    function increment() {
      count++
    }

    let message = `Count is ${count}`
    function log() {
      console.log(message)
    }

    return [increment, log]
  }

  const [increment, log] = createIncrement()
  increment()
  increment()
  increment()
  log() // => ?
})()

// 第二题
;(function () {
  function fn() {
    a = 0
    return function (b) {
      return b + a++
    }
  }
  var f = fn()
  console.log(f(5)) // 5
  console.log(fn()(5)) // 5
  console.log(f(5)) // 6
  console.log(a) // 2
})()

深浅拷贝

浅拷贝: Object.assign{...someObject}

深拷贝: JSON.parse(JSON.stringfy(object)) ,但是也有局限性

  • 会忽略 undefined
  • 会忽略 symbol
  • 不能序列化函数
  • 不能解决循环引用的对象
/**
 * 简易版深拷贝函数
 */
const deepClone = obj => {
  if (typeof obj !== 'object') {
    throw new Error('发生错误')
  }

  const newObj = obj instanceof Array ? [] : {}

  for (const key in obj) {
    if (Object.hasOwnProperty.call(obj, key)) {
      const value = obj[key]
      newObj[key] = typeof value === 'object' ? deepClone(value) : value
    }
  }
  return newObj
}

原型及原型链

当我们创建一个实例let person = new Person('Jim', '25', 'football player') ,我们可以发现能使用很多种函数,但是我们明明没有定义过它们,对于这种情况你是否有过疑惑?

当我们在浏览器中打印 person 时你会发现,在 person 上居然还有一个 __proto__ 属性。其实每个 JS 对象都有 __proto__ 属性,这个属性指向了该对象 Person 的原型 Person.prototype

原型也是一个对象,并且这个对象中包含了很多函数,所以我们可以得出一个结论:对于 person 来说,可以通过 __proto__ 找到一个原型对象,在该对象中定义了很多函数让我们来使用。

__proto__ 对象中还有一个 constructor 属性,也就是构造函数。打开 constructor 属性我们又可以发现其中还有一个 prototype 属性,并且这个属性对应的值和先前我们在 __proto__ 中看到的一模一样。所以我们又可以得出一个结论:原型的 constructor 属性指向构造函数,构造函数又通过 prototype 属性指回原型,但是并不是所有函数都具有这个属性,Function.prototype.bind() 就没有这个属性。

示意图

总结:

  • Object 是所有对象的爸爸,所有对象都可以通过 __proto__ 找到它
  • Function 是所有函数的爸爸,所有函数都可以通过 __proto__ 找到它
  • 函数的 prototype 是一个对象
  • 对象的 __proto__ 属性指向原型, __proto__ 将对象和原型连接起来组成了原型链

作用域及作用域链

作用域就是一个独立的地盘,最大的用处就是隔离变量,不同作用域下同名变量不会有冲突。

ES6 之前 JavaScript 没有块级作用域,只有全局作用域和函数作用域。ES6的到来,为我们提供了块级作用域,可通过新增命令 letconst 来体现。

全局作用域

  • 最外层函数 和在最外层函数外面定义的变量拥有全局作用域
  • 所有末定义直接赋值的变量自动声明为拥有全局作用域
  • 所有 window 对象的属性拥有全局作用域

块级作用域

块级作用域可通过新增命令 let 和 const 声明,所声明的变量在指定块的作用域外无法被访问。块级作用域在如下情况被创建:

  • 在一个函数内部
  • 在一个代码块(由一对花括号包裹)内部

块级作用域有以下几个特点:

  • 声明变量不会提升到代码块顶部,所以会暂时性锁区
  • 禁止重复声明

作用域链

当在当前作用域中取值失败时,就要到创建这个函数的那个作用域中取值,这里强调的是“创建”,而不是“调用”。如果还是取值失败,继续往上,形成的链条,就叫作用域链。

什么是变量提升

JavaScript 引擎的⼯作⽅式是,先解析代码,获取所有被声明的变量,然后再⼀⾏⼀⾏地运⾏。这造成的结果,就是所有的变量的声明语句,都会被提升到代码的头部,这就叫做变量提升(hoisting)。存在提升的原因:解决函数之间的相互调用。

console.log(a) // undefined
var a = 1
function b() {
  console.log(a)
}
b() // 1

上⾯的代码实际执⾏顺序是这样的:

第⼀步: 引擎将 var a = 1 拆解为 var a = undefineda = 1 ,并将 var a = undefined 放到最顶端, a = 1 还在原来的位置,这样⼀来代码就是这样:

var a = undefined
console.log(a) // undefined
a = 1
function b() {
  console.log(a)
}
b() // 1

第⼆步就是执⾏,因此 js 引擎⼀⾏⼀⾏从上往下执⾏就造成了当前的结果,这就叫变量提升。

函数提升优于变量提升,函数提升会把 整个函数 挪到作用域顶部,变量提升只会把 变量声明 挪到作用域顶部

var、let 及 const

  • var 存在提升,我们能在声明之前使用。 let 、const 因为暂时性死区,不能在声明之前使用
  • 全局作用域下使用 letconst 声明变量,变量不会被挂载到 window 上,这和 var 不一样
  • letconst 作用基本一致,但是后者声明的变量不能再次赋值

原型继承和 Class 继承

/**
 * 组合继承:子类的构造函数中通过 Parent.call(this) 继承父类的属性,
 * 然后改变子类的原型为 new Parent() 来继承父类的函数
 */
function Parent(value) {
  this.value = value
}

Parent.prototype.getValue = function () {
  console.log(this.value)
}

function Child(value) {
  Parent.call(this, value) // 构造函数可以传参,不会与父类引用属性共享
}

Child.prototype = new Parent() // 可以复用父类的函数,但是子类原型上多了不需要的父类的属性

const child = new Child(1)

child.getValue() // 1

child instanceof Parent // true
/**
 * Class 继承,关键点在于 extends、super
 */
class Parent {
  constructor(value) {
    this.value = value
  }

  getValue() {
    console.log(this.value)
  }
}

class Child extends Parent {
  constructor(value) {
    super(value)
    this.value = value
  }
}

const child = new Child(1)

child.getValue() // 1

child instanceof Parent // true

模块化

Proxy

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。

let proxy = new Proxy(target, handler)
  • target —— 是要包装的对象,可以是任何东西,包括函数。
  • handler —— 代理配置:带有“钩子”(“traps”,即拦截操作的方法)的对象。比如 get 钩子用于读取 target 属性, set 钩子写入 target 属性等等。

proxy 进行操作,如果在 handler 中存在相应的钩子,则它将运行,并且 Proxy 有机会对其进行处理,否则将直接对 target 进行处理。

Handler 对象包含的方法:getsethas (in 运算符) 、 deleteProperty (delete 操作) 、 apply (proxy 对象作为函数被调用)、 construct (new 操作)、 defineProperty

/**
 * 通过自定义 set 和 get 函数的方式,在原本的逻辑中插入了我们的函数
 * 逻辑(回调函数),实现了在对对象任何属性进行读写时发出通知
 */
let obj = { a: 1 }

let onWatch = (target, setCallback, getCallback) => {
  return new Proxy(target, {
    set(target, key, value, receiver) {
      setCallback(key, value)
      return Reflect.set(target, key, value, receiver)
    },
    get(target, key, receiver) {
      getCallback(target, key)

      // return target[key]
      return Reflect.get(target, key, receiver)
    },
  })
}

let p = onWtch(
  obj,
  (k, v) => {
    // 数据变化,响应式监听
    console.log(`监测到属性${k}改变为${v}`)
  },
  (t, k) => {
    // 数据读取,响应式派发
    console.log(`'${k}' = ${t[k]}`)
  }
)

p.a = 3 // 监测到属性a改变为3
p.a // 'a' = 3

Proxy 有一些局限:

  • 内置对象(MapSetPromiseDate)具有“内部插槽”,对这些对象的访问无法被代理。
  • 私有类字段也是如此,因为它们是在内部使用插槽实现的。因此,代理方法的调用必须具有目标对象 this 才能访问它们
  • 对象相等性测试 === 不能被拦截
  • 性能:基准测试取决于引擎,但通常使用最简单的代理访问属性所需的时间要长几倍。实际上,这仅对某些“瓶颈”对象重要

Reflect

  1. Object 对象的一些明显属于语言内部的方法(比如 Object.defineProperty ),放到 Reflect 对象上。现阶段,某些方法同时在 ObjectReflect 对象上部署,未来的新方法将只部署在 Reflect 对象上
  2. 修改某些 Object 方法的返回结果,让其变得更合理。比如,Object.defineProperty(obj, name, desc) 在无法定义属性时,会抛出一个错误,而 Reflect.defineProperty(obj, name, desc) 则会返回 false
  3. Object 操作都变成函数行为。某些 Object 操作是命令式,比如 name in objdelete obj[name],而 Reflect.has(obj, name)Reflect.deleteProperty(obj, name) 让它们变成了函数行为
  4. Reflect 对象的方法与 Proxy 对象的方法一一对应,只要是 Proxy 对象的方法,就能在 Reflect 对象上找到对应的方法。这就让 Proxy 对象可以方便地调用对应的 Reflect 方法,完成原始对象的默认行为,作为修改行为的基础。也就是说,不管 Proxy 怎么修改默认行为(e.g. set()、get()),你总可以在 Reflect 上获取原始对象的默认行为。

在大多数情况下,我们可以不使用 Reflect 完成相同的事情,例如,使用 Reflect.get(target, prop, receiver) 读取属性可以替换为 target[prop] 。尽管有一些细微的差别。

let user = {
  _name: 'Guest',
  get name() {
    return this._name
  },
}

let userProxy = new Proxy(user, {
  get(target, prop, receiver) {
    return target[prop]
  },
})

console.log(userProxy.name) // Guest

get 钩子在这里是“ 透明的 ”,它返回原来的属性,不会做别的任何事情。对于我们的示例而言,这就足够了。

但是对象 adminuser 继承后,我们可以观察到错误的行为

let user = {
  _name: 'Guest',
  get name() {
    return this._name
  },
}

let userProxy = new Proxy(user, {
  get(target, prop, receiver) {
    return target[prop] // (*) target = user
  },
})

let admin = {
  __proto__: userProxy,
  _name: 'Admin',
}

// Expected: Admin
alert(admin.name) // 输出:Guest (?!?)

问题实际上出在代理中,在 (*)

  1. 当我们读取 admin.name ,由于 admin 对象自身没有对应的的属性,搜索将转到其原型
  2. 原型是 userProxy
  3. 从代理读取 name 属性时, get 钩子会触发并从原始对象返回 target[prop] 属性,在 (*) 行当调用 target[prop] 时,若 prop 是一个 getter ,它将在 this=target 上下文中运行其代码。因此,结果是来自原始对象 targetthis._name 即来自 user

更正后的变体

let user = {
  _name: 'Guest',
  get name() {
    return this._name
  },
}

let userProxy = new Proxy(user, {
  get(target, prop, receiver) {
    // receiver = admin
    return Reflect.get(target, prop, receiver) // (*)
    // return Reflect.get(...arguments)
  },
})

let admin = {
  __proto__: userProxy,
  _name: 'Admin',
}

alert(admin.name) // Admin

回调函数

回调地狱:

  • 嵌套函数存在耦合性,一旦有所改动,就会牵一发而动全身
  • 嵌套函数一多,就很难处理错误

Generator

Promise

特点:

  • 三种状态( pendingresolvedrejected ),状态一旦切换,不能改变
  • new Promise 立即执行
  • 链式调用,.then 都是返回一个全新的 Promise 对象
  • 解决了回调地狱

缺点:

  • Promise 无法取消
  • 错误只能在回调函数里面捕获
/**
 * 手写 Promise
 * 作用:1、消灭嵌套调用;2、合并多个任务的请求结果
 * API: Promise.resolve, Promise.reject, Promise.prototype.catch,
 * Promise.prototype.finally, Promise.all, Promise.race
 */
const PENDING = 'pending'
const RESOLVED = 'resolved'
const REJECTED = 'rejected'
function MyPromise(executor) {
  this.status = PENDING
  this.value = undefined
  this.reason = undefined

  this.onResolvedCallbacks = []
  this.onRejectedCallbacks = []

  const resolve = value => {
    if (this.status === PENDING) {
      this.value = value
      this.status = RESOLVED

      this.onResolvedCallbacks.forEach(fn => fn())
    }
  }

  const reject = reason => {
    if (this.status === PENDING) {
      this.reason = reason
      this.status = REJECTED

      this.onRejectedCallbacks.forEach(fn => fn())
    }
  }

  try {
    executor(resolve, reject)
  } catch (error) {
    reject(error)
  }
}

MyPromise.prototype.then = function (onResolved, onRejected) {
  onResolved = typeof onResolved === 'function' ? onResolved : v => v
  onRejected =
    typeof onRejected === 'function'
      ? onRejected
      : err => {
          throw err
        }

  if (this.status === RESOLVED) {
    onResolved(this.value)
  }

  if (this.status === REJECTED) {
    onRejected(this.reason)
  }

  if (this.status === PENDING) {
    this.onResolvedCallbacks.push(() => {
      onResolved(this.value)
    })

    this.onRejectedCallbacks.push(() => {
      onRejected(this.reason)
    })
  }
}

async / await

async 函数,就是 Generator 函数的语法糖,它建⽴在 Promises 上,并且与所有现有的基于 Promise 的 API 兼容。

  1. async 声明⼀个异步函数(async function someName(){…})
  2. ⾃动将常规函数转换成 Promise 函数,返回值也是⼀个 Promise 对象
  3. 只有 async 函数内部的异步操作执⾏完,才会执⾏ then ⽅法指定的回调函数
  4. 异步函数内部可以使⽤ await
  5. await 暂停异步的功能执⾏(var result = await someAsyncCall();)
  6. 放置在 Promise 调⽤之前,await 强制其他代码等待,直到 Promise 完成并返回结果
  7. 只能与 Promise ⼀起使⽤,不适⽤与回调
  8. await 只能在 async 函数内部使⽤

async/await 相⽐于 promise 的优势与劣势

  1. 代码读起来更加同步,Promise 虽然摆脱了回调地狱,但是 then 的链式调⽤也会带来额外的阅读负担
  2. Promise 传递中间值⾮常麻烦,⽽ async/await ⼏乎是同步的写法,⾮常优雅
  3. 错误处理友好,async/await 可以⽤成熟的 try/catch,Promise 的错误捕获⾮常冗余
  4. 调试友好,Promise 的调试很差,由于没有代码块,你不能在⼀个返回表达式的箭头函数中设置断点,如果你在⼀个 .then 代码块中使⽤调试器的步进(step-over)功能,调试器并不会进⼊后续的 .then 代码块,因为调试器只能跟踪同步代码的『每⼀步』
  5. 多个异步代码变为同步,浪费性能

定时器

setTimeoutsetIntervalrequestAnimationFrame,其中前两者的时间并不准确。但是最后 requestAnimationFrame 自带函数节流功能,基本可以保证 16.6 毫秒只执行一次,并且该函数的定时效果是精确的,不会有定时器时间不准的问题。

function mySetInterval(callback, interval) {
  let timer
  const now = Date.now
  let startTime = now()
  let endTime = startTime

  const loop = () => {
    timer = window.requestAnimationFrame(loop)
    endTime = now()
    if (endTime - startTime >= interval) {
      startTime = endTime = now()
      callback(timer)
    }
  }

  timer = window.requestAnimationFrame(loop)
  return timer
}

let a = 0
mySetInterval(timer => {
  console.log('1')
  a++
  if (a === 3) {
    window.cancelAnimationFrame(timer)
  }
}, 1000)

事件循环 (Event Loop)

渲染 Renderer 进程的主要线程

  • GUI 渲染线程
  • JS 引擎线程
  • 事件触发线程
  • 定时触发器线程
  • 异步 http 请求线程

常见的宏任务(macrotask)

  • 主代码块 script
  • setTimeout
  • setInterval
  • setImmediate - Node
  • requestAnimationFrame - 浏览器

常见微任务(microtask)

  • process.nextTick() - Node
  • Promise.then()
  • catch
  • finally
  • Object.observe
  • MutationObserver

具体步骤

  • 首先,整体的script(作为第一个宏任务)开始执行之前,会把所有代码分为同步任务、异步任务两部分,其中异步任务会再分为宏任务和微任务
  • 同步任务会直接进入主线程依次执行
  • 当主线程内的任务执行完毕,主线程为空时,会检查微任务,如果有任务,就全部执行
  • 执行完微任务,就渲染页面
  • 开始下一轮 Event Loop,执行下一个宏任务(异步代码: setTimeout 诸如此类)

PS: 我们可以看到 setTimeout 等宏任务的回调函数在主线程执行,因此,回调函数的执行上下文(this)为 window

/**
 * Event loop(执行一个宏任务,执行所有微任务,再继续如此循环)
 * log:1,4,8,7,3,9,6,5,2
 */
;(function () {
  function test() {
    console.log(1)
    setTimeout(function () {
      console.log(2)
    }, 1000)
  }

  test()

  setTimeout(function () {
    Promise.resolve().then(() => {
      console.log(9)
    })
    console.log(3)
  })

  new Promise(function (resolve) {
    console.log(4)
    setTimeout(function () {
      console.log(5)
    }, 100)
    resolve()
  }).then(function () {
    setTimeout(function () {
      console.log(6)
    }, 0)
    console.log(7)
  })

  console.log(8)
})()

手写 applycallbind 函数

/**
 * apply、call 的模拟实现,这两个方法被调用时,函数会立即执行,并返回结果
 */
Function.prototype.myCall = function (context) {
  const context = context || window
  context.fn = this
  const args = []
  for (let i = 1; i < arguments.length; i++) {
    args.push('arguments[' + i + ']') // 由于后面会使用 eval 表达式,所以不能直接 push 具体的值
  }
  const result = eval('context.fn(' + args + ')')
  delete context.fn
  return result
}

Function.prototype.myApply = function (context, arr) {
  const context = Object(context) || window
  context.fn = this
  let result
  if (!arr) {
    result = context.fn()
  } else {
    const args = []
    for (let i = 0; i < arr.length; i++) {
      args.push('arr[' + i + ']')
    }
    result = eval('context.fn(' + args + ')')
  }
  delete context.fn
  return result
}
/**
 * bind 的模拟实现。bind 方法会创建一个新函数,这个函数并不会立即执行。当这个新函数被调用时,bind的第一个参数将作为它运行时的 this,之后的一序列参数将会在传递的实参前传入作为它的参数。
 */
Function.prototype.myBind = function (context) {
  if (typeof this !== 'function') {
    throw new Error('Function.prototype.bind - what is trying to be bound is not callable')
  }

  const self = this
  const args = Array.prototype.slice.call(arguments, 1) // 此处的 arguments 为 bind 时传递的参数
  const fNOP = function () {}

  const fbound = function () {
    /**
     * 当作为构造函数时,this 指向实例,self 指向绑定函数,因为下面修改了 fbound.prototype 为 绑定函数的
     * prototype,此时结果为 true,当结果为 true 的时候,this 指向实例。
     *
     * 当作为普通函数时,this 指向 window,self 指向绑定函数,此时结果为 false,当结果为 false 的时候,
     * this 指向绑定的 context。
     */
    self.apply(this instanceof self ? this : context, args.concat(Array.prototype.slice.call(arguments))) // 此处的 arguments 返回的函数执行时的参数,两处参数合并起来成为 bind 函数完整的参数
  }

  fNOP.prototype = this.prototype // 空函数中转,防止改变 fbound 函数的 prototype 时改变了原来函数的原型
  fbound.prototype = new fNOP()

  return fbound
}

new

new 过程中发生的四件事儿

  1. 创建了一个空对象
  2. 链接到原型
  3. 绑定 this
  4. 返回对象
/**
 * 模拟实现 new 操作。e.g. myNew(Person,18)
 * @returns 新对象
 */
function myNew() {
  const obj = new Object(), // 用new Object() 的方式新建了一个对象 obj
    Constructor = [].shift.call(arguments) // 取出第一个参数,就是我们要传入的构造函数。此外因为 shift 会修改原数组,所以 arguments 会被去除第一个参数

  obj.__proto__ = Constructor.prototype

  // function Person(name, age) {
  //   this.strength = 60
  //   this.age = age

  //   return {
  //     name: name,
  //     habit: 'Games',
  //   }
  // }

  // var person = new Person('Kevin', '18')

  // console.log(person.name) // Kevin
  // console.log(person.habit) // Games
  // console.log(person.strength) // undefined
  // console.log(person.age) // undefined

  const result = Constructor.apply(obj, arguments)

  return typeof result === 'object' ? result : obj // 构造函数返回值如果是一个对象,就返回这个对象,如果不是,就该返回什么就返回什么
}

instanceof 原理

通过判断对象(左边)的原型链(__proto__)是不是能找到类型(右边)的 prototype

/**
 * 自定义instanceof函数
 */
function myInstanceof(left, right) {
  const prototype = right.prototype
  let left = left.__proto__

  while (true) {
    if (left === null || left === undefined) {
      return false
    }

    if (prototype === left) {
      return true
    }

    left = left.__proto__
  }
}

事件机制

事件触发过程:

  1. window事件触发处(通常来说就是目标 dom 元素)传播,遇到注册的捕获事件会触发
  2. 传播到事件触发处时触发注册的事件
  3. 从事件触发处往 window 传播,遇到注册的冒泡事件会触发

默认注册的就是冒泡事件,所以目标元素的事件触发后应该阻止冒泡

但是,如果给 body 中的子节点同时注册冒泡和捕获事件,事件触发会按照注册的顺序执行

// 以下会先打印冒泡然后是捕获
node.addEventListener(
  'click',
  event => {
    console.log('冒泡')
  },
  false
)

node.addEventListener(
  'click',
  event => {
    console.log('捕获')
  },
  true
)

addEventListener 注册事件,第三个参数可以是 boolean 或者 object , 默认为 false ,可以冒泡,但是阻止捕获。

事件代理

<ul id="ul">
  <li>1</li>
  <li>2</li>
  <li>3</li>
  <li>4</li>
  <li>5</li>
</ul>

<script>
  var ul = document.querySelector('#ul')
  ul.addEventListener('click',(event) => {
    console.log(event.target) // li1、li2、li3等等
  })
<script>

优点:

  • 节省内存
  • 不需要给子节点注销事件

跨域

JSONP

/**
 * url 链接返回的是:[callbackName]({code:0, data:[], msg: ''})
 * 在执行栈这个函数会立即执行
 */
const jsonp = (url, callbackName, success) => {
  let script = document.createElement('script')
  script.src = url
  script.async = true
  script.type = 'text/script'
  window[callbackName] = function (data) {
    success && success(data)
  }
  document.body.appendChild(script)
}

jsonp('https://xxx', 'callback', function (value) {
  console.log(value)
})

浏览器缓存机制

我们认为览器存在一个缓存数据库,用于储存一些不经常变化的静态文件(图片、css、js 等)。我们将缓存分为强制缓存和协商缓存。

强制缓存

当缓存数据库中已有所请求的数据时。客户端直接从缓存数据库中获取数据。当缓存数据库中没有所请求的数据时,客户端的才会从服务端获取数据。

强制缓存

服务器响应的 header 中会用两个字段来表明进行强制缓存——Expires 和 Cache-Control 。

  • Exprires 的值为服务端返回的数据到期时间。当再次请求时的请求时间小于返回的此时间,则直接使用缓存数据。但由于服务端时间和客户端时间可能有误差,这也将导致缓存命中的误差,另一方面,Expires 是 HTTP1.0 的产物,故现在大多数使用 Cache-Control 替代。
  • Cache-Control 有很多属性,不同的属性代表的意义也不同。
    • private:客户端可以缓存
    • public:客户端和代理服务器都可以缓存
    • max-age=t:缓存内容将在 t 秒后失效
    • no-cache:需要使用协商缓存来验证缓存数据
    • no-store:所有内容都不会缓存。

协商缓存

又称对比缓存,客户端会先从缓存数据库中获取到一个缓存数据的标识,得到标识后请求服务端验证是否失效(新鲜),如果没有失效服务端会返回 304,此时客户端直接从缓存中获取所请求的数据,如果标识失效,服务端会返回更新后的数据。

协商缓存

两类缓存机制可以同时存在,强制缓存的优先级高于协商缓存,当执行强制缓存时,如若缓存命中,则直接使用缓存数据库数据,不在进行缓存协商。

协商缓存需要进行对比判断是否可以使用缓存。浏览器第一次请求数据时,服务器会将缓存标识与数据一起响应给客户端,客户端将它们备份至缓存中。再次请求时,客户端会将缓存中的标识发送给服务器,服务器根据此标识判断。若未失效,返回 304 状态码,浏览器拿到此状态码就可以直接使用缓存数据了。
对于协商缓存来说,缓存标识我们需要着重理解一下,下面我们将着重介绍它的两种缓存方案。

Last-Modified

Last-Modified: 服务器在响应请求时,会告诉浏览器资源的最后修改时间。

if-Modified-Since:
浏览器再次请求服务器的时候,请求头会包含此字段,后面跟着在缓存中获得的最后修改时间。服务端收到此请求头发现有 if-Modified-Since,则与被请求资源的最后修改时间进行对比,如果一致则返回 304 和响应报文头,浏览器只需要从缓存中获取信息即可。从字面上看,就是说:从某个时间节点算起,是否文件被修改了

  1. 如果真的被修改:那么开始传输响应一个整体,服务器返回:200 OK
  2. 如果没有被修改:那么只需传输响应 header,服务器返回:304 Not Modified

if-Unmodified-Since:
从字面上看, 就是说: 从某个时间点算起, 是否文件没有被修改

  1. 如果没有被修改:则开始’继续’传送文件: 服务器返回: 200 OK
  2. 如果文件被修改:则不传输,服务器返回: 412 Precondition failed (预处理错误)

这两个的区别是一个是修改了才下载一个是没修改才下载。Last-Modified 说好却也不是特别好,因为如果在服务器上,一个资源被修改了,但其实际内容根本没发生改变,会因为 Last-Modified 时间匹配不上而返回了整个实体给客户端(即使客户端缓存里有个一模一样的资源)。为了解决这个问题,HTTP1.1 推出了 Etag。

Etag
Etag:服务器响应请求时,通过此字段告诉浏览器当前资源在服务器生成的唯一标识(生成规则由服务器决定)

If-None-Match:再次请求服务器时,浏览器的请求报文头部会包含此字段,后面的值为在缓存中获取的标识。服务器接收到次报文后发现 If-None-Match 则与被请求资源的唯一标识进行对比。

  1. 不同,说明资源被改动过,则响应整个资源内容,返回状态码 200。
  2. 相同,说明资源无心修改,则响应 header,浏览器直接从缓存中获取数据信息。返回状态码 304。

但是实际应用中由于 Etag 的计算是使用算法来得出的,而算法会占用服务端计算的资源,所有服务端的资源都是宝贵的,所以就很少使用 Etag 了。

缓存的优点

  1. 减少了冗余的数据传递,节省宽带流量
  2. 减少了服务器的负担,大大提高了网站性能
  3. 加快了客户端加载网页的速度 这也正是 HTTP 缓存属于客户端缓存的原因

不同刷新的请求执行过程

  1. 浏览器地址栏中写入 URL,回车,浏览器发现缓存中有这个文件了,不用继续请求了,直接去缓存拿(最快)
  2. F5,就是告诉浏览器,别偷懒,好歹去服务器看看这个文件是否有过期了。于是浏览器就胆胆襟襟的发送一个请求带上 If-Modify-since。
  3. Ctrl+F5,告诉浏览器,你先把你缓存中的这个文件给我删了,然后再去服务器请求个完整的资源文件下来。于是客户端就完成了强行更新的操作

实际场景应用缓存策略

频繁变动的资源

首先使用 Cache-control: no-cache 使浏览器每次都请求服务器,然后配合 ETag 或者 Last-Modified 来验证资源是否有效。这样的做法虽然不能节省流量,但是能显著减小响应数据的大小

代码文件

这里指除了 html 外的代码文件,因为 html 文件一般不缓存或者缓存时间很短

给代码文件设置一年有效期: Cache-Control: max-age=31536000 ,这样只有文件 hash 值发生变化才会去请求文件,否则一直使用缓存

浏览器渲染原理

性能优化

  • 图片加载优化:不使用图片,CSS 代替(三角形等);不同的分辨率裁剪不同的图片;小图用 base64;雪碧图
  • DNS 预解析 <link rel="dns-prefetch" href="http://xxxxx"
  • 防抖
/**
 * 防抖:事件触发,N秒之后执行。期间再次触发,则重新计算
 */
const debounce = (fn, wait, immediate) => {
  let timer

  const debounced = function (...args) {
    timer && clearTimeout(timer)
    if (immediate) {
      const callNow = !timer

      if (callNow) {
        fn.apply(this, args)
      }

      timer = setTimeout(() => {
        timer = null
      }, wait)
    } else {
      timer = setTimeout(() => {
        fn.apply(this, args)
      }, wait)
    }
  }

  return debounced
}
  • 节流
/**
 * 节流:事件触发,马上执行,N秒之内,事件不再执行,N秒结束之时,再执行一次
 */
const throttle = (fn, wait) => {
  let timer,
    previous = 0
  const throttled = function (...args) {
    const now = +new Date()
    const remaining = wait - (now - previous)

    if (remaining <= 0 || remaining > wait) {
      if (timer) {
        clearTimeout(timer)
        timer = null
      }
      previous = now
      fn.apply(this, args)
    } else if (!timer) {
      timer = setTimeout(() => {
        fn.apply(this, args)
        previous = +new Date()
        timer = null
      }, remaining)
    }
  }

  return throttled
}
  • 预加载 <link rel="preload" href="http://xxxxx"
  • 预渲染 <link rel="prerender" href="http://xxxxx"
  • 懒执行,某些逻辑需要时再计算,可以用于首屏优化,因为某些逻辑并不会在首页用到。
  • 懒加载
  • CDN

Webpack 性能优化

减少 webpack 打包时间

  • 优化 loader
module.export = {
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader?cacheDirectory=true', // 便以后的文件缓存起来
        include: [resolve('src')],
        exclude: '/node_modules/',
      },
    ],
  },
}
  • HappyPack: node 是单线程的,webpack 也是单线程的,HappyPack 可以将 Loader 的同步执行转换为并行的
  • DllPlugin: DllPlugin 可以将特定的类库提前打包引入
  • 代码压缩

减小打包体积

  • 按需加载
  • Tree Shaking 摇树优化

从输入 URL 到页面渲染

  • DNS 查询,通过域名找到 IP
  • 建立 TCP 连接,TCP 三次握手
  • 发送 HTTP 请求
  • 服务端处理并响应
  • 浏览器解析并渲染
    • 200 开始解析,302 重定向,500 报错
    • HTML 文件解析成 DOM 树,CSS 解析成 CSSDOM 树,遇到 script 标签,判断是否有 async 或 defer 字段,前者并行下载,后者先下载,延后执行,若没有,下载并执行,阻塞渲染进程。然后 DOM 树和 CSSDOM 树合并成为 Render 树,确定页面元素的布局和样式等等
    • 调用 GPU 开始绘制,合成图层,显示在屏幕上
  • 断开连接,TCP 连接结束