弹铁蛋同学

弹铁蛋同学 查看完整档案

北京编辑南昌大学  |  软件工程 编辑bytedance  |  前端开发 编辑填写个人主网站
编辑

Refurbishing
公众号:前端铁蛋
https://github.com/NieZhuZhu/...

个人动态

弹铁蛋同学 提出了问题 · 10月19日

谁能详细描述一下前端工程师化是什么

大家都在说的前端工程化到底是啥呢,是打包吗?我看到的感觉都太全面,谁能来总结一下😂

关注 2 回答 1

弹铁蛋同学 发布了文章 · 10月18日

所以 JavaScript 到底是什么?我居然懵了😮💫

前言

引用《JavaScript 高级程序设计第四版》中说的话——“从简单的输入验证脚本到强大的编程语言,JavaScript 的崛起没有任何人预测到。它很简单,学会用只要几分钟;它又很复杂,掌握它要很多年。要真正学好用好 JavaScript,理解其本质、历史及局限性是非常重要的”。

面试官:JavaScript 是什么?
我:em... JavaScript 就是 JavaScript... 👀

试想一下,如果面试官问你:JavaScript 是个啥?你会怎么回答呢?说实话,听到这个问题的时候我的脑子是有一瞬间是空白的。我们学了这么久的前端,连最简单的 “JavaScript 是什么?” 这个问题都不能跟面试官马上说出个所以然。今天我们就这个问题来聊一聊,当面试官问你 JavaScript 是什么的时候,你可以从哪几个点切入,尽可能说多、说全并且给面试官留下一个好印象。

JavaScript 的定位是什么?

1. JavaScript 是脚本编程语言

为什么是脚本编程语言呢?我们先来简单看看关于 JavaScript 问世的风风雨雨,一句话带过就是,1995 年网景公司为了解决 页面中简单的表单验证时客户端和服务端通信时延 推出了的一款叫做 JavaScript 的客户端脚本语言并且在当时取得了很大的成果,这时微软决定向 IE 投入更多资源,针对性推出 JScript,再到 Ecma (欧洲计算机制造商协会)推出 ECMAScript (发音为“ek-ma-script”)国际标准化了两门语言,来解决两个版本并存问题。

脚本语言能干啥呢?它可以在网页上实现复杂的功能,包括操作页面中的 DOM 元素、CSS 样式,能实现交互式的地图,2D/3D 动画,滚动播放的视频等等。JavaScript 在它所在的宿主环境中具有非常强大且灵活的能力,给开发人员提供了更多的可能性。

2. JavaScript 是弱类型语言

这意味着变量可以被隐式地转换为另一个类型。类型的隐藏转换,给 JavaScript 带了了一定的灵活性,但是也增加了规则的复杂度与发生错误的可能性。

  • 二元运算符 + 会把两个操作数转换为字符串,除非两个操作数都为数字类型。这是因为 + 也可以用来连接字符串。
  • 二元操作符 - 会把两个操作数转换为数字类型。
  • 一元操作符,包括 + 和 -,都会把操作数转换为数字。
console.log(1 + '2' === '12')
// true
console.log('3' - '1' === 2)
// true
console.log(+'1' === 1)
// true
console.log(-'1' === -1)
// true

3. JavaScript 是动态类型的

  • 正如大部分脚本语言,其类型与值而不是与变量关联。例如变量可以为数值,随后又可被赋值为字符串。
let a = 233
a = '弹铁蛋同学'
console.log(a)
// '弹铁蛋同学'
  • 可以在运行时直接执行 Javascript 语句
eval("console.log('弹铁蛋同学')")
// '弹铁蛋同学'

4. JavaScript 是单线程的

JavaScript 需要和页面进行交互,操作 DOM 等,如果是多线程的话,会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?所以这决定了它只能是单线程,

5. JavaScript 解释型语言

解释型语言(英语:Interpreted language)是一种编程语言类型。这种类型的编程语言,会将代码一句一句直接运行,不需要像编译型语言(Compiled language)一样,经过编译器先行编译为机器代码,之后再运行。

6. JavaScript 具有良好的跨平台性

跨平台特性,在绝大多数浏览器的支持下,可以在多种平台下运行(如 WindowsLinuxMacAndroidiOS 等)。

JavaScriptECMAScript 有什么区别,以及和 DOMBOM 的关系?

首先简单概括 ECMAScriptDOMBOM 三者概念吧。

DOM

DOM(文档对象模型),提供了与网页内容交互的 方法接口DOM 将整个页面抽象为一组分层节点。HTMLXML 页面的每个组成部分都是一种节点,包含不同的数据。DOM 通过创建表示文档的树,让开发者可以随心所欲地控制网页的内容和结构。使用 DOM API,可以轻松地删除、添加、替换、修改节点。

<html>
        <head>
            <title>Sample Page</title>
        </head>
        <body>
            <p> Hello World!</p>
        </body>
</html>

image.png

BOM

BOM(浏览器对象模型),提供了与浏览器交互的 方法接口BOM 主要针对浏览器窗口和子窗口(frame)。使用 BOM,开发者可以操控浏览器显示页面之外的部分, 比如:

1)弹出新浏览器窗口的能力;
2)移动、缩放和关闭浏览器窗口的能力;
3)navigator 对象,提供关于浏览器的详尽信息;
4)location 对象,提供浏览器加载页面的详尽信息;
5)screen 对象,提供关于用户屏幕分辨率的详尽信息;
6)performance 对象,提供浏览器内存占用、导航行为和时间统计的详尽信息;
7)对浏览器存储相关的操作比如 cookiessessionStorage localStorage 等;
8)其他自定义对象,如 XMLHttpRequestIEActiveXObject

ECMAScript

ECMAScript 描述了 JavaScript 的语法和基本对象:1)语法;2)类型;3)语句;4)关键字;5) 保留字;6)操作符;7)全局对象;

ECMA 发布 262 号标准文件(ECMA-262)的第一版,规定了浏览器脚本语言的标准,并将这种语言称为 ECMAScript,这个版本就是 1.0 版,所以一句话描述就是,ECMAScript 是一套规范,JavaScript 则是 ECMAScript 一种实现。为什么说是一种实现呢,因为 Adobe ActionScript 同样也实现了 ECMAScriptJScript 也同样实现了 ECMAScript

[ES 版本相关]

关系

所以在简单了解了上面几个关键的概念之后我们就很容易得出他们之间的关系啦!在《JavaScript 高级程序设计第四版》中有这样一张图,将 JavaScriptECMAScriptDOMBOM 四者的关系描述的非常清晰。

image.png

我们得出结论: ECMAScript、 DOM 、BOM 是 JavaScript 的组成部分。

总结

所以看到文章最后,当面试官问:”JavaScript 是什么“ 的时候,我们就知道从哪开始说,从哪些方面开始讲。所以如果我们把这篇文章的要点都和面试官说清楚了的话,相信可以给面试官留下一个不至于太糟糕的印象😋

  • JavaScript 的定位

    • JavaScript 是脚本编程语言
    • JavaScript 是弱类型语言
    • JavaScript 是动态类型的
    • JavaScript 是单线程的
    • JavaScript 解释型语言
    • JavaScript 具有良好的跨平台性
  • JavaScriptECMAScript 的区别,以及和 DOMBOM 的关系

    • DOM(文档对象模型),提供了与网页内容交互的 方法接口
    • BOM(浏览器对象模型),提供了与浏览器交互的 方法接口
    • ECMAScript 描述了 JavaScript 的语法和基本对象

最后还是引用开头那段话,”要真正学好用好 JavaScript,理解其本质、历史及局限性是非常重要的“ ,一起共勉~

  • 更多优质有趣的文章,请佛系关注公众号「前端铁蛋」啦
  • Github 原文

参考

查看原文

赞 16 收藏 12 评论 0

弹铁蛋同学 赞了文章 · 10月11日

图解九种常见的设计模式

在软件工程中,设计模式(Design Pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。根据模式的目的来划分的话,GoF(Gang of Four)设计模式可以分为以下 3 种类型:

image

1、创建型模式:用来描述 “如何创建对象”,它的主要特点是 “将对象的创建和使用分离”。包括单例、原型、工厂方法、抽象工厂和建造者 5 种模式。

2、结构型模式:用来描述如何将类或对象按照某种布局组成更大的结构。包括代理、适配器、桥接、装饰、外观、享元和组合 7 种模式。

3、行为型模式:用来识别对象之间的常用交流模式以及如何分配职责。包括模板方法、策略、命令、职责链、状态、观察者、中介者、迭代器、访问者、备忘录和解释器 11 种模式。

接下来阿宝哥将结合一些生活中的场景并通过精美的配图,来向大家介绍 9 种常用的设计模式。

一、建造者模式

建造者模式(Builder Pattern)将一个复杂对象分解成多个相对简单的部分,然后根据不同需要分别创建它们,最后构建成该复杂对象。

一辆小汽车 🚗 通常由 发动机、底盘、车身和电气设备 四大部分组成。汽车电气设备的内部构造很复杂,简单起见,我们只考虑三个部分:引擎、底盘和车身。

image

在现实生活中,小汽车也是由不同的零部件组装而成,比如上图中我们把小汽车分成引擎、底盘和车身三大部分。下面我们来看一下如何使用建造者模式来造车子。

1.1 实现代码
class Car {
  constructor(
    public engine: string,
    public chassis: string, 
    public body: string
  ) {}
}

class CarBuilder {
  engine!: string; // 引擎
  chassis!: string; // 底盘
  body!: string; // 车身

  addChassis(chassis: string) {
    this.chassis = chassis;
    return this;
  }

  addEngine(engine: string) {
    this.engine = engine;
    return this;
  }

  addBody(body: string) {
    this.body = body;
    return this;
  }

  build() {
    return new Car(this.engine, this.chassis, this.body);
  }
}

在以上代码中,我们定义一个 CarBuilder 类,并提供了 addChassisaddEngineaddBody 3 个方法用于组装车子的不同部位,当车子的 3 个部分都组装完成后,调用 build 方法就可以开始造车。

1.2 使用示例
const car = new CarBuilder()
  .addEngine('v12')
  .addBody('镁合金')
  .addChassis('复合材料')
  .build();
1.3 应用场景及案例
  • 需要生成的产品对象有复杂的内部结构,这些产品对象通常包含多个成员属性。
  • 需要生成的产品对象的属性相互依赖,需要指定其生成顺序。
  • 隔离复杂对象的创建和使用,并使得相同的创建过程可以创建不同的产品。
  • Github - node-sql-queryhttps://github.com/dresende/n...

二、工厂模式

在现实生活中,工厂是负责生产产品的,比如牛奶、面包或礼物等,这些产品满足了我们日常的生理需求。

image

在众多设计模式当中,有一种被称为工厂模式的设计模式,它提供了创建对象的最佳方式。工厂模式可以分为:简单工厂模式、工厂方法模式和抽象工厂模式

2.1 简单工厂

简单工厂模式又叫 静态方法模式,因为工厂类中定义了一个静态方法用于创建对象。简单工厂让使用者不用知道具体的参数就可以创建出所需的 ”产品“ 类,即使用者可以直接消费产品而不需要知道产品的具体生产细节。

image

在上图中,阿宝哥模拟了用户购车的流程,小王和小秦分别向 BMW 工厂订购了 BMW730 和 BMW840 型号的车型,接着工厂会先判断用户选择的车型,然后按照对应的模型进行生产并在生产完成后交付给用户。

下面我们来看一下如何使用简单工厂来描述 BMW 工厂生产指定型号车子的过程。

2.1.1 实现代码
abstract class BMW {
  abstract run(): void;
}

class BMW730 extends BMW {
  run(): void {
    console.log("BMW730 发动咯");
  }
}

class BMW840 extends BMW {
  run(): void {
    console.log("BMW840 发动咯");
  }
}

class BMWFactory {
  public static produceBMW(model: "730" | "840"): BMW {
    if (model === "730") {
      return new BMW730();
    } else {
      return new BMW840();
    }
  }
}

在以上代码中,我们定义一个 BMWFactory 类,该类提供了一个静态的 produceBMW() 方法,用于根据不同的模型参数来创建不同型号的车子。

2.1.2 使用示例
const bmw730 = BMWFactory.produceBMW("730");
const bmw840 = BMWFactory.produceBMW("840");

bmw730.run();
bmw840.run();
2.1.3 应用场景
  • 工厂类负责创建的对象比较少:由于创建的对象比较少,不会造成工厂方法中业务逻辑过于复杂。
  • 客户端只需知道传入工厂类静态方法的参数,而不需要关心创建对象的细节。

2.2 工厂方法

工厂方法模式(Factory Method Pattern)又称为工厂模式,也叫多态工厂(Polymorphic Factory)模式,它属于类创建型模式。

在工厂方法模式中,工厂父类负责定义创建产品对象的公共接口,而工厂子类则负责生成具体的产品对象, 这样做的目的是将产品类的实例化操作延迟到工厂子类中完成,即通过工厂子类来确定究竟应该实例化哪一个具体产品类。

image

在上图中,阿宝哥模拟了用户购车的流程,小王和小秦分别向 BMW 730 和 BMW 840 工厂订购了 BMW730 和 BMW840 型号的车子,接着工厂按照对应的模型进行生产并在生产完成后交付给用户。

同样,我们来看一下如何使用工厂方法来描述 BMW 工厂生产指定型号车子的过程。

2.2.1 实现代码
abstract class BMWFactory {
  abstract produceBMW(): BMW;
}

class BMW730Factory extends BMWFactory {
  produceBMW(): BMW {
    return new BMW730();
  }
}

class BMW840Factory extends BMWFactory {
  produceBMW(): BMW {
    return new BMW840();
  }
}

在以上代码中,我们分别创建了 BMW730FactoryBMW840Factory 两个工厂类,然后使用这两个类的实例来生产不同型号的车子。

2.2.2 使用示例
const bmw730Factory = new BMW730Factory();
const bmw840Factory = new BMW840Factory();

const bmw730 = bmw730Factory.produceBMW();
const bmw840 = bmw840Factory.produceBMW();

bmw730.run();
bmw840.run();
2.2.3 应用场景
  • 一个类不知道它所需要的对象的类:在工厂方法模式中,客户端不需要知道具体产品类的类名,只需要知道所对应的工厂即可,具体的产品对象由具体工厂类创建;客户端需要知道创建具体产品的工厂类。
  • 一个类通过其子类来指定创建哪个对象:在工厂方法模式中,对于抽象工厂类只需要提供一个创建产品的接口,而由其子类来确定具体要创建的对象,利用面向对象的多态性和里氏代换原则,在程序运行时,子类对象将覆盖父类对象,从而使得系统更容易扩展。
继续阅读:Typescript 设计模式之工厂方法

2.3 抽象工厂

抽象工厂模式(Abstract Factory Pattern),提供一个创建一系列相关或相互依赖对象的接口,而无须指定它们具体的类。

在工厂方法模式中具体工厂负责生产具体的产品,每一个具体工厂对应一种具体产品,工厂方法也具有唯一性,一般情况下,一个具体工厂中只有一个工厂方法或者一组重载的工厂方法。 但是有时候我们需要一个工厂可以提供多个产品对象,而不是单一的产品对象。

image

在上图中,阿宝哥模拟了用户购车的流程,小王向 BMW 工厂订购了 BMW730,工厂按照 730 对应的模型进行生产并在生产完成后交付给小王。而小秦向同一个 BMW 工厂订购了 BMW840,工厂按照 840 对应的模型进行生产并在生产完成后交付给小秦。

下面我们来看一下如何使用抽象工厂来描述上述的购车过程。

2.3.1 实现代码
abstract class BMWFactory {
  abstract produce730BMW(): BMW730;
  abstract produce840BMW(): BMW840;
}

class ConcreteBMWFactory extends BMWFactory {
  produce730BMW(): BMW730 {
    return new BMW730();
  }

  produce840BMW(): BMW840 {
    return new BMW840();
  }
}
2.3.2 使用示例
const bmwFactory = new ConcreteBMWFactory();

const bmw730 = bmwFactory.produce730BMW();
const bmw840 = bmwFactory.produce840BMW();

bmw730.run();
bmw840.run();
2.3.3 应用场景
  • 一个系统不应当依赖于产品类实例如何被创建、组合和表达的细节,这对于所有类型的工厂模式都是重要的。
  • 系统中有多于一个的产品族,而每次只使用其中某一产品族。
  • 系统提供一个产品类的库,所有的产品以同样的接口出现,从而使客户端不依赖于具体实现。
继续阅读:创建对象的最佳方式是什么?

三、单例模式

单例模式(Singleton Pattern)是一种常用的模式,有一些对象我们往往只需要一个,比如全局缓存、浏览器中的 window 对象等。单例模式用于保证一个类仅有一个实例,并提供一个访问它的全局访问点。

image

在上图中,阿宝哥模拟了借车的流程,小王临时有急事找阿宝哥借车子,阿宝哥家的车子刚好没用,就借给小王了。当天,小秦也需要用车子,也找阿宝哥借车,因为阿宝哥家里只有一辆车子,所以就没有车可借了。

对于车子来说,它虽然给生活带来了很大的便利,但养车也需要一笔不小的费用(车位费、油费和保养费等),所以阿宝哥家里只有一辆车子。在开发软件系统时,如果遇到创建对象时耗时过多或耗资源过多,但又经常用到的对象,我们就可以考虑使用单例模式。

下面我们来看一下如何使用 TypeScript 来实现单例模式。

3.1 实现代码
class Singleton {
  // 定义私有的静态属性,来保存对象实例
  private static singleton: Singleton;
  private constructor() {}

  // 提供一个静态的方法来获取对象实例
  public static getInstance(): Singleton {
    if (!Singleton.singleton) {
      Singleton.singleton = new Singleton();
    }
    return Singleton.singleton;
  }
}
3.2 使用示例
let instance1 = Singleton.getInstance();
let instance2 = Singleton.getInstance();

console.log(instance1 === instance2); // true
3.3 应用场景
  • 需要频繁实例化然后销毁的对象。
  • 创建对象时耗时过多或耗资源过多,但又经常用到的对象。
  • 系统只需要一个实例对象,如系统要求提供一个唯一的序列号生成器或资源管理器,或者需要考虑资源消耗太大而只允许创建一个对象。
继续阅读:TypeScript 设计模式之单例模式

四、适配器模式

在实际生活中,也存在适配器的使用场景,比如:港式插头转换器、电源适配器和 USB 转接口。而在软件工程中,适配器模式的作用是解决两个软件实体间的接口不兼容的问题。 使用适配器模式之后,原本由于接口不兼容而不能工作的两个软件实体就可以一起工作。

image

4.1 实现代码
interface Logger {
  info(message: string): Promise<void>;
}

interface CloudLogger {
  sendToServer(message: string, type: string): Promise<void>;
}

class AliLogger implements CloudLogger {
  public async sendToServer(message: string, type: string): Promise<void> {
    console.info(message);
    console.info('This Message was saved with AliLogger');
  }
}

class CloudLoggerAdapter implements Logger {
  protected cloudLogger: CloudLogger;

  constructor (cloudLogger: CloudLogger) {
    this.cloudLogger = cloudLogger;
  }

  public async info(message: string): Promise<void> {
    await this.cloudLogger.sendToServer(message, 'info');
  }
}

class NotificationService {
  protected logger: Logger;
  
  constructor (logger: Logger) {    
    this.logger = logger;
  }

  public async send(message: string): Promise<void> {
    await this.logger.info(`Notification sended: ${message}`);
  }
}

在以上代码中,因为 LoggerCloudLogger 这两个接口不匹配,所以我们引入了 CloudLoggerAdapter 适配器来解决兼容性问题。

4.2 使用示例
(async () => {
  const aliLogger = new AliLogger();
  const cloudLoggerAdapter = new CloudLoggerAdapter(aliLogger);
  const notificationService = new NotificationService(cloudLoggerAdapter);
  await notificationService.send('Hello semlinker, To Cloud');
})();
4.3 应用场景及案例
继续阅读:TypeScript 设计模式之适配器模式

五、观察者模式 & 发布订阅模式

5.1 观察者模式

观察者模式,它定义了一种一对多的关系,让多个观察者对象同时监听某一个主题对象,这个主题对象的状态发生变化时就会通知所有的观察者对象,使得它们能够自动更新自己。

在观察者模式中有两个主要角色:Subject(主题)和 Observer(观察者)。

image

在上图中,Subject(主题)就是阿宝哥的 TS 专题文章,而观察者就是小秦和小王。由于观察者模式支持简单的广播通信,当消息更新时,会自动通知所有的观察者。

下面我们来看一下如何使用 TypeScript 来实现观察者模式。

5.1.1 实现代码
interface Observer {
  notify: Function;
}

class ConcreteObserver implements Observer{
  constructor(private name: string) {}

  notify() {
    console.log(`${this.name} has been notified.`);
  }
}

class Subject { 
  private observers: Observer[] = [];

  public addObserver(observer: Observer): void {
    console.log(observer, "is pushed!");
    this.observers.push(observer);
  }

  public deleteObserver(observer: Observer): void {
    console.log("remove", observer);
    const n: number = this.observers.indexOf(observer);
    n != -1 && this.observers.splice(n, 1);
  }

  public notifyObservers(): void {
    console.log("notify all the observers", this.observers);
    this.observers.forEach(observer => observer.notify());
  }
}
5.1.2 使用示例
const subject: Subject = new Subject();
const xiaoQin = new ConcreteObserver("小秦");
const xiaoWang = new ConcreteObserver("小王");
subject.addObserver(xiaoQin);
subject.addObserver(xiaoWang);
subject.notifyObservers();

subject.deleteObserver(xiaoQin);
subject.notifyObservers();
5.1.3 应用场景及案例
继续阅读:TypeScript 设计模式之观察者模式

5.2 发布订阅模式

在软件架构中,发布/订阅是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者)。而是将发布的消息分为不同的类别,然后分别发送给不同的订阅者。 同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者存在。

在发布订阅模式中有三个主要角色:Publisher(发布者)、 Channels(通道)和 Subscriber(订阅者)。

image

在上图中,Publisher(发布者)是阿宝哥,Channels(通道)中 Topic A 和 Topic B 分别对应于 TS 专题和 Deno 专题,而 Subscriber(订阅者)就是小秦、小王和小池。

下面我们来看一下如何使用 TypeScript 来实现发布订阅模式。

5.2.1 实现代码
type EventHandler = (...args: any[]) => any;

class EventEmitter {
  private c = new Map<string, EventHandler[]>();

  // 订阅指定的主题
  subscribe(topic: string, ...handlers: EventHandler[]) {
    let topics = this.c.get(topic);
    if (!topics) {
      this.c.set(topic, topics = []);
    }
    topics.push(...handlers);
  }

  // 取消订阅指定的主题
  unsubscribe(topic: string, handler?: EventHandler): boolean {
    if (!handler) {
      return this.c.delete(topic);
    }

    const topics = this.c.get(topic);
    if (!topics) {
      return false;
    }
    
    const index = topics.indexOf(handler);

    if (index < 0) {
      return false;
    }
    topics.splice(index, 1);
    if (topics.length === 0) {
      this.c.delete(topic);
    }
    return true;
  }

  // 为指定的主题发布消息
  publish(topic: string, ...args: any[]): any[] | null {
    const topics = this.c.get(topic);
    if (!topics) {
      return null;
    }
    return topics.map(handler => {
      try {
        return handler(...args);
      } catch (e) {
        console.error(e);
        return null;
      }
    });
  }
}
5.2.2 使用示例
const eventEmitter = new EventEmitter();
eventEmitter.subscribe("ts", (msg) => console.log(`收到订阅的消息:${msg}`) );

eventEmitter.publish("ts", "TypeScript发布订阅模式");
eventEmitter.unsubscribe("ts");
eventEmitter.publish("ts", "TypeScript发布订阅模式");
5.2.3 应用场景
继续阅读:如何优雅的实现消息通信?

六、策略模式

策略模式(Strategy Pattern)定义了一系列的算法,把它们一个个封装起来,并且使它们可以互相替换。策略模式的重心不是如何实现算法,而是如何组织、调用这些算法,从而让程序结构更灵活、可维护、可扩展。

image

目前在一些主流的 Web 站点中,都提供了多种不同的登录方式。比如账号密码登录、手机验证码登录和第三方登录。为了方便维护不同的登录方式,我们可以把不同的登录方式封装成不同的登录策略。

下面我们来看一下如何使用策略模式来封装不同的登录方式。

6.1 实现代码

为了更好地理解以下代码,我们先来看一下对应的 UML 类图:

image

interface Strategy {
  authenticate(...args: any): any;
}

class Authenticator {
  strategy: any;
  constructor() {
    this.strategy = null;
  }

  setStrategy(strategy: any) {
    this.strategy = strategy;
  }

  authenticate(...args: any) {
    if (!this.strategy) {
      console.log('尚未设置认证策略');
      return;
    }
    return this.strategy.authenticate(...args);
  }
}

class WechatStrategy implements Strategy {
  authenticate(wechatToken: string) {
    if (wechatToken !== '123') {
      console.log('无效的微信用户');
      return;
    }
    console.log('微信认证成功');
  }
}

class LocalStrategy implements Strategy {
  authenticate(username: string, password: string) {
    if (username !== 'abao' && password !== '123') {
      console.log('账号或密码错误');
      return;
    }
    console.log('账号和密码认证成功');
  }
}
6.2 使用示例
const auth = new Authenticator();

auth.setStrategy(new WechatStrategy());
auth.authenticate('123456');

auth.setStrategy(new LocalStrategy());
auth.authenticate('abao', '123');
6.3 应用场景及案例

七、职责链模式

职责链模式是使多个对象都有机会处理请求,从而避免请求的发送者和接受者之间的耦合关系。在职责链模式里,很多对象由每一个对象对其下家的引用而连接起来形成一条链。请求在这个链上传递,直到链上的某一个对象决定处理此请求。

image

在公司中不同的岗位拥有不同的职责与权限。以上述的请假流程为例,当阿宝哥请 1 天假时,只要组长审批就可以了,不需要流转到主管和总监。如果职责链上的某个环节无法处理当前的请求,若含有下个环节,则会把请求转交给下个环节来处理。

在日常的软件开发过程中,对于职责链来说,一种常见的应用场景是中间件,下面我们来看一下如何利用职责链来处理请求。

7.1 实现代码

为了更好地理解以下代码,我们先来看一下对应的 UML 类图:

image

interface IHandler {
  addMiddleware(h: IHandler): IHandler;
  get(url: string, callback: (data: any) => void): void;
}

abstract class AbstractHandler implements IHandler {
  next!: IHandler;
  addMiddleware(h: IHandler) {
    this.next = h;
    return this.next;
  }

  get(url: string, callback: (data: any) => void) {
    if (this.next) {
      return this.next.get(url, callback);
    }
  }
}

// 定义Auth中间件
class Auth extends AbstractHandler {
  isAuthenticated: boolean;
  constructor(username: string, password: string) {
    super();

    this.isAuthenticated = false;
    if (username === 'abao' && password === '123') {
      this.isAuthenticated = true;
    }
  }

  get(url: string, callback: (data: any) => void) {
    if (this.isAuthenticated) {
      return super.get(url, callback);
    } else {
      throw new Error('Not Authorized');
    }
  }
}

// 定义Logger中间件
class Logger extends AbstractHandler {
  get(url: string, callback: (data: any) => void) {
    console.log('/GET Request to: ', url);
    return super.get(url, callback);
  }
}

class Route extends AbstractHandler {
  URLMaps: {[key: string]: any};
  constructor() {
    super();
    this.URLMaps = {
      '/api/todos': [{ title: 'learn ts' }, { title: 'learn react' }],
      '/api/random': Math.random(),
    };
  }

  get(url: string, callback: (data: any) => void) {
    super.get(url, callback);

    if (this.URLMaps.hasOwnProperty(url)) {
      callback(this.URLMaps[url]);
    }
  }
}
7.2 使用示例
const route = new Route();
route.addMiddleware(new Auth('abao', '123')).addMiddleware(new Logger());

route.get('/api/todos', data => {
  console.log(JSON.stringify({ data }, null, 2));
});

route.get('/api/random', data => {
  console.log(data);
});
7.3 应用场景
  • 可处理一个请求的对象集合应被动态指定。
  • 想在不明确指定接收者的情况下,向多个对象中的一个提交一个请求。
  • 有多个对象可以处理一个请求,哪个对象处理该请求运行时自动确定,客户端只需要把请求提交到链上即可。

八、模板方法模式

模板方法模式由两部分结构组成:抽象父类和具体的实现子类。通常在抽象父类中封装了子类的算法框架,也包括实现一些公共方法以及封装子类中所有方法的执行顺序。子类通过继承这个抽象类,也继承了整个算法结构,并且可以选择重写父类的方法。

image

在上图中,阿宝哥通过使用不同的解析器来分别解析 CSV 和 Markup 文件。虽然解析的是不同的类型的文件,但文件的处理流程是一样的。这里主要包含读取文件、解析文件和打印数据三个步骤。针对这个场景,我们就可以引入模板方法来封装以上三个步骤的处理顺序。

下面我们来看一下如何使用模板方法来实现上述的解析流程。

8.1 实现代码

为了更好地理解以下代码,我们先来看一下对应的 UML 类图:

image

import fs from 'fs';

abstract class DataParser {
  data: string = '';
  out: any = null;

  // 这就是所谓的模板方法
  parse(pathUrl: string) {
    this.readFile(pathUrl);
    this.doParsing();
    this.printData();
  }

  readFile(pathUrl: string) {
    this.data = fs.readFileSync(pathUrl, 'utf8');
  }

  abstract doParsing(): void;
  
  printData() {
    console.log(this.out);
  }
}

class CSVParser extends DataParser {
  doParsing() {
    this.out = this.data.split(',');
  }
}

class MarkupParser extends DataParser {
  doParsing() {
    this.out = this.data.match(/<\w+>.*<\/\w+>/gim);
  }
}
8.2 使用示例
const csvPath = './data.csv';
const mdPath = './design-pattern.md';

new CSVParser().parse(csvPath);
new MarkupParser().parse(mdPath);
8.3 应用场景
  • 算法的整体步骤很固定,但其中个别部分易变时,这时候可以使用模板方法模式,将容易变的部分抽象出来,供子类实现。
  • 当需要控制子类的扩展时,模板方法只在特定点调用钩子操作,这样就只允许在这些点进行扩展。

九、参考资源

十、推荐阅读

查看原文

赞 46 收藏 37 评论 2

弹铁蛋同学 关注了用户 · 2019-12-28

前端小布 @zhuchirenyuanxiaobu

能工摹形,巧匠窃意

关注 15

弹铁蛋同学 赞了文章 · 2019-12-28

2020年了,再不会webpack敲得代码就不香了(近万字实战)

前言

2020年即将到来,在众多前端的招聘要求里,webpack工程化这些字眼频率越来越高。日常开发者中,我们常常在用诸如vue-clicreate-react-app的脚手架来构建我们的项目。但是如果你想在团队脱颖而出(鹤立鸡群)、拿到更好的offer(还房贷),那么你必须去深刻的认识下我们经常打交道的webpack

本文共分为三个部分带你领略webpack的魅力。如有不足之处,恳请斧正


kaiche.gif

1. 入门(一起来用这些小例子让你熟悉webpack的配置)

1.1 初始化项目

新建一个目录,初始化npm

npm init

webpack是运行在node环境中的,我们需要安装以下两个npm包

npm i -D webpack webpack-cli
  • npm i -D 为npm install --save-dev的缩写
  • npm i -S 为npm install --save的缩写

新建一个文件夹src ,然后新建一个文件main.js,写一点代码测试一下

console.log('call me 老yuan')

配置package.json命令
20191211104852887.png
执行

npm run build

此时如果生成了一个dist文件夹,并且内部含有main.js说明已经打包成功了

1.2 开始我们自己的配置

上面一个简单的例子只是webpack自己默认的配置,下面我们要实现更加丰富的自定义配置

新建一个build文件夹,里面新建一个webpack.config.js

// webpack.config.js

const path = require('path');
module.exports = {
    mode:'development', // 开发模式
    entry: path.resolve(__dirname,'../src/main.js'),    // 入口文件
    output: {
        filename: 'output.js',      // 打包后的文件名称
        path: path.resolve(__dirname,'../dist')  // 打包后的目录
    }
}

更改我们的打包命令
p2.png
执行 npm run build
会发现生成了以下目录(图片)

其中dist文件夹中的main.js就是我们需要在浏览器中实际运行的文件

当然实际运用中不会仅仅如此,下面让我们通过实际案例带你快速入手webpack

1.3 配置html模板

js文件打包好了,但是我们不可能每次在html文件中手动引入打包好的js

这里可能有的朋友会认为我们打包js文件名称不是一直是固定的嘛(output.js)?这样每次就不用改动引入文件名称了呀?实际上我们日常开发中往往会这样配置:
module.exports = {
    // 省略其他配置
    output: {
      filename: '[name].[hash:8].js',      // 打包后的文件名称
      path: path.resolve(__dirname,'../dist')  // 打包后的目录
    }
}

这时候生成的dist目录文件如下
p3.png
为了缓存,你会发现打包好的js文件的名称每次都不一样。webpack打包出来的js文件我们需要引入到html中,但是每次我们都手动修改js文件名显得很麻烦,因此我们需要一个插件来帮我们完成这件事情

npm i -D html-webpack-plugin

新建一个build同级的文件夹public,里面新建一个index.html

具体配置文件如下

// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
    mode:'development', // 开发模式
    entry: path.resolve(__dirname,'../src/main.js'),    // 入口文件
    output: {
      filename: '[name].[hash:8].js',      // 打包后的文件名称
      path: path.resolve(__dirname,'../dist')  // 打包后的目录
    },
    plugins:[
      new HtmlWebpackPlugin({
        template:path.resolve(__dirname,'../public/index.html')
      })
    ]
}

生成目录如下(图片)
p4.png
可以发现打包生成的js文件已经被自动引入html文件中

1.3.1. 多入口文件如何开发

生成多个html-webpack-plugin实例来解决这个问题
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
    mode:'development', // 开发模式
    entry: {
      main:path.resolve(__dirname,'../src/main.js'),
      header:path.resolve(__dirname,'../src/header.js')
  }, 
    output: {
      filename: '[name].[hash:8].js',      // 打包后的文件名称
      path: path.resolve(__dirname,'../dist')  // 打包后的目录
    },
    plugins:[
      new HtmlWebpackPlugin({
        template:path.resolve(__dirname,'../public/index.html'),
        filename:'index.html',
        chunks:['main'] // 与入口文件对应的模块名
      }),
      new HtmlWebpackPlugin({
        template:path.resolve(__dirname,'../public/header.html'),
        filename:'header.html',
        chunks:['header'] // 与入口文件对应的模块名
      }),
    ]
}

此时会发现生成以下目录
p5.png

1.3.2 clean-webpack-plugin

每次执行npm run build 会发现dist文件夹里会残留上次打包的文件,这里我们推荐一个plugin来帮我们在打包输出前清空文件夹clean-webpack-plugin
const {CleanWebpackPlugin} = require('clean-webpack-plugin')
module.exports = {
    // ...省略其他配置
    plugins:[new CleanWebpackPlugin()]
}

1.4 引用CSS

我们的入口文件是js,所以我们在入口js中引入我们的css文件
p6.png
同时我们也需要一些loader来解析我们的css文件

npm i -D style-loader css-loader

如果我们使用less来构建样式,则需要多安装两个

npm i -D less less-loader

配置文件如下

// webpack.config.js
module.exports = {
    // ...省略其他配置
    module:{
      rules:[
        {
          test:/\.css$/,
          use:['style-loader','css-loader'] // 从右向左解析原则
        },
        {
          test:/\.less$/,
          use:['style-loader','css-loader','less-loader'] // 从右向左解析原则
        }
      ]
    }
} 

浏览器打开html如下
p7.png

1.4.1 为css添加浏览器前缀

npm i -D postcss-loader autoprefixer  

配置如下

// webpack.config.js
module.exports = {
    module:{
        rules:[
            test/\.less$/,
            use:['style-loader','css-loader','postcss-loader','less-loader'] // 从右向左解析原则
        ]
    }
} 

接下来,我们还需要引入autoprefixer使其生效,这里有两种方式

1,在项目根目录下创建一个postcss.config.js文件,配置如下:

module.exports = {
    plugins: [require('autoprefixer')]  // 引用该插件即可了
}

2,直接在webpack.config.js里配置

// webpack.config.js
module.exports = {
    //...省略其他配置
    module:{
        rules:[{
            test:/\.less$/,
            use:['style-loader','css-loader',{
                loader:'postcss-loader',
                options:{
                    plugins:[require('autoprefixer')]
                }
            },'less-loader'] // 从右向左解析原则
        }]
    }
}

这时候我们发现css通过style标签的方式添加到了html文件中,但是如果样式文件很多,全部添加到html中,难免显得混乱。这时候我们想用把css拆分出来用外链的形式引入css文件怎么做呢?这时候我们就需要借助插件来帮助我们

1.4.2 拆分css

npm i -D mini-css-extract-plugin
webpack 4.0以前,我们通过extract-text-webpack-plugin插件,把css样式从js文件中提取到单独的css文件中。webpack4.0以后,官方推荐使用mini-css-extract-plugin插件来打包css文件

配置文件如下

const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
  //...省略其他配置
  module: {
    rules: [
      {
        test: /\.less$/,
        use: [
           MiniCssExtractPlugin.loader,
          'css-loader',
          'less-loader'
        ],
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
        filename: "[name].[hash].css",
        chunkFilename: "[id].css",
    })
  ]
}

1.4.3 拆分多个css

这里需要说的细一点,上面我们所用到的mini-css-extract-plugin会将所有的css样式合并为一个css文件。如果你想拆分为一一对应的多个css文件,我们需要使用到extract-text-webpack-plugin,而目前mini-css-extract-plugin还不支持此功能。我们需要安装@next版本的extract-text-webpack-plugin
npm i -D extract-text-webpack-plugin@next
// webpack.config.js

const path = require('path');
const ExtractTextWebpackPlugin = require('extract-text-webpack-plugin')
let indexLess = new ExtractTextWebpackPlugin('index.less');
let indexCss = new ExtractTextWebpackPlugin('index.css');
module.exports = {
    module:{
      rules:[
        {
          test:/\.css$/,
          use: indexCss.extract({
            use: ['css-loader']
          })
        },
        {
          test:/\.less$/,
          use: indexLess.extract({
            use: ['css-loader','less-loader']
          })
        }
      ]
    },
    plugins:[
      indexLess,
      indexCss
    ]
}

1.5 打包 图片、字体、媒体、等文件

file-loader就是将文件在进行一些处理后(主要是处理文件名和路径、解析文件url),并将文件移动到输出的目录中

url-loader 一般与file-loader搭配使用,功能与 file-loader 类似,如果文件小于限制的大小。则会返回 base64 编码,否则使用 file-loader 将文件移动到输出的目录中

// webpack.config.js
module.exports = {
  // 省略其它配置 ...
  module: {
    rules: [
      // ...
      {
        test: /\.(jpe?g|png|gif)$/i, //图片文件
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 10240,
              fallback: {
                loader: 'file-loader',
                options: {
                    name: 'img/[name].[hash:8].[ext]'
                }
              }
            }
          }
        ]
      },
      {
        test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, //媒体文件
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 10240,
              fallback: {
                loader: 'file-loader',
                options: {
                  name: 'media/[name].[hash:8].[ext]'
                }
              }
            }
          }
        ]
      },
      {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/i, // 字体
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 10240,
              fallback: {
                loader: 'file-loader',
                options: {
                  name: 'fonts/[name].[hash:8].[ext]'
                }
              }
            }
          }
        ]
      },
    ]
  }
}

1.6 用babel转义js文件

为了使我们的js代码兼容更多的环境我们需要安装依赖

npm i babel-loader @babel/preset-env @babel/core
  • 注意

babel-loaderbabel-core的版本对应关系

  1. babel-loader 8.x 对应babel-core 7.x
  2. babel-loader 7.x 对应babel-core 6.x

配置如下

// webpack.config.js
module.exports = {
    // 省略其它配置 ...
    module:{
        rules:[
          {
            test:/\.js$/,
            use:{
              loader:'babel-loader',
              options:{
                presets:['@babel/preset-env']
              }
            },
            exclude:/node_modules/
          },
       ]
    }
}

上面的babel-loader只会将 ES6/7/8语法转换为ES5语法,但是对新api并不会转换 例如(promise、Generator、Set、Maps、Proxy等)

此时我们需要借助babel-polyfill来帮助我们转换

npm i @babel/polyfill
// webpack.config.js
const path = require('path')
module.exports = {
    entry: ["@babel/polyfill",path.resolve(__dirname,'../src/index.js')],    // 入口文件
}
  • 手动把上面的demo敲一遍对阅读下面的文章更有益,建议入门的同学敲三遍以上

学习.png

上面的实践是我们对webpack的功能有了一个初步的了解,但是要想熟练应用于开发中,我们需要一个系统的实战。让我们一起摆脱脚手架尝试自己搭建一个vue开发环境

2. 搭建vue开发环境

上面的小例子已经帮助而我们实现了打包css、图片、js、html等文件。
但是我们还需要以下几种配置

2.1 解析.vue文件

npm i -D vue-loader vue-template-compiler vue-style-loader
npm i -S vue

vue-loader 用于解析.vue文件

vue-template-compiler 用于编译模板
配置如下

const vueLoaderPlugin = require('vue-loader/lib/plugin')
module.exports = {
    module:{
        rules:[{
            test:/\.vue$/,
            use:['vue-loader']
        },]
     },
    resolve:{
        alias:{
          'vue$':'vue/dist/vue.runtime.esm.js',
          ' @':path.resolve(__dirname,'../src')
        },
        extensions:['*','.js','.json','.vue']
   },
   plugins:[
        new vueLoaderPlugin()
   ]
}

2.2 配置webpack-dev-server进行热更新

npm i -D webpack-dev-server

配置如下

const Webpack = require('webpack')
module.exports = {
  // ...省略其他配置
  devServer:{
    port:3000,
    hot:true,
    contentBase:'../dist'
  },
  plugins:[
    new Webpack.HotModuleReplacementPlugin()
  ]
}

完整配置如下

// webpack.config.js
const path = require('path');
const {CleanWebpackPlugin} = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const ExtractTextWebpackPlugin = require('extract-text-webpack-plugin')
const vueLoaderPlugin = require('vue-loader/lib/plugin')
const Webpack = require('webpack')
module.exports = {
    mode:'development', // 开发模式
    entry: {
      main:path.resolve(__dirname,'../src/main.js'),
    }, 
    output: {
      filename: '[name].[hash:8].js',      // 打包后的文件名称
      path: path.resolve(__dirname,'../dist')  // 打包后的目录
    },
    module:{
      rules:[
        {
          test:/\.vue$/,
          use:['vue-loader']
        },
        {
          test:/\.js$/,
          use:{
            loader:'babel-loader',
            options:{
              presets:[
                ['@babel/preset-env']
              ]
            }
          }
        },
        {
          test:/\.css$/,
          use: ['vue-style-loader','css-loader',{
            loader:'postcss-loader',
            options:{
              plugins:[require('autoprefixer')]
            }
          }]
        },
        {
          test:/\.less$/,
          use: ['vue-style-loader','css-loader',{
            loader:'postcss-loader',
            options:{
              plugins:[require('autoprefixer')]
            }
          },'less-loader']
        }
      ]
    },
    resolve:{
      alias:{
        'vue$':'vue/dist/vue.runtime.esm.js',
        ' @':path.resolve(__dirname,'../src')
      },
      extensions:['*','.js','.json','.vue']
    },
    devServer:{
      port:3000,
      hot:true,
      contentBase:'../dist'
    },
    plugins:[
      new CleanWebpackPlugin(),
      new HtmlWebpackPlugin({
        template:path.resolve(__dirname,'../public/index.html'),
        filename:'index.html'
      }),
      new vueLoaderPlugin(),
      new Webpack.HotModuleReplacementPlugin()
    ]
}

2.3 配置打包命令

c4.png
打包文件已经配置完毕,接下来让我们测试一下

首先在src新建一个main.js
c1.png
新建一个App.vue
c2.png
新建一个public文件夹,里面新建一个index.html
c3.png
执行npm run dev这时候如果浏览器出现Vue开发环境运行成功,那么恭喜你,已经成功迈出了第一步

2.4 区分开发环境与生产环境

实际应用到项目中,我们需要区分开发环境与生产环境,我们在原来webpack.config.js的基础上再新增两个文件

  • webpack.dev.js 开发环境配置文件
开发环境主要实现的是热更新,不要压缩代码,完整的sourceMap
  • webpack.prod.js生产环境配置文件
生产环境主要实现的是压缩代码、提取css文件、合理的sourceMap、分割代码
需要安装以下模块:
npm i -D  webpack-merge copy-webpack-plugin optimize-css-assets-webpack-plugin uglifyjs-webpack-plugin
  • webpack-merge 合并配置
  • copy-webpack-plugin 拷贝静态资源
  • optimize-css-assets-webpack-plugin 压缩css
  • uglifyjs-webpack-plugin 压缩js
webpack mode设置production的时候会自动压缩js代码。原则上不需要引入uglifyjs-webpack-plugin进行重复工作。但是optimize-css-assets-webpack-plugin压缩css的同时会破坏原有的js压缩,所以这里我们引入uglifyjs进行压缩

2.4.1 webpack.config.js

const path = require('path')
const {CleanWebpackPlugin} = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const vueLoaderPlugin = require('vue-loader/lib/plugin')
const MiniCssExtractPlugin = require("mini-css-extract-plugin")
const devMode = process.argv.indexOf('--mode=production') === -1;
module.exports = {
  entry:{
    main:path.resolve(__dirname,'../src/main.js')
  },
  output:{
    path:path.resolve(__dirname,'../dist'),
    filename:'js/[name].[hash:8].js',
    chunkFilename:'js/[name].[hash:8].js'
  },
  module:{
    rules:[
      {
        test:/\.js$/,
        use:{
          loader:'babel-loader',
          options:{
            presets:['@babel/preset-env']
          }
        },
        exclude:/node_modules/
      },
      {
        test:/\.vue$/,
        use:[{
          loader:'vue-loader',
          options:{
            compilerOptions:{
              preserveWhitespace:false
            }
          }
        }]
      },
      {
        test:/\.css$/,
        use:[{
          loader: devMode ? 'vue-style-loader' : MiniCssExtractPlugin.loader,
          options:{
            publicPath:"../dist/css/",
            hmr:devMode
          }
        },'css-loader',{
          loader:'postcss-loader',
          options:{
            plugins:[require('autoprefixer')]
          }
        }]
      },
      {
        test:/\.less$/,
        use:[{
          loader:devMode ? 'vue-style-loader' : MiniCssExtractPlugin.loader,
          options:{
            publicPath:"../dist/css/",
            hmr:devMode
          }
        },'css-loader','less-loader',{
          loader:'postcss-loader',
          options:{
            plugins:[require('autoprefixer')]
          }
        }]
      },
      {
        test:/\.(jep?g|png|gif)$/,
        use:{
          loader:'url-loader',
          options:{
            limit:10240,
            fallback:{
              loader:'file-loader',
              options:{
                name:'img/[name].[hash:8].[ext]'
              }
            }
          }
        }
      },
      {
        test:/\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
        use:{
          loader:'url-loader',
          options:{
            limit:10240,
            fallback:{
              loader:'file-loader',
              options:{
                name:'media/[name].[hash:8].[ext]'
              }
            }
          }
        }
      },
      {
        test:/\.(woff2?|eot|ttf|otf)(\?.*)?$/i,
        use:{
          loader:'url-loader',
          options:{
            limit:10240,
            fallback:{
              loader:'file-loader',
              options:{
                name:'media/[name].[hash:8].[ext]'
              }
            }
          }
        }
      }
    ]
  },
  resolve:{
    alias:{
      'vue$':'vue/dist/vue.runtime.esm.js',
      ' @':path.resolve(__dirname,'../src')
    },
    extensions:['*','.js','.json','.vue']
  },
  plugins:[
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      template:path.resolve(__dirname,'../public/index.html')
    }),
    new vueLoaderPlugin(),
    new MiniCssExtractPlugin({
      filename: devMode ? '[name].css' : '[name].[hash].css',
      chunkFilename: devMode ? '[id].css' : '[id].[hash].css'
    })
  ]
}

2.4.2 webpack.dev.js

const Webpack = require('webpack')
const webpackConfig = require('./webpack.config.js')
const WebpackMerge = require('webpack-merge')
module.exports = WebpackMerge(webpackConfig,{
  mode:'development',
  devtool:'cheap-module-eval-source-map',
  devServer:{
    port:3000,
    hot:true,
    contentBase:'../dist'
  },
  plugins:[
    new Webpack.HotModuleReplacementPlugin()
  ]
})

2.4.3 webpack.prod.js

const path = require('path')
const webpackConfig = require('./webpack.config.js')
const WebpackMerge = require('webpack-merge')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin')
const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
module.exports = WebpackMerge(webpackConfig,{
  mode:'production',
  devtool:'cheap-module-source-map',
  plugins:[
    new CopyWebpackPlugin([{
      from:path.resolve(__dirname,'../public'),
      to:path.resolve(__dirname,'../dist')
    }]),
  ],
  optimization:{
    minimizer:[
      new UglifyJsPlugin({//压缩js
        cache:true,
        parallel:true,
        sourceMap:true
    }),
    new OptimizeCssAssetsPlugin({})
    ],
    splitChunks:{
      chunks:'all',
      cacheGroups:{
        libs: {
          name: "chunk-libs",
          test: /[\\/]node_modules[\\/]/,
          priority: 10,
          chunks: "initial" // 只打包初始时依赖的第三方
        }
      }
    }
  }
})

2.5 优化webpack配置

看到这里你或许有些累了,但是要想获取更好的offer,更高的薪水,下面必须继续深入
qiong111.jpg
优化配置对我们来说非常有实际意义,这实际关系到你打包出来文件的大小,打包的速度等。
具体优化可以分为以下几点:

2.5.1 优化打包速度

构建速度指的是我们每次修改代码后热更新的速度以及发布前打包文件的速度。

2.5.1.1 合理的配置mode参数与devtool参数

mode可设置development production两个参数

如果没有设置,webpack4 会将 mode 的默认值设置为 production
production模式下会进行tree shaking(去除无用代码)和uglifyjs(代码压缩混淆)

2.5.1.2 缩小文件的搜索范围(配置include exclude alias noParse extensions)

  • alias: 当我们代码中出现 import 'vue'时, webpack会采用向上递归搜索的方式去node_modules 目录下找。为了减少搜索范围我们可以直接告诉webpack去哪个路径下查找。也就是别名(alias)的配置。
  • include exclude 同样配置include exclude也可以减少webpack loader的搜索转换时间。
  • noParse 当我们代码中使用到import jq from 'jquery'时,webpack会去解析jq这个库是否有依赖其他的包。但是我们对类似jquery这类依赖库,一般会认为不会引用其他的包(特殊除外,自行判断)。增加noParse属性,告诉webpack不必解析,以此增加打包速度。
  • extensions webpack会根据extensions定义的后缀查找文件(频率较高的文件类型优先写在前面)

carbon-2.png

2.5.1.3 使用HappyPack开启多进程Loader转换

在webpack构建过程中,实际上耗费时间大多数用在loader解析转换以及代码的压缩中。日常开发中我们需要使用Loader对js,css,图片,字体等文件做转换操作,并且转换的文件数据量也是非常大。由于js单线程的特性使得这些转换操作不能并发处理文件,而是需要一个个文件进行处理。HappyPack的基本原理是将这部分任务分解到多个子进程中去并行处理,子进程处理完成后把结果发送到主进程中,从而减少总的构建时间
npm i -D happypack

carbon-3.png

2.5.1.4 使用webpack-parallel-uglify-plugin 增强代码压缩

上面对于loader转换已经做优化,那么下面还有另一个难点就是优化代码的压缩时间。
npm i -D webpack-parallel-uglify-plugin

carbon-4.png

2.5.1.5 抽离第三方模块

对于开发项目中不经常会变更的静态依赖文件。类似于我们的elementUi、vue全家桶等等。因为很少会变更,所以我们不希望这些依赖要被集成到每一次的构建逻辑中去。 这样做的好处是每次更改我本地代码的文件的时候,webpack只需要打包我项目本身的文件代码,而不会再去编译第三方库。以后只要我们不升级第三方包的时候,那么webpack就不会对这些库去打包,这样可以快速的提高打包的速度。

这里我们使用webpack内置的DllPlugin DllReferencePlugin进行抽离

在与webpack配置文件同级目录下新建webpack.dll.config.js
代码如下

// webpack.dll.config.js
const path = require("path");
const webpack = require("webpack");
module.exports = {
  // 你想要打包的模块的数组
  entry: {
    vendor: ['vue','element-ui'] 
  },
  output: {
    path: path.resolve(__dirname, 'static/js'), // 打包后文件输出的位置
    filename: '[name].dll.js',
    library: '[name]_library' 
     // 这里需要和webpack.DllPlugin中的`name: '[name]_library',`保持一致。
  },
  plugins: [
    new webpack.DllPlugin({
      path: path.resolve(__dirname, '[name]-manifest.json'),
      name: '[name]_library', 
      context: __dirname
    })
  ]
};

package.json中配置如下命令

"dll": "webpack --config build/webpack.dll.config.js"

接下来在我们的webpack.config.js中增加以下代码

module.exports = {
  plugins: [
    new webpack.DllReferencePlugin({
      context: __dirname,
      manifest: require('./vendor-manifest.json')
    }),
    new CopyWebpackPlugin([ // 拷贝生成的文件到dist目录 这样每次不必手动去cv
      {from: 'static', to:'static'}
    ]),
  ]
};

执行

npm run dll

会发现生成了我们需要的集合第三地方
代码的vendor.dll.js
我们需要在html文件中手动引入这个js文件

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>老yuan</title>
  <script data-original="static/js/vendor.dll.js"></script>
</head>
<body>
  <div id="app"></div>
</body>
</html>

这样如果我们没有更新第三方依赖包,就不必npm run dll。直接执行npm run dev npm run build的时候会发现我们的打包速度明显有所提升。因为我们已经通过dllPlugin将第三方依赖包抽离出来了。

2.5.1.6 配置缓存

我们每次执行构建都会把所有的文件都重复编译一遍,这样的重复工作是否可以被缓存下来呢,答案是可以的,目前大部分 loader 都提供了cache 配置项。比如在 babel-loader 中,可以通过设置cacheDirectory 来开启缓存,babel-loader?cacheDirectory=true 就会将每次的编译结果写进硬盘文件(默认是在项目根目录下的node_modules/.cache/babel-loader目录内,当然你也可以自定义)
<p>但如果 loader 不支持缓存呢?我们也有方法,我们可以通过cache-loader ,它所做的事情很简单,就是 babel-loader 开启 cache 后做的事情,将 loader 的编译结果写入硬盘缓存。再次构建会先比较一下,如果文件较之前的没有发生变化则会直接使用缓存。使用方法如官方 demo 所示,在一些性能开销较大的 loader 之前添加此 loader即可</p>
npm i -D cache-loader

carbon-5.png

2.5.2 优化打包文件体积

打包的速度我们是进行了优化,但是打包后的文件体积却是十分大,造成了页面加载缓慢,浪费流量等,接下来让我们从文件体积上继续优化

2.5.2.1 引入webpack-bundle-analyzer分析打包后的文件

webpack-bundle-analyzer将打包后的内容束展示为方便交互的直观树状图,让我们知道我们所构建包中真正引入的内容

npm i -D webpack-bundle-analyzer

carbon-6.png
接下来在package.json里配置启动命令

"analyz": "NODE_ENV=production npm_config_report=true npm run build" 

windows请安装npm i -D cross-env

"analyz": "cross-env NODE_ENV=production npm_config_report=true npm run build" 

接下来npm run analyz浏览器会自动打开文件依赖图的网页

2.5.2.3 externals

按照官方文档的解释,如果我们想引用一个库,但是又不想让webpack打包,并且又不影响我们在程序中以CMD、AMD或者window/global全局等方式进行使用,那就可以通过配置Externals。这个功能主要是用在创建一个库的时候用的,但是也可以在我们项目开发中充分使用
Externals的方式,我们将这些不需要打包的静态资源从构建逻辑中剔除出去,而使用 CDN
的方式,去引用它们。
<p>有时我们希望我们通过script引入的库,如用CDN的方式引入的jquery,我们在使用时,依旧用require的方式来使用,但是却不希望webpack将它又编译进文件中。这里官网案例已经足够清晰明了,大家有兴趣可以点击了解 </p>

webpack
官网案例如下

<script
  data-original="https://code.jquery.com/jquery-3.1.0.js"
  integrity="sha256-slogkvB1K3VOkzAI8QITxV3VzpOnkeNVsKvtkYLMjfk="
  crossorigin="anonymous">
</script>
module.exports = {
  //...
  externals: {
    jquery: 'jQuery'
  }
};
import $ from 'jquery';
$('.my-element').animate(/* ... */);

2.5.2.3 Tree-shaking

这里单独提一下tree-shaking,是因为这里有个坑。tree-shaking的主要作用是用来清除代码中无用的部分。目前在webpack4 我们设置modeproduction的时候已经自动开启了tree-shaking。但是要想使其生效,生成的代码必须是ES6模块。不能使用其它类型的模块如CommonJS之流。如果使用Babel的话,这里有一个小问题,因为Babel的预案(preset)默认会将任何模块类型都转译成CommonJS类型。修正这个问题也很简单,在.babelrc文件或在webpack.config.js文件中设置modules: false就好了
// .babelrc
{
  "presets": [
    ["@babel/preset-env",
      {
        "modules": false
      }
    ]
  ]
}

或者

// webpack.config.js

module: {
    rules: [
        {
            test: /\.js$/,
            use: {
                loader: 'babel-loader',
                options: {
                    presets: ['@babel/preset-env', { modules: false }]
                }
            },
            exclude: /(node_modules)/
        }
    ]
}
经历过上面两个系列的洗礼,到现在我们成为了一名合格的webpack配置工程师。但是光拧螺丝,自身的可替代性还是很高,下面我们将深入webpack的原理中去

TEST3.png

3.手写webpack系列

经历过上面两个部分,我们已经可以熟练的运用相关的loader和plugin对我们的代码进行转换、解析。接下来我们自己手动实现loader与plugin,使其在平时的开发中获得更多的乐趣。

3.1 手写webpack loader

loader从本质上来说其实就是一个node模块。相当于一台榨汁机(loader)将相关类型的文件代码(code)给它。根据我们设置的规则,经过它的一系列加工后还给我们加工好的果汁(code)

loader编写原则

  • 单一原则: 每个 Loader 只做一件事;
  • 链式调用: Webpack 会按顺序链式调用每个 Loader
  • 统一原则: 遵循 Webpack 制定的设计规则和结构,输入与输出均为字符串,各个 Loader 完全独立,即插即用;

在日常开发环境中,为了方便调试我们往往会加入许多console打印。但是我们不希望在生产环境中存在打印的值。那么这里我们自己实现一个loader去除代码中的console

知识点普及之ASTAST通俗的来说,假设我们有一个文件a.js,我们对a.js里面的1000行进行一些操作处理,比如为所有的await 增加try catch,以及其他操作,但是a.js里面的代码本质上来说就是一堆字符串。那我们怎么办呢,那就是转换为带标记信息的对象(抽象语法树)我们方便进行增删改查。这个带标记的对象(抽象语法树)就是AST。这里推荐一篇不错的AST文章 AST快速入门
npm i -D @babel/parser @babel/traverse @babel/generator @babel/types
  • @babel/parser 将源代码解析成 AST
  • @babel/traverseAST节点进行递归遍历,生成一个便于操作、转换的path对象
  • @babel/generatorAST解码生成js代码
  • @babel/types通过该模块对具体的AST节点进行进行增、删、改、查

新建 drop-console.js

const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const generator = require('@babel/generator').default
const t = require('@babel/types')
module.exports=function(source){
  const ast = parser.parse(source,{ sourceType: 'module'})
  traverse(ast,{
    CallExpression(path){ 
      if(t.isMemberExpression(path.node.callee) && t.isIdentifier(path.node.callee.object, {name: "console"})){
        path.remove()
      }
    }
  })
  const output = generator(ast, {}, source);
  return output.code
}

如何使用

const path = require('path')
module.exports = {
  mode:'development',
  entry:path.resolve(__dirname,'index.js'),
  output:{
    filename:'[name].[contenthash].js',
    path:path.resolve(__dirname,'dist')
  },
  module:{
    rules:[{
      test:/\.js$/,
      use:path.resolve(__dirname,'drop-console.js')
      }
    ]
  }
}
实际上在webpack4中已经集成了去除console功能,在minimizer中可配置 去除console

附上官网 如何编写一个loader

3.2 手写webpack plugin

Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过Webpack提供的API改变输出结果。通俗来说:一盘美味的 盐豆炒鸡蛋 需要经历烧油 炒制 调味到最后的装盘等过程,而plugin相当于可以监控每个环节并进行操作,比如可以写一个少放胡椒粉plugin,监控webpack暴露出的生命周期事件(调味),在调味的时候执行少放胡椒粉操作。那么它与loader的区别是什么呢?上面我们也提到了loader的单一原则,loader只能一件事,比如说less-loader,只能解析less文件,plugin则是针对整个流程执行广泛的任务。

一个基本的plugin插件结构如下

class firstPlugin {
  constructor (options) {
    console.log('firstPlugin options', options)
  }
  apply (compiler) {
    compiler.plugin('done', compilation => {
      console.log('firstPlugin')
    ))
  }
}

module.exports = firstPlugin
compiler 、compilation是什么?
  • compiler 对象包含了 Webpack 环境所有的的配置信息。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 optionsloaderplugin。当在 webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用它来访问 webpack 的主环境。
  • compilation对象包含了当前的模块资源、编译生成资源、变化的文件等。当运行webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。

compiler和 compilation的区别在于

  • compiler代表了整个webpack从启动到关闭的生命周期,而compilation只是代表了一次新的编译过程
  • compiler和compilation暴露出许多钩子,我们可以根据实际需求的场景进行自定义处理

compiler钩子文档
compilation钩子文档

下面我们手动开发一个简单的需求,在生成打包文件之前自动生成一个关于打包出文件的大小信息

新建一个webpack-firstPlugin.js

class firstPlugin{
  constructor(options){
    this.options = options
  }
  apply(compiler){
    compiler.plugin('emit',(compilation,callback)=>{
      let str = ''
      for (let filename in compilation.assets){
        str += `文件:${filename}  大小${compilation.assets[filename]['size']()}\n`
      }
      // 通过compilation.assets可以获取打包后静态资源信息,同样也可以写入资源
      compilation.assets['fileSize.md'] = {
        source:function(){
          return str
        },
        size:function(){
          return str.length
        }
      }
      callback()
    })
  }
}
module.exports = firstPlugin

如何使用

const path = require('path')
const firstPlugin = require('webpack-firstPlugin.js')
module.exports = {
    // 省略其他代码
    plugins:[
        new firstPlugin()
    ]
}

执行 npm run build即可看到在dist文件夹中生成了一个包含打包文件信息的fileSize.md

上面两个loaderplugin案例只是一个引导,实际开发需求中的loaderplugin要考虑的方面很多,建议大家自己多动手尝试一下。

附上官网 如何编写一个plugin

3.3 手写webpack

由于篇幅过长,且原理深入较多。鉴于本篇以快速上手应用于实际开发的原则,这里决定另起一篇新的文章去详细剖析webpack原理以及实现一个demo版本。待格式校准后,将会贴出文章链接在下方

4. webpack5.0的时代

无论是前端框架还是构建工具的更新速度远远超乎了我们的想象,前几年的jquery一把梭的时代一去不复返。我们要拥抱的是不断更新迭代的vue、react、node、serverless、docker、k8s....


不甘落后的webpack也已经在近日发布了 webpack 5.0.0 beta 10 版本。在之前作者也曾提过webpack5.0旨在减少配置的复杂度,使其更容易上手(webpack4的时候也说了这句话),以及一些性能上的提升

  • 使用持久化缓存提高构建性能;
  • 使用更好的算法和默认值改进长期缓存(long-term caching);
  • 清理内部结构而不引入任何破坏性的变化;
  • 引入一些breaking changes,以便尽可能长的使用v5版本。

目前来看,维护者的更新很频繁,相信用不了多久webpack5.0将会拥抱大众。感兴趣的同学可以先安装beta版本尝尝鲜。不过在此之前建议大家先对webpack4进行一番掌握,这样后面的路才会越来越好走。

查看原文

赞 80 收藏 62 评论 14

弹铁蛋同学 关注了专栏 · 2019-12-28

我说我不帅你肯定不信

能工摹形,巧匠窃意

关注 13

弹铁蛋同学 收藏了文章 · 2019-12-23

你不知道的 JSON.stringify() 的威力

image

前言

其实有很多有用的东西,当时学习了,也记住了,但是时间久了就是记不住,所以导致在日常开发中总是想不起来原来这个东西可以这么用,而去选择了更加复杂和麻烦的方式。其实我们日常学习的知识就是拿来用的,即使你今天把知识点背下来了,没有去思考这个知识点我们可以用来干嘛,不需要几天就会慢慢地忘掉。所以今天我们来了解一下在日常学习时你遗漏掉或者忘掉或者没有思考过的你不知道的 JSON.stringify() 的威力。

原文戳我Github 戳我

通过需求学习JSON.stringify()

首先我们在开发的过程当中遇到这样一个处理数据的需求

const todayILearn = {
  _id: 1,
  content: '今天学习 JSON.stringify(),我很开心!',
  created_at: 'Mon Nov 25 2019 14:03:55 GMT+0800 (中国标准时间)',
  updated_at: 'Mon Nov 25 2019 16:03:55 GMT+0800 (中国标准时间)'
}

我们需要将上面这个对象处理成下面这个对象

const todayILearn = {
  id: 1,
  content: '今天学习 JSON.stringify(),我很开心!',
  createdAt: 'Mon Nov 25 2019 14:03:55 GMT+0800 (中国标准时间)',
  updatedAt: 'Mon Nov 25 2019 16:03:55 GMT+0800 (中国标准时间)'
}

也就是在不改变属性的值的前提下,将对象属性修改一下。 把_id 改成 id,把 updated_at 改成 updatedAt,把 created_at 改成 createdAt。我们现在通过这个小小的需求来见识一下 JSON.stringify() 的强大吧。

首先要解决这个问题我们有很多种解决方式,我们先提供两种不优雅的解决方案:

  • 方案一:一次遍历+多声明一个变量
// 多一个变量存储
const todayILearnTemp = {};
for (const [key, value] of Object.entries(todayILearn)) {
  if (key === "_id") todayILearnTemp["id"] = value;
  else if (key === "created_at") todayILearnTemp["createdAt"] = value;
  else if (key === "updatedAt") todayILearnTemp["updatedAt"] = value;
  else todayILearnTemp[key] = value;
}
console.log(todayILearnTemp);
// 结果:
// { id: 1,
//  content: '今天学习 JSON.stringify(),我很开心!',
//  createdAt: 'Mon Nov 25 2019 14:03:55 GMT+0800 (中国标准时间)',
//  updated_at: 'Mon Nov 25 2019 16:03:55 GMT+0800 (中国标准时间)' 
// }

方案一完全没有问题,可以实现。但是多声明了一个变量又加上一层循环并且还有很多的 ifelse 语句,怎么都显得不太优雅。

  • 方案二:暴力 delete 属性和增加属性
// 极致的暴力美学
todayILearn.id = todayILearn._id;
todayILearn.createdAt = todayILearn.created_at;
todayILearn.updatedAt = todayILearn.updated_at;
delete todayILearn._id;
delete todayILearn.created_at;
delete todayILearn.updated_at;
console.log(todayILearn);
//     太暴力😢
//{ 
//  content: '今天学习 JSON.stringify(),我很开心!',
//  id: 1,
//  createdAt: 'Mon Nov 25 2019 14:03:55 GMT+0800 (中国标准时间)',
//  updatedAt: 'Mon Nov 25 2019 16:03:55 GMT+0800 (中国标准时间)' 
//}

直接 delete 暴力解决太粗鲁了,而且有一个缺点,属性的顺序变了。

  • 方案三:序列化+ replace 美学典范
const mapObj = {
  _id: "id",
  created_at: "createdAt",
  updated_at: "updatedAt"
};
JSON.parse(
  JSON.stringify(todayILearn).replace(
    /_id|created_at|updated_at/gi,
    matched => mapObj[matched])
    )

// { 
// id: 1,
//  content: '今天学习 JSON.stringify(),我很开心!',
//  createdAt: 'Mon Nov 25 2019 14:03:55 GMT+0800 (中国标准时间)',
//  updatedAt: 'Mon Nov 25 2019 16:03:55 GMT+0800 (中国标准时间)' 
// }

瞬间感觉非常优雅和舒服,有木有!

接下来,正片开始,我们今天将系统的学习或者说是复习一遍 JSON.stringify 的基础知识,让我们在日常开发中更加的游刃有余。


JSON.stringify() 九大特性

JSON.stringify() 第一大特性

对于 undefined、任意的函数以及 symbol 三个特殊的值分别作为对象属性的值、数组元素、单独的值时 JSON.stringify() 将返回不同的结果。

首先,我们来复习一下知识点,看一道非常简单的面试题目:请问下面代码会输出什么?

const data = {
  a: "aaa",
  b: undefined,
  c: Symbol("dd"),
  fn: function() {
    return true;
  }
};
JSON.stringify(data); // 输出:?

// "{"a":"aaa"}"

很简单这道题目面试官主要考察的知识点是:

  • undefined、任意的函数以及 symbol 作为对象属性值时 JSON.stringify() 将跳过(忽略)对它们进行序列化

面试官追问:假设 undefined、任意的函数以及 symbol 值作为数组元素会是怎样呢?

JSON.stringify(["aaa", undefined, function aa() {
    return true
  }, Symbol('dd')])  // 输出:?

// "["aaa",null,null,null]"

知识点是:

  • undefined、任意的函数以及 symbol 作为数组元素值时,JSON.stringify() 会将它们序列化为 null

我们稍微再动下脑筋,如果单独序列化这些值会是什么样的结果呢?

JSON.stringify(function a (){console.log('a')})
// undefined
JSON.stringify(undefined)
// undefined
JSON.stringify(Symbol('dd'))
// undefined

单独转换的结果就是:

  • undefined、任意的函数以及 symbolJSON.stringify() 作为单独的值进行序列化时都会返回 undefined

JSON.stringify() 第一大特性总结

  • undefined、任意的函数以及 symbol 作为对象属性值时 JSON.stringify() 对跳过(忽略)它们进行序列化
  • undefined、任意的函数以及 symbol 作为数组元素值时,JSON.stringify() 将会将它们序列化为 null
  • undefined、任意的函数以及 symbolJSON.stringify() 作为单独的值进行序列化时,都会返回 undefined

JSON.stringify() 第二大特性

也是在使用过程中必须要非常注意的一个点:

  • 非数组对象的属性不能保证以特定的顺序出现在序列化后的字符串中。
const data = {
  a: "aaa",
  b: undefined,
  c: Symbol("dd"),
  fn: function() {
    return true;
  },
  d: "ddd"
};
JSON.stringify(data); // 输出:?
// "{"a":"aaa","d":"ddd"}"

JSON.stringify(["aaa", undefined, function aa() {
    return true
  }, Symbol('dd'),"eee"])  // 输出:?

// "["aaa",null,null,null,"eee"]"

正如我们在第一特性所说,JSON.stringify() 序列化时会忽略一些特殊的值,所以不能保证序列化后的字符串还是以特定的顺序出现(数组除外)。

JSON.stringify() 第三大特性

  • 转换值如果有 toJSON() 函数,该函数返回什么值,序列化结果就是什么值,并且忽略其他属性的值。
JSON.stringify({
    say: "hello JSON.stringify",
    toJSON: function() {
      return "today i learn";
    }
  })
// "today i learn"

JSON.stringify() 第四大特性

  • JSON.stringify() 将会正常序列化 Date 的值。
JSON.stringify({ now: new Date() });
// "{"now":"2019-12-08T07:42:11.973Z"}"

实际上 Date 对象自己部署了 toJSON() 方法(同Date.toISOString()),因此 Date 对象会被当做字符串处理。

JSON.stringify() 第五大特性

  • NaNInfinity 格式的数值及 null 都会被当做 null

直接上代码:

JSON.stringify(NaN)
// "null"
JSON.stringify(null)
// "null"
JSON.stringify(Infinity)
// "null"

JSON.stringify() 第六大特性

关于基本类型的序列化:

  • 布尔值、数字、字符串的包装对象在序列化过程中会自动转换成对应的原始值。
JSON.stringify([new Number(1), new String("false"), new Boolean(false)]);
// "[1,"false",false]"

JSON.stringify() 第七大特性

关于对象属性的是否可枚举:

  • 其他类型的对象,包括 Map/Set/WeakMap/WeakSet,仅会序列化可枚举的属性。
// 不可枚举的属性默认会被忽略:
JSON.stringify( 
    Object.create(
        null, 
        { 
            x: { value: 'json', enumerable: false }, 
            y: { value: 'stringify', enumerable: true } 
        }
    )
);
// "{"y","stringify"}"

JSON.stringify() 第八大特性

我们都知道实现深拷贝最简单粗暴的方式就是序列化:JSON.parse(JSON.stringify()),这个方式实现深拷贝会因为序列化的诸多特性从而导致诸多的坑点:比如现在我们要说的循环引用问题。

// 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。 
const obj = {
  name: "loopObj"
};
const loopObj = {
  obj
};
// 对象之间形成循环引用,形成闭环
obj.loopObj = loopObj;
function deepClone(obj) {
  return JSON.parse(JSON.stringify(obj));
}
deepClone(obj)
/**
 VM44:9 Uncaught TypeError: Converting circular structure to JSON
    --> starting at object with constructor 'Object'
    |     property 'loopObj' -> object with constructor 'Object'
    --- property 'obj' closes the circle
    at JSON.stringify (<anonymous>)
    at deepClone (<anonymous>:9:26)
    at <anonymous>:11:13
 */
  • 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。

这也就是为什么用序列化去实现深拷贝时,遇到循环引用的对象会抛出错误的原因。

JSON.stringify() 第九大特性

最后,关于 symbol 属性还有一点要说的就是:

  • 所有以 symbol 为属性键的属性都会被完全忽略掉,即便 replacer 参数中强制指定包含了它们。
JSON.stringify({ [Symbol.for("json")]: "stringify" }, function(k, v) {
    if (typeof k === "symbol") {
      return v;
    }
  })

// undefined

replacerJSON.stringify() 的第二个参数,我们比较少用到,所以很多时候我们会忘记 JSON.stringify() 第二个、第三个参数,场景不多,但是用的好的话会非常方便,关于 JSON.stringify() 第九大特性的例子中对 replacer 参数不明白的同学先别急,其实很简单,我们马上就会在下面的学习中弄懂。

第二个参数和第三个参数

强大的第二个参数 replacer

replacer 参数有两种形式,可以是一个函数或者一个数组。作为函数时,它有两个参数,键(key)和值(value),函数类似就是数组方法 mapfilter 等方法的回调函数,对每一个属性值都会执行一次该函数。如果 replacer 是一个数组,数组的值代表将被序列化成 JSON 字符串的属性名。

replacer 作为函数时

可以打破九大特性的大多数特性

第二个参数replacer 非常强大, replacer 作为函数时,我们可以打破九大特性的大多数特性,我们直接来看代码吧。

const data = {
  a: "aaa",
  b: undefined,
  c: Symbol("dd"),
  fn: function() {
    return true;
  }
};
// 不用 replacer 参数时
JSON.stringify(data); 

// "{"a":"aaa"}"
// 使用 replacer 参数作为函数时
JSON.stringify(data, (key, value) => {
  switch (true) {
    case typeof value === "undefined":
      return "undefined";
    case typeof value === "symbol":
      return value.toString();
    case typeof value === "function":
      return value.toString();
    default:
      break;
  }
  return value;
})
// "{"a":"aaa","b":"undefined","c":"Symbol(dd)","fn":"function() {\n    return true;\n  }"}"

虽然使用 toString() 方法有点耍流氓的意思但是不得不说第二个参数很强大。

传入 replacer 函数的第一个参数

需要注意的是,replacer 被传入的函数时,第一个参数不是对象的第一个键值对,而是空字符串作为 key 值,value 值是整个对象的键值对:

const data = {
  a: 2,
  b: 3,
  c: 4,
  d: 5
};
JSON.stringify(data, (key, value) => {
  console.log(value);
  return value;
})
// 第一个被传入 replacer 函数的是 {"":{a: 2, b: 3, c: 4, d: 5}}
// {a: 2, b: 3, c: 4, d: 5}   
// 2
// 3
// 4
// 5

实现 map 函数

我们还可以用它来手写实现一个对象的类似 map 的函数。

// 实现一个 map 函数
const data = {
  a: 2,
  b: 3,
  c: 4,
  d: 5
};
const objMap = (obj, fn) => {
  if (typeof fn !== "function") {
    throw new TypeError(`${fn} is not a function !`);
  }
  return JSON.parse(JSON.stringify(obj, fn));
};
objMap(data, (key, value) => {
  if (value % 2 === 0) {
    return value / 2;
  }
  return value;
});
// {a: 1, b: 3, c: 2, d: 5}

replacer 作为数组时

replacer 作为数组时,结果非常简单,数组的值就代表了将被序列化成 JSON 字符串的属性名。

const jsonObj = {
  name: "JSON.stringify",
  params: "obj,replacer,space"
};

// 只保留 params 属性的值
JSON.stringify(jsonObj, ["params"]);
// "{"params":"obj,replacer,space"}" 

有意思却没啥用的第三个参数 space

space 参数用来控制结果字符串里面的间距。首先看一个例子就是到这东西到底是干啥用的:

const tiedan = {
  name: "弹铁蛋同学",
  describe: "今天在学 JSON.stringify()",
  emotion: "like shit"
};
JSON.stringify(tiedan, null, "🐷");
// 接下来是输出结果
// "{
// 🐷"name": "弹铁蛋同学",
// 🐷"describe": "今天在学 JSON.stringify()",
// 🐷"emotion": "like shit"
// }"
JSON.stringify(tiedan, null, 2);
// "{
//   "name": "弹铁蛋同学",
//   "describe": "今天在学 JSON.stringify()",
//   "emotion": "like shit"
// }"

上面代码一眼就能看出第三个参数的作用了,花里胡哨的,其实这个参数还是比较鸡肋的,除了好看没啥特别的用处。我们用 \t\n 等缩进能让输出更加格式化,更适于观看。

  • 如果是一个数字, 则在字符串化时每一级别会比上一级别缩进多这个数字值的空格(最多10个空格);
  • 如果是一个字符串,则每一级别会比上一级别多缩进该字符串(或该字符串的前10个字符)。

总结

JSON.stringify() 九大特性:

一、对于 undefined、任意的函数以及 symbol 三个特殊的值分别作为对象属性的值、数组元素、单独的值时的不同返回结果。

  • undefined、任意的函数以及 symbol 作为对象属性值时 JSON.stringify() 跳过(忽略)对它们进行序列化
  • undefined、任意的函数以及 symbol 作为数组元素值时,JSON.stringify() 将会将它们序列化为 null
  • undefined、任意的函数以及 symbolJSON.stringify() 作为单独的值进行序列化时都会返回 undefined

二、非数组对象的属性不能保证以特定的顺序出现在序列化后的字符串中。

三、转换值如果有 toJSON() 函数,该函数返回什么值,序列化结果就是什么值,并且忽略其他属性的值。

四、JSON.stringify() 将会正常序列化 Date 的值。

五、NaNInfinity 格式的数值及 null 都会被当做 null

六、布尔值、数字、字符串的包装对象在序列化过程中会自动转换成对应的原始值。

七、其他类型的对象,包括 Map/Set/WeakMap/WeakSet,仅会序列化可枚举的属性。

八、对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。

九、所有以 symbol 为属性键的属性都会被完全忽略掉,即便 replacer 参数中强制指定包含了它们。

JSON.stringify() 第二个参数和第三个参数

强大的第二个参数:

  • 作为函数时,它有两个参数,键(key)和值(value),函数类似就是数组方法 mapfilter 等方法的回调函数,对每一个属性值都会执行一次该函数(期间我们还简单实现过一个 map 函数)。
  • 如果 replacer 是一个数组,数组的值代表将被序列化成 JSON 字符串的属性名。

华丽的第三个参数:

  • 如果是一个数字, 则在字符串化时每一级别会比上一级别缩进多这个数字值的空格(最多10个空格);
  • 如果是一个字符串,则每一级别会比上一级别多缩进该字符串(或该字符串的前10个字符)。

注意:

第一个例子的方案三,有小伙伴提示说这个方案会有风险,确实是这样的(可能会把对象的值给替换掉)。大家慎用吧,大部分情况下这样使用是 ok 的。小伙伴们提供的第四种方案还是很不错的:

const todayILearn = {
  _id: 1,
  content: '今天学习 JSON.stringify(),我很开心!',
  created_at: 'Mon Nov 25 2019 14:03:55 GMT+0800 (中国标准时间)',
  updated_at: 'Mon Nov 25 2019 16:03:55 GMT+0800 (中国标准时间)'
}
const mapObj = {
  _id: "id",
  created_at: "createdAt",
  updated_at: "updatedAt"
};
Object.fromEntries(Object.entries(todayILearn).map(([k, v]) => [mapObj[k]||k, v]))
// { 
// id: 1,
//  content: '今天学习 JSON.stringify(),我很开心!',
//  createdAt: 'Mon Nov 25 2019 14:03:55 GMT+0800 (中国标准时间)',
//  updatedAt: 'Mon Nov 25 2019 16:03:55 GMT+0800 (中国标准时间)' 
// }

推荐阅读

面试官连环追问:数组拍平(扁平化) flat 方法实现

查看原文

弹铁蛋同学 收藏了文章 · 2019-12-22

面试官连环追问:数组拍平(扁平化) flat 方法实现

前言

前段时间秋招面哔哩哔哩的时候,面试官问:如何实现 flat 方法?当时手写的并不完美,后来回盘复习,发现面试要求手写数组拍平(扁平化) flat 方法的面试官不在少数。其中包括:拼多多、小米、美团、滴滴、shopee、有赞等。手写 flat 方法是一道非常基础的面试题,通常出现在笔试或者第一轮面试当中,主要考察基本的手写代码的能力。今天就从了解 flat 特性到实现 flat 再到接住面试官的连环追问重新学习一遍数组拍平(扁平化) flat 方法吧。

Github 戳我

一段代码总结 Array.prototype.flat() 特性

注:数组拍平方法 Array.prototype.flat() 也叫数组扁平化、数组拉平、数组降维。 本文统一叫:数组拍平
const animals = ["🐷", ["🐶", "🐂"], ["🐎", ["🐑", ["🐲"]], "🐛"]];

// 不传参数时,默认“拉平”一层
animals.flat();
// ["🐷", "🐶", "🐂", "🐎", ["🐑", ["🐲"]], "🐛"]

// 传入一个整数参数,整数即“拉平”的层数
animals.flat(2);
// ["🐷", "🐶", "🐂", "🐎", "🐑", ["🐲"], "🐛"]

// Infinity 关键字作为参数时,无论多少层嵌套,都会转为一维数组
animals.flat(Infinity);
// ["🐷", "🐶", "🐂", "🐎", "🐑", "🐲", "🐛"]

// 传入 <=0 的整数将返回原数组,不“拉平”
animals.flat(0);
animals.flat(-10);
// ["🐷", ["🐶", "🐂"], ["🐎", ["🐑", ["🐲"]], "🐛"]];

// 如果原数组有空位,flat()方法会跳过空位。
["🐷", "🐶", "🐂", "🐎",,].flat();
// ["🐷", "🐶", "🐂", "🐎"]

Array.prototype.flat() 特性总结

  • Array.prototype.flat() 用于将嵌套的数组“拉平”,变成一维的数组。该方法返回一个新数组,对原数据没有影响。
  • 不传参数时,默认“拉平”一层,可以传入一个整数,表示想要“拉平”的层数。
  • 传入 <=0 的整数将返回原数组,不“拉平”
  • Infinity 关键字作为参数时,无论多少层嵌套,都会转为一维数组
  • 如果原数组有空位,Array.prototype.flat() 会跳过空位。

面试官 N 连问

第一问:实现一个简单的数组拍平 flat 函数

首先,我们将花一点篇幅来探讨如何实现一个简单的数组拍平 flat 函数,详细介绍多种实现的方案,然后再尝试接住面试官的连环追问。

实现思路

如何实现呢,思路非常简单:实现一个有数组拍平功能的 flat 函数,我们要做的就是在数组中找到是数组类型的元素,然后将他们展开。这就是实现数组拍平 flat 方法的关键思路。

有了思路,我们就需要解决实现这个思路需要克服的困难:

  • 第一个要解决的就是遍历数组的每一个元素;
  • 第二个要解决的就是判断元素是否是数组;
  • 第三个要解决的就是将数组的元素展开一层;

遍历数组的方案

遍历数组并取得数组元素的方法非常之多,包括且不限于下面几种

  • for 循环
  • for...of
  • for...in
  • forEach()
  • entries()
  • keys()
  • values()
  • reduce()
  • map()
const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }];
// 遍历数组的方法有太多,本文只枚举常用的几种
// for 循环
for (let i = 0; i < arr.length; i++) {
  console.log(arr[i]);
}
// for...of
for (let value of arr) {
  console.log(value);
}
// for...in
for (let i in arr) {
  console.log(arr[i]);
}
// forEach 循环
arr.forEach(value => {
  console.log(value);
});
// entries()
for (let [index, value] of arr.entries()) {
  console.log(value);
}
// keys()
for (let index of arr.keys()) {
  console.log(arr[index]);
}
// values()
for (let value of arr.values()) {
  console.log(value);
}
// reduce()
arr.reduce((pre, cur) => {
  console.log(cur);
}, []);
// map()
arr.map(value => console.log(value));

只要是能够遍历数组取到数组中每一个元素的方法,都是一种可行的解决方案。

判断元素是数组的方案

  • instanceof
  • constructor
  • Object.prototype.toString
  • isArray
const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }];
arr instanceof Array
// true
arr.constructor === Array
// true
Object.prototype.toString.call(arr) === '[object Array]'
// true
Array.isArray(arr)
// true

说明

  • instanceof 操作符是假定只有一种全局环境,如果网页中包含多个框架,多个全局环境,如果你从一个框架向另一个框架传入一个数组,那么传入的数组与在第二个框架中原生创建的数组分别具有各自不同的构造函数。(所以在这种情况下会不准确)
  • typeof 操作符对数组取类型将返回 object
  • 因为 constructor 可以被重写,所以不能确保一定是数组。

    const str = 'abc';
    str.constructor = Array;
    str.constructor === Array 
    // true

将数组的元素展开一层的方案

  • 扩展运算符 + concat

    concat() 方法用于合并两个或多个数组,在拼接的过程中加上扩展运算符会展开一层数组。详细见下面的代码。

  • concat + apply

主要是利用 apply 在绑定作用域时,传入的第二个参数是一个数组或者类数组对象,其中的数组元素将作为单独的参数传给 func 函数。也就是在调用 apply 函数的过程中,会将传入的数组一个一个的传入到要执行的函数中,也就是相当对数组进行了一层的展开。

  • toString + split

不推荐使用 toString + split 方法,因为操作字符串是和危险的事情,在上一文章中我做了一个操作字符串的案例还被许多小伙伴们批评了。如果数组中的元素所有都是数字的话,toString + split 是可行的,并且是一步搞定。

const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }];
// 扩展运算符 + concat
[].concat(...arr)
// [1, 2, 3, 4, 1, 2, 3, [1, 2, 3, [1, 2, 3]], 5, "string", { name: "弹铁蛋同学" }];

// concat + apply
[].concat.apply([], arr);
// [1, 2, 3, 4, 1, 2, 3, [1, 2, 3, [1, 2, 3]], 5, "string", { name: "弹铁蛋同学" }];

// toString  + split
const arr2 =[1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]]]
arr2.toString().split(',').map(v=>parseInt(v))
// [1, 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3]

总结完要解决的三大困难,那我们就可以非常轻松的实现一版数组拍平 flat 函数了。

const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }];
// concat + 递归
function flat(arr) {
  let arrResult = [];
  arr.forEach(item => {
    if (Array.isArray(item)) {
      arrResult = arrResult.concat(arguments.callee(item));   // 递归
      // 或者用扩展运算符
      // arrResult.push(...arguments.callee(item));
    } else {
      arrResult.push(item);
    }
  });
  return arrResult;
}
flat(arr)
// [1, 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3, 5, "string", { name: "弹铁蛋同学" }];

到这里,恭喜你成功得到了面试官对你手撕代码能力的基本认可🎉。但是面试官往往会不止于此,将继续考察面试者的各种能力。

第二问:用 reduce 实现 flat 函数

我见过很多的面试官都很喜欢点名道姓的要面试者直接用 reduce 去实现 flat 函数。想知道为什么?文章后半篇我们考虑数组空位的情况的时候就知道为啥了。其实思路也是一样的。

const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }]

// 首先使用 reduce 展开一层
arr.reduce((pre, cur) => pre.concat(cur), []);
// [1, 2, 3, 4, 1, 2, 3, [1, 2, 3, [1, 2, 3]], 5, "string", { name: "弹铁蛋同学" }];

// 用 reduce 展开一层 + 递归
const flat = arr => {
  return arr.reduce((pre, cur) => {
    return pre.concat(Array.isArray(cur) ? flat(cur) : cur);
  }, []);
};
// [1, 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3, 5, "string", { name: "弹铁蛋同学" }];

第三问:使用栈的思想实现 flat 函数

// 栈思想
function flat(arr) {
  const result = []; 
  const stack = [].concat(arr);  // 将数组元素拷贝至栈,直接赋值会改变原数组
  //如果栈不为空,则循环遍历
  while (stack.length !== 0) {
    const val = stack.pop(); 
    if (Array.isArray(val)) {
      stack.push(...val); //如果是数组再次入栈,并且展开了一层
    } else {
      result.unshift(val); //如果不是数组就将其取出来放入结果数组中
    }
  }
  return result;
}
const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }]
flat(arr)
// [1, 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3, 5, "string", { name: "弹铁蛋同学" }];

第四问:通过传入整数参数控制“拉平”层数

// reduce + 递归
function flat(arr, num = 1) {
  return num > 0
    ? arr.reduce(
        (pre, cur) =>
          pre.concat(Array.isArray(cur) ? flat(cur, num - 1) : cur),
        []
      )
    : arr.slice();
}
const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }]
flat(arr, Infinity);
// [1, 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3, 5, "string", { name: "弹铁蛋同学" }];

第五问:使用 Generator 实现 flat 函数

function* flat(arr, num) {
  if (num === undefined) num = 1;
  for (const item of arr) {
    if (Array.isArray(item) && num > 0) {   // num > 0
      yield* flat(item, num - 1);
    } else {
      yield item;
    }
  }
}
const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }]
// 调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象。
// 也就是遍历器对象(Iterator Object)。所以我们要用一次扩展运算符得到结果
[...flat(arr, Infinity)]    
// [1, 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3, 5, "string", { name: "弹铁蛋同学" }];

第六问:实现在原型链上重写 flat 函数

Array.prototype.fakeFlat = function(num = 1) {
  if (!Number(num) || Number(num) < 0) {
    return this;
  }
  let arr = this.concat();    // 获得调用 fakeFlat 函数的数组
  while (num > 0) {           
    if (arr.some(x => Array.isArray(x))) {
      arr = [].concat.apply([], arr);    // 数组中还有数组元素的话并且 num > 0,继续展开一层数组 
    } else {
      break; // 数组中没有数组元素并且不管 num 是否依旧大于 0,停止循环。
    }
    num--;
  }
  return arr;
};
const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }]
arr.fakeFlat(Infinity)
// [1, 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3, 5, "string", { name: "弹铁蛋同学" }];

第七问:考虑数组空位的情况

由最开始我们总结的 flat 特性知道,flat 函数执行是会跳过空位的。ES5 大多数数组方法对空位的处理都会选择跳过空位包括:forEach(), filter(), reduce(), every()some() 都会跳过空位。

所以我们可以利用上面几种方法来实现 flat 跳过空位的特性

// reduce + 递归
Array.prototype.fakeFlat = function(num = 1) {
  if (!Number(num) || Number(num) < 0) {
    return this;
  }
  let arr = [].concat(this);
  return num > 0
    ? arr.reduce(
        (pre, cur) =>
          pre.concat(Array.isArray(cur) ? cur.fakeFlat(--num) : cur),
        []
      )
    : arr.slice();
};
const arr = [1, [3, 4], , ,];
arr.fakeFlat()
// [1, 3, 4]

// foEach + 递归
Array.prototype.fakeFlat = function(num = 1) {
  if (!Number(num) || Number(num) < 0) {
    return this;
  }
  let arr = [];
  this.forEach(item => {
    if (Array.isArray(item)) {
      arr = arr.concat(item.fakeFlat(--num));
    } else {
      arr.push(item);
    }
  });
  return arr;
};
const arr = [1, [3, 4], , ,];
arr.fakeFlat()
// [1, 3, 4]

扩展阅读:由于空位的处理规则非常不统一,所以建议避免出现空位。

ES5 对空位的处理,就非常不一致,大多数情况下会忽略空位。

  • forEach(), filter(), reduce(), every()some() 都会跳过空位。
  • map() 会跳过空位,但会保留这个值。
  • join()toString() 会将空位视为 undefined,而undefinednull 会被处理成空字符串。

ES6 明确将空位转为 undefined

  • entries()keys()values()find()findIndex() 会将空位处理成 undefined
  • for...of 循环会遍历空位。
  • fill() 会将空位视为正常的数组位置。
  • copyWithin() 会连空位一起拷贝。
  • 扩展运算符(...)也会将空位转为 undefined
  • Array.from 方法会将数组的空位,转为 undefined

总结

面试官现场考察一道写代码的题目,其实不仅仅是写代码,在写代码的过程中会遇到各种各样的知识点和代码的边界情况。虽然大多数情况下,面试官不会那么变态,就 flat 实现去连续追问面试者,并且手撕好几个版本,但面试官会要求在你写的那版代码的基础上再写出一个更完美的版本是常有的事情。只有我们沉下心来把基础打扎实,不管面试官如何追问,我们都能自如的应对。flat 的实现绝对不会只有文中列出的这几个版本,敲出自己的代码是最好的进步,在评论区或者在 issue 中写出你自己的版本吧!

Today i learned.如果你学到了就点波关注点个赞👍吧

推荐阅读

你不知道的 JSON.stringify() 的威力

查看原文

弹铁蛋同学 发布了文章 · 2019-12-22

面试官连环追问:数组拍平(扁平化) flat 方法实现

前言

前段时间秋招面哔哩哔哩的时候,面试官问:如何实现 flat 方法?当时手写的并不完美,后来回盘复习,发现面试要求手写数组拍平(扁平化) flat 方法的面试官不在少数。其中包括:拼多多、小米、美团、滴滴、shopee、有赞等。手写 flat 方法是一道非常基础的面试题,通常出现在笔试或者第一轮面试当中,主要考察基本的手写代码的能力。今天就从了解 flat 特性到实现 flat 再到接住面试官的连环追问重新学习一遍数组拍平(扁平化) flat 方法吧。

Github 戳我

一段代码总结 Array.prototype.flat() 特性

注:数组拍平方法 Array.prototype.flat() 也叫数组扁平化、数组拉平、数组降维。 本文统一叫:数组拍平
const animals = ["🐷", ["🐶", "🐂"], ["🐎", ["🐑", ["🐲"]], "🐛"]];

// 不传参数时,默认“拉平”一层
animals.flat();
// ["🐷", "🐶", "🐂", "🐎", ["🐑", ["🐲"]], "🐛"]

// 传入一个整数参数,整数即“拉平”的层数
animals.flat(2);
// ["🐷", "🐶", "🐂", "🐎", "🐑", ["🐲"], "🐛"]

// Infinity 关键字作为参数时,无论多少层嵌套,都会转为一维数组
animals.flat(Infinity);
// ["🐷", "🐶", "🐂", "🐎", "🐑", "🐲", "🐛"]

// 传入 <=0 的整数将返回原数组,不“拉平”
animals.flat(0);
animals.flat(-10);
// ["🐷", ["🐶", "🐂"], ["🐎", ["🐑", ["🐲"]], "🐛"]];

// 如果原数组有空位,flat()方法会跳过空位。
["🐷", "🐶", "🐂", "🐎",,].flat();
// ["🐷", "🐶", "🐂", "🐎"]

Array.prototype.flat() 特性总结

  • Array.prototype.flat() 用于将嵌套的数组“拉平”,变成一维的数组。该方法返回一个新数组,对原数据没有影响。
  • 不传参数时,默认“拉平”一层,可以传入一个整数,表示想要“拉平”的层数。
  • 传入 <=0 的整数将返回原数组,不“拉平”
  • Infinity 关键字作为参数时,无论多少层嵌套,都会转为一维数组
  • 如果原数组有空位,Array.prototype.flat() 会跳过空位。

面试官 N 连问

第一问:实现一个简单的数组拍平 flat 函数

首先,我们将花一点篇幅来探讨如何实现一个简单的数组拍平 flat 函数,详细介绍多种实现的方案,然后再尝试接住面试官的连环追问。

实现思路

如何实现呢,思路非常简单:实现一个有数组拍平功能的 flat 函数,我们要做的就是在数组中找到是数组类型的元素,然后将他们展开。这就是实现数组拍平 flat 方法的关键思路。

有了思路,我们就需要解决实现这个思路需要克服的困难:

  • 第一个要解决的就是遍历数组的每一个元素;
  • 第二个要解决的就是判断元素是否是数组;
  • 第三个要解决的就是将数组的元素展开一层;

遍历数组的方案

遍历数组并取得数组元素的方法非常之多,包括且不限于下面几种

  • for 循环
  • for...of
  • for...in
  • forEach()
  • entries()
  • keys()
  • values()
  • reduce()
  • map()
const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }];
// 遍历数组的方法有太多,本文只枚举常用的几种
// for 循环
for (let i = 0; i < arr.length; i++) {
  console.log(arr[i]);
}
// for...of
for (let value of arr) {
  console.log(value);
}
// for...in
for (let i in arr) {
  console.log(arr[i]);
}
// forEach 循环
arr.forEach(value => {
  console.log(value);
});
// entries()
for (let [index, value] of arr.entries()) {
  console.log(value);
}
// keys()
for (let index of arr.keys()) {
  console.log(arr[index]);
}
// values()
for (let value of arr.values()) {
  console.log(value);
}
// reduce()
arr.reduce((pre, cur) => {
  console.log(cur);
}, []);
// map()
arr.map(value => console.log(value));

只要是能够遍历数组取到数组中每一个元素的方法,都是一种可行的解决方案。

判断元素是数组的方案

  • instanceof
  • constructor
  • Object.prototype.toString
  • isArray
const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }];
arr instanceof Array
// true
arr.constructor === Array
// true
Object.prototype.toString.call(arr) === '[object Array]'
// true
Array.isArray(arr)
// true

说明

  • instanceof 操作符是假定只有一种全局环境,如果网页中包含多个框架,多个全局环境,如果你从一个框架向另一个框架传入一个数组,那么传入的数组与在第二个框架中原生创建的数组分别具有各自不同的构造函数。(所以在这种情况下会不准确)
  • typeof 操作符对数组取类型将返回 object
  • 因为 constructor 可以被重写,所以不能确保一定是数组。

    const str = 'abc';
    str.constructor = Array;
    str.constructor === Array 
    // true

将数组的元素展开一层的方案

  • 扩展运算符 + concat

    concat() 方法用于合并两个或多个数组,在拼接的过程中加上扩展运算符会展开一层数组。详细见下面的代码。

  • concat + apply

主要是利用 apply 在绑定作用域时,传入的第二个参数是一个数组或者类数组对象,其中的数组元素将作为单独的参数传给 func 函数。也就是在调用 apply 函数的过程中,会将传入的数组一个一个的传入到要执行的函数中,也就是相当对数组进行了一层的展开。

  • toString + split

不推荐使用 toString + split 方法,因为操作字符串是和危险的事情,在上一文章中我做了一个操作字符串的案例还被许多小伙伴们批评了。如果数组中的元素所有都是数字的话,toString + split 是可行的,并且是一步搞定。

const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }];
// 扩展运算符 + concat
[].concat(...arr)
// [1, 2, 3, 4, 1, 2, 3, [1, 2, 3, [1, 2, 3]], 5, "string", { name: "弹铁蛋同学" }];

// concat + apply
[].concat.apply([], arr);
// [1, 2, 3, 4, 1, 2, 3, [1, 2, 3, [1, 2, 3]], 5, "string", { name: "弹铁蛋同学" }];

// toString  + split
const arr2 =[1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]]]
arr2.toString().split(',').map(v=>parseInt(v))
// [1, 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3]

总结完要解决的三大困难,那我们就可以非常轻松的实现一版数组拍平 flat 函数了。

const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }];
// concat + 递归
function flat(arr) {
  let arrResult = [];
  arr.forEach(item => {
    if (Array.isArray(item)) {
      arrResult = arrResult.concat(arguments.callee(item));   // 递归
      // 或者用扩展运算符
      // arrResult.push(...arguments.callee(item));
    } else {
      arrResult.push(item);
    }
  });
  return arrResult;
}
flat(arr)
// [1, 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3, 5, "string", { name: "弹铁蛋同学" }];

到这里,恭喜你成功得到了面试官对你手撕代码能力的基本认可🎉。但是面试官往往会不止于此,将继续考察面试者的各种能力。

第二问:用 reduce 实现 flat 函数

我见过很多的面试官都很喜欢点名道姓的要面试者直接用 reduce 去实现 flat 函数。想知道为什么?文章后半篇我们考虑数组空位的情况的时候就知道为啥了。其实思路也是一样的。

const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }]

// 首先使用 reduce 展开一层
arr.reduce((pre, cur) => pre.concat(cur), []);
// [1, 2, 3, 4, 1, 2, 3, [1, 2, 3, [1, 2, 3]], 5, "string", { name: "弹铁蛋同学" }];

// 用 reduce 展开一层 + 递归
const flat = arr => {
  return arr.reduce((pre, cur) => {
    return pre.concat(Array.isArray(cur) ? flat(cur) : cur);
  }, []);
};
// [1, 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3, 5, "string", { name: "弹铁蛋同学" }];

第三问:使用栈的思想实现 flat 函数

// 栈思想
function flat(arr) {
  const result = []; 
  const stack = [].concat(arr);  // 将数组元素拷贝至栈,直接赋值会改变原数组
  //如果栈不为空,则循环遍历
  while (stack.length !== 0) {
    const val = stack.pop(); 
    if (Array.isArray(val)) {
      stack.push(...val); //如果是数组再次入栈,并且展开了一层
    } else {
      result.unshift(val); //如果不是数组就将其取出来放入结果数组中
    }
  }
  return result;
}
const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }]
flat(arr)
// [1, 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3, 5, "string", { name: "弹铁蛋同学" }];

第四问:通过传入整数参数控制“拉平”层数

// reduce + 递归
function flat(arr, num = 1) {
  return num > 0
    ? arr.reduce(
        (pre, cur) =>
          pre.concat(Array.isArray(cur) ? flat(cur, num - 1) : cur),
        []
      )
    : arr.slice();
}
const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }]
flat(arr, Infinity);
// [1, 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3, 5, "string", { name: "弹铁蛋同学" }];

第五问:使用 Generator 实现 flat 函数

function* flat(arr, num) {
  if (num === undefined) num = 1;
  for (const item of arr) {
    if (Array.isArray(item) && num > 0) {   // num > 0
      yield* flat(item, num - 1);
    } else {
      yield item;
    }
  }
}
const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }]
// 调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象。
// 也就是遍历器对象(Iterator Object)。所以我们要用一次扩展运算符得到结果
[...flat(arr, Infinity)]    
// [1, 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3, 5, "string", { name: "弹铁蛋同学" }];

第六问:实现在原型链上重写 flat 函数

Array.prototype.fakeFlat = function(num = 1) {
  if (!Number(num) || Number(num) < 0) {
    return this;
  }
  let arr = this.concat();    // 获得调用 fakeFlat 函数的数组
  while (num > 0) {           
    if (arr.some(x => Array.isArray(x))) {
      arr = [].concat.apply([], arr);    // 数组中还有数组元素的话并且 num > 0,继续展开一层数组 
    } else {
      break; // 数组中没有数组元素并且不管 num 是否依旧大于 0,停止循环。
    }
    num--;
  }
  return arr;
};
const arr = [1, 2, 3, 4, [1, 2, 3, [1, 2, 3, [1, 2, 3]]], 5, "string", { name: "弹铁蛋同学" }]
arr.fakeFlat(Infinity)
// [1, 2, 3, 4, 1, 2, 3, 1, 2, 3, 1, 2, 3, 5, "string", { name: "弹铁蛋同学" }];

第七问:考虑数组空位的情况

由最开始我们总结的 flat 特性知道,flat 函数执行是会跳过空位的。ES5 大多数数组方法对空位的处理都会选择跳过空位包括:forEach(), filter(), reduce(), every()some() 都会跳过空位。

所以我们可以利用上面几种方法来实现 flat 跳过空位的特性

// reduce + 递归
Array.prototype.fakeFlat = function(num = 1) {
  if (!Number(num) || Number(num) < 0) {
    return this;
  }
  let arr = [].concat(this);
  return num > 0
    ? arr.reduce(
        (pre, cur) =>
          pre.concat(Array.isArray(cur) ? cur.fakeFlat(--num) : cur),
        []
      )
    : arr.slice();
};
const arr = [1, [3, 4], , ,];
arr.fakeFlat()
// [1, 3, 4]

// foEach + 递归
Array.prototype.fakeFlat = function(num = 1) {
  if (!Number(num) || Number(num) < 0) {
    return this;
  }
  let arr = [];
  this.forEach(item => {
    if (Array.isArray(item)) {
      arr = arr.concat(item.fakeFlat(--num));
    } else {
      arr.push(item);
    }
  });
  return arr;
};
const arr = [1, [3, 4], , ,];
arr.fakeFlat()
// [1, 3, 4]

扩展阅读:由于空位的处理规则非常不统一,所以建议避免出现空位。

ES5 对空位的处理,就非常不一致,大多数情况下会忽略空位。

  • forEach(), filter(), reduce(), every()some() 都会跳过空位。
  • map() 会跳过空位,但会保留这个值。
  • join()toString() 会将空位视为 undefined,而undefinednull 会被处理成空字符串。

ES6 明确将空位转为 undefined

  • entries()keys()values()find()findIndex() 会将空位处理成 undefined
  • for...of 循环会遍历空位。
  • fill() 会将空位视为正常的数组位置。
  • copyWithin() 会连空位一起拷贝。
  • 扩展运算符(...)也会将空位转为 undefined
  • Array.from 方法会将数组的空位,转为 undefined

总结

面试官现场考察一道写代码的题目,其实不仅仅是写代码,在写代码的过程中会遇到各种各样的知识点和代码的边界情况。虽然大多数情况下,面试官不会那么变态,就 flat 实现去连续追问面试者,并且手撕好几个版本,但面试官会要求在你写的那版代码的基础上再写出一个更完美的版本是常有的事情。只有我们沉下心来把基础打扎实,不管面试官如何追问,我们都能自如的应对。flat 的实现绝对不会只有文中列出的这几个版本,敲出自己的代码是最好的进步,在评论区或者在 issue 中写出你自己的版本吧!

Today i learned.如果你学到了就点波关注点个赞👍吧

推荐阅读

你不知道的 JSON.stringify() 的威力

查看原文

赞 19 收藏 14 评论 2

弹铁蛋同学 发布了文章 · 2019-12-09

你不知道的 JSON.stringify() 的威力

image

前言

其实有很多有用的东西,当时学习了,也记住了,但是时间久了就是记不住,所以导致在日常开发中总是想不起来原来这个东西可以这么用,而去选择了更加复杂和麻烦的方式。其实我们日常学习的知识就是拿来用的,即使你今天把知识点背下来了,没有去思考这个知识点我们可以用来干嘛,不需要几天就会慢慢地忘掉。所以今天我们来了解一下在日常学习时你遗漏掉或者忘掉或者没有思考过的你不知道的 JSON.stringify() 的威力。

原文戳我Github 戳我

通过需求学习JSON.stringify()

首先我们在开发的过程当中遇到这样一个处理数据的需求

const todayILearn = {
  _id: 1,
  content: '今天学习 JSON.stringify(),我很开心!',
  created_at: 'Mon Nov 25 2019 14:03:55 GMT+0800 (中国标准时间)',
  updated_at: 'Mon Nov 25 2019 16:03:55 GMT+0800 (中国标准时间)'
}

我们需要将上面这个对象处理成下面这个对象

const todayILearn = {
  id: 1,
  content: '今天学习 JSON.stringify(),我很开心!',
  createdAt: 'Mon Nov 25 2019 14:03:55 GMT+0800 (中国标准时间)',
  updatedAt: 'Mon Nov 25 2019 16:03:55 GMT+0800 (中国标准时间)'
}

也就是在不改变属性的值的前提下,将对象属性修改一下。 把_id 改成 id,把 updated_at 改成 updatedAt,把 created_at 改成 createdAt。我们现在通过这个小小的需求来见识一下 JSON.stringify() 的强大吧。

首先要解决这个问题我们有很多种解决方式,我们先提供两种不优雅的解决方案:

  • 方案一:一次遍历+多声明一个变量
// 多一个变量存储
const todayILearnTemp = {};
for (const [key, value] of Object.entries(todayILearn)) {
  if (key === "_id") todayILearnTemp["id"] = value;
  else if (key === "created_at") todayILearnTemp["createdAt"] = value;
  else if (key === "updatedAt") todayILearnTemp["updatedAt"] = value;
  else todayILearnTemp[key] = value;
}
console.log(todayILearnTemp);
// 结果:
// { id: 1,
//  content: '今天学习 JSON.stringify(),我很开心!',
//  createdAt: 'Mon Nov 25 2019 14:03:55 GMT+0800 (中国标准时间)',
//  updated_at: 'Mon Nov 25 2019 16:03:55 GMT+0800 (中国标准时间)' 
// }

方案一完全没有问题,可以实现。但是多声明了一个变量又加上一层循环并且还有很多的 ifelse 语句,怎么都显得不太优雅。

  • 方案二:暴力 delete 属性和增加属性
// 极致的暴力美学
todayILearn.id = todayILearn._id;
todayILearn.createdAt = todayILearn.created_at;
todayILearn.updatedAt = todayILearn.updated_at;
delete todayILearn._id;
delete todayILearn.created_at;
delete todayILearn.updated_at;
console.log(todayILearn);
//     太暴力😢
//{ 
//  content: '今天学习 JSON.stringify(),我很开心!',
//  id: 1,
//  createdAt: 'Mon Nov 25 2019 14:03:55 GMT+0800 (中国标准时间)',
//  updatedAt: 'Mon Nov 25 2019 16:03:55 GMT+0800 (中国标准时间)' 
//}

直接 delete 暴力解决太粗鲁了,而且有一个缺点,属性的顺序变了。

  • 方案三:序列化+ replace 美学典范
const mapObj = {
  _id: "id",
  created_at: "createdAt",
  updated_at: "updatedAt"
};
JSON.parse(
  JSON.stringify(todayILearn).replace(
    /_id|created_at|updated_at/gi,
    matched => mapObj[matched])
    )

// { 
// id: 1,
//  content: '今天学习 JSON.stringify(),我很开心!',
//  createdAt: 'Mon Nov 25 2019 14:03:55 GMT+0800 (中国标准时间)',
//  updatedAt: 'Mon Nov 25 2019 16:03:55 GMT+0800 (中国标准时间)' 
// }

瞬间感觉非常优雅和舒服,有木有!

接下来,正片开始,我们今天将系统的学习或者说是复习一遍 JSON.stringify 的基础知识,让我们在日常开发中更加的游刃有余。


JSON.stringify() 九大特性

JSON.stringify() 第一大特性

对于 undefined、任意的函数以及 symbol 三个特殊的值分别作为对象属性的值、数组元素、单独的值时 JSON.stringify() 将返回不同的结果。

首先,我们来复习一下知识点,看一道非常简单的面试题目:请问下面代码会输出什么?

const data = {
  a: "aaa",
  b: undefined,
  c: Symbol("dd"),
  fn: function() {
    return true;
  }
};
JSON.stringify(data); // 输出:?

// "{"a":"aaa"}"

很简单这道题目面试官主要考察的知识点是:

  • undefined、任意的函数以及 symbol 作为对象属性值时 JSON.stringify() 将跳过(忽略)对它们进行序列化

面试官追问:假设 undefined、任意的函数以及 symbol 值作为数组元素会是怎样呢?

JSON.stringify(["aaa", undefined, function aa() {
    return true
  }, Symbol('dd')])  // 输出:?

// "["aaa",null,null,null]"

知识点是:

  • undefined、任意的函数以及 symbol 作为数组元素值时,JSON.stringify() 会将它们序列化为 null

我们稍微再动下脑筋,如果单独序列化这些值会是什么样的结果呢?

JSON.stringify(function a (){console.log('a')})
// undefined
JSON.stringify(undefined)
// undefined
JSON.stringify(Symbol('dd'))
// undefined

单独转换的结果就是:

  • undefined、任意的函数以及 symbolJSON.stringify() 作为单独的值进行序列化时都会返回 undefined

JSON.stringify() 第一大特性总结

  • undefined、任意的函数以及 symbol 作为对象属性值时 JSON.stringify() 对跳过(忽略)它们进行序列化
  • undefined、任意的函数以及 symbol 作为数组元素值时,JSON.stringify() 将会将它们序列化为 null
  • undefined、任意的函数以及 symbolJSON.stringify() 作为单独的值进行序列化时,都会返回 undefined

JSON.stringify() 第二大特性

也是在使用过程中必须要非常注意的一个点:

  • 非数组对象的属性不能保证以特定的顺序出现在序列化后的字符串中。
const data = {
  a: "aaa",
  b: undefined,
  c: Symbol("dd"),
  fn: function() {
    return true;
  },
  d: "ddd"
};
JSON.stringify(data); // 输出:?
// "{"a":"aaa","d":"ddd"}"

JSON.stringify(["aaa", undefined, function aa() {
    return true
  }, Symbol('dd'),"eee"])  // 输出:?

// "["aaa",null,null,null,"eee"]"

正如我们在第一特性所说,JSON.stringify() 序列化时会忽略一些特殊的值,所以不能保证序列化后的字符串还是以特定的顺序出现(数组除外)。

JSON.stringify() 第三大特性

  • 转换值如果有 toJSON() 函数,该函数返回什么值,序列化结果就是什么值,并且忽略其他属性的值。
JSON.stringify({
    say: "hello JSON.stringify",
    toJSON: function() {
      return "today i learn";
    }
  })
// "today i learn"

JSON.stringify() 第四大特性

  • JSON.stringify() 将会正常序列化 Date 的值。
JSON.stringify({ now: new Date() });
// "{"now":"2019-12-08T07:42:11.973Z"}"

实际上 Date 对象自己部署了 toJSON() 方法(同Date.toISOString()),因此 Date 对象会被当做字符串处理。

JSON.stringify() 第五大特性

  • NaNInfinity 格式的数值及 null 都会被当做 null

直接上代码:

JSON.stringify(NaN)
// "null"
JSON.stringify(null)
// "null"
JSON.stringify(Infinity)
// "null"

JSON.stringify() 第六大特性

关于基本类型的序列化:

  • 布尔值、数字、字符串的包装对象在序列化过程中会自动转换成对应的原始值。
JSON.stringify([new Number(1), new String("false"), new Boolean(false)]);
// "[1,"false",false]"

JSON.stringify() 第七大特性

关于对象属性的是否可枚举:

  • 其他类型的对象,包括 Map/Set/WeakMap/WeakSet,仅会序列化可枚举的属性。
// 不可枚举的属性默认会被忽略:
JSON.stringify( 
    Object.create(
        null, 
        { 
            x: { value: 'json', enumerable: false }, 
            y: { value: 'stringify', enumerable: true } 
        }
    )
);
// "{"y","stringify"}"

JSON.stringify() 第八大特性

我们都知道实现深拷贝最简单粗暴的方式就是序列化:JSON.parse(JSON.stringify()),这个方式实现深拷贝会因为序列化的诸多特性从而导致诸多的坑点:比如现在我们要说的循环引用问题。

// 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。 
const obj = {
  name: "loopObj"
};
const loopObj = {
  obj
};
// 对象之间形成循环引用,形成闭环
obj.loopObj = loopObj;
function deepClone(obj) {
  return JSON.parse(JSON.stringify(obj));
}
deepClone(obj)
/**
 VM44:9 Uncaught TypeError: Converting circular structure to JSON
    --> starting at object with constructor 'Object'
    |     property 'loopObj' -> object with constructor 'Object'
    --- property 'obj' closes the circle
    at JSON.stringify (<anonymous>)
    at deepClone (<anonymous>:9:26)
    at <anonymous>:11:13
 */
  • 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。

这也就是为什么用序列化去实现深拷贝时,遇到循环引用的对象会抛出错误的原因。

JSON.stringify() 第九大特性

最后,关于 symbol 属性还有一点要说的就是:

  • 所有以 symbol 为属性键的属性都会被完全忽略掉,即便 replacer 参数中强制指定包含了它们。
JSON.stringify({ [Symbol.for("json")]: "stringify" }, function(k, v) {
    if (typeof k === "symbol") {
      return v;
    }
  })

// undefined

replacerJSON.stringify() 的第二个参数,我们比较少用到,所以很多时候我们会忘记 JSON.stringify() 第二个、第三个参数,场景不多,但是用的好的话会非常方便,关于 JSON.stringify() 第九大特性的例子中对 replacer 参数不明白的同学先别急,其实很简单,我们马上就会在下面的学习中弄懂。

第二个参数和第三个参数

强大的第二个参数 replacer

replacer 参数有两种形式,可以是一个函数或者一个数组。作为函数时,它有两个参数,键(key)和值(value),函数类似就是数组方法 mapfilter 等方法的回调函数,对每一个属性值都会执行一次该函数。如果 replacer 是一个数组,数组的值代表将被序列化成 JSON 字符串的属性名。

replacer 作为函数时

可以打破九大特性的大多数特性

第二个参数replacer 非常强大, replacer 作为函数时,我们可以打破九大特性的大多数特性,我们直接来看代码吧。

const data = {
  a: "aaa",
  b: undefined,
  c: Symbol("dd"),
  fn: function() {
    return true;
  }
};
// 不用 replacer 参数时
JSON.stringify(data); 

// "{"a":"aaa"}"
// 使用 replacer 参数作为函数时
JSON.stringify(data, (key, value) => {
  switch (true) {
    case typeof value === "undefined":
      return "undefined";
    case typeof value === "symbol":
      return value.toString();
    case typeof value === "function":
      return value.toString();
    default:
      break;
  }
  return value;
})
// "{"a":"aaa","b":"undefined","c":"Symbol(dd)","fn":"function() {\n    return true;\n  }"}"

虽然使用 toString() 方法有点耍流氓的意思但是不得不说第二个参数很强大。

传入 replacer 函数的第一个参数

需要注意的是,replacer 被传入的函数时,第一个参数不是对象的第一个键值对,而是空字符串作为 key 值,value 值是整个对象的键值对:

const data = {
  a: 2,
  b: 3,
  c: 4,
  d: 5
};
JSON.stringify(data, (key, value) => {
  console.log(value);
  return value;
})
// 第一个被传入 replacer 函数的是 {"":{a: 2, b: 3, c: 4, d: 5}}
// {a: 2, b: 3, c: 4, d: 5}   
// 2
// 3
// 4
// 5

实现 map 函数

我们还可以用它来手写实现一个对象的类似 map 的函数。

// 实现一个 map 函数
const data = {
  a: 2,
  b: 3,
  c: 4,
  d: 5
};
const objMap = (obj, fn) => {
  if (typeof fn !== "function") {
    throw new TypeError(`${fn} is not a function !`);
  }
  return JSON.parse(JSON.stringify(obj, fn));
};
objMap(data, (key, value) => {
  if (value % 2 === 0) {
    return value / 2;
  }
  return value;
});
// {a: 1, b: 3, c: 2, d: 5}

replacer 作为数组时

replacer 作为数组时,结果非常简单,数组的值就代表了将被序列化成 JSON 字符串的属性名。

const jsonObj = {
  name: "JSON.stringify",
  params: "obj,replacer,space"
};

// 只保留 params 属性的值
JSON.stringify(jsonObj, ["params"]);
// "{"params":"obj,replacer,space"}" 

有意思却没啥用的第三个参数 space

space 参数用来控制结果字符串里面的间距。首先看一个例子就是到这东西到底是干啥用的:

const tiedan = {
  name: "弹铁蛋同学",
  describe: "今天在学 JSON.stringify()",
  emotion: "like shit"
};
JSON.stringify(tiedan, null, "🐷");
// 接下来是输出结果
// "{
// 🐷"name": "弹铁蛋同学",
// 🐷"describe": "今天在学 JSON.stringify()",
// 🐷"emotion": "like shit"
// }"
JSON.stringify(tiedan, null, 2);
// "{
//   "name": "弹铁蛋同学",
//   "describe": "今天在学 JSON.stringify()",
//   "emotion": "like shit"
// }"

上面代码一眼就能看出第三个参数的作用了,花里胡哨的,其实这个参数还是比较鸡肋的,除了好看没啥特别的用处。我们用 \t\n 等缩进能让输出更加格式化,更适于观看。

  • 如果是一个数字, 则在字符串化时每一级别会比上一级别缩进多这个数字值的空格(最多10个空格);
  • 如果是一个字符串,则每一级别会比上一级别多缩进该字符串(或该字符串的前10个字符)。

总结

JSON.stringify() 九大特性:

一、对于 undefined、任意的函数以及 symbol 三个特殊的值分别作为对象属性的值、数组元素、单独的值时的不同返回结果。

  • undefined、任意的函数以及 symbol 作为对象属性值时 JSON.stringify() 跳过(忽略)对它们进行序列化
  • undefined、任意的函数以及 symbol 作为数组元素值时,JSON.stringify() 将会将它们序列化为 null
  • undefined、任意的函数以及 symbolJSON.stringify() 作为单独的值进行序列化时都会返回 undefined

二、非数组对象的属性不能保证以特定的顺序出现在序列化后的字符串中。

三、转换值如果有 toJSON() 函数,该函数返回什么值,序列化结果就是什么值,并且忽略其他属性的值。

四、JSON.stringify() 将会正常序列化 Date 的值。

五、NaNInfinity 格式的数值及 null 都会被当做 null

六、布尔值、数字、字符串的包装对象在序列化过程中会自动转换成对应的原始值。

七、其他类型的对象,包括 Map/Set/WeakMap/WeakSet,仅会序列化可枚举的属性。

八、对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。

九、所有以 symbol 为属性键的属性都会被完全忽略掉,即便 replacer 参数中强制指定包含了它们。

JSON.stringify() 第二个参数和第三个参数

强大的第二个参数:

  • 作为函数时,它有两个参数,键(key)和值(value),函数类似就是数组方法 mapfilter 等方法的回调函数,对每一个属性值都会执行一次该函数(期间我们还简单实现过一个 map 函数)。
  • 如果 replacer 是一个数组,数组的值代表将被序列化成 JSON 字符串的属性名。

华丽的第三个参数:

  • 如果是一个数字, 则在字符串化时每一级别会比上一级别缩进多这个数字值的空格(最多10个空格);
  • 如果是一个字符串,则每一级别会比上一级别多缩进该字符串(或该字符串的前10个字符)。

注意:

第一个例子的方案三,有小伙伴提示说这个方案会有风险,确实是这样的(可能会把对象的值给替换掉)。大家慎用吧,大部分情况下这样使用是 ok 的。小伙伴们提供的第四种方案还是很不错的:

const todayILearn = {
  _id: 1,
  content: '今天学习 JSON.stringify(),我很开心!',
  created_at: 'Mon Nov 25 2019 14:03:55 GMT+0800 (中国标准时间)',
  updated_at: 'Mon Nov 25 2019 16:03:55 GMT+0800 (中国标准时间)'
}
const mapObj = {
  _id: "id",
  created_at: "createdAt",
  updated_at: "updatedAt"
};
Object.fromEntries(Object.entries(todayILearn).map(([k, v]) => [mapObj[k]||k, v]))
// { 
// id: 1,
//  content: '今天学习 JSON.stringify(),我很开心!',
//  createdAt: 'Mon Nov 25 2019 14:03:55 GMT+0800 (中国标准时间)',
//  updatedAt: 'Mon Nov 25 2019 16:03:55 GMT+0800 (中国标准时间)' 
// }

推荐阅读

面试官连环追问:数组拍平(扁平化) flat 方法实现

查看原文

赞 76 收藏 55 评论 7

认证与成就

  • 获得 134 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

注册于 2019-08-26
个人主页被 682 人浏览