0%

初次使用

安装 ts

1
2
3
4
5
6
7
npm i -g typescript
//查看版本
tsc -v

//使用tsc编译文件,但是每次都要运行
//监听模式可以实时编译
tsc -w

基础类型

数组

数组有两种定义方法.

  1. 在元素类型后接 [] .表示由此类型元素组成的一个数组.
1
let list: number[] = [1, 2, 3];
  1. 使用数组泛型.
1
let list: Array<number> = [1, 2, 3];

元组 Tuple

元组类型允许表示一个已知元素数量和类型的数组,各元素的类型不必相同.(可以理解为混合数组).

1
2
3
let x: [string, number];
x = ["hello", 10]; //正确
x = [10, "hello"]; //错误

当访问一个越界的元素,会使用联合类型替代:

1
2
3
x[3] = "world"; // OK, 字符串可以赋值给(string | number)类型
console.log(x[5].toString()); // OK, 'string' 和 'number' 都有 toString
x[6] = true; // Error, 布尔不是(string | number)类型

在 react 中使用

便于重命名

1
2
3
4
5
const useHappy = () => {
return [isHappy,makeTomHappy,makeUnHappy]
}
const SomeComponent = () => {
const [tomIsHappy,makeTomHappy,makeTomUnHappy] = useHappy(false)

枚举

enum  是对 JS 标准数据类型的一种补充.

1
2
3
4
enum Color {Red, Green, Blue}
//有一种数据类型是Color,这种类型有三种参数
let c: Color = Color.Green
//设定c是Color类型中的Green

默认情况下,从 0  开始为元素符号. 可以手动赋值

1
2
3
enum Color {Red = 1, Green, Blue}
let c: Color = Color.Green;
console.log(c) //2,初始值设定为1,那么其余成员会从1开始增长

然后可以由枚举值获得它的名字.

1
2
3
enum Color {Red = 1, Green, Blue}
let c: Color = Color[2]
console.log(c) //显示"Green"

Any

用于暂时不清楚类型的指定类型.
当只知道一部分数据类型时, any  类型也是有用的.

1
2
let list: any = [1, true, "free"];
list[1] = 100;

Void

void  类型表示与 any  类型相反.它表示没有任何类型,
当一个函数没有返回值时,通常其返回值类型是 void .
声明一个void类型的变量没有什么大用,因为你只能为它赋予undefinednull

1
let unusable: void = undefined;

Null 和 Undefined

TypeScript 里,undefinednull两者各自有自己的类型分别叫做undefinednull。 和  void相似,它们的本身的类型用处不是很大:

1
2
let u: undefined = undefined;
let n: null = null;

默认情况下nullundefined是所有类型的子类型。 就是说你可以把  nullundefined赋值给number类型的变量。

然而,当你指定了--strictNullChecks标记,nullundefined只能赋值给void和它们各自。 这能避免 很多常见的问题。 也许在某处你想传入一个 stringnullundefined,你可以使用联合类型string | null | undefined。 再次说明,稍后我们会介绍联合类型。
注意:我们鼓励尽可能地使用--strictNullChecks,但在本手册里我们假设这个标记是关闭的。

Never

never  类型表示那些永不存在的类型.例如, never类型是那些总是会抛出异常或根本就不会有返回值的函数表达式或箭头函数表达式的返回值类型; 变量也可能是  never类型,当它们被永不为真的类型保护所约束时。

never类型是任何类型的子类型,也可以赋值给任何类型;然而,没有类型是never的子类型或可以赋值给never类型(除了never本身之外)。 即使  any也不可以赋值给never

1
2
3
4
// 返回never的函数必须存在无法达到的终点
function error(message: string): never {
throw new Error(message);
}

Object

object  表示非原始类型.也就是除 number , string , boolean , symbol , null , undefined  之外的类型.
用来表示对象类型。

1
2
3
4
5
declare function create(o: object | null): void;
create({ prop: 0 }); //ok
create(null); //ok

create(42); //Error

类型断言

类型断言类似于类型转换,不进行特殊的数据检查和解构.
没有运行时的影响,只在编译阶段起作用.
Typescript  假设已进行了必须的检查.

类型断言的形式

  1. 尖括号
1
2
let someValue: any = "this is a string"
let strLength: number = (<string>someString).length
  1. as  语法
1
2
let someValue: any = "this is a stirng"
let strLength: number = (someValue as string).length

我们传给setTimeout的每一个函数表达式实际上都引用了相同作用域里的同一个i

接口 interface

接口的作用就是为类型命名和三方代码定义契约.

可选属性

带可选属性的接口在可选属性名字定义的后面加 ?  符号.

好处一: 对可能存在的属性进行预定义.
好处二: 可以捕获引用了不存在属性时的错误.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): { color: string, area: number } {
let newSquare = { color: "white", area: 100 };
if (config.color) {
newSquare.color = config.color;
}
if (config.width) {
newSquare.area = config.width * config.width;
}

return newSquare;
}
let mySquare = createSquare({ color: "black" });

可选参数

1
2
3
4
5
interface Person {
name: string;
age?: string;
[propName: string]: any;
}

只读属性

一些对象属性只能在对象刚刚创建时修改其值.
可以在属性名前加 readonly  指定只读属性.

1
2
3
4
interface Point {
readonly x: number;
readonly y: number;
}

通过赋值一个对象字面量来构造 Point .赋值后, x  和 y  不能再被改变.

1
2
let p1: Point = { x: 10, y: 20 };
p1.x = 5; //Error

TypeScript 具有 ReadonlyArray<T>  类型,与 Array<T>  相似.
只是把所有可变方法去掉了.可以确保数组创建后再也不会被修改.

1
2
3
4
5
let a: number[] = [1, 2, 3];
let ro: ReadonlyArray<number> = a;
ro[0] = 2; //error
ro.push(4); //error
a = ro; //error

ReadonlyArray  赋值到一个普通数组也不行.
但是可以用类型断言重写.

1
a = ro as number[]

readonly  和 const

最简单判断该用 readonly 还是 const 的方法是看要把它做为变量使用还是做为一个属性。  做为变量使用的话用  const,若做为属性则使用 readonly。

额外的属性检查

如可选属性上的例子,如果

1
let mySquare = createSquare({ colour: "red", width: 100 }); //error

额外的属性检查会报错.

绕过属性检查

  1. 使用类型断言
1
let mySquare = createSquare({ width: 100, opacity: 0.5}) as SquareConfig;
  1. 添加字符串索引签名
1
2
3
4
5
interface SquareConfig {
color?: string;
width?: number;
[propName: string]: any;
}
  1. 将这个对象赋值给另一个变量
1
2
let SquareOptions = { colour: "red", width: 10 };
let mySquare = createsquare(squareOptions);

因为  squareOptions 不会经过额外属性检查,所以编译器不会报错。

函数类型

给接口定义一个调用签名就可以使用接口表示函数类型.

1
2
3
interface SearchFunc {
(source: string, substring: string): boolean;
}

展示创建一个函数类型的变量,并将同一类型的函数赋值给这个变量.

1
2
3
4
5
let mySearch: SearchFunc;
mySearch = function (source: string, subString: string) {
let result = src.search(subString);
return result > -1;
};

对于函数类型的类型检查来说,函数的参数名不需要与接口里定义的名字相匹配。  比如,我们使用下面的代码重写上面的例子:

1
2
3
4
5
let mySearch: SearchFunc;
mySearch = function (src: string, sub: string): boolean {
let result = src.search(sub);
return result > -1;
};

可索引的类型

指能够”通过索引得到”的类型.

1
2
3
4
5
6
interface StringArray {
[index: number]: string;
}
let myArray: StringArray;
myAray = ["Bob", "Fred"];
let myStr: string = myArray[0];

上面例子,定义了 StringArray  接口,具有索引签名.
这个索引签名表示当用 number  去索引 StringArray  时会得到 String  类型的返回值.

TypeScript 支持两种索引签名:字符串和数字。  可以同时使用两种类型的索引,但是数字索引的返回值必须是字符串索引返回值类型的子类型。  这是因为当使用  number 来索引时,JavaScript 会将它转换成 string 然后再去索引对象。  也就是说用  100(一个 number)去索引等同于使用”100”(一个 string)去索引,因此两者需要保持一致。

可以将索引签名设置为只读,这样就防止了给索引赋值:

1
2
3
4
5
interface ReadonlyStringArray {
readonly [index: number]: string;
}
let myArray: ReadonlyStringArray = ["Alice", "Bob"]
mArray[2] = "Marry" //error

你不能设置 myArray[2],因为索引签名是只读的。

类类型

实现接口

与 C#或 Java 里接口的基本作用一样,TypeScript 也能够用它来明确的强制一个类去符合某种契约。

1
2
3
4
5
6
7
8
interface ClockInterface {
currentTime: Date;
}

class Clock implements ClockInterface {
currentTime: Date;
constructor(h: number, m: number) {}
}

也可以在接口中描述一个方法,在类里实现,

1
2
3
4
5
6
7
8
9
10
11
12
interface ClockInterface {
currentTime: Date;
setTime(d: Date);
}

class Clock implements ClockInterface {
currentTime: Date;
setTime(d: Date) {
this.currentTime = d;
}
constructor(h: number, m: number){ }
}

接口描述了类的公共部分,而不是公共和私有两部分。  它不会帮你检查类是否具有某些私有成员。

类静态部分与实例部分的区别(没看懂)

当你操作类和接口的时候,你要知道类是具有两个类型的:静态部分的类型和实例的类型。  你会注意到,当你用构造器签名去定义一个接口并试图定义一个类去实现这个接口时会得到一个错误:
这里因为当一个类实现了一个接口时,只对其实例部分进行类型检查。 constructor 存在于类的静态部分,所以不在检查的范围内。
因此,我们应该直接操作类的静态部分。  看下面的例子,我们定义了两个接口, ClockConstructor 为构造函数所用和 ClockInterface 为实例方法所用。  为了方便我们定义一个构造函数  createClock,它用传入的类型创建实例。(没看懂)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
interface ClockConstructor {
new (hour: number, minute: number): ClockInterface;
}
interface ClockInterface {
tick();
}

function createClock(ctor: ClockConstructor, hour: number, minute: number): ClockInterface {
return new ctor(hour, minute);
}

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 是否符合构造函数签名。

继承接口

和类一样,接口也可以继承.

1
2
3
4
5
6
7
8
9
interface Shape {
color: string;
}
interface Square extends Shape {
sideLength: number;
}
let square = <Square>{}
square.color = "blue";
square.sideLength = 10;

一个接口可以继承多个接口,创建出多个接口的合成接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface Shape {
color: string;
}

interface PenStroke {
penWidth: number;
}

interface Square extends Shape, PenStroke {
sideLength: number;
}

let square = <Square>{};
square.color = "blue";
square.sideLength = 10;
square.penWidth = 5.0;

混合类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface Counter {
(start: number): string;
interval: number;
reset(): void;
}

function getCounter(): Counter {
let counter = <Counter>function (start: number) { };
counter.interval = 123;
counter.reset = function () { };
return counter;
}

let c = getCounter();
c(10);
c.reset();
c.interval = 5.0;

接口继承类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Control {
private state: any;
}

interface SelectableControl extends Control {
select(): void;
}

class Button extends Control implements SelectableControl {
select() { }
}

class TextBox extends Control {
select() { }
}

// 错误:“Image”类型缺少“state”属性。
class Image implements SelectableControl {
select() { }
}

class Location {

}

在上面的例子里,SelectableControl 包含了 Control 的所有成员,包括私有成员 state。  因为  state 是私有成员,所以只能够是 Control 的子类们才能实现 SelectableControl 接口。  因为只有  Control 的子类才能够拥有一个声明于 Control 的私有成员 state,这对私有成员的兼容性是必需的。
在 Control 类内部,是允许通过 SelectableControl 的实例来访问私有成员 state 的。  实际上, SelectableControl 接口和拥有 select 方法的 Control 类是一样的。 Button 和 TextBox 类是 SelectableControl 的子类(因为它们都继承自 Control 并有 select 方法),但 Image 和 Location 类并不是这样的

类型别名 type

大部分时候,类型别名 type 和接口 interface 可以互换.但是有些时候只能使用类型别名.
比如,联合类型.

1
2
3
4
5
6
7
8
9
10
class Greeter {
greeting: string;
constructor(message: string) {
this.greeting = message;
}
greet() {
return "Hello, " + this.greeting;
}
}
let greeter = new Greeter("world");

类的关键字

  • public
  • private 类的外部不可用,继承也不行
  • protected 类的外部不可用,继承可以
  • public readOnly xxx 只读属性
  • static funcXXX 静态方法,不需要 new 就可以调用
  • abstract funcXXX 抽象类,所有子类都必须要实现 funcXXX

类有 3 个成员: greeting  内部参数, constructor  构造函数, greet()  方法

继承

使用继承来扩展类.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//基类
class Animal {
move(distanceInMeters: number = 0) {
console.log(`Animal moved ${distanceInMeters}m`);
}
}
类;
class Dog extends Animal {
bark() {
console.log("aa");
}
}
const dog = new Dog();
dog.bark();
dog.move(10);
dog.bark();

类从基类中继承了属性和方法.
其中, Dog  是个派生类,它派生自 Animal  基类,通过 extends  关键字.
派生类通常被称为子类, 基类为超类.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Animal {
name: string;
constructor(theName: string) {
this.name = theName
}
move(distanceInMeters: number = 0){
console.log(`${this.name} moved ${distanceInMeters}m`)
}
class Snake extends Animal {
constructor(name: string) {
super(name)
}
move(distanceInMeters = 5){
console.log("Slitering...")
super.move(distanceInMeters)
}
}
class Horse extends Animal {
constructor(name: string){
super(name)
}
move(distanceInMeters = 40){
console.log("Galloping...");
super.move(distanceInMeters);
}
}
let sam = new Snake('Python');
let tom: Animal = new Horse('Palo');
sam.move();
tom.move(34);

派生类 Snake  包含一个 super() ,它会执行基类的构造函数.
在构造函数里访问 this  的属性之前,一定要调用 super .

Snake 类和  Horse 类都创建了  move 方法,它们重写了从  Animal 继承来的  move 方法,使得  move 方法根据不同的类而具有不同的功能。  注意,即使  tom 被声明为  Animal 类型,但因为它的值是  Horse,调用  tom.move(34)时,它会调用  Horse 里重写的方法

公有,私有与受保护的修饰符

默认为 public

Ts 中.成员默认为 public ,

private

当成员被标记为 private ,就不能在声明它的外部访问.

1
2
3
4
5
6
class Animal {
private name: string;
constructor(theName: string) {
this.name = theName;
}
new Animal("Cat").name //error, name是私有的.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Animal {
private name: string;
constructor(theName: string) {
this.name = theName;
}
}
class Rhio extends Animal {
constructor() {
super("Rhio");
}
}
class Employee {
private name: string;
cosntructor(theName: string) {
this.name = theName;
}
}
let animal = new Animal("Goat");
let rhio = new Rhio();
let employee = new Employee("Bob");

animal = rhio;
animal = employee; //error,Animal和Employee不兼容.

Rhio  继承了 Animal ,它们共享了私有定义 private name: string ,因此兼容.
Emploee  虽然也有私有定义 name ,但是和 Animal  定义的并不相同.

protected

protected 修饰符与  private 修饰符的行为很相似,但有一点不同, protected 成员在派生类中仍然可以访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Person {
protected name: string;
constructor(name: string) {
this.name = name;
}
}

class Employee extends Person {
private department: string;

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

public getElevatorPitch() {
return `Hello, my name is ${this.name} and I work in ${this.department}.`;
}
}

let howard = new Employee("Howard", "Sales");
console.log(howard.getElevatorPitch());
console.log(howard.name); // 错误

注意,我们不能在  Person 类外使用  name,但是我们仍然可以通过  Employee 类的实例方法访问,因为  Employee 是由  Person 派生而来的。

构造函数也可以被标记成  protected。  这意味着这个类不能在包含它的类外被实例化,但是能被继承

readonly 修饰符

你可以使用  readonly 关键字将属性设置为只读的。  只读属性必须在声明时或构造函数里被初始化。

参数属性

参数属性通过给构造函数参数前面添加一个访问限定符来声明。
在构造函数里使用 readonly name:string  参数来创建和初始化 name  成员.
对于 private  和 protected  也一样.

存储器

TS 支持通过 getters/setters  来截取对对象成员的访问.
将下面的例子改写为使用 get  和 set .

1
2
3
4
5
6
7
8
class Employee {
fullName: string;
}
let employee = new Employee();
employee.fullName = "Bob Smith";
if (employee.fullName) {
console.log(employee.fullName);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let passcode = "secret passcode";
class Employee {
private _fullName: string;

get fullName(): string {
return this._fullName();
}
set fullName(newName: string) {
if (passcode && passcode == "secret passcode") {
this._fullName = newName;
} else {
console.log("Error");
}
}
}

let empolyee = new Employee();
employee.fullName = "Bob Smith";
if (employee.fullName) {
alert(employee.fullName);
}

修改一下密码,来验证一下存取器是否是工作的。当密码不对时,会提示我们没有权限.

抽象类

抽象类一般不会被实例化.不同接口,抽象类可以包含成员的细节.
abstract  用于定义抽象类和在抽象类内部定义抽象方法.

1
2
3
4
5
6
7
8
9
10
11
12
abstract class Department {
constructor(public name: string) {
}
printName(): void {
console.log('department name: ' + this.name);
}
abstract printMeeting(): void; //必须在派生类中实现
}

class AccountingDepartment extends Department {

}

抽象类中的抽象方法不包含具体实现并且必须在派生类中实现。  抽象方法的语法与接口方法相似。  两者都是定义方法签名但不包含方法体。  然而,抽象方法必须包含  abstract 关键字并且可以包含访问修饰符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
abstract class Department {
constructor(public name: string) {}

printName(): void {
console.log("Department name: " + this.name);
}

abstract printMeeting(): void; // 必须在派生类中实现
}

class AccountingDepartment extends Department {
constructor() {
super("Accounting and Auditing"); // 在派生类的构造函数中必须调用 super()
}

printMeeting(): void {
console.log("The Accounting Department meets each Monday at 10am.");
}

generateReports(): void {
console.log("Generating accounting reports...");
}
}

let department: Department; // 允许创建一个对抽象类型的引用
department = new Department(); // 错误: 不能创建一个抽象类的实例
department = new AccountingDepartment(); // 允许对一个抽象子类进行实例化和赋值
department.printName();
department.printMeeting();
department.generateReports(); // 错误: 方法在声明的抽象类中不存在

把类当做接口用

类定义会创建两个东西:类的实例类型和一个构造函数。  因为类可以创建出类型,所以你能够在允许使用接口的地方使用类

1
2
3
4
5
6
7
8
9
10
class Point {
x: number;
y: number;
}

interface Point3d extends Point {
z: number;
}

let point3d: Point3d = { x: 1, y: 2, z: 3 };

单例模式

使用instance关键字.
constructor私有化.
在类的内部去 new 一个实例,在外部无法 new 该实例.

1
2
3
4
5
6
7
8
9
10
11
12
class DellAnalyzer implements IAnalyzer {
private static instance: DellAnalyzer
// 使用该函数提供对外的方法入口
static getInstance() {
if (!DellAnalyzer.instance) {
DellAnalyzer.instance = new DellAnalyzer()
}
return DellAnalyzer.instance
}
// 改成单例模式,私有化constructor,外部无法调用
private constructor() { }
...

装饰器

类的装饰器

装饰器本身是一个函数,通过@符号使用.
接收的参数是一个构造函数.
执行时机在类创建时立即执行.
多个装饰器,执行顺序是反着的,先写的后执行.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function testDecorator() {
// 使用泛型规范入参为构造函数
return function <T extends new (...args: any[]) => any>(constructor: T) {
// 扩展constructor
return class extends constructor {
name = "lee";
getName() {
return this.name;
}
};
};
}

const Test = testDecorator()(
class {
name: string;
constructor(name: stirng) {
this.name = name;
}
}
);
const test = new Test("dell");
console.log(test.getName);

类中方法的装饰器

普通方法,target 对应的是类的prototype
静态方法,target 对应的是类的构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function getNameDecorator(
target: any,
key: string,
descriptor: PropertypeDecorator
) {}
class Test {
name: string;
constructor(name: string) {
this.name = name;
}
@getNameDecorator
getName() {
return this.name;
}
}

const test = new Test("dell");
consoele.log(test.getName());

类中访问器的装饰器

getset不能同时使用装饰器.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function visitDecorator(
target: any,
key: string,
descriptor: PropertyDecorator
) {}
class Test {
private _name: string;
constructor(name: string) {
this._name = name;
}
get name() {
return this._name;
}
@visitDecorator
set name(name: string) {
this._name = name;
}
}
const test = new Test("dell");
test.name = "dell lee";
console.log(test.name);

类中属性的装饰器

和方法的装饰器的区别是没有第三个参数,但是可以自己添加

装饰器的修改是修改 prototype 上的,即修改并不是实例上的,而是原型上的.

1
2
3
4
5
6
7
8
9
10
11
12
13
function nameDecorator(target: any, key: string): any {
const descriptor: PropertyDecorator = {
writable: false,
};
return descriptor;
}
class Test {
@nameDecorator
name = "dell";
}
const test = new Test();
test.name = "dell lee";
console.log(test.name); //报错

类中参数的装饰器

1
2
3
4
5
6
7
8
9
10
function paramDecorator(target: any, method: string, paramIndex: number) {
console.log(target, method, paramIndex);
}
class Test {
getInfo(@paramDecorator name: string, age: number) {
console.log(name, age);
}
}
const test = new Test();
test.getInfo("Dell", 30);

示例

将报错捕获统一使用装饰器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 工厂模式返回一个装饰器
function catchError(msg: string) {
return function (target: any, key: string, discriptor: propertyDecorator) {
const fn = discriptor.value;
descriptor.value = function () {
try {
fn();
} catch (e) {
console.log(msg);
}
};
};
}
class Test {
@catchError("userInfo.name不存在")
getName() {
return userInfo.name;
}
@catchError("userInfo.age不存在")
getAge() {
return userInfo.age;
}
}
const test = new Test();
test.getName();
test.getAge();

函数

函数类型

函数类型包括两部分: 参数类型和返回值类型.

1
2
3
4
5
6
let myAdd: (x: number, y: number) => number = function (
x: number,
y: number
): number {
return x + y;
};

只要参数类型匹配,就认为他是有效的函数类型,不在乎参数名是否正确.

在函数和返回值类型之前使用( => )符号.

返回值类型是函数类型的必要成分,如果函数没有返回任何值.也必须指定返回值类型为 void .

推断类型

如果在赋值语句一边指定类型,另一边没有指定.TS 会自定识别处类型:

1
2
3
4
5
6
7
8
9
// myAdd has the full function type
let myAdd = function (x: number, y: number): number {
return x + y;
};

// The parameters `x` and `y` have the type number
let myAdd: (baseValue: number, increment: number) => number = function (x, y) {
return x + y;
};

这叫’按上下文归类’,是类型推论的一种.

可选参数和默认参数

TypeScript 里的每个函数参数都是必须的。  这不是指不能传递  null 或 undefined 作为参数,而是说编译器检查用户是否为每个参数都传入了值。  编译器还会假设只有这些参数会被传递进函数。  简短地说,传递给一个函数的参数个数必须与函数期望的参数个数一致。

1
2
3
4
5
function buildName(firstName: string, lastName: string;) {
return firstName + " " + lastName
}
let result1 = buildName("Bob"); //error, 少参数
let result2 = buildName("Bob",

剩余参数

若要同时操作多个参数,可以使用arguments来访问所有传入的参数.

1
2
3
4
function buildName(firstName: string, ...restOfName: string[]){
return firstName + ' ' + restOfName.join(" ")
}
let employeeName = buildName("Jose", "Sam" ,"Lucas", "Mack"

this

在箭头函数中指定this参数.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
interface Card {
suit: string;
card: number;
}
interface Deck {
suits: string[];
cards: number[];
createCardPicker(this: Deck): () => Card;
}
let deck: Deck = {
suits: ["hearts", "spades", "clubs", "diamonds"],
cards: Array(52),
// NOTE: The function now explicitly specifies that its callee must be of type Deck
createCardPicker: function (this: Deck) {
return () => {
let pickedCard = Math.floor(Math.random() * 52);
let pickedSuit = Math.floor(pickedCard / 13);

return { suit: this.suits[pickedSuit], card: pickedCard % 13 };
};
},
};

let cardPicker = deck.createCardPicker();
let pickedCard = cardPicker();

alert("card: " + pickedCard.card + " of " + pickedCard.suit);

重载

为同一个函数提供多个函数类型定义来进行函数重载.
编译器会根据这个列表去处理函数的调用.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
let suits = ["hearts", "spades", "clubs", "diamonds"];

function pickCard(x: { suit: string; card: number }[]): number;
function pickCard(x: number): { suit: string; card: number };
function pickCard(x): any {
// Check to see if we're working with an object/array
// if so, they gave us the deck and we'll pick the card
if (typeof x == "object") {
let pickedCard = Math.floor(Math.random() * x.length);
return pickedCard;
}
// Otherwise just let them pick the card
else if (typeof x == "number") {
let pickedSuit = Math.floor(x / 13);
return { suit: suits[pickedSuit], card: x % 13 };
}
}

let myDeck = [
{ suit: "diamonds", card: 2 },
{ suit: "spades", card: 10 },
{ suit: "hearts", card: 4 },
];
let pickedCard1 = myDeck[pickCard(myDeck)];
alert("card: " + pickedCard1.card + " of " + pickedCard1.suit);

let pickedCard2 = pickCard(15);
alert("card: " + pickedCard2.card + " of " + pickedCard2.suit);

为了让编译器能够选择正确的检查类型,它与 JavaScript 里的处理流程相似。 它查找重载列表,尝试使用第一个重载定义。 如果匹配的话就使用这个。 因此,在定义重载的时候,一定要把最精确的定义放在最前面。

注意,function pickCard(x): any 并不是重载列表的一部分,因此这里只有两个重载:一个是接收对象另一个接收数字。 以其它参数调用 pickCard 会产生错误。

泛型

使用<>来指定泛型的类型,保证传入的参数和输出的参数是一致的.
更普遍的泛型是不写,让编译器自己通过类型推断确定类型,如果编译器无法推断,就需要写泛型了.
泛型是声明的时候随便定义,使用的时候进行真正定义.

1
2
3
4
5
6
7
8
function join<T, P>(first: T, second: P) {
return `${first}${second}`;
}
// 声明的时候并不知道具体参数是什么类型
// 但是使用时, 通过在这里声明就限定了参数的类型
join<number, string>(1, "1");
// 当然也可以不写,这样会自己推断出
join(1, "1");

使用泛型变量

通过指定 arg 的参数类型是 T 的数组,返回的元素类型也是 T 的数组,存在长度,不会报错.

1
2
3
4
function loggingIdentity<T>(arg: T[]): T[] {
console.log(arg.length); // Array has a .length, so no more error
return arg;
}

泛型类型

泛型类

类似于泛型接口,泛型类使用<>括起泛型类型,跟在类名后面.

1
2
3
4
5
6
7
8
9
class GenericNumber<T> {
zeroValue: T;
add: (x:T, y:T) => T
}
let myGenericNumber = new GenericNumber<number>()
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x,y){
return x + y
}
1
2
3
4
5
6
7
8
9
class DataManager<T> {
constructor(private data: T[]) {}
getItem(index: number): T {
return this.data[index];
}
}
// 调用时指定泛型是number
const data = new DataManger<number>(["1"]);
data.getItem(0);

此时 GenericNumber 类只能使用 number 类型.
类有两部分:静态部分和实例部分.泛型类指的是实例部分的类型,所以类的静态属性不能使用这个泛型类型.

泛型约束

通过extends关键字约束条件.
定义一个接口来描述约束条件,创建一个包含.length属性的接口.

1
2
3
4
5
6
7
interface Lengthwise {
length: number
}
function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(arg.length)
retrun arg
}

枚举

作用:使用枚举定义一些带名字的常量.

数字枚举

1
2
3
4
5
6
7
8
enum Direction {
Up = 1,
Down,
Left,
Right,
}
//Up初始化为1,其余成员从1开始增长
//如果不设置初始值,那么up为0,总之,各个值都是不同的

用法: 通过枚举的属性来访问枚举的成员,和枚举的名字来访问枚举类型:

1
2
3
4
5
6
7
8
enum Response {
No = 0,
Yes = 1,
}
function respond(recipient: string, message: Response): void {
//...
}
respond('Princess Caroline', Response.Yes)

不带初始化器的枚举或者被放在第一的位置,或者被放在使用了数字常量或其他常量初始化了的枚举后面.

字符串枚举

在一个字符串枚举中,每个成员都必须用字符串字面量,或另外一个字符串枚举成员进行初始化.

异构枚举

枚举可以混合字符串和数字成员.但不建议这样做.

计算的和常量成员

每个枚举成员都带有一个值,他可以是常量或计算出来的.当满足如下条件时,枚举成员被当做是常量:

  1. 它是枚举的第一个成员且没有初始化器,这种情况下它被赋予值 0:
1
enum E { x }
  1. 它不带有初始化器且它之前的枚举成员是一个数字常量.这种情况下,当前枚举成员的值为它上一个枚举成员的值加 1.
1
2
3
enum E {
A =1, B, C
}
  1. 枚举成员使用常量枚举表达式初始化.常数枚举表达式是 TS 表达式的子集.它可以在编译阶段求值.当一个表达式满足下面条件之一时,它就是一个常量枚举表达式:
  • 一个枚举表达式字面量
  • 一个对之前定义的常量枚举成员的引用
  • 带括号的常量枚举表达式
  • 一元运算符+,-,~其中之一应用在常量枚举表达式
  • 常量枚举表达式作为二次运算符+,-,*,/,%,<<,>>,>>>,&,|,^的操作对象,若常熟枚举表达式求值后为NaNInfinity,则会在编译阶段报错.

所有其他情况的枚举成员被当做是需要计算得出的值.

1
2
3
4
5
6
7
enum FileAccess {
None,
Read = 1 << 1,
Write = 1 << 2,
ReadWrite = Read | Write,
G = '123'.length
}

联合枚举与枚举成员的类型

字面量枚举成员是指不带有初始值的常量枚举成员,或者值被初始化为

  • 任何字符串字面量
  • 任何数字字面量
  • 应用了一元-符号的数字字面量

当所有枚举成员都拥有字面量枚举值时,他就带有了一种特殊的语义.
首先,枚举成员成为了类型.
枚举类型本身变成了每个枚举成员的联合.

运行时枚举

枚举是在运行时真正存在的对象.

1
2
3
enum E {
X,Y,Z
}

反向映射

1
2
3
4
5
enum Enum {
A
}
let a = Enum.A
let nameOfA = Enum[a] //"A"

const 枚举

为了避免在额外生成的代码上的开销和额外的非直接的对枚举成员的访问.
可以使用 const 枚举,常量枚举通过在枚举上使用const 修饰符来定义.

1
2
3
4
const enum Enum {
A = 1,
B = A * 2
}

常量枚举只能使用常量枚举表达式,并且不同于常规枚举,它们在编译阶段会被删除,常量枚举成员在使用的地方会被内联进来.原因是常量枚举不允许包含计算成员.

1
2
3
4
5
6
7
8
const enum Directions {
Up,
Down,
Left,
Right
}

let directions = [Directions.Up, Directions.Down, Directions.Left, Directions.Right]

生产的代码为

1
var directions = [0, 1, 2, 3];

外部枚举

描述已经存在的枚举类型的形状.

1
2
3
4
5
declare enum Enum {
A = 1,
B,
C =2
}

类型推论

上下文类型

TS 的类型推论可能按照相反的方向进行.叫做按上下文归类.

1
2
3
window.onmousedown = function (mouseEvent) {
console.log(mouseEvnet.button); //error
};

这个例子会得到一个类型错误.TS 类型检查器使用Window.onmousedown函数的类型来推断右边函数表达式的类型.如果函数表达式不是在上下文类型的位置,mouseEvent参数的类型需要指定为any,这样也不会报错.

类型兼容性

开始

结构化类型系统的基本规则是,如果x想兼容y,那么y至少具有与x相同的属性.

1
2
3
4
5
6
7
interface Named {
name: string;
}
let x: Named;
let y = { name: "Alice", location: "Seattle" };
//类型推断y的类型是{name: string, location: string}
x = y;

这里检查 y 是否可以赋值给 x,编译器检查 x 中的每个属性,看是否能在 y 中也找到对应属性.注意,y 有个额外参数,但不会引起错误.只有目标类型(这里是 Named)的成员会被一一检查是否兼容.

比较函数

1
2
3
4
let x = (a: number) => 0;
let y = (a: number, s: string) => 0;
y = x; //ok
x = y; //Error

看 x 是否可以赋值给 y 首先看参数列表.
x 的每个参数必须在 y 里找到对应类型的参数.注意的是参数的名字相同与否无所谓,只看类型.
x 的每个参数都可以在 y 中找到对应的参数,所以运行赋值.
第二个赋值错误,因为 y 有个必须的第二个参数,但是 x 没有,所以不允许赋值.

枚举

枚举类型与数字类型兼容,并且数字类型与枚举类型兼容.不同枚举类型之间是不兼容.

1
2
3
4
5
enum Status { Ready, Waiting}
enum Color { Red, Blue,Green }

let status = Status.Ready
status = Color.Green //Error

类与对象字面量和接口差不多,但有一点不同: 类有静态部分和实例部分的类型.
比较两个类类型的对象时,只有实例的成员会被比较.静态成员和构造函数不在比较的范围内.

泛型

因为 TS 是结构性的类型系统,类型参数只影响使用其作为类型一部分的结果类型.

1
2
3
4
interface Empty<T> {
let x: Empty<number>;
let y: Empty<string>;
x = y; //OK

其中,x 和 y 是兼容的,因为他们的结构使用类型参数时并没有什么不同.

高级类型

交叉类型

即多个类型合并成一个类型.
适合场景: 混入或者其他不适合典型面向对象模型的地方.
下面的例子没看懂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function extend<T, U>(first: T, second: U): T & U {
let result = <T & U>{};
for (let id in first) {
(<any>result)[id] = (<any>first)[id];
}
for (let id in second) {
if (!result.hasOwnProperty(id)) {
(<any>result)[id] = (<any>second)[id];
}
}
return result;
}

class Person {
constructor(public name: string) { }
}
interface Loggable {
log(): void;
}
class ConsoleLogger implements Loggable {
log() {
// ...
}
}
var jim = extend(new Person("Jim"), new ConsoleLogger());
var n = jim.name;
jim.log();

联合类型

表示一个值可以是几种类型之一.我们用竖线(|)分隔每个类型.
可以访问联合类型的所有类型里的共有成员.

类型保护与区分类型

1
2
3
4
5
6
7
8
//下面代码会报错
let pet = getSmallPet();
//每一个成员访问都会报错
if (pet.swim) {
pet.swim();
} else if (pet.fly) {
pet.fly();
}

可以使用类型断言解决:

1
2
3
4
5
6
let pet = getSmallPet()
if((<Fish>pet).swim){
(<Fish>pet).swim()
}else{
(<Bird>pet).fly()
}

声明文件

全局库声明文件

通过declare关键字进行声明文件可以在全局访问,而一般将该文件定义在types/*.d.ts中方便管理.
interfacetype不需要declare也可以在全局访问.
一般的declare声明不能重复声明,即使重复也只显示第一个.而interface重复声明会发生合并.
declare使用export替换效果是一样的.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// jQuery.d.ts
// 定义全局变量
declare var $: (param: () => void) => void;
// 定义全局函数
interface JQueryInstance {
html: (html: string) => JqueryInstance;
}

// 函数重载(也就是重复声明)
declare function $(readyFunc: () => void): void;
declare function $(selector: string): JqueryInstance;
// 使用interface定义
interface JQuery {
(readyFunc: () => void): void;
(selector: string): JqueryInstance;
}
declare var $: JQuery;

namespace

1
2
3
4
5
6
7
8
declare let n: number = 123; //可以全局访问
declare namespace myLib {
function making(s: string): string;
let numbeOfMaking: number;
}
// 可以将这些变量放在namespace的作用域内,
// 通过myLib.making()进行访问.
// 内部不用再写declare,另外内部也可以嵌套namespace

模块化库声明文件

模块化声明时文件放置到types/*,声明该模块同名的文件夹.
注意修改tsconfig.json文件中的配置

1
2
3
4
5
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
},
"esModuleInterop": true,

泛型帮助类型

类型保护

通过类型推论进行类型保护,即类型收窄.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
interface Pet {
name: string;
}

interface Fish extends Pet {
swim(): void;
}

interface Bird extends Pet {
fly(): void;
}
function isFish(pet: Pet): pet is Fish {
return (pet as Fish).swim !== undefined;
}

pets.forEach((pet) => {
if (isFish(pet)) {
pet.swim();
} else {
pet.fly();
}
});

in 操作符

同样用于类型收窄(类型保护)

1
2
3
4
5
6
7
pets.forEach((pet) => {
if ("swim" in pet) {
pet.swim();
} else {
pet.fly();
}
});

typeof 操作符

typeof variable === '类型名称'表达式明确告知 TypeScript 变量 variable 的类型,
起到类型保护的作用.

instanceof 操作符

variable instanceof Type告知 TypeScript 变量 variable 的类型为 Type,起到类型保护的作用:
使用instanceof注意只能使用class,不能使用interface.否则无法调用instanceof.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Fish {
swim() {
console.log("fish is swiming");
}
}

class Bird {
fly() {
console.log("bird is flying");
}
}

function move(pet: Fish | Bird) {
if (pet instanceof Fish) {
pet.swim();
} else {
pet.fly();
}
}

关键字

extends

T extends U ? X : Y;
若 T 能赋值给 U,则类型是 X,否则是 Y.

1
2
3
4
type Words = "a" | "b" | "c";
type W<T> = T extends Words ? true : false;
type WA = W<"a">; // => true
type WD = W<"d">; // => false

infer

表示在 extends 条件语句中待推断的类型变量.i

1
type Union<T> = T extends Array<infer U> ? U : never;
  • 如果泛型参数 T 满足约束条件 Array,那就返回这个类型变量 U.
1
2
3
4
5
6
7
8
type ParamType<T> = T extends (param: infer P) => any ? P : T;
interface IDog {
name: string;
age: number;
}
type Func = (dog: IDog) => void;
type Param = ParamType<Func>; //IDog
type TypeSring = ParamType<stirng>; //string

keyof(索引类型)

用来取得一个对象接口的所有 key 值.
keyof 可以获取某种类型的所有键,返回类型是联合类型。

1
2
3
4
5
6
7
8
interface IPerson {
name: string;
age: number;
sex?: string;
}
type K1 = keyof IPerson; //'name' | 'age' | 'sex'
type K2 = keyof IPerson[]; //'length'|'push'|'pop'...
type K3 = typeof { [x:string]: Person }; //string | number`

typeof

在 JS 中,typeof 判断数据类型.
在 TS 中,获取一个变量的声明类型,如果不存在,获取该变量的推论类型.
typeof 可以获取一个变量或者对象(包括函数)的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface IPerson {
name: string;
age: number;
sex?: string;
}

const jack: IPerson = { name: "jack", age: 100 };
type Jack = typeof jack; // -> IPerson

function foo(x: number): Array<number> {
return [x];
}

type F = typeof foo; // -> (x: number) => number[]
//Jack 这个类型别名实际上就是 jack 的类型 IPerson,
//而 F 的类型就是 TS 自己推导出来的 foo 的类型 (x: number) => number[]。

内置帮助类型

Partial(映射类型)

让 T 中所有属性都是可选的.

1
2
3
type Partial<T> = {
[P in keyof T]?: T[P];
};

在某些情况下,我们希望类型中的所有属性都不是必需的,只有在某些条件下才存在,我们就可以使用 Partial 来将已声明的类型中的所有属性标识为可选的。

1
2
3
4
5
6
7
8
9
10
11
12
13
interface Dog {
age: number;
name: string;
pirce: number;
}

type PartialDog = Partial<Dog>;
//等价于
type PartialDog = {
age?: number;
name?: string;
pirce?: number;
};

Required

Partial正好相反,所有属性都是必选的.

Readonly(映射类型)

所有属性设为只读

Record<K,T>

1
2
3
4
5
6
7
8
/**
* Construct a type with a set of properties K of type T
* 构造一个具有一组属性K(类型T)的类型
*/

type Record<K extends keyof any, T> = {
[P in K]: T;
};

构造一个具有一组属性 K(类型 T)的类型.
K 对应 key,T 对应对象的 value.返回是一个声明好的对象.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type petsGroup = "dog" | "cat";
interface IPetInfo {
name: string;
age: number;
}

type IPets = Record<petsGroup, IPetInfo>;

const animalsInfo: IPets = {
dog: {
name: "wangcai",
age: 2,
},
cat: {
name: "xiaobai",
age: 3,
},
};

Pick<T,K>

1
2
3
4
5
6
7
/**
* From T, pick a set of properties whose keys are in the union K
* 从T中,选择一组键在并集K中的属性
*/
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};

从源码可以看到 K 必须是 T 的 key,然后用 in 进行遍历, 将值赋给 P, 最后 T[P] 取得相应属性的值.
相当于从 T 里挑选 K(可能是多个)出来作为属性.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
interface IDog {
name: string;
age: number;
height: number;
weight: number;
}

type PickDog = Pick<IDog, "name" | "age" | "height">;
// 等价于
type PickDog = {
name: string;
age: number;
height: number;
};

let dog: PickDog = {
name: "wangcai",
age: 3,
height: 70,
};

Exclude<T,U>

排除 T 中的 U.

1
2
3
4
5
/**
* Exclude from T those types that are assignable to U
* 从T中排除那些可分配给U的类型
*/
type Exclude<T, U> = T extends U ? never : T;

与 Pick 相反,Pick 用于拣选出我们需要关心的属性,而 Exclude 用于排除掉我们不需要关心的属性.

1
2
3
4
5
6
7
8
9
10
11
12
13
interface IDog {
name: string;
age: number;
height: number;
weight: number;
sex: string;
}

type keys = keyof IDog; // -> "name" | "age" | "height" | "weight" | "sex"

type ExcludeDog = Exclude<keys, "name" | "age">;
// 等价于
type ExcludeDog = "height" | "weight" | "sex";

Extract<T,U>

相当于取交集

1
2
3
4
5
/**
* Extract from T those types that are assignable to U
* 从T中提取可分配给U的类型
*/
type Extract<T, U> = T extends U ? T : never;

Omit<T,K>

类似于Exclude,但是更方便.
用于保留一些属性,再排除一些属性.

1
2
3
4
5
/**
* Construct a type with the properties of T except for those in type K.
* 构造一个除类型K之外的T属性的类型
*/
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

NonNullable

1
2
3
4
5
/**
* Exclude null and undefined from T
* 从T中排除null和undefined
*/
type NonNullable<T> = T extends null | undefined ? never : T;

Parameters

返回类型为 T 的函数的参数类型所组成的数组.
也就是将参数取出来放进数组里.

1
2
3
4
5
6
7
8
9
/**
* 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;
1
2
type T0 = Parameters<() => string>; // []
type T1 = Parameters<(s: string) => void>; // [string]

ConstructorParamters

1
2
3
4
5
6
/**
* Obtain the parameters of a constructor function type in a tuple
* 在元组中获取构造函数类型的参数
*/
type ConstructorParameters<T extends new (...args: any) => any> =
T extends new (...args: infer P) => any ? P : never;

ReturnType

1
2
3
4
5
6
7
8
9
/**
* Obtain the return type of a function type
* 获取函数类型的返回类型
*/
type ReturnType<T extends (...args: any) => any> = T extends (
...args: any
) => infer R
? R
: any;

InstanceType

1
2
3
4
5
6
7
8
9
10
/**
* Obtain the return type of a constructor function type
* 获取构造函数类型的返回类型
*/

type InstanceType<T extends new (...args: any) => any> = T extends new (
...args: any
) => infer R
? R
: any;

ThisType

1
2
3
4
5
/**
* Marker for contextual 'this' type
* 上下文“this”类型的标记
*/
interface ThisType<T> {}
1
2
3
4
5
6
7
8
9
interface Cat {
name: string;
age: number;
}
const obj: ThisType<Person> = {
mimi() {
this.name; // string
},
};

这样的话,就可以指定 obj 里的所有方法里的上下文对象改成 Person 这个类型了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 没有ThisType情况下
const dog = {
wang() {
console.log(this.age); // error,在dog中只有wang一个函数,不存在a
},
};
// 使用ThisType
const dog: { wang: any } & ThisType<{ age: number }> = {
wang() {
console.log(this.wang); // error,因为没有在ThisType中定义
console.log(this.age); // ok
},
};
dog.wang; // ok 正常调用
dog.age; // error,在外面的话,就跟ThisType没有关系了,这里就是没有定义age了

ThisType 的作用是:提示其下所定义的函数,在函数 body 中,其调用者的类型是什么。

命名空间

在命名空间中,将想要暴露出去的代码使用export暴露出去,即可在外部调用.

补充

三斜线指令

1
///<reference path="...' />

表示引入文件,只能存在于最前端,在后面会被认为是注释.

书写建议

  1. 避免 enum 枚举
  2. 避免命名空间 namespace
  3. 避免装饰器,尽量等到这个语法标准化完成。如果你需要一个库用装饰器,要考虑它的标准化状态。
  4. 尽量用 #somePrivateField而不是private somePrivateField.

node 基础

require 模块结束时,需要用;结尾.否则报错.

常用模块

请求模块

request

npm 地址
虽然 request 模块是几乎学习 node 接触的第一个模块,但是目前已经被弃用了.所以推荐使用 superagent.

superagent

npm 地址
简单的调用,简单的写法,配套的插件.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const superagent = require("superagent");

// callback
superagent
.post("/api/pet")
.send({ name: "Manny", species: "cat" }) // sends a JSON post body
.set("X-API-Key", "foobar")
.set("accept", "json")
.end((err, res) => {
// Calling the end function will send the request
});

// promise with then/catch
superagent.post("/api/pet").then(console.log).catch(console.error);

// promise with async/await
(async () => {
try {
const res = await superagent.post("/api/pet");
console.log(res);
} catch (err) {
console.error(err);
}
})();

解析模块

cheerio

cheerio 是 node 中的 jQuery,可方便的控制 dom 节点.获取相应参数.
语法使用 jquery 语法.

1
2
const $ = cheerio.load(body);
$("div");

Puppeteer

Puppeteer 俗称无头浏览器,实际是一个没有显示界面的 Chrome 浏览器.故能在浏览器上操作的也可以通过 Puppeteer 操作.
缺点是比常规爬虫慢.

cheerio 和 Puppeteer 的区别

cherrico 本质上只是一个使用类似 jquery 的语法操作 HTML 文档的库,使用 cherrico 爬取数据,只是请求到静态的 HTML 文档,如果网页内部的数据是通过 ajax 动态获取的,那么便爬取不到的相应的数据。
而 Puppeteer 能够模拟一个浏览器的运行环境,能够请求网站信息,并运行网站内部的逻辑。然后再通过 WS 协议动态的获取页面内部的数据,并能够进行任何模拟的操作(点击、滑动、hover 等),并且支持跳转页面,多页面管理。
甚至能注入 node 上的脚本到浏览器内部环境运行,总之,你能对一个网页做的操作它都能做,你不能做的它也能做。

其他模块

iconv-lite

转码模块,如果中文乱码.可使用该模块进行转码,中文显示正常后开始解析源码,获取需要的 URL.

superagent-charset

如果不想那么麻烦,直接使用,省的再安装模块

1
2
3
4
5
6
7
const request = require("superagent");
require("superagent-charset")(request);

request
.get("http://www.xxx.com/")
.charset("gbk")
.end((err, res) => {});

Tesseract

OCR 识别模块,可用于识别验证码图片.

gm

gm 是 Node.js 对 GraphicsMagick 和 ImageMagick 封装。GraphicsMagick 和 ImageMagick 是老牌的图片处理工具.用于对验证码图片的噪点处理.

配合

Puppeteer 用于抓取网页中的图片,Tesseract 做图像识别,gm 实现图片去噪点,三个工具各司其职,分工明确。

node 爬虫框架

ppspider

github 地址

操作思想

爬虫主要在于两个字,’爬’和’取’.
‘爬’在于发起请求,获取数据.
‘取’在于解析数据.

错误处理

node 多会进行 IO 操作,所以需要经常使用错误处理.
常用的比如:

1
2
3
4
5
6
7
superagent.get(url).end(function (err, res) {
// 抛错拦截
if (err) {
return throw Error(err);
}
// 等待 code
});

try/catch 操作注意异步处理.

《深入浅出 Nodejs》书中描述” 尝试对异步方法进行 try/catch 操作只能捕获当次事件循环内的异常,对 callback 执行时抛出的异常将无能为力 “。
可参考此文

反爬虫

  1. 通过 UA 机制识别爬虫。

UA 的全称是 UserAgent,它是请求浏览器的身份标志,许多网站使用它来作为鉴别爬虫的标志,假如访问请求的头部中没有带 UA 那么就会被判定为爬虫,但由于这种要针对这种反爬虫机制非常容易,即随机 UA,因此这种反爬机制使用的很少。

  1. 通过访问频率鉴别爬虫。

爬虫为了更好地保证效率,通常会在很短的时间内多次访问目标网站,因此能够通过单个 IP 访问的频率来判断是否为爬虫。并且,这种反爬方式比较难以被反反爬机制反制,只能通过更换代理 IP 来保证效率。

  1. 通过 Cookie 和验证码识别爬虫。

Cookie 是指会员制的账号密码登陆验证,这就可以通过限制单账号抓取频率来限制爬虫抓取,而验证码完全是随机的,爬虫脚本无法正确鉴别,同样能够限制爬虫程序。

反反爬虫

爬虫过于频繁就会触发封 ip,弹验证码等反爬虫的行为.那么就要有一些反反爬虫的策略.
常用操作有使用代理,降低操作频率.
添加多个 user-agent 用于随机调换.

动态 userAgent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//userAgent
const userAgents = [
"Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.0.12) Gecko/20070731 Ubuntu/dapper-security Firefox/1.5.0.12",
"Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0; Acoo Browser; SLCC1; .NET CLR 2.0.50727; Media Center PC 5.0; .NET CLR 3.0.04506)",
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.56 Safari/535.11",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_3) AppleWebKit/535.20 (KHTML, like Gecko) Chrome/19.0.1036.7 Safari/535.20",
"Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.0.8) Gecko Fedora/1.9.0.8-1.fc10 Kazehakase/0.5.6",
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.71 Safari/537.1 LBBROWSER",
"Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0; .NET CLR 3.5.30729; .NET CLR 3.0.30729; .NET CLR 2.0.50727; Media Center PC 6.0) ,Lynx/2.8.5rel.1 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/1.2.9",
"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322; .NET CLR 2.0.50727)",
"Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; QQBrowser/7.0.3698.400)",
"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; QQDownload 732; .NET4.0C; .NET4.0E)",
"Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:2.0b13pre) Gecko/20110307 Firefox/4.0b13pre",
"Opera/9.80 (Macintosh; Intel Mac OS X 10.6.8; U; fr) Presto/2.9.168 Version/11.52",
"Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.0.12) Gecko/20070731 Ubuntu/dapper-security Firefox/1.5.0.12",
"Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; LBBROWSER)",
"Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.0.8) Gecko Fedora/1.9.0.8-1.fc10 Kazehakase/0.5.6",
"Mozilla/5.0 (X11; U; Linux; en-US) AppleWebKit/527+ (KHTML, like Gecko, Safari/419.3) Arora/0.6",
"Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; QQBrowser/7.0.3698.400)",
"Opera/9.25 (Windows NT 5.1; U; en), Lynx/2.8.5rel.1 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/1.2.9",
"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36",
];

module.exports = userAgents;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//app.js
import request from "superagent";
import userAgents from "../src/userAgent";

async function doRequest() {
let userAgent = userAgents[parseInt(Math.random() * userAgents.length)];
request
.get("http://www.xxx.com")
.set({ "User-Agent": userAgent }) //随机调用UA
.timeout({ response: 5000, deadline: 60000 })
.end(async (err, res) => {
// 处理数据
});
}

superagent-cache-plugin

有的需要 cookie 才可正常访问的接口.使用此插件.

1
npm install superagent-cache-plugin --save
1
2
3
4
5
6
7
8
9
10
11
12
13
var cacheModule = require("cache-service-cache-module");
var cache = new cacheModule({ storage: "session" });

// Require superagent-cache-plugin and pass your cache module
var superagentCache = require("superagent-cache-plugin")(cache);

superagent
.get(uri)
.use(superagentCache)
.end(function (err, response) {
// response is now cached!
// subsequent calls to this superagent request will now fetch the cached response
});

节流模块 superagent-throttle

设置限时,节流.
github 地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const request     = require('superagent')
const Throttle = require('superagent-throttle')

let throttle = new Throttle({
active: true, // 插件开关
rate: 5, // how many requests can be sent every `ratePer`
ratePer: 10000, // number of ms in which `rate` requests may be sent
concurrent: 2 // 并发数
})

request
.get('http://placekitten.com/100/100')
.use(throttle.plugin())
.end((err, res) => { ... })

限流模块 async

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function fetchContents(urls) {
return new Promise((resolve, reject) => {
var results = [];
async.eachLimit(
urls,
3,
(url, callback) => {
spider(
{ url: url, decoding: "gb2312" },
{
url: {
selector: "#Zoom table td a!text",
},
title: {
selector: ".title_all h1!text",
},
}
).then(
(d) => {
results.push(d);
callback();
},
() => {
callback();
}
);
},
() => {
resolve(results);
}
);
});
}

动态 ip

构建个人代理池

1
npm install ip-proxy-pool

转载文章

避免重复抓取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
var fs = require("fs-extra");
var path = require("path");
var uniqueArray = [];
const UNIQUE_ARRAY_URL = "./_fetchedList.json";
try {
uniqueArray = require(UNIQUE_ARRAY_URL);
} catch (e) {}

function dealListData(data) {
return new Promise((resolve, reject) => {
var urls = request.get(data, "items");
if (urls) {
urls = urls
.map((url) => {
return "http://www.dytt8.net" + url;
})
.filter((url) => {
return uniqueArray.indexOf(url) === -1;
});
// 如果为空就reject
urls.length ? resolve(urls) : reject("empty urls");
} else {
reject(urls);
}
});
}

function addUniqueArray(url) {
uniqueArray.push(url);
if (uniqueArray.length > 300) {
// 超长就删掉多余的
uniqueArray.shift();
}
}

fetchList()
.then(dealListData)
.then(fetchContents)
.then((d) => {
console.log(d, d.length);
// json落地
fs.writeJson(path.join(__dirname, UNIQUE_ARRAY_URL), uniqueArray);
})
.catch((e) => {
console.log(e);
});

文件写入

node 中文件读取写入有两种方法.fs.readFile/writeFilefs.createReadStream/writeStream.

二者区别

fs.writeFile把文件内容全部读入内存,然后再写入文件,对于小型的文本文件,这没有多大问题,比如 grunt-file-copy 就是这样实现的。但是对于体积较大的二进制文件,比如音频、视频文件,动辄几个 GB 大小,如果使用这种方法,很容易使内存“爆仓”。理想的方法应该是读一部分,写一部分,不管文件有多大,只要时间允许,总会处理完成,这里就需要用到流的概念

1
2
3
fs.createReadStream("/path/to/source").pipe(
fs.createWriteStream("/path/to/dest")
);

以上都是我瞎编的

访问网页时发生了什么

当用户在浏览器地址栏输入地址,敲下回车键,直到看到网页界面,一般时间不过两三秒左右。然而在这瞬时间,计算机实际上已经完成了非常复杂的操作。这段过程中发生的事情,其实有很大一部分就与 HTTP TCP/IP 有关,我们可以简要的概括一下大概的流程。

第一步,找服务器 IP

当用户输入一个网址并按下回车键的时候,浏览器得到了一个域名。而在实际通信过程中,浏览器需要的是一个 IP 地址。为了获得 IP 地址,浏览器会做如下操作,一般我们把浏览器通过域名查找对应 IP 的行为叫做 DNS 解析。

1.先找浏览器的本地的缓存

2.再找电脑硬盘里的 host 文件,有没有记录这个域名和 IP 的映射关系

3.实在没找到,只好通过网络链路去域名供应商那里查询

第二步,建立 TCP/IP 连接

1.浏览器获取到了服务器对应 IP,就会向对应 IP 的服务器发送 TCP 连接请求。

2.服务器收到请求后回应,双方多次确认后建立起 TCP 双向连接。

从客户端发起连接请求一直到 TCP 连接建立,这个过程,叫做 三次握手。

如果请求是 HTTPS 的,还需要在 TCP 连接上,再通过 SSL 或 TLS 提供加密处理数据、验证对方身份以及数据完整性,来保证数据传输的安全。

第三步,请求资源

1.TCP 连接创建完成,浏览器开始向服务端发送正式的 HTTP 请求的数据包。

2.服务端接受请求,对请求进行解析,经过数据操作后,返回客户端需要的数据包。

第四步,浏览器渲染

浏览器获取到需要的数据以后,对数据进行拼接、解析、执行,最终将完整的网页绘制在页面上。

第五步,浏览器缓存

浏览器拿到服务端返回的数据后,会根据一定的策略进行数据的缓存,这样在下一次请求同样数据的时候,就可直接到缓存拿取,不再请求服务器。

上述流程可以看作是一个应用在完整网络通信过程中的实践场景,其中带出了很多网络通信的知识点,下面就以这条线为索引,对其中涉及到的知识碎片进行阐述和说明。

经典网络五层模型

在每台计算机设备上,都有这么一套系统链路的关系,来保证网络传输的正常进行,因为统一集成了这么一套经典模型,所以自己使用的计算机也是可以作为一台服务器来提供网络服务的。

应用层:

应用层包含了我们所说的 HTTP 协议,为各个应用软件提供了很多服务,常见的应用层服务有:HTTP 服务 、FTP 服务 、Email 服务等。应用层屏蔽了底层模型的相关细节,作为应用支持,只提供给使用者一些必要的使用方式。

传输层

常见的传输层协议有 TCP 和 UDP ,传输层作为为应用层的基础,定义了“端到端(end to end)”之间数据间的传输方式,比如:两台设备如何建立连接?设备之间需要以何种规范进行数据传输?需要以什么方式进行数据的分片、重组、拼接?这些都是传输层为我们定义好的。

网络层

通常我们常说的 IP 协议就位于这一层。网络层为数据在结点之间传输创建逻辑链路,当我们在浏览器敲下域名,浏览器在网络里如何通过这个域名,找到对应的 IP 映射,这个查询的逻辑关系和链路,是网络层规范和定义的。

数据链路层

数据链路层在通信实体间建立数据链路连接,物理设备连接完成以后,需要相应的软件和驱动来连接和打通这些物理设备,创建电路的连接。

物理层

定义物理设备如何传输数据,常见的物理层有网线,光缆,网卡,声卡等,物理层是一切软件的基础。

URI、URL 和 URN

对于 URL 我们基本比较熟悉,然而对 URI 和 URN 的了解可能比较少,URI、URL 和 URN 是识别、定位和命名互联网上的资源的标准途径。

当我们在浏览器地址栏里输入域名的那一刻,其实已经和这三个概念牵扯上了联系。

URI

Uniform Resource Identifier,统一资源标识符,简称为 URI。

每个 Web 服务器都有一个 URI 标识符,它在世界范围内唯一标识并定位信息资源,一个资源信息有了 URI 标识以后,在互联网上就能通过一个固定的地址访问到这个资源。

它具有两种形式,URN (统一资源名)、URL(统一资源定位符),也就是说 URL 和 URN 是它的子集。

URL

Uniform Resource Locator,统一资源定位符,简称 URL,下图是一个完整的 URL 组成。

一个完整的 URL 从左到右包含如下部分:

  1. schema 标识了这个资源地址所基于的访问协议,常见的比如:HTTP 和 FTP。
  2. user information 标识了用户信息(如果这个资源需要用户信息认证的话),不过一般现在的认证都不采用这种方式,一来输入非常麻烦,二来不安全。
  3. host 标识了资源的域信息,可以是域名,也可以是 IP ,这块的作用主要是找到资源所存放的物理服务器地址。
  4. port 端口号,一个物理服务器,通过开启不同的端口,就同时可以运行多个 web 服务器,资源文件会部署在某一个 web 服务器的某一个地方,而端口号就是用来定位资源存在的 web 服务器的。
  5. path 路径,或者叫路由,一个 web 服务器下有许多目录,一般 path 就是用来定位到资源文件所存放的目录的。由于现在很多的 web 应用非常庞大,这个路径也不一定就是目录地址,也可能是 web 服务器指定的静态资源文件的请求地址。
  6. query 查询字符串,一般用于 GET 查询,传递查询参数。
  7. fragment 片段,哈希,或者叫锚点,主要用于前端文档的定位,或者是前端渲染时控制路由跳转的手段。

这里需要注意将 URL 与网址区别开来。

URL 不仅仅包含了网页的资源地址,还包含了组成网页所需的图片、视频等超文本资源,以及 css js 等资源地址。

网址本质上是 IP 地址的一个更有辨别度的映射,在通过 DNS 解析之后,浏览器最先拿到的是 html 文档的 URL 地址,根据浏览器对 Html 文档的解析,继续通过网页内其他资源文件的 URL 获取对应的资源文件。

URN

Uniform Resource Name,统一资源名称,简称 URN,它的用处简单说就是永久定位资源,因为同一个资源可能会更换存储位置,存储位置一旦更换,再访问原来的 url 肯定是拿不到的,URN 就是解决这个问题的,不管资源位置怎么移动,只要访问同一个 URN 都能定位到。

TCP/IP 协议族

TCP/IP 协议(传输控制协议/互联网协议)不是简单的一个协议,而是一组特别的协议,包括:TCP,IP,UDP,ARP 等,这些被称为子协议。在这些协议中,最重要、最著名的就是 TCP 和 IP。因此我们习惯将整个协议族称为 TCP/IP。

IP 协议

IP 协议使互联网成为一个允许连接不同类型的计算机和不同操作系统的网络。

IP 地址是 IP 协议提供的一种统一的地址格式,它为互联网上的每一个网络和每一台主机分配一个逻辑地址,相当于这台机器的暂用名,别的机器可以通过这个名字找到它,进而能互相建立起连接进行通信和交流。

TCP 协议

TCP 协议是面向连接的全双工协议,因此不管是客户端还是服务端都能在 TCP 连接通道下向对端接收和发送数据。

TCP 相比于 UDP 的优势在于它的传输稳定性,在数据传输之前必须经过三次握手建立连接;在数据传输过程中必须保证数据有序完整地传到对端。

TCP 相比于 UDP 的劣势在于它的复杂度,连接建立、断开都是比较大的性能开销,而且数据传输过程中一旦卡住,则必须等前面的数据发送完毕以后,后续数据才能继续传输。

每台服务器可提供支持的 TCP 连接数量是有限的,所以这也使得 TCP 连接变成了稀缺资源,经不起浪费。

UDP 协议

UDP 协议是面向无连接的,不需要在传输数据前先建立连接,想发就发想传就传。

UDP 做的工作只是报文搬运,不负责有序且不丢失地传递到对端,因此容易出现丢包的情况。

UDP 不仅支持一对一的传输方式,还支持一对多、多对多、多对一的方式,也就是说 UPD 提供了单播、多播、广播的功能。

UDP 相比于 TCP 的优势在于它的轻量、高效和灵活,在一些对于实时性应用要求较高的场景下需要使用到 UDP,比如直播、视频会议、LOL 等实时对战游戏。

UDP 相比于 TCP 的劣势在于它的不可靠性和不稳定性。

TCP 连接

在客户端发送正式的 HTTP 请求之前,需要先创建一个 TCP 连接,在创建的 TCP Connect   通道下,所有的 HTTP 请求和响应才能正常的发送和接受。

在不同的 HTTP 协议版本里,这个 TCP 连接通道的创建和持续机制也有所不同。

在 HTTP1.0 中,每一次 HTTP 请求都会创建一个 TCP 连接,在请求发送完成,服务器响应以后,这个 TCP 连接就自动断开了。

在 HTTP1.1 中,可以通过手动设置 Connection: keep-alive 请求头来建立 TCP 的持久连接,多个 HTTP 请求可以共用一个 TCP 连接。但是 TCP 连接存在线头阻塞,即若干个请求排队等待发送,一旦有某请求超时等,后续请求只能被阻塞。

在 HTTP2 中,采用了信道复用,使 TCP 连接支持并发请求,即多个请求可同时在一个连接上并行执行。某个请求任务耗时严重,不会影响到其它连接的正常执行吗,这样一来,大部分请求可以使用一个 TCP 连接,而不用创建新的 TCP 连接通道,既节省了三次握手的开销,又节约了服务端维护 TCP 端口的成本。

TCP 的三次握手和四次挥手

三次握手

提示:关于 ACK、FIN、SYN 状态码的含义

1.ACK 用于确认,表示通知对方,我已经收到你发来的信息了。

2.FIN 用于结束,表示告知对方,我这边已经结束,数据全部发送完毕,没有后续输出,请求终止连接。

3.SYN 用于同步和建立连接,表示告知对方,我这边请求同步建立连接。

  1. 第一次握手:由客户端向服务端发送连接请求 SYN 报文,该报文段中包含自身的数据通讯初始序号,请求发送后,客户端便进入 SYN-SENT 状态。
  2. 第二次握手:服务端收到连接请求报文段后,如果同意连接,则会发送一个包含了 ACK 和 SYN 报文信息的应答,该应答中也会包含自身的数据通讯初始序号(在断开连接的“四次挥手”时,ACK 和 SYN 这两个报文是作为两次应答,独立开来发送的,因此会有四次挥手),服务端发送完成后便进入 SYN-RECEIVED 状态。
  3. 第三次握手:当客户端收到连接同意的应答后,还要向服务端发送一个确认报文。客户端发完这个报文段后便进入 ESTABLISHED 状态,服务端收到这个应答后也进入 ESTABLISHED 状态,此时连接建立成功。

面试时可能会问的一个问题就是,明明两次握手就能确定的连接,为什么需要三次握手?

因为由于很多不可控制的因素,例如网络原因,可能会造成第一次请求隔了很久才到达服务端,这个时候客户端已经等待响应等了很久,之前发起的请求已超时,已经被客户端废弃掉不再继续守着监听了。

然而服务端过了很久,收到了废弃的延迟请求,发起回应的同时又开启了一个新的 TCP 连接端口,在那里呆等客户端。

而服务端能维护的 TCP 连接是有限的,这种闲置的无用链接会造成服务端的资源浪费。

因此在服务端发送了 SYN 和 ACK 响应后,需要收到客户端接的再次确认,双方连接才能正式建立起来。三次握手就是为了规避这种由于网络延迟而导致服务器额外开销的问题。

四次挥手

和建立 TCP 连接类似,断开 TCP 连接也同样需要客户端于服务端的双向交流,因为整个断开动作需要双端共发送 4 个数据包才能完成,所以简称为“四次挥手”。

  1. 第一次挥手:客户端认为自己这边的数据已经全部发送完毕了,于是发送一个 FIN 用来关闭客户端到服务端的数据传输,发送完成以后,客户端进入 FIN_WAIT_1 状态。
  2. 第二次挥手:服务端收到客户端发送回来的 FIN 以后,会告诉应用层要释放 TCP 链接,并且发送一个 ACK 给客户端,表明已经收到客户端的释放请求了,不会再接受客户端发来的数据,自此,服务端进入 CLOSE_WAIT 的状态。
  3. 第三次挥手:服务端如果此时还有未发送完的数据可以继续发送,发送完毕后,服务端也会发送一个释放连接的 FIN 请求用来关闭服务端到客户端的数据传送,然后服务端进入 LAST_ACK 状态。
  4. 第四次挥手:客户端接收到服务端的 FIN 请求后,发送最后一个 ACK 给服务端,接着进入 TIME_WAIT_2 状态,该状态会持续 2MSL(最大段生存期,指报文段在网络中生存的时间,超时会被抛弃) 时间,若该时间段内没有服务端的重发请求的话,客户端就进入 CLOSED 状态.服务端在收到应答消息后,也会进入 CLOSED 状态,至此完成四次挥手的过程,双方正式断开连接。

上面的内容可能还是有些不够直观,所以我还准备了一段人话来描述整个过程:

  1. 客户端:喂,我好了。
  2. 服务端:噢,你好了是吧,我知道了,我还没好,你等一哈。
  3. 服务端:OK,现在我也好了。
  4. 客户端:收到,这次玩的很开心,我们下次再约。

可能有些面试中会问,为什么建立连接有三次握手,而断开连接却有四次?

这是因为在建立连接过程中,服务端在收到客户但建立连接请求的 SYN 报文后,会把 ACK 和 SYN 放在一个报文里发送给客户端。

而关闭连接时,服务端收到客户端的 FIN 报文,只是表示客户端不再发送数据了,但是还能接收数据,而且这会儿服务端可能还有数据没有发送完,不能马上发送 FIN 报文,只能先发送 ACK 报文,先响应客户端,在确认自己这边所有数据发送完毕以后,才会发送 FIN。

所以,在断开连接时,服务器的 ACK 和 FIN 一般都会单独发送,这就导致了断开连接比请求连接多了一次发送操作。

HTTP 定义

一旦端对端成功建立起了 TCP 连接,下一步就要开始发送正式的 HTTP 请求了。流淌在 TCP Connect 通道里的 HTTP 只负责传输数据包,并没有连接的概念,因此 HTTP 也被叫做“无状态协议”。

HTTP 协议是 Hyper Text Transfer Protocol(超文本传输协议)的缩写,它通常运行在 TCP 之上,通过浏览器和服务器进行数据交互,进行超文本(文本、图片、视频等)传输的规定。也就是说,HTTP 协议规定了超文本传输所要遵守的规则。

  1. HTTP 协议是无状态的。这意味着客户端和服务端之间无法知晓当前对方的状态信息,HTTP 请求本身是不带有任何状态存储的。但实际情况下,客户端和服务端必然需要状态的认证和交互,所以就引入了 Cookie, 用于存储当前浏览器的一些状态信息,每次通过独立的 HTTP 请求进行收发,从而解决这个问题。
  2. HTTP 请求互相独立。HTTP 互相之间都是一个独立的个体请求,在客户端请求网页时多数情况下并不是一次请求就能成功的,服务端首先是响应 HTML 页面,然后浏览器收到响应之后发现页面还引用了其他的资源,例如,CSS,JS 文件,图片等等,还会自动发送 HTTP 请求获取这些需要的资源。
  3. HTTP 协议基于 TCP 协议。HTTP 协议目的是规定客户端和服务端数据传输的格式和数据交互行为,并不负责数据传输的细节,底层是基于 TCP 实现的。现在使用的版本当中是默认持久连接的,也就是多次 HTTP 请求使用一个 TCP 连接。

注意:HTTP 请求和 TCP 连接是不一样的,HTTP 是在 TCP 连接建立的基础上而发起的传输请求,在同一个 TCP 连接通道下,可以发送多个 HTTP 请求,举个例子的话就是高速公路和车子的关系。

HTTP 发展历史

HTTP 0.9 版本

只有一个 GET 命令。

没有请求头和响应头来描述传输相关的数据信息。

服务器发送完数据后,直接关闭 TCP 连接,不支持 TCP 持久化连接。

HTTP 1.0 版本

增加了很多命令,HEAD、POST、PUT、DELETE 等。

增设了 status code 状态码和 header 请求头和响应头。

增加了多字符集支持、多部分发送、权限、缓存等。

可通过开启 Connection: keep-alive 来指定使用 TCP 长连接

HTTP 1.1 (目前普遍使用)

默认支持持久连接

默认支持长连接(PersistentConnection),即默认开启 Connection: keep-alive。

支持请求的流水线(Pipelining)处理,即在一个 TCP 连接上可以传送多个 HTTP 请求和响应。

增加了 host 请求头字段,通过对 host 解析,就能够允许在同一台物理服务器上运行多个软件服务,极大提高了服务器的使用率。目前的 nginx 反向代理就是根据 HTTP 请求头中的 host 来分辨不同的请求,从而将这些请求代理到同一台服务器不同的软件服务上。

HTTP 2.0

HTTP1.x 的解析是基于文本,存在解析上的缺陷;而 HTTP2.0 直接使用二进制的解析方式来替代 HTTP 1.X 的字符串解析,更为高效和健壮。

HTTP2.0 所有数据以“帧”的方式进行传输,因此同一个连接中发送的多个请求不再需要按照顺序进行返回处理,可以达到并行的数据传输。

HTTP2.0 压缩头信息进行传输数据量的优化。HTTP1.x 的请求头带有大量信息,而且每次都要重复发送,HTTP2.0 使用 encoder 来减少需要传输的请求头大小,通讯双方各自缓存一份 header fields 表,既避免了重复的传输,又减小了传输信息的大小。

HTTP2.0 新增了 server push(服务端推送) 的概念,服务端可以主动发起一些数据推送。比如,服务端在接收到浏览器发来的 HTML 请求的同时,可以主动推送相关的资源文件(js/css)给客户端,并行发送,提高网页的传输和渲染效率。

目前如果要使用 HTTP2 需要首先使用 HTTPS 在这基础上,才能使用 HTTP2

HTTPS

我们经常会在有些网页上看到悬浮的弹窗或者广告,有的时候甚至会在自己编写的上线网页上也看到这些垃圾广告,然而开发者明明没有写过这些东西,可是这种垃圾信息是怎么上去的呢?

究其根本原因就在于各种代理服务,当我们从客户端发起一个 HTTP 请求,并不是直接就能传递到目标服务器的,期间会经过层层的代理服务,我们常用的 nginx ,以及在 DNS 解析过程中要经过的宽带运营商,都是一种代理服务。

由于 HTTP 时使用明文字符串来传递数据的,那么这些数据就能很轻易地被中间服务读取甚至篡改,那么中间服务拿到了原始的 HTML 数据,想插入点小广告进去自然不是难事。

HTTPS 是为了解决 HTTP 明文传输而出现的安全问题而出现的一种解决机制 ———— 对 HTTP 请求中的信息进行加密之后传输,从而有效地防止了中间代理服务截获或篡改信息的问题。

HTTPS 其实就是一个安全加强版的 HTTP 1.1 ,有几点需要注意的是:

  1. HTTPS 协议需要到 CA 申请证书,一般免费证书很少,需要交费
  2. HTTP 协议运行在 TCP 之上,所有传输的内容都是明文,HTTPS 运行在 SSL/TLS 之上,SSL/TLS 运行在 TCP 之上,所有传输的内容都经过加密的。
  3. HTTP 和 HTTPS 使用的是完全不同的连接方式,用的端口也不一样,前者是 80,后者是 443。
  4. HTTPS 可以有效的防止运营商劫持,解决了防劫持的一个大问题。

HTTP 的报文组成

HTTP 是以请求和响应的形式存在的,由于发起方主动发起一个 HTTP 请求,然后由响应方回应,双方按照一定的报文格式进行数据的互传,一个完整的 HTTP 报文通常由 首行、首部 和 主体 构成。

首行

首行并不属于 Http Headers ,它包含了:

  1. HTTP Method(GET、POST、PUT、DELETE 等 ),不同的 HTTP Method 有不同的语意。
    HTTP Method 对应予以
    GET 一般用于获取服务器资源
    POST 一般用于传输实体主体
    PUT 一般用于传输文件
    DELETE 用于删除文件
    HEAD 用于获取报文首部,不返回报文主体
    OPTIONS 用于预检请求中,询问请求 URI 资源支持的方法

HTTP Method 只是 HTTP 协议推崇的一种规范,就像 ESLint,你可以选择遵循,也可以选择不遵循,它们所作的事情实质上没有差别,只是语义化更明确。

  1. URL 请求资源的地址,这个地址只会包含请求的路由地址。

  2. 协议的版本,HTTP 1.0 / HTTP 1.1 / HTTP 2。

  3. HTTP 返回状态码(响应报文首行包含)

HTTP 定义了 40 个标准状态代码,可用于传递客户端请求的结果,状态代码分为以下五类,关于各个分段下的返回状态码信息可以参考 HTTP 响应码:

这边需要注意的一点是,一个好的 HTTP 应用服务应该是有完善的 HTTP status code 的返回信息的,即访问者单从 HTTP status > code 上就能得知当前 HTTP 请求的状态信息。

而目前我们大部分的开发模式下的 HTTP 返回码,只有 200 和 500。服务端的同学会先把 200 返回过来,然后再告诉你出了什么 “没登录” / “没认证” / “没权限” 这一类的问题。

业界也有一句戏言:又不是不能用,其实这种开发方式是不正确的,不管从代码的维护性还是个人自身发展角度,我们都需要> 尽量避免这种问题。

HTTP 头信息

HTTP 头信息,即 HTTP Header,首行换行后的信息都是 HTTP Header。HTTP header 里一般存放了客户端和服务端之间交互的非业务信息,例如:本次请求的数据类型、请求日期、字符集支持、自定义头部字段、一些通信凭证和缓存支持等。

HTTP Header 完整字段列表:传送门

主体

主体,即 HTTP body,HTTP Header 信息和主体信息间以一个空行 + 一个换行来区分。HTTP body 里一般会放置请求的一些具体业务信息

HTTP 数据协商

在 HTTP 协议中,数据协商是这样一种机制,客户端通过请求头告知服务端本次请求希望获取的数据格式、展现形式、以及数据的压缩方式等。常见的数据协商例如,文档使用的自然语言,图片的格式,或者内容编码形式。

服务端可以对请求头中携带的数据协商字段进行解析,然后在返回客户端数据的时候,也会用相对字段来通知客户端:本次返回的数据格式、压缩方式等信息。这样浏览器就可以使用特定的解析方式,来对这些资源进行解析、处理和渲染。

下面简单列举一些常用的数据协商字段,完整的数据协商信息传送门

  • Accept 请求头字段,指定期望获得的数据类型
  • Accept-Encoding 请求头字段,指定期望获得的数据需要以什么样的编码方式进行传输,常用于限制服务端对数据的压缩方式,常见的 JS 文件包大小优化的 GZIP 压缩,就使用了这个方法
  • Accept-Language 请求头字段,指定期望获得的数据语言类型:中文、英语、还是其他语言,这个头信息字段,一般是浏览器自动加上的
  • User-Agent 请求头字段,指定本次请求的浏览器信息,服务端可根据此信息选择不同兼容性的页面返回给用户,或者是做用户使用浏览器信息、操作系统等数据的统计
  • Content-Type 响应头字段,请求头里的 Accept 字段可能会指定好几种可以接受的数据格式,服务端最终会返回一种数据格式给客户端
  • Content-Encoding 响应头字段,对应 Accept-Encoding
  • Content-Language 响应头字段,对应 Accept-Language

HTTP 长连接

每一个 HTTP 请求都需要在 TCP 连接通道里才能完成发送和接受。在 HTTP 协议的早期版本里,每一条 HTTP 请求发送之前,都会创建一条新的 TCP 连接通道,在这个请求完成以后,该条 TCP 通道就会自动关闭。

这样带来的问题就是,单条 TCP 连接没有办法复用,造成很大的新能浪费。好在这一问题随着 HTTP 协议的逐步完善已经得到解决。

在 HTTP 1.0 中引入的 Connection 头字段,允许对其设置 Keep-Alive 或者是 Close 来决定是否需要复用 TCP 连接,还是说在一次请求完成之后直接关闭。而在 HTTP 1.1 中默认双端都会默认开启这个字段,即默认支持 HTTP 的长连接。

需要注意的是:Connection: Keep-Alive 需要双端同时开启才能启动 HTTP 长连接,如果任何一段手动设置 Connection 为 Close,长连接都无法位置,因为 TCP 连接的建立和持久保持是一个双端交互的过程。

那么我们在本地如何看到 TCP 的连接 ID 呢,可以打开 Chrome 的调试工具来查看:

图上可以看到有不同的 Connection ID,这就代表着本次请求实际上是开启了一个新的 TCP 连接,最下面的请求的 Connection ID 都是相同的,代表着多个 HTTP 请求复用了同一个 TCP 连接。

Chrome 浏览器所能够支持的最大并发 TCP 连接数是 6 个,并且在 HTTP 2.0 以下的 HTTP 版本中,请求是阻塞的。也就是说,一旦六个连接开满,前面的请求未完成,那么后续请求就会被阻塞,直到前面的请求返回,后续才能继续发送。

HTTP 缓存

虽然 HTTP 缓存不是必须的,但重用缓存的资源通常是必要的。然而常见的 HTTP 缓存只能存储 GET 响应,对于其他类型的响应则无能为力。缓存的关键主要包括 request method 和目标 URI(一般只有 GET 请求才会被缓存)。

缓存读取策略

前端环境下的文件缓存,分为几个不同的位置。当我们打开 Chrome 控制台,查看 Network 下每条请求记录的 size 选项,会发现非常丰富的来源信息。

对于前端浏览器环境来说,缓存读取位置是由先后顺序的,顺序分别是(由上到下寻找,找到即返回;找不到则继续)

  • Service Worker
  • Memory Cache
  • Disk Cache
  • 网络请求

Service Worker

Service Worker 的缓存与浏览器其他内建的缓存机制不同,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的。

浏览器优先查找。

持久存储。

可以更加灵活地控制存储的内容,可以选择缓存哪些文件、定义缓存文件的路由匹配规则等。

可以从 Chrome 的 F12 中,Application -> Cache Storage 查看。

Memory Cache

memory cache 是内存中的缓存存储。

读取速度快。

存储空间较小。

存储时间短,当浏览器的 tab 页被关闭,内存资源即被释放。

如果明确指定了 Cache-Control 为 no-store,浏览器则不会使用 memory-cache。

Disk Cache

Disk Cache 是硬盘中的缓存存储。

读取速度慢于 Memory Cache ,快于网络请求。

存储空间较大。

持久存储。

Disk Cache 严格依照 HTTP 头信息中的字段来判断资源是否可缓存、是否要认证等。

经常听到的“强制缓存”,“对比缓存”,以及 Cache-Control 等,归于此类。

网络请求

如果一个请求的资源文件均未命中上述缓存策略,那么就会发起网络请求。浏览器拿到资源后,会把这个新资源加入缓存。

Cache-Control

HTTP/1.1 定义的 Cache-Control 头用来区分对缓存机制的支持情况, 请求头和响应头都支持这个属性。通过它提供的不同的值来定义缓存策略。需要注意的是,数据变化频率很快的场景并不适合开启 Cache-Control。

指令 作用
public 公共缓存:表示该响应可以被任何中间人(比如中间代理、CDN 等)缓存。
private 私有缓存:表示该响应是专用于某单个用户的,中间人不能缓存此响应,该响应只能应用于浏览器私有缓存中。
max-age (单位/秒)设置缓存的过期时间,过期需要重新请求,否则就读取本地缓存,并不实际发送请求
s-maxage (单位/秒)覆盖 max-age,作用一样,只在代理服务器中生效
max-stale (单位/秒)表示即使缓存过期,也使用这个过期缓存
no-store 禁止进行缓存
no-transform 不得对资源进行转换或压缩等操作,Content-Encoding、Content-Range、Content-Type 等 HTTP 头不能由代理修改(有时候资源比较大的情况下,代理服务器可能会自行做压缩处理,这个指令就是为了防止这种情况)。
no-cache 强制确认缓存:即每次使用本地缓存之前,需要请求服务器,查看缓存是否失效,若未过期(注:实际就是返回 304),则缓存才使用本地缓存副本。
must-revalidate 缓存验证确认:意味着缓存在考虑使用一个陈旧的资源时,必须先验证它的状态,已过期的缓存将不被使用
proxy-revalidate 与 must-revalidate 作用相同,但它仅适用于共享缓存(例如代理),并被私有缓存忽略。

缓存校验

在浏览器使用缓存的过程中,为了配合 Cache-Control 中 no-cache ,我们还需要一个机制来验证缓存是否有效。比如服务器的资源更新了,客户端需要及时刷新缓存;又或者客户端的资源过了有效期,但服务器上的资源还是旧的,此时并不需要重新发送。

缓存校验就是用来解决这些问题的,在 http 1.1 中,我们主要关注下 Last-Modified 和 ETag 这两个字段。

Last-Modified

顾名思义,就是资源的最新一次修改时间。当客户端访问服务端的资源,服务端会将这个 Last-Modified 值返回给客户端,客户端收到之后,下次发送请求就会将服务端返回回来的 Last-Modified 值装在 If-Modified-Since 或者 If-Unmodified-Since 里,发送给服务端进行缓存校验。

这样服务器就可以通过读取 If-Modified-Since (较常用)或 If-UnModified-Since 的值,和本地的 Last-Modified 值做对比校验。如果校验发现这两个值是一样的,就代表本次请求的资源文件没有被修改过,那么服务器就会告诉浏览器,资源有效,可以继续使用,否则就需要使用最新的资源。

来看一下下面的两张图:

当请求服务端的 script.js 的脚本资源时,可以看到服务端返回了 Last-Modified,里面记录了该资源最后一次的修改时间

当客户端下次再次发起请求,会携带上这个过期时间给服务端进行验证

来看下服务端的部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const http = require("http");
const fs = require("fs");
http.createServer((request, response) => {
const ifModifiedSince = request.headers["If-Modified-Since"];
const lastModified =
"Web Aug 19 2019 19:01:15 GMT+0800 (China Standard Time)";

if (request.url === "/") {
const html = fs.readFileSync("test.html", "utf-8");

response.writeHead(200, {
"Content-Type": "text/html",
});
response.end(html);
}
if (request.url === "/script.js") {
const js = fs.readFileSync("script.js", "utf-8");
let status = 200;
// 如果读取到的 If-Modified-Since 和 lastModified 相同,则设置头部 304 表示可使用缓存
if (ifModifiedSince === lastModified) {
status = 304;
response.end("");
}
response.writeHead(status, {
"Content-Type": "text/javascript",
"Cache-Control": "no-cache,max-age=2000",
"Last-Modified": lastModified,
});
response.end(js);
}
});

ETag

Etag 的作用本质上和 Last-Modified 差别不大。相比于 Last-Modified 使用最后修改日期来比较资源是否失效的缓存校验策略,ETag 则是通过数据签名来做一个更加严格的缓存验证。

所谓数据签名,其实就是通过对资源内容进行一个唯一的签名标记,一旦资源内容改变,那么签名必将改变,服务端就以此签名作为暗号,来标记缓存的有效性。典型的做法是针对资源内容进行一个 hash 计算,类似于 webpack 打包线上资源所加的 hash 标识

和 Last-Modified 对应 If-Modified-Since 相同,ETag 也会对应 If-Match 或者 If-None-Match(If-None-Match 比较常用),如果前后的签名相同,则不需要返回新的资源内容。

缓存校验的合理使用

Last-Modified 和 ETag 只是给服务端提供了一个控制缓存有效期的手段,并没有任何强制缓存的作用,最终决定是否使用缓存、还是使用新的资源文件,还是需要靠服务端指定对应的 http code 来决定。

对于保存在服务器上的文件,都有最后修改日期的属性,当使用 Last-Modified 可以利用这个有效的属性进行数据缓存验证;或者在数据库存入一个 updatetime 字段来标识具体的修改日期,从而判断缓存是否有效。

具体如何构建一个能够合理使用缓存的服务器,就比较涉及后端知识了,这里不做具体描述。

浏览器的同源策略

浏览器的同源限制:当浏览器访问 URL 地址的协议(schema)/ 端口(port)/ 域名(host),三者中有任何一个与当前的 URL 片段信息不匹配的时候,便存在跨域问题。

对于跨域的几点需要明确:

  1. 跨域,是浏览器提供的一种保护手段,服务端是不存在跨域这一说的。这也就是为什么现在前后端分离的开发模式下,前端比较依赖 webpack-dev-server 启动代理服务来中转和代理后台接口的原因,因为两个服务器之间相互通信是没有跨域障碍的。
  2. 跨域,是对于 XMLHttpRequest 来说的,浏览器获取不同源服务器下的静态资源,是没有跨域限制的,这也是 JSONP 跨域请求得以实现的本质。
  3. 不同于 XMLHttpRequest 的是,通过 src 属性加载的脚本资源,浏览器限制了 Javascript 的权限,使其不能读写、返回内容。
  4. 对于浏览器来说,除了 DOM 、Cookie、XMLHttpRequest 会收到同源策略限制以外,一些常见的插件,比如 Flash、Java Applet 、Silverlight、Google Gears 等也都有自己的控制策略。

当浏览器向不同域的服务器发送请求时,请求是真能发出去,对方服务端也是真能接收到请求,并且真能给你的浏览器响应,浏览器也真能接收到有效数据。

但是,如果在跨域的情况下、服务端返回数据的响应头里的 Access-Control-Allow-Origin 字段,没有把当前域名列进白名单,那么浏览器会把服务端返回的数据给藏起来,不告诉你,然后给你抛个 Access-Control-Allow-Origin 的错误。

至于为什么资源文件不受同源策略限制呢?可以试想一下,如果资源文件也被限制跨域,那么现在大量使用的 CDN 缓存策略基本就没办法用了。而且现在很多网站的资源文件,都会放到云服务器的 OSS 上,OSS 资源对应的 url 地址肯定是不同域的,那这些资源也不能使用了。

Access-Control-Allow-Origin

Access-Control-Allow-Origin 标识了服务器允许的跨域白名单,它有以下几种设置方法:

  1. 直接设置 * 通配符,简单粗暴,但是这么做等于把服务器的所有接口资源对外完全暴露,是不安全的。
  2. 设置制定域,比如 Access-Control-Allow-Origin: https://www.baidu.com ,这样只会允许指定域的请求进行跨域访问。
  3. 由后端动态设置。Access-Control-Allow-Origin 限制只能写一个白名单,但是当我们有多个域都需要跨域请求怎么呢?这个时候,这时可以由服务端自己维护一套白名单列表,在请求进来的时候对请求的源 host 进行白名单比对,如果在白名单中,就将这个 Access-Control-Allow-Origin 动态设置上去,然后返回响应。

CORS 的预请求

如果我们像上面一样,只设置的 Access-Control-Allow-Origin 白名单,是否就可以完全畅通无阻地进行跨域了呢?并不是。

就算对端开启了域名白名单认证,然鹅有一些操作仍然是需要进一步认证的,这种进一步的认证操作,就是 CORS 预请求。

预请求触发过程

浏览器预请求的触发条件,是判断本次请求是否属于一个简单请求。

如果本次请求属于一个复杂请求,那么在发送正式的跨域请求之前,浏览器会先准备一个名为 OPTIONS 的 HTTP Method ,作为预请求发送。

在服务器通过预请求后,下面浏览器才会发生正式的数据请求。整个请求过程其实是发生了两次请求:一个预检请求,以及后续的实际数据请求。

简单请求

  1. 请求方式只能是 GET POST HEAD
  2. 请求头字段只允许:

Accept

Accept-Language

Content-Language

Content-Type

  1. Content-Type 的值仅限于:

text/plain

multipart/form-data

application/x-www-form-urlencoded

  1. XMLHttpRequestUpload 对象均没有注册任何事件监听器(了解就好)。
  2. 请求中没有使用 ReadableStream 对象(了解就好)。

复杂请求

除了简单请求里定义的,都是复杂请求,统统需要预请求。

预请求的验证

那么怎样使预检请求成功认证呢?还是需要服务端继续帮忙设置请求头的白名单:

  1. ccess-Control-Allow-Headers,设置允许的额外请求头字段。
  2. Access-Control-Allow-Methods,设置允许的额外请求方法。
  3. Access-Control-Max-Age (单位/秒),指定了预请求的结果能够被缓存多久,在这个时间范围内,再次发送跨域请求不会被预检。

HTTP 性能优化方案

合理使用 HTTP 的缓存策略,避免同一资源多次请求服务端而导致的额外性能开销

尽量使用 HTTP 长连接,避免每次重建 TCP 连接带来的时间损耗

尽量使用 HTTPS 来保证网络传输的安全性。

可以使用 HTTP2 来大幅提高数据传输的效率,使用 server push 开启 HTTP2 的服务端推送功能

客户端开启 Accept-Encoding 压缩方式的支持,服务端传输压缩后的文件,减少传输数据的大小

一、真实 DOM 和其解析流程

本节我们主要介绍真实 DOM 的解析过程,通过介绍其解析过程以及存在的问题,从而引出为什么需要虚拟 DOM。一图胜千言,如下图为 webkit 渲染引擎工作流程图

所有的浏览器渲染引擎工作流程大致分为 5 步:创建 DOM 树 —> 创建 Style Rules -> 构建 Render 树 —> 布局 Layout -—> 绘制 Painting。

第一步,构建 DOM 树:用 HTML 分析器,分析 HTML 元素,构建一棵 DOM 树;

第二步,生成样式表:用 CSS 分析器,分析 CSS 文件和元素上的 inline 样式,生成页面的样式表;

第三步,构建 Render 树:将 DOM 树和样式表关联起来,构建一棵 Render 树(Attachment)。每个 DOM 节点都有 attach 方法,接受样式信息,返回一个 render 对象(又名 renderer),这些 render 对象最终会被构建成一棵 Render 树;

第四步,确定节点坐标:根据 Render 树结构,为每个 Render 树上的节点确定一个在显示屏上出现的精确坐标;

第五步,绘制页面:根据 Render 树和节点显示坐标,然后调用每个节点的 paint 方法,将它们绘制出来。

注意点:

1、DOM 树的构建是文档加载完成开始的? 构建 DOM 树是一个渐进过程,为达到更好的用户体验,渲染引擎会尽快将内容显示在屏幕上,它不必等到整个 HTML 文档解析完成之后才开始构建 render 树和布局。

2、Render 树是 DOM 树和 CSS 样式表构建完毕后才开始构建的? 这三个过程在实际进行的时候并不是完全独立的,而是会有交叉,会一边加载,一边解析,以及一边渲染。

3、CSS 的解析注意点? CSS 的解析是从右往左逆向解析的,嵌套标签越多,解析越慢。

4、JS 操作真实 DOM 的代价? 用我们传统的开发模式,原生 JS 或 JQ 操作 DOM 时,浏览器会从构建 DOM 树开始从头到尾执行一遍流程。在一次操作中,我需要更新 10 个 DOM 节点,浏览器收到第一个 DOM 请求后并不知道还有 9 次更新操作,因此会马上执行流程,最终执行 10 次。例如,第一次计算完,紧接着下一个 DOM 更新请求,这个节点的坐标值就变了,前一次计算为无用功。计算 DOM 节点坐标值等都是白白浪费的性能。即使计算机硬件一直在迭代更新,操作 DOM 的代价仍旧是昂贵的,频繁操作还是会出现页面卡顿,影响用户体验

二、Virtual-DOM 基础

2.1、虚拟 DOM 的好处

虚拟 DOM 就是为了解决浏览器性能问题而被设计出来的。如前,若一次操作中有 10 次更新 DOM 的动作,虚拟 DOM 不会立即操作 DOM,而是将这 10 次更新的 diff 内容保存到本地一个 JS 对象中,最终将这个 JS 对象一次性 attch 到 DOM 树上,再进行后续操作,避免大量无谓的计算量。所以,用 JS 对象模拟 DOM 节点的好处是,页面的更新可以先全部反映在 JS 对象(虚拟 DOM )上,操作内存中的 JS 对象的速度显然要更快,等更新完成后,再将最终的 JS 对象映射成真实的 DOM,交由浏览器去绘制。

2.2、算法实现

2.2.1、用 JS 对象模拟 DOM 树

(1)如何用 JS 对象模拟 DOM 树

例如一个真实的 DOM 节点如下:

1
2
3
4
5
6
7
8
9
<div id="virtual-dom">
<p>Virtual DOM</p>
<ul id="list">
<li class="item">Item 1</li>
<li class="item">Item 2</li>
<li class="item">Item 3</li>
</ul>
<div>Hello World</div>
</div>

我们用 JavaScript 对象来表示 DOM 节点,使用对象的属性记录节点的类型、属性、子节点等。

element.js 中表示节点对象代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* Element virdual-dom 对象定义
* @param {String} tagName - dom 元素名称
* @param {Object} props - dom 属性
* @param {Array<Element|String>} - 子节点
*/
function Element(tagName, props, children) {
this.tagName = tagName;
this.props = props;
this.children = children;
// dom 元素的 key 值,用作唯一标识符
if (props.key) {
this.key = props.key;
}
var count = 0;
children.forEach(function (child, i) {
if (child instanceof Element) {
count += child.count;
} else {
children[i] = "" + child;
}
count++;
});
// 子元素个数
this.count = count;
}

function createElement(tagName, props, children) {
return new Element(tagName, props, children);
}

module.exports = createElement;

根据 element 对象的设定,则上面的 DOM 结构就可以简单表示为:

1
2
3
4
5
6
7
8
9
10
var el = require("./element.js");
var ul = el("div", { id: "virtual-dom" }, [
el("p", {}, ["Virtual DOM"]),
el("ul", { id: "list" }, [
el("li", { class: "item" }, ["Item 1"]),
el("li", { class: "item" }, ["Item 2"]),
el("li", { class: "item" }, ["Item 3"]),
]),
el("div", {}, ["Hello World"]),
]);

现在 ul 就是我们用 JavaScript 对象表示的 DOM 结构,我们输出查看 ul 对应的数据结构如下:

(2)渲染用 JS 表示的 DOM 对象

但是页面上并没有这个结构,下一步我们介绍如何将 ul 渲染成页面上真实的 DOM 结构,相关渲染函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* render 将virdual-dom 对象渲染为实际 DOM 元素
*/
Element.prototype.render = function () {
var el = document.createElement(this.tagName);
var props = this.props;
// 设置节点的DOM属性
for (var propName in props) {
var propValue = props[propName];
el.setAttribute(propName, propValue);
}

var children = this.children || [];
children.forEach(function (child) {
var childEl =
child instanceof Element
? child.render() // 如果子节点也是虚拟DOM,递归构建DOM节点
: document.createTextNode(child); // 如果字符串,只构建文本节点
el.appendChild(childEl);
});
return el;
};

我们通过查看以上 render 方法,会根据 tagName 构建一个真正的 DOM 节点,然后设置这个节点的属性,最后递归地把自己的子节点也构建起来。

我们将构建好的 DOM 结构添加到页面 body 上面,如下:

1
2
ulRoot = ul.render();
document.body.appendChild(ulRoot);

这样,页面 body 里面就有真正的 DOM 结构,效果如下图所示:

2.2.2、比较两棵虚拟 DOM 树的差异 — diff 算法

diff 算法用来比较两棵 Virtual DOM 树的差异,如果需要两棵树的完全比较,那么 diff 算法的时间复杂度为 O(n^3)。但是在前端当中,你很少会跨越层级地移动 DOM 元素,所以 Virtual DOM 只会对同一个层级的元素进行对比,如下图所示, div 只会和同一层级的 div 对比,第二层级的只会跟第二层级对比,这样算法复杂度就可以达到 O(n)。

####(1)深度优先遍历,记录差异

在实际的代码中,会对新旧两棵树进行一个深度优先的遍历,这样每个节点都会有一个唯一的标记:

在深度优先遍历的时候,每遍历到一个节点就把该节点和新的的树进行对比。如果有差异的话就记录到一个对象里面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// diff 函数,对比两棵树
function diff(oldTree, newTree) {
var index = 0; // 当前节点的标志
var patches = {}; // 用来记录每个节点差异的对象
dfsWalk(oldTree, newTree, index, patches);
return patches;
}

// 对两棵树进行深度优先遍历
function dfsWalk(oldNode, newNode, index, patches) {
var currentPatch = [];
if (typeof oldNode === "string" && typeof newNode === "string") {
// 文本内容改变
if (newNode !== oldNode) {
currentPatch.push({ type: patch.TEXT, content: newNode });
}
} else if (
newNode != null &&
oldNode.tagName === newNode.tagName &&
oldNode.key === newNode.key
) {
// 节点相同,比较属性
var propsPatches = diffProps(oldNode, newNode);
if (propsPatches) {
currentPatch.push({ type: patch.PROPS, props: propsPatches });
}
// 比较子节点,如果子节点有'ignore'属性,则不需要比较
if (!isIgnoreChildren(newNode)) {
diffChildren(
oldNode.children,
newNode.children,
index,
patches,
currentPatch
);
}
} else if (newNode !== null) {
// 新节点和旧节点不同,用 replace 替换
currentPatch.push({ type: patch.REPLACE, node: newNode });
}

if (currentPatch.length) {
patches[index] = currentPatch;
}
}

从以上可以得出,patches[1] 表示 p ,patches[3] 表示 ul ,以此类推。

(2)差异类型

DOM 操作导致的差异类型包括以下几种:

节点替换:节点改变了,例如将上面的 div 换成 h1;

顺序互换:移动、删除、新增子节点,例如上面 div 的子节点,把 p 和 ul 顺序互换;

属性更改:修改了节点的属性,例如把上面 li 的 class 样式类删除;

文本改变:改变文本节点的文本内容,例如将上面 p 节点的文本内容更改为 “Real Dom”;

以上描述的几种差异类型在代码中定义如下所示:

1
2
3
4
var REPLACE = 0; // 替换原先的节点
var REORDER = 1; // 重新排序
var PROPS = 2; // 修改了节点的属性
var TEXT = 3; // 文本内容改变

####(3)列表对比算法

子节点的对比算法,例如 p, ul, div 的顺序换成了 div, p, ul。这个该怎么对比?如果按照同层级进行顺序对比的话,它们都会被替换掉。如 p 和 div 的 tagName 不同,p 会被 div 所替代。最终,三个节点都会被替换,这样 DOM 开销就非常大。而实际上是不需要替换节点,而只需要经过节点移动就可以达到,我们只需知道怎么进行移动。

将这个问题抽象出来其实就是字符串的最小编辑距离问题(Edition Distance),最常见的解决方法是 Levenshtein Distance , Levenshtein Distance 是一个度量两个字符序列之间差异的字符串度量标准,两个单词之间的 Levenshtein Distance 是将一个单词转换为另一个单词所需的单字符编辑(插入、删除或替换)的最小数量。Levenshtein Distance 是 1965 年由苏联数学家 Vladimir Levenshtein 发明的。Levenshtein Distance 也被称为编辑距离(Edit Distance),通过动态规划求解,时间复杂度为 O(M*N)。

定义:对于两个字符串 a、b,则他们的 Levenshtein Distance 为:

示例:字符串 a 和 b,a=“abcde” ,b=“cabef”,根据上面给出的计算公式,则他们的 Levenshtein Distance 的计算过程如下:

本文的 demo 使用插件 list-diff2 算法进行比较,该算法的时间复杂度伟 O(n*m),虽然该算法并非最优的算法,但是用于对于 dom 元素的常规操作是足够的。该算法具体的实现过程这里不再详细介绍,该算法的具体介绍可以参照:https://github.com/livoras/list-diff

(4)实例输出

两个虚拟 DOM 对象如下图所示,其中 ul1 表示原有的虚拟 DOM 树,ul2 表示改变后的虚拟 DOM 树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var ul1 = el("div", { id: "virtual-dom" }, [
el("p", {}, ["Virtual DOM"]),
el("ul", { id: "list" }, [
el("li", { class: "item" }, ["Item 1"]),
el("li", { class: "item" }, ["Item 2"]),
el("li", { class: "item" }, ["Item 3"]),
]),
el("div", {}, ["Hello World"]),
]);
var ul2 = el("div", { id: "virtual-dom" }, [
el("p", {}, ["Virtual DOM"]),
el("ul", { id: "list" }, [
el("li", { class: "item" }, ["Item 21"]),
el("li", { class: "item" }, ["Item 23"]),
]),
el("p", {}, ["Hello World"]),
]);
var patches = diff(ul1, ul2);
console.log("patches:", patches);

我们查看输出的两个虚拟 DOM 对象之间的差异对象如下图所示,我们能通过差异对象得到,两个虚拟 DOM 对象之间进行了哪些变化,从而根据这个差异对象(patches)更改原先的真实 DOM 结构,从而将页面的 DOM 结构进行更改。

2.2.3、将两个虚拟 DOM 对象的差异应用到真正的 DOM 树

(1)深度优先遍历 DOM 树

因为步骤一所构建的 JavaScript 对象树和 render 出来真正的 DOM 树的信息、结构是一样的。所以我们可以对那棵 DOM 树也进行深度优先的遍历,遍历的时候从步骤二生成的 patches 对象中找出当前遍历的节点差异,如下相关代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function patch(node, patches) {
var walker = { index: 0 };
dfsWalk(node, walker, patches);
}

function dfsWalk(node, walker, patches) {
// 从patches拿出当前节点的差异
var currentPatches = patches[walker.index];

var len = node.childNodes ? node.childNodes.length : 0;
// 深度遍历子节点
for (var i = 0; i < len; i++) {
var child = node.childNodes[i];
walker.index++;
dfsWalk(child, walker, patches);
}
// 对当前节点进行DOM操作
if (currentPatches) {
applyPatches(node, currentPatches);
}
}

(2)对原有 DOM 树进行 DOM 操作

我们根据不同类型的差异对当前节点进行不同的 DOM 操作 ,例如如果进行了节点替换,就进行节点替换 DOM 操作;如果节点文本发生了改变,则进行文本替换的 DOM 操作;以及子节点重排、属性改变等 DOM 操作,相关代码如 applyPatches 所示 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function applyPatches(node, currentPatches) {
currentPatches.forEach((currentPatch) => {
switch (currentPatch.type) {
case REPLACE:
var newNode =
typeof currentPatch.node === "string"
? document.createTextNode(currentPatch.node)
: currentPatch.node.render();
node.parentNode.replaceChild(newNode, node);
break;
case REORDER:
reorderChildren(node, currentPatch.moves);
break;
case PROPS:
setProps(node, currentPatch.props);
break;
case TEXT:
node.textContent = currentPatch.content;
break;
default:
throw new Error("Unknown patch type " + currentPatch.type);
}
});
}

(3)DOM 结构改变

通过将第 2.2.2 得到的两个 DOM 对象之间的差异,应用到第一个(原先)DOM 结构中,我们可以看到 DOM 结构进行了预期的变化,如下图所示:

2.3、结语

Virtual DOM 算法主要实现上面三个步骤来实现:

用 JS 对象模拟 DOM 树 — element.js

1
2
3
4
5
6
7
8
9
<div id="virtual-dom">
<p>Virtual DOM</p>
<ul id="list">
<li class="item">Item 1</li>
<li class="item">Item 2</li>
<li class="item">Item 3</li>
</ul>
<div>Hello World</div>
</div>

比较两棵虚拟 DOM 树的差异 — diff.js

将两个虚拟 DOM 对象的差异应用到真正的 DOM 树 — patch.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function applyPatches(node, currentPatches) {
currentPatches.forEach((currentPatch) => {
switch (currentPatch.type) {
case REPLACE:
var newNode =
typeof currentPatch.node === "string"
? document.createTextNode(currentPatch.node)
: currentPatch.node.render();
node.parentNode.replaceChild(newNode, node);
break;
case REORDER:
reorderChildren(node, currentPatch.moves);
break;
case PROPS:
setProps(node, currentPatch.props);
break;
case TEXT:
node.textContent = currentPatch.content;
break;
default:
throw new Error("Unknown patch type " + currentPatch.type);
}
});
}

三、Vue 源码 Virtual-DOM 简析

我们从第二章节(Virtual-DOM 基础)中已经掌握 Virtual DOM 渲染成真实的 DOM 实际上要经历 VNode 的定义、diff、patch 等过程,所以本章节 Vue 源码的解析也按这几个过程来简析。

3.1、VNode 模拟 DOM 树

3.1.1、VNode 类简析

在 Vue.js 中,Virtual DOM 是用 VNode 这个 Class 去描述,它定义在 src/core/vdom/vnode.js 中 ,从以下代码块中可以看到 Vue.js 中的 Virtual DOM 的定义较为复杂一些,因为它这里包含了很多 Vue.js 的特性。实际上 Vue.js 中 Virtual DOM 是借鉴了一个开源库  snabbdom 的实现,然后加入了一些 Vue.js 的一些特性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
export default class VNode {
tag: string | void;
data: VNodeData | void;
children: ?Array<VNode>;
text: string | void;
elm: Node | void;
ns: string | void;
context: Component | void; // rendered in this component's scope
key: string | number | void;
componentOptions: VNodeComponentOptions | void;
componentInstance: Component | void; // component instance
parent: VNode | void; // component placeholder node

// strictly internal
raw: boolean; // contains raw HTML? (server only)
isStatic: boolean; // hoisted static node
isRootInsert: boolean; // necessary for enter transition check
isComment: boolean; // empty comment placeholder?
isCloned: boolean; // is a cloned node?
isOnce: boolean; // is a v-once node?
asyncFactory: Function | void; // async component factory function
asyncMeta: Object | void;
isAsyncPlaceholder: boolean;
ssrContext: Object | void;
fnContext: Component | void; // real context vm for functional nodes
fnOptions: ?ComponentOptions; // for SSR caching
devtoolsMeta: ?Object; // used to store functional render context for devtools
fnScopeId: ?string; // functional scope id support

constructor(
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
this.tag = tag;
this.data = data;
this.children = children;
this.text = text;
this.elm = elm;
this.ns = undefined;
this.context = context;
this.fnContext = undefined;
this.fnOptions = undefined;
this.fnScopeId = undefined;
this.key = data && data.key;
this.componentOptions = componentOptions;
this.componentInstance = undefined;
this.parent = undefined;
this.raw = false;
this.isStatic = false;
this.isRootInsert = true;
this.isComment = false;
this.isCloned = false;
this.isOnce = false;
this.asyncFactory = asyncFactory;
this.asyncMeta = undefined;
this.isAsyncPlaceholder = false;
}
}

这里千万不要因为 VNode 的这么属性而被吓到,或者咬紧牙去摸清楚每个属性的意义,其实,我们主要了解其几个核心的关键属性就差不多了,例如:

  • tag 属性即这个 vnode 的标签属性
  • data 属性包含了最后渲染成真实 dom 节点后,节点上的 class,attribute,style 以及绑定的事件
  • children 属性是 vnode 的子节点
  • text 属性是文本属性
  • elm 属性为这个 vnode 对应的真实 dom 节点
  • key 属性是 vnode 的标记,在 diff 过程中可以提高 diff 的效率

3.1.2、源码创建 VNode 过程

(1)初始化 vue

我们在实例化一个 vue 实例,也即 new Vue( ) 时,实际上是执行 src/core/instance/index.js 中定义的 Function 函数。

1
2
3
4
5
6
function Vue(options) {
if (process.env.NODE_ENV !== "production" && !(this instanceof Vue)) {
warn("Vue is a constructor and should be called with the `new` keyword");
}
this._init(options);
}

通过查看 Vue 的 function,我们知道 Vue 只能通过 new 关键字初始化,然后调用 this._init 方法,该方法在 src/core/instance/init.js 中定义。

1
2
3
4
5
6
7
8
9
10
Vue.prototype._init = function (options?: Object) {
const vm: Component = this;

// 省略一系列其它初始化的代码

if (vm.$options.el) {
console.log("vm.$options.el:", vm.$options.el);
vm.$mount(vm.$options.el);
}
};

(2)Vue 实例挂载

Vue 中是通过 $mount 实例方法去挂载 dom 的,下面我们通过分析 compiler 版本的 mount 实现,相关源码在目录 src/platforms/web/entry-runtime-with-compiler.js 文件中定义:

1
2
3
4
5
6
7
8
9
10
11
const mount = Vue.prototype.$mount;
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && query(el);

// 省略一系列初始化以及逻辑判断代码

return mount.call(this, el, hydrating);
};

我们发现最终还是调用用原先原型上的 $mount 方法挂载 ,原先原型上的 $mount 方法在 src/platforms/web/runtime/index.js 中定义 。

1
2
3
4
5
6
7
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined;
return mountComponent(this, el, hydrating);
};

我们发现$mount 方法实际上会去调用 mountComponent 方法,这个方法定义在 src/core/instance/lifecycle.js 文件中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
export function mountComponent(
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el;
// 省略一系列其它代码
let updateComponent;
/* istanbul ignore if */
if (process.env.NODE_ENV !== "production" && config.performance && mark) {
updateComponent = () => {
// 生成虚拟 vnode
const vnode = vm._render();
// 更新 DOM
vm._update(vnode, hydrating);
};
} else {
updateComponent = () => {
vm._update(vm._render(), hydrating);
};
}

// 实例化一个渲染Watcher,在它的回调函数中会调用 updateComponent 方法
new Watcher(
vm,
updateComponent,
noop,
{
before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, "beforeUpdate");
}
},
},
true /* isRenderWatcher */
);
hydrating = false;

return vm;
}

从上面的代码可以看到,mountComponent 核心就是先实例化一个渲染 Watcher,在它的回调函数中会调用 updateComponent 方法,在此方法中调用 vm._render 方法先生成虚拟 Node,最终调用 vm._update 更新 DOM。

(3)创建虚拟 Node

Vue 的 _render 方法是实例的一个私有方法,它用来把实例渲染成一个虚拟 Node。它的定义在 src/core/instance/render.js 文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Vue.prototype._render = function (): VNode {
const vm: Component = this
const { render, _parentVnode } = vm.$options
let vnode
try {
// 省略一系列代码
currentRenderingInstance = vm
// 调用 createElement 方法来返回 vnode
vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e) {
handleError(e, vm, `render`){}
}
// set parent
vnode.parent = _parentVnode
console.log("vnode...:",vnode);
return vnode
}

Vue.js 利用 _createElement 方法创建 VNode,它定义在 src/core/vdom/create-elemenet.js 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
export function _createElement(
context: Component,
tag?: string | Class<Component> | Function | Object,
data?: VNodeData,
children?: any,
normalizationType?: number
): VNode | Array<VNode> {
// 省略一系列非主线代码

if (normalizationType === ALWAYS_NORMALIZE) {
// 场景是 render 函数不是编译生成的
children = normalizeChildren(children);
} else if (normalizationType === SIMPLE_NORMALIZE) {
// 场景是 render 函数是编译生成的
children = simpleNormalizeChildren(children);
}
let vnode, ns;
if (typeof tag === "string") {
let Ctor;
ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag);
if (config.isReservedTag(tag)) {
// 创建虚拟 vnode
vnode = new VNode(
config.parsePlatformTagName(tag),
data,
children,
undefined,
undefined,
context
);
} else if (
(!data || !data.pre) &&
isDef((Ctor = resolveAsset(context.$options, "components", tag)))
) {
// component
vnode = createComponent(Ctor, data, context, children, tag);
} else {
vnode = new VNode(tag, data, children, undefined, undefined, context);
}
} else {
vnode = createComponent(tag, data, context, children);
}
if (Array.isArray(vnode)) {
return vnode;
} else if (isDef(vnode)) {
if (isDef(ns)) applyNS(vnode, ns);
if (isDef(data)) registerDeepBindings(data);
return vnode;
} else {
return createEmptyVNode();
}
}

_createElement 方法有 5 个参数,context 表示 VNode 的上下文环境,它是 Component 类型;tag 表示标签,它可以是一个字符串,也可以是一个 Component;data 表示 VNode 的数据,它是一个 VNodeData 类型,可以在 flow/vnode.js 中找到它的定义;children 表示当前 VNode 的子节点,它是任意类型的,需要被规范为标准的 VNode 数组;

3.1.3、实例查看

为了更直观查看我们平时写的 Vue 代码如何用 VNode 类来表示,我们通过一个实例的转换进行更深刻了解。

例如,实例化一个 Vue 实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var app = new Vue({
el: "#app",
render: function (createElement) {
return createElement(
"div",
{
attrs: {
id: "app",
class: "class_box",
},
},
this.message
);
},
data: {
message: "Hello Vue!",
},
});

我们打印出其对应的 VNode 表示:

3.2、diff 过程

3.2.1、Vue.js 源码的 diff 调用逻辑

Vue.js 源码实例化了一个 watcher,这个 ~ 被添加到了在模板当中所绑定变量的依赖当中,一旦 model 中的响应式的数据发生了变化,这些响应式的数据所维护的 dep 数组便会调用 dep.notify() 方法完成所有依赖遍历执行的工作,这包括视图的更新,即 updateComponent 方法的调用。watcher 和 updateComponent 方法定义在  src/core/instance/lifecycle.js 文件中 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
export function mountComponent(
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el;
// 省略一系列其它代码
let updateComponent;
/* istanbul ignore if */
if (process.env.NODE_ENV !== "production" && config.performance && mark) {
updateComponent = () => {
// 生成虚拟 vnode
const vnode = vm._render();
// 更新 DOM
vm._update(vnode, hydrating);
};
} else {
updateComponent = () => {
vm._update(vm._render(), hydrating);
};
}

// 实例化一个渲染Watcher,在它的回调函数中会调用 updateComponent 方法
new Watcher(
vm,
updateComponent,
noop,
{
before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, "beforeUpdate");
}
},
},
true /* isRenderWatcher */
);
hydrating = false;

return vm;
}

完成视图的更新工作事实上就是调用了 vm._update 方法,这个方法接收的第一个参数是刚生成的 Vnode,调用的 vm._update 方法定义在 src/core/instance/lifecycle.js 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this;
const prevEl = vm.$el;
const prevVnode = vm._vnode;
const restoreActiveInstance = setActiveInstance(vm);
vm._vnode = vnode;
if (!prevVnode) {
// 第一个参数为真实的node节点,则为初始化
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
} else {
// 如果需要diff的prevVnode存在,那么对prevVnode和vnode进行diff
vm.$el = vm.__patch__(prevVnode, vnode);
}
restoreActiveInstance();
// update __vue__ reference
if (prevEl) {
prevEl.__vue__ = null;
}
if (vm.$el) {
vm.$el.__vue__ = vm;
}
// if parent is an HOC, update its $el as well
if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
vm.$parent.$el = vm.$el;
}
};

在这个方法当中最为关键的就是 vm.__patch__ 方法,这也是整个 virtual-dom 当中最为核心的方法,主要完成了 prevVnode 和 vnode 的 diff 过程并根据需要操作的 vdom 节点打 patch,最后生成新的真实 dom 节点并完成视图的更新工作。

接下来,让我们看下 vm.__patch__ 的逻辑过程, vm.__patch__ 方法定义在 src/core/vdom/patch.js 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function patch (oldVnode, vnode, hydrating, removeOnly) {
......
if (isUndef(oldVnode)) {
// 当oldVnode不存在时,创建新的节点
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} else {
// 对oldVnode和vnode进行diff,并对oldVnode打patch
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}
......
}
}

在 patch 方法中,我们看到会分为两种情况,一种是当 oldVnode 不存在时,会创建新的节点;另一种则是已经存在 oldVnode ,那么会对 oldVnode 和 vnode 进行 diff 及 patch 的过程。其中 patch 过程中会调用 sameVnode 方法来对对传入的 2 个 vnode 进行基本属性的比较,只有当基本属性相同的情况下才认为这个 2 个 vnode 只是局部发生了更新,然后才会对这 2 个 vnode 进行 diff,如果 2 个 vnode 的基本属性存在不一致的情况,那么就会直接跳过 diff 的过程,进而依据 vnode 新建一个真实的 dom,同时删除老的 dom 节点。

1
2
3
4
5
6
7
8
9
function sameVnode(a, b) {
return (
a.key === b.key &&
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
);
}

diff 过程中主要是通过调用 patchVnode 方法进行的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function patchVnode (oldVnode, vnode, insertedVnodeQueue, ownerArray, index, removeOnly) {
......
const elm = vnode.elm = oldVnode.elm
const oldCh = oldVnode.children
const ch = vnode.children
// 如果vnode没有文本节点
if (isUndef(vnode.text)) {
// 如果oldVnode的children属性存在且vnode的children属性也存在
if (isDef(oldCh) && isDef(ch)) {
// updateChildren,对子节点进行diff
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch)
}
// 如果oldVnode的text存在,那么首先清空text的内容,然后将vnode的children添加进去
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 删除elm下的oldchildren
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// oldVnode有子节点,而vnode没有,那么就清空这个节点
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
// 如果oldVnode和vnode文本属性不同,那么直接更新真是dom节点的文本元素
nodeOps.setTextContent(elm, vnode.text)
}
......
}

从以上代码得知,

diff 过程中又分了好几种情况,oldCh 为 oldVnode 的子节点,ch 为 Vnode 的子节点:

首先进行文本节点的判断,若 oldVnode.text !== vnode.text,那么就会直接进行文本节点的替换;

在 vnode 没有文本节点的情况下,进入子节点的 diff;

当 oldCh 和 ch 都存在且不相同的情况下,调用 updateChildren 对子节点进行 diff;

若 oldCh 不存在,ch 存在,首先清空 oldVnode 的文本节点,同时调用 addVnodes 方法将 ch 添加到 elm 真实 dom 节点当中;

若 oldCh 存在,ch 不存在,则删除 elm 真实节点下的 oldCh 子节点;

若 oldVnode 有文本节点,而 vnode 没有,那么就清空这个文本节点。

3.2.2、子节点 diff 流程分析

(1)Vue.js 源码

这里着重分析下 updateChildren 方法,它也是整个 diff 过程中最重要的环节,以下为 Vue.js 的源码过程,为了更形象理解 diff 过程,我们给出相关的示意图来讲解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
function updateChildren(
parentElm,
oldCh,
newCh,
insertedVnodeQueue,
removeOnly
) {
// 为oldCh和newCh分别建立索引,为之后遍历的依据
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
let oldKeyToIdx, idxInOld, vnodeToMove, refElm;

// 直到oldCh或者newCh被遍历完后跳出循环
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(
oldStartVnode,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(
oldEndVnode,
newEndVnode,
insertedVnodeQueue,
newCh,
newEndIdx
);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// Vnode moved right
patchVnode(
oldStartVnode,
newEndVnode,
insertedVnodeQueue,
newCh,
newEndIdx
);
canMove &&
nodeOps.insertBefore(
parentElm,
oldStartVnode.elm,
nodeOps.nextSibling(oldEndVnode.elm)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
patchVnode(
oldEndVnode,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
canMove &&
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
if (isUndef(oldKeyToIdx))
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
if (isUndef(idxInOld)) {
// New element
createElm(
newStartVnode,
insertedVnodeQueue,
parentElm,
oldStartVnode.elm,
false,
newCh,
newStartIdx
);
} else {
vnodeToMove = oldCh[idxInOld];
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(
vnodeToMove,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
oldCh[idxInOld] = undefined;
canMove &&
nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
} else {
// same key but different element. treat as new element
createElm(
newStartVnode,
insertedVnodeQueue,
parentElm,
oldStartVnode.elm,
false,
newCh,
newStartIdx
);
}
}
newStartVnode = newCh[++newStartIdx];
}
}
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
addVnodes(
parentElm,
refElm,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
);
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}

在开始遍历 diff 前,首先给 oldCh 和 newCh 分别分配一个 startIndex 和 endIndex 来作为遍历的索引,当 oldCh 或者 newCh 遍历完后(遍历完的条件就是 oldCh 或者 newCh 的 startIndex >= endIndex ),就停止 oldCh 和 newCh 的 diff 过程。接下来通过实例来看下整个 diff 的过程(节点属性中不带 key 的情况)。

(2)无 key 的 diff 过程

我们通过以下示意图对以上代码过程进行讲解:

####(2.1)首先从第一个节点开始比较,不管是 oldCh 还是 newCh 的起始或者终止节点都不存在 sameVnode ,同时节点属性中是不带 key 标记的,因此第一轮的 diff 完后,newCh 的 startVnode 被添加到 oldStartVnode 的前面,同时 newStartIndex 前移一位;

(2.2)第二轮的 diff 中,满足 sameVnode(oldStartVnode, newStartVnode),因此对这 2 个 vnode 进行 diff,最后将 patch 打到 oldStartVnode 上,同时 oldStartVnode 和 newStartIndex 都向前移动一位 ;

(2.3)第三轮的 diff 中,满足 sameVnode(oldEndVnode, newStartVnode),那么首先对 oldEndVnode 和 newStartVnode 进行 diff,并对 oldEndVnode 进行 patch,并完成 oldEndVnode 移位的操作,最后 newStartIndex 前移一位,oldStartVnode 后移一位;

(2.4)第四轮的 diff 中,过程同步骤 3;

(2.5)第五轮的 diff 中,同过程 1;

(2.6)遍历的过程结束后,newStartIdx > newEndIdx,说明此时 oldCh 存在多余的节点,那么最后就需要将这些多余的节点删除。

(3)有 key 的 diff 流程

在 vnode 不带 key 的情况下,每一轮的 diff 过程当中都是起始和结束节点进行比较,直到 oldCh 或者 newCh 被遍历完。而当为 vnode 引入 key 属性后,在每一轮的 diff 过程中,当起始和结束节点都没有找到 sameVnode 时,然后再判断在 newStartVnode 的属性中是否有 key,且是否在 oldKeyToIndx 中找到对应的节点 :

如果不存在这个 key,那么就将这个 newStartVnode 作为新的节点创建且插入到原有的 root 的子节点中;

如果存在这个 key,那么就取出 oldCh 中的存在这个 key 的 vnode,然后再进行 diff 的过;

通过以上分析,给 vdom 上添加 key 属性后,遍历 diff 的过程中,当起始点,结束点的搜寻及 diff 出现还是无法匹配的情况下时,就会用 key 来作为唯一标识,来进行 diff,这样就可以提高 diff 效率。

带有 Key 属性的 vnode 的 diff 过程可见下图:

(3.1)首先从第一个节点开始比较,不管是 oldCh 还是 newCh 的起始或者终止节点都不存在 sameVnode,但节点属性中是带 key 标记的, 然后在 oldKeyToIndx 中找到对应的节点,这样第一轮 diff 过后 oldCh 上的 B 节点被删除了,但是 newCh 上的 B 节点上 elm 属性保持对 oldCh 上 B 节点 的 elm 引用。

(3.2)第二轮的 diff 中,满足 sameVnode(oldStartVnode, newStartVnode),因此对这 2 个 vnode 进行 diff,最后将 patch 打到 oldStartVnode 上,同时 oldStartVnode 和 newStartIndex 都向前移动一位 ;

(3.3)第三轮的 diff 中,满足 sameVnode(oldEndVnode, newStartVnode),那么首先对 oldEndVnode 和 newStartVnode 进行 diff,并对 oldEndVnode 进行 patch,并完成 oldEndVnode 移位的操作,最后 newStartIndex 前移一位,oldStartVnode 后移一位;

(3.4)第四轮的 diff 中,过程同步骤 2;

(3.5)第五轮的 diff 中,因为此时 oldStartIndex 已经大于 oldEndIndex,所以将剩余的 Vnode 队列插入队列最后。

3.3、patch 过程

通过 3.2 章节介绍的 diff 过程中,我们会看到 nodeOps 相关的方法对真实 DOM 结构进行操作,nodeOps 定义在 src/platforms/web/runtime/node-ops.js 中,其为基本 DOM 操作,这里就不在详细介绍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export function createElementNS(namespace: string, tagName: string): Element {
return document.createElementNS(namespaceMap[namespace], tagName);
}

export function createTextNode(text: string): Text {
return document.createTextNode(text);
}

export function createComment(text: string): Comment {
return document.createComment(text);
}

export function insertBefore(
parentNode: Node,
newNode: Node,
referenceNode: Node
) {
parentNode.insertBefore(newNode, referenceNode);
}

export function removeChild(node: Node, child: Node) {
node.removeChild(child);
}

3.4、总结

通过前三小节简析,我们从主线上把模板和数据如何渲染成最终的 DOM 的过程分析完毕了,我们可以通过下图更直观地看到从初始化 Vue 到最终渲染的整个过程。

转载

本文转载自https://github.com/fengshi123/blog/issues/10

感谢作者@fengshi123

书接上回

我们看下 entry-server.js 主要做了什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import { createApp } from "./app";

const isDev = process.env.NODE_ENV !== "production";

//此导出的函数将由`bundleRenderer`调用。
//这是我们执行数据预取以确定
//实际渲染应用程序之前的状态。
//由于数据获取是异步的,因此该函数有望
//返回解析为应用实例的Promise。

// This exported function will be called by `bundleRenderer`.
// This is where we perform data-prefetching to determine the
// state of our application before actually rendering it.
// Since data fetching is async, this function is expected to
// return a Promise that resolves to the app instance.
export default (context) => {
return new Promise((resolve, reject) => {
const s = isDev && Date.now();
const { app, router, store } = createApp();

const { url } = context;
const { fullPath } = router.resolve(url).route;

if (fullPath !== url) {
return reject({ url: fullPath });
}

//设置路由位置
// set router's location
router.push(url);

//等待路由可能的异步钩子
// wait until router has resolved possible async hooks
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
// no matched routes
if (!matchedComponents.length) {
return reject({ code: 404 });
}
//路由上匹配的组件唤醒fetchData钩子
//一个预置钩子匹配一个状态返回promise
//当解析完成且状态更新
// Call fetchData hooks on components matched by the route.
// A preFetch hook dispatches a store action and returns a Promise,
// which is resolved when the action is complete and store state has been
// updated.
// 使用Promise.all执行匹配到的Component的asyncData方法,即预取数据
Promise.all(
matchedComponents.map(
({ asyncData }) =>
asyncData &&
asyncData({
store,
route: router.currentRoute,
})
)
)
.then(() => {
isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`);
//解析了所有prefetch钩子后,渲染app所需要的state充满了store,

//在渲染上下文中公开状态,并让请求处理程序
//内联HTML响应中的状态。这允许客户端
//存储以获取服务器端状态,而不必重复
//在客户端上获取初始数据。

// After all preFetch hooks are resolved, our store is now
// filled with the state needed to render the app.
// Expose the state on the render context, and let the request handler
// inline the state in the HTML response. This allows the client-side
// store to pick-up the server-side state without having to duplicate
// the initial data fetching on the client.

// 把vuex的state设置到传入的context.initialState上
context.state = store.state;
// 返回state, router已经设置好的Vue实例app
resolve(app);
})
.catch(reject);
}, reject);
});
};

entry-server.js 的主要工作:

0.返回一个函数,该函数接受一个从服务端传递过来的 context 的参数,

将 vue 实例通过 Promise 返回。 context 一般包含 当前页面的 url。

1.手动路由切换到请求的 url,即’/‘

2.找到该路由对应要渲染的组件,并调用组件的 asyncData 方法来预取数据

3.同步 vuex 的 state 数据至传入的 context.initialState 上,

后面会把这些数据直接发送到浏览器端与客户端的 vue 实例进行数据(状态)同步,

以避免客户端首屏重新加载数据(在客户端入口文件 entry-client.js)

还记得 index.template.html 被设置到 template 属性中吗?

此时 Vue 渲染器内部就会将 Vue 实例渲染进我们传入的这个 html 模板,那么 Vue render 内部是如何知道把 Vue 实例插入到模板的什么位置呢?

1
2
3
<body>
<!--vue-ssr-outlet-->
</body>

就是这里,这个<!--vue-ssr-outlet-->Vue 渲染器就是根据这个自动替换插入,所以这是个固定的 placeholder。

如果改动,服务端渲染时会有错误提示:Error: Content placeholder not found in template.

接下来,Vue 渲染器会回调 callback 方法,我们回到 server.js

1
2
3
4
5
6
7
8
function render (req, res) {

···
renderer.renderToString(context, (err, html) => {
res.end(html)
···
})
}

此时只需要将渲染好的 html 写入 http 响应体就结束了,浏览器客户端就可以看到页面了。

接下来我们看看服务端数据预取的实现

服务端渲染时的数据预取流程

上文提到,服务端渲染时,会手动将路由导航到请求地址即’/‘下,然后调用该路由组件的 asyncData 方法来预取数据

那么我们看看路由配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// /src/router/index.js
Vue.use(Router);
// route-level code splitting
const createListView = (id) => () =>
System.import("../views/CreateListView").then((m) => m.default(id));
const ItemView = () => System.import("../views/ItemView.vue");
const UserView = () => System.import("../views/UserView.vue");
export function createRouter() {
return new Router({
mode: "history",
scrollBehavior: () => ({ y: 0 }),
routes: [
{ path: "/top/:page(\\d+)?", component: createListView("top") },
{ path: "/new/:page(\\d+)?", component: createListView("new") },
{ path: "/show/:page(\\d+)?", component: createListView("show") },
{ path: "/ask/:page(\\d+)?", component: createListView("ask") },
{ path: "/job/:page(\\d+)?", component: createListView("job") },
{ path: "/item/:id(\\d+)", component: ItemView },
{ path: "/user/:id", component: UserView },
{ path: "/", redirect: "/top" },
],
});
}

地址’/‘是做了 redirect 到’/top’,其实就是默认地址就是到 top 页面,在看第一条路由配置,’/top’路由对应的组件是 createListView(‘top’)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// /src/views/CreateListView.js
import ItemList from "./ItemList.vue";

const camelize = (str) => str.charAt(0).toUpperCase() + str.slice(1);

// This is a factory function for dynamically creating root-level list views,
// since they share most of the logic except for the type of items to display.
// They are essentially higher order components wrapping ItemList.vue.
export default function createListView(type) {
return {
name: `${type}-stories-view`,
//从store中取值
asyncData({ store }) {
return store.dispatch("FETCH_LIST_DATA", { type });
},

title: camelize(type),
//创建itemlist的节点,渲染节点
render(h) {
return h(ItemList, { props: { type } });
},
};
}

Vuex state 状态变更流程

asyncData 方法被调用,通过 store.dispatch 分发了一个数据预取的事件,接下来我们可以看到通过 FireBase 的 API 获取到 Top 分类的数据,然后又做了一系列的内部事件分发,保存数据状态到 Vuex store,获取 Top 页面的 List 子项数据,最后处理并保存数据到 store.

最后数据就都保存在 store 这里了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import Vue from "vue";
import Vuex from "vuex";
import actions from "./actions";
import mutations from "./mutations";
import getters from "./getters";

Vue.use(Vuex);

export function createStore() {
return new Vuex.Store({
state: {
activeType: null,
itemsPerPage: 20,
items: {
/* [id: number]: Item */
},
users: {
/* [id: string]: User */
},
lists: {
top: [
/* number */
],
new: [],
show: [],
ask: [],
job: [],
},
},
actions,
mutations,
getters,
});
}

然后将开始通过 Render 函数创建 HTML

1
2
3
4
5
// /src/views/CreateListView.js
render (h) {
console.log(`createListView render`)
return h(ItemList, { props: { type }})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// /src/views/ItemList.vue
···
<template>
<div class="news-view">
<div class="news-list-nav">
<router-link v-if="page > 1" :to="'/' + type + '/' + (page - 1)">< prev</router-link>
<a v-else class="disabled">< prev</a>
<span>{{ page }}/{{ maxPage }}</span>
<router-link v-if="hasMore" :to="'/' + type + '/' + (page + 1)">more ></router-link>
<a v-else class="disabled">more ></a>
</div>
<transition :name="transition">
<div class="news-list" :key="displayedPage" v-if="displayedPage > 0">
<transition-group tag="ul" name="item">
<item v-for="item in displayedItems" :key="item.id" :item="item">
</item>
</transition-group>
</div>
</transition>
</div>
</template>
···

这样创建完 HTML Body 部分,前面提到的 Vue 渲染器会自动把这部分内容插入 index.template.html 中,替换对应的,然后就又回到前面的流程了,server.js 将整个 html 写入 http 响应体,浏览器就得到了整个 html 页面,整个首次访问过程完成。

暂时先这样

HackerNews 是基于 HN 的官方 firebase API 、Vue 2.0 、vue-router 和 vuex 来构建的,使用服务器端渲染。

vue-hackernews 项目,是尤大神在 Vue-SSR 官方文档所展示的范例 DEMO.涉及知识点及技术栈非常全面,对于初学者来说,直接阅读该项目,极具挑战。那么我就来一句一句的解析,看看到底都是啥.

本文借鉴了 osan 的文章(https://wangfuda.github.io/2017/05/14/vue-hackernews-2.0-code-explain/),加上本人自己的理解,从菜鸡的角度来看,添加一些理解.

牢骚

去了一家公司面试,问了半天技术,答基础不错(因为我没有工作经验).回复说我司非常依赖 SSR,对 SEO 要求很高,搜索引擎排名必须第一,要求我必须尽快做出一份 demo 出来满足他们的最低要求.那么问题来了,我对 SSR 基本只是一知半解,怎么用完全属于白板啊.当时周五,那么只剩周六日两天了,也就是说,两天从不知 SSR 为何物到搞出一个 demo.压力好大.回去路上就开始上网查文档.我一看,这是啥,这又是啥.完全不知所云啊.

无奈看看有没有入门教程吧,一搜,好少啊,看看掘金,挺多的,挨个看一遍,为什么都在 copy 官方文档啊,把各个组件,方法讲讲都是干嘛的也行啊,根本没讲,copy 完官方文档,直接甩一个 demo.我:what?

这真是应验了官方指南的话,

本指南将会非常深入,并且假设你已经熟悉 Vue.js 本身,并且具有 Node.js 和 webpack 的相当不错的应用经验。

我这完全不熟悉 node 和 webpack 的人完全就是抓瞎啊.

好吧,经过不断努(bao)力(gan),终于搞出了一个 demo,人家说技术不过关,期待下次合作.wtf?

也在意料之中了,有老手为什么还要菜鸡呢?

不过这技术不能扔了,我得记下来,以警示后人…

结构概览

项目结构图上显示,有两个入口文件,entry-server.js 和 entry-client.js, 分别是服务端渲染和客户端渲染的实现入口,webpack 将两个入口文件分别打包成给服务端用的 server bundle 和给客户端用的 client bundle.

服务端:当 Node Server 收到来自 Browser 的请求后,会创建一个 Vue 渲染器 BundleRenderer,这个 bundleRenderer 会读取上面生成的 server bundle 文件(即 entry-server.js),并且执行它,而 server bundle 实现了数据预取并返回已填充数据的 Vue 实例,接下来 Vue 渲染器内部就会将 Vue 实例渲染进 html 模板,最后把这个完整的 html 发送到浏览器。

客户端:Browser 收到 HTML 后,客户端加载了 client bundle(即 entry-client.js) ,通过 app.$mount(‘#app’)挂载 Vue 实例到服务端渲染的 DOM 上,并会和服务端渲染的 HTML 进行 Hydration(合并).

大体操作

目录概览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
│  manifest.json				# progressive web apps配置文件
│ package.json # 项目配置文件
│ server.js # 服务端渲染

├─public # 静态资源
│ logo-120.png
│ logo-144.png
│ logo-152.png
│ logo-192.png
│ logo-384.png
│ logo-48.png

└─src
│ app.js # 整合 router,filters,vuex 的入口文件
App.vue # 根 vue 组件
│ entry-client.js # client 的入口文件
│ entry-server.js # server 的入口文件
│ index.template.html # html 模板

├─api
│ create-api-client.js # Client数据源配置
│ create-api-server.js # server数据源配置
│ index.js # 数据请求API

├─components
Comment.vue # 评论组件
Item.vue #
ProgressBar.vue # 进度条组件
Spinner.vue # 加载提示组件

├─router
│ index.js # router配置

├─store # Vue store模块
│ actions.js # 根级别的 action
│ getters.js # 属性接口
│ index.js # 我们组装模块并导出 store 的地方
│ mutations.js # 根级别的 mutation

├─util
│ filters.js # 过滤器
│ title.js # 工具类

└─views
CreateListView.js # 动态生成列表界面的工厂方法
ItemList.vue # List界面组件
ItemView.vue # 单List项组件
UserView.vue # 用户界面组件

开发环境的服务端渲染流程

1
2
# serve in dev mode, with hot reload at localhost:8080
$npm run dev

看看发生了什么

上述执行 dev 属性对应的脚本:node server 即 node server.js,即执行 server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
//server.js
const fs = require("fs");
const path = require("path");
const LRU = require("lru-cache");
const express = require("express");
const favicon = require("serve-favicon");
const compression = require("compression");
const microcache = require("route-cache");
const resolve = (file) => path.resolve(__dirname, file);
const { createBundleRenderer } = require("vue-server-renderer");

//设置生产环境
const isProd = process.env.NODE_ENV === "production";
//使用微缓存
const useMicroCache = process.env.MICRO_CACHE !== "false";
//服务端信息
const serverInfo =
`express/${require("express/package.json").version} ` +
`vue-server-renderer/${require("vue-server-renderer/package.json").version}`;

//调用expess
const app = express();

//创建渲染器
function createRenderer(bundle, options) {
// 调用vue-server-renderer的createBundleRenderer方法创建渲染器,
//并设置HTML模板,以后后续将服务端预取的数据填充至模板中
return createBundleRenderer(
bundle,
Object.assign(options, {
// for component caching
//设置一个缓存
cache: LRU({
max: 1000,
maxAge: 1000 * 60 * 15,
}),
// this is only needed when vue-server-renderer is npm-linked
basedir: resolve("./dist"),
// recommended for performance
runInNewContext: false,
})
);
}

let renderer;
let readyPromise;
//模板路径
const templatePath = resolve("./src/index.template.html");
if (isProd) {
//生产环境:
//webpack结合vue-ssr-webpack-plugin插件生成的server bundle
//服务端渲染的HTML模板
const template = fs.readFileSync(templatePath, "utf-8");
//生产环境的时候这里已经打包好了这个json文件可以直接调用
const bundle = require("./dist/vue-ssr-server-bundle.json");

//client manifests是可选项,允许渲染器自动预加载,渲染添加<script>标签

// The client manifests are optional, but it allows the renderer
// to automatically infer preload/prefetch links and directly add <script>
// tags for any async chunks used during render, avoiding waterfall requests.
const clientManifest = require("./dist/vue-ssr-client-manifest.json");
//vue-server-renderer创建bundle渲染器并绑定server bundle
renderer = createRenderer(bundle, {
template,
clientManifest,
});
} else {
// 开发环境下,使用dev-server来通过回调把生成在内存中的bundle文件传回
// 通过dev server的webpack-dev-middleware和webpack-hot-middleware实现客户端代码的热更新
//以及通过webpack的watch功能实现服务端代码的热更新

// In development: setup the dev server with watch and hot-reload,
// and create a new renderer on bundle / index template update.
readyPromise = require("./build/setup-dev-server")(
app,
templatePath,
(bundle, options) => {
// 基于热更新,回调生成最新的bundle渲染器
renderer = createRenderer(bundle, options);
}
);
}

//静态缓存时间
const serve = (path, cache) =>
express.static(resolve(path), {
maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0,
});
//依次装载一系列Express中间件,用来处理静态资源,数据压缩等
app.use(compression({ threshold: 0 }));
app.use(favicon("./public/logo-48.png"));
app.use("/dist", serve("./dist", true));
app.use("/public", serve("./public", true));
app.use("/manifest.json", serve("./manifest.json", true));
app.use("/service-worker.js", serve("./dist/service-worker.js"));

// since this app has no user-specific content, every page is micro-cacheable.
// if your app involves user-specific content, you need to implement custom
// logic to determine whether a request is cacheable based on its url and
// headers.
// 1-second microcache.
// https://www.nginx.com/blog/benefits-of-microcaching-nginx/

//设置微缓存
app.use(microcache.cacheSeconds(1, (req) => useMicroCache && req.originalUrl));

function render(req, res) {
const s = Date.now();

res.setHeader("Content-Type", "text/html");
res.setHeader("Server", serverInfo);

const handleError = (err) => {
if (err.url) {
res.redirect(err.url);
} else if (err.code === 404) {
res.status(404).send("404 | Page Not Found");
} else {
// Render Error Page or Redirect
res.status(500).send("500 | Internal Server Error");
console.error(`error during render : ${req.url}`);
console.error(err.stack);
}
};
// 设置请求的url
const context = {
title: "Vue HN 2.0", // default title
url: req.url,
};

// 为渲染器绑定的server bundle(即entry-server.js)设置入参context
renderer.renderToString(context, (err, html) => {
if (err) {
return handleError(err);
}
res.send(html);
if (!isProd) {
console.log(`whole request: ${Date.now() - s}ms`);
}
});
}
//启动一个服务并监听从 8080 端口进入的所有连接请求。
app.get(
"*",
isProd
? render
: (req, res) => {
readyPromise.then(() => render(req, res));
}
);

const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`server started at localhost:${port}`);
});

server.js 最终监听 8080 端口等待处理客户端请求,此时在浏览器访问 localhost:8080

请求经由 express 路由接收后,执行处理逻辑:readyPromise.then(() => render(req, res))

沿着 Promise 的调用链处理:

开发环境下 1.调用 setup-dev-server.js 模块,根据上图中 webpack config 文件实现入口文件打包,热替换功能实现。

最终通过回调把生成在内存中的 server bundle 传回。 2.创建渲染器,绑定 server bundle,设置渲染模板,缓存等 3.依次装载一系列 Express 中间件,用来处理静态资源,数据压缩等 4.最后将渲染好的 HTML 写入 http 响应体,传回浏览器。

setup-dev-server

server.js 的模块依赖关系图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// setup-dev-server.js
const fs = require("fs");
const path = require("path");
const MFS = require("memory-fs");
const webpack = require("webpack");
const chokidar = require("chokidar");
const clientConfig = require("./webpack.client.config");
const serverConfig = require("./webpack.server.config");

//读取文件
const readFile = (fs, file) => {
try {
//fs读取客户端输出路径
return fs.readFileSync(path.join(clientConfig.output.path, file), "utf-8");
} catch (e) {}
};

//输出模块,创建开发服务
module.exports = function setupDevServer(app, templatePath, cb) {
let bundle;
let template;
let clientManifest;

let ready;
const readyPromise = new Promise((r) => {
ready = r;
});
//设定更新
const update = () => {
if (bundle && clientManifest) {
ready();
cb(bundle, {
template,
clientManifest,
});
}
};

//读取本地模板并观测

// read template from disk and watch
template = fs.readFileSync(templatePath, "utf-8");
chokidar.watch(templatePath).on("change", () => {
template = fs.readFileSync(templatePath, "utf-8");
console.log("index.html template updated.");
update();
});

//客户端热加载服务
// modify client config to work with hot middleware
clientConfig.entry.app = [
"webpack-hot-middleware/client",
clientConfig.entry.app,
];
clientConfig.output.filename = "[name].js";
clientConfig.plugins.push(
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin()
);

//开发中间件
// dev middleware
const clientCompiler = webpack(clientConfig);
const devMiddleware = require("webpack-dev-middleware")(clientCompiler, {
publicPath: clientConfig.output.publicPath,
noInfo: true,
});
app.use(devMiddleware);
// 在client webpack结合vue-ssr-webpack-plugin完成编译后,获取devMiddleware的fileSystem
// 读取内存中的bundle 并通过传入的回调更新server.js中的bundle
clientCompiler.plugin("done", (stats) => {
stats = stats.toJson();
stats.errors.forEach((err) => console.error(err));
stats.warnings.forEach((err) => console.warn(err));
if (stats.errors.length) return;
clientManifest = JSON.parse(
readFile(devMiddleware.fileSystem, "vue-ssr-client-manifest.json")
);
update();
});

//中间件热加载
// hot middleware
app.use(
require("webpack-hot-middleware")(clientCompiler, { heartbeat: 5000 })
);

//观测更新服务渲染
// watch and update server renderer
const serverCompiler = webpack(serverConfig);
// 获取基于memory-fs创建的内存文件系统对象
const mfs = new MFS();
serverCompiler.outputFileSystem = mfs;
// 设置文件重新编译监听并通过传入的回调更新server.js中的bundle
serverCompiler.watch({}, (err, stats) => {
if (err) throw err;
stats = stats.toJson();
if (stats.errors.length) return;

//读取bundle文件
// read bundle generated by vue-ssr-webpack-plugin
bundle = JSON.parse(readFile(mfs, "vue-ssr-server-bundle.json"));
update();
});

return readyPromise;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// build/webpack.base.config.js
const path = require("path");
const webpack = require("webpack");
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const FriendlyErrorsPlugin = require("friendly-errors-webpack-plugin");
const { VueLoaderPlugin } = require("vue-loader");
//生产环境
const isProd = process.env.NODE_ENV === "production";

module.exports = {
//开发环境搞这些
// 开发环境下,开启代码调试map,方便调试断点时代码寻址,推荐模式选择:cheap-module-source-map
devtool: isProd ? false : "#cheap-module-source-map",
// 打包输出配置
output: {
path: path.resolve(__dirname, "../dist"),
publicPath: "/dist/",
filename: "[name].[chunkhash].js",
},
resolve: {
alias: {
public: path.resolve(__dirname, "../public"),
},
},
//解析模块,各种加载器
module: {
noParse: /es6-promise\.js$/, // avoid webpack shimming process
rules: [
{
test: /\.vue$/,
loader: "vue-loader",
options: {
compilerOptions: {
preserveWhitespace: false,
},
},
},
{
test: /\.js$/,
loader: "babel-loader",
exclude: /node_modules/,
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: "url-loader",
options: {
limit: 10000,
name: "[name].[ext]?[hash]",
},
},
{
test: /\.styl(us)?$/,
use: isProd
? ExtractTextPlugin.extract({
use: [
{
loader: "css-loader",
options: { minimize: true },
},
"stylus-loader",
],
fallback: "vue-style-loader",
})
: ["vue-style-loader", "css-loader", "stylus-loader"],
},
],
},
performance: {
hints: false,
},
//生产环境搞这些
plugins: isProd
? [
new VueLoaderPlugin(),
// 压缩js的插件
new webpack.optimize.UglifyJsPlugin({
compress: { warnings: false },
}),
new webpack.optimize.ModuleConcatenationPlugin(),
// 从bundle中提取出特定的text到一个文件中,可以把css从js中独立抽离出来
new ExtractTextPlugin({
filename: "common.[chunkhash].css",
}),
]
: [new VueLoaderPlugin(), new FriendlyErrorsPlugin()],
};

创建渲染器

就是 server.js 里这一步

1
2
3
4
5
6
7
8
9
function createRenderer (bundle, options) {
// https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/README.md#why-use-bundlerenderer
console.log(`createRenderer`)
return createBundleRenderer(bundle, Object.assign(options, {
template,

···
}))
}

创建渲染器时重点两件事:

1.绑定渲染用的 server bundle 至渲染器,这个 bundle 是在 setup-dev-server.js 中将服务端入口文件 entry-server.js 打包生成的。

当渲染器调用 renderer.renderToString 开始渲染时,会执行该入口文件的默认方法。

2.传入了一个 html 模板 index.template.html,这个模板稍后在服务端渲染时就会动态填充预取数据到模板中。

顺着 readyPromise.then 的调用链,接下来调用 render 方法

1
2
3
4
5
6
function render (req, res) {
···
renderer.renderToString(context, (err, html) => {
res.end(html)
})
}

renderer.renderToString 方法内部会先调用入口模块 entry-server.js 的默认方法.

下节再叙

小程序的文件结构

app.js: 设置一些项目的全局变量

app.json: 每一个新页面都必须在这个页面注册

app.wxss: 项目全局样式

project.config.json: 项目配置文件

一个页面主要是包含以下四个文件,这四个文件的名字应该都是一样的,最好以页面所在的文件夹名字为标准:

xxx.wxml: 页面结构

xxx.wxss: 页面样式

xxx.json: 页面配置文件

xxx.js: 页面脚本文件

通过新建目录,新建 component.可以快速建立上述同名称文件.

页面可以嵌套,但不能超过 5 个层级.

app.jsontabBar可以设置导航栏颜色背景,下属的list中可以设置下标按钮的信息.

设备分辨率和 RPX

pt: 视觉单位.与屏幕的物理尺寸有关系,也叫做逻辑分辨率,与移动端的栅格渲染有关.

px: 像素点,物理分辨率.随着屏幕变化不会变化.一般设计稿以 px 为单位.iphone6 的分辨率是 375,设计稿一般是 750,那么

rpx : px = 2:1.

rpx 是响应式像素,可以根据屏幕宽度进行自适应。规定屏幕宽为 750rpx.

当需要的元素大小适应屏幕尺寸,就选择 rpx 作为单位,否则使用 px 作为单位。

小程序的事件

冒泡事件: 当一个组件上的事件被触发,该事件会向父节点传递.

非冒泡事件: 与之相反,不向父节点传递.

事件绑定

key 以 bind 或者 catch 开头,然后跟上事件的类型,如bindtap,catchtouchstart.

value 是一个字符串,需要在对应的 page 中定义同名的函数,不然当触发时会报错.

区别: bind 事件绑定不会阻止冒泡事件向上冒泡,catch 事件绑定可以阻止.

下拉刷新

监听用户下拉刷新事件

  1. 在 app.json 的 window 选项或者页面配置中开启enablePullDownRefresh.
  2. 可以通过wx.startPullDownRefresh触发下拉刷新,调用后触发下拉刷新动画.
  3. 当处理完数据刷新后,wx.stopPullDownRefresh可以停止当前页面的下拉刷新.

小程序更新数据的值

函数用于将数据从逻辑层更新到视图层(异步)

1
this.setData(object data, function callback)

data: 传一个 object,是这次要改变的数据

callback: 传一个 function,是 setData 引起页面更新渲染完毕后的回调函数

//赋值的方式

1
this.setData({ arr: this.data.arr });

小程序中 push 方法基本失效.

改变 object 中的值

1
this.setData({ "obj.text": "message2" });

小程序的路由方式

打开新页面: wx.navigateTo 或使用组件<navigator open-type="navigateTo" />

页面重定向: wx.redirectTo 或使用组件<navigator open-type="redirectTo"/>

压面返回: wx.navigateBack 或使用组件<navigator open-type="navigateBack"/>

Tab 切换: wx.switchTab或使用组件<navigator open-type="switchTab"/>

重启动: wx.reLauch或使用组件<navigator open-type="reLauch"/>

小程序请求接口方式

HTTPS 请求: wx.request

上传文件: wx.uploadFile

下载文件: wx.downloadFile

webSocket 通信: wx.connectSocket

小程序的生命周期

onLoad: 页面加载时触发.一个页面只会调用一次.可以在onLoad的参数中获取打开当前页面路径中的参数

onShow: 页面显示/切入前台时触发

onReady: 页面初次渲染时触发.一个页面只调用一次.

onHide: 页面隐藏/切入后台时触发.如navigateTo或底部 tab 切换到其他页面,小程序切入后台.

onUnload: 页面卸载时触发.如navigateTonavigateBack时触发.

组件通信

父传子: properties

子传父: triggerEvent('自定义事件名', {})

AppID(小程序 ID)

也是开发者 ID,路径:

微信公众平台-小程序-开发-开发设置

标签

微信小程序采用自有的标签,但是基本和 h5 标签有对应

小程序标签 h5 标签
view div
text span
scroll-view 可以滚动的 div

数据绑定

数据绑定采用双大括号语法.

1
<view>{{ message }}</view>

数据来自对应Page的 data 中.

1
2
3
4
5
Page({
data: {
message: "hello world",
},
});

组件属性(需要在双大括号内)

1
<view id="item-{{id}}"></view>
1
2
3
4
5
Page({
data: {
id: 0,
},
});

条件判断

wx: if="{{判断传入的文本用双括号}}"

wx: else="{{判断传入的文本用双括号}}"

wx: ifhidden

类似于v-ifv-show,hidden是始终渲染的,wx: if只有为true才渲染.

列表渲染

wx: for

在组件上使用 wx:for 控制属性绑定一个数组,即可使用数组中各项的数据重复渲染该组件。

默认数组的当前项的下标变量名默认为 index,数组当前项的变量名默认为 item

1
2
3
<view wx:for="{{array}}">
{{index}}: {{item.message}}
</view>
1
2
3
4
5
6
7
8
9
10
11
12
Page({
data: {
array: [
{
message: "foo",
},
{
message: "bar",
},
],
},
});

使用 wx:for-item 可以指定数组当前元素的变量名,

使用 wx:for-index 可以指定数组当前下标的变量名:

1
2
3
<view wx:for="{{array}}" wx:for-index="idx" wx:for-item="itemName">
{{idx}}: {{itemName.message}}
</view>

注意: 当 wx:for 的值为字符串时,会将字符串解析成字符串数组.

花括号和引号之间如果有空格,将最终被解析成为字符串.

wx:key

wx:key 的值以两种形式提供

  1. 字符串,代表在 for 循环的 array 中 item 的某个 property,该 property 的值需要是列表中唯一的字符串或数字,且不能动态改变。

key 是字符串,所以不用双大括号.item是默认值,可以不加.

  1. 保留关键字 *this 代表在 for 循环中的 item 本身,这种表示需要 item 本身是一个唯一的字符串或者数字,如:

当数据改变触发渲染层重新渲染的时候,会校正带有 key 的组件,框架会确保他们被重新排序,而不是重新创建,以确保使组件保持自身的状态,并且提高列表渲染时的效率。

组件中的样式

组件对应 wxss 文件的样式,只对组件 wxml 内的节点生效。编写组件样式时,需要注意以下几点:

组件和引用组件的页面不能使用 id 选择器(#a)、属性选择器([a])和标签名选择器,请改用 class 选择器。

组件和引用组件的页面中使用后代选择器(.a .b)在一些极端情况下会有非预期的表现,如遇,请避免使用。

子元素选择器(.a>.b)只能用于 view 组件与其子节点之间,用于其他组件可能导致非预期的情况。

继承样式,如 font 、 color ,会从组件外继承到组件内。

除继承样式外, app.wxss 中的样式、组件所在页面的的样式对自定义组件无效(除非更改组件样式隔离选项)。

1
2
3
4
#a { } /* 在组件中不能使用 */
[a] { } /* 在组件中不能使用 */
button { } /* 在组件中不能使用 */
.a > .b { } /* 除非 .a 是 view 组件节点,否则不一定会生效 */

除此以外,组件可以指定它所在节点的默认样式,使用 :host 选择器(需要包含基础库 1.7.2 或更高版本的开发者工具支持)。

1
2
3
4
/* 组件 custom-component.wxss */
:host {
color: yellow;
}
1
2
<!-- 页面的 WXML -->
<custom-component>这段文本是黄色的</custom-component>

模板

WXML 提供模板(template),可以在模板中定义代码片段,然后在不同的地方调用。

##定义模板

使用 name属性,作为模板的名字。然后在<template/>内定义代码片段,如:

1
2
3
4
5
6
7
8
9
<template name="msgItem">
<view>
<text>
{" "}
{{ index }}: {{ msg }}{" "}
</text>
<text> Time: {{ time }} </text>
</view>
</template>

使用模板

使用is属性,声明需要的使用模板,然后将模板所需要的 data 传入,

1
<template is="msgItem" data="{{..item}}">
1
2
3
4
5
6
7
8
9
Page({
data: {
item: {
index: 0,
msg: "this is a template",
time: "2019-11-11",
},
},
});

is属性可以用双大括号,来动态决定渲染哪个模板.

1
2
3
4
5
6
7
8
9
10
<template name="odd">
<view> odd</view>
</template>
<template name="even">
<view> even </view>
</template>

<block wx:for="{{[1,2,3,4,5]}}">
<template is="{{item % 2 == 0 ? 'even' : 'odd'}}"/>
</block>

模板的作用域

模板拥有自己的作用域,只能使用 data 传入的数据以及模板定义文件中定义的 <wxs /> 模块。

WXS语法(WeiXin Script)

  1. WXS 不依赖于运行时的基础库版本,可以在所有版本的小程序中运行。
  2. WXS 与 JavaScript 是不同的语言,有自己的语法,并不和 JavaScript 一致。
  3. WXS 的运行环境和其他 JavaScript 代码是隔离的,WXS 中不能调用其他 JavaScript 文件中定义的函数,也不能调用小程序提供的 API。
  4. WXS 函数不能作为组件的事件回调。
  5. 由于运行环境的差异,在 iOS 设备上小程序内的 WXS 会比 JavaScript 代码快 2 ~ 20 倍。在 android 设备上二者运行效率无差异

封装小程序 wx.request

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 官方例子
wx.request({
url: "test.php", //仅为示例,并非真实的接口地址
data: {
x: "",
y: "",
},
header: {
"content-type": "application/json", // 默认值
},
success: function (res) {
console.log(res.data);
},
});

Promsie 封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
const baseUrl = "https://api.it120.cc";

const http = ({ url = "", param = {}, ...other } = {}) => {
wx.showLoading({
title: "请求中,请耐心等待..",
});
let timeStart = Date.now();
return new Promise((resolve, reject) => {
wx.request({
url: getUrl(url),
data: param,
header: {
"content-type": "application/json", // 默认值 ,另一种是 "content-type": "application/x-www-form-urlencoded"
},
...other,
complete: (res) => {
wx.hideLoading();
console.log(`耗时${Date.now() - timeStart}`);
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data);
} else {
reject(res);
}
},
});
});
};

const getUrl = (url) => {
if (url.indexOf("://") == -1) {
url = baseUrl + url;
}
return url;
};

// get方法
const _get = (url, param = {}) => {
return http({
url,
param,
});
};

const _post = (url, param = {}) => {
return http({
url,
param,
method: "post",
});
};

const _put = (url, param = {}) => {
return http({
url,
param,
method: "put",
});
};

const _delete = (url, param = {}) => {
return http({
url,
param,
method: "put",
});
};
module.exports = {
baseUrl,
_get,
_post,
_put,
_delete,
};

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const api = require("../../utils/api.js");

// 单个请求
api
.get("list")
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
});

// 一个页面多个请求
Promise.all([api.get("list"), api.get(`detail/${id}`)])
.then((result) => {
console.log(result);
})
.catch((e) => {
console.log(e);
});

登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
//app.js
App({
onLaunch: function () {
console.log("App onLaunch");
var that = this;
// 获取商城名称
wx.request({
url:
"https://api.it120.cc/" +
that.globalData.subDomain +
"/config/get-value",
data: {
key: "mallName",
},
success: function (res) {
wx.setStorageSync("mallName", res.data.data.value);
},
});
this.login();
this.getUserInfo();
},
login: function () {
var that = this;
var token = that.globalData.token;
// 如果有token
if (token) {
// 检查token是否有效
wx.request({
url:
"https://api.it120.cc/" +
that.globalData.subDomain +
"/user/check-token",
data: {
token: token,
},
success: function (res) {
// 如果token失效了
if (res.data.code != 0) {
that.globalData.token = null;
that.login(); // 重新登陆
}
},
});
return;
}

// 【1】调用微信自带登陆
wx.login({
success: function (res) {
// 【2】 拿到code去访问我们的后台换取其他信息
wx.request({
url:
"https://api.it120.cc/" +
that.globalData.subDomain +
"/user/wxapp/login",
data: {
code: res.code,
},
success: function (res) {
// 如果说这个code失效的
if (res.data.code == 10000) {
// 去注册
that.registerUser();
return;
}
// 如果返回失败了
if (res.data.code != 0) {
// 登录错误
wx.hideLoading();
// 提示无法登陆
wx.showModal({
title: "提示",
content: "无法登录,请重试",
showCancel: false,
});
return;
}

// 【3】 如果成功后设置token到本地
that.globalData.token = res.data.data.token;
// 保存用户信息
wx.setStorage({
key: "token",
data: res.data.data.token,
});
},
});
},
});
},
// 注册?? [这个看需求]
registerUser: function () {
var that = this;
wx.login({
success: function (res) {
var code = res.code; // 微信登录接口返回的 code 参数,下面注册接口需要用到
wx.getUserInfo({
success: function (res) {
var iv = res.iv;
var encryptedData = res.encryptedData;
// 下面开始调用注册接口
wx.request({
url:
"https://api.it120.cc/" +
that.globalData.subDomain +
"/user/wxapp/register/complex",
data: { code: code, encryptedData: encryptedData, iv: iv }, // 设置请求的 参数
success: (res) => {
wx.hideLoading();
that.login();
},
});
},
});
},
});
},
// 获取用户信息
getUserInfo: function () {
wx.getUserInfo({
success: (data) => {
this.globalData.userInfo = data.userInfo;
wx.setStorage({
key: "userInfo",
data: data.userInfo,
});
return this.globalData.userInfo;
},
});
},
globalData: {
userInfo: null,
subDomain: "34vu54u7vuiuvc546d",
token: null,
},
});

授权

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
getUserInfo: function () {
// 先调用wx.getSetting 获取用户权限设置
wx.getSetting({
success(res) {
console.log('1');
if (!res.authSetting['scope.userInfo']) {
wx.authorize({
scope: 'scope.userInfo',
success() {
// 用户已经同意小程序使用录音功能,后续调用 wx.getUserInfo接口不会弹窗询问
wx.getUserInfo({
success: (data) => {
this.globalData.userInfo = data.userInfo;
wx.setStorage({
key: 'userInfo',
data: data.userInfo
})
return this.globalData.userInfo;
}
})
}
})
} else {
console.log(2);
}
}
})

},

小程序的双向绑定和 vue 哪里不一样

小程序直接this.data的属性不可以直接同步到视图,必须调用this.data

data 和 params 的区别

哪些方法可以用来提高微信小程序的应用速度

  1. 提高页面加载速度
  2. 用户行为预测
  3. 减少默认 data 的大小
  4. 组件化方案

番茄闹钟的问题

  1. 组件内 css 选择器不能有 id,target,attr 选择器,一律使用 class 选择器.
  2. wx:key后跟字符串,不能是双大括号.
  3. 页面 wxss 样式名不要和app.wxss重复,容易使设置样式失效.
  4. 没有出现登录页的情况是需要在app.json中将pages/login/login放在最前面.
  5. 登录页面中跳转时,使用wx.reLaunch并未跳转,使用wx.switchTab可以.
1
2
3
4
//原因:reLaunch必须写相对路径才能正确跳转
wx.reLaunch({ url: "../../pages/home/home" });
//wx.switchTab可以直接跳转绝对路径
wx.switchTab({ url: "/pages/home/home" });
  1. 登录验证中的encrypted_data,event返回的是encryptedData.注意
  2. 请求头里的设置't-app-id': t_app_id,注意横线.
  3. 登录存储 storage 时,wx.setStorageSync('me', response.res.data.resource),注意是response.res
  4. 创建任务时,第一个按照后端 api 文档写的报错,第二个正确
1
2
3
4
5
6
req.post("/todos", {
todo: {
completed: false,
description,
},
});
1
2
3
4
req.post("/todos", {
completed: false,
description,
});
  1. 创建时多一个空白,并且没有 id 和 index.原因:将 resource 写成 resources.有些有 s,有些没有.

11.登录时显示 500 报错,解决方法: 清缓存,全部清除,编译.即可.

介绍

Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记”激活”为客户端上完全可交互的应用程序。

服务器渲染的 Vue.js 应用程序也可以被认为是”同构”或”通用”,因为应用程序的大部分代码都可以在服务器和客户端上运行。

优缺点

优点:

  1. 更好的 SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。
  2. 更快的内容到达时间 (time-to-content),特别是对于缓慢的网络情况或运行缓慢的设备。

缺点:

  1. 开发条件所限。
  2. 涉及构建设置和部署的更多要求。
  3. 更多的服务器端负载。

基本用法

安装

npm install vue vue-server-renderer --save

渲染实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 第 1 步:创建一个 Vue 实例
const Vue = require("vue");
const app = new Vue({
template: `<div>Hello World</div>`,
});

// 第 2 步:创建一个 renderer
const renderer = require("vue-server-renderer").createRenderer();

// 第 3 步:将 Vue 实例渲染为 HTML
renderer.renderToString(app, (err, html) => {
if (err) throw err;
console.log(html);
// => <div data-server-rendered="true">Hello World</div>
});

// 在 2.5.0+,如果没有传入回调函数,则会返回 Promise:
renderer
.renderToString(app)
.then((html) => {
console.log(html);
})
.catch((err) => {
console.error(err);
});

与服务端集成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//安装
npm install express --save
//操作express
const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer()

server.get('*', (req, res) => {
const app = new Vue({
data: {
url: req.url
},
template: `<div>访问的 URL 是: {{ url }}</div>`
})

renderer.renderToString(app, (err, html) => {
if (err) {
res.status(500).end('Internal Server Error')
return
}
res.end(`
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>${html}</body>
</html>
`)
})
})

server.listen(8080)

使用页面模板

1
2
3
4
5
6
7
8
9
//index.template.html
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>
<!--vue-ssr-outlet-->
<!--这里将是应用程序 HTML 标记注入的地方-->
</body>
</html>

读取和传输文件到 Vue renderer 中:

1
2
3
4
5
6
7
const renderer = createRenderer({
template: require("fs").readFileSync("./index.template.html", "utf-8"),
});

renderer.renderToString(app, (err, html) => {
console.log(html); // html 将是注入应用程序内容的完整页面
});

vue-srr 范例详解

Vuex 介绍

每一个 Vuex 应用的核心就是 store(仓库)。“store”基本上就是一个容器,它包含着你的应用中大部分的**状态(state)**。Vuex 和单纯的全局对象有以下两点不同:

  1. Vuex 的状态存储是响应式的。当 Vue 组件从 store 中读取状态的时候,若 store 中的状态发生变化,那么相应的组件也会相应地得到高效更新。
  2. 不能直接改变 store 中的状态。改变 store 中的状态的唯一途径就是显式地提交**(commit)mutation**。这样使得我们可以方便地跟踪每一个状态的变化,从而让我们能够实现一些工具帮助我们更好地了解我们的应用。

我的理解: Vuex 相当于一个扩展板的bus中介.起步虽相似,但功能更丰富.

Vuex 之 store

起步: 安装,引入,use,创建状态仓库.在 Vue 实例中注入(把 store 写进去).一条龙服务.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//创建仓库格式
var store = new Vuex.Store({
state: {
//定义你的状态
XXX: xxx
},
mutations: {
//在此处定义状态改变的方法
increase(state){
state.num ++
}
decrease(state){
state.num --
}
},
actions: {
//actions中传递的不再是state,而是context
increaseAction(context){
//actions中只能对mutations中的方法进行操作
context.commit('increase')
}
}
})

mutations 改变状态:

直接通过this.$store.state.XXX拿到状态

在 methods 中.定义一个函数通过this.$store.commit('xxx')方法触发状态变更

actions 改变状态:

通过this.$store.dispatch(xxx)调用

二者区别:

  1. 传入参数不同,mutations 传入state.actions 传入context
  2. 调用方式不同,前者this.$store.commit('xxx').后者this.$store.dispatch(xxx)
  3. 函数要求不同.mutations 中只能有同步操作,actions 中可以有异步操作.

getters

有时需要对 mutations 处理后的 state 进行处理.

可以当做 vuex 的计算属性(computed).

1
2
3
4
5
6
7
getters: {
getNum(state){
//因为要处理state,自然传入state
return state.num > 0 ? state.num : 0
//这里是防止一直减出现负数进行的处理
}
}

获取状态也就改为

1
2
3
4
5
6
7
//在组件中
computed: {
getCount(){
//return this.$store.state.num
return this.$store.getters.getNum
}
}

Vuex 状态管理流程

view–>actions–>mutations–>state–>view

新增

VueX 中

state相当于data

getter相当于computed

mutation相当于methods

action是异步的mutation

module模块将上述进行封装分块

mapState 和 mapGetters

mapState辅助函数仅仅是将 store 中的getter映射到局部计算属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...Vuex.mapState(['firstName', 'lastName'])
//相当于
firstName(){
return this.$store.state.firstName
},
lastName(){
return this.$store.state.lastName
}

..Vuex.mapGetters(['fullName'])
//相当于
fullName(){
return this.$store.getters.fullName
}

store.commit('xxFirstName', 'jirengu')

提交 mutation 中 xxFirstName 的方法。

第一个参数是 state 中有的,后添加的 jirengu 是载荷payload

提交载荷(payload)

store.commit传入额外参数,即mutation的载荷

1
2
3
4
5
6
7
8
mutations:{
increment (state, payload){
state.count += payload.amount
}
}
store.commit('increment', {
amount: 10
})

actions 中传入第一个参数是context,第二个是payload

触发 action

1
store.dispatch("increment");

moudle(模块化)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const moduleA = {
state: { ... },
mutations: { ... },
actions: { ... },
getters: { ... }
}

const moduleB = {
state: { ... },
mutations: { ... },
actions: { ... }
}

const store = new Vuex.Store({
modules: {
a: moduleA,
b: moduleB
}
})

store.state.a // -> moduleA 的状态
store.state.b // -> moduleB 的状态

前端路由原理

监听 url 的变化,然后匹配路由规则,显示相应页面,无需刷新。
目前前端路由只有两种实现方式

  1. hash 模式

监听 hash 值变化(url 后的,从#号开始)
window.addEventListener('hashchange',()=>{})

1
2
3
4
5
6
//监听hash变化
window.onhashchange = function (event) {
console.log(event.oldURL, event.newURL);
let hash = loaction.hash; //通过location对象来获取hash地址
console.log(hash); // "#/notebooks/260827/list" 从#号开始
};

因为 hash 发生变化的 url 都会被浏览器记录下来,从而你会发现浏览器的前进后退都可以用.
特点: hash 虽然出现在 URL 中,但不会被包括在 HTTP 请求中,对后端完全没有影响,因此改变 hash 不会重新加载页面。

  1. history 模式

通过此模式改变 url 同样不会引起页面刷新,只会更新浏览器的历史记录’
修改为 history 模式:在 router 中添加mode: 'history'.

history.pushState
替换当前历史记录
history.replaceState
点击后退触发 popState 事件
window.addEventListener('popState',e=>{})

切换历史状态

包括 back,forward,go 三个方法,对应浏览器的前进,后退,跳转操作.

1
2
3
4
history.go(-2); //后退两次
history.go(2); //前进两次
history.back(); //后退
hsitory.forward(); //前进

修改历史状态

包括了 pushState,replaceState 两个方法,这两个方法接收三个参数:stateObj,title,url.

1
2
3
history.pushState({color:'red'}, 'red', 'red'})
history.back();
history.forward();

通过 pushstate 把页面的状态保存在 state 对象中,当页面的 url 再变回这个 url 时,可以通过 event.state 取到这个 state 对象.

history 模式缺点

通过 history api,我们丢掉了丑陋的#,但是它也有个毛病:
不怕前进,不怕后退,就怕刷新,f5(如果后端没有准备的话),因为刷新是实实在在地去请求服务器的,不玩虚的。

在 hash 模式下,前端路由修改的是#中的信息,而浏览器请求时是不带它玩的,所以没有问题.但是在 history 下,你可以自由的修改 path,当刷新时,如果服务器中没有相应的响应或者资源,会请求失败.

所以,如果你想在 github.io 上搭一个单页博客,就应该选择 hash 模式

vue-router 路由

  1. 安装 vue-router
    npm install --save vue-router
  2. 引用
1
2
import router from "vue-router";
Vue.use(router);
  1. 配置路由文件,并在 Vue 实例中注入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import Vue from "vue";
import App from "./App";
import router from "vue-router";
import HelloWorld from "./components/HelloWorld";

Vue.use(router); //引入并且使用

var rt = new VueRouter({
//下面这个routes是数组,用[]
routes: [
{
path: "/hello", //指定要跳转的路径
component: HelloWorld, //指定要跳转的组件
},
],
});

new Vue({
el: "#app",
router: rt, //把路由实例rt写到Vue实例中就注入了
components: { App }, //组件直接注入.分开写便于模块化
template: "<App/>", //同上
});
  1. 确定视图加载的位置
1
2
<router-view></router-view>
//把这个写到想注入的位置就可以了

路由跳转

在路由文件中,一般是router/index.js.就是把路由单独写一个文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import Vue from "vue";
import router from "vue-router";
import HelloWorld from "./components/HelloWorld";
import HelloEarth from "./components/Helloearth";

Vue.use(router); //引入并且使用

export default new router({
routes: [
{
path: "/helloworld", //指定要跳转的路径
component: HelloWorld, //指定要跳转的组件
},
{
path: "/helloearth", //指定要跳转的路径
component: HelloEarth, //指定要跳转的组件
},
],
});

然后在页面组件中

1
2
3
4
5
6
7
8
9
10
<template>
<ul>
<li>
<router-link to="/helloworld">HelloWorld</router-link>
</li>
<li>
<router-link to="/helloearth">HelloEarth</router-link>
</li>
</ul>
</template>

vue-router 路由参数传递(传参)

  1. 必须在路由内加入路由的name
  2. 必须在path后加/:加传递的参数
  3. 传递参数(传值)和接收参数(具体看下面两种方法)

传递参数方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//params:
this.$router.push({
name: "xxx",
params: {
id: id,
},
});

//params读取参数: this.$route.params.id

//query
this.$router.push({
path: "/xxx",
query: {
id: id,
},
});

//query读取参数: this.$route.query.id

注意: params 传参,push 里只能是 name:’xxx’,不能是 path:’/xxx’,因为 params 只能用 name 来引入路由,如果这个写成 path,接收参数页面会是 undefined.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//两种方式:
<router-link :to="{name: helloworld,params:{worldmsg: '你好世界' }}">
helloworld</router-link>
//接收参数:$route.params.xxx
//这种方式等同于./helloworld/你好世界

export default new router({
routes: [{
name: 'helloworld',
path: '/helloworld/:id', //动态绑定,各个不同id就都使用HelloWorld组件了
component: HelloWorld //指定要跳转的组件
},
{
name: 'helloearth',
path: '/helloearth/:earthmsg', //指定要跳转的路径
component: HelloEarth //指定要跳转的组件
},
]
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  <router-link :to="{path: '/helloearth',query:{msg: 只有一个地球}}">
</router-link>
//方式等同于./helloworld?name=xxx&count=xxx
//接收参数this.$route.query.xxx

coust couter = new VueRouter({
routes:[{
path: '/search',
component: SearchUser,
props: (route) =>({
query: route.query.q
})
}
]
})

导航守卫

导航守卫就是路由跳转过程中的一些钩子函数.

1
2
3
4
5
6
7
8
9
10
11
//钩子函数执行后输出的顺序
全局前置守卫: beforeEach;
路由独享守卫: beforeEnter;
组件路由守卫: beforeRouteEnter, 此时this并不指向该组件实例;
全局解析守卫: beforeResolve;
全局后置守卫: aferEach;
组件生命周期: beforeCreate;
组件生命周期: created;
组件生命周期: borforeMount;
组件生命周期: mounted;
组件路由守卫: beforeRouteEnter的next回调;

导航守卫分为:全局的、单个路由独享的、组件内的三种.

全局的

分别是beforeEach,beforeResolve,afterEach

1
2
3
4
5
const router = new VueRouter({ ... })

router.beforeEach((to, from, next) => {
// ...
})

beforeEach: 全局前置守卫.在路由跳转前触发,参数:to,from,next.这个钩子函数主要用于登录验证.
beforeResolve: 全局解析守卫.和beforeEach类似.区别是在beforeEach和组件内beforeRouteEnter之后,afterEach之前调用.
afterEach: 全局后置钩子.和beforeEach相反,在路由跳转完成后触发,参数: to,from.他发生在beforeEachbeforeResolve之后.

三个参数(to,from,next)

  1. to: Route: 即将要进入的目标路由对象
  2. from: Route: 当前导航正要离开的路由
  3. next: Function: 必须调用该方法来 resolve 这个钩子.否则不能进入路由.执行效果依赖 next 方法的调用函数.
  4. next(false): 中断当前导航.如果浏览器的 URL 改变,那么 URL 会重置到 from 路由对应的地址.
  5. next('/'): 跳转到一个不同的地址.
  6. next(error): 如果传入的 next 的参数是一个 Error 实例.则导航会被终止且该错误会被传递给router.onerror()注册过的回调.

确保要调用next方法,否则钩子就不会被resolved.

路由独享的守卫 beforeEnter

只在进入路由时调用.

可以在路由配置上直接定义 beforeEnter 守卫:

1
2
3
4
5
6
7
8
9
10
11
12
const router = new VueRouter({
routes: [
{
path: "/foo",
component: Foo,
brforeEnter: (to, from, next) => {
//参数用法什么的都一样,调用顺序在全局前置守卫后面,所以不会被全局守卫覆盖
//...
},
},
],
});

组件内的守卫

beforeRouteEnter 进入路由前
beforeRouteUpdate 路由复用同一个组件时
beforeRouteLeave 离开当前路由时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const Foo = {
template: `...`,
beforeRouteEnter(to, from, next) {
//在渲染该组件的对应路由被confirm前调用
//不能!!获取组件实例this
//因为当守卫执行前,组件实例还未创建
},
beforeRouteUpdate(to, from, next) {
//在当前路由改变,但是该组件被复用时调用
//举例来说,对于一个带有动态参数的路径 /foo/:id,在/foo/1和/foo/2之间跳转的时候,
//由于会渲染同样的Foo组件,因此组件实例会被复用.而这个钩子就会在这个情况下被调用.
//可以访问组件实例this
},
beforeRouteLeave(to, from, next) {
//导航离开该组件的对应路由时调用
//可以访问组件实例this
},
};

beforeRouteEnter 守卫不能访问 this

因为钩子在组件实例还没被创建的时候调用,可以通过传一个回调给next来访问组件实例 。
不过,可以通过传一个回调给 next 来访问组件实例.在导航被确认的时候执行回调,并且把组件实例作为回调方法的参数.
但是回调的执行时机在 mounted 后面,所以在我看来这里对 this 的访问意义不太大,可以放在created或者mounted里面。

1
2
3
4
5
beforeRouteEnter(to, from, next){
next(vm => {
//通过`vm`访问组件实例
})
}

beforeRouteLeave

导航离开该组件的对应路由时调用,我们用它来禁止用户离开,比如还未保存草稿,或者在用户离开前,将 setInterval销毁,防止离开之后,定时器还在调用。

1
2
3
4
5
6
7
beforeRouteLeave (to, from , next) {
if (文章保存) {
next(); // 允许离开或者可以跳到别的路由 上面讲过了
} else {
next(false); // 取消离开
}
}

路由钩子函数的错误捕获

如果在导航守卫的钩子函数中有错误,可以这样捕获:

1
2
3
router.onError((callback) => {
console.log(callback, "callback");
});

假设是从 a 组件离开,第一次进入 b 组件

完整的路由导航解析流程:

  1. 触发进入其他路由。
  2. 调用要离开路由的组件守卫beforeRouteLeave
  3. 调用全局前置守卫: beforeEach
  4. 在重用的组件里调用beforeRouteUpdate
  5. 调用路由独享守卫beforeEnter
  6. 解析异步路由组件
  7. 在将要进入的路由组件中调用beforeRouteEnter
  8. 调用全局解析守卫beforeResolve
  9. 导航被确认
  10. 调用全局后置钩子afterEach
  11. 触发 DOM 更新(mounted)
  12. 执行beforeRouteEnter守卫中传给 next 的回调函数

疑难杂症

监听物理返回键或页面返回

1
2
3
4
5
6
7
8
9
//unit.js
//存储当前历史记录点,实现控制手机物理返回键的按钮事件
export const pushHistory = () => {
let state = {
title: "",
url: "",
};
window.history.pushState(state, state.title, state.url);
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//index.vue
import { pushHistory } from '@/misc/utils' //引入工具
export default {
mounted(){
pushHistory()
//监听历史记录点,添加返回事件监听
window.onpopstate = ()=>{
this.$router.push(...)
//输入要返回的上一级路由地址
}
},
destroyed(){
window.removeEventListener('popstate', this.fun, false);//false阻止默认事件
}
//页面销毁时,取消监听。否则其他vue路由页面也会被监听
}