金九银十,初中级前端面试复习总结「JavaScript篇」

6,088 阅读19分钟

JavaScript

数据类型

String、Number、Boolean、Null、Undefined、Symbol、BigInt、Object

堆、栈

两者都是存放数据的地方。

栈(stack)是自动分配的内存空间,它存放基本类型的值和引用类型的内存地址。

堆(heap)是动态分配的内存空间,它存放引用类型的值。

JavaScript 不允许直接操作堆空间的对象,在操作对象时,实际操作是对象的引用,而存放在栈空间中的内存地址就起到指向的作用,通过内存地址找到堆空间中的对应引用类型的值。

隐式类型转换

JavaScript 作为一个弱类型语言,因使用灵活的原因,在一些场景中会对类型进行自动转换。

常见隐式类型转换场景有3种:运算取反比较

运算

运算的隐式类型转换会将运算的成员转换为 number 类型。

原始类型转换:

true + false   // 1
null + 10      // 10
false + 20     // 20
undefined + 30 // NaN
1 + '2'        // "12"
NaN + ''       // "NaN"
undefined + '' // "undefined"
null + ''      // "null"
'' - 3         // -3
  • nullfalse'' 转换 number 类型都是 0
  • undefined 转换 number 类型是 NaN,所以 undefined 和其他原始类型运算都会输出 NaN
  • 字符串在加法运算(其实是字符串拼接)中很强势,和任何类型相加都会输出字符串(symbol除外),即使是 NaNundefined。其他运算则正常转为 number 进行运算。

引用类型转换:

[1] + 10    // "110"
[] + 20     // "20"
[1,2] + 20  // "1,220"
[20] - 10   // 10
[1,2] - 10  // NaN
({}) + 10   // "[object Object]10"
({}) - 10   // NaN
  • 引用类型运算时,会先调用 valueOf 判断返回值是否为原始数据类型,如果是则进行运算。如果不是,则调用 toString 先转换为 string 再运算
  • 同上结论,除了加法都会输出字符串外,其他情况都是先转 string 再转 number

解析引用类型转换过程:

[1,2] + 20
// 过程:
[1,2].toString() // '1,2'
'1,2' + 20       // '1,220'

[20] - 10
// 过程
[20].toString()  // '20'
Number('20')     // 20
20 - 10          // 10

取反

取反的隐式类型转换会将运算的成员转换为 boolean 类型。

这个隐式类型转换比较简单,就是将值转为布尔值再取反:

![]     // false
!{}     // false
!false  // true

通常为了快速获得一个值的布尔值类型,可以取反两次:

!![]  // true
!!0   // false

比较

比较分为 严格比较=== 和 非严格比较==,由于 === 会比较类型,不会进行类型转换。这里只讨论 ==

比较的隐式类型转换基本会将运算的成员转换为 number 类型。

undefined == null  // true
'' == 0            // true
true == 1          // true
'1' == true        // true
[1] == '1'         // true
[1,2] == '1,2'     // true
({}) == '[object Object]' // true
  • undefined 等于 null
  • 字符串、布尔值、null比较时,都会转 number
  • 引用类型在隐式转换时会先转成 string 比较,如果不相等然再转成 number 比较

预编译

预编译发生在 JavaScript 代码执行前,对代码进行语法分析和代码生成,初始化的创建并存储变量,为执行代码做好准备。

预编译过程:

  1. 创建GO/AO对象(GO是全局对象,AO是活动对象)
  2. 将形参和变量声明赋值为 undefined
  3. 实参形参相统一
  4. 函数声明提升(将变量赋值为函数体)

例子:

function foo(x, y) {
    console.log(x)
    var x = 10
    console.log(x)
    function x(){}
    console.log(x)
}
foo(20, 30)
// 1. 创建AO对象
AO {}
// 2. 寻找形参和变量声明赋值为 undefined
AO {
    x: undefined
    y: undefined
}
// 3. 实参形参相统一
AO {
    x: 20
    y: 30
}
// 4. 函数声明提升
AO {
    x: function x(){}
    y: 30
}

编译结束后代码开始执行,第一个 x 从 AO 中取值,输出是函数xx 被赋值为 10,第二个 x 输出 10;函数x 已被声明提升,此处不会再赋值 x,第三个 x 输出 10。

作用域

作用域能保证对有权访问的所有变量和函数的有序访问,是代码在运行期间查找变量的一种规则。

函数作用域

函数在运行时会创建属于自己的作用域,将内部的变量和函数定义“隐藏”起来,外部作用域无法访问包装函数内部的任何内容。

块级作用域

在ES6之前创建块级作用域,可以使用 withtry/catch。而在ES6引入 let 关键字后,让块级作用域声明变得更简单。let 关键字可以将变量绑定到所在的任意作用域中(通常是{...}内部)。

{
    let num = 10
}
console.log(num) // ReferenceError: num is not defined

参数作用域

一旦设置了参数的默认值,函数进行声明初始化时,参数会形成一个单独的作用域。等到初始化结束,这个作用域就会消失。这种语法行为,在不设置参数默认值时,是不会出现的。

let x = 1;

function f(x, y = x) {
  console.log(y);
}

f(2) // 2

参数y的默认值等于变量x。调用函数f时,参数形成一个单独的作用域。在这个作用域里面,默认值变量x指向第一个参数x,而不是全局变量x,所以输出是2。

let x = 1;
function foo(x, y = function() { x = 2; }) {
  x = 3;
  y();
  console.log(x);
}

foo() // 2
x // 1

y 的默认是一个匿名函数,匿名函数内的x指向同一个作用域的第一个参数x。函数foo的内部变量x就指向第一个参数x,与匿名函数内部的x是一致的。y函数执行对参数x重新赋值,最后输出的就是2,而外层的全局变量x依然不受影响。

闭包

闭包的本质就是作用域问题。当函数可以记住并访问所在作用域,且该函数在所处作用域之外被调用时,就会产生闭包。

简单点说,一个函数内引用着所在作用域的变量,并且它被保存到其他作用域执行,引用变量的作用域并没有消失,而是跟着这个函数。当这个函数执行时,就可以通过作用域链查找到变量。

let bar
function foo() {
    let a = 10
    // 函数被保存到了外部
    bar = function () {
        // 引用着不是当前作用域的变量a
        console.log(a)
    }
}
foo()
// bar函数不是在本身所处的作用域执行
bar() // 10

优点:私有变量或方法、缓存

缺点:闭包让作用域链得不到释放,会导致内存泄漏

原型链

JavaScript 中的对象有一个特殊的内置属性 prototype(原型),它是对于其他对象的引用。当查找一个变量时,会优先在本身的对象上查找,如果找不到就会去该对象的 prototype 上查找,以此类推,最终以 Object.prototype 为终点。多个 prototype 连接在一起被称为原型链。

原型继承

原型继承的方法有很多种,这里不会全部提及,只记录两种常用的方法。

圣杯模式

function inherit(Target, Origin){
  function F() {};
  F.prototype = Origin.prototype;
  Target.prototype = new F();
  // 还原 constuctor
  Target.prototype.constuctor = Target;
  // 记录继承自谁
  Target.prototype.uber = Origin.prototype; 
}

圣杯模式的好处在于,使用中间对象隔离,子级添加属性时,都会加在这个对象里面,不会对父级产生影响。而查找属性是沿着 __proto__ 查找,可以顺利查找到父级的属性,实现继承。

使用:

function Person() {
    this.name = 'people'
}
Person.prototype.sayName = function () { console.log(this.name) }
function Child() {
    this.name = 'child'
}
inherit(Child, Person)
Child.prototype.age = 18
let child = new Child()

ES6 Class

class Person {
    constructor() {
        this.name = 'people'
    }
    sayName() {
        console.log(this.name)
    }
}
class Child extends Person {
    constructor() {
        super()
        this.name = 'child'
    }
}
Child.prototype.age = 18
let child = new Child()

Class 可以通过 extends 关键字实现继承,这比 ES5 的通过修改原型链实现继承,要清晰和方便很多。

基本包装类型

let str = 'hello'
str.split('')

基本类型按道理说是没有属性和方法,但是在实际操作时,我们却能从基本类型调用方法,就像一个字符串能调用 split 方法。

为了方便操作基本类型值,每当读取一个基本类型值的时候,后台会创建一个对应的基本包装类型的对象,从而让我们能够调用方法来操作这些数据。大概过程如下:

  1. 创建String类型的实例
  2. 在实例上调用指定的方法
  3. 销毁这个实例
let str = new String('hello')
str.split('')
str = null

this

this是函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。我理解的this是函数的调用者对象,当在函数内使用this,可以访问到调用者对象上的属性和方法。

this绑定的四种情况:

  1. new 绑定。new实例化
  2. 显示绑定。callapplybind手动更改指向
  3. 隐式绑定。由上下文对象调用,如 obj.fn()this 指向 obj
  4. 默认绑定。默认绑定全局对象,在严格模式下会绑定到undefined

优先级new绑定最高,最后到默认绑定。

new的过程

  1. 创建一个空对象
  2. 设置原型,将对象的 __proto__ 指向构造函数的 prototype
  3. 构造函数中的 this 执行对象,并执行构造函数,为空对象添加属性和方法
  4. 返回实例对象

注意点:构造函数内出现return,如果返回基本类型,则提前结束构造过程,返回实例对象;如果返回引用类型,则返回该引用类型。

// 返回基本类型
function Foo(){
    this.name = 'Joe'
    return 123
    this.age = 20
}
new Foo() // Foo {name: "Joe"}

// 返回引用类型
function Foo(){
    this.name = 'Joe'
    return [123]
    this.age = 20
}
new Foo() // [123]

call、apply、bind

三者作用都是改变this指向的。

callapply 改变 this 指向并调用函数,它们两者区别就是传参形式不同,前者的参数是逐个传入,后者传入数组类型的参数列表。

bind 改变 this 并返回一个函数引用,bind 多次调用是无效的,它改变的 this 指向只会以第一次调用为准。

手写call

Function.prototype.mycall = function () {
  if(typeof this !== 'function'){
    throw 'caller must be a function'
  }
  let othis = arguments[0] || window
  othis._fn = this
  let arg = [...arguments].slice(1)
  let res = othis._fn(...arg)
  Reflect.deleteProperty(othis, '_fn') //删除_fn属性
  return res
}

apply 实现同理,修改传参形式即可

手写bind

Function.prototype.mybind = function (oThis) {
  if(typeof this != 'function'){
    throw 'caller must be a function'
  }
  let fThis = this
  //Array.prototype.slice.call 将类数组转为数组
  let arg = Array.prototype.slice.call(arguments,1)
  let NOP = function(){}
  let fBound = function(){
    let arg_ = Array.prototype.slice.call(arguments)
    // new 绑定等级高于显式绑定
    // 作为构造函数调用时,保留指向不做修改
    // 使用 instanceof 判断是否为构造函数调用
    return fThis.apply(this instanceof fBound ? this : oThis, arg.concat(arg_))
  }
  // 维护原型
  if(this.prototype){
    NOP.prototype = this.prototype
    fBound.prototype = new NOP()
  }
  return fBound
}

对ES6语法的了解

常用:let、const、扩展运算符、模板字符串、对象解构、箭头函数、默认参数、Promise

数据结构:Set、Map、Symbol

其他:Proxy、Reflect

Set、Map、WeakSet、WeakMap

Set

  • 成员的值都是唯一的,没有重复的值,类似于数组
  • 可以遍历

WeakSet:

  • 成员必须为引用类型
  • 成员都是弱引用,可以被垃圾回收。成员所指向的外部引用被回收后,该成员也可以被回收
  • 不能遍历

Map:

  • 键值对的集合,键值可以是任意类型
  • 可以遍历

WeakMap

  • 只接受引用类型作为键名
  • 键名是弱引用,键值可以是任意值,可以被垃圾回收。键名所指向的外部引用被回收后,对应键名也可以被回收
  • 不能遍历

箭头函数和普通函数的区别

  1. 箭头函数的this指向在编写代码时就已经确定,即箭头函数本身所在的作用域;普通函数在调用时确定this
  2. 箭头函数没有arguments
  3. 箭头函数没有prototype属性

Promise

Promise 是ES6中新增的异步编程解决方案,避免回调地狱问题。Promise 对象是通过状态的改变来实现通过同步的流程来表示异步的操作, 只要状态发生改变就会自动触发对应的函数。

Promise对象有三种状态,分别是:

  • pending: 默认状态,只要没有告诉 promise 任务是成功还是失败就是 pending 状态
  • fulfilled: 只要调用 resolve 函数, 状态就会变为fulfilled, 表示操作成功
  • rejected: 只要调用 rejected 函数, 状态就会变为 rejected, 表示操作失败

状态一旦改变既不可逆,可以通过函数来监听 Promise 状态的变化,成功执行 then 函数的回调,失败执行 catch 函数的回调

浅拷贝

浅拷贝是值的复制,对于对象是内存地址的复制,目标对象的引用和源对象的引用指向的是同一块内存空间。如果其中一个对象改变,就会影响到另一个对象。

常用浅拷贝的方法:

  • Array.prototype.slice
let arr = [{a:1}, {b:2}]
let newArr = arr1.slice()
  • 扩展运算符
let newArr = [...arr1]

深拷贝

深拷贝是将一个对象从内存中完整的拷贝一份出来,对象与对象间不会共享内存,而是在堆内存中新开辟一个空间去存储,所以修改新对象不会影响原对象。

常用的深拷贝方法:

  • JSON.parse(JSON.stringify())
JSON.parse(JSON.stringify(obj))
  • 手写深拷贝
function deepClone(obj, map = new WeakMap()) {
  if (obj === null || typeof obj !== "object") return obj; 
  const type = Object.prototype.toString.call(obj).slice(8, -1) 
  let strategy = {
    Date: (obj) => new Date(obj),
    RegExp: (obj) => new RegExp(obj),
    Array: clone,
    Object: clone
  }
  function clone(obj){
    // 防止循环引用,导致栈溢出,相同引用的对象直接返回
    if (map.get(obj)) return map.get(obj);
    let target = new obj.constructor();
    map.set(obj, target);
    for (let key in obj) {
      if (obj.hasOwnProperty(key)) {
        target[key] = deepClone(obj[key], map);
      }
    }
    return target;
  }
  return strategy[type] && strategy[type](obj)
}

事件委托

事件委托也叫做事件代理,是一种dom事件优化的手段。事件委托利用事件冒泡的机制,只指定一个事件处理程序,就可以管理某一类型的所有事件。

假设有个列表,其中每个子元素都会有个点击事件。当子元素变多时,事件绑定占用的内存将会成线性增加,这时候就可以使用事件委托来优化这种场景。代理的事件通常会绑定到父元素上,而不必为每个子元素都添加事件。

<ul @click="clickHandler">
    <li class="item">1</li>
    <li class="item">2</li>
    <li class="item">3</li>
</ul>
clickHandler(e) {
    // 点击获取的子元素
    let target = e.target
    // 输出子元素内容
    consoel.log(target.textContent)
}

防抖

防抖用于减少函数调用次数,对于频繁的调用,只执行这些调用的最后一次。

/**
 * @param {function} func - 执行函数
 * @param {number} wait - 等待时间
 * @param {boolean} immediate - 是否立即执行
 * @return {function}
 */
function debounce(func, wait = 300, immediate = false){
  let timer, ctx;
  let later = (arg) => setTimeout(()=>{
    func.apply(ctx, arg)
    timer = ctx = null
  }, wait)
  return function(...arg){
    if(!timer){
      timer = later(arg)
      ctx = this
      if(immediate){
        func.apply(ctx, arg)
      }
    }else{
      clearTimeout(timer)
      timer = later(arg)
    }
  }
}

节流

节流用于减少函数请求次数,与防抖不同,节流是在一段时间执行一次。

/**
 * @param {function} func - 执行函数
 * @param {number} delay - 延迟时间
 * @return {function}
 */
function throttle(func, delay){
  let timer = null
  return function(...arg){
    if(!timer){
      timer = setTimeout(()=>{
        func.apply(this, arg)
        timer = null
      }, delay)
    }
  }
}

柯里化

Currying(柯里化)是把接受多个参数的函数变换成接受一个单一参数的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

通用柯里化函数:

function currying(fn, arr = []) {
  let len = fn.length
  return (...args) => {
    let concatArgs = [...arr, ...args]
    if (concatArgs.length < len) {
      return currying(fn, concatArgs)
    } else {
      return fn.call(this, ...concatArgs)
    }
  }
}

使用:

let sum = (a,b,c,d) => {
  console.log(a,b,c,d)
}

let newSum = currying(sum)

newSum(1)(2)(3)(4)

优点:

  1. 参数复用,由于参数可以分开传入,我们可以复用传入参数后的函数
  2. 延迟执行,就跟 bind 一样可以接收参数并返回函数的引用,而没有调用

垃圾回收

堆分为新生代和老生代,分别由副垃圾回收器和主垃圾回收器来负责垃圾回收。

新生代

一般刚使用的对象都会放在新生代,它的空间比较小,只有几十MB,新生代里还会划分出两个空间:form空间和to空间。

对象会先被分配到form空间中,等到垃圾回收阶段,将form空间的存活对象复制到to空间中,对未存活对象进行回收,之后调换两个空间,这种算法称之为 “Scanvage”。

新生代的内存回收频率很高、速度也很快,但空间利用率较低,因为让一半的内存空间处于“闲置”状态。

老生代

老生代的空间较大,新生代经过多次回收后还存活的对象会被送到老生代。

老生代使用“标记清除”的方式,从根元素开始遍历,将存活对象进行标记。标记完成后,对未标记的对象进行回收。

经过标记清除之后的内存空间会产生很多不连续的碎片空间,导致一些大对象无法存放进来。所以在回收完成后,会对这些不连续的碎片空间进行整理。

JavaScript设计模式

单例模式

定义:保证一个类仅有一个实例,并提供一个访问它的全局访问点。

JavaScript 作为一门无类的语言,传统的单例模式概念在 JavaScript 中并不适用。稍微转换下思想:单例模式确保只有一个对象,并提供全局访问。

常见的应用场景就是弹窗组件,使用单例模式封装全局弹窗组件方法:

import Vue from 'vue'
import Index from './index.vue'

let alertInstance = null
let alertConstructor = Vue.extend(Index)

let init = (options)=>{
  alertInstance = new alertConstructor()
  Object.assign(alertInstance, options)
  alertInstance.$mount()
  document.body.appendChild(alertInstance.$el)
}

let caller = (options)=>{
  // 单例判断
  if(!alertInstance){
    init(options)
  }
  return alertInstance.show(()=>alertInstance = null)
}

export default {
  install(vue){
    vue.prototype.$alert = caller
  }
}

无论调用几次,组件也只实例化一次,最终获取的都是同一个实例。

策略模式

定义:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。

策略模式是开发中最常用的设计模式,在一些场景下如果存在大量的 if/else,且每个分支点的功能独立,这时候就可以考虑使用策略模式来优化。

就像就上面手写深拷贝就用到策略模式来实现:

function deepClone(obj, map = new WeakMap()) {
  if (obj === null || typeof obj !== "object") return obj; 
  const type = Object.prototype.toString.call(obj).slice(8, -1) 
  // 策略对象
  let strategy = {
    Date: (obj) => new Date(obj),
    RegExp: (obj) => new RegExp(obj),
    Array: clone,
    Object: clone
  }
  function clone(obj){
    // 防止循环引用,导致栈溢出,相同引用的对象直接返回
    if (map.get(obj)) return map.get(obj);
    let target = new obj.constructor();
    map.set(obj, target);
    for (let key in obj) {
      if (obj.hasOwnProperty(key)) {
        target[key] = deepClone(obj[key], map);
      }
    }
    return target;
  }
  return strategy[type] && strategy[type](obj)
}

这样的代码看起来会更简洁,只需要维护一个策略对象,需要新功能就添加一个策略。由于策略项是单独封装的方法,也更易于复用。

代理模式

定义:为一个对象提供一个代用品,以便控制对它的访问。

当不方便直接访问一个对象或者不满足需要的时候,提供一个代理对象来控制对这个对象的访问,实际访问的是代理对象,代理对象对请求做出处理后,再转交给本体对象。

使用缓存代理请求数据:

function getList(page) {
    return this.$api.getList({
        page
    }).then(res => {
        this.list = res.data
        return res
    })
}

// 代理getList
let proxyGetList = (function() {
    let cache = {}
    return async function(page) {
        if (cache[page]) {
            return cache[page]
        }
        let res = await getList.call(this, page)
        return cache[page] = res.data
    }
})()

上面的场景是常见的分页需求,同一页的数据只需要去后台获取一次,并将获取到的数据缓存起来,下次再请求同一页时,便可以直接使用之前的数据。

发布订阅模式

定义:它定义对象间的一种一对多的依赖关系,当一个对象的状态发送改变时,所有依赖于它的对象都将得到通知。

发布订阅模式主要优点是解决对象间的解耦,它的应用非常广泛,既可以用在异步编程中,也可以帮助我们完成松耦合的代码编写。像 eventBus 的通信方式就是发布订阅模式。

let event = {
    events: [],
    on(key, fn){
        if(!this.events[key]) {
            this.events[key] = []
        }
        this.events[key].push(fn)
    },
    emit(key, ...arg){
        let fns = this.events[key]
        if(!fns || fns.length == 0){
            return false
        }
        fns.forEach(fn => fn.apply(this, arg))
    }
}

上面只是发布订阅模式的简单实现,还可以为其添加 off 方法来取消监听事件。在 Vue 中,通常是实例化一个新的 Vue 实例来做发布订阅中心,解决组件通信。而在小程序中可以手动实现发布订阅模式,用于解决页面通信的问题。

装饰器模式

定义:动态地为某个对象添加一些额外的职责,而不会影响对象本身。

装饰器模式在开发中也是很常用的设计模式,它能够在不影响源代码的情况下,很方便的扩展属性和方法。比如以下应用场景是提交表单。

methods: {
    submit(){
        this.$api.submit({
            data: this.form
        })
    },
    // 为提交表单添加验证功能
    validateForm(){
        if(this.form.name == ''){
            return
        }
        this.submit()
    }
}

想象一下,如果你刚接手一个项目,而 submit 的逻辑很复杂,可能还会牵扯到很多地方。冒然的侵入源代码去扩展功能会有风险,这时候装饰器模式就帮上大忙了。

模块化

这里只记录常用的两种模块:CommonJS模块、ES6模块。

CommonJS模块

Node.js 采用 CommonJS 模块规范,在服务端运行时是同步加载,在客户端使用需要编译后才可以运行。

特点

  • 模块可以多次加载。但在第一次加载时,结果会被缓存起来,再次加载模块,直接获取缓存的结果
  • 模块加载的顺序,按照其在代码中出现的顺序

语法

  • 暴露模块:module.exports = valueexports.xxx = value
  • 引入模块:require('xxx'),如果是第三方模块,xxx为模块名;如果是自定义模块,xxx为模块文件路径
  • 清楚模块缓存:delete require.cache[moduleName];,缓存保存在 require.cache 中,可操作该属性进行删除

模块加载机制

  • 加载某个模块,其实是加载该模块的 module.exports 属性
  • exports 是指向 module.exports 的引用
  • module.exports 的初始值为一个空对象,exports 也为空对象,module.exports 对象不为空的时候 exports 对象就被忽略
  • 模块加载的是值的拷贝,一旦输出值,模块内的变化不会影响到值,引用类型除外

module.exports 不为空:

// nums.js
exports.a = 1
module.exports = {
    b: 2
}
exports.c = 3
let nums = require('./nums.js') // { b: 2 }

module.exports 为空:

// nums.js
exports.a = 1
exports.c = 3
let nums = require('./nums.js') // { a: 1, c: 3 }

值拷贝的体现:

// nums.js
let obj = {
    count: 10
}
let count = 20
function addCount() {
    count++
}
function getCount() {
    return count
}
function addObjCount() {
    obj.count++
}
module.exports = { count, obj, addCount, getCount, addObjCount }
let { count, obj, addCount, getCount, addObjCount } = require('./nums.js')

// 原始类型不受影响
console.log(count) // 20
addCount()
console.log(count) // 20
// 如果想获取到变化的值,可以使用函数返回
console.log(getCount()) // 21

// 引用类型会被改变
console.log(obj) // { count: 10 }
addObjCount()
console.log(obj) // { count: 11 }

ES6模块

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。

特点

  • 由于静态分析的原因,ES6模块加载只能在代码顶层使用
  • 模块不能多次加载同一个变量

语法

  • 暴露模块:exportexport default
  • 引入模块:import

模块加载机制

  • 模块加载的是引用的拷贝,模块内的变化会影响到值
// nums.js
export let count = 20
export function addCount() {
  count++
}
export default {
  other: 30
}
// 同时引入 export default 和 export 的变量
import other, { count, addCount } from './async.js'

console.log(other) // { other: 30 }
console.log(count) // 20
addCount()
console.log(count) // 21

ES6 模块与 CommonJS 模块的差异

  1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
  2. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

参考资料

推荐阅读