counterxing

counterxing 查看完整档案

成都编辑电子科技大学  |  软件工程 编辑腾讯  |  前端开发 编辑 blog.xingbofeng.com 编辑
编辑

个人动态

counterxing 赞了文章 · 2020-09-09

细数 TS 中那些奇怪的符号

TypeScript 是一种由微软开发的自由和开源的编程语言。它是 JavaScript 的一个超集,而且本质上向这个语言添加了可选的静态类型和基于类的面向对象编程。

本文阿宝哥将分享这些年在学习 TypeScript 过程中,遇到的 10 大 “奇怪” 的符号。其中有一些符号,阿宝哥第一次见的时候也觉得 “一脸懵逼”,希望本文对学习 TypeScript 的小伙伴能有一些帮助。

好的,下面我们来开始介绍第一个符号 —— ! 非空断言操作符

一、! 非空断言操作符

在上下文中当类型检查器无法断定类型时,一个新的后缀表达式操作符 ! 可以用于断言操作对象是非 null 和非 undefined 类型。具体而言,x! 将从 x 值域中排除 null 和 undefined 。

那么非空断言操作符到底有什么用呢?下面我们先来看一下非空断言操作符的一些使用场景。

1.1 忽略 undefined 和 null 类型

function myFunc(maybeString: string | undefined | null) {
  // Type 'string | null | undefined' is not assignable to type 'string'.
  // Type 'undefined' is not assignable to type 'string'. 
  const onlyString: string = maybeString; // Error
  const ignoreUndefinedAndNull: string = maybeString!; // Ok
}

1.2 调用函数时忽略 undefined 类型

type NumGenerator = () => number;

function myFunc(numGenerator: NumGenerator | undefined) {
  // Object is possibly 'undefined'.(2532)
  // Cannot invoke an object which is possibly 'undefined'.(2722)
  const num1 = numGenerator(); // Error
  const num2 = numGenerator!(); //OK
}

因为 ! 非空断言操作符会从编译生成的 JavaScript 代码中移除,所以在实际使用的过程中,要特别注意。比如下面这个例子:

const a: number | undefined = undefined;
const b: number = a!;
console.log(b); 

以上 TS 代码会编译生成以下 ES5 代码:

"use strict";
const a = undefined;
const b = a;
console.log(b);

虽然在 TS 代码中,我们使用了非空断言,使得 const b: number = a!; 语句可以通过 TypeScript 类型检查器的检查。但在生成的 ES5 代码中,! 非空断言操作符被移除了,所以在浏览器中执行以上代码,在控制台会输出 undefined

二、?. 运算符

TypeScript 3.7 实现了呼声最高的 ECMAScript 功能之一:可选链(Optional Chaining)。有了可选链后,我们编写代码时如果遇到 nullundefined 就可以立即停止某些表达式的运行。可选链的核心是新的 ?. 运算符,它支持以下语法:

obj?.prop
obj?.[expr]
arr?.[index]
func?.(args)

这里我们来举一个可选的属性访问的例子:

const val = a?.b;

为了更好的理解可选链,我们来看一下该 const val = a?.b 语句编译生成的 ES5 代码:

var val = a === null || a === void 0 ? void 0 : a.b;

上述的代码会自动检查对象 a 是否为 nullundefined,如果是的话就立即返回 undefined,这样就可以立即停止某些表达式的运行。你可能已经想到可以使用 ?. 来替代很多使用 && 执行空检查的代码:

if(a && a.b) { } 

if(a?.b){ }
/**
* if(a?.b){ } 编译后的ES5代码
* 
* if(
*  a === null || a === void 0 
*  ? void 0 : a.b) {
* }
*/

但需要注意的是,?.&& 运算符行为略有不同,&& 专门用于检测 falsy 值,比如空字符串、0、NaN、null 和 false 等。而 ?. 只会验证对象是否为 nullundefined,对于 0 或空字符串来说,并不会出现 “短路”。

2.1 可选元素访问

可选链除了支持可选属性的访问之外,它还支持可选元素的访问,它的行为类似于可选属性的访问,只是可选元素的访问允许我们访问非标识符的属性,比如任意字符串、数字索引和 Symbol:

function tryGetArrayElement<T>(arr?: T[], index: number = 0) {
  return arr?.[index];
}

以上代码经过编译后会生成以下 ES5 代码:

"use strict";
function tryGetArrayElement(arr, index) {
    if (index === void 0) { index = 0; }
    return arr === null || arr === void 0 ? void 0 : arr[index];
}

通过观察生成的 ES5 代码,很明显在 tryGetArrayElement 方法中会自动检测输入参数 arr 的值是否为 nullundefined,从而保证了我们代码的健壮性。

2.2 可选链与函数调用

当尝试调用一个可能不存在的方法时也可以使用可选链。在实际开发过程中,这是很有用的。系统中某个方法不可用,有可能是由于版本不一致或者用户设备兼容性问题导致的。函数调用时如果被调用的方法不存在,使用可选链可以使表达式自动返回 undefined 而不是抛出一个异常。

可选调用使用起来也很简单,比如:

let result = obj.customMethod?.();

该 TypeScript 代码编译生成的 ES5 代码如下:

var result = (_a = obj.customMethod) === null
  || _a === void 0 ? void 0 : _a.call(obj);

另外在使用可选调用的时候,我们要注意以下两个注意事项:

  • 如果存在一个属性名且该属性名对应的值不是函数类型,使用 ?. 仍然会产生一个 TypeError 异常。
  • 可选链的运算行为被局限在属性的访问、调用以及元素的访问 —— 它不会沿伸到后续的表达式中,也就是说可选调用不会阻止 a?.b / someMethod() 表达式中的除法运算或 someMethod 的方法调用。

三、?? 空值合并运算符

在 TypeScript 3.7 版本中除了引入了前面介绍的可选链 ?. 之外,也引入了一个新的逻辑运算符 —— 空值合并运算符 ??当左侧操作数为 null 或 undefined 时,其返回右侧的操作数,否则返回左侧的操作数

与逻辑或 || 运算符不同,逻辑或会在左操作数为 falsy 值时返回右侧操作数。也就是说,如果你使用 || 来为某些变量设置默认的值时,你可能会遇到意料之外的行为。比如为 falsy 值(''、NaN 或 0)时。

这里来看一个具体的例子:

const foo = null ?? 'default string';
console.log(foo); // 输出:"default string"

const baz = 0 ?? 42;
console.log(baz); // 输出:0

以上 TS 代码经过编译后,会生成以下 ES5 代码:

"use strict";
var _a, _b;
var foo = (_a = null) !== null && _a !== void 0 ? _a : 'default string';
console.log(foo); // 输出:"default string"

var baz = (_b = 0) !== null && _b !== void 0 ? _b : 42;
console.log(baz); // 输出:0

通过观察以上代码,我们更加直观的了解到,空值合并运算符是如何解决前面 || 运算符存在的潜在问题。下面我们来介绍空值合并运算符的特性和使用时的一些注意事项。

3.1 短路

当空值合并运算符的左表达式不为 nullundefined 时,不会对右表达式进行求值。

function A() { console.log('A was called'); return undefined;}
function B() { console.log('B was called'); return false;}
function C() { console.log('C was called'); return "foo";}

console.log(A() ?? C());
console.log(B() ?? C());

上述代码运行后,控制台会输出以下结果:

A was called 
C was called 
foo 
B was called 
false 

3.2 不能与 && 或 || 操作符共用

若空值合并运算符 ?? 直接与 AND(&&)和 OR(||)操作符组合使用 ?? 是不行的。这种情况下会抛出 SyntaxError。

// '||' and '??' operations cannot be mixed without parentheses.(5076)
null || undefined ?? "foo"; // raises a SyntaxError

// '&&' and '??' operations cannot be mixed without parentheses.(5076)
true && undefined ?? "foo"; // raises a SyntaxError

但当使用括号来显式表明优先级时是可行的,比如:

(null || undefined ) ?? "foo"; // 返回 "foo"

3.3 与可选链操作符 ?. 的关系

空值合并运算符针对 undefined 与 null 这两个值,可选链式操作符 ?. 也是如此。可选链式操作符,对于访问属性可能为 undefined 与 null 的对象时非常有用。

interface Customer {
  name: string;
  city?: string;
}

let customer: Customer = {
  name: "Semlinker"
};

let customerCity = customer?.city ?? "Unknown city";
console.log(customerCity); // 输出:Unknown city

前面我们已经介绍了空值合并运算符的应用场景和使用时的一些注意事项,该运算符不仅可以在 TypeScript 3.7 以上版本中使用。当然你也可以在 JavaScript 的环境中使用它,但你需要借助 Babel,在 Babel 7.8.0 版本也开始支持空值合并运算符。

四、?: 可选属性

在面向对象语言中,接口是一个很重要的概念,它是对行为的抽象,而具体如何行动需要由类去实现。 TypeScript 中的接口是一个非常灵活的概念,除了可用于对类的一部分行为进行抽象以外,也常用于对「对象的形状(Shape)」进行描述

在 TypeScript 中使用 interface 关键字就可以声明一个接口:

interface Person {
  name: string;
  age: number;
}

let semlinker: Person = {
  name: "semlinker",
  age: 33,
};

在以上代码中,我们声明了 Person 接口,它包含了两个必填的属性 nameage。在初始化 Person 类型变量时,如果缺少某个属性,TypeScript 编译器就会提示相应的错误信息,比如:

// Property 'age' is missing in type '{ name: string; }' but required in type 'Person'.(2741)
let lolo: Person  = { // Error
  name: "lolo"  
}

为了解决上述的问题,我们可以把某个属性声明为可选的:

interface Person {
  name: string;
  age?: number;
}

let lolo: Person  = {
  name: "lolo"  
}

4.1 工具类型

4.1.1 Partial<T>

在实际项目开发过程中,为了提高代码复用率,我们可以利用 TypeScript 内置的工具类型 Partial<T> 来快速把某个接口类型中定义的属性变成可选的:

interface PullDownRefreshConfig {
  threshold: number;
  stop: number;
}

/**
 * type PullDownRefreshOptions = {
 *   threshold?: number | undefined;
 *   stop?: number | undefined;
 * }
 */ 
type PullDownRefreshOptions = Partial<PullDownRefreshConfig>

是不是觉得 Partial<T> 很方便,下面让我们来看一下它是如何实现的:

/**
 * Make all properties in T optional
 */
type Partial<T> = {
  [P in keyof T]?: T[P];
};
4.1.2 Required<T>

既然可以快速地把某个接口中定义的属性全部声明为可选,那能不能把所有的可选的属性变成必选的呢?答案是可以的,针对这个需求,我们可以使用 Required<T> 工具类型,具体的使用方式如下:

interface PullDownRefreshConfig {
  threshold: number;
  stop: number;
}

type PullDownRefreshOptions = Partial<PullDownRefreshConfig>

/**
 * type PullDownRefresh = {
 *   threshold: number;
 *   stop: number;
 * }
 */
type PullDownRefresh = Required<Partial<PullDownRefreshConfig>>

同样,我们来看一下 Required<T> 工具类型是如何实现的:

/**
 * Make all properties in T required
 */
type Required<T> = {
  [P in keyof T]-?: T[P];
};

原来在 Required<T> 工具类型内部,通过 -? 移除了可选属性中的 ?,使得属性从可选变为必选的。

五、& 运算符

在 TypeScript 中交叉类型是将多个类型合并为一个类型。通过 & 运算符可以将现有的多种类型叠加到一起成为一种类型,它包含了所需的所有类型的特性。

type PartialPointX = { x: number; };
type Point = PartialPointX & { y: number; };

let point: Point = {
  x: 1,
  y: 1
}

在上面代码中我们先定义了 PartialPointX 类型,接着使用 & 运算符创建一个新的 Point 类型,表示一个含有 x 和 y 坐标的点,然后定义了一个 Point 类型的变量并初始化。

5.1 同名基础类型属性的合并

那么现在问题来了,假设在合并多个类型的过程中,刚好出现某些类型存在相同的成员,但对应的类型又不一致,比如:

interface X {
  c: string;
  d: string;
}

interface Y {
  c: number;
  e: string
}

type XY = X & Y;
type YX = Y & X;

let p: XY;
let q: YX;

在上面的代码中,接口 X 和接口 Y 都含有一个相同的成员 c,但它们的类型不一致。对于这种情况,此时 XY 类型或 YX 类型中成员 c 的类型是不是可以是 stringnumber 类型呢?比如下面的例子:

p = { c: 6, d: "d", e: "e" }; 

q = { c: "c", d: "d", e: "e" }; 

为什么接口 X 和接口 Y 混入后,成员 c 的类型会变成 never 呢?这是因为混入后成员 c 的类型为 string & number,即成员 c 的类型既可以是 string 类型又可以是 number 类型。很明显这种类型是不存在的,所以混入后成员 c 的类型为 never

5.2 同名非基础类型属性的合并

在上面示例中,刚好接口 X 和接口 Y 中内部成员 c 的类型都是基本数据类型,那么如果是非基本数据类型的话,又会是什么情形。我们来看个具体的例子:

interface D { d: boolean; }
interface E { e: string; }
interface F { f: number; }

interface A { x: D; }
interface B { x: E; }
interface C { x: F; }

type ABC = A & B & C;

let abc: ABC = {
  x: {
    d: true,
    e: 'semlinker',
    f: 666
  }
};

console.log('abc:', abc);

以上代码成功运行后,控制台会输出以下结果:

由上图可知,在混入多个类型时,若存在相同的成员,且成员类型为非基本数据类型,那么是可以成功合并。

六、| 分隔符

在 TypeScript 中联合类型(Union Types)表示取值可以为多种类型中的一种,联合类型使用 | 分隔每个类型。联合类型通常与 nullundefined 一起使用:

const sayHello = (name: string | undefined) => { /* ... */ };

以上示例中 name 的类型是 string | undefined 意味着可以将 stringundefined 的值传递给 sayHello 函数。

sayHello("semlinker");
sayHello(undefined);

此外,对于联合类型来说,你可能会遇到以下的用法:

let num: 1 | 2 = 1;
type EventNames = 'click' | 'scroll' | 'mousemove';

示例中的 12'click' 被称为字面量类型,用来约束取值只能是某几个值中的一个。

6.1 类型保护

当使用联合类型时,我们必须尽量把当前值的类型收窄为当前值的实际类型,而类型保护就是实现类型收窄的一种手段。

类型保护是可执行运行时检查的一种表达式,用于确保该类型在一定的范围内。换句话说,类型保护可以保证一个字符串是一个字符串,尽管它的值也可以是一个数字。类型保护与特性检测并不是完全不同,其主要思想是尝试检测属性、方法或原型,以确定如何处理值。

目前主要有四种的方式来实现类型保护:

6.1.1 in 关键字
interface Admin {
  name: string;
  privileges: string[];
}

interface Employee {
  name: string;
  startDate: Date;
}

type UnknownEmployee = Employee | Admin;

function printEmployeeInformation(emp: UnknownEmployee) {
  console.log("Name: " + emp.name);
  if ("privileges" in emp) {
    console.log("Privileges: " + emp.privileges);
  }
  if ("startDate" in emp) {
    console.log("Start Date: " + emp.startDate);
  }
}
6.1.2 typeof 关键字
function padLeft(value: string, padding: string | number) {
  if (typeof padding === "number") {
      return Array(padding + 1).join(" ") + value;
  }
  if (typeof padding === "string") {
      return padding + value;
  }
  throw new Error(`Expected string or number, got '${padding}'.`);
}

typeof 类型保护只支持两种形式:typeof v === "typename"typeof v !== typename"typename" 必须是 "number""string""boolean""symbol"。 但是 TypeScript 并不会阻止你与其它字符串比较,语言不会把那些表达式识别为类型保护。

6.1.3 instanceof 关键字
interface Padder {
  getPaddingString(): string;
}

class SpaceRepeatingPadder implements Padder {
  constructor(private numSpaces: number) {}
  getPaddingString() {
    return Array(this.numSpaces + 1).join(" ");
  }
}

class StringPadder implements Padder {
  constructor(private value: string) {}
  getPaddingString() {
    return this.value;
  }
}

let padder: Padder = new SpaceRepeatingPadder(6);

if (padder instanceof SpaceRepeatingPadder) {
  // padder的类型收窄为 'SpaceRepeatingPadder'
}
6.1.4 自定义类型保护的类型谓词(type predicate)
function isNumber(x: any): x is number {
  return typeof x === "number";
}

function isString(x: any): x is string {
  return typeof x === "string";
}

七、_ 数字分隔符

TypeScript 2.7 带来了对数字分隔符的支持,正如数值分隔符 ECMAScript 提案中所概述的那样。对于一个数字字面量,你现在可以通过把一个下划线作为它们之间的分隔符来分组数字:

const inhabitantsOfMunich = 1_464_301;
const distanceEarthSunInKm = 149_600_000;
const fileSystemPermission = 0b111_111_000;
const bytes = 0b1111_10101011_11110000_00001101;

分隔符不会改变数值字面量的值,但逻辑分组使人们更容易一眼就能读懂数字。以上 TS 代码经过编译后,会生成以下 ES5 代码:

"use strict";
var inhabitantsOfMunich = 1464301;
var distanceEarthSunInKm = 149600000;
var fileSystemPermission = 504;
var bytes = 262926349;

7.1 使用限制

虽然数字分隔符看起来很简单,但在使用时还是有一些限制。比如你只能在两个数字之间添加 _ 分隔符。以下的使用方式是非法的:

// Numeric separators are not allowed here.(6188)
3_.141592 // Error
3._141592 // Error

// Numeric separators are not allowed here.(6188)
1_e10 // Error
1e_10 // Error

// Cannot find name '_126301'.(2304)
_126301  // Error
// Numeric separators are not allowed here.(6188)
126301_ // Error

// Cannot find name 'b111111000'.(2304)
// An identifier or keyword cannot immediately follow a numeric literal.(1351)
0_b111111000 // Error

// Numeric separators are not allowed here.(6188)
0b_111111000 // Error

当然你也不能连续使用多个 _ 分隔符,比如:

// Multiple consecutive numeric separators are not permitted.(6189)
123__456 // Error

7.2 解析分隔符

此外,需要注意的是以下用于解析数字的函数是不支持分隔符:

  • Number()
  • parseInt()
  • parseFloat()

这里我们来看一下实际的例子:

Number('123_456')
NaN
parseInt('123_456')
123
parseFloat('123_456')
123

很明显对于以上的结果不是我们所期望的,所以在处理分隔符时要特别注意。当然要解决上述问题,也很简单只需要非数字的字符删掉即可。这里我们来定义一个 removeNonDigits 的函数:

const RE_NON_DIGIT = /[^0-9]/gu;

function removeNonDigits(str) {
  str = str.replace(RE_NON_DIGIT, '');
  return Number(str);
}

该函数通过调用字符串的 replace 方法来移除非数字的字符,具体的使用方式如下:

removeNonDigits('123_456')
123456
removeNonDigits('149,600,000')
149600000
removeNonDigits('1,407,836')
1407836

八、<Type> 语法

8.1 TypeScript 断言

有时候你会遇到这样的情况,你会比 TypeScript 更了解某个值的详细信息。通常这会发生在你清楚地知道一个实体具有比它现有类型更确切的类型。

通过类型断言这种方式可以告诉编译器,“相信我,我知道自己在干什么”。类型断言好比其他语言里的类型转换,但是不进行特殊的数据检查和解构。它没有运行时的影响,只是在编译阶段起作用。

类型断言有两种形式:

8.1.1 “尖括号” 语法
let someValue: any = "this is a string";
let strLength: number = (<string>someValue).length;
8.1.2 as 语法
let someValue: any = "this is a string";
let strLength: number = (someValue as string).length;

8.2 TypeScript 泛型

对于刚接触 TypeScript 泛型的读者来说,首次看到 <T> 语法会感到陌生。其实它没有什么特别,就像传递参数一样,我们传递了我们想要用于特定函数调用的类型。

参考上面的图片,当我们调用 identity<Number>(1)Number 类型就像参数 1 一样,它将在出现 T 的任何位置填充该类型。图中 <T> 内部的 T 被称为类型变量,它是我们希望传递给 identity 函数的类型占位符,同时它被分配给 value 参数用来代替它的类型:此时 T 充当的是类型,而不是特定的 Number 类型。

其中 T 代表 Type,在定义泛型时通常用作第一个类型变量名称。但实际上 T 可以用任何有效名称代替。除了 T 之外,以下是常见泛型变量代表的意思:

  • K(Key):表示对象中的键类型;
  • V(Value):表示对象中的值类型;
  • E(Element):表示元素类型。

其实并不是只能定义一个类型变量,我们可以引入希望定义的任何数量的类型变量。比如我们引入一个新的类型变量 U,用于扩展我们定义的 identity 函数:

function identity <T, U>(value: T, message: U) : T {
  console.log(message);
  return value;
}

console.log(identity<Number, string>(68, "Semlinker"));

除了为类型变量显式设定值之外,一种更常见的做法是使编译器自动选择这些类型,从而使代码更简洁。我们可以完全省略尖括号,比如:

function identity <T, U>(value: T, message: U) : T {
  console.log(message);
  return value;
}

console.log(identity(68, "Semlinker"));

对于上述代码,编译器足够聪明,能够知道我们的参数类型,并将它们赋值给 T 和 U,而不需要开发人员显式指定它们。

九、@XXX 装饰器

9.1 装饰器语法

对于一些刚接触 TypeScript 的小伙伴来说,在第一次看到 @Plugin({...}) 这种语法可能会觉得很惊讶。其实这是装饰器的语法,装饰器的本质是一个函数,通过装饰器我们可以方便地定义与对象相关的元数据。

@Plugin({
  pluginName: 'Device',
  plugin: 'cordova-plugin-device',
  pluginRef: 'device',
  repo: 'https://github.com/apache/cordova-plugin-device',
  platforms: ['Android', 'Browser', 'iOS', 'macOS', 'Windows'],
})
@Injectable()
export class Device extends IonicNativePlugin {}

在以上代码中,我们通过装饰器来保存 ionic-native 插件的相关元信息,而 @Plugin({...}) 中的 @ 符号只是语法糖,为什么说是语法糖呢?这里我们来看一下编译生成的 ES5 代码:

var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};

var Device = /** @class */ (function (_super) {
    __extends(Device, _super);
    function Device() {
        return _super !== null && _super.apply(this, arguments) || this;
    }
    Device = __decorate([
        Plugin({
            pluginName: 'Device',
            plugin: 'cordova-plugin-device',
            pluginRef: 'device',
            repo: 'https://github.com/apache/cordova-plugin-device',
            platforms: ['Android', 'Browser', 'iOS', 'macOS', 'Windows'],
        }),
        Injectable()
    ], Device);
    return Device;
}(IonicNativePlugin));

通过生成的代码可知,@Plugin({...})@Injectable() 最终会被转换成普通的方法调用,它们的调用结果最终会以数组的形式作为参数传递给 __decorate 函数,而在 __decorate 函数内部会以 Device 类作为参数调用各自的类型装饰器,从而扩展对应的功能。

9.2 装饰器的分类

在 TypeScript 中装饰器分为类装饰器、属性装饰器、方法装饰器和参数装饰器四大类。

9.2.1 类装饰器

类装饰器声明:

declare type ClassDecorator = <TFunction extends Function>(
  target: TFunction
) => TFunction | void;

类装饰器顾名思义,就是用来装饰类的。它接收一个参数:

  • target: TFunction - 被装饰的类

看完第一眼后,是不是感觉都不好了。没事,我们马上来个例子:

function Greeter(target: Function): void {
  target.prototype.greet = function (): void {
    console.log("Hello Semlinker!");
  };
}

@Greeter
class Greeting {
  constructor() {
    // 内部实现
  }
}

let myGreeting = new Greeting();
myGreeting.greet(); // console output: 'Hello Semlinker!';

上面的例子中,我们定义了 Greeter 类装饰器,同时我们使用了 @Greeter 语法糖,来使用装饰器。

友情提示:读者可以直接复制上面的代码,在 TypeScript Playground 中运行查看结果。
9.2.2 属性装饰器

属性装饰器声明:

declare type PropertyDecorator = (target:Object, 
  propertyKey: string | symbol ) => void;

属性装饰器顾名思义,用来装饰类的属性。它接收两个参数:

  • target: Object - 被装饰的类
  • propertyKey: string | symbol - 被装饰类的属性名

趁热打铁,马上来个例子热热身:

function logProperty(target: any, key: string) {
  delete target[key];

  const backingField = "_" + key;

  Object.defineProperty(target, backingField, {
    writable: true,
    enumerable: true,
    configurable: true
  });

  // property getter
  const getter = function (this: any) {
    const currVal = this[backingField];
    console.log(`Get: ${key} => ${currVal}`);
    return currVal;
  };

  // property setter
  const setter = function (this: any, newVal: any) {
    console.log(`Set: ${key} => ${newVal}`);
    this[backingField] = newVal;
  };

  // Create new property with getter and setter
  Object.defineProperty(target, key, {
    get: getter,
    set: setter,
    enumerable: true,
    configurable: true
  });
}

class Person { 
  @logProperty
  public name: string;

  constructor(name : string) { 
    this.name = name;
  }
}

const p1 = new Person("semlinker");
p1.name = "kakuqo";

以上代码我们定义了一个 logProperty 函数,来跟踪用户对属性的操作,当代码成功运行后,在控制台会输出以下结果:

Set: name => semlinker
Set: name => kakuqo
9.2.3 方法装饰器

方法装饰器声明:

declare type MethodDecorator = <T>(target:Object, propertyKey: string | symbol,          
  descriptor: TypePropertyDescript<T>) => TypedPropertyDescriptor<T> | void;

方法装饰器顾名思义,用来装饰类的方法。它接收三个参数:

  • target: Object - 被装饰的类
  • propertyKey: string | symbol - 方法名
  • descriptor: TypePropertyDescript - 属性描述符

废话不多说,直接上例子:

function LogOutput(tarage: Function, key: string, descriptor: any) {
  let originalMethod = descriptor.value;
  let newMethod = function(...args: any[]): any {
    let result: any = originalMethod.apply(this, args);
    if(!this.loggedOutput) {
      this.loggedOutput = new Array<any>();
    }
    this.loggedOutput.push({
      method: key,
      parameters: args,
      output: result,
      timestamp: new Date()
    });
    return result;
  };
  descriptor.value = newMethod;
}

class Calculator {
  @LogOutput
  double (num: number): number {
    return num * 2;
  }
}

let calc = new Calculator();
calc.double(11);
// console ouput: [{method: "double", output: 22, ...}]
console.log(calc.loggedOutput); 
9.2.4 参数装饰器

参数装饰器声明:

declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, 
  parameterIndex: number ) => void

参数装饰器顾名思义,是用来装饰函数参数,它接收三个参数:

  • target: Object - 被装饰的类
  • propertyKey: string | symbol - 方法名
  • parameterIndex: number - 方法中参数的索引值
function Log(target: Function, key: string, parameterIndex: number) {
  let functionLogged = key || target.prototype.constructor.name;
  console.log(`The parameter in position ${parameterIndex} at ${functionLogged} has
    been decorated`);
}

class Greeter {
  greeting: string;
  constructor(@Log phrase: string) {
    this.greeting = phrase; 
  }
}

// console output: The parameter in position 0 
// at Greeter has been decorated

十、#XXX 私有字段

在 TypeScript 3.8 版本就开始支持 ECMAScript 私有字段,使用方式如下:

class Person {
  #name: string;

  constructor(name: string) {
    this.#name = name;
  }

  greet() {
    console.log(`Hello, my name is ${this.#name}!`);
  }
}

let semlinker = new Person("Semlinker");

semlinker.#name;
//     ~~~~~
// Property '#name' is not accessible outside class 'Person'
// because it has a private identifier.

与常规属性(甚至使用 private 修饰符声明的属性)不同,私有字段要牢记以下规则:

  • 私有字段以 # 字符开头,有时我们称之为私有名称;
  • 每个私有字段名称都唯一地限定于其包含的类;
  • 不能在私有字段上使用 TypeScript 可访问性修饰符(如 public 或 private);
  • 私有字段不能在包含的类之外访问,甚至不能被检测到。

10.1 私有字段与 private 的区别

说到这里使用 # 定义的私有字段与 private 修饰符定义字段有什么区别呢?现在我们先来看一个 private 的示例:

class Person {
  constructor(private name: string){}
}

let person = new Person("Semlinker");
console.log(person.name);

在上面代码中,我们创建了一个 Person 类,该类中使用 private 修饰符定义了一个私有属性 name,接着使用该类创建一个 person 对象,然后通过 person.name 来访问 person 对象的私有属性,这时 TypeScript 编译器会提示以下异常:

Property 'name' is private and only accessible within class 'Person'.(2341)

那如何解决这个异常呢?当然你可以使用类型断言把 person 转为 any 类型:

console.log((person as any).name);

通过这种方式虽然解决了 TypeScript 编译器的异常提示,但是在运行时我们还是可以访问到 Person 类内部的私有属性,为什么会这样呢?我们来看一下编译生成的 ES5 代码,也许你就知道答案了:

var Person = /** @class */ (function () {
    function Person(name) {
      this.name = name;
    }
    return Person;
}());

var person = new Person("Semlinker");
console.log(person.name);

这时相信有些小伙伴会好奇,在 TypeScript 3.8 以上版本通过 # 号定义的私有字段编译后会生成什么代码:

class Person {
  #name: string;

  constructor(name: string) {
    this.#name = name;
  }

  greet() {
    console.log(`Hello, my name is ${this.#name}!`);
  }
}

以上代码目标设置为 ES2015,会编译生成以下代码:

"use strict";
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) 
  || function (receiver, privateMap, value) {
    if (!privateMap.has(receiver)) {
      throw new TypeError("attempted to set private field on non-instance");
    }
    privateMap.set(receiver, value);
    return value;
};

var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) 
  || function (receiver, privateMap) {
    if (!privateMap.has(receiver)) {
      throw new TypeError("attempted to get private field on non-instance");
    }
    return privateMap.get(receiver);
};

var _name;
class Person {
    constructor(name) {
      _name.set(this, void 0);
      __classPrivateFieldSet(this, _name, name);
    }
    greet() {
      console.log(`Hello, my name is ${__classPrivateFieldGet(this, _name)}!`);
    }
}
_name = new WeakMap();

通过观察上述代码,使用 # 号定义的 ECMAScript 私有字段,会通过 WeakMap 对象来存储,同时编译器会生成 __classPrivateFieldSet__classPrivateFieldGet 这两个方法用于设置值和获取值。

十一、参考资源

十二、推荐阅读

查看原文

赞 27 收藏 13 评论 1

counterxing 赞了文章 · 2020-07-17

typescript 难点梳理

1.new关键字在类型中的使用

泛型

在泛型里使用类类型

在TypeScript使用泛型创建工厂函数时,需要引用构造函数的类类型。比如,

function create<T>(c: {new(): T; }): T {//这边的new()不好理解
    return new c();
}

一个更高级的例子,使用原型属性推断并约束构造函数与类实例的关系。

class BeeKeeper {
    hasMask: boolean;
}

class ZooKeeper {
    nametag: string;
}

class Animal {
    numLegs: number;
}

class Bee extends Animal {
    keeper: BeeKeeper;
}

class Lion extends Animal {
    keeper: ZooKeeper;
}

function createInstance<A extends Animal>(c: new () => A): A {
    return new c();
}

createInstance(Lion).keeper.nametag;  // typechecks!
createInstance(Bee).keeper.hasMask;   // typechecks!

查了不少资料,比较好的解释是what is new() in Typescript?意思就是create函数的参数是构造函数没有参数的T类的类型,同理,createInstance函数的参数是构造函数没有参数的A类的类型。
带着疑问写了测试代码:


vscode依然报错,仔细想下,createInstance函数return new c();这句话是类的实例化,所以传进来的参数c是个类,而不是类的实例,故要使用(c: new () => A)标明c是个类,而不是(c: Animal)类的实例,从下面的调用也可以看出传递的是类而不是实例。
我们知道js里面是没有类的,ES6里面的class也只是个语法糖,编译后依然为一个function。所以去修饰一个class也就是修饰一个function,但是修饰的是构造函数,所以这边加以区别,前面有个new。


接口

类静态部分与实例部分的区别

这边同样用到了关键字new()
第一个例子报错了,
官方解释:
当你操作类和接口的时候,你要知道类是具有两个类型的:静态部分的类型和实例的类型。 你会注意到,当你用构造器签名去定义一个接口并试图定义一个类去实现这个接口时会得到一个错误:

这里因为当一个类实现了一个接口时,只对其实例部分进行类型检查。 constructor存在于类的静态部分,所以不在检查的范围内。
因此,我们应该直接操作类的静态部分。 看下面的例子,我们定义了两个接口, ClockConstructor为构造函数所用和ClockInterface为实例方法所用。 为了方便我们定义一个构造函数 createClock,它用传入的类型创建实例。

interface ClockConstructor {
    new (hour: number, minute: number): ClockInterface;
}
interface ClockInterface {
    tick:()=>void;
}

function createClock(ctor: ClockConstructor, hour: number, minute: number): ClockInterface {//这个和泛型中使用类类型相同,
    return new ctor(hour, minute);//需要类型为ClockInterface的两个参数的构造器类,只是二者写法有点区别
}

class DigitalClock implements ClockInterface {//这边实现的接口不能是直接的构造器
    constructor(h: number, m: number) { }
    tick() {
        console.log("beep beep");
    }
}
class AnalogClock implements ClockInterface {
    constructor(h: number, m: number) { }
    tick() {
        console.log("tick tock");
    }
}

let digital = createClock(DigitalClock, 12, 17);
let analog = createClock(AnalogClock, 7, 32);

因为createClock的第一个参数是ClockConstructor类型,在createClock(AnalogClock, 7, 32)里,会检查AnalogClock是否符合构造函数签名。

再结合react官方接口的书写,

interface Component<P = {}, S = {}> extends ComponentLifecycle<P, S> { }
    class Component<P, S> {//这里全部是实例方法和属性
        constructor(props?: P, context?: any);

        // Disabling unified-signatures to have separate overloads. It's easier to understand this way.
        // tslint:disable:unified-signatures
        setState<K extends keyof S>(f: (prevState: S, props: P) => Pick<S, K>, callback?: () => any): void;
        setState<K extends keyof S>(state: Pick<S, K>, callback?: () => any): void;
        // tslint:enable:unified-signatures

        forceUpdate(callBack?: () => any): void;
        render(): JSX.Element | null | false;

        // React.Props<T> is now deprecated, which means that the `children`
        // property is not available on `P` by default, even though you can
        // always pass children as variadic arguments to `createElement`.
        // In the future, if we can define its call signature conditionally
        // on the existence of `children` in `P`, then we should remove this.
        props: Readonly<{ children?: ReactNode }> & Readonly<P>;
        state: Readonly<S>;
        context: any;
        refs: {
            [key: string]: ReactInstance
        };
    }

 interface ComponentClass<P = {}> {
        new (props?: P, context?: any): Component<P, ComponentState>;//此处对Component做了修饰,规定react的构造函数类型
        propTypes?: ValidationMap<P>;//下面几个全部是静态方法和属性
        contextTypes?: ValidationMap<any>;
        childContextTypes?: ValidationMap<any>;
        defaultProps?: Partial<P>;
        displayName?: string;
    }

为什么写了interface Component又写了class Component,interface Component没有写里面具体的实例方法和属性,而写了同名的class Component,是不是意味着class Component就是interface的具体实现,这里是index.d.ts文件,应该在.ts文件里面不能这么写,同事被相同的问题纠结住,

 interface Component<P = {}, S = {}, SS = any> extends ComponentLifecycle<P, S, SS> { }
    class Component<P, S> {

        static contextType?: Context<any>;

        // TODO (TypeScript 3.0): unknown
        context: any;

        constructor(props: Readonly<P>);
        /**
         * @deprecated
         * @see https://reactjs.org/docs/legacy-context.html
         */
        constructor(props: P, context?: any);

        // We MUST keep setState() as a unified signature because it allows proper checking of the method return type.
        // See: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18365#issuecomment-351013257
        // Also, the ` | S` allows intellisense to not be dumbisense
        setState<K extends keyof S>(
            state: ((prevState: Readonly<S>, props: Readonly<P>) => (Pick<S, K> | S | null)) | (Pick<S, K> | S | null),
            callback?: () => void
        ): void;

        forceUpdate(callback?: () => void): void;
        render(): ReactNode;

      
        readonly props: Readonly<P> & Readonly<{ children?: ReactNode }>;
        state: Readonly<S>;
        /**
         * @deprecated
         * https://reactjs.org/docs/refs-and-the-dom.html#legacy-api-string-refs
         */
        refs: {
            [key: string]: ReactInstance
        };
    }

再次看React index.d.ts 会发现interface Component没有声明任何内容,生命周期方法也是继承过来的,包括setState,forceUpdate,render的声明都是在class Component中的,通过组件中的this.setState也是定位到class Component中,我们知道interface Component是用来修饰组件的实例的,但是没法描述组件的静态属性,比如contextType,这时候也许只能借助class来描述static成员,通过上述分析可知interface是可以用来修饰类的,再结合interface ComponentClass

    interface ComponentClass<P = {}, S = ComponentState> extends StaticLifecycle<P, S> {
        new (props: P, context?: any): Component<P, S>;
        propTypes?: WeakValidationMap<P>;
        contextType?: Context<any>;
        contextTypes?: ValidationMap<any>;
        childContextTypes?: ValidationMap<any>;
        defaultProps?: Partial<P>;
        displayName?: string;
    }

ComponentClass是用来描述class Component的,这个时候限制Component的static成员,接着上面的例子做个测试:

// 用来描述类类型
interface ClockConstructor {
  new (hour: number, minute: number);
  contextType: any; // 新增个成员变量
}

interface ClockInterface {
  tick: () => void;
}

class DigitalClock implements ClockInterface {
  //这边实现的接口不能是直接的构造器
  constructor(h: number, m: number) {}
  tick() {
    console.log("beep beep");
  }
}

function createClock(
  ctor: ClockConstructor,
  hour: number,
  minute: number
): ClockInterface {
  //这个和泛型中使用类类型相同,
  return new ctor(hour, minute); //需要类型为ClockInterface的两个参数的构造器类,只是二者写法有点区别
}

用类类型描述class的静态属性
vscode给报错了,说是DigitalClock缺少contextType,其实就是缺少静态属性 contextType,

class DigitalClock implements ClockInterface {
  static contextType;
  //这边实现的接口不能是直接的构造器
  constructor(h: number, m: number) {}
  tick() {
    console.log("beep beep");
  }
}
let digital = createClock(DigitalClock, 12, 17);

给DigitalClock 加上static contextType就不会报错了,ClockInterface是用来描述类的实例的,ClockConstructor是用来描述类的,用ClockConstructor描述类有哪些静态属性没问题,但是怎么去描述实例的静态属性呢?仿造React的方式在.ts文件中定义同名interface跟class

interface ClockInterface {}

class ClockInterface {
  tick(): void;
}

clipboard.png

说函数没有具体实现,将代码贴到d.ts文件,就不会报错,

interface ClockInterface {
  name: string;
}

class ClockInterface {
  tick(): void;
  static jump(): void;
}

使用该接口,

class DigitalClock implements ClockInterface {
  static jump: () => {};
  name: string = "";
  tick() {
    console.log("beep beep");
  }
}

DigitalClock.jump();

在vscode中输入DigitalClock. 或者static j都有相应的提示,所以可以在d.ts中用同名interface和class描述类的静态属性

到此new()关键字在类型中的使用基本搞清楚了。

类装饰器
function classDecorator<T extends {new(...args:any[]):{}}>(constructor:T) {
    return class extends constructor {
        newProperty = "new property";
        hello = "override";
    }
}

@classDecorator
class Greeter {
    property = "property";
    hello: string;
    constructor(m: string) {
        this.hello = m;
    }
}

console.log(new Greeter("world"));

2. 装饰器的使用

装饰器(Decorator)在React中的应用
JavaScript 中的装饰器是什么?

3. 函数类型声明的不同方式

1. 最常见的方式

函数声明(Function Declaration)类型的,就是普通的具名函数

  function add(x: number, y: number): number {
    return x + y
  }
2. 函数表达式(Function Expression)类型声明
  • 1.这种就是后面赋值号后面有个匿名或者具名函数,比较好理解的写法,都在函数体内写类型
  handle = (
    baseValue: number,
    increment: number,
  ): number => {
    return baseValue
  }
  • 2.给变量定义类型,同时也在函数内部定义类型
  handle: (baseValue: number, increment: number) => number = (
    baseValue: number,
    increment: number,
  ): number => {
    return baseValue
  }
  • 3.将变量的类型抽取到接口中
interface IHandle {
  (baseValue: number, increment: number): number
}

 handle: IHandle = (baseValue: number, increment: number): number => {
    return baseValue
  }

既然前面的变量声明了接口,那么后面的函数里面的类型就可以去掉了

interface IHandle {
  (baseValue: number, increment: number): number
}

 handle: IHandle = (baseValue,increment)=> {
    return baseValue
  }

但是发现个问题

interface IHandle {
  (baseValue: number, increment: number): number
}

 handle: IHandle = (baseValue)=> {
    return baseValue
  }

这么写居然vscode没有报错,increment明明不是可选参数,不是很理解,有待讨论

查阅了typescript的官方文档的函数章节

interface UIElement {
    addClickListener(onclick: (this: void, e: Event) => void): void;
}

这么写的接口,和函数类型的接口声明惊人的相似,

interface IHandle {
  (baseValue: number, increment: number): number
}

差别在哪呢.函数类型的接口声明是匿名的,而上面对象的类型声明的函数是具名的,做了一下测试

var src:UIElement = function() {}
//报错:
// Type '() => void' is not assignable to type 'UIElement'.
// Property 'addClickListener' is missing in type '() => void'.
// var src: UIElement

interface UIElement {
  addClickListener(name: string): void
}

var src: UIElement = {//不报错
  addClickListener() {},
}

var src: UIElement = { //报错
  addClickListener(name: string, age: number) {},
}
// Type '{ addClickListener(name: string, age: number): void; }' is not assignable to type 'UIElement'.
// Types of property 'addClickListener' are incompatible.
//   Type '(name: string, age: number) => void' is not assignable to type '(name: string) => void'.

看样就是实际的函数的参数可以比接口少定义,但是不能多定义
函数接口的是用来修饰变量的,当然包括函数的形参的修饰,以及返回值的修饰,但是不能修饰具名函数

interface IHandle {
  props?: object
  (baseValue: number, increment: number): number
}

function source:IHandle(baseValue) {//这么修饰具名函数会报错
  return baseValue
}

function source(baseValue): IHandle {//这么写是可以的,表明返回了一个IHandle 的函数
  return baseValue
}

3. 怎样修饰类(类类型)

class Greeter {
    static standardGreeting = "Hello, there";
    greeting: string;
    greet() {
        if (this.greeting) {
            return "Hello, " + this.greeting;
        }
        else {
            return Greeter.standardGreeting;
        }
    }
}

let greeter1: Greeter;
greeter1 = new Greeter();
console.log(greeter1.greet());

let greeterMaker: typeof Greeter = Greeter;
greeterMaker.standardGreeting = "Hey there!";

let greeter2: Greeter = new greeterMaker();
console.log(greeter2.greet());
  1. 我们一般都是修饰一个类的实例的,怎么简单的修饰类,typeof是个很好的办法,

*这个例子里,greeter1与之前看到的一样。 我们实例化 Greeter类,并使用这个对象。 与我们之前看到的一样。
再之后,我们直接使用类。 我们创建了一个叫做 greeterMaker的变量。 这个变量保存了这个类或者说保存了类构造函数。 然后我们使用 typeof Greeter,意思是取Greeter类的类型,而不是实例的类型。 或者更确切的说,"告诉我 Greeter标识符的类型",也就是构造函数的类型。 这个类型包含了类的所有静态成员和构造函数。 之后,就和前面一样,我们在 greeterMaker上使用new,创建Greeter的实例。*
也就是使用typeof ClassName

interface IPerson {
  age: number;
}
class Person {
  age: 99;
}

let p: typeof IPerson = Person;//这么写会报错的

  1. 使用构造函数接口修饰类
interface IPerson {
  age: number;
}

interface IPersonConstructor {
  new (): IPerson;
}

class Person {
  age: 99;
}

let p: IPersonConstructor = Person;

之前对new (): IPerson;这句话后面的返回值不是很理解,直到看到了将基类构造函数的返回值作为'this',也就是说new Person()的时候执行的是构造函数,那么构造函数就返回了Person的实例,自然new (): IPerson;构造函数返回IPerson就很好理解了

4. keyof

比如有个interface a{

 a1: 'a1';
 a2: 'a2';
 .....
 .....
 a100: 'a100';

}
然后又个类型要继承这个interface的某一个value的值
比如 type anum = 'a1' | 'a2' | 'a3' | ....| 'a100',
应该怎么写?

type anum = typeof a.a1 | typeof a.a2 | typeof a.a3 | ....| typeof a.a100;

有没有简单的写法 那个interface 有可能随时在变

a1到a100全要?

对全要

而且有可能 到a100以上 一直在添加

我想在添加的时候只添加interface type不用在修改了 有没有办法

key还是value

value

keyof

keyof.png

5. 函数名后面的感叹号(非空断言操作符)

    onOk = () => {
      this.props.onOk!(this.picker && this.picker.getValue());
      this.fireVisibleChange(false);
    }

react-component/m-picker
群里问了是非空操作符

再去搜索,在typescript 2.0的文档里找到了,叫 非空断言操作符

// 使用--strictNullChecks参数进行编译
function validateEntity(e?: Entity) {
    // 如果e是null或者无效的实体,就会抛出异常
}

function processEntity(e?: Entity) {
    validateEntity(e);
    let s = e!.name;  // 断言e是非空并访问name属性
}

vscode检测

需要在tsconfig.json 里面加上"strictNullChecks": true,这样vscode会自动检测null和undefined

6. ts差集运算

Add support for literal type subtraction

具体应用在高阶组件里面的props
TypeScript在React高阶组件中的使用技巧

7. 方法重写

重写

方法重写子类的参数需要跟父类一致,否则会报错

8. 剩余参数

const renderWidget = ({ field, widget, ...restParams }) => {
  const min = restParams.min;
};

这段代码的剩余参数restParams 怎么表示
感觉应该这么写

const renderWidget = (
  {
    field,
    widget,
    ...restParams
  }: { field: string; widget: string;restParams: [key: string]: any },
  idx: number,
  primaryField: string
) => {
  const min = restParams.min;
};

但是还是报错,其实应该省去restParams

const renderWidget = ({
  field,
  widget,
  ...restParams
}: {
  field: string;
  widget: string;
  [key: string]: any;
}) => {
  const min = restParams.min;
};

这样就好了Destructuring a function parameter object and …rest

9. 元组推断

群里看到的疑问,为什么最后推断出是string|number

type TTuple = [string, number];

type Res = TTuple[number]; // string|number

一开始不理解,自己改下写法

type TTuple = [string, number];

type Res = TTuple[string]; 

错误写法
再尝试写下去

type TTuple = [string, number];

type Res = TTuple[0];//string
type Res1 = TTuple[1];//number
type Res2 = TTuple[2];//报错

2报错
TTuple[2]报错

首先js中没有元组概念,数组中可以存放任何数据类型,ts中把存放不同数据类型的数组叫做元组,这样就很好理解TTuple[number]中的number就是索引index,索引只能是数字,而且不能越界,所以两次报错就好理解了,TTuple[number]为什么返回string|number是因为没有指定具体的索引,只能推断出两种可能,string或number

联想到获取接口的属性的类型

interface IProps {
  name: string;
  age: number;
}

type IAge = IProps["name"];// string

const per: IAge = "geek";

10. type interface 泛型差异

下面实现类似于Record的代码

interface Collection<T extends string, U> {
  [P in T]: U;
}

type Collection33<K extends string, T> = {
  [P in K]: T;
};

ESD(W@7WYG6U)90@$Y`JVY6.png

0]Z%[$KN8AQB41693SDT~5F.png

type完全没问题,interface 的两种写法都会报错,好奇葩

11. 函数的this参数

function toHex() {
  return this.toString(16);
}

function声明的函数的this是在调用的时候决定的,为了限制调用者的类型,ts中的函数可以通过this参数限制调用者的类型

function toHex(this: number) {
  return this.toString(16);
}

const x = toHex.call("xxx");

image.png

此时vscode会给出报错提示,单纯的建个ts文件,是不会报错的,需要在项目根目录配置tsconfig.json,

{
  "compilerOptions": {
    "strict": true,//设置为严格模式
    "target": "ES5",
    "noImplicitThis": true//这个设置无效
  }
}

直接在js中显式的指定this会报错

function show(this) {
  console.info("this:", this);
  console.info("arguments:", arguments);
}

const state = {
  person: {
    age: 10,
  },
};

const next = show.bind(state);

next("dd", "ccc");

输出:
image.png
在ts中限制this类型
image.png
这样不会报错,而且this并不会影响arguments参数,为什么?因为编译后的函数中是没有this作为参数的。咋一看觉得this会影响arguments,要看到this后面的类型,说明是在ts中,就有了清醒的认识。

再看个例子

let bar = {
  x: 'hello',
  f(this: { message: string }) {
    this; // { message: string }
  }
};

bar.f()

image.png
函数的this声明了类型即使是外面的定义的对象调用,类型不匹配也会报错。

12. infer关键字

infer关键字可以理解为泛型变量的声明关键字,ts内置的一些使用到infer的类型

  • 获取函数参数类型
/**
 * Obtain the parameters of a function type in a tuple
 */
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;

例子

type Func = (animal: Animal) => void;
type Param = Parameters<Func>;

image.png

T extends (...args: infer P) => any 是个整体,判断T是不是个函数,然后根据条件判断得出结果

  • 获取函数返回值类型
/**
 * Obtain the return type of a function type
 */
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

例子

type NextFunc = (animal: Animal) => number;
type CC = ReturnType<NextFunc>;

image.png

这里需要注意的是泛型中传递的是类型,不是具体的函数

function show(animal: Animal) {
  return 99;
}
type NextFunc = (animal: Animal) => number;
type CC = ReturnType<show>;

image.png
这样就会报错,用typeof show 可以推断函数的类型

function show(animal: Animal) {
  return 99;
}
type NextFunc = (animal: Animal) => number;
type CC = ReturnType<typeof show>;

这样就不会报错了

查看原文

赞 10 收藏 9 评论 0

counterxing 赞了文章 · 2020-06-25

Vue3之——和Vite不得不说的事

1.创建一个vite项目

npm init vite-app <project-name>
cd <project-name>
npm install 
npm run dev

或者

yarn create vite-app <project-name>
cd <project-name>
yarn 
yarn dev

2.vite简介

vite 是一个基于 Vue3 单文件组件的非打包开发服务器,它做到了本地快速开发启动:

  1. 快速的冷启动,不需要等待打包操作;
  2. 即时的热模块更新,替换性能和模块数量的解耦让更新飞起;
  3. 真正的按需编译,不再等待整个应用编译完成,这是一个巨大的改变。

并且vite也成功地革了webpack的命,让webpack开发者直接喊大哥:

尤神放弃webpack

那么vite是如何做到这些的呢?

3.第一个疑问

通过运行npm run dev,可以观察到这个项目是秒级打开,打开调试器可以看到:

模块请求

浏览器直接请求了.vue文件,并且后面带了一些type参数。点击这些请求,简单查看一下文件返回内容:

//main.js
import { createApp } from '/@modules/vue.js'        
import App from '/src/App.vue'    //
import '/src/index.css?import'        //

createApp(App).mount('#app')

最直观地看到这里:

  • 将vue引用转化为/@modules/vue.js
  • ./App.vue转换为/src/App.vue
  • ./index.css转化为/src/index.css?import
//HelloWorld.vue?type=style&index=0
import { updateStyle } from "/vite/hmr"
const css = "\np{color: red;}\n"
updateStyle("62a9ebed-0", css)
export default css

这里编译了Helloworld.vue中的style样式,将p{color:red}进行了编译;

//index.css?import
import { updateStyle } from "/vite/hmr"
const css = "#app {\n  font-family: Avenir, Helvetica, Arial, sans-serif;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-align: center;\n  color: #2c3e50;\n  margin-top: 60px;\n}\n"
updateStyle("\"2418ba23\"", css)
export default css

同时还对全局样式进行了更新监听。

既然浏览器直接请求了.vue 文件,那么文件内容是如何做出解析的呢。项目是如何在不使用webpack等打包工具的条件下如何直接运行vue文件。

3.1挖掘vite运行原理

从上面的代码片段中可以看到,最明显的特征就是使用了ES Module,代码以模块的形式引入到文件,同时实现了按需加载。

其最大的特点是在浏览器端使用 export import 的方式导入和导出模块,在 script 标签里设置 type="module" ,然后使用 ES module

正因如此,vite高度依赖module script特性,也就意味着从这里开始抛弃了IE市场,参见Javascript MDN

在这种操作下,伴随的另一个效果就是去掉了webpack打包步骤,不用再将各个模块文件打包成一个bundle,以便支持浏览器的模块化加载。那么vite是如何处理这些模块的呢?

关键在于vite使用Koa构建的服务端,在createServer中主要通过中间件注册相关功能。

vite 对 import 都做了一层处理,其过程如下:

  1. 在 koa 中间件里获取请求 body
  2. 通过 es-module-lexer 解析资源 ast 拿到 import 的内容
  3. 判断 import 的资源是否是绝对路径,绝对视为 npm 模块
  4. 返回处理后的资源路径,例如:"vue" => "/@modules/vue"

将处理的template,script,style等所需的依赖以http请求的形式,通过query参数形式区分并加载SFC文件各个模块内容。

为什么这里需要@modules?

举个栗子:

import vue from 'vue'

vue模块安装在node_modules中,浏览器ES Module是无法直接获取到项目下node_modules目录中的文件。所以viteimport都做了一层处理,重写了前缀使其带有@modules,以便项目访问引用资源;另一方面,把文件路径都写进同一个@modules中,类似面向切片编程,可以从中再进行其他操作而不影响其他部分资源,比如后续可加入alias等其他配置。

通过koa middleware正则匹配上带有@modules的资源,再通过require('XXX')获取到导出资源并返给浏览器。

3.2文件请求

单页面文件的请求有个特点,都是以*.vue作为请求路径结尾,当服务器接收到这种特点的http请求,主要处理

  • 根据ctx.path确定请求具体的vue文件
  • 使用parseSFC解析该文件,获得descriptor,一个descriptor包含了这个组件的基本信息,包括templatescriptstyles等属性 下面是Comp.vue文件经过处理后获得的descriptor

然后根据descriptorctx.query.type选择对应类型的方法,处理后返回ctx.body

  • type为空时表示处理script标签,使用compileSFCMain方法返回js内容
  • type为template时表示处理template标签,使用compileSFCTemplate方法返回render方法
  • type为styles时表示处理style标签,使用compileSFCStyle方法返回css文件内容

在浏览器里使用 ES module 是使用 http 请求拿到的模块,所以 vite 必须提供一个 web server 去代理这些模块,上文中提到的 koa中间件 就是负责这个事情,vite 通过对请求路径query.type的劫持获取资源的内容返回给浏览器,然后通过拼接不同的处理单页面文件解析后的各个资源文件,最后响应给浏览器进行渲染。

从另一方面来看,这也是一个非常有趣的方法,webpack之类的打包工具会把各种各样的模块提前打包进bundle中,但打包结果是静态的,不管某个模块的代码是否用得到,它都要被打包进去,显而易见的坏处就是随着项目越来越大,打包文件也越来越大。vite的优雅之处就在于需要某个模块时动态引入,而不是提前打包,自然而然提高了开发体验。

4.hmr热更新

vite的热更新主要有四步:

  1. 通过 watcher 监听文件改动;
  2. 通过 server 端编译资源,并推送新资源信息给 client ;
  3. 需要框架支持组件 rerender/reload ;
  4. client 收到资源信息,执行框架 rerender 逻辑。

在client端,Websocket监听了一些更新的消息类型,然后分别处理:

  • vue-reload —— vue 组件更新:通过 import 导入新的 vue 组件,然后执行 HMRRuntime.reload
  • vue-rerender —— vue template 更新:通过 import 导入新的 template ,然后执行 HMRRuntime.rerender
  • vue-style-update —— vue style 更新:直接插入新的 stylesheet
  • style-update —— css 更新:document 插入新的 stylesheet
  • style-remove —— css 移除:document 删除 stylesheet
  • js-update —— js 更新:直接执行
  • full-reload —— 页面 roload:使用 window.reload 刷新页面

在server端,通过watcher监听页面改动,根据文件类型判断是js Reload还是vue Reload。通过解析器拿到当前文件内容,并与缓存里的上一次解析结果进行比较,如果发生改变则执行相应的render。

5.后续

本文简述了vite的启动链路和背后的简易原理,虽然短时间内vite不会替代webpack,但是能够看到vite的强大潜力和不可阻挡的趋势。vite的更新实在是太快了,特别佩服尤大的勤奋和开源精神,目前还在快速迭代中,后续将会有单独的文章来分析各个内部原理实现,希望大家多多期待,有任何问题欢迎随时交流。

欢迎大家关注作者公众号:前端优选

查看原文

赞 11 收藏 8 评论 4

counterxing 赞了文章 · 2020-05-31

玉伯的人生答案:做一个简单自由有爱的技术人

导语:

前端工程师如何成长?如何管理前端团队?如何打造团队文化?近日,蚂蚁研究员兼体验技术部负责人玉伯,在蚂蚁内部技术人的成长公开课上,分享了他的人生愿景和心路历程。

玉伯,蚂蚁研究员,体验技术部负责人。2008年加入淘宝,2012年开始在支付宝致力于设计语言 Ant Design、数据可视化 AntV、知识协同语雀等领域的工作。目前一心打造服务于蚂蚁金服及业界的一流技术与产品。

正文:

mceclip1_20200509104332_PT8K.png

今天给大家分享的议题,是如何做一个简单自由有爱的技术人。简单自由有爱是体验技术部的团队文化,同时也是我个人的人生愿景。我一直会去想,自己要成为什么样的一个人,究竟要活成什么样?这几年我找到的一个答案,就是去做一个简单自由有爱的人。今天跟大家分享一下我对这几个词的一些理解,以及背后的一些心路历程。

一、做一个简单的技术人

简单,对我来说有些特殊的含义。

我从开始做前端到现在已经有13年。一直以来,我觉得自己做技术时,追求的就是保持简单,追求技术的简单性,也追求做技术时心态的简单性。

在很多圈,包括技术圈,都有鄙视链的存在,比如说做Java的可能看不起做前端的,做前端的可能看不起做测试的,做产品的可能看不起做技术的,做运营的觉得产品都是为业务打工的。在这个鄙视链里,很多岗位的同学或多或少都会有职业上的困惑。

我知道很多前端同学,都会问自己一个问题,前端职业发展的天花板在什么地方?我究竟应该做几年前端?临近 35 岁要不要转型?很多同学都会有疑惑。但在这几年的工作经历里,我觉得其实每个岗位都很重要。我印象中逍遥子说过一句话,他说在公司里面,如果一个岗位不重要的话,其实早就取消了。每一位前端同学,每一位技术岗位的同学,职业上的困惑往往源自心态,要想在某个领域做到好,心态一定要保持简单,这一点很关键。

我经历过从前端转Java,在做前端之前我是写C++的。在转行过程中,我会问自己一个问题,究竟什么样的工作能让自己进入心流状态,能够让自己开心、有成就感、有价值感。然后我会发现在做前端写界面时,在做人机交互实现时,自己会没日没夜地去写代码,最终调试产出后很有喜悦感。只要一个岗位能够带给自己这种心流和喜悦感,那这个岗位对自己来说,就是很重要的。其实没必要去做很多横向比较。

比如说现在AI很火,算法很火,是不是我们都要去转型做人工智能?如果AI这一块确实能让你感觉到心流状态,能持续兴奋,去做就好。但如果只是为了去趁一个热点,那千万别去做。每一个岗位都很重要,不必去做比较。踢足球跟打篮球谁更重要?并不存在这种比较,每个人都很重要。这是我想分享的第一个点。

后来我就持续去做前端了。我自己还有一个感觉,就是做技术,一定要保持真实不装,用专业说话。我之前做SeaJS、KISSY、Ant Design等技术项目时,和团队同学会有不少争执,开源项目里,远程异步吵架更是家常便饭。

在这些争吵里,技术人都很简单,不看层级不看谁长得黑,只看谁在专业上能说服大家。坚持用专业说话,很多事情都会变得简单。现在挺怀念做技术时这种专业上的简单讨论,比如性能哪个方案好,通过数据来看,有一说一,非常简单有效。

现在做产品,我们也在尝试用专业说话,任何人都可以反驳我,但要从专业上说服我,还可以和我赌,我赌输了,就给大家发红包,赌赢了,也是我给大家发红包,鼓励专业上的深入思考,敢于争辩,任何据理力争的探讨,都是对团队有益的,最怕的是沉默。

做技术时,还会强调一点,要静水深流,很多领域都是要花长时间去做的。举个例子,像数据可视化,我们做G2和AntV是14年开始做,直到18年的时候,才初步有一些感觉出来。这之前的3年多时间,是一定要静下心来去做的。静水深流,很大程度上需要你的真热爱。

做数据可视化时,我当时是很兴奋的。萧庆有推荐一本书给我,《The Grammar of Graphics》,这是图形语法的一本书。我们之前写的图表,饼图、柱状图、趋势图等,都是一图一表。如果有一种图形语法,让我们可以自由选择直角坐标或极坐标,再通过可视通道映射,把不同的数据,映射到不同的可视通道里,就可以生成出不一样的图表出来。这种灵活性,用传统的 ECharts等图表类库是感受不到的。一旦感受到,就会非常兴奋。

但真要实现 G2 图形语法,需要我们能静下心来,花很长时间去阅读文献,去钻研,小到一个布局算法的实现,可能就是好几周的时间。真正花很长时间,深入去做,才会有些产出。

静水深流的同时,我们还要考虑如何接地气。所谓接地气就是如何跟业务衔接上,如果你做了很多专业研究,最终在业务上不能落地,那肯定有问题。一定要两手都要抓,一手要在专业上不断静水深流,一手要在业务上不断找落脚点。

我们常说,“此时此刻,非我莫属”,这个说法有个背面,是“每时每刻,做好自己”,坚持每时每刻做好自己,会让工作和生活都很简单。最近支付宝在用户体验上被很多用户吐槽,每个同学都会有自己的一些意见。做为技术,我们在吐完槽后,更重要的也许是去尝试推动解决技术能解决的问题。

“此时此刻,非我莫属”,更多强调的并不是态度问题,而是能力问题。怎么提升各方面的专业能力,才是最重要的。很多时候并不需要你主动去说“此时此刻,非我莫属”,而是要让你的能力能让别人看到,因为你的能力而被选中去做,才叫“此时此刻,非我莫属”。要被选中,一定需要长时间积累提高专业能力,这样别人才能够认可你,才会有被选中的机会。

二、做一个自由的产品人

mceclip3_20200509105144_TKVO.png

第二点谈到自由,我会着重讲下做产品的一些感受和经验。我的一个梦想是希望做一个自由的产品人。怎么才能在做产品时,拥有自由的状态呢?

大家常说“唯一不变的是变化”,这是很好的一个价值倡导。但对我来说,一开始挺困惑的。小学学数学,勾股定律非常吸引我,居然就是勾三股四玄五,它在欧氏几何里是一个不变的规律。大学研究生期间我是学物理的,物理学非常注重的一点,就是寻找万世万物的规律,这些规律里也有很多不变的东西,比如普朗克常数、光速等物理常量。为什么不变?这中间的物理诠释,非常美妙。“唯一不变的是变化”,我的理解里,背后还有一句话,叫做“万变之中,不变至美”。当我开始做产品,发现这句话非常管用。

举个例子,语雀里面的不变,是始终在知识领域,一直专注在知识的创作与交流上。做产品经常需要面对各种变化,这时寻找到不变的初心或定位,对产品的长远发展非常重要。这需要刻意锻炼自己在产品上的宏观眼力,能判断产品处于什么样的大趋势下,核心的差异化竞争优势在哪。

几年前,公司用Confluence或Wiki管理文档,也能用,选择做语雀,很大一个原因,是因为看见了Confluence的痛点,它不能跟上公司的变化,Confluence里很多文档,是跟随组织结构的,但组织结构在阿里经常快速变化,很容易导致Confluence上的文档被不断抛弃,停滞更新,很容易带来知识的荒岛化和孤岛化。

在这种背景下去做语雀,采用团队+知识库的模型,不绑定组织结构,让知识尽可能扁平化、尽可能开放,就能让语雀上的文档更有生命力,这是语雀在知识管理领域很核心的一个差异化竞争优势。同时不断提升文档的创作体验,让优势更具优势,并努力想办法让知识能流动起来,这是语雀里的关键点。文档的创作体验与知识的流动性,是语雀里面非常关键的不变点。当把这些不变点给抓住时,很多产品上的功能决策,就会变简单很多。

做产品过程中,光有眼力是不够的,还需要手力。手力是方法论,是术,是具体怎么去做。比如如何做用户体验地图,如何做具体的产品决策。俞军有一本书很不错,叫《产品方法论》,它里面有个概念是:用户是需求的集合。我们做一个产品,要去把握每个功能背后,究竟在满足什么用户什么场景下的什么具体需求,要去看这些需求有没有共性,这个共性的需求集合,构成的才是一种用户。用户并不是某一个具体的人,而是一种抽象。当你把这层抽象找到之后,你才能找到产品的真正用户。有非常多的手力,需要我们不断去学,去实践,然后才能掌握。

做产品过程中,还有很重要一点是心力。很多产品功能点做上去之后,可能要花很长时间用户才会用起来,并不是上线之后,马上就会有很多用户喜欢。如果刚开始一两周,数据不好看,就把它给毙掉的话,很多东西是做不出来的。技术产品领域,数据更多是一种辅助决策,你可以去参考它,但千万别迷信它,特别是在产品早期阶段。根据数据去做的产品功能,能让产品血肉丰满。但产品的灵魂,往往来自那些不根据数据、还坚持去做的产品功能。

做产品过程中还有一点,是往前一步,不给自己设限。做语雀,最大的一个感触,是啥都得做。最开始我是半个PD,然后很快变成了客服,同时还需要兼做运营,还需要去承担BD的工作,因为没有BD,只能逼着自己去做,一切为了产品往前跑。开心的是,每次跟用户的各种碰撞,在和用户一起面对各种各样的问题时,很多好的产品想法就涌现出来。经历时的各种苦逼,回忆起来却是幸福的。

万变之中,不变至美,找到产品中的不变点,很多事情就变简单了。同时不断逼自己去提升产品上的眼力、手力和心力,不给自己受限,随着这些能力的提高,我相信,做一个自由的产品人,就不会是太遥远的梦。

三、做一个有爱的活人

mceclip4_20200509105427_Q1IK.png

最后我想说一说“认真生活,快乐工作”。我曾经是个工作狂,第一次看到这句话时,第一反应是为什么词语错位了。马总非常厉害,故意把认真和快乐反了一下,让认真去搭生活,让快乐去搭工作。

我们很容易在工作中认真,但在生活中不认真。比如回到家里,陪小孩陪家人的时候,很容易松懈不在状态。后来我觉得不对,生活真的需要去认真对待的。现在我都会尽量早点回家,赶在小孩睡觉前能到家,尽量能花半个小时沉下心来,在陪伴小孩时,努力去做到把小孩看成整个全世界。很开心的是,真正这么去做后,哪怕每天只有半小时,也会发现小孩跟自己的互动多了很多,而且从这种互动中,父子彼此都能成长和收获。

快乐工作我只说一点。对我来说,快乐工作的核心是眼睛里要有光芒,你对自己的工作要有足够的热爱。我经常会问团队同学一个问题,你是不是对所做的事情,眼睛里是有光芒的,你内心是不是真的很期待去做。这句话能激发一些同学,同时也是把双刃剑,会杀伤一些同学。有些同学听完这句话后,反思自己的工作,觉得当前工作好像挺枯燥的,然后选择转岗或离职。这并不是一件坏事情,真正有深入思考后,意识到当前的工作对自己来说是很枯燥的,是没有激情的,有这种触动后再选择转岗或者离职,长远来看对这个同学是更好的,对团队也是更好的。自己究竟为什么东西而痴狂,内心激情在哪,想清楚后,个体或团队的战斗力是很不一样的。这能让个体和团队都能变得更好。

还有一句话,是去年的一个分享,“全情投入,守正出奇,愿等花开”,这个就不多说了,讲的是心态的定力,以及策略上的取舍。分享我最近的钉钉签名档,我改成了“关心、用心、静心”。关心非常关键,无论刚才说对生活的认真,对家人的关注,还是工作中对同学的关注,都很重要。很多团队的管理问题,我觉得都是leader对团队本身不够关心导致。年初或年中目标设定完成之后,等几个月后去看结果,这样是不行的,日常的过程管理更关键。生活中关心家人,工作中关心同学,朋友中关心好朋友,这是一个基本功,非常关键。

关心是第一步,很多事情还需要真正用心去做,同时愿意花时间去静心等一些结果。我们团队有句土话叫做“要快但不要急”。很多项目迭代,都希望能够尽快上线,包括我们做产品也希望能尽快拿到结果,但一定不能着急,很多东西不是短时间可以达成的。比如做云凤蝶,云凤蝶是一个企业级低代码研发平台,我们从17年开始投入,做过几次转型,一直到去年年底,我们在低代码领域才有一些真正的应用上来,才开始看到一些希望。用心去做,静心去等,这样关心才有效。

关心于人,用心于事,静心于己,我觉得能做到这三点的人,就是一个有爱的人。做一个有爱的活人,让自己始终处于活着的状态,希望自己能努力去做到。

四、体验技术部的团队⽂化

mceclip5_20200509105544_8SZW.png

前面这几点,是我对自己的要求,希望自己能在技术上做个简单人,在产品上做个自由人,在生活过程中能学会去爱。在2014年起,也在逐步把“简单、自由、有爱”倡导为整个体验技术部的团队文化。

简单自由有爱是三枚硬币。简单是枚硬币的话,正面是简单,反面则是专业。因为只有足够专业,才能够保持简单性,不够专业时,很多事情都会变复杂。

自由的背面是责任。光追求自由,没有担当没有责任是不行的。足够有责任心去担当,这样去做事情,才能真正获得自由感。

有爱也是一样,背后要有很强的行动力。公司做公益,光嘴巴上说是不行的,哪怕一年抽出三个小时真正做一次公益,才是真正的做公益。

我最近有做一个公益,是帮助小区的保安,在小区人员进出的地方帮忙测体温和看健康码。我在小区门口站了4个多小时,这个过程中,我发现保安的生活远远不像我们想像中那么枯燥,同时很惊讶发现支付宝的用户打开健康码有将近十几种方式,有些打开健康码的方式,我压根就想不到。比如很多人打开健康码,是通过中间那个banner广告,还有一个高中生给我看的是一张图片。后来发现给我看图片的还不只一个人,累计有四五个人给我看图片。有这个实际的体感后,就能很快理解,为什么健康码后续把时间给加上去。同时还会发现,有一些老人家没有智能机,这可怎么用健康码?估计大家如果没去接触,光凭想象是永远猜不出来的。没有智能机的老人家,是找小区开个单子,每天盖章来证明。

无论做公益还是做其他,一定要自己真正去做,在做的过程中,才会真正懂得一些东西。

“简单、自由、有爱”和“专业、责任、行动”,形成了体验技术部的亚文化。我们日常还会沉淀一些团队的土话,比如,“不要在毛坯房里雕花”,这是去年很强调的。因为在体验技术部,主要人群是设计和前端,我们身上有个特点,就是比较关注细节。这个特点,有时是个好事,可以让我们把东西做到极致,但同时在很多情况下,也会变成一个缺点。比如有设计师转做产品时,很容易去抓边角料,抓各种细节,但这些细节带来的性价比并不高。所以我们就会一直强调如果当前产品是个毛坯房的话,一定不要去雕花。我们真正要雕花的地方,应该是我们想清楚的一些关键主流程,在这些关键主流程上,可以花大力气去精心打磨,其他更多地方,该放则放,大胆取舍,才是更好的选择。

说了很多,最最关键的,是内心真的要去believe,要去相信。带着相信去疯狂做到时,往往真的就会往你想的方向发展。

【非常问答】Q&A环节

前端学习最重要的是什么?

玉伯:这个问题我说两个点。第一我觉得要保持学习的欲望、要保有好奇心,能持续不断对一些东西感兴趣,不断去往前学。还有一点,是在学的过程中,要去抓住一些不变的东西。比如说CSS的学习,很多前端同学可能都已经不太会CSS了,但是真的要去学CSS,要知道它最最核心的是盒模型、布局、层叠等原理,你要从一个更高的维度,去建立自己的理解。有了这些理解后,往往就可以四两拨千金,可以把整个知识体系建立起来。建好之后,就可以在学习过程中,知道自己究竟是在学一个新东西,还是只是学老方法的一个优化。

如何长期持续保持团队的战斗力和凝聚力,如何吸引更优秀的人才加入团队。

玉伯:我觉得非常简单的一招,叫做用事情去吸引人。团队做的事情一定要足够去吸引到对方的加入,让他认可这件事情,去为这件事情而疯狂。比如说Ant Design,这是一种设计语言,我们要做成全球一流的,认可这个方向并感兴趣的人就会被吸引过来。做语雀也是这样。在我心目中,语雀要做成新一代Office。在想一个问题,为什么从上个世纪80年代出现的Word、Excel、PPT,一直延续到现在。一个Word文档究竟要解决的本质问题是什么,是否环境已发生变化,是否有新的解法。根据这个思路去思考,你会发现Office现有的Word是面向打印机设计的,如今在数字化转型浪潮中,打印需求急剧下降,我们并不需要分页,很多面向A4纸打印的产品功能是可以简化的。这个大趋势下,我们其实有机会去重新定义什么是一份新型的Word文档。这个文档可以跟传统文档不一样,传统Word文档是静态的,新的文档可以基于互联网Web技术让整个文档活起来。当真正把这些东西想清楚后,去找到相应的同学去聊的过程中,感兴趣的对方,往往眼睛里就会有光芒,这就是团队的吸引力。对已有团队来说,有希望有前景的的事情,就是团队的战斗力和凝聚力所在,对内心有相信的团队同学来说,工作就不是简单一份工作了,而是为了内心的相信在做事。

中国的产品设计和西方的差别很大。如何去走向全球做到像FaceBook那种全球流行的设计?

玉伯:这个问题我其实没想过,我目前更多想的一个问题是很多全球化的设计为什么在中国推行不下去。适合中国的设计究竟应该怎样。有一个例子挺好玩。

在企业级IM里面,国外有一款产品很流行,叫Slack。当时钉钉也考虑过要不要做成Slack的样子,但是后来钉钉还是选择不往Slack的方式去做,而是借鉴了微信,采用了中国人更熟悉的产品形态,钉钉群的形态,让钉钉变得更接近中国人的使用习惯。我觉得更好的全球化应该是本地化,要回归到每一个国家每一个地区的用户群体,他们的用户习惯可能真的是不一样的。

之前听国际的一个同事分享,谈中国的红包,在东南亚有些地区不能用红包,要变成绿包或者是白包,因为当地文化对红色的理解不像我们一样觉得是喜庆的,喜庆的是白色或者绿色。

面向不同人群,也是一种“本地化”。比如说面向技术人员的产品应该怎么设计,和面向设计师的会很不同。像VSCode是面向程序员的,就很强调快捷键,很强调效率,甚至可以形成整个IDE领域的一整套体系化设计。产品的本地化设计,核心还是要回到用户本身习惯去看问题。

查看原文

赞 43 收藏 17 评论 3

counterxing 赞了文章 · 2020-04-29

告别无聊的undefined判断, 让老vue-cli3支持?"可选链"等"ES2020"特性

本文能学到什么?

  1. 让老项目(基于vue-cli)支持ES新语法(处于试验阶段), 比如"可选链".
  2. 了解其他目前在实验阶段的ES新语法.

可选链

近期看到多个群中都在聊"可选链", 所以就把单位的老项目也开启了"可选链"功能, 使用了1个月后的感受就是: 再也不用写那么长的"undefined"判断了, 可选链"真香".

const obj = {
  foo: {
    bar: {
      baz: 42,
    },
  },
};

const a = obj?.a; // undefined, 如果没有"?"可就报错喽
// 等价于
// const a = (null === obj || undefined === obj) ? undefined : obj.a;
const baz = obj?.foo?.bar?.baz; // 42
const baz = obj?.['foo']?.bar?.baz // 42

注意:

  1. 近期发布的vue-cli3已经默认支持"可选链", 大家可以先试下是否支持再安装.
  2. 使用ts的小伙伴, 如果使用的是3.7以后的版本, 那么默认也支持"可选链".

安装"ES新特性", 需要vue-cli3

第一步

yarn add -D @babel/plugin-proposal-optional-chaining

第二步

项目根目录下找到"babel.config.js"文件, 修改"presets"字段:

module.exports = {
    presets: [
        '@vue/cli-plugin-babel/preset'
    ],
    plugins:[
        // 可选链插件, 其他babel插件也是一样的安装方式
        "@babel/plugin-proposal-optional-chaining"
    ]
}

其他可玩的ES新特性(实验阶段)

通过babel的官网, 我们可以看到babel支持的"ES新特性"
参考: https://babeljs.io/docs/en/pl...

挑几个有意思的说明下, 其他的大家可以自行看下官网说明:

@babel/plugin-proposal-nullish-coalescing-operator

"非undefined且非null"判断

var object1 = {}
var foo = object1.foo ?? "default"; // "default"

var nl = null;
var res = nl ?? 1 // 2

@babel/plugin-proposal-logical-assignment-operators

短路符判断后赋值的简写.

let a = false;
a ||= 1; // 1

编译后的代码是这样的:

var a = false;
a || (a = 1);

@babel/plugin-proposal-function-bind

用"::"符号来代替"bind", "call"语法.

obj::func
// 等价 func.bind(obj)

::obj.func
// 等价 obj.func.bind(obj)

obj::func(val)
// 等价 func.call(obj, val)

::obj.func(val)
// 等价 obj.func.call(obj, val)

$('.some-link').on('click', ::view.reset);
// 等价  $('.some-link').on('click', view.reset.bind(view));

复杂点的例子:

const { map, filter } = Array.prototype;

let sslUrls = document.querySelectorAll('a')
                ::map(node => node.href)
                ::filter(href => href.substring(0, 5) === 'https');

@babel/plugin-proposal-partial-application

函数科里化

function add(x, y) { return x + y; }
const addOne = add(1, ?); // 返回函数addOne
addOne(2); // 3

@babel/plugin-proposal-private-methods

私有属性关键词"#"

class Counter extends HTMLElement {
  #xValue = 0;

  get #x() { return this.#xValue; }
  set #x(value) {
    this.#xValue = value;
    window.requestAnimationFrame(
      this.#render.bind(this));
  }

  #clicked() {
    this.#x++;
  }
}

其他特性

其他特性可能在业务代码中不常用(大神们可能常用, 但是大神也不用看我写文章学这些?)就不在此介绍了, 有兴趣大家可以看下bebal实验特性.

总结

其实就这里最实用的就是"可选链"功能, 大家快快开始用起来吧, 让老项目的代码更优雅, 加油?.

?typescript系列课程

基础教程从这里开始

第一课, 体验typescript

第二课, 基础类型和入门高级类型

第三课, 泛型

第四课, 解读高级类型

第五课, 命名空间(namespace)是什么

特别篇, 在vue3?源码中学会typescript? - "is"

第六课, 什么是声明文件(declare)? ? - 全局声明篇

新手前端学?typescript - 实战篇, 实现浏览器全屏(59行)

?往期热门文章

?常用正则大全2020

?新手前端不要慌! 给你✊10根救命稻草?

真.1px边框, ? 支持任意数量边和圆角, 1 个万金油的方法

?揭秘vue/react组件库中?5个"作者不造的轮子"

vue / react的UI库都在用的几个DOM API?

微博

刚玩微博, 咱们可以互相关注, 嘿嘿
weibo.png

微信群

感谢大家的阅读, 如有疑问可以加我微信, 我拉你进入微信群(由于腾讯对微信群的100人限制, 超过100人后必须由群成员拉入)

查看原文

赞 9 收藏 5 评论 4

counterxing 赞了文章 · 2020-03-18

webpack 小技巧:动态批量加载文件

背景

最近笔者在工作中遇到了一个小需求:

要实现一个组件来播放帧图片

这个需求本身不复杂,但是需要在组件中一次性引入十张图片,就像下面这样:

// 就是这么任性,下标从0开始~
import frame0 from './assets/frame_0.png'
import frame1 from './assets/frame_1.png'
import frame2 from './assets/frame_2.png'
// ..省略n张
import frame7 from './assets/frame_8.png'
import frame8 from './assets/frame_9.png'
import frame9 from './assets/frame_10.png'

作为一个有代码洁癖的程序员,我是不允许这种重复性代码存在滴,于是乎就尝试有没有什么简单的方法。

方法一:绕过 webpack

由于笔者用的是 vue-cli 3,熟悉的小伙伴都知道,将图片以固定的格式放在 public 文件夹下面,然后在代码中直接以绝对路径引入即可。这么做的话,就可以根据文件名构造一个 url 数组,简单代码如下:

const frames = []
_.times(10, v => {
    frames.push(`/images/frame_${v}.png`)
})
// 然后你就得到 10个 url 的数组啦

此方法本身是 vue-cli 提供的一个 应急手段,它有几个缺点:

  1. 无法利用 webpack 处理资源,无法产生内容哈希,不利于缓存更新
  2. 无法利用 url-loader 将资源内联成 base64 字符串 以减少网络请求

方法二:require

由于 import 是静态关键字,所以如果想要批量加载文件,可以使用 require,但是直接像下面这样写是不行的:

const frames = []
_.times(10, v => {
    const path = `./assets/images/frame_${v}.png`
    frames.push(require(path))
}

上面的代码中的 path 是在程序运行时才能确定的,即属于 runtime 阶段,而 webpack 中的 require 是在构建阶段确定文件位置的,所以 webpack 没法推测出这个 path 在哪里。

但是却可以这样写:

const frames = []
_.times(10, v => {
    frames.push(require(`./assets/images/frame_${v}.png`))
}
// frames 中就得到 带 hash 值的路径

虽然这两种写法在语法上没有差别,但是第二种写法在构建时提示了 webpack,webpack 会将 ./assets/images 中的所有文件都加入到 bundle 中,从而在你运行时可以找到对应的文件。

在使用方法二的时候笔者尝试将批量加载的逻辑提取到其他模块用来复用:

export function loadAll (n, prefix, suffix) {
  const frames = []
  _.times(n, v => {
    frames.push(require('./' + prefix + v + suffix))
  })
  return frames
}

但是显然失败了,因为提取后的代码,运行的 context 属于另一个模块,所以也就无法找到相对路径中的文件。

方法三:require.context

上面两种方法都不算很优雅,于是就去翻 webpack 的文档,终于,让我找到了这么一个方法:require.context

require.context(
  directory: String,
  includeSubdirs: Boolean /* 可选的,默认值是 true */,
  filter: RegExp /* 可选的,默认值是 /^\.\/.*$/,所有文件 */,
  mode: String  /* 可选的,'sync' | 'eager' | 'weak' | 'lazy' | 'lazy-once',默认值是 'sync' */
)
指定一系列完整的依赖关系,通过一个 directory 路径、一个 includeSubdirs 选项、一个 filter 更细粒度的控制模块引入和一个 mode 定义加载方式。然后可以很容易地解析模块.

我们还是看上面的例子:

const frames = []
const context = require.context('./assets/images', false, /frame_\d+.png/)
context.keys().forEach(k => {
    frames.push(context(k))
})

这里的代码通过 require.context 创建了一个 require 上下文。

  • 第一个参数指定了需要加载的文件夹,即组件当前目录下的 ./assets/images 文件夹
  • 第二个参数指定是否需要包含子目录,由于没有子目录,所以传 false
  • 第三个参数指定需要包含的文件的匹配规则,我们用一个正则表示

然后使用 context.keys() 就能拿到该上下文的文件路径列表,而 context 本身也是一个方法,相当于设置过上下文的 require,我们将 require 后的文件放入数组中,数组中的路径其实是带 hash 值的,如下是我项目中的图片:

["/static/img/frame_0.965ef86f.png", "/static/img/frame_1.c7465967.png", "/static/img/frame_2.41e82904.png", "/static/img/frame_3.faef7de9.png", "/static/img/frame_4.27ebbe45.png", "/static/img/frame_5.d98cbebe.png", "/static/img/frame_6.c10859bc.png", "/static/img/frame_7.5e9cbdf0.png", "/static/img/frame_8.b3b92c71.png", "/static/img/frame_9.36660295.png"]

而且如果设置过内联图片的话,数组中可能还有图片的 base64 串。

重构一下

方法三已经解决了我们的问题,而且可以批量 require 某个文件夹中的文件。但是 forEach 那块的逻辑明显是重复的,所以我们当然提取出来啦,以后多个组件调用的时候只需要引入即可:

公共模块:

/**
 * 批量加载帧图片
 * @param {Function} context - require.context 创建的函数
 * @returns {Array<string>} 返回的所有图片
 */
function loadFrames (context) {
  const frames = []
  context.keys().forEach(k => {
    frames.push(context(k))
  })
  return frames
}

组件中:

const context = require.context('./assets/images', false, /frame_\d+.png/)
const frames = loadFrames(context)

大功告成!感兴趣的小伙伴可以点击文末链接查看详细文档~

参考链接


本文首发于公众号:码力全开(codingonfire)

本文随意转载哈,注明原文链接即可,公号文章转载联系我开白名单就好~

codingonfire.jpg

查看原文

赞 8 收藏 3 评论 0

counterxing 赞了回答 · 2019-08-13

解决defer和async的区别

先来试个一句话解释仨,当浏览器碰到 script 脚本的时候:

  1. <script data-original="script.js"></script>

    没有 deferasync,浏览器会立即加载并执行指定的脚本,“立即”指的是在渲染该 script 标签之下的文档元素之前,也就是说不等待后续载入的文档元素,读到就加载并执行。

  2. <script async data-original="script.js"></script>

    async,加载和渲染后续文档元素的过程将和 script.js 的加载与执行并行进行(异步)。

  3. <script defer data-original="myscript.js"></script>

    defer,加载后续文档元素的过程将和 script.js 的加载并行进行(异步),但是 script.js 的执行要在所有元素解析完成之后,DOMContentLoaded 事件触发之前完成。

然后从实用角度来说呢,首先把所有脚本都丢到 </body> 之前是最佳实践,因为对于旧浏览器来说这是唯一的优化选择,此法可保证非脚本的其他一切元素能够以最快的速度得到加载和解析。

接着,我们来看一张图咯:

请输入图片描述

蓝色线代表网络读取,红色线代表执行时间,这俩都是针对脚本的;绿色线代表 HTML 解析。

此图告诉我们以下几个要点:

  1. deferasync 在网络读取(下载)这块儿是一样的,都是异步的(相较于 HTML 解析)
  2. 它俩的差别在于脚本下载完之后何时执行,显然 defer 是最接近我们对于应用脚本加载和执行的要求的
  3. 关于 defer,此图未尽之处在于它是按照加载顺序执行脚本的,这一点要善加利用
  4. async 则是一个乱序执行的主,反正对它来说脚本的加载和执行是紧紧挨着的,所以不管你声明的顺序如何,只要它加载完了就会立刻执行
  5. 仔细想想,async 对于应用脚本的用处不大,因为它完全不考虑依赖(哪怕是最低级的顺序执行),不过它对于那些可以不依赖任何脚本或不被任何脚本依赖的脚本来说却是非常合适的,最典型的例子:Google Analytics

关注 70 回答 7

counterxing 发布了文章 · 2019-08-11

Vue 3.0 前瞻,体验 Vue Function API

最近 Vue 官方公布了 Vue 3.0 最重要的RFC:Function-based component API,并发布了兼容 Vue 2.0 版本的 plugin:vue-function-api,可用于提前体验 Vue 3.0 版本的 Function-based component API。笔者出于学习的目的,提前在项目中尝试了vue-function-api

笔者计划写两篇文章,本文为笔者计划的第一篇,主要为笔者在体验 Vue Function API 的学习心得。第二篇计划写阅读vue-function-api的核心部分代码原理,包括setupobservablelifecycle

本文阅读时间约为15~20分钟。

概述

Vue 2.x 及以前的高阶组件的组织形式或多或少都会面临一些问题,特别是在需要处理重复逻辑的项目中,一旦开发者组织项目结构组织得不好,组件代码极有可能被人诟病为“胶水代码”。而在 Vue 2.x 及之前的版本,解决此类问题的办法大致是下面的方案:

笔者维护的项目也需要处理大量复用逻辑,在这之前,笔者一直尝试使用mixin的方式来实现组件的复用。有些问题也一直会对开发者和维护者造成困惑,如一个组件同时mixin多个组件,很难分清对应的属性或方法写在哪个mixin里。其次,mixin的命名空间冲突也可能造成问题。难以保证不同的mixin不用到同一个属性名。为此,官方团队提出函数式写法的意见征求稿,也就是RFC:Function-based component API。使用函数式的写法,可以做到更灵活地复用组件,开发者在组织高阶组件时,不必在组件组织上考虑复用,可以更好地把精力集中在功能本身的开发上。

注:本文只是笔者使用vue-function-api提前体验 Vue Function API ,而这个 API 只是 Vue 3.0 的 RFC,而并非与最终 Vue 3.x API 一致。发布后可能有不一致的地方。

在 Vue 2.x 中使用

要想提前在Vue 2.x中体验 Vue Function API ,需要引入vue-function-api,基本引入方式如下:

import Vue from 'vue';
import { plugin as VueFunctionApiPlugin } from 'vue-function-api';

Vue.use(VueFunctionApiPlugin);

基本组件示例

先来看一个基本的例子:

<template>
    <div>
        <span>count is {{ count }}</span>
        <span>plusOne is {{ plusOne }}</span>
        <button @click="increment">count++</button>
    </div>
</template>

<script>
import Vue from 'vue';
import { value, computed, watch, onMounted } from 'vue-function-api';

export default {
    setup(props, context) {
        // reactive state
        const count = value(0);
        // computed state
        const plusOne = computed(() => count.value + 1);
        // method
        const increment = () => {
            count.value++;
        };
        // watch
        watch(
            () => count.value * 2,
            val => {
                console.log(`count * 2 is ${val}`);
            }
        );
        // lifecycle
        onMounted(() => {
            console.log(`mounted`);
        });
        // expose bindings on render context
        return {
            count,
            plusOne,
            increment,
        };
    },
};
</script>

详解

setup

setup函数是Vue Function API 构建的函数式写法的主逻辑,当组件被创建时,就会被调用,函数接受两个参数,分别是父级组件传入的props和当前组件的上下文context。看下面这个例子,可以知道在context中可以获取到下列属性值:

const MyComponent = {
    props: {
        name: String
    },
    setup(props, context) {
        console.log(props.name);
        // context.attrs
        // context.slots
        // context.refs
        // context.emit
        // context.parent
        // context.root
    }
}

value & state

value函数创建一个包装对象,它包含一个响应式属性value

那么为何要使用value呢,因为在JavaScript中,基本类型并没有引用,为了保证属性是响应式的,只能借助包装对象来实现,这样做的好处是组件状态会以引用的方式保存下来,从而可以被在setup中调用的不同的模块的函数以参数的形式传递,既能复用逻辑,又能方便地实现响应式。

直接获取包装对象的值必须使用.value,但是,如果包装对象作为另一个响应式对象的属性,Vue内部会通过proxy来自动展开包装对象。同时,在模板渲染的上下文中,也会被自动展开。

import { state, value } from 'vue-function-api';
const MyComponent = {
    setup() {
        const count = value(0);
        const obj = state({
            count,
        });
        console.log(obj.count) // 作为另一个响应式对象的属性,会被自动展开

        obj.count++ // 作为另一个响应式对象的属性,会被自动展开
        count.value++ // 直接获取响应式对象,必须使用.value

        return {
            count,
        };
    },
    template: `<button @click="count++">{{ count }}</button>`,
};

如果某一个状态不需要在不同函数中被响应式修改,可以通过state创建响应式对象,这个state创建的响应式对象并不是包装对象,不需要使用.value来取值。

watch & computed

watchcomputed的基本概念与 Vue 2.x 的watchcomputed一致,watch可以用于追踪状态变化来执行一些后续操作,computed用于计算属性,用于依赖属性发生变化进行重新计算。

computed返回一个只读的包装对象,和普通包装对象一样可以被setup函数返回,这样就可以在模板上下文中使用computed属性。可以接受两个参数,第一个参数返回当前的计算属性值,当传递第二个参数时,computed是可写的。

import { value, computed } from 'vue-function-api';

const count = value(0);
const countPlusOne = computed(() => count.value + 1);

console.log(countPlusOne.value); // 1

count.value++;
console.log(countPlusOne.value); // 2

// 可写的计算属性值
const writableComputed = computed(
    // read
    () => count.value + 1,
    // write
    val => {
        count.value = val - 1;
    },
);

watch第一个参数和computed类似,返回被监听的包装对象属性值,不过另外需要传递两个参数:第二个参数是回调函数,当数据源发生变化时触发回调函数,第三个参数是options。其默认行为与 Vue 2.x 有所不同:

  • lazy:是否会在组件创建时就调用一次回调函数,与 Vue 2.x 相反,lazy默认是false,默认会在组件创建时调用一次。
  • deep:与 Vue 2.x 的 deep 一致
  • flush:有三个可选值,分别为 'post'(在渲染后,即nextTick后才调用回调函数),'pre'(在渲染前,即nextTick前调用回调函数),'sync'(同步触发)。默认值为'post'。
// double 是一个计算包装对象
const double = computed(() => count.value * 2);

watch(double, value => {
    console.log('double the count is: ', value);
}); // -> double the count is: 0

count.value++; // -> double the count is: 2

watch多个被包装对象属性时,参数均可以通过数组的方式进行传递,同时,与 Vue 2.x 的vm.$watch一样,watch返回取消监听的函数:

const stop = watch(
    [valueA, () => valueB.value],
    ([a, b], [prevA, prevB]) => {
        console.log(`a is: ${a}`);
        console.log(`b is: ${b}`);
    }
);

stop();
注意:在RFC:Function-based component API初稿中,有提到effect-cleanup,是用于清理一些特殊情况的副作用的,目前已经在提案中被取消了。

生命周期

所有现有的生命周期都有对应的钩子函数,通过onXXX的形式创建,但有一点不同的是,destoryed钩子函数需要使用unmounted代替:

import { onMounted, onUpdated, onUnmounted } from 'vue-function-api';

const MyComponent = {
    setup() {
        onMounted(() => {
            console.log('mounted!');
        });
        onUpdated(() => {
            console.log('updated!');
        });
        // destroyed 调整为 unmounted
        onUnmounted(() => {
            console.log('unmounted!');
        });
    },
};

一些思考

上面的详解部分,主要抽取的是 Vue Function API 的常见部分,并非RFC:Function-based component API的全部,例如其中的依赖注入,TypeScript类型推导等优势,在这里,由于篇幅有限,想要了解更多的朋友,可以点开RFC:Function-based component API查看。个人也在Function-based component API讨论区看到了更多地一些意见:

  • 由于底层设计,在setup取不到组件实例this的问题,这个问题在笔者尝试体验时也遇到了,期待正式发布的 Vue 3.x 能够改进这个问题。
  • 对于基本类型的值必须使用包装对象的问题:在 RFC 讨论区,为了同时保证TypeScript类型推导、复用性和保留Vue的数据监听,包装属性必须使用.value来取值是讨论最激烈的
  • 关于包装对象valuestate方法命名不清晰可能导致开发者误导等问题,已经在Amendment proposal to Function-based Component API这个提议中展开了讨论:
setup() {
    const state = reactive({
        count: 0,
    });

    const double = computed(() => state.count * 2);

    function increment() {
        state.count++;
    }

    return {
        ...toBindings(state), // retains reactivity on mutations made to `state`
        double,
        increment,
    };
}
  • 引入reactive API 和 binding API,其中reactive API 类似于 state API , binding API 类似于 value API。
  • 之前使用的方法名state在 Vue 2.x 中可能被用作组件状态对象,导致变量命名空间的冲突问题,团队认为将state API 更名为 reactive 更为优雅。开发者能够写出const state = ... ,然后通过state.xxxx这种方式来获取组件状态,这样也相对而言自然一些。
  • value方法用于封装基本类型时,确实会出现不够优雅的.value的情况,开发者可能会在直接对包装对象取值时忘记使用.value,修正方案提出的 reactive API,其含义是创建响应式对象,初始化状态state就使用reactive创建,可保留每项属性的gettersetter,这么做既满足类型推导,也可以保留响应式引用,从而可在不同模块中共享状态值的引用。
  • reactive可能导致下面的问题,需要引入binding API。 解决,如使用reactive创建的响应式对象,对其使用拓展运算符...时,则会丢失对象的gettersetter,提供toBindings方法能够保留状态的响应式。

下一篇文章中,笔者将阅读vue-function-api的核心部分代码原理,包括setupobservablelifecycle等,从内部探索 Vue Function API 可能带给我们的改变。

当然,目前 Vue Function API 还处在讨论阶段,Vue 3.0 还处在开发阶段,还是期待下半年 Vue 3.0 的初版问世吧,希望能给我们带来更多的惊喜。

查看原文

赞 17 收藏 12 评论 0

counterxing 发布了文章 · 2019-06-09

从零开始,手写一个简易的Virtual DOM

众所周知,对前端而言,直接操作 DOM 是一件及其耗费性能的事情,以 React 和 Vue 为代表的众多框架普遍采用 Virtual DOM 来解决如今愈发复杂 Web 应用中状态频繁发生变化导致的频繁更新 DOM 的性能问题。本文为笔者通过实际操作,实现了一个非常简单的 Virtual DOM ,加深对现今主流前端框架中 Virtual DOM 的理解。

关于 Virtual DOM ,社区已经有许多优秀的文章,而本文是笔者采用自己的方式,并有所借鉴前辈们的实现,以浅显易懂的方式,对 Virtual DOM 进行简单实现,但不包含snabbdom的源码分析,在笔者的最终实现里,参考了snabbdom的原理,将本文的Virtual DOM实现进行了改进,感兴趣的读者可以阅读上面几篇文章,并参考笔者本文的最终代码进行阅读。

本文阅读时间约15~20分钟。

概述

本文分为以下几个方面来讲述极简版本的 Virtual DOM 核心实现:

  • Virtual DOM 主要思想
  • 用 JavaScript 对象表示 DOM 树
  • 将 Virtual DOM 转换为真实 DOM

    • 设置节点的类型
    • 设置节点的属性
    • 对子节点的处理
  • 处理变化

    • 新增与删除节点
    • 更新节点
    • 更新子节点

Virtual DOM 主要思想

要理解 Virtual DOM 的含义,首先需要理解 DOM ,DOM 是针对 HTML 文档和 XML 文档的一个 API , DOM 描绘了一个层次化的节点树,通过调用 DOM API,开发人员可以任意添加,移除和修改页面的某一部分。而 Virtual DOM 则是用 JavaScript 对象来对 Virtual DOM 进行抽象化的描述。Virtual DOM 的本质是JavaScript对象,通过 Render函数,可以将 Virtual DOM 树 映射为 真实 DOM 树。

一旦 Virtual DOM 发生改变,会生成新的 Virtual DOM ,相关算法会对比新旧两颗 Virtual DOM 树,并找到他们之间的不同,尽可能地通过最少的 DOM 操作来更新真实 DOM 树。

我们可以这么表示 Virtual DOM 与 DOM 的关系:DOM = Render(Virtual DOM)

用 JavaScript 对象表示 DOM 树

Virtual DOM 是用 JavaScript 对象表示,并存储在内存中的。主流的框架均支持使用 JSX 的写法, JSX 最终会被 babel 编译为JavaScript 对象,用于来表示Virtual DOM,思考下列的 JSX:

<div>
    <span className="item">item</span>
    <input disabled={true} />
</div>

最终会被babel编译为如下的 JavaScript对象:

{
    type: 'div',
    props: null,
    children: [{
        type: 'span',
        props: {
            class: 'item',
        },
        children: ['item'],
    }, {
        type: 'input',
        props: {
            disabled: true,
        },
        children: [],
    }],
}

我们可以注意到以下两点:

  • 所有的 DOM 节点都是一个类似于这样的对象:
{ type: '...', props: { ... }, children: { ... }, on: { ... } }
  • 本文节点是用 JavaScript 字符串来表示

那么 JSX 又是如何转化为 JavaScript 对象的呢。幸运的是,社区有许许多多优秀的工具帮助我们完成了这件事,由于篇幅有限,本文对这个问题暂时不做探讨。为了方便大家更快速地理解 Virtual DOM ,对于这一个步骤,笔者使用了开源工具来完成。著名的 babel 插件babel-plugin-transform-react-jsx帮助我们完成这项工作。

为了更好地使用babel-plugin-transform-react-jsx,我们需要搭建一下webpack开发环境。具体过程这里不做阐述,有兴趣自己实现的同学可以到simple-virtual-dom查看代码。

对于不使用 JSX 语法的同学,可以不配置babel-plugin-transform-react-jsx,通过我们的vdom函数创建 Virtual DOM:

function vdom(type, props, ...children) {
    return {
        type,
        props,
        children,
    };
}

然后我们可以通过如下代码创建我们的 Virtual DOM 树:

const vNode = vdom('div', null,
    vdom('span', { class: 'item' }, 'item'),
    vdom('input', { disabled: true })
);

在控制台输入上述代码,可以看到,已经创建好了用 JavaScript对象表示的 Virtual DOM 树:

将 Virtual DOM 转换为真实 DOM

现在我们知道了如何用 JavaScript对象 来代表我们的真实 DOM 树,那么, Virtual DOM 又是怎么转换为真实 DOM 给我们呈现的呢?

在这之前,我们要先知道几项注意事项:

  • 在代码中,笔者将以$开头的变量来表示真实 DOM 对象;
  • toRealDom函数接受一个 Virtual DOM 对象为参数,将返回一个真实 DOM 对象;
  • mount函数接受两个参数:将挂载 Virtual DOM 对象的父节点,这是一个真实 DOM 对象,命名为$parent;以及被挂载的 Virtual DOM 对象vNode

下面是toRealDom的函数原型:

function toRealDom(vNode) {
    let $dom;
    // do something with vNode
    return $dom;
}

通过toRealDom方法,我们可以将一个vNode对象转化为一个真实 DOM 对象,而mount函数通过appendChild,将真实 DOM 挂载:

function mount($parent, vNode) {
    return $parent.appendChild(toRealDom(vNode));
}

下面,让我们来分别处理vNodetypepropschildren

设置节点的类型

首先,因为我们同时具有字符类型的文本节点和对象类型的element节点,需要对type做单独的处理:

if (typeof vNode === 'string') {
    $dom = document.createTextNode(vNode);
} else {
    $dom = document.createElement(vNode.type);
}

在这样一个简单的toRealDom函数中,对type的处理就完成了,接下来让我们看看对props的处理。

设置节点的属性

我们知道,如果节点有props,那么props是一个对象。通过遍历props,调用setProp方法,对每一类props单独处理。

if (vNode.props) {
    Object.keys(vNode.props).forEach(key => {
        setProp($dom, key, vNode.props[key]);
    });
}

setProp接受三个参数:

  • $target,这是一个真实 DOM 对象,setProp将对这个节点进行 DOM 操作;
  • name,表示属性名;
  • value,表示属性的值;

读到这里,相信你已经大概清楚setProp需要做什么了,一般情况下,对于普通的props,我们会通过setAttribute给 DOM 对象附加属性。

function setProp($target, name, value) {
    return $target.setAttribute(name, value);
}

但这远远不够,思考下列的 JSX 结构:

<div>
    <span className="item" data-node="item" onClick={() => console.log('item')}>item</span>
    <input disabled={true} />
</div>

从上面的 JSX 结构中,我们发现以下几点:

  • 由于class是 JavaScript 的保留字, JSX 一般使用className来表示 DOM 节点所属的class
  • 一般以on开头的属性来表示事件;
  • 除字符类型外,属性还可能是布尔值,如disabled,当该值为true时,则添加这一属性;

所以,setProp也同样需要考虑上述情况:

function isEventProp(name) {
    return /^on/.test(name);
}

function extractEventName(name) {
    return name.slice(2).toLowerCase();
}

function setProp($target, name, value) {
    if (name === 'className') { // 因为class是保留字,JSX使用className来表示节点的class
        return $target.setAttribute('class', value);
    } else if (isEventProp(name)) { // 针对 on 开头的属性,为事件
        return $target.addEventListener(extractEventName(name), value);
    } else if (typeof value === 'boolean') { // 兼容属性为布尔值的情况
        if (value) {
            $target.setAttribute(name, value);
        }
        return $target[name] = value;
    } else {
        return $target.setAttribute(name, value);
    }
}

最后,还有一类属性是我们的自定义属性,例如主流框架中的组件间的状态传递,即通过props来进行传递的,我们并不希望这一类属性显示在 DOM 中,因此需要编写一个函数isCustomProp来检查这个属性是否是自定义属性,因为本文只是为了实现 Virtual DOM 的核心思想,为了方便,在本文中,这个函数直接返回false

function isCustomProp(name) {
    return false;
}

最终的setProp函数:

function setProp($target, name, value) {
    if (isCustomProp(name)) {
        return;
    } else if (name === 'className') { // fix react className
        return $target.setAttribute('class', value);
    } else if (isEventProp(name)) {
        return $target.addEventListener(extractEventName(name), value);
    } else if (typeof value === 'boolean') {
        if (value) {
            $target.setAttribute(name, value);
        }
        return $target[name] = value;
    } else {
        return $target.setAttribute(name, value);
    }
}

对子节点的处理

对于children里的每一项,都是一个vNode对象,在进行 Virtual DOM 转化为真实 DOM 时,子节点也需要被递归转化,可以想到,针对有子节点的情况,需要对子节点以此递归调用toRealDom,如下代码所示:

if (vNode.children && vNode.children.length) {
    vNode.children.forEach(childVdom => {
        const realChildDom = toRealDom(childVdom);
        $dom.appendChild(realChildDom);
    });
}

最终完成的toRealDom如下:

function toRealDom(vNode) {
    let $dom;
    if (typeof vNode === 'string') {
        $dom = document.createTextNode(vNode);
    } else {
        $dom = document.createElement(vNode.type);
    }

    if (vNode.props) {
        Object.keys(vNode.props).forEach(key => {
            setProp($dom, key, vNode.props[key]);
        });
    }

    if (vNode.children && vNode.children.length) {
        vNode.children.forEach(childVdom => {
            const realChildDom = toRealDom(childVdom);
            $dom.appendChild(realChildDom);
        });
    }

    return $dom;
}

处理变化

Virtual DOM 之所以被创造出来,最根本的原因是性能提升,通过 Virtual DOM ,开发者可以减少许多不必要的 DOM 操作,以达到最优性能,那么下面我们来看看 Virtual DOM 算法 是如何通过对比更新前的 Virtual DOM 树和更新后的 Virtual DOM 树来实现性能优化的。

注:本文是笔者的最简单实现,目前社区普遍通用的算法是snabbdom,如 Vue 则是借鉴该算法实现的 Virtual DOM ,有兴趣的读者可以查看这个库的源代码,基于本文的 Virtual DOM 的小示例,笔者最终也参考了该算法实现,本文demo传送门,由于篇幅有限,感兴趣的读者可以自行研究。

为了处理变化,首先声明一个updateDom函数,这个函数接受以下四个参数:

  • $parent,表示将被挂载的父节点;
  • oldVNode,旧的VNode对象;
  • newVNode,新的VNode对象;
  • index,在更新子节点时使用,表示当前更新第几个子节点,默认为0;

函数原型如下:

function updateDom($parent, oldVNode, newVNode, index = 0) {

}

新增与删除节点

首先我们来看新增一个节点的情况,对于原本没有该节点,需要添加新的一个节点到 DOM 树中,我们需要通过appendChild来实现:

转化为代码表述为:

// 没有旧的节点,添加新的节点
if (!oldVNode) {
    return $parent.appendChild(toRealDom(newVNode));
}

同理,对于删除一个旧节点的情况,我们通过removeChild来实现,在这里,我们应该从真实 DOM 中将旧的节点删掉,但问题是在这个函数中是直接取不到这一个节点的,我们需要知道这个节点在父节点中的位置,事实上,可以通过$parent.childNodes[index]来取到,这便是上面提到的为何需要传入index,它表示当前更新的节点在父节点中的索引:

转化为代码表述为:

const $currentDom = $parent.childNodes[index];

// 没有新的节点,删除旧的节点
if (!newVNode) {
    return $parent.removeChild($currentDom);
}

更新节点

Virtual DOM 的核心在于如何高效更新节点,下面我们来看看更新节点的情况。

首先,针对文本节点,我们可以简单处理,对于文本节点是否发生改变,只需要通过比较其新旧字符串是否相等即可,如果是相同的文本节点,是不需要我们更新 DOM 的,在updateDom函数中,直接return即可:

// 都是文本节点,都没有发生变化
if (typeof oldVNode === 'string' && typeof newVNode === 'string' && oldVNode === newVNode) {
    return;
}

接下来,考虑节点是否真的需要更新,如图所示,一个节点的类型从span换成了div,显而易见,这是一定需要我们去更新DOM的:

我们需要编写一个函数isNodeChanged来帮助我们判断旧节点和新节点是否真的一致,如果不一致,需要我们把节点进行替换:

function isNodeChanged(oldVNode, newVNode) {
    // 一个是textNode,一个是element,一定改变
    if (typeof oldVNode !== typeof newVNode) {
        return true;
    }

    // 都是textNode,比较文本是否改变
    if (typeof oldVNode === 'string' && typeof newVNode === 'string') {
        return oldVNode !== newVNode;
    }

    // 都是element节点,比较节点类型是否改变
    if (typeof oldVNode === 'object' && typeof newVNode === 'object') {
        return oldVNode.type !== newVNode.type;
    }
}

updateDom中,发现节点类型发生变化,则将该节点直接替换,如下代码所示,通过调用replaceChild,将旧的 DOM 节点移除,并将新的 DOM 节点加入:

if (isNodeChanged(oldVNode, newVNode)) {
    return $parent.replaceChild(toRealDom(newVNode), $currentDom);
}

但这远远还没有结束,考虑下面这种情况:

<!-- old -->
<div class="item" data-item="old-item"></div>
<!-- new -->
<div id="item" data-item="new-item"></div>

对比上面的新旧两个节点,发现节点类型并没有发生改变,即VNode.type都是'div',但是节点的属性却发生了改变,除了针对节点类型的变化更新 DOM 外,针对节点的属性的改变,也需要对应把 DOM 更新。

与上述方法类似,我们编写一个isPropsChanged函数,来判断新旧两个节点的属性是否有发生变化:

function isPropsChanged(oldProps, newProps) {
    // 类型都不一致,props肯定发生变化了
    if (typeof oldProps !== typeof newProps) {
        return true;
    }

    // props为对象
    if (typeof oldProps === 'object' && typeof newProps === 'object') {
        const oldKeys = Object.keys(oldProps);
        const newkeys = Object.keys(newProps);
        // props的个数都不一样,一定发生了变化
        if (oldKeys.length !== newkeys.length) {
            return true;
        }
        // props的个数相同的情况,遍历props,看是否有不一致的props
        for (let i = 0; i < oldKeys.length; i++) {
            const key = oldKeys[i]
            if (oldProps[key] !== newProps[key]) {
                return true;
            }
        }
        // 默认未改变
        return false;
    }

    return false;
}

因为当节点没有任何属性时,propsnullisPropsChanged首先判断新旧两个节点的props是否是同一类型,即是否存在旧节点的propsnull,新节点有新的属性,或者反之:新节点的propsnull,旧节点的属性被删除了。如果类型不一致,那么属性一定是被更新的。

接下来,考虑到节点在更新前后都有props的情况,我们需要判断更新前后的props是否一致,即两个对象是否全等,遍历即可。如果有不相等的属性,则认为props发生改变,需要处理props的变化。

现在,让我们回到我们的updateDom函数,看看是把Virtual DOM 节点props的更新应用到真实 DOM 上的。

// 虚拟DOM的type未改变,对比节点的props是否改变
const oldProps = oldVNode.props || {};
const newProps = newVNode.props || {};
if (isPropsChanged(oldProps, newProps)) {
    const oldPropsKeys = Object.keys(oldProps);
    const newPropsKeys = Object.keys(newProps);

    // 如果新节点没有属性,把旧的节点的属性清除掉
    if (newPropsKeys.length === 0) {
        oldPropsKeys.forEach(propKey => {
            removeProp($currentDom, propKey, oldProps[propKey]);
        });
    } else {
        // 拿到所有的props,以此遍历,增加/删除/修改对应属性
        const allPropsKeys = new Set([...oldPropsKeys, ... newPropsKeys]);
        allPropsKeys.forEach(propKey => {
            // 属性被去除了
            if (!newProps[propKey]) {
                return removeProp($currentDom, propKey, oldProps[propKey]);
            }
            // 属性改变了/增加了
            if (newProps[propKey] !== oldProps[propKey]) {
                return setProp($currentDom, propKey, newProps[propKey]);
            }
        });
    }
}

上面的代码也非常好理解,如果发现props改变了,那么对旧的props的每项去做遍历。把不存在的属性清除,再把新增加的属性加入到更新后的 DOM 树中:

  • 首先,如果新的节点没有属性,遍历删除所有旧的节点的属性,在这里,我们通过调用removeProp删除。removePropsetProp相对应,由于本文篇幅有限,笔者在这里就不做过多阐述;
function removeProp($target, name, value) {
    if (isCustomProp(name)) {
        return;
    } else if (name === 'className') { // fix react className
        return $target.removeAttribute('class');
    } else if (isEventProp(name)) {
        return $target.removeEventListener(extractEventName(name), value);
    } else if (typeof value === 'boolean') {
        $target.removeAttribute(name);
        $target[name] = false;
    } else {
        $target.removeAttribute(name);
    }
}
  • 如果新节点有属性,那么拿到旧节点和新节点所有属性,遍历新旧节点的所有属性,如果属性在新节点中没有,那么说明该属性被删除了。如果新的节点与旧的节点属性不一致/或者是新增的属性,则调用setProp给真实 DOM 节点添加新的属性。

更新子节点

在最后,与toRealDom类似的是,在updateDom中,我们也应当处理所有子节点,对子节点进行递归调用updateDom,一个一个对比所有子节点的VNode是否有更新,一旦VNode有更新,则真实 DOM 也需要重新渲染:

// 根节点相同,但子节点不同,要递归对比子节点
if (
    (oldNode.children && oldNode.children.length) ||
    (newNode.children && newNode.children.length)
) {
    for (let i = 0; i < oldNode.children.length || i < newNode.children.length; i++) {
        updateDom($currentDom, oldNode.children[i], newNode.children[i], i);
    }
}

远远没有结束

以上是笔者实现的最简单的 Virtual DOM 代码,但这与社区我们所用到 Virtual DOM 算法是有天壤之别的,笔者在这里举个最简单的例子:

<!-- old -->
<ul>
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
    <li>5</li>
</ul>
<!-- new -->
<ul>
    <li>5</li>
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
</ul>

对于上述代码中实现的updateDom函数而言,更新前后的 DOM 结构如上所示,则会触发五个li节点全部重新渲染,这显然是一种性能的浪费。而snabbdom则通过移动节点的方式较好地解决了上述问题,由于本文篇幅有限,并且社区也有许多对该 Virtual DOM 算法的分析文章,笔者就不在本文做过多阐述了,有兴趣的读者可以到自行研究。笔者也基于本文实例,参考snabbdom算法实现了最终的版本,有兴趣的读者可以查看本文示例最终版

查看原文

赞 32 收藏 22 评论 0

counterxing 赞了文章 · 2019-04-03

webpack 构建性能优化策略小结

背景

如今前端工程化的概念早已经深入人心,选择一款合适的编译和资源管理工具已经成为了所有前端工程中的标配,而在诸多的构建工具中,webpack以其丰富的功能和灵活的配置而深受业内吹捧,逐步取代了grunt和gulp成为大多数前端工程实践中的首选,React,Vue,Angular等诸多知名项目也都相继选用其作为官方构建工具,极受业内追捧。但是,随者工程开发的复杂程度和代码规模不断地增加,webpack暴露出来的各种性能问题也愈发明显,极大的影响着开发过程中的体验。

图片描述

问题归纳

历经了多个web项目的实战检验,我们对webapck在构建中逐步暴露出来的性能问题归纳主要有如下几个方面:

  • 代码全量构建速度过慢,即使是很小的改动,也要等待长时间才能查看到更新与编译后的结果(引入HMR热更新后有明显改进);
  • 随着项目业务的复杂度增加,工程模块的体积也会急剧增大,构建后的模块通常要以M为单位计算;
  • 多个项目之间共用基础资源存在重复打包,基础库代码复用率不高;
  • node的单进程实现在耗cpu计算型loader中表现不佳;

针对以上的问题,我们来看看怎样利用webpack现有的一些机制和第三方扩展插件来逐个击破。

慢在何处

作为工程师,我们一直鼓励要理性思考,用数据和事实说话,“我觉得很慢”,“太卡了”,“太大了”之类的表述难免显得太笼统和太抽象,那么我们不妨从如下几个方面来着手进行分析:

图片描述

  • 从项目结构着手,代码组织是否合理,依赖使用是否合理;
  • 从webpack自身提供的优化手段着手,看看哪些api未做优化配置;
  • 从webpack自身的不足着手,做有针对性的扩展优化,进一步提升效率;

在这里我们推荐使用一个wepback的可视化资源分析工具:webpack-bundle-analyzer,在webpack构建的时候会自动帮你计算出各个模块在你的项目工程中的依赖与分布情况,方便做更精确的资源依赖和引用的分析。

从上图中我们不难发现大多数的工程项目中,依赖库的体积永远是大头,通常体积可以占据整个工程项目的7-9成,而且在每次开发过程中也会重新读取和编译对应的依赖资源,这其实是很大的的资源开销浪费,而且对编译结果影响微乎其微,毕竟在实际业务开发中,我们很少会去主动修改第三方库中的源码,改进方案如下:

方案一、合理配置 CommonsChunkPlugin

webpack的资源入口通常是以entry为单元进行编译提取,那么当多entry共存的时候,CommonsChunkPlugin的作用就会发挥出来,对所有依赖的chunk进行公共部分的提取,但是在这里可能很多人会误认为抽取公共部分指的是能抽取某个代码片段,其实并非如此,它是以module为单位进行提取。

假设我们的页面中存在entry1,entry2,entry3三个入口,这些入口中可能都会引用如utils,loadash,fetch等这些通用模块,那么就可以考虑对这部分的共用部分机提取。通常提取方式有如下四种实现:

1、传入字符串参数,由chunkplugin自动计算提取

new webpack.optimize.CommonsChunkPlugin('common.js')

这种做法默认会把所有入口节点的公共代码提取出来, 生成一个common.js

2、有选择的提取公共代码

new webpack.optimize.CommonsChunkPlugin('common.js',['entry1','entry2']);

只提取entry1节点和entry2中的共用部分模块, 生成一个common.js

3、将entry下所有的模块的公共部分(可指定引用次数)提取到一个通用的chunk中

new webpack.optimize.CommonsChunkPlugin({
    name: 'vendors',
    minChunks: function (module, count) {
       return (
          module.resource &&
          /\.js$/.test(module.resource) &&
          module.resource.indexOf(
            path.join(__dirname, '../node_modules')
          ) === 0
       )
    }
});

提取所有node_modules中的模块至vendors中,也可以指定minChunks中的最小引用数;

4、抽取enry中的一些lib抽取到vendors中

entry = {
    vendors: ['fetch', 'loadash']
};
new webpack.optimize.CommonsChunkPlugin({
    name: "vendors",
    minChunks: Infinity
});

添加一个entry名叫为vendors,并把vendors设置为所需要的资源库,CommonsChunk会自动提取指定库至vendors中。

方案二、通过 externals 配置来提取常用库

在实际项目开发过程中,我们并不需要实时调试各种库的源码,这时候就可以考虑使用external选项了。

图片描述

简单来说external就是把我们的依赖资源声明为一个外部依赖,然后通过script外链脚本引入。这也是我们早期页面开发中资源引入的一种翻版,只是通过配置后可以告知webapck遇到此类变量名时就可以不用解析和编译至模块的内部文件中,而改用从外部变量中读取,这样能极大的提升编译速度,同时也能更好的利用CDN来实现缓存。

external的配置相对比较简单,只需要完成如下三步:

1、在页面中加入需要引入的lib地址,如下:

<head>
<script data-original="//cdn.bootcss.com/jquery.min.js"></script>
<script data-original="//cdn.bootcss.com/underscore.min.js"></script>
<script data-original="/static/common/react.min.js"></script>
<script data-original="/static/common/react-dom.js"></script>
<script data-original="/static/common/react-router.js"></script>
<script data-original="/static/common/immutable.js"></script>
</head>

2、在webapck.config.js中加入external配置项:

module.export = {
    externals: {
        'react-router': {
            amd: 'react-router',
            root: 'ReactRouter',
            commonjs: 'react-router',
            commonjs2: 'react-router'
        },
        react: {
            amd: 'react',
            root: 'React',
            commonjs: 'react',
            commonjs2: 'react'
        },
        'react-dom': {
            amd: 'react-dom',
            root: 'ReactDOM',
            commonjs: 'react-dom',
            commonjs2: 'react-dom'
        }
    }
}

这里要提到的一个细节是:此类文件在配置前,构建这些资源包时需要采用amd/commonjs/cmd相关的模块化进行兼容封装,即打包好的库已经是umd模式包装过的,如在node_modules/react-router中我们可以看到umd/ReactRouter.js之类的文件,只有这样webpack中的require和import * from 'xxxx'才能正确读到该类包的引用,在这类js的头部一般也能看到如下字样:


if (typeof exports === 'object' && typeof module === 'object') {
    module.exports = factory(require("react"));
} else if (typeof define === 'function' && define.amd) {
    define(["react"], factory);
} else if (typeof exports === 'object') {
    exports["ReactRouter"] = factory(require("react"));
} else {
    root["ReactRouter"] = factory(root["React"]);
}

3、非常重要的是一定要在output选项中加入如下一句话:

output: {
  libraryTarget: 'umd'
}

由于通过external提取过的js模块是不会被记录到webapck的chunk信息中,通过libraryTarget可告知我们构建出来的业务模块,当读到了externals中的key时,需要以umd的方式去获取资源名,否则会有出现找不到module的情况。

通过配置后,我们可以看到对应的资源信息已经可以在浏览器的source map中读到了。

externals.png

对应的资源也可以直接由页面外链载入,有效地减小了资源包的体积。

图片描述

方案三、利用 DllPlugin 和 DllReferencePlugin 预编译资源模块

我们的项目依赖中通常会引用大量的npm包,而这些包在正常的开发过程中并不会进行修改,但是在每一次构建过程中却需要反复的将其解析,如何来规避此类损耗呢?这两个插件就是干这个用的。

简单来说DllPlugin的作用是预先编译一些模块,而DllReferencePlugin则是把这些预先编译好的模块引用起来。这边需要注意的是DllPlugin必须要在DllReferencePlugin执行前先执行一次,dll这个概念应该也是借鉴了windows程序开发中的dll文件的设计理念。

相对于externals,dllPlugin有如下几点优势:

  • dll预编译出来的模块可以作为静态资源链接库可被重复使用,尤其适合多个项目之间的资源共享,如同一个站点pc和手机版等;
  • dll资源能有效地解决资源循环依赖的问题,部分依赖库如:react-addons-css-transition-group这种原先从react核心库中抽取的资源包,整个代码只有一句话:

    module.exports = require('react/lib/ReactCSSTransitionGroup');

    却因为重新指向了react/lib中,这也会导致在通过externals引入的资源只能识别react,寻址解析react/lib则会出现无法被正确索引的情况。

  • 由于externals的配置项需要对每个依赖库进行逐个定制,所以每次增加一个组件都需要手动修改,略微繁琐,而通过dllPlugin则能完全通过配置读取,减少维护的成本;

1、配置dllPlugin对应资源表并编译文件

那么externals该如何使用呢,其实只需要增加一个配置文件:webpack.dll.config.js:

const webpack = require('webpack');
const path = require('path');
const isDebug = process.env.NODE_ENV === 'development';
const outputPath = isDebug ? path.join(__dirname, '../common/debug') : path.join(__dirname, '../common/dist');
const fileName = '[name].js';

// 资源依赖包,提前编译
const lib = [
  'react',
  'react-dom',
  'react-router',
  'history',
  'react-addons-pure-render-mixin',
  'react-addons-css-transition-group',
  'redux',
  'react-redux',
  'react-router-redux',
  'redux-actions',
  'redux-thunk',
  'immutable',
  'whatwg-fetch',
  'byted-people-react-select',
  'byted-people-reqwest'
];

const plugin = [
  new webpack.DllPlugin({
    /**
     * path
     * 定义 manifest 文件生成的位置
     * [name]的部分由entry的名字替换
     */
    path: path.join(outputPath, 'manifest.json'),
    /**
     * name
     * dll bundle 输出到那个全局变量上
     * 和 output.library 一样即可。
     */
    name: '[name]',
    context: __dirname
  }),
  new webpack.optimize.OccurenceOrderPlugin()
];

if (!isDebug) {
  plugin.push(
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('production')
    }),
    new webpack.optimize.UglifyJsPlugin({
      mangle: {
        except: ['$', 'exports', 'require']
      },
      compress: { warnings: false },
      output: { comments: false }
    })
  )
}

module.exports = {
  devtool: '#source-map',
  entry: {
    lib: lib
  },
  output: {
    path: outputPath,
    filename: fileName,
    /**
     * output.library
     * 将会定义为 window.${output.library}
     * 在这次的例子中,将会定义为`window.vendor_library`
     */
    library: '[name]',
    libraryTarget: 'umd',
    umdNamedDefine: true
  },
  plugins: plugin
};

然后执行命令:

 $ NODE_ENV=development webpack --config  webpack.dll.lib.js --progress
 $ NODE_ENV=production webpack --config  webpack.dll.lib.js --progress 

即可分别编译出支持调试版和生产环境中lib静态资源库,在构建出来的文件中我们也可以看到会自动生成如下资源:

common
├── debug
│   ├── lib.js
│   ├── lib.js.map
│   └── manifest.json
└── dist
    ├── lib.js
    ├── lib.js.map
    └── manifest.json

文件说明:

  • lib.js可以作为编译好的静态资源文件直接在页面中通过src链接引入,与externals的资源引入方式一样,生产与开发环境可以通过类似charles之类的代理转发工具来做路由替换;
  • manifest.json中保存了webpack中的预编译信息,这样等于提前拿到了依赖库中的chunk信息,在实际开发过程中就无需要进行重复编译;

2、dllPlugin的静态资源引入

lib.js和manifest.json存在一一对应的关系,所以我们在调用的过程也许遵循这个原则,如当前处于开发阶段,对应我们可以引入common/debug文件夹下的lib.js和manifest.json,切换到生产环境的时候则需要引入common/dist下的资源进行对应操作,这里考虑到手动切换和维护的成本,我们推荐使用add-asset-html-webpack-plugin进行依赖资源的注入,可得到如下结果:

<head>
<script data-original="/static/common/lib.js"></script>
</head>

在webpack.config.js文件中增加如下代码:

const isDebug = (process.env.NODE_ENV === 'development');
const libPath = isDebug ? '../dll/lib/manifest.json' : 
'../dll/dist/lib/manifest.json';

// 将mainfest.json添加到webpack的构建中

module.export = {
  plugins: [
       new webpack.DllReferencePlugin({
       context: __dirname,
       manifest: require(libPath),
      })
  ]
}

配置完成后我们能发现对应的资源包已经完成了纯业务模块的提取

图片描述

多个工程之间如果需要使用共同的lib资源,也只需要引入对应的lib.js和manifest.js即可,plugin配置中也支持多个webpack.DllReferencePlugin同时引入使用,如下:

module.export = {
  plugins: [
     new webpack.DllReferencePlugin({
        context: __dirname,
        manifest: require(libPath),
      }),
      new webpack.DllReferencePlugin({
        context: __dirname,
        manifest: require(ChartsPath),
      })
  ]
}

方案四、使用 Happypack 加速你的代码构建

以上介绍均为针对webpack中的chunk计算和编译内容的优化与改进,对资源的实际体积改进上也较为明显,那么除此之外,我们能否针对资源的编译过程和速度优化上做些尝试呢?

众所周知,webpack中为了方便各种资源和类型的加载,设计了以loader加载器的形式读取资源,但是受限于node的编程模型影响,所有的loader虽然以async的形式来并发调用,但是还是运行在单个 node的进程以及在同一个事件循环中,这就直接导致了当我们需要同时读取多个loader文件资源时,比如babel-loader需要transform各种jsx,es6的资源文件。在这种同步计算同时需要大量耗费cpu运算的过程中,node的单进程模型就无优势了,那么happypack就针对解决此类问题而生。

开启happypack的线程池

happypack的处理思路是将原有的webpack对loader的执行过程从单一进程的形式扩展多进程模式,原本的流程保持不变,这样可以在不修改原有配置的基础上来完成对编译过程的优化,具体配置如下:

 const HappyPack = require('happypack');
 const os = require('os')
 const HappyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length}); // 启动线程池});

module:{
    rules: [
      {
        test: /\.(js|jsx)$/,
        // use: ['babel-loader?cacheDirectory'],
        use: 'happypack/loader?id=jsx',
        exclude: /^node_modules$/
      }
    ]
  },
  plugins:[
    new HappyPack({
     id: 'jsx',
     cache: true,
     threadPool: HappyThreadPool,
     loaders: ['babel-loader']
   })
  ]

我们可以看到通过在loader中配置直接指向happypack提供的loader,对于文件实际匹配的处理 loader,则是通过配置在plugin属性来传递说明,这里happypack提供的loader与plugin的衔接匹配,则是通过id=happybabel来完成。配置完成后,laoder的工作模式就转变成了如下所示:

图片描述

happypack在编译过程中除了利用多进程的模式加速编译,还同时开启了cache计算,能充分利用缓存读取构建文件,对构建的速度提升也是非常明显的,经过测试,最终的构建速度提升如下:

优化前:
图片描述

优化后:
图片描述

关于happyoack的更多介绍可以查看:

方案五、增强 uglifyPlugin

uglifyJS凭借基于node开发,压缩比例高,使用方便等诸多优点已经成为了js压缩工具中的首选,但是我们在webpack的构建中观察发现,当webpack build进度走到80%前后时,会发生很长一段时间的停滞,经测试对比发现这一过程正是uglfiyJS在对我们的output中的bunlde部分进行压缩耗时过长导致,针对这块我们可以使用webpack-uglify-parallel来提升压缩速度。

从插件源码中可以看到,webpack-uglify-parallel的是实现原理是采用了多核并行压缩的方式来提升我们的压缩速度。

plugin.nextWorker().send({
    input: input,
    inputSourceMap: inputSourceMap,
    file: file,
    options: options
});

plugin._queue_len++;
                
if (!plugin._queue_len) {
    callback();
}               

if (this.workers.length < this.maxWorkers) {
    var worker = fork(__dirname + '/lib/worker');
    worker.on('message', this.onWorkerMessage.bind(this));
    worker.on('error', this.onWorkerError.bind(this));
    this.workers.push(worker);
}

this._next_worker++;
return this.workers[this._next_worker % this.maxWorkers];

使用配置也非常简单,只需要将我们原来webpack中自带的uglifyPlugin配置:

new webpack.optimize.UglifyJsPlugin({
   exclude:/\.min\.js$/
   mangle:true,
   compress: { warnings: false },
   output: { comments: false }
})

修改成如下代码即可:

const os = require('os');
    const UglifyJsParallelPlugin = require('webpack-uglify-parallel');
    
    new UglifyJsParallelPlugin({
      workers: os.cpus().length,
      mangle: true,
      compressor: {
        warnings: false,
        drop_console: true,
        drop_debugger: true
       }
    })

目前webpack官方也维护了一个支持多核压缩的UglifyJs插件:uglifyjs-webpack-plugin,使用方式类似,优势在于完全兼容webpack.optimize.UglifyJsPlugin中的配置,可以通过uglifyOptions写入,因此也做为推荐使用,参考配置如下:

 const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
  new UglifyJsPlugin({
    uglifyOptions: {
      ie8: false,
      ecma: 8,
      mangle: true,
      output: { comments: false },
      compress: { warnings: false }
    },
    sourceMap: false,
    cache: true,
    parallel: os.cpus().length * 2
  })

方案六、Tree-shaking & Scope Hoisting

wepback在2.X和3.X中从rolluo中借鉴了tree-shakingScope Hoisting,利用es6的module特性,利用AST对所有引用的模块和方法做了静态分析,从而能有效地剔除项目中的没有引用到的方法,并将相关方法调用归纳到了独立的webpack_module中,对打包构建的体积优化也较为明显,但是前提是所有的模块写法必须使用ES6 Module进行实现,具体配置参考如下:

 // .babelrc: 通过配置减少没有引用到的方法
  {
    "presets": [
      ["env", {
        "targets": {
          "browsers": ["last 2 versions", "safari >= 7"]
        }
      }],
      // https://www.zhihu.com/question/41922432
      ["es2015", {"modules": false}]  // tree-shaking
    ]
  }

  // webpack.config: Scope Hoisting
  {
    plugins:[
      // https://zhuanlan.zhihu.com/p/27980441
      new webpack.optimize.ModuleConcatenationPlugin()
    ]
  }

适用场景

在实际的开发过程中,可灵活地选择适合自身业务场景的优化手段。

优化手段开发环境生产环境
CommonsChunk
externals 
DllPlugin
Happypack 
uglify-parallel 

工程演示demo

温馨提醒

本文中的所有例子已经重新优化,支持最新的webpack3特性,并附带有分享ppt地址,可以在线点击查看

小结

性能优化无小事,追求快没有止境,在前端工程日益庞大复杂的今天,针对实际项目,持续改进构建工具的性能,对项目开发效率的提升和工具深度理解都是极其有益的。

查看原文

赞 109 收藏 338 评论 25

认证与成就

  • 获得 523 次点赞
  • 获得 6 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 6 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-08-13
个人主页被 2.1k 人浏览