解析 React Transition Group ①

Transition 组件

优先设计以下用户接口,只考虑最核心的功能。

  • 高阶组件的形式使用
  • in 属性,控制元素是否显示

关键在于如何处理状态变化,因为要实现状态切换,需要避免在同一渲染帧中执行。 实现一个 hooks 专门用管理状态改变。

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
const useTransitionState = (props) => {
const stateRef = useRef();
const propsRef = useRef();

const [state, setState] = useState(() => {
if (props.in) {
if (props.appear) return "exited";
return "entered";
} else {
return "exited";
}
});

stateRef.current = state;
propsRef.current = props;

const next = useCallback(() => {
const state = stateRef.current;
const props = propsRef.current;

let nextState = null;
if (props.in) {
if (
(props.appear && state === "exited") ||
(state !== "entering" && state !== "entered")
) {
nextState = "entering";
}

if (!props.enter) {
nextState = "entered";
}

if (state === "entering") {
nextState = "entered";
}
} else {
if (state === "entering" || state === "entered") {
nextState = "exiting";
}
if (!props.exit) {
nextState = "exited";
}

if (state === "exiting") {
nextState = "exited";
}
}

if (nextState !== null) setState(nextState);

return nextState;
}, []);

return [state, next];
};

调用 next 方法即可完成状态切换,在组件渲染后调用 next 实现状态切换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
useEffect(() => {
let nextState = next();

if (nextState === null) return;

if (nextState === "entering") {
return transitionEnd(
props.timeout,
safeCallback(() => {
next();
})
);
}

if (nextState === "exiting") {
return transitionEnd(
props.timeout,
safeCallback(() => {
next();
})
);
}
}, [next, props.in, props.timeout]);

在状态切换的过程中,如果状态改变,如何清除副作用?

当通过用户交互修改了 in 属性,会再次进入到 useEffect 函数中,在进入会执行清理函数, 清理函数需要可以清除执行到一半的中间状态。

如果使用计时器可以执行 clear 方法,也可以实现一个清除函数,它不会清除定时器的执行,但是会让回调函数失效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const safeCallback = (callback) => {
let active = true;

const nextCallback = () => {
if (active) {
callback();
}
};

nextCallback.cancel = () => {
active = false;
};
return nextCallback;
};

更加细节的处理:

  • 考虑组件的类型,函数还是组件

    1
    2
    3
    4
    5
    if (typeof children === "function") {
    renderChildren = children(state);
    } else {
    renderChildren = React.Children.only(children);
    }
  • 控制进入和退出状态切换是否产生动画 exit enter,需要跳过中间状态

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    if (
    (props.appear && state === "exited") ||
    (state !== "entering" && state !== "entered")
    ) {
    nextState = "entering";
    }

    if (!props.enter) {
    nextState = "entered";
    }

    if (state === "entering") {
    nextState = "entered";
    }
  • 实现 appear 首次挂载的时候是否展示动画
    如果需要首次渲染时执行动画,那么即使 in 为 true,内部状态也要初始化为退出状态。然后通过 next 方法进行状态切换

    1
    2
    3
    4
    5
    6
    7
    8
    const [state, setState] = useState(() => {
    if (props.in) {
    if (props.appear) return "exited";
    return "entered";
    } else {
    return "exited";
    }
    });
  • timeout 可以为退出,进入单独配置

  • 剩余属性透传

⑦适配器模式-外观模式

适配器模式

适配器就像是转换国内充电器和香港充电器的插座一样,能让原本可以使用的插座在另一个地方也能正常使用。

而转换的过过程中,并没有修改原有插座的功能,只是将他的功能进行转换,这也是适配器的核心逻辑,有别于外观模式,和之前的装饰者模式。

适配器模式:将一个类的接口,转换为消费方期望的另一个接口。适配器让原本不兼容的类可以正常使用。, 而装饰着模式通常为类提供额外的功能接口,已达到增强类的目的。

真实的场景中适配器模式可以适配对象或类。

通常对类的扩展操作会考虑继承,但是 JS 中的扩展方式很灵活,也可以通过函数的形式对对象或类进行操作。

通过一个适配器,为对象实现 iterator

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
const obj = {
a: 1,
b: 2,
};

const objIteratorAdapter = (
obj: Record<string, any>
): Record<string, any> & { [Symbol.iterator]: () => any } => {
const iterator = (): { next: () => any } => {
let keys = Object.keys(obj);
let index = 0;
return {
next() {
return index < keys.length
? {
value: obj[keys[index++]],
done: false,
}
: {
value: undefined,
done: true,
};
},
};
};
const iteratorObj = Object.create(obj, {
[Symbol.iterator]: {
value: iterator,
writable: false,
enumerable: false,
configurable: false,
},
});

return iteratorObj;
};

const newObj = objIteratorAdapter(obj);
for (let i of newObj) {
console.log(i);
}

外观模式

外观模式提供了统一的接口,用来访问子系统中的一群接口。外观定义了一个高层接口,让子系统的接口更容易使用。

外观模式并不会封装新的功能,而是封装了一段子系统的接口。

虽然外观模式定义简单,但是引出了一个设计理念, 最少知识原则,只和你的密友谈话。

在对象的方法调用中,应该遵循几个原则:

  • 调用该对象本身的方法
  • 调用当作参数出入对象中的方法
  • 调用通过此方法创建或实例化对象中的方法
  • 调用对象实例中引用的对象中的方法

⑥封装调用-命令模式

解耦调用者和请求者

现在有 2 个开关需要控制 2 台设备的开机和停止。设备的开机和停止是一个繁琐的过程,需要设置一些参数,并经历几步操作。

如果我们把对机器的操作都写在开关的实现中,一旦开关流程改变或是更换了设备,都需要重新修改开关的代码。

所以我们考虑先把设备的开机和停止过程封装在一个对象方法里面,开关只要直接调用这个对象的方法就能实现设备开始和停止,不需要关心实现的细节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface ICommand {
execute(): void;
}

class Device1 implements ICommand {
setOption() {}
step1() {}
step2() {}
on() {}
execute() {
this.setOption();
this.step1();
this.step2();
this.on();
console.log("设备已经开启");
}
}

开关类接受命令类,并直接调用命令类的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ControlA {
command: ICommand;
constructor(command: ICommand) {
this.command = command;
}
setCommand(command: ICommand) {
this.command = command;
}
on() {
this.command.execute();
}
}

const controlA = new ControlA(new Device1());
controlA.on();

对于某些按钮,可能没有实现 execute 方法,而我们又不想通过 ifelse 判断破坏代码风格,可以使用占位符类。

1
2
3
4
5
6
class NoCommand implements ICommand {
execute() {}
}

const controlB = new ControlA(new NoCommand());
controlB.on();

命令模式

将请求封装成对象,以便使用不同的请求,队列,或日志来参数化其他的对象,命令模式也支持可撤销的操作。

宏命令

把所有的命令对象通过一个类编排起来,统一执行,就实现了宏命令

1
2
3
4
5
6
7
8
9
10
11
class MacroCommand implements ICommand {
commandStack: ICommand[] = [];
setCommand(command: ICommand) {
this.commandStack.push(command);
}
execute(): void {
for (let i = 0; i < this.commandStack.length; i++) {
this.commandStack[i].execute();
}
}
}

⑤独一无二-单例模式

单例模式

确保一类只有一个实例,并提供全局访问点

这个模式很简单,目的就是让类只能被实例化一次,思路就是如果没有被实例化就实例化类,如果已经实例化直接返回实例化的对象。

因为在 JS 中,类的静态方法不能访问类的实例属性,所以考虑把类的实例缓存在类的静态属性中。

1
2
3
4
5
6
7
8
9
10
class Singleton {
static instance: InstanceType<typeof Singleton> | null = null;
static getInstance() {
if (!this.instance) {
return (this.instance = new Singleton());
} else {
return this.instance;
}
}
}

如果不使用 ES6 class 实现,可以使用函数的方式配合模块化规范,导出创建方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface ISingleton {}
interface ISingletonConstructor {
new (): ISingleton;
}
class Singleton implements ISingleton {
constructor() {}
}

let getSingletonInstance = (Singleton: ISingletonConstructor): ISingleton => {
let instance: ISingleton = new Singleton();
getSingletonInstance = (Singleton?: ISingletonConstructor): ISingleton =>
instance;
return instance;
};

export { getSingletonInstance };

④隐藏new操作符-工厂模式

new 有什么不对

实例化对象的过程不应该总是公开的进行,这里面会有一些耦合的问题。

对 new 本身来讲并没有什么不对,需要通过 new 操作符实例化对象。但是对于设计模式来讲,new 操作符让我们针对业务编写代码,初始化的逻辑可能在一段条件语句中,如果添加了新的类,必须修改原来的代码。也就是说我们代码没有对修改关闭。

当有一些相关类实例化的时候,可能会写出下面的代码,每当加入新的类这段代码就会被修改,也就违反对修改关闭的设计原则。

1
2
3
if (picnic) {duck = new MallardDuck();}
else if (hunting) (duck = new DecoyDuck();)
else if (inBathTub) {duck = new RubberDuck()}

简单工厂

一个最简单的工厂就是把实例化对象的过程提取出来,单独放到一个工厂类中,并暴露方法,允许第三方类通过这个方法实例化对象。

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
abstract class FruitFactory {
// 抽象工厂类的公用方法
common() {}
// 需要子类重新实现
abstract createTea(type: string): AppleTea;
}

interface AppleTea {
name(): void;
}

class Type1AppleTea implements AppleTea {
name() {
console.log("苹果茶类型1");
}
}
class Type2AppleTea implements AppleTea {
name() {
console.log("苹果茶类型2");
}
}
class Apple extends FruitFactory {
taste() {
console.log("苹果很甜");
}
public createTea(type: string): AppleTea {
if (type == "appleTeaType1") {
return new Type1AppleTea();
} else if (type == "appleTeaType1") {
return new Type2AppleTea();
}
return new Type1AppleTea();
}
}

class FruitStore {
factory: FruitFactory;
constructor(factory: FruitFactory) {
this.factory = factory;
}
makeFruitTea(type: string) {
return this.factory.createTea("type1");
}
orderTea() {
const tea = this.makeFruitTea("type1");
tea.name();
}

// 公用的茶的制作方法
// ...
}

const store = new FruitStore(new Apple());
store.orderTea();

工厂方法模式: 定义了一个创建对象的接口,但由子类决定要实例化的类是哪一个的,工厂方法让类把实例化推迟到子类。

上面的代码中可以看到工厂是外部传入,可以创建不同类型的茶,但是茶的制作行为更像是 Store 的一部分,所以做一点改变。

  • 定义一个 Store 的抽象类,所有的 Store 都要实现这个抽象类。
  • 在 Store 抽象类中定义 createTea 的抽象方法,这个方法的内容就是之前外部传入的工厂。
  • 换句话说, 工厂方法转移到子类中实现
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
interface AppleTea {
name(): void;
}

class Type1AppleTea implements AppleTea {
name() {
console.log("苹果茶类型1");
}
}
class Type2AppleTea implements AppleTea {
name() {
console.log("苹果茶类型2");
}
}

abstract class FruitStore {
abstract createTea(type: string): any;
}

class AppleFruitStore extends FruitStore {
constructor() {
super();
}
public createTea(type: string): AppleTea {
if (type == "type1") {
return new Type1AppleTea();
} else if (type == "type2") {
return new Type2AppleTea();
}
return new Type1AppleTea();
}
orderTea() {
const tea = this.createTea("type1");
tea.name();
}
}

const store = new AppleFruitStore();
store.orderTea();

所有的工厂模式都是用来封装对象创建的。创建者类 需要有一个抽象创建者类,定义了一个抽象工厂方法,所有实现了抽象创建者类的子类,都可以用自己实现的工厂方法生产产品。产品类 是在创建者实例的工厂方法中创建出来的。

依赖倒置与抽象工厂

依赖倒置原则:要依赖抽象,不要依赖具体类,这句话很像是要面向接口编程,而不是具体的类。没错,但是这句话更强调,高层的组件不应该依赖低层的组件,而是应该两者都依赖于 抽象。抽象可以是接口,也可以是抽象类。

抽象工厂模式:提供一个接口,用于创建相关或依赖对象的家族,而不需要明确指定具体类

思考

  • 当只有一个创建者类的时候是否应该使用工厂模式,答案是肯定的,工厂模式让你便于扩展,并将产品类的创建与使用解耦。
  • 所有的工厂都是用来封装对象的创建
  • 简单工厂,虽然不是真正的设计模式,但仍不失为一个简单的方法,可以将客户程序从具体类解耦。
  • 工厂方法使用继承:把对象的创建委托给子类,子类实现工厂方法来创建对象。
  • 抽象工厂使用对象组合:对象的创建被实现在工厂接口所暴露出来的方法中
  • 所有工厂模式都通过减少应用程序和具体类之间的依赖促进松耦合。
  • 工厂方法允许类将实例化延迟到子类进行。
  • 抽象工厂创建相关的对象家族,而不需要依赖它们的具体类。
  • 依赖倒置原则,指导我们避免依赖具体类型,而要尽量依赖抽象。
  • 工厂是很有威力的技巧,帮助我们针对抽象编程,而不要针对具体类编程。

①从继承中解脱-策略模式

继承真的合理么

想象一下有一个汽车类,你用它创造了很多汽车,他们都跑在虚拟游戏世界里面。

1
2
3
4
5
6
7
8
9
10
11
class CarSuper {
public run() {
console.log("快快跑");
}
public blow() {
console.log("滴滴叫");
}
}

const benz = new CarSuper();
const bmw = new CarSuper();

但是并不是所有的汽车都是 快快跑 ,它们都有自己的极限速度,所以你很容易想到应该有一个子类,让子类去实现特有方法。

1
2
3
4
5
6
class Benz extends CarSuper {
public run() {
console.log("飞速跑");
}
}
const benz = new Benz();

现在你已经在继承中体会到了好处,突然有一天,你觉得创造出来的汽车应该可以载人,这正是你擅长的东西,所以不假思索的在 CarSuper类上添加了 carrying 公用方法,并开心的睡觉去了。

1
2
3
4
5
6
7
8
9
class CarSuper {
public run() {
console.log("快快跑");
}
public blow() {
console.log("滴滴叫");
}
public carrying() {}
}

第二天起来,你不敢相信自己的眼睛,一位乘客上了一辆玩具车,所以你赶紧关停了游戏服务。并紧急的思考对策,显然载人的行为并不通用,每一个不能载人的车,都需要重写载人的方法,例如玩具车。

其实你还有一个想法,既然载人的方法不通用,不如把类似载人这样的功能看作是一个接口,每一个子类都需要实现这个接口

1
2
3
4
5
6
7
8
9
10
11
12
interface Carrying {
carrying: () => void;
}

class Benz extends CarSuper implements Carrying {
public run() {
console.log("飞速跑");
}
public carrying() {
console.log("拉商人");
}
}

这显然不是一个好办法,接口不具有实现代码,虽然子类自己实现增加了灵活性,但却使子类显的非常冗余,每一个子类后面都跟着一串各不相同且需要独立实现的接口。

为了复用而是用继承可能是导致难以维护
比如上面的玩具车可以载人,是因为没有重写子类载人的方法。

找到变化之处

类的行为在不断改变,所以一旦把这个行为变成类的一部分,就需要大量的经历去跟踪这些行为会在那里造成影响,有一个原则能帮助我们 ❤‍🔥 把变化的内容独立出来,不要和稳定的代码混在一起

现在就可以着手于将载人的行为分离出来,那么这个行为应该以哪种形式存在呢,方法,类,还是对象? 其实一谈到设计模式绕不开的就是 OO(面向对象设计模式),所以我们还是采用 OO 的思想,使用类来实现这个行为。

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
interface CarryingBehavior {
carrying: () => void;
}

class BusinessCarrying implements CarryingBehavior {
public carrying() {
console.log("载商人");
}
}

class ToyCarrying implements CarryingBehavior {
public carrying() {
console.log("载玩具");
}
}

class CarSuper {
protected carryingBehavior: CarryingBehavior;
// 提供初始化时的参数,以提供默认的行为
constructor({ carryingBehavior }: { carryingBehavior: CarryingBehavior }) {
this.carryingBehavior = carryingBehavior;
}
public run() {}
public blow() {}
public carrying() {
this.carryingBehavior.carrying();
}
// 增加set方法是为了可以在运行时改变对象的行为
public setCarryingBehavior(behavior: CarryingBehavior) {
this.carryingBehavior = behavior;
}
}

class Benz extends CarSuper {
constructor() {
super({
carryingBehavior: new BusinessCarrying(),
});
}
public run() {
console.log("飞速跑");
}
}
const benz = new Benz();
benz.carrying();
benz.setCarryingBehavior(new ToyCarrying());
benz.carrying();

我们将汽车的行为抽象为 BusinessCarrying 类,抽象为类和抽象为接口的区别就是分离了实现

BusinessCarrying 类有自己的行为类接口,可就保证了行为类的灵活性,为相同的行为实现不同的效果,例如汽车载人坐在前排还是后排。

通过上面的改造,只需要让汽车类提供一个能设置行为的方法,就可以实现行为的动态化,这也是另一个设计原则 ❤‍🔥 针对接口,而不是实现编程

这样的话和私有方法有什么区别? 行为是可抽象的,是可以被穷举的,他会动态的散布在各种各样的汽车子类中。而私有方法是无法被穷举的,一旦定义一个新的子类,那么他就有自己的私有方法和私有属性。

现在我们分离了一种行为类,在共有类中被声明但不会实现,而是在子类中实例化这个行为类,这其中的原则就是 ❤‍🔥 多用组合,少用继承, 巧合的是 React 哲学中也提到了这样的思想。

最后用官方语言定义策略模式: 定义算法族,分别封装起来,让他们之间可以相互替换,让算法的变换独立于使用算法的客户

  • 知道 OO 基础,并不足以让你设计出良好的 OO 系统。
  • 良好的 OO 设计必须具备可复用、可扩充、可维护三个特性。
  • 模式可以让我们建造出具有良好 OO 设计质量的系统。
  • 模式被认为是历经验证的 O0 设计经验。
  • 模式不是代码,而是针对设计问题的通用解决方案。你可把它们应用到特定的应用中。
  • 模式不是被发明,而是被发现。
  • 大多数的模式和原则,都着眼于软件变化的主题。
  • 大多数的模式都允许系统局部改变独立于其他部分。
  • 我们常把系统中会变化的部分抽出来封装。
  • 模式让开发人员之间有共享的语言,能够最大化沟通的价值。

②发布+订阅=观察者模式

像广播一样发消息

思考一个问题,有没有一种可能,另一个人A,不知道你这个人的存在,但是却可以给你发消息。A 就像是一个广播站一样,将你们联系在一起的就是手中的收音机,你等待着 A 发布消息,而你等待消息就是一个订阅的行为。

或者换一种说法,你就像是一个观察者一样,观察消息有没有到来。无论哪种定义无非都是在描述一种关系,接受消息者和发送消息者之间的关系。

下面要实现一个新闻广播站,每当有新的新闻,会调用新闻对象的broadcastNews,并向观察者们推送消息。下面是一个非常不好的写法:

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
interface Observer {
update(message?: { news: string[] }): void;
}

class NewsObserver implements Observer {
update(message): void {
console.log(message);
}
}

const new1 = new NewsObserver();
const new2 = new NewsObserver();

interface Subscribe {
register(observer: Observer): void;
unregister(observer: Observer): void;
getWeather(city: string): NewsInfo;
notify(): void;
}

class NewsSubscribe {
getNews() {
return "获取到新闻";
}
broadcastNews() {
const news = this.getNews();
new1.update(news);
new2.update(news);
}
}

const newsSubscribe = new NewsSubscribe();

这种写法存在着很多严重的问题:

  • 观察者与被观察者紧耦合在了一起
  • 没办法在程序执行的时候动态添加或删除观察者
  • 观察者是会动态变化的对象,但是没有独立且封装

设计观察者模式

首先我们让被观察者有注册观察者和取消注册观察者的能力。这样能让观察者与被观察者解耦,并在程序执行的时候动态的添加或删除。

观察者模式定义了对象之间的一对多依赖,这样一来,当一个对象改变状态时,它的所有依赖者都会收到通知并自动更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class NewsSubscribe implements Subscribe {
private observerList: Observer[];
constructor() {
this.observerList = [];
}
register(observer: Observer): void {
this.observerList.push(observer);
}

unregister(observer: Observer): void {
const index = this.observerList.indexOf(observer);
if (index > 0) {
this.observerList.splice(index, 1);
}
}
notify(): void {
this.observerList.forEach((observer) => {
observer.update();
});
}
}

被观察者不知道观察者的细节,只知道观察者实现了观察者接口。

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
interface NewsInfo {
news: string[];
}

interface Observer {
update(subscribe: NewsSubscribe): void;
}

class NewsObserver implements Observer {
update(subscribe: NewsSubscribe): void {
console.log(subscribe.getNews());
}
}

const new1 = new NewsObserver();

interface Subscribe {
register(observer: Observer): void;
unregister(observer: Observer): void;
getNews(city: string): NewsInfo;
notify(): void;
}

class NewsSubscribe implements Subscribe {
private observerList: Observer[];
constructor() {
this.observerList = [];
}
register(observer: Observer): void {
this.observerList.push(observer);
}

unregister(observer: Observer): void {
const index = this.observerList.indexOf(observer);
if (index > 0) {
this.observerList.splice(index, 1);
}
}

notify(): void {
this.observerList.forEach((observer) => {
observer.update(this);
});
}

getNews(): NewsInfo {
return {
news: ["新闻"],
};
}
}

const newsSubscribe = new NewsSubscribe();
newsSubscribe.register(new NewsObserver());
newsSubscribe.register(new NewsObserver());

newsSubscribe.notify();
  • 可以为观察者模式设置 setChanged hasChanged 方法,此方法可以控制通知的条件,避免通知的频率过高
  • 观察者模式定义了对象之间一对多的关系。主题 (也就是可观察者) 用共同的接口来更新观察者
  • 观察者和可观察者之间用松耦合方式结合 (loosecoupling),可观察者不知道观察者的细节,只知道观察者实现了观察者接口。
    有弹
  • 使用此模式时,你可从被观察者处推 (push)或拉 (pul1)数据(然而,推的方式被认为更“正确”)。
  • 有多个观察者时,不可以依赖特定的通知次序

webpack执行流程

主要流程

  • 初始化阶段(Initialization):

    解析配置:Webpack 开始处理配置文件(如 webpack.config.js),包括解析入口点、加载器、插件等。
    初始化插件:加载并初始化配置中指定的插件。
    环境准备:设置编译环境,例如选择开发模式或生产模式。

  • 编译阶段(Compilation):

    创建编译器:Webpack 创建一个编译器实例,它管理整个编译过程。
    创建编译对象:创建一个新的编译对象,它包含了此次编译的所有细节。
    读取记录:从之前的编译中读取记录(如果有),以优化编译。
    解析入口:根据配置的入口点,分析出所有依赖的模块。

  • 构建阶段(Building):

    加载模块:Webpack 递归地加载每个依赖模块,这可能涉及到使用不同的加载器处理不同类型的文件。
    模块转换:应用加载器和插件,转换模块内容(如 TS 转 JS,SASS 转 CSS)。
    构建依赖图:构建模块间的依赖关系图。

  • 优化阶段(Optimization):

    优化模块:应用各种优化策略,以减小最终资产的大小。
    代码分割:根据需要将代码分割成不同的块。
    树摇(Tree Shaking):移除未使用的代码。

  • 输出阶段(Output):

    生成资产:根据依赖图,Webpack 将所有模块打包成
    少量的打包文件(资产),通常是一个或多个 JavaScript 文件、CSS 文件和其他静态资源文件。

  • 输出资源:将生成的打包文件写入到文件系统中,通常是输出到指定的 dist 目录。

    完成阶段(Completion):
    执行插件:执行各种插件的完成钩子,完成额外的任务或清理工作。
    输出结果:Webpack 提供编译过程的摘要和详情,如编译时间、打包后的文件大小等。
    监听模式:如果启用了监听模式(watch mode),Webpack 将保持活跃状态,并在源文件更改时重新编译。
    在这个过程中,Webpack 通过其强大的插件系统和加载器机制,提供了高度的可扩展性和灵活性,允许开发人员针对不同的需求和场景进行定制和优化。

Initialization

cli.run

使用 commander 处理命令行参数

1
2
3
4
5
6
7
8
9
10
11
12
// 重写异常退出
this.program.exitOverride(async((async) => {}));

// 监听选项 以及选项触发时的处理函数
this.program.option("--no-color", "Disable colors on console.");
this.program.on("option:no-color", function () {});

// 绑定处理函数
// 最终执行webpack
this.program.action(async () => {
compiler = this.webpack(config.options);
});

创建 compiler

node_modules\webpack\lib\webpack.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
const createCompiler = (rawOptions) => {
const options = getNormalizedWebpackOptions(rawOptions);
applyWebpackOptionsBaseDefaults(options);
const compiler = new Compiler(options);
new NodeEnvironmentPlugin({
infrastructureLogging: options.infrastructureLogging,
}).apply(compiler);
// 初始化所有的插件
if (Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
if (typeof plugin === "function") {
plugin.call(compiler, compiler);
} else if (plugin) {
plugin.apply(compiler);
}
}
}
applyWebpackOptionsDefaults(options);
compiler.hooks.environment.call();
compiler.hooks.afterEnvironment.call();

// 注册内部依赖插件
// ExternalsPlugin
// ChunkPrefetchPreloadPlugin
// ArrayPushCallbackChunkFormatPlugin
// EnableChunkLoadingPlugin
// JsonpChunkLoadingPlugin
// ImportScriptsChunkLoadingPlugin
// EnableWasmLoadingPlugin
// CleanPlugin
// JavascriptModulesPlugin
// JsonModulesPlugin
// AssetModulesPlugin
// EntryOptionPlugin
// EntryPlugin
// RuntimePlugin
// InferAsyncModulesPlugin
// DataUriPlugin
// FileUriPlugin
// CompatibilityPlugin

// HarmonyModulesPlugin
// 模块解析和绑定:它帮助 Webpack 解析和绑定 ES6 模块的 import 和 export 语句,确保模块之间的依赖关系被正确处理。
// 树摇(Tree Shaking):这个插件支持树摇优化,即移除未使用的模块或模块部分,以减小最终打包文件的大小。这是通过静态分析 import 和 export 语句来实现的。
// ES6 模块的原生支持:由于 ES6 模块是 JavaScript 语言的一部分,HarmonyModulesPlugin 提供了对这些模块的原生支持,无需转换为其他格式。
// 代码分割和异步加载:插件支持基于 ES6 模块的代码分割和异步加载,这有助于提高大型应用的性能。
// 与其他 Webpack 特性的集成:HarmonyModulesPlugin 与 Webpack 的其他功能(如模块热替换、代码压缩等)紧密集成。

// InferAsyncModulesPlugin
// DataUriPlugin
// FileUriPlugin
// CompatibilityPlugin
// HarmonyModulesPlugin
// AMDPlugin
// RequireJsStuffPlugin
// CommonJsPlugin
// LoaderPlugin
// NodeStuffPlugin
// APIPlugin
// ExportsInfoApiPlugin
// WebpackIsIncludedPlugin
// ConstPlugin
// UseStrictPlugin
// RequireIncludePlugin
// RequireEnsurePlugin
// RequireContextPlugin
// ImportPlugin
// ImportMetaContextPlugin
// SystemPlugin
// ImportMetaPlugin
// URLPlugin
// DefaultStatsFactoryPlugin
// DefaultStatsPresetPlugin
// DefaultStatsPrinterPlugin
// JavascriptMetaInfoPlugin
// EnsureChunkConditionsPlugin
// RemoveEmptyChunksPlugin
// MergeDuplicateChunksPlugin
// FlagIncludedChunksPlugin
// SideEffectsFlagPlugin
// FlagDependencyExportsPlugin
// FlagDependencyUsagePlugin
// InnerGraphPlugin
// MangleExportsPlugin
// ModuleConcatenationPlugin
// SplitChunksPlugin
// RuntimeChunkPlugin
// NoEmitOnErrorsPlugin
// RealContentHashPlugin
// WasmFinalizeExportsPlugin
// DeterministicModuleIdsPlugin
// DeterministicChunkIdsPlugin
// DefinePlugin
// SizeLimitsPlugin
// TemplatedPathPlugin
// RecordIdsPlugin
// WarnCaseSensitiveModulesPlugin
// AddManagedPathsPlugin
// ResolverCachePlugin

WorkerPlugin;
new WebpackOptionsApply().process(options, compiler);
compiler.hooks.initialize.call();
return compiler;
};

compiler.run()

1
2
3
4
5
6
7
this.hooks.beforeRun.callAsync(this, (err) => {
this.hooks.run.callAsync(this, (err) => {
this.readRecords((err) => {
this.compile(onCompiled);
});
});
});

React 18 新特性

兼容性

V18 已不再支持 IE11,计划时间是 2022 年 6 月 15 日, 因为用到的一些现代浏览器新特性如 micro-tasks,在 IE 中无法充分 polyfill .

并发

V18 版本在 V17 的基础上又做了一些调整。在过去的 V17 版本中,传统模式和并发模式是共存的, 通过 createRoot API 就可以启用并发模式。React 为向并发模式迁移的最初策略是设计三种模式。

  • Legacy 模式: V17 中默认使用的模式。默认开启严格模式。默认同步更新。Legacy Suspense semantics。
  • Blocking 模式: Legacy 和 Concurrent 混合模式。默认开启严格模式。默认同步更新。开放一些新特性。
  • Concurrent 模式:V18 使用的模式。默认开启严格模式。默认并发更新。开发所有的新特性。

React 最初的计划是用户可以从 Legacy 切换到 Blocking 模式,并不需要修改任何语法,配合严格模式(StrictMode)修改其中的报错,当所有错误被解决之后,可以直接切换到的 Concurrent 模式。

但在实际的场景 React 思考了以下几个问题:

  • 项目中会有成百上千个文件,开启严格模式,会有大量的错误信息,虽然不影响程序运行,但是会干扰开发并且不能快速的一次性解决。
  • 启用并发模式的好处不言而喻,可能会在未来默认启用。提供 startTransition Suspense 等 API 在内部实现并发特性。用户可以增量的选择性的使用这些 API 从而获得并发的特性。
  • 如果按照上述的思路,那么 Concurrent 模式和 Blocking 模式的唯一区别就只有是否提示错误信息。那么如果默认不启用并发模式,就可以不开启严格模式,用于提示错误信息。

基于上面的思考,V18 的策略是不会默认启用并发的特性,即使用 createRoot 启用并发模式并不能体验并发的特性,如果想体验并发的特性,需要使用例如 startTransition 等支持并发特性的 API. 所以官方描述为 没有并发模式,只有并发特性

API

  • startTransition

这个 API 可以防止渲染任务立即执行,允许将应用程序中的某些更新标记为非紧急更新,因此它们会暂停,同时优先考虑更紧急的更新。这可以在一个复杂的更新中相应用户输入。

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
function aaaaaaaaaaaaaaa() {
for (let i = 0; i < 100000000; i += 1) {
const a = 1;
}
}
const [num, setNum] = useState<string>("");
const [list, setList] = useState<any[]>([]);
useEffect(() => {
setList(new Array(20000).fill(null));
setTimeout(() => {
const event = document.createEvent("MouseEvents");
event.initEvent("click", true, true);
document.getElementById("button")!.dispatchEvent(event);
}, 500);
}, []);
return (
<>
<button
id="button"
type="button"
onClick={() => {
setNum(() => {
aaaaaaaaaaaaaaa();
return "123";
});
}}
>
点击
</button>
<p>{num}</p>
{list.map((_, i) => (
<div key={Math.random()}>{i}</div>
))}
</>
);

在没有使用并发特性的时候,列表的渲染是一个同步任务,不会相应模拟的用户事件

当开启了并发特性之后,会被拆分成小任务异步执行

1
2
3
startTransition(() => {
setList(new Array(20000).fill(null));
});

  • useDeferredValue

会创建一个数据的副本,如果当前更新是一个紧急更新,useDeferredValue 会返回之前的状态,从而优先响应紧急更新。当紧急更新渲染完成后,才会去执行的当前更新。底层实现与 useDeferredValue 类似。

使用 useDeferredValue 也可以实现相同的效果

1
2
const [list, setList] = useState<any[]>([]);
const dList = useDeferredValue(list);
  • useId

生成一个唯一 ID, 在服务端与客户端生成的相同,防止 ID 不匹配

  • useSyncExternalStore

useSyncExternalStore  是一个新的 api,经历了一次修改,由  useMutableSource  改变而来,主要用来解决外部数据撕裂问题。

useSyncExternalStore 能够通过强制同步更新数据让 React 组件在 CM 下安全地有效地读取外接数据源。 在 Concurrent Mode 下,React 一次渲染会分片执行(以 fiber 为单位),中间可能穿插优先级更高的更新。假如在高优先级的更新中改变了公共数据(比如 redux 中的数据),那之前低优先的渲染必须要重新开始执行,否则就会出现前后状态不一致的情况。

useSyncExternalStore 一般是三方状态管理库使用,我们在日常业务中不需要关注。因为 React 自身的 useState 已经原生的解决的并发特性下的 tear(撕裂)问题。useSyncExternalStore 主要对于框架开发者,比如 redux,它在控制状态时可能并非直接使用的 React 的 state,而是自己在外部维护了一个 store 对象,用发布订阅模式实现了数据更新,脱离了 React 的管理,也就无法依靠 React 自动解决撕裂问题。因此 React 对外提供了这样一个 API。

目前 React-Redux 8.0 已经基于 useSyncExternalStore 实现。

  • useInsertionEffect

这个 Hooks 只建议  css-in-js 库来使用。 这个 Hooks 执行时机在 DOM 生成之后,useLayoutEffect 之前,它的工作原理大致和  useLayoutEffect  相同,只是此时无法访问  DOM  节点的引用,一般用于提前注入  <style>  脚本。

  • Suspense

官方对 空的 fallback 属性的处理方式做了改变:不再跳过 缺失值 或 值为 null 的 fallback 的 Suspense。如果没有指定 fallback 将会把 fallback 呈现为 null。

批处理

在 18 之前,只有在 react 事件处理函数中,才会自动执行批处理,其它情况会多次更新

在 18 之后,任何情况都会自动执行批处理,多次更新始终合并为一次

如果想要跳出批处理使用 flushSync

关于卸载组件时的更新状态警告

有的时候会遇到如下的错误

这个错误表示:无法对未挂载(已卸载)的组件执行状态更新。这是一个无效操作,并且表明我们的代码中存在内存泄漏。

实际上,这个错误并不多见,在以往的版本中,这个警告被广泛误解,并且有些误导。

这个错误的初衷,原本旨在针对一些特殊场景,譬如 你在 useEffect 里面设置了定时器,或者订阅了某个事件,从而在组件内部产生了副作用,而且忘记 return 一个函数清除副作用,则会发生内存泄漏…… 之类的场景

但是在实际开发中,更多的场景是,我们在 useEffect 里面发送了一个异步请求,在异步函数还没有被 resolve 或者被 reject 的时候,我们就卸载了组件。 在这种场景中,警告同样会触发。但是,在这种情况下,组件内部并没有内存泄漏,因为这个异步函数已经被垃圾回收了,此时,警告具有误导性。

综上所述原因,在 React 18 中,官方删除了这个报错。

返回值类型

在 React 18 中,不再检查因返回 undefined 而导致崩溃。既能返回 null,也能返回 undefined。但需要修改相应的 dts 文件。

Strict Mode

严格模式会打印两次日志,可以在 React DevTools 中关闭。

在 React 17 中,取消了其中一次渲染的控制台日志,以便让日志更容易阅读。

在 React 18 中,官方取消了这个限制。如果你安装了 React DevTools,第二次渲染的日志信息将显示为灰色,以柔和的方式显式在控制台。

Javascript 执行机制

变量提升与执行上下文

JS 代码运行会分为编译执行两个阶段

所谓的变量提升,是指在 JavaScript 代码编译过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值并存到内存中,这个默认值就是我们熟悉的 undefined。

最终在编译阶段会生成两部分内容:执行上下文(Execution context)和 可执行代码

执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。

  • 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
  • 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
  • 当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文。

调用栈

先进先出,执行上下文通过调用栈来管理

全局执行是上下文最先被压入栈中, 接下来如果有函数执行,当为函数创建好执行上下文后,也会被压入栈中,当函数返回时,执行上下文会从栈顶弹出.

调用栈是 JavaScript 引擎追踪函数执行的一个机制,当一次有多个函数被调用时,通过调用栈就能够追踪到哪个函数正在被执行以及各函数之间的调用关系。

使用 console.trace() 可以查看当前的调用栈信息.

作用域

作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

ES6 总共有三种作用于, 全局作用域,函数作用域,块级作用域,如果没有块级作用域会存在两个问题

  • 变量容易在不被察觉的情况下被覆盖掉
  • 本应销毁的变量没有被销毁

JS 通过 const, let 实现块级作用域,在创建执行上下文的时候, 这两个关键字会单独存放在词法环境中,而 var 声明的变量于函数会存放在变量环境中.

1
2
3
4
5
6
7
8
9
10
function foo() {
var a = 1;
let b = 2;
{
let b = 3;
var c = 4;
let d = 5;
}
}
foo();

在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。这里所讲的变量是指通过 let 或者 const 声明的变量。

查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。

作用域链于词法作用域

作用域链是由词法作用域决定的.

下面的代码当 barfoo 内部调用的时候, 会先查找自己的词法环境中有没有 name, 然后查找自己的环境变量.

但是当发现都没有的时候并不会在 foo 的词法环境和变量环境中查找,而是直接查找全局执行上下文中的词法环境和变量环境

1
2
3
4
5
6
7
8
9
function bar() {
console.log(name);
}
function foo() {
var name = "one";
bar();
}
var name = "tow";
foo();

控制变量查找顺序的就是作用域链,其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外 部的执行上下文,这个外部引用称为 outer. 但是决定作用域链的不是执行上下文,而是词法作用域

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

foobar 的上级作用域都是全局作用域,所以如果 foo 或者 bar 函数使用了一个它们没有定义的变量,那么它们会到全局作用域去查找。也就是说,词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。

闭包

根据词法作用域的规则,内部函数 getName 和 setName 总是可以访问它们的外部函数 foo 中的变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function foo() {
let name = "one";
const obj = {
getName() {
return name;
},
setName(_name) {
name = _name;
},
};
return obj;
}

var bar = foo();
bar.setName("two");
bar.getName();

当 obj 对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 getName 和 setName 函数依然可以使用 foo 函数中的变量 name.

虽然 foo 函数执行结束后执行上下文已经从调用栈中弹出,但是由于 obj 对象的方法使用了内部 name 变量,所以 name 变量还是保存在内存中,而保存 name 变量称作闭包. 无论在那里调用 obj 对象的方法,都可以访问到 name 变量.

在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。

而调用 obj 方法的时候,作用域链的顺序就是:当前执行上下文–>foo 函数闭包–> 全局执行上下文

闭包回收

如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。

如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。

this

实现 this 的一个初衷就是 在对象内部的方法中使用对象内部的属性

因为作用域链由词法作用域决定,所以调用 getName 并不会获取对象属性而是在全局的执行上下文中查找

1
2
3
4
5
6
const obj = {
name: "one",
getName() {
console.log(name);
},
};
全局执行上下文中的 this

this 和作用域链的唯一交点,作用域链的最底端包含了 window 对象,全局执行上下文中的 this 也是指向 window 对象。

这种设计很容易误操作,所以严格模式下默认执行一个函数 this 为 undefined

函数执行上下文中的 this

直接调用一个函数,其执行上下文中的 this 也是指向 window 对象的. 但是可以通过 call apply bind 修改 this 的指向

通过对象调用方法 this

this 是指向对象本身的。 也可以理解为在调用的时候转化为了这样的形式 myObj.showThis.call(myObj)

构造函数中的 this

this 指向创建的实例. new 操作符实际上做了一下几件事.

  • 创建一个空的简单 JavaScript 对象(即{});
  • 为步骤 1 新创建的对象添加属性__proto__,将该属性链接至构造函数的原型对象 ;
  • 将步骤 1 新创建的对象作为 this 的上下文 ;
  • 如果该函数没有返回对象,则返回 this。
  • Copyrights © 2015-2026 SunZhiqi

此时无声胜有声!

支付宝
微信