Will Xiao

分享是最好的学习

命令模式实现 undo & redo

前端 undo & redo 功能是非常常见的,通常会使用命令模式来实现。

下面以一个低代码编辑器的例子,来介绍 JavaScript 是如何使用命令模式来实现 undo & redo 功能的。

首先,我们来看一下命令模式的结构示意图。

alt text

在命令模式中,关键是定义了一个 Command 接口,它有 execute 和 undo 两个方法,具体的命令类都需要实现这两个方法。调用者(Invoker)在调用命令的时候,只需要执行命令对象的 execute 和 undo 方法即可,而不用关心这两个方法具体做了什么。实际上这两方法的具体实现,通常都是在接收者(Receiver)中,命令类中通常有一个接收者实例,命令类只需要调用接收者实例方法即可。

OK,我们来看一下,我们的低代码编辑器的状态库(简化版的)。它是使用 zustand 定义的,它有一个组件列表 componentList,以及相关的3个方法。

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
import { createStore } from "zustand/vanilla";

const store = createStore((set) => ({
componentList: [], // 组件列表
// 添加组件
addComponent: (comp) =>
set((state) => ({ componentList: [...state.componentList, comp] })),
// 删除组件
removeComponent: (comp) =>
set((state) => ({
componentList: state.componentList.filter((v) => v.id !== comp.id),
})),
// 更新组件属性
updateComponentProps: (comp, newProps) =>
set((state) => {
const index = state.componentList.findIndex((v) => v.id === comp.id);
if (index > -1) {
const list = [...state.componentList];
return {
componentList: [
...list.slice(0, index),
{ ...comp, props: newProps },
...list.slice(index + 1),
],
};
}
}),
}));
// const { getState, setState, subscribe, getInitialState } = store;

export default 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
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
// 命令基类
class Command {
constructor() {}

execute() {
throw new Error("未重写 execute 方法!");
}

undo() {
throw new Error("未重写 undo 方法!");
}
}

export class AddComponentCommand extends Command {
editorStore; // 状态库(它充当 Receiver)
comp;

constructor(editorStore, comp) {
super();
this.editorStore = editorStore;
this.comp = comp;
}

execute(comp) {
this.editorStore.getState().addComponent(this.comp);
}

undo() {
this.editorStore.getState().removeComponent(this.comp);
}
}

export class RemoveComponentCommand extends Command {
editorStore;
comp;

constructor(editorStore, comp) {
super();
this.editorStore = editorStore;
this.comp = comp;
}

execute() {
this.editorStore.getState().removeComponent(this.comp);
}

undo() {
this.editorStore.getState().addComponent(this.comp);
}
}

export class UpdateComponentPropsCommand extends Command {
editorStore;
comp;
newProps;
prevProps; // 保存之前的属性

constructor(editorStore, comp, newProps) {
super();
this.editorStore = editorStore;
this.comp = comp;
this.newProps = newProps;
}

execute() {
const { updateComponentProps, componentList } = this.editorStore.getState();
this.prevProps = componentList.find((v) => v.id === this.comp.id)?.props;
updateComponentProps(this.comp, this.newProps);
}

undo() {
const { updateComponentProps } = this.editorStore.getState();
updateComponentProps(this.comp, this.prevProps);
}
}

我们实现了 AddComponentCommand、RemoveComponentCommand 和 UpdateComponentPropsCommand 3个命令类,在我们的命令类中都有一个 editorStore 属性,它在这里充当了 Receiver 接收者,因为编辑器相关操作我们都定义在状态库中。

其中 AddComponentCommand 和 RemoveComponentCommand 相对比较简单,有直接的操作可以实现撤销。UpdateComponentPropsCommand 就稍微复杂一点,我们更新了属性之后,没有一个直接的操作可以撤销修改,这种情况我们通常需要增加一个属性,记录修改之前的状态,用于实现撤销功能,在 UpdateComponentPropsCommand 中就是 prevProps。

到这里,我们的命令类都已经实现了,要实现 undo 和 redo 功能,通常我们还需要实现一个命令管理类,它需要实现 execute、undo 和 redo 三个方法。它的具体实现多种方法,我们这里使用两个栈(Stack)来实现,具体代码如下:

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
class CommandManager {
undoStack = []; // 撤销栈
redoStack = []; // 重做栈

execute(command) {
command.execute();
this.undoStack.push(command);
this.redoStack = [];
}

undo() {
const command = this.undoStack.pop();
if (command) {
command.undo();
this.redoStack.push(command);
}
}

redo() {
const command = this.redoStack.pop();
if (command) {
command.execute();
this.undoStack.push(command);
}
}
}

export default new CommandManager();

有了这些,接下来我们可以进入测试环节了,下面是我们的测试代码:

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
import store from "./store/editorStore";
import cmdManager from "./commands/cmdManager";

// 实时打印组件列表
store.subscribe((state) =>
console.log(JSON.stringify(state.componentList))
);

const comp1 = {
id: 101,
componentName: "Comp1",
props: {},
children: null,
};
const comp2 = {
id: 102,
componentName: "Comp2",
props: {},
children: null,
};

cmdManager.execute(new AddComponentCommand(store, comp1));
cmdManager.execute(new AddComponentCommand(store, comp2));
cmdManager.undo();
cmdManager.redo();

cmdManager.execute(new RemoveComponentCommand(store, comp1));
cmdManager.undo();

cmdManager.execute(
new UpdateComponentPropsCommand(store, comp1, { visible: true })
);
cmdManager.undo();

测试结果如下,说明我们的代码正常工作了。

1
2
3
4
5
6
7
8
// [{"id":101,"componentName":"Comp1","props":{},"children":null}]
// [{"id":101,"componentName":"Comp1","props":{},"children":null},{"id":102,"componentName":"Comp2","props":{},"children":null}]
// [{"id":101,"componentName":"Comp1","props":{},"children":null}]
// [{"id":101,"componentName":"Comp1","props":{},"children":null},{"id":102,"componentName":"Comp2","props":{},"children":null}]
// [{"id":102,"componentName":"Comp2","props":{},"children":null}]
// [{"id":102,"componentName":"Comp2","props":{},"children":null},{"id":101,"componentName":"Comp1","props":{},"children":null}]
// [{"id":102,"componentName":"Comp2","props":{},"children":null},{"id":101,"componentName":"Comp1","props":{"visible":true},"children":null}]
// [{"id":102,"componentName":"Comp2","props":{},"children":null},{"id":101,"componentName":"Comp1","props":{},"children":null}]

至此,我们已经完成了完整的第一个版本了。但是代码还有优化的空间,我们继续改进一下。

第一点,执行命令的地方,要手动 new 命令类,传入 store 状态库,有较多的模板代码。

1
2
3
4
cmdManager.execute(new AddComponentCommand(store, comp1));
cmdManager.execute(new AddComponentCommand(store, comp2));
cmdManager.undo();
cmdManager.redo();

我们可以参考 js 原生方法 document.execCommand 实现一个 executeCommand () 方法,这样执行命令就变成了 executeCommand(commandName, …args) 这样,更为方便。

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
import cmdManager from "./cmdManager";
import {
AddComponentCommand,
RemoveComponentCommand,
UpdateComponentPropsCommand,
} from "./index";
import store from "../store/editorStore";

const commondActions = {
addComponent(...args) {
const cmd = new AddComponentCommand(store, ...args);
cmdManager.execute(cmd);
},

removeComponent(...args) {
const cmd = new RemoveComponentCommand(store, ...args);
cmdManager.execute(cmd);
},

updateComponentProps(...args) {
const cmd = new UpdateComponentPropsCommand(store, ...args);
cmdManager.execute(cmd);
},

undo() {
cmdManager.undo();
},

redo() {
cmdManager.redo();
},
};

const executeCommand = (cmdName, ...args) => {
commondActions[cmdName](...args);
};

export default executeCommand;
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
store.subscribe((state) =>
console.log(JSON.stringify(state.componentList))
);

const comp1 = {
id: 101,
componentName: "Comp1",
props: {},
children: null,
};

const comp2 = {
id: 102,
componentName: "Comp2",
props: {},
children: null,
};

executeCommand("addComponent", comp1);
executeCommand("addComponent", comp2);
executeCommand("undo");
executeCommand("redo");

executeCommand("removeComponent", comp1);
executeCommand("undo");

executeCommand("updateComponentProps", comp1, { visible: true });
executeCommand("undo");

第二点,CommandManager 其实使用一个栈(Stack)加上指针也可以实现,我们参考了网上的代码(JavaScript command pattern for undo and redo),优化之后代码如下:

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
class CommandManager {
_commandsList = [];
_currentCommand = -1;

execute(command) {
command.execute();
this._currentCommand++;
this._commandsList[this._currentCommand] = command;
if (this._commandsList[this._currentCommand + 1]) {
this._commandsList.splice(this._currentCommand + 1);
}
}

undo() {
const command = this._commandsList[this._currentCommand];
if (command) {
command.undo();
this._currentCommand--;
}
}

redo() {
const command = this._commandsList[this._currentCommand + 1];
if (command) {
command.execute();
this._currentCommand++;
}
}
}

export default new CommandManager();

OK,这就是我们的第二个版本了。

参考资料:

《Head First 设计模式 - 命令模式》

javascript - 基于Web的svg编辑器(1)——撤销重做功能 - 个人文章 - SegmentFault 思否

JavaScript command pattern for undo and redo (s24.com)

UUID 全局唯一标识

UUID

UUID 通用唯一识别码(Universally Unique Identifier)是用于计算机体系中以识别信息的一个128位标识符。

UUID按照标准方法生成时,在实际应用中具有唯一性,且不依赖中央机构的注册和分配。UUID重复的概率接近零,可以忽略不计。

因此,UUID 的应用非常普遍,例如被用作分布式数据库表的主键,微软的GUID(Globals Unique Identifiers)等等。

在其规范的文本表示中,UUID 的 16 个 8 位字节表示为 32 个十六进制数字,由连字符 ‘-‘ 分隔成五组显示,形式为“8-4-4-4-12”总共 36 个字符(32 个十六进制数字和 4 个连字符)。例如:

1
2
123e4567-e89b-12d3-a456-426655440000
00000000-0000-0000-0000-000000000000

它有多个不同的版本,比较常用的有v1、v3、v4、v5:

版本1的UUID是根据时间和节点ID(通常是MAC地址)生成;

版本3、版本5透过对命名空间(namespace)标识符和名称进行散列生成确定性的UUID;

版本4的UUID则使用随机性或伪随机性生成。

Alt text

它的冲突概率非常小,以版本4的UUID为例,如果要有50%的几率至少发生一次冲突,需要生成至少 2.71E18 个UUID。计算如下:

Alt text

这个数字相当于每秒产生 10 亿个 UUID 持续 85 年。

现代的浏览器提供了 crypto.randomUUID() 方法,可以非常方便的生成一个版本4的UUID。

或者你也可以使用第三方库 uuid 来生成 v1、v3、v4、v5 版本的 UUID。

nanoid

nanoid 是 UUID 的有力竞争者,它同样可以生成唯一的标识字符串。

与 UUID 相比,它使用更大的字母表,这样一来它生成的字符串长度更短,只有21个字符,并且它的包体积只有UUID的1/4。nanoid 大有取代 UUID 的趋势。

Alt text

另外,nanoid 可以自定义字母表和ID长度,这给用户提供了更多灵活性。

1
2
3
import { customAlphabet } from 'nanoid'
const nanoid = customAlphabet('1234567890abcdef', 10)
model.id = nanoid() //=> "4f90d13a42"

SHA-1

SHA-1(英语:Secure Hash Algorithm 1,中文名:安全散列算法1)是一种密码散列函数,美国国家安全局设计,并由美国国家标准技术研究所(NIST)发布为联邦数据处理标准(FIPS)。SHA-1可以生成一个被称为消息摘要的160位(20字节)散列值,散列值通常的呈现形式为40个十六进制数。

SHA-1 一种密码散列函数,密码散列函数的特性是,对任意一组输入数据进行计算,得到一个固定长度的输出摘要。如果输入相同一定会得到相同的输出,如果输入不同大概率会得到不同的输出。

它的主要目的是验证原始数据是否被篡改。主要应用有文件校验和(checkSum)、密码文本加密存储等等。

它其实也可以用作全局唯一标识符,它的长度160位,冲突的概率也非常小。因为相同的输入一定会产生相同的输出,所以它作为唯一标识符,是跟输入内容相关的,非常适合分布式开发。它在 git 中就有大量应用,git 中的对象唯一标识都是使用它生成的。

electron-vue 开发经验总结

最近公司让我开发一个桌面报警器,以解决浏览器页面关闭无法播放报警声音的问题。

接到这个项目,自然的选择了 electron-vue 进行开发(我们公司使用的 vue)

现在有时间了,对项目中遇到的问题进行一个总结。

一、项目搭建 & 打包

项目搭建比较简单,直接使用 electron-vue 的官方模板就可以生成项目,需要安装 vue-cli 命令行工具。

1
2
3
4
npm install -g vue-cli // 需要安装 vue-cli 脚手架
vue init simulatedgreg/electron-vue project-name // 使用 electron-vue 官方模板生成项目
npm install // 安装依赖
npm run dev // 启动项目

项目打包也比较简单,可能也是因为我的项目本身不复杂吧。普通打包执行 npm run build 即可,如果要打包成免安装文件,执行 npm run build:dir,非常方便!

1
2
npm run build // 打包成可执行文件
npm run build:dir // 打包成免安装文件

二、状态管理

因为 electron 每个网页都在自己的渲染进程(renderer process)中运行,所以如果要在多个渲染进程间共享状态,就不能直接使用 vuex 了。

vuex-electron 这个开源库为我们提供了,在多个进程间共享状态的方案(包括主进程)。

如果需要在多个进程间共享状态,需要使用 createSharedMutations 中间件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// store.js 文件
import Vue from "vue"
import Vuex from "vuex"

import { createPersistedState, createSharedMutations } from "vuex-electron"

Vue.use(Vuex)

export default new Vuex.Store({
// ...
plugins: [
createPersistedState(),
createSharedMutations() // 用于多个进程共享状态,包括主进程
],
// ...
})

并在主进程中引入 store 文件。这里有点坑,最开始的时候我不知道要在 main.js 中引入 store 文件,结果状态一直无法更新,又没有任何报错,调试了一下午😓

1
2
// main.js 文件
import './path/to/your/store' // 需要在主进程引入 store ,否则状态无法更新

另外,使用 createSharedMutations 中间件,必须使用 dispatch 或 mapActions 更新状态,不能使用 commit 。

阅读 vuex-electron 的源代码,发现渲染进程对 dispatch 进行了重写,dispatch 只是通知主进程,而不实际更新 store,主进程收到 action 之后,立即更新自己的 store,主进程 store 更新成功之后,会通知所有的渲染进程,这个时候渲染进程才调用 originalCommit 更新自己的 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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
rendererProcessLogic() {
// Connect renderer to main process
this.connect()

// Save original Vuex methods
this.store.originalCommit = this.store.commit
this.store.originalDispatch = this.store.dispatch

// Don't use commit in renderer outside of actions
this.store.commit = () => {
throw new Error(`[Vuex Electron] Please, don't use direct commit's, use dispatch instead of this.`)
}

// Forward dispatch to main process
this.store.dispatch = (type, payload) => {
// 只是通知主进程,没有更新 store
this.notifyMain({ type, payload })
}

// Subscribe on changes from main process and apply them
this.onNotifyRenderers((event, { type, payload }) => {
// 渲染进程真正更新自己的 store
this.store.originalCommit(type, payload)
})
}

// ... 省略其他代码

mainProcessLogic() {
const connections = {}

// Save new connection
this.onConnect((event) => {
const win = event.sender
const winId = win.id

connections[winId] = win

// Remove connection when window is closed
win.on("destroyed", () => {
delete connections[winId]
})
})

// Subscribe on changes from renderer processes
this.onNotifyMain((event, { type, payload }) => {
// 主进程更新了自己的 store
this.store.dispatch(type, payload)
})

// Subscribe on changes from Vuex store
this.store.subscribe((mutation) => {
const { type, payload } = mutation

// 主进程更新成功之后,通知所有渲染进程
this.notifyRenderers(connections, { type, payload })
})
}

注意,渲染进程真正更新 store 用的 originalCommit 方法,而不是 originalDispatch 方法,其实 originalDispatch 只是个代理,每一个 mutations 都需要写一个同名的 actions 方法,接收相同的参数,如下面的官方样例:

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
import Vue from "vue"
import Vuex from "vuex"

import { createPersistedState, createSharedMutations } from "vuex-electron"

Vue.use(Vuex)

export default new Vuex.Store({
state: {
count: 0
},

actions: {
increment(store) {
// 按照推理,这里的 commit 其实不起作用,不是必须
// 关键是名称相同
store.commit("increment")
},
decrement(store) {
store.commit("decrement")
}
},

mutations: {
increment(state) {
state.count++
},
decrement(state) {
state.count--
}
},

plugins: [createPersistedState(), createSharedMutations()],
strict: process.env.NODE_ENV !== "production"
})

事实上,如果应用很简单,比如我的项目只有一个窗口,就不存在共享状态的问题,所以完全可以不用 createSharedMutations 中间件,也不用在 main.js 中引入 store 文件,store 所有用法就跟 vuex 一样了。

三、日志

日志我采用的是 electron-log,也可以用 log4js

在主进程中使用 electron-log 很简单,直接引入,调用 info 等方法即可。
electron-log 提供了 error, warn, info, verbose, debug, silly 六种级别的日志,默认都是开启。

1
2
3
4
import log from 'electron-log';

log.info('client 启动成功');
log.error('主进程出错');

在渲染进程使用 electron-log,可以覆盖 console.log 等方法,这样就不用到处引入 electron-log 了,需要写日志的地方直接使用 console.log 等方法即可。

1
2
3
4
5
6
7
8
9
10
11
import log from 'electron-log';

// 覆盖 console 的 log、error、debug 三个方法
console.log = log.log;
Object.assign(console, {
error: log.error,
debug: log.debug,
});

// 之后,就可以直接使用 console 收集日志
console.error('渲染进程出错')

electron-log 默认会打印到 console 控制台,并写入到本地文件,本地文件路径如下:

  • on Linux: ~/.config/{app name}/logs/{process type}.log
  • on macOS: ~/Library/Logs/{app name}/{process type}.log
  • on Windows: %USERPROFILE%\AppData\Roaming{app name}\logs{process type}.log

如果使用 log4js 的话,配置相对复杂一点,需要注意的是文件不能直接写到当前目录,而是要使用 app.getPath(‘logs’) 获取应用程序日志文件夹路径,否则打包之后无法生成日志文件。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import log4js from 'log4js'

// 注意:这里必须使用 app.getPath('logs') 获取日志文件夹路径
log4js.configure({
appenders: { cheese: { type: 'file', filename: app.getPath('logs') + '/cheese.log' } },
categories: { default: { appenders: ['cheese'], level: 'error' } }
})

const logger = log4js.getLogger('cheese')
logger.trace('Entering cheese testing')
logger.debug('Got cheese.')
logger.info('Cheese is Comté.')
logger.warn('Cheese is quite smelly.')
logger.error('Cheese is too ripe!')
logger.fatal('Cheese was breeding ground for listeria.')

四、其他问题

1.修改系统托盘图标,下面代码参考了:https://juejin.im/post/6844903872905871373

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
let tray;
function createTray() {
const iconUrl = path.join(__static, '/app-icon.png');
const appIcon = nativeImage.createFromPath(iconUrl);
tray = new Tray(appIcon);

const contextMenu = Menu.buildFromTemplate([
{
label: '显示主界面',
click: () => {
if (mainWindow) {
mainWindow.show();
}
},
},
{ label: '退出程序', role: 'quit' },
]);

const appName = app.getName();
tray.setToolTip(appName);
tray.setContextMenu(contextMenu);

let timer;
let count = 0;
ipcMain.on('newMessage', () => {
// 图标闪烁
timer = setInterval(() => {
count += 1;
if (count % 2 === 0) {
tray.setImage(appIcon);
} else {
// 创建一个空的 nativeImage 实例
tray.setImage(nativeImage.createEmpty());
}
}, 500);
tray.setToolTip('您有一条新消息');
});

tray.on('click', () => {
if (mainWindow) {
mainWindow.show();
if (timer) {
clearInterval(timer);
tray.setImage(appIcon);
tray.setToolTip(appName);
timer = undefined;
count = 0;
}
}
});
}

2.播放声音

1
2
3
audio = new Audio('static/alarm.wav');
audio.play(); // 开始播放
audio.pause(); // 暂停

3.显示通知消息

1
2
3
4
5
6
7
8
9
10
11
12
const notify = new Notification('标题', {
tag: '唯一标识', // 相同 tag 只会显示一个通知
body: '描述信息',
icon: '图标地址',
requireInteraction: true, // 要求用户有交互才关闭(实测无效)
data, // 其他数据
});

// 通知消息被点击事件
notify.onclick = () => {
console.log(notify.data)
};

4.隐藏顶部菜单栏

1
2
3
4
import { Menu } from 'electron'

// 隐藏顶部菜单
Menu.setApplicationMenu(null);

五、参考资料

(完)

Vue 与 React 的区别

Vue 与 React 有什么区别?

这是前端开发同学面试时经常遇到的问题。

我最开始接触的是 React,对 Vue 的理解一直比较片面,感觉 Vue 要学很多 html 指令,很不习惯,也没觉得 Vue 比 React 有什么优势。

直到现在,使用了 Vue 一年之后,对 Vue 有了更多感受,也消除了一些刻板印象。

首先,这两个框架都是非常优秀的,它们其实非常相似,都有以下特性:

  1. 响应式(Reactive)。两个框架都是一种类似 VM 的架构,将状态从视图层分离出来,开发者只需要关注业务逻辑,不需要直接操作 DOM 。当应用发生改变时,我们只需要更新状态即可,框架会自动帮我们重新渲染页面。
  2. 组件化(Composable)。一个页面,可以拆分成一棵嵌套的组件树,我们只需要开发一个个组件即可,同一个组件可以在多个地方使用,这样就提升了代码的复用性和可维护性。
  3. Virtual DOM。框架在操作真实 DOM 之前,会先在内存中生成虚拟 DOM,最后再批量操作真实 DOM,以提高性能。

至于它们的区别,我个人理解,最大的有以下三点:

  1. 响应式原理不同;
  2. Vue 推荐使用模版的方式定义组件,React 推荐使用 JSX;
  3. React 推荐使用不可变的数据;

当然它们肯定还有其他区别,比如代码实现、状态管理等,但上面这三点是它们比较大的区别,是框架有意为之的,对日常的开发和理解影响也是比较大的。

一、响应式原理不同

Vue 使用观察者模式自动跟踪数据的变化,自动更新组件。

Vue 会遍历 data 数据对象,使用 Object.defineProperty() 将每个属性都转换为 getter/setter。每个 Vue 组件实例都有一个对应的 watcher 实例,在组件初次渲染(render)时,会记录组件用到了(调用 getter)哪些数据。当数据发生改变时,会触发 setter 方法,并通知所有依赖这个数据的 watcher 实例,然后 watcher 实例调用对应组件的 render 方法,生成一颗新的 vdom 树,Vue 会将新生成的 vdom 树与上一次生成的 vdom 树进行比较(diff),来决定具体要更新哪些 dom。


React 必须显式调用 setState() 方法更新状态,状态更新之后,组件也会重新渲染。

Vue 和 React 在状态更新之后,都会生成一颗新的虚拟 dom 树,与上一颗虚拟 dom 树进行比较(diff),找出其中的差异,再更新真实 dom。这个虚拟 dom diff 算法,Vue 与 React 差异其实并不大,基本思想是差不多的。大家可以看看网上的文章,我这里就不展开了。

二、Vue 推荐使用 template 定义组件,React 推荐使用 JSX

Vue 推荐使用 template 的方式定义组件,因为这样更接近原生 html,可以在不破坏原有 html 代码的基础上引入 Vue 的能力。Vue 的组件也参考了一些 Web Component 的规范,Vue 的组件可以很容易打包成 Web Component。

React 推荐使用 JSX,JSX 是使用 JS 的语法来编写 html 代码,所以一些流程控制,数据绑定也会更加方便。也不需要再学一套模板语法。

事实上 Vue 也提供了 JSX 的支持,不过 Vue 更推荐 template 的方式。

三、React 推荐使用不可变的数据

这一点对于从 Vue 转换到 React 的同学,需要特别注意。

所谓不可变的数据,就是当我们要改变一个数据对象时,不要直接修改原数据对象,而是返回一个新的数据对象。比如使用 Object.assign() 方法修改数据属性:

1
2
3
4
5
6
const data = {
fontSize: 14,
color: "black"
};

const newData = Object.assign({}, data, { color: "blue" });

之所以推荐使用不可变的数据,一个原因是使用不可变的数据,可以更容易的实现“时间旅行”功能。但是更重要的一个原因是可以更容易的实现 pure component。

当一个组件的状态发生改变时,React 会重新调用 render() 方法,比较生成的 VDOM 的差别。如果一个子组件的 proos 和 state 都没有改变,React 仍然需要进行一次对比,这个情况就有点儿浪费了。所以 React 提供了 shouldComponentUpdate() 生命周期函数,允许开发者判断什么时候应该更新组件,比如当组件的 props 和 state 都没有改变的时候,shouldComponentUpdate 就可以返回 false,那么 React 就不会再去比较 VDOM 的差异了。

React.PureComponent 类,实现了 shouldComponentUpdate 方法,会对 props 和 state 进行浅比较,如果没有变化,就返回 false 跳过组件更新。但是它只进行浅比较,所以如果直接修改了 props 或 state 的属性,shouldComponentUpdate 方法还是返回 false,就漏掉了这次更新。所以这种情况下,推荐使用不可变的数据。

更多信息请看官方文档:为什么不可变性在 React 中非常重要

Proudly powered by Hexo and Theme by Hacker
© 2024 Will Xiao