从零开发一款ChatGPT VSCode插件

引言

OpenAI发布了ChatGPT,就像是给平静许久的互联网湖面上扔了一颗重磅炸弹,刹那间所有人都在追捧学习它。究其原因,它其实是一款真正意义上的人工智能对话机器人。它使用了深度学习技术,通过大量的训练数据和自监督学习方法进行训练,以模拟人类的对话能力和生成自然语言回应。日常生产、学习中利用好ChatGPT这个工具,是绝对能够提升我们工作效率的,这一点对于我们程序员来说,感受应该尤为明显。我们最常用的开发工具VSCode,已经有许多的插件集成了ChatGPT功能,这篇文章将从零开始,介绍这些插件的实现原理与思路,希望对你有所帮助。

基本需求

实现一款可以跟ChatGPT对话的插件,可以通过一问一答的形式来进行对话,并且可以将我们选中的代码发送给ChatGPT,让其可以对代码进行优化。当然如果要访问ChatGPT,首先需要绑定我们在OpenAI后台申请的ApiKey.

VSCode 插件基本配置

首先简单介绍一下VSCode插件开发的基本流程

  1. 安装脚手架
1
npm install -g yo generator-code

然后cd到你的工作目录,运行yo code,根据向导一步步选择即可,没啥好说的,运行完后就生成了一个干净的可以运行的插件工程了。
2. 工程目录介绍
pic
查看当前目录,工程的核心是package.jsonextension.js.首先看下package.json的配置文件:

  • name:工程名称

  • displayName: 应用市场名称

  • description: 应用描述

  • version: 当前插件版本

  • engines: 表示插件最低支持的vscode版本

  • categories: 插件应用市场分类

  • main: 程序的主入口文件

  • activationEvents:重要,扩展的激活事件数组,表示可以被哪些事件激活当前插件。比如:

1
2
3
4
5
6
7
8
9
"activationEvents": [
"onView:chatgpt-for-vscode.view",
"onCommand:chatgpt-for-vscode.setAPIKey",
"onCommand:chatgpt-for-vscode.askGPT",
"onCommand:chatgpt-for-vscode.whyBroken",
"onCommand:chatgpt-for-vscode.optimizeCode",
"onCommand:chatgpt-for-vscode.explainCode",
"onCommand:chatgpt-for-vscode.refactor"
],

onView:表示 通过视图触发,chatgpt-for-vscode.view是视图Id。当触发这个视图时,唤起当前插件
onCommand: 表示通过命令触发,后面是命令Id,这些都是我们自定义的命令。在VSCode中按下快捷键:Command + Shift + P 输入命令title后唤起插件,命令titlecontributes,commands模块里面定义,后面介绍。
除了这两个还有:onLanguageonUrionDebugworkspaceContainsonFileSystem等,如果设置为*,只要一启动VSCode,插件就会被激活,当然为了用户体验,官方不推荐这么做。

  • contributes: 重要,配置插件的主要功能点。比如:
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
"contributes": {
"commands": [
{
"command": "chatgpt-for-vscode.setAPIKey",
"title": "GPT:绑定APIKey"
},
{
"command": "chatgpt-for-vscode.askGPT",
"title": "GPT:询问 GPT"
},
{
"command": "chatgpt-for-vscode.whyBroken",
"title": "GPT:说明这段代码存在的问题"
},
{
"command": "chatgpt-for-vscode.optimizeCode",
"title": "GPT:优化这段代码"
},
{
"command": "chatgpt-for-vscode.explainCode",
"title": "GPT:解释这段代码"
},
{
"command": "chatgpt-for-vscode.refactor",
"title": "GPT:重构这段代码"
}
],
"menus": {
"editor/context": [
{
"command": "chatgpt-for-vscode.askGPT",
"group": "navigation@1"
},
{
"command": "chatgpt-for-vscode.whyBroken",
"group": "navigation@2"
},
{
"command": "chatgpt-for-vscode.optimizeCode",
"group": "navigation@3"
},
{
"command": "chatgpt-for-vscode.explainCode",
"group": "navigation@4"
},
{
"command": "chatgpt-for-vscode.refactor",
"group": "navigation@5"
},
{
"command": "chatgpt-for-vscode.setAPIKey",
"group": "navigation@6"
}
]
},
"viewsContainers": {
"activitybar": [
{
"id": "chatgpt-for-vscode",
"title": "ChatGPT",
"icon": "images/ChatGPT.png"
}
]
},
"views": {
"chatgpt-for-vscode": [
{
"type": "webview",
"id": "chatgpt-for-vscode.view",
"name": "ChatGPT"
}
]
}
},
  • commands: command: 命令Id,这个命令Id跟activationEvents中配置的命令Id相同。title:输入的命令的名称。Command + Shift + P 输入这个命令title后找到对应的命令。
    pic

  • menus: editor/context:配置编辑器右键展示内容。command是命令Id,group:右键后展示看板的命令位置。这里navigation表示展示在模块的顶部。@*表示排序。
    pic

  • viewsContainers: activitybar:配置右侧工具栏视图入口,配置后展示,注意这里的id,要跟后面的
    views模块里面的视图key值保持一致,表示点击右侧icon后展示那个视图,icon是你本地的图片路径。
    pic

  • views: 配置视图,这里使用webview展示自定义视图

  1. 配置完成package.json后右键命令展示,左侧状态栏Icon,顶部命令行选择输入命令,已经可以展示了。运行npm run test 后会打开默认安装你插件的VSCode面板,接下来就是完善触发命令后的代码逻辑了,核心在extension.ts中实现。

extension.ts模块开发

extension.ts 是程序的入口文件,里面有两个核心方法:

1
2
export function activate(context: vscode.ExtensionContext) {}
export function deactivate() {}

看字面意思很好理解,分别表示插件被激活与释放调用的生命周期方法.

1. 绑定APIKey命令逻辑

要想使用OpenAI的api,首先需要将自己的ApiKey与插件进行关联。这里使用VSCode自有APIvscode.window.showInputBox来获取用户输入.

1
2
3
4
5
6
7
8
9
this.apiKey = await this.context.globalState.get('chatgpt-api-key');
if (!this.apiKey) {
const apiKeyInput = await vscode.window.showInputBox({
prompt: "请输入你的API Key",
ignoreFocusOut: true,
});
this.apiKey = apiKeyInput;
this.context.globalState.update('chatgpt-api-key', this.apiKey);
}
  • 使用上下文的globalState来持久化保存ApiKey
  • 如果要让这个命令生效,需要在activate中进行注册
    1
    2
    3
    4
    5
    6
    7
    8
    context.subscriptions.push(vscode.commands.registerCommand('chatgpt-for-vscode.setAPIKey', resetToken))

    async function resetToken() {
    await vscode.window.showInputBox({
    prompt: "请输入OpenAI API Key",
    ignoreFocusOut: true,
    });
    }

    执行command + shift + p 输入命令titleGPT:绑定APIKey后,展示效果如下:截屏2023-08-21 16.33.01
    这样就完成了对用户ApiKey的绑定逻辑.

2. 命令触发逻辑

与绑定用户ApiKey类似,其他命令的执行也是同样的流程,这里以onCommand:chatgpt-for-vscode.askGPT命令来说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 注册命令
vscode.commands.registerCommand('chatgpt-for-vscode.askGPT', askChatGPT)
// 命令实现
async function askChatGPT(userInput: string) {
let editor = vscode.window.activeTextEditor;
if (editor) {
const selectedCode = editor.document.getText(vscode.window.activeTextEditor?.selection);
if(selectedCode.length) {
chatViewProvider.sendOpenAiApiRequest(userInput, selectedCode);
vscode.window.showInformationMessage(selectedCode);
}else {
vscode.window.showInformationMessage(`请选中一段代码`);
}
}
}
  • 注册命令后 使用editor.document.getText(vscode.window.activeTextEditor?.selection)来获取选中的代码段落,并判空.
  • 利用chatViewProvider.sendOpenAiApiRequest(userInput, selectedCode);利用这个方法用户输入的Prompt
    与选中的代码端传递出去,这个方法的实现后面介绍,注册所有的命令后,activate方法是这样的
    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
    export function activate(context: vscode.ExtensionContext) {

    const chatViewProvider = new view_provider.default(context);

    context.subscriptions.push(
    vscode.commands.registerCommand('chatgpt-for-vscode.askGPT', askChatGPT),
    vscode.commands.registerCommand('chatgpt-for-vscode.whyBroken', askGPTWhyBroken),
    vscode.commands.registerCommand('chatgpt-for-vscode.explainCode', askGPTToExplain),
    vscode.commands.registerCommand('chatgpt-for-vscode.refactor', askGPTToRefactor),
    vscode.commands.registerCommand('chatgpt-for-vscode.optimizeCode', askGPTToOptimize),
    vscode.commands.registerCommand('chatgpt-for-vscode.setAPIKey', resetToken),
    vscode.window.registerWebviewViewProvider("chatgpt-for-vscode.view", chatViewProvider, {
    webviewOptions: { retainContextWhenHidden: true }})
    );
    async function askGPTWhyBroken() { await askChatGPT('说明下面的代码会出现什么问题?'); }
    async function askGPTToExplain() { await askChatGPT('请帮我解释一下下面的代码?'); }
    async function askGPTToRefactor() { await askChatGPT('帮我重构下面的代码'); }
    async function askGPTToOptimize() { await askChatGPT('帮我优化下面的代码'); }
    async function resetToken() {
    await chatViewProvider.ensureApiKey();
    }

    async function askChatGPT(userInput: string) {

    let editor = vscode.window.activeTextEditor;
    if (editor) {
    const selectedCode = editor.document.getText(vscode.window.activeTextEditor?.selection);
    if(selectedCode.length) {
    chatViewProvider.sendOpenAiApiRequest(userInput, selectedCode);
    vscode.window.showInformationMessage(selectedCode);
    }else {
    vscode.window.showInformationMessage(`请选中一段代码`);
    }
    }
    }
    }
3.webView与chatViewProvider

上面的代码除了注册命令的APIregisterCommand,还有一个注册自定义webview视图的API,registerWebviewViewProvider,作用是展示我们自定义的webview,它有三个参数:

  • chatgpt-for-vscode.view是视图Id,跟package.jsonviews模块对应的Id相同,表示为那个视图Id注册provider.
  • chatViewProvider 视图提供者.
  • 第三个参数:webview的属性配置,retainContextWhenHidden: true表示:webview被隐藏时保持状态,避免被重置.
    接下来重点来看chatViewProvider:
    作为自定义视图的provider首先需要继承vscode.WebviewViewProvider这个接口
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    export interface WebviewViewProvider {
    /**
    * Revolves a webview view.
    *
    * `resolveWebviewView` is called when a view first becomes visible. This may happen when the view is
    * first loaded or when the user hides and then shows a view again.
    *
    * @param webviewView Webview view to restore. The provider should take ownership of this view. The
    * provider must set the webview's `.html` and hook up all webview events it is interested in.
    * @param context Additional metadata about the view being resolved.
    * @param token Cancellation token indicating that the view being provided is no longer needed.
    *
    * @return Optional thenable indicating that the view has been fully resolved.
    */
    resolveWebviewView(webviewView: WebviewView, context: WebviewViewResolveContext, token: CancellationToken): Thenable<void> | void;
    }
    这个接口只有一个方法,resolveWebviewView在视图首次可见时被调用。这可能发生在视图第一次加载时,或者当用户隐藏然后再次显示视图时。在这个方面里面设置webviewhtml与视图属性。
    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
    export default class ChatGptViewProvider implements vscode.WebviewViewProvider {
    private webView?: vscode.WebviewView;
    private apiKey?: string;
    private message?: any;

    constructor(private context: vscode.ExtensionContext) { }

    public resolveWebviewView(
    webviewView: vscode.WebviewView,
    _context: vscode.WebviewViewResolveContext,
    _token: vscode.CancellationToken,
    ) {
    this.webView = webviewView;
    // webview属性设置
    webviewView.webview.options = {
    enableScripts: true,
    localResourceRoots: [this.context.extensionUri]
    };
    // 返回Html代码
    webviewView.webview.html = this.getHtml(webviewView.webview);
    // 接收
    webviewView.webview.onDidReceiveMessage(data => {
    if (data.type === 'askChatGPT') {
    this.sendOpenAiApiRequest(data.value);
    }
    });

    if (this.message !== null) {
    this.sendMessageToWebView(this.message);
    this.message = null;
    }
    }
    }
4. 通信机制

自定义的webview和普通网页非常类似,都不能直接调用任何VSCodeAPI,但是,它唯一特别之处就在于多了一个名叫acquireVsCodeApi的方法,执行这个方法会返回一个超级阉割版的vscode对象.利用这个对象,可以实现webview与插件也就是provider的通信。

  • providerwebview发送消息:
    1
    this.webView?.webview.postMessage(message);
  • webview端接收消息:
    1
    2
    3
    4
    window.addEventListener('message', event => {
    const message = event.data;
    console.log('Webview接收到的消息:', message);
    }
  • webview主动发送消息给provider
    1
    2
    const vscode = acquireVsCodeApi();
    vscode.postMessage({text: '你好,我是Webview啊!'});
  • provider接收消息:
    1
    2
    3
    4
    5
    this.webView?.webview.onDidReceiveMessage(data => {
    if (data.type === 'askChatGPT') {
    this.sendOpenAiApiRequest(data.value);
    }
    });
    了解完双方的通信机制后,基本逻辑是:当点击webview上的发送按钮后,将用户输入发送给ChatGPTChatGPT处理完成后将返回信息发送给webviewwebview将回答信息展示出来,完成一次对话逻辑。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 按钮绑定点击事件
    document.getElementById("ask-button")?.addEventListener("click", submitHandler);
    let submitHandler = function (e) {
    e.preventDefault();
    e.stopPropagation();
    const input = document.getElementById("question-input");
    if (input.value?.length > 0) {
    // 发送消息给 插件,使其完成ChatGPT请求
    vscode.postMessage({
    type: "askChatGPT",
    value: input.value,
    });

    input.value = "";
    }
    };
5. 调用OPenAI接口

要想完成一次对话,需要调用OPenAI的API.具体的API你可以在官网找到:
pic

  • 参数model是你要对话的ChatGPT模型代码,不同模型针对同一个问题的答案会有所区别。具体模块区别可以参考下面图片:
    pic1
    更多模型可以点击这里去查看

  • 参数messages: 你的问题信息

  • 参数temperature: 它是一个用于控制生成文本的创造性的参数,其值介于0到2之间。值为1意味着模型将使用其默认采样策略,而值低于1.0将导致更保守和可预测的响应,值大于1.0将导致更有创造性和多样化的响应。

  • 参数max_tokens: 生成对话的最大token数量。这里的token可以理解为模型的构建块。了解完成上面的参数,可以利用fetch发起请求了:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
     let completion =  await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    body: JSON.stringify({
    model: "text-davinci-003",
    messages: [{ role: "user", content: question }],
    temperature: 0.7
    }),
    headers: {
    // eslint-disable-next-line @typescript-eslint/naming-convention
    "Content-Type": 'application/json',
    Authorization: 'Bearer ' + this.apiKey,
    },
    }) as any;

    根据返回的数据结构,解析响应数据,并将结果发送给webview进行展示,完成开发。
    pic

发布插件

  • 扩展安装
    通过以上步骤基本完成了插件的开发,接下来有两种方式发布我们的插件,如果你的插件只是在内网使用,可以通过命令:vsce package, 将插件打包为vsix插件包,通过VSCode的扩展,从VSIX安装.
    当然首先要安装vsce这个工具

    1
    npm i vsce -g

    pic

  • 上传到应用VSCode插件市场

    插件上传到VSCode应用市场,需要有应用市场的publisher账号,具体的账号创建流程这里不再涉及,创建账号后,登录当前账号,执行vsce publish,发布成功后大概需要过几分钟才能在应用市场搜到.发布账号有几个注意事项:

  • README.md文件默认会显示在插件主页;

  • README.md中的资源必须全部是HTTPS的,如果是HTTP会发布失败;

  • CHANGELOG.md会显示在变更选项卡;

  • 如果代码是放在git仓库并且设置了repository字段,发布前必须先提交git,否则会提示Git working directory not clean

  • 发布后需要等待几分钟应用市场才会更新;

  • 当然你可以在插件市场里面搜索chatgpt-for-vscode 来试用这个插件;

    pic

总结

以上就是一个ChatGPT插件的基本创建流程,核心是对VSCode API以及ChatGPT API的了解与使用。当然你所需要的功能都可以在对应的官方文档中找到。

参考文献:

https://code.visualstudio.com/api/extension-guides/overview
https://platform.openai.com/docs/api-reference/chat/create
http://blog.haoji.me/vscode-plugin-publish.html

浅谈Vue3响应式原理与源码解读

一. 了解几个概念

什么是响应式

在开始响应式原理与源码解析之前,需要先了解一下什么是响应式?首先明确一个概念:响应式是一个过程,它有两个参与方:

  • 触发方:数据
  • 响应方:引用数据的函数

当数据发生改变时,引用数据的函数会自动重新执行,例如,视图渲染中使用了数据,数据改变后,视图也会自动更新,这就完成了一个响应的过程。

副作用函数

VueReact中都有副作用函数的概念,什么是副作用函数?如果一个函数引用了外部的数据,这个函数会受到外部数据改变的影响,我们就说这个函数存在副作用,也就是我们所说的副作用函数。初听这个名字不太好理解,其实 副作用函数就是引用了数据的函数或是与数据相关联的函数。举个例子:

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
<!DOCTYPE html>
<html lang="">

<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>

<body>
<div id="app"></div>
<script>
const obj = {
name: 'John',
}
// 副作用函数 effect
function effect() {
app.innerHTML = obj.name
console.log('effect', obj.name)
}

effect()

setTimeout(() => {
obj.name = 'ming'
// 手动执行 effect 函数
effect()
}, 1000);
</script>
</body>
</html>

在上面例子中,effect函数里面引用了外部的数据obj.name,如果这个数据发生了改变,则会影响到这个函数,类似effect的这种函数就是副作用函数。

实现响应式的基本步骤

在上面的例子中,当obj.name发生了改变,effect是我们手动执行的,如果能监听到obj.name的变化,让其自动执行副作用函数effect,那么就实现了响应式的过程。其实无论是 Vue2 还是 Vue3 ,响应式的核心都是 数据劫持/代理、依赖收集、依赖更新,只不过由于实现数据劫持方式的差异从而导致具体实现的差异。

  • Vue2响应式:基于Object.defineProperty()实现的数据的劫持

  • Vue3响应式:基于Proxy实现对整个对象的代理

关于Vue2的响应式这里不做重点讲解,这篇文章主要关注Vue3响应式原理的实现。

二. Proxy 与 Reflect

在解析Vue3的响应式原理之前,首先需要了解两个ES6新增的API:PorxyReflect

Proxy

Proxy: 代理,顾名思义主要用于为对象创建一个代理,从而实现对对象基本操作的拦截和自定义。可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。基本语法:

1
let proxy = new Proxy(target, handler);
  • target: 需要拦截的目标对象

  • handler: 也是一个对象,用来定制拦截行为
    举个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const obj = {
    name: 'John',
    age: 16
    }

    const objProxy = new Proxy(obj,{})
    objProxy.age = 20
    console.log('obj.age',obj.age);
    console.log('objProxy.age',objProxy.age);
    console.log('obj与objProxy是否相等',obj === objProxy);
    // 输出
    [Log] obj.age20
    [Log] objProxy.age20
    [Log] obj与objProxy是否相等 – false

    这里objProxyhandler为空,则直接指向被代理对象,并且代理对象与数据源对象并不全等.如果需要更加灵活的拦截对象的操作,就需要在handler中添加对应的属性。例如:

    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 obj = {
    name: 'John',
    age: 16
    }

    const handler = {
    get(target, key, receiver) {
    console.log(`获取对象属性${key}值`)
    return target[key]
    },
    set(target, key, value, receiver) {
    console.log(`设置对象属性${key}值`)
    target[key] = value
    },
    deleteProperty(target, key) {
    console.log(`删除对象属性${key}值`)
    return delete target[key]
    },
    }

    const proxy = new Proxy(obj, handler)
    console.log(proxy.age)
    proxy.age = 20
    console.log(delete proxy.age)

    // 输出
    [Log] 获取对象属性age值 (example01.html, line 22)
    [Log] 16 (example01.html, line 36)
    [Log] 设置对象属性age值 (example01.html, line 26)
    [Log] 删除对象属性age值 (example01.html, line 30)
    [Log] true (example01.html, line 38)

    上面的例子,我们在捕获器中定义了set()get()deleteProperty()属性,通过对proxy的操作实现了对obj的操作拦截。这些属性的触发方法有如下参数:

  • target —— 是目标对象,该对象被作为第一个参数传递给new Proxy

  • key —— 目标属性名称

  • value —— 目标属性的值

  • receiver —— 指向的是当前操作 正确的上下文。如果目标属性是一个 getter 访问器属性,则 receiver 就是本次读取属性所指向的 this 对象。通常,receiver这就是 proxy 对象本身,但是如果我们从 proxy 继承,则receiver指的是从该 proxy 继承的对象

  • 当然除了以上三个还有一些常用的属性操作方法:

    • has(),拦截:in操作符.
    • ownKeys(),拦截:

Object.getOwnPropertyNames(proxy) Object.getOwnPropertySymbols(proxy) Object.keys(proxy)

  • construct(),拦截:new 操作等

Reflect

Reflect: 反射,就是将代理的内容反射出去。ReflectProxy一样,也是 ES6 为了操作对象而提供的新 API。它提供拦截JavaScript操作的方法,这些方法与Proxy handlers 提供的的方法是一一对应的,只要是Proxy对象的方法,就能在Reflect对象上找到对应的方法。且 Reflect 不是一个函数对象,即不能进行实例化,其所有属性和方法都是静态的。还是上面的例子

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 obj = {
name: 'John',
age: 16
}

const handler = {
get(target, key, receiver) {
console.log(`获取对象属性${key}值`)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
console.log(`设置对象属性${key}值`)
return Reflect.set(target, key, value, receiver)
},
deleteProperty(target, key) {
console.log(`删除对象属性${key}值`)
return Reflect.deleteProperty(target, key)
},
}

const proxy = new Proxy(obj, handler)
console.log(proxy.age)
proxy.age = 20
console.log(delete proxy.age)

上面的例子中

  • Reflect.get()代替target[key]操作
  • Reflect.set()代替target[key] = value操作
  • Reflect.deleteProperty()代替delete target[key]操作
    当然除了上面的方法还有一些常用的Reflect方法:
    1
    2
    3
    4
    5
    Reflect.construct(target, args)
    Reflect.has(target, name)
    Reflect.ownKeys(target)
    Reflect.getPrototypeOf(target)
    Reflect.setPrototypeOf(target, prototype)

三. reactive、ref源码解析

了解了ProxyReflect,看下Vue3是如何通过porxy实现响应式的。其核心是下面要介绍的两个方法:reactiveref.这里依照Vue3.2版本的源码进行解析。

reactive的源码实现

打开源文件,找到文件packages/reactivity/src/reactive.ts 查看源码。

1
2
3
4
5
6
7
8
9
10
11
12
13
export function reactive(target: object) {
// if trying to observe a readonly proxy, return the readonly version.
if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
return target
}
return createReactiveObject(
target,
false,
mutableHandlers,
mutableCollectionHandlers,
reactiveMap
)
}
  • 刚开始对target进行响应式只读判断,如果为true,则直接返回targetreactive实现的核心方法是createReactiveObject()
    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
    function createReactiveObject(
    target: Target,
    isReadonly: boolean,
    baseHandlers: ProxyHandler<any>,
    collectionHandlers: ProxyHandler<any>,
    proxyMap: WeakMap<Target, any>
    ) {
    if (!isObject(target)) {
    if (__DEV__) {
    console.warn(`value cannot be made reactive: ${String(target)}`)
    }
    return target
    }
    // target is already a Proxy, return it.
    // exception: calling readonly() on a reactive object
    if (
    target[ReactiveFlags.RAW] &&
    !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
    ) {
    return target
    }
    // target already has corresponding Proxy
    const existingProxy = proxyMap.get(target)
    if (existingProxy) {
    return existingProxy
    }
    // only a whitelist of value types can be observed.
    const targetType = getTargetType(target)
    if (targetType === TargetType.INVALID) {
    return target
    }
    const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
    )
    proxyMap.set(target, proxy)
    return proxy
    }
  • createReactiveObject()方法有五个参数:
    • target: 传入的原始目标对象
    • isReadonly: 是否是只读的标识
    • baseHandlers: 为普通对象创建proxy时的第二个参数handler
    • collectionHandlers: 为collection类型对象创建proxy时的第二个参数handler
    • proxyMap: WeakMap类型的map,主要用于存储 target与他的proxy之间的对应关系
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      function targetTypeMap(rawType: string) {
      switch (rawType) {
      case 'Object':
      case 'Array':
      return TargetType.COMMON
      case 'Map':
      case 'Set':
      case 'WeakMap':
      case 'WeakSet':
      return TargetType.COLLECTION
      default:
      return TargetType.INVALID
      }
      }
  • 源码可以看到,他将对象分为COMMON对象(ObjectArray)与COLLECTION类型对象(MapSetWeakMapWeakSet),这样区分的主要目的是为了根据不通的对象类型,来定制不同的handler
  • createReactiveObject()的前几行,进行了一系列的判断:
    • 首先判断target是否是对象,如果为false,直接return
    • 判断target是否是响应式对象,如果为true,直接return
    • 判断是否已经为target创建过proxy了,如果为true,直接return
    • 判断target是否是刚才上面提到的6种对象类型,如果为false,直接return
    • 如果以上条件都满足,则为target创建proxy,并return这个proxy

接下来就是根据不同的对象类型,传入不同的handler的逻辑处理了,主要关注baseHandlers,里面存在五个属性操作方法,这重点解析getset方法。

源码位置:packages/reactivity/src/baseHandlers.ts

1
2
3
4
5
6
7
export const mutableHandlers: ProxyHandler<object> = {
get,
set,
deleteProperty,
has,
ownKeys
}
get与依赖收集
  • 可以看到mutableHandlers里面就是我们熟悉的各种钩子函数。当我们对proxy对象进行访问或是修改时,调用相应的函数进行处理。首先看get里面是如何对访问target的副作用函数进行收集的:

    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
    function createGetter(isReadonly = false, shallow = false) {
    return function get(target: Target, key: string | symbol, receiver: object) {
    if (key === ReactiveFlags.IS_REACTIVE) {
    return !isReadonly
    } else if (key === ReactiveFlags.IS_READONLY) {
    return isReadonly
    } else if (
    key === ReactiveFlags.RAW &&
    receiver ===
    (isReadonly
    ? shallow
    ? shallowReadonlyMap
    : readonlyMap
    : shallow
    ? shallowReactiveMap
    : reactiveMap
    ).get(target)
    ) {
    return target
    }

    const targetIsArray = isArray(target)

    if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
    return Reflect.get(arrayInstrumentations, key, receiver)
    }

    const res = Reflect.get(target, key, receiver)

    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
    return res
    }

    if (!isReadonly) {
    track(target, TrackOpTypes.GET, key)
    }

    if (shallow) {
    return res
    }

    if (isRef(res)) {
    // ref unwrapping - does not apply for Array + integer key.
    const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
    return shouldUnwrap ? res.value : res
    }

    if (isObject(res)) {
    // Convert returned value into a proxy as well. we do the isObject check
    // here to avoid invalid value warning. Also need to lazy access readonly
    // and reactive here to avoid circular dependency.
    return isReadonly ? readonly(res) : reactive(res)
    }

    return res
    }
    }
  • 如果key值为__v_isReactive__v_isReadonly进行相应的返回,如果key==='__v_raw'并且WeakMapkeytarget的值不为空,则返回target

  • 如果target是数组,则 重写/增强 数组对应的方法

    • 数组元素的查找方法includes、indexOf、lastIndexOf
    • 修改原数组 的方法:push、pop、unshift、shift、splice

    在这些方法里面调用track()进行依赖收集

  • Reflect.get()方法的返回值,也就是当前数据对象的属性值res进行判断,如果res是普通对象且非只读,则调用track()进行依赖收集

  • 如果res是浅层响应,直接返回,如果resref对象,则返回其value

  • 如果res对象类型并且是只读的,则调用readonly(res),否则递归调用reactive(res)方法

  • 如果以上都不满足,直接向外返回对应的 属性值

那么核心方法就是如果利用**track()**进行依赖收集的处理了,源码在``packages/reactivity/src/effect.ts`

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function track(target: object, type: TrackOpTypes, key: unknown) {
if (!isTracking()) {
return
}
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = createDep()))
}

const eventInfo = __DEV__
? { effect: activeEffect, target, type, key }
: undefined

trackEffects(dep, eventInfo)
}
  • 首先进行是否正在进行依赖收集的判断处理

  • const targetMap = new WeakMap<any, KeyToDepMap>()创建一个targetMap容器,用于保存和当前响应式对象相关的依赖内容,本身是一个 WeakMap类型

  • 将对应的 响应式对象 作为 targetMaptargetMapValue是一个depsMap(属于 Map 实例), depsMap 存储的就是和当前响应式对象的每一个 key 对应的具体依赖

  • depsMap是响应式数据对象的key,Value是一个deps(属于 Set 实例),这里之所以使用Set是为了避免副作用函数的重复添加,避免重复调用

以上就是整个**get()**捕获器以及依赖收集的核心流程。

set与依赖更新

我们在回到baseHandlers中看Set捕获器中是如何进行依赖更新的

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 createSetter(shallow = false) {
return function set(
target: object,
key: string | symbol,
value: unknown,
receiver: object
): boolean {
let oldValue = (target as any)[key]
if (!shallow) {
value = toRaw(value)
oldValue = toRaw(oldValue)
if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
oldValue.value = value
return true
}
} else {
// in shallow mode, objects are set as-is regardless of reactive or not
}

const hadKey =
isArray(target) && isIntegerKey(key)
? Number(key) < target.length
: hasOwn(target, key)
const result = Reflect.set(target, key, value, receiver)
// don't trigger if target is something up in the prototype chain of original
if (target === toRaw(receiver)) {
if (!hadKey) {
trigger(target, TriggerOpTypes.ADD, key, value)
} else if (hasChanged(value, oldValue)) {
trigger(target, TriggerOpTypes.SET, key, value, oldValue)
}
}
return result
}
}
  • 首先进行旧值的保存oldValue
  • 如果不是浅层响应,target是普通对象,并且旧值是个响应式对象,则执行赋值操作:oldValue.value = value ,返回true,表示赋值成功
  • 判断是否存在对应key值hadKey
  • 执行Reflect.set设置对应的属性值
  • 判断对象是原始原型链上的内容(非自定义添加),则不触发依赖更新
  • 根据目标对象不存在对应的 key, 调用trigger,进行依赖更新

以上就是整个baseHandlers关于依赖收集依赖更新的核心流程。

ref的源码实现

我们知道ref可以定义基本数据类型、引用数据类型的响应式。来看下它的源码实现:packages/reactivity/src/ref.ts

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
export function ref(value?: unknown) {
return createRef(value)
}

function createRef(rawValue: unknown, shallow = false) {
if (isRef(rawValue)) {
return rawValue
}
return new RefImpl(rawValue, shallow)
}

class RefImpl<T> {
private _value: T
private _rawValue: T

public dep?: Dep = undefined
public readonly __v_isRef = true

constructor(value: T, public readonly _shallow = false) {
this._rawValue = _shallow ? value : toRaw(value)
this._value = _shallow ? value : convert(value)
}

get value() {
trackRefValue(this)
return this._value
}

set value(newVal) {
newVal = this._shallow ? newVal : toRaw(newVal)
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal
this._value = this._shallow ? newVal : convert(newVal)
triggerRefValue(this, newVal)
}
}
}
  • 从上面的函数调用流程可以看出,实现ref的核心就是实例化了一个RefImpl对象。为什么这里要实例化一个RefImpl对象呢,其目的在于 Proxy 代理的目标也是对象类型,无法通过为基本数据类型创建proxy的方式来进行数据代理。只能把基本数据类型包装为一个对象,通过自定义的 get、set 方法进行 依赖收集依赖更新
  • 来看RefImpl对象属性的含义:
    • _ value:用于保存ref当前值,如果传递的参数是对象,它就是用于保存经过reactive函数转化后的值,否则_value_rawValue相同
    • _ rawValue:用于保存当前ref值对应的原始值,如果传递的参数是对象,它就是用于保存转化前的原始值,否则_value_rawValue相同。这里toRaw()函数的作用就是将的响应式对象转为普通对象
    • dep:是一个Set类型的数据,用来存储当前的ref值收集的依赖。至于这里为什么用Set上面我们有阐述,这里也是同样的道理
    • _v_isRef :标记位,只要被ref定义了,都会标识当前数据为一个Ref,也就是它的值标记为true
    • 另外可以很清楚的看到RefImpl类暴露给实例对象的get、set方法是value,所以对于ref定义的响应式数据的操作我们都要带上**.value**
  • 如果传入的值是对象类型,会调用convert()方法,这个方法里面会调用reactive()方法对其进行响应式处理
  • RefImpl实例关键就在于trackRefValue(this)triggerRefValue(this, newVal)的两个函数的处理,我们大概也知道它们就是依赖收集依赖更新,原理基本与reactive处理方式类似,这里就不在阐述了

五. 总结

  • 对于基础数据类型只能通过ref来实现其响应式,核心还是将其包装成一个RefImpl对象,并在内部通过自定义的 get value()set value(newVal)实现依赖收集与依赖更新。
  • 对于对象类型refreactive都可以将其转化为响应式数据,但其在ref内部,最终还是会调用reactive函数实现转化。reactive函数,主要通过创建了Proxy实例对象,通过Reflect实现数据的获取与修改。

一些参考:

https://github.com/vuejs/vue

https://zh.javascript.info/proxy#reflect

setState与useState使用注意项

一. 概述

我们知道React支持两种形式来创建组件,一种是 class 组件,一种是函数组件。在 React16.8 推出之前,如果要为组件添加状态,那么只能使用 class 组件来实现,并通过 setState 来修改状态值,达到更新组件的目的。在 React16.8 推出 Hook 后,可以利用 useState 来为函数组件添加状态,这让我们的状态管理更加容易。

前端开发的核心在于状态管理,在实际项目开发中,特别是对新手React开发人员而言,对setStateuseState Hook 的使用上仍有一些容易犯的错误和注意事项,在本文中,我们将探讨如何避免这些问题。

二. Class 组件中的 setState 的使用

在 class 组件中添加一个构造函数constructor,然后在该函数中为 this.state赋初值添加状态.

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
class ClassComponent extends Component {

constructor(props) {
super(props)
this.state = {
count: 0,
otherCount: 100
}
this.addCount = this.addCount.bind(this);
}

addCount(params) {
this.setState({count:this.state.count + 1})
}

render() {
return (
<div>
class组件
<div>
count: {this.state.count}<br></br>
otherCount: {this.state.otherCount}
</div>
<div>
<button onClick={this.addCount}>+1</button>
</div>
</div>
);
}
}

export default ClassComponent;

pic01

通过this.setState({count:this.state.count + 1})改变 state 里面 count 的值。这样操作是可以达到预期效果,但是再次添加一行同样的代码看会发生什么

1
2
3
4
addCount(params) {
this.setState({count:this.state.count + 1})
this.setState({count:this.state.count + 1})
}

pic01

count 的值并没有像我们预想的那样每次点击+2。是什么原因导致的呢?
State 的更新可能是异步的,出于性能考虑,React 可能会把多个setState()调用合并成一个调用,要解决这个问题,可以让 setState() 接收一个函数而不是一个对象。再来重新认识一下setState

setState()

1. setState(stateChange,[callback])

第一个参数是你要改变的 state 对象,第二个是可选的回调函数。这种形式的setState()是异步的,并且在同一周期内会对多个 setState进行批处理。后调用的 setState() 将覆盖同一周期内先调用 setState 的值,因此count数仅增加一次。如果要解决这个问题,我们需要为setState传入一个函数.

2. setState(updater,[callback])

第一个参数是带有形参的updater函数

1
(state, props) => stateChange

state对应变化时组件状态的引用,也就是最新的state,此次更新被应用时的props是第二个参数(可选)。因此可以使用这种方式来解决我们上面遇到的问题.

1
2
3
4
5
6
7
8
addCount(params) {
this.setState((state) => ({
count: state.count + 1
}))
this.setState((state) => ({
count: state.count + 1
}))
}

pic02

[callback]setState()的第二个参数,为可选的回调函数,它将在setState完成合并并重新渲染组件后执行。因此我们也可以在这个回调函数中获取到最新的state值.

对State赋值

State里面的状态数据可能是值类型或是引用类型,对于基本数据类型(String(字符串),Number(数值),Boolean(布尔值),Undefined,Null)可以直接对其赋值。对于引用类型的数据(Array,Object)赋值时要特别注意:不要直接修改原始引用类型的值。例如:为数组增加一项

1
2
3
4
5
6
7
8
// 错误,不要直接修改this.state的值
this.setState({
listData: this.state.listData.push(100)
})
// 错误
this.setState(preSate => ({
listData: preSate.listData.push(100)
}))

正确的做法是使用concat或是ES6的扩展语法:

1
2
3
4
5
6
7
8
// 正确
this.setState(preSate => ({
listData: preSate.listData.concat(100)
}))
// 正确
this.setState(preSate => ({
listData: [...preSate.listData,100]
}))

注意:在修改数组类型的状态时不要使用pushpopshiftunshift等方法,因为这些方法都是在原数组的基础上修改,取而代之的是使用concatslicesplicefilter或是ES6的扩展语法,返回一个新的数组。

对于对象类型的状态而言可以这样赋值:

1
2
3
4
5
6
7
8
// 使用ES6 的Object.assgin方法
this.setState(preState => ({
mapData: Object.assign({},preState.mapData,{name:'React'})
}))
// 使用对象扩展语法
this.setState(preState => ({
mapData: {...preState.mapData,name:'React'}
}))

三. 函数组件中的useState

Hook 是 React 16.8 的新增特性,它可以让你在不编写 class 组件的情况下使用 state 以及其他的 React 特性。useState 是允许你在 React 函数组件中添加 state 的 Hook.

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 React, { memo, useState } from 'react';

const HookComponent = () => {
const [data,setData] = useState({
count:0,
otherCount: 100
})

const addCount = () => {
// wrong
// setState({count:state.count+1})
// correct
setData(Object.assign({},{...data,count:data.count+1}))
setData(Object.assign({},{...data,count:data.count+1}))
console.log('data.count=====',data.count); // 0
}

return (
<div>
函数组件
<div>
count: {data.count}<br></br>
otherCount: {data.otherCount}
</div>
<div>
<button onClick={addCount}>+1</button>
</div>
</div>
);
}
export default memo(HookComponent);

更新state

useState的唯一参数是初始 state,返回值为:当前 state 以及更新 state 的函数。如果我们也像在 class 组件中使用对象对其进行赋值,并调用更新 state 函数,那么一定要注意:class 组件中的 this.setState是合并state,而函数组件中更新 state 的函数是替换当前的 state。例如要对count执行 +1 操作,以下写法是错误的:

1
2
// 错误
setData({count:data.count+1})

pic04

otherCount 的值变为了 undefined.

正确写法:

1
setData(Object.assign({},{...data,count:data.count+1}))

上面的写法这和预期的一样。但是,直接更新状态是一种不好的做法, 同样如果连续调用两次,count的值并没有像预期的那样改变.

1
2
setData(Object.assign({},{...data,count:data.count+1}))
setData(Object.assign({},{...data,count:data.count+1}))

pic05

与 class 组件状态更新原理不同,这里setData()是替换当前的 state,只执行最后一次+1的操作。如果要解决这个问题需要传递给 setState() 一个回调函数,在这个回调函数中我们获取该实例的当前状态,例如 setState(currentState => currentState + newValue)

1
2
3
4
5
6
7
8
setData(data => ({
...data,
count:data.count + 1
}))
setData(data => ({
...data,
count:data.count + 1
}))

pic06

获取State的最新值

我们知道State 的更新可能是异步的,但有时候的业务场景需要我们同步拿到变量的最新变化值,以便做下一步操作,可以用一下几种方式获取:

1. 回调函数内获取

在setXXX中使用回调函数更新

1
2
3
4
5
6
7
8
setData(data => {
const count = data.count + 1
console.log('count====>>>',count); // 获取最新值,执行后续逻辑
return {
...data,
count:count
}
})
2. 直接使用useEffect 获取
1
2
3
4
5
6
7
8
9
... 
const addCount = () => {
setData(Object.assign({},{...data,count:data.count+1}))
}
// 使用 useEffect 解决数据同步问题
useEffect(() => {
// 获取最新值,执行后续逻辑
console.log('count====',data.count);
}, [data]);
3.使用Promise实现
1
2
3
4
5
6
7
8
9
10
 new Promise((resolve) => {
const newData = {
...data,
count:data.count + 1
}
setData(Object.assign({},newData))
resolve(newData)
}).then(data => {
console.log('data====',data); // 获取到最新值,进行后续逻辑处理
});

四. 小结

需要注意的是,在 React18 之前,在非 React 调度流程如setTimeout setInterval ,直接在 DOM 上绑定原生事件等,setState 是同步的,除了这些,setState都是异步执行。从react18开始, 使用了createRoot创建应用后, 所有的更新都会自动进行批处理,也就是异步合并。后续文章中我们在探讨 React 的自动批处理流程,本文不再涉及。

一些参考:

React

State 和生命周期指南
深入学习:何时以及为什么 setState() 会批量执行?
深入:为什么不直接更新 this.state?

使用useReducer + useContext 代替 react-redux

一. 概述

在 React16.8推出之前,我们使用react-redux并配合一些中间件,来对一些大型项目进行状态管理,React16.8推出后,我们普遍使用函数组件来构建我们的项目,React提供了两种Hook来为函数组件提供状态支持,一种是我们常用的useState,另一种就是useReducer, 其实从代码底层来看useState实际上执行的也是一个useReducer,这意味着useReducer是更原生的,你能在任何使用useState的地方都替换成使用useReducer.

Reducer的概念是伴随着Redux的出现逐渐在JavaScript中流行起来的,useReducer从字面上理解这个是reducer的一个Hook,那么能否使用useReducer配合useContext 来代替react-redux来对我们的项目进行状态管理呢?答案是肯定的。

二. useReducer 与 useContext

1. useReducer

在介绍useReducer这个Hook之前,我们先来回顾一下Reducer,简单来说 Reducer是一个函数(state, action) => newState:它接收两个参数,分别是当前应用的state和触发的动作action,它经过计算后返回一个新的state.来看一个todoList的例子:

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
export interface ITodo {
id: number
content: string
complete: boolean
}

export interface IStore {
todoList: ITodo[],
}

export interface IAction {
type: string,
payload: any
}

export enum ACTION_TYPE {
ADD_TODO = 'addTodo',
REMOVE_TODO = 'removeTodo',
UPDATE_TODO = 'updateTodo',
}

import { ACTION_TYPE, IAction, IStore, ITodo } from "./type";

const todoReducer = (state: IStore, action: IAction): IStore => {
const { type, payload } = action
switch (type) {
case ACTION_TYPE.ADD_TODO: //增加
if (payload.length > 0) {
const isExit = state.todoList.find(todo => todo.content === payload)
if (isExit) {
alert('存在这个了值了')
return state
}
const item = {
id: new Date().getTime(),
complete: false,
content: payload
}
return {
...state,
todoList: [...state.todoList, item as ITodo]
}
}
return state
case ACTION_TYPE.REMOVE_TODO: // 删除
return {
...state,
todoList: state.todoList.filter(todo => todo.id !== payload)
}
case ACTION_TYPE.UPDATE_TODO: // 更新
return {
...state,
todoList: state.todoList.map(todo => {
return todo.id === payload ? {
...todo,
complete: !todo.complete
} : {
...todo
}
})
}
default:
return state
}
}
export default todoReducer

上面是个todoList的例子,其中reducer可以根据传入的action类型(ACTION_TYPE.ADD_TODO、ACTION_TYPE.REMOVE_TODO、UPDATE_TODO)来计算并返回一个新的state。reducer本质是一个纯函数,没有任何UI和副作用。接下来看下useReducer:

1
const [state, dispatch] = useReducer(reducer, initState);

useReducer 接受两个参数:第一个是上面我们介绍的reducer,第二个参数是初始化的state,返回的是个数组,数组第一项是当前最新的state,第二项是dispatch函数,它主要是用来dispatch不同的Action,从而触发reducer计算得到对应的state.

利用上面创建的reducer,看下如何使用useReducer这个Hook:

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
const initState: IStore = {
todoList: [],
themeColor: 'black',
themeFontSize: 16
}

const ReducerExamplePage: React.FC = (): ReactElement => {
const [state, dispatch] = useReducer(todoReducer, initState)
const inputRef = useRef<HTMLInputElement>(null);

const addTodo = () => {
const val = inputRef.current!.value.trim()
dispatch({ type: ACTION_TYPE.ADD_TODO, payload: val })
inputRef.current!.value = ''
}

const removeTodo = useCallback((id: number) => {
dispatch({ type: ACTION_TYPE.REMOVE_TODO, payload: id })
}, [])

const updateTodo = useCallback((id: number) => {
dispatch({ type: ACTION_TYPE.UPDATE_TODO, payload: id })
}, [])

return (
<div className="example" style={{ color: state.themeColor, fontSize: state.themeFontSize }}>
ReducerExamplePage
<div>
<input type="text" ref={inputRef}></input>
<button onClick={addTodo}>增加</button>
<div className="example-list">
{
state.todoList && state.todoList.map((todo: ITodo) => {
return (
<ListItem key={todo.id} todo={todo} removeTodo={removeTodo} updateTodo={updateTodo} />
)
})
}
</div>
</div>
</div>
)
}
export default ReducerExamplePage

ListItem.tsx

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
import React, { ReactElement } from 'react';
import { ITodo } from '../typings';

interface IProps {
todo:ITodo,
removeTodo: (id:number) => void,
updateTodo: (id: number) => void
}
const ListItem:React.FC<IProps> = ({
todo,
updateTodo,
removeTodo
}) : ReactElement => {
const {id, content, complete} = todo
return (
<div>
{/* 不能使用onClick,会被认为是只读的 */}
<input type="checkbox" checked={complete} onChange = {() => updateTodo(id)}></input>
<span style={{textDecoration:complete?'line-through' : 'none'}}>
{content}
</span>
<button onClick={()=>removeTodo(id)}>
删除
</button>
</div>
);
}
export default ListItem;

useReducer利用上面创建的todoReducer与初始状态initState完成了初始化。用户触发增加、删除、更新操作后,通过dispatch派发不类型的Actionreducer根据接收到的不同Action,调用各自逻辑,完成对state的处理后返回新的state。

可以看到useReducer的使用逻辑,几乎跟react-redux的使用方式相同,只不过react-redux中需要我们利用actionCreator来进行action的创建,以便利用Redux中间键(如redux-thunk)来处理一些异步调用。

那是不是可以使用useReducer来代替react-redux了呢?我们知道react-redux可以利用connect函数,并且使用Provider来对<App />进行了包裹,可以使任意组件访问store的状态。

1
2
3
<Provider store={store}>
<App />
</Provider>

如果想要useReducer到达类似效果,我们需要用到useContext这个Hook。

2. useContext

useContext顾名思义,它是以Hook的方式使用React Context。先简单介绍 Context
Context设计目的是为了共享那些对于一个组件树而言是“全局”的数据,它提供了一种在组件之间共享值的方式,而不用显式地通过组件树逐层的传递props

1
const value = useContext(MyContext);

useContext:接收一个context对象(React.createContext 的返回值)并返回该context的当前值,当前的 context值由上层组件中距离当前组件最近的<MyContext.Provider>value prop 决定。来看官方给的例子:

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
const themes = {
light: {
foreground: "#000000",
background: "#eeeeee"
},
dark: {
foreground: "#ffffff",
background: "#222222"
}
};

const ThemeContext = React.createContext(themes.light);

function App() {
return (
<ThemeContext.Provider value={themes.dark}>
<Toolbar />
</ThemeContext.Provider>
);
}

function Toolbar(props) {
return (
<div>
<ThemedButton />
</div>
);
}

function ThemedButton() {
const theme = useContext(ThemeContext);
return (
<button style={{ background: theme.background, color: theme.foreground }}>
I am styled by theme context!
</button>
);
}

上面的例子,首先利用React.createContext创建了context,然后用ThemeContext.Provider标签包裹需要进行状态共享的组件树,在子组件中使用useContext获取到value值进行使用。

利用useReduceruseContext这两个Hook就可以实现对react-redux的替换了。

三. 代替方案

通过一个例子看下如何利用useReducer+useContext代替react-redux,实现下面的效果:
pic

react-redux实现

这里假设你已经熟悉了react-redux的使用,如果对它不了解可以去 查看.使用它来实现上面的需求:

  • 首先项目中导入我们所需类库后,创建Store

    Store/index.tsx

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import { createStore,compose,applyMiddleware } from 'redux';
    import reducer from './reducer';
    import thunk from 'redux-thunk';// 配置 redux-thunk

    const composeEnhancers = compose;
    const store = createStore(reducer,composeEnhancers(
    applyMiddleware(thunk)// 配置 redux-thunk
    ));
    export type RootState = ReturnType<typeof store.getState>
    export default store;
  • 创建reduceractionCreator

    reducer.tsx

    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
    import { ACTION_TYPE, IAction, IStore, ITodo } from "../../ReducerExample/type";

    const defaultState:IStore = {
    todoList:[],
    themeColor: '',
    themeFontSize: 14
    };
    const todoReducer = (state: IStore = defaultState, action: IAction): IStore => {
    const { type, payload } = action
    switch (type) {
    case ACTION_TYPE.ADD_TODO: // 新增
    if (payload.length > 0) {
    const isExit = state.todoList.find(todo => todo.content === payload)
    if (isExit) {
    alert('存在这个了值了')
    return state
    }
    const item = {
    id: new Date().getTime(),
    complete: false,
    content: payload
    }
    return {
    ...state,
    todoList: [...state.todoList, item as ITodo]
    }
    }
    return state
    case ACTION_TYPE.REMOVE_TODO: // 删除
    return {
    ...state,
    todoList: state.todoList.filter(todo => todo.id !== payload)
    }
    case ACTION_TYPE.UPDATE_TODO: // 更新
    return {
    ...state,
    todoList: state.todoList.map(todo => {
    return todo.id === payload ? {
    ...todo,
    complete: !todo.complete
    } : {
    ...todo
    }
    })
    }
    case ACTION_TYPE.CHANGE_COLOR:
    return {
    ...state,
    themeColor: payload
    }
    case ACTION_TYPE.CHANGE_FONT_SIZE:
    return {
    ...state,
    themeFontSize: payload
    }
    default:
    return state
    }
    }
    export default todoReducer

    actionCreator.tsx

    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 {ACTION_TYPE, IAction } from "../../ReducerExample/type"
    import { Dispatch } from "redux";

    export const addCount = (val: string):IAction => ({
    type: ACTION_TYPE.ADD_TODO,
    payload:val
    })
    export const removeCount = (id: number):IAction => ({
    type: ACTION_TYPE.REMOVE_TODO,
    payload:id
    })
    export const upDateCount = (id: number):IAction => ({
    type: ACTION_TYPE.UPDATE_TODO,
    payload:id
    })
    export const changeThemeColor = (color: string):IAction => ({
    type: ACTION_TYPE.CHANGE_COLOR,
    payload:color
    })
    export const changeThemeFontSize = (fontSize: number):IAction => ({
    type: ACTION_TYPE.CHANGE_FONT_SIZE,
    payload:fontSize
    })
    export const asyncAddCount = (val: string) => {
    console.log('val======',val);

    return (dispatch:Dispatch) => {
    Promise.resolve().then(() => {
    setTimeout(() => {
    dispatch(addCount(val))
    }, 2000);
    })
    }

    }

最后我们在组件中通过useSelector,useDispatch这两个Hook来分别获取state以及派发action

1
2
3
const todoList = useSelector((state: RootState) => state.newTodo.todoList)
const dispatch = useDispatch()
......
useReducer + useContext 实现

为了实现修改颜色与字号的需求,在最开始的useReducer我们再添加两种action类型,完成后的reducer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const todoReducer = (state: IStore, action: IAction): IStore => {
const { type, payload } = action
switch (type) {
...
case ACTION_TYPE.CHANGE_COLOR: // 修改颜色
return {
...state,
themeColor: payload
}
case ACTION_TYPE.CHANGE_FONT_SIZE: // 修改字号
return {
...state,
themeFontSize: payload
}
default:
return state
}
}
export default todoReducer

在父组件中创建Context,并将需要与子组件共享的数据传递给Context.ProviderValue prop

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
const initState: IStore = {
todoList: [],
themeColor: 'black',
themeFontSize: 14
}
// 创建 context
export const ThemeContext = React.createContext(initState);

const ReducerExamplePage: React.FC = (): ReactElement => {
...

const changeColor = () => {
dispatch({ type: ACTION_TYPE.CHANGE_COLOR, payload: getColor() })
}

const changeFontSize = () => {
dispatch({ type: ACTION_TYPE.CHANGE_FONT_SIZE, payload: 20 })
}

const getColor = (): string => {
const x = Math.round(Math.random() * 255);
const y = Math.round(Math.random() * 255);
const z = Math.round(Math.random() * 255);
return 'rgb(' + x + ',' + y + ',' + z + ')';
}

return (
// 传递state值
<ThemeContext.Provider value={state}>
<div className="example">
ReducerExamplePage
<div>
<input type="text" ref={inputRef}></input>
<button onClick={addTodo}>增加</button>
<div className="example-list">
{
state.todoList && state.todoList.map((todo: ITodo) => {
return (
<ListItem key={todo.id} todo={todo} removeTodo={removeTodo} updateTodo={updateTodo} />
)
})
}
</div>
<button onClick={changeColor}>改变颜色</button>
<button onClick={changeFontSize}>改变字号</button>
</div>
</div>
</ThemeContext.Provider>
)
}
export default memo(ReducerExamplePage)

然后在ListItem中使用const theme = useContext(ThemeContext); 获取传递的颜色与字号,并进行样式绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 引入创建的context
import { ThemeContext } from '../../ReducerExample/index'
...
// 获取传递的数据
const theme = useContext(ThemeContext);

return (
<div>
<input type="checkbox" checked={complete} onChange={() => updateTodo(id)} style={{ color: theme.themeColor, fontSize: theme.themeFontSize }}></input>
<span style={{ textDecoration: complete ? 'line-through' : 'none', color: theme.themeColor, fontSize: theme.themeFontSize }}>
{content}
</span>
<button onClick={() => removeTodo(id)} style={{ color: theme.themeColor, fontSize: theme.themeFontSize }}>
删除
</button>
</div>
);

可以看到在useReducer结合useContext,通过Contextstate数据给组件树中的所有组件使用 ,而不用通过props添加回调函数的方式一层层传递,达到了数据共享的目的。

四. 总结

总体来说,相比react-redux而言,使用useReducer + userContext 进行状态管理更加简单,免去了导入各种状态管理库以及中间键的麻烦,也不需要再创建storeactionCreator,对于新手来说,减轻了状态管理的难度。对于一些小型项目完全可以用它来代替react-redux,当然一些大型项目普遍还是使用react-redux来进行的状态管理,所以深入学习Redux 也是很有必要的。

一些参考:

React

React Redux

用useState还是用useReducer

GitHub Actions 自动部署前端 Vue 项目

一. 概述

作为前端技术人员,如果要部署一个项目大体要经过:代码开发代码推送打包dist文件scp到服务器服务器nginx配置完成部署这几个流程,现实中我们希望项目部署尽可能自动且简单,因此诞生了各种CI/CD工具,比如:Jenkinsgitlab cigitlab runner等,其实我们最熟悉的 GitHub 也提供了CI/CD 的能力:GitHub Actions,它于2019年11月正式发布,现已经支持多种的语言和框架:Node.js, Python, Java, PHP, Ruby, Go, Rust, C/C++, .NET, Android, iOS.当然在利用GitHub Actions自动部署项目之前,先要利用GitHub Pages来发布我们的前端项目。

二. GitHub Pages

github pages

什么是 GitHub Pages?官网的介绍:Websites for you and your projects.Hosted directly from your GitHub repository. Just edit, push, and your changes are live. 说的很明确了,可以利用它,将我们托管在 GitHub 仓库的项目部署为一个可以对外访问的网站,免去了我们自己购买与配置服务器的麻烦。

  • 首先创建一个项目,以Vue项目为例,利用 Vue 脚手架创建一个项目
    1
    npm init vue@latest

这里假设你已经熟悉了 Vue 项目创建,如果不熟悉Vue可以去查看
执行如下命令:

1
2
3
> cd <your-project-name>
> npm install
> npm run dev

运行后在浏览器中打开本地地址,得到如下页面:
vue

  • GitHub上创建一个新的Repository,将项目上传到GitHub仓库

    1
    2
    3
    4
    5
    git init
    git add .
    git commit -m "备注信息"
    git remote add origin 你的远程仓库地址
    git push -u origin master
  • 配置 GitHub Actions
    回到GitHub,点击Setting->Pages,看到如下界面

    github github 并没有展示网址,别急!此时还需要我们去新建一个名为**gh-pages**的分支,创建完成后再次打开`Pages`,可以看到页面发生了变化 github > **Source**: 选择`Deploy from a branch`, **Branch**:`github pages` 默认只能识别项目根目录的 `index` 文件,我们这里选择新建的`gh-pages`的`root`根目录,意思是去这个分支的根目录加载`index.html`文件.
  • 打包应用,并发布到 gh-pages 分支
    打包应用,执行npm run build ,在项目根目录下得到打包后的产物dist文件夹,

    截屏2022-11-18 17.53.12

    切换当前分支到gh-pages,并且将原有内容全部删除, 最后将dist文件夹下的内容全部拷贝到gh-pages上,push到远端.

pic 再次点击`Setting`->`Pages` ,稍等一会儿,下面出现了一个网址,这就是项目线上地址 pci1
  • 遇到问题
    点击查看网址,并没有像我们预期的那样展示页面,而是一片空白。打开调试版查看错误信息:pic02 如果有项目部署经验的一看就知道是怎么回事了,这是打包编译后的文件路径配置有问题,资源文件`css`、`js`,加载的路径地址不对,加载的是根路径 `https://<用户名>.github.io/assets/index.bf782a5b.js`,而我们的资源文件在`/vue-pages/`目录下,所以当然报错`404`,修复也很简单,如果你的Vue项目是基于 Vite 的构建的,需要修改`vite.config.js`,添加`base:'./'`
    1
    2
    3
    4
    5
    6
    7
    8
    9
    export default defineConfig({
    plugins: [vue(), vueJsx()],
    base:'./',// 将根路径换成相对路径
    resolve: {
    alias: {
    "@": fileURLToPath(new URL("./src", import.meta.url)),
    },
    },
    })
    如果是基于`webpack`构建,修改`vue.config.js`添加`publicPath: './'`.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    module.exports = {
    /**
    * publicPath 默认是 / 是根路径,这个是指服务的根路径:https://xxx.github.io/,发布后会从这个路径下找 js.css 等资源,而生成的网站路径是这个 https://xxx.github.io/Vue-Element/,显然是找不到的
    * 我们需要修改为 相对路径'./' 或是‘.’ 或是 直接设置的项目子路径 :/项目名称/ 就可找到资源了
    */
    publicPath: './',
    outputDir: 'dist', // dist
    assetsDir: 'static',
    lintOnSave: process.env.NODE_ENV === 'development',
    productionSourceMap: false,
    ...
    重新打包,将`dist`文件夹下内容拷贝到`gh-pages`分支下,并重新打开`pages`链接:`https://<用户名>.github.io/vue-pages/` 成功部署!

每一次修改后都要重新打包,切换分支拷贝dist文件夹,实属麻烦,能不能让GitHub自动检测push动作,自动进行打包部署吗?那就是GitHub Actions的工作了.

三. GitHub Actions

什么是GitHub Actions?

GitHub ActionsGitHub推出的一款持续集成(CI/CD)服务,它给我们提供了虚拟的服务器资源,让我们可以基于它完成自动化测试、集成、部署等操作。这里简单介绍一下它的几个基本概念,更多内容可以去官网查看

基本概念

  • Workflows(工作流程)
    持续集成的运行过程称为一次工作流程,也就是我们项目开始自动化部署到部署结束的这一段过程可以称为工作流程.

  • job (任务)
    一个工作流程中包含多个任务,简单来说就是一次自动部署的过程需要完成一个或多个任务.

  • step(步骤)
    部署项目需要按照一个一个的步骤来进行,每个job由多个step构成.

  • action(动作)
    每个步骤step可以包含一个或多个动作,比如我们在一个步骤中执行打包命令这个Action.

语法简介

  • name
    name字段是workflow的名称。如果省略该字段,默认为当前workflow的文件名.

    1
    name: GitHub CI
  • on
    on字段指定触发workflow的条件,通常是某些事件,比如代码推送push,拉取pull_request,可以是事件的数组.

    1
    2
    3
    on: push
    or
    on: [push, pull_request]

    指定触发事件时,可以限定分支或标签:

    1
    2
    3
    4
    on:
    push:
    branches:
    - master

    上面代码表示:只有master分支发生push事件时,才会触发workflow.

  • jobs
    workflow的核心就是jobs,任务job放在jobs这个集合下,每一个job都有job_id,用job_id标识一个具体任务

  • jobs.<job_id>.name
    任务说明

    1
    2
    3
    4
    5
    jobs:
    my_first_job: // job_id
    name: My first job
    my_second_job:// job_id
    name: My second job

    上面的jobs字段包含两项任务,job_id分别是my_first_jobmy_second_job

  • jobs.<job_id>.runs-on
    runs-on字段指定运行所需要的虚拟机环境,它是必填字段。

1
runs-on: ubuntu-18.04

GitHub Actions给我们提提供的运行环境主要有以下几种:
ubuntu-latestubuntu-18.04或ubuntu-16.04
windows-latest,windows-2019或windows-2016
macOS-latest或macOS-10.14

  • jobs.<job_id>.steps
    任务步骤,一个job可以包含多个步骤,我们需要分为多个步骤来完成这个任务,每个步骤包含下面三个字段:
1
2
3
jobs.<job_id>.steps.name:步骤名称。
jobs.<job_id>.steps.run:该步骤运行的命令或者 action。
jobs.<job_id>.steps.env:该步骤所需的环境变量。

使用介绍

  • 新建.yml文件
    点击主页Actions -> New workflow -> set up a workflow yourself,当然你也可以选择一个模板,点击start commit则会自动在我们项目目录下新建.github/workflows/main.yml文件.pic

整个workflow的核心就是yml脚本的书写。如果你需要某个action,不必自己写复杂的脚本,直接引用他人写好的 action即可,整个持续集成过程,就变成了一个actions的组合,你可以在GitHub官方市场,可以搜索到他人提交的actions. 下面是我们要自动发布GitHub pages所写的脚本:

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
name: CI Github Pages
on:
#监听push操作
push:
branches:
- main # 这里只配置了main分支,所以只有推送main分支才会触发以下任务
jobs:
# 任务ID
build-and-deploy:
# 运行环境
runs-on: ubuntu-latest
# 步骤
steps:
# 官方action,将代码拉取到虚拟机
- name: Checkout
uses: actions/checkout@v3

- name: Install and Build # 安装依赖、打包,如果提前已打包好无需这一步
run: |
npm install
npm run build

- name: Deploy # 部署
uses: JamesIves/github-pages-deploy-action@v4.3.3
with:
branch: gh-pages # 部署后提交到那个分支
folder: dist # 这里填打包好的目录名称

上面整个workflow的说明:

  • 只有当main分支有新的push推送时候才会执行整个workflow.
  • 整个workflow只有一个job,job_idbuild-and-deploy,name被省略.
  • job 有三个step: 第一步是Checkout,获取源码,使用的actionGitHub官方的actions/checkout.
  • 第二步:Install and Build,执行了两条命令:npm install,npm run build,分别安装依赖与打包应用.
  • 第三步:Deploy 部署,使用的第三方actionJamesIves/github-pages-deploy-action@v4.3.3,它有两个参数:分别是branchfolder,更多关于这个action的详情可以去查看.

当点击**Start commit**,GitHub Actions 会自动运行workflow. 修改工程文字欢迎文字:

1
<HelloWorld msg="You did it!" />

改为:

1
<HelloWorld msg="GitHub Actions CI Succeed!" />

push可以点击Actions查看工作流的运行情况
flow1
当这个黄色加载动画变成绿色后表示workflow运行完成,看下最终效果:
flow2
达到了自动部署的目的.

四. 设置Custom domain

其实经过上面的三步已经可以实现自动部署的目的了,但是还是有点瑕疵。我们部署后的项目地址是:https://<用户名>.github.io/vue-pages/,域名还是GitHub的,能不能改成我们自己的专属域名呢?比如改成http://<用户名>.com/,那就需要设置Custom domain了。

1. 购买域名

如果想将项目地址改成自己的专属域名,首先需要你去购买一个域名,目前阿里云,腾讯云都支持域名的购买,搜索自己喜欢的域名直接付款就好了。
domain

2. 购买域名后,还需要我们进行实名认证以及备案,按照平台的提示进行操作就好了,这里不再涉及.

3. 进行DNS解析配置

这里以阿里云为例,打开域名解析控制台,点击解析按钮
pic
点击添加记录按钮,将下面两种类型的记录值添加上,记录类型是:CNAME,记录值就是你GitHub的主域名.
feak

4. 设置Custom domain

返回到项目的GitHub pages设置页面,将我们购买的域名添加在Custom domain中,点击save,可以看到pages的地址变成了我们自己的域名,访问它就会看到你的网站了.
pic

五. 小结

GitHub Actions给我们提供了一站式的自动化部署体验,加上Custom domain的设置,完全可以用于搭建我们的个人博客,最重要的是这完全免费. 你也可以用它来部署其他框架的项目,当然这里的重点是的yml脚本的书写.

一些参考:

https://pages.github.com

https://github.com/features/actions

https://blog.csdn.net/formula10000/article/details/98946098

Vue3.2语法糖使用总结

一. 概述

Vue2时期,组件里定义的各类变量、方法、计算属性等是分别存放到datamethodscomputed等选项里,这样编写的代码不便于后期的查阅,查找一个业务逻辑需要在各个选项来回切换。vue3.0组合式APIsetup函数的推出就是为了解决这个问题,它让我们的逻辑关注点更加集中,语法也更加精简,但是当我们在使用vue3.0的语法就构建组件的时候,总是需要把外面定义的方法变量必须要return出去才能在<template>,比较麻烦一些. vue3.2语法糖的出现以及一些新增的API,让我们的代码进一步简化。

什么是语法糖?

语法糖(英语:Syntactic sugar)是由英国计算机科学家彼得·兰丁发明的一个术语,指计算机语言中添加的某种语法,这种语法对语言的功能没有影响,但是更方便程序员使用。语法糖让程序更加简洁,有更高的可读性。

Vue3.2语法糖

来看下vue3.0vue3.2的单文件组件(SFC,即.vue 文件)的结构对比

  • vue3.0组件
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <template>
    <div>
    </div>
    </template>
    <script>
    export default {
    components: {
    },
    props: {
    },
    setup () {
    return {}
    }
    }
    </script>
    <style lang="scss" scoped>
    </style>
  • vue3.2组件
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <template>
    <MyTestVue :title="title" @click="changeTitle" />
    </template>
    <script setup>
    import MyTestVue from './MyTest.vue';
    import { ref } from 'vue';
    const title = ref('测试一下')
    const changeTitle = () => {
    title.value = 'Hello,World'
    }
    </script>
    <style lang="scss" scoped>
    </style>
  1. 对比vue3.0vue3.2版本的组件模板,最主要的变化是3.2中没有了setup函数,而是把它放在了script标签中。

  2. 我们定义的属性和方法也不用在return中返回,直接就可以用在模板语法中

    这些是直观的变化,接下来我们学习具体的用法。

二.使用介绍

1.组件注册

vue3.0中使用组件,需要使用 components 选项来显式注册:

1
2
3
4
5
6
7
8
9
10
11
12
<script>
import ComponentA from './ComponentA.js'

export default {
components: {
ComponentA
},
setup() {
// ...
}
}
</script>

vue3.2 <script setup> 的单文件组件中,导入的组件可以直接在模板中使用,组件会自动注册,并且无需指定当前组件的名字,它会自动以文件名为主,也就是不用再写name属性了。

1
2
3
4
5
6
7
<script setup>
import ComponentA from './ComponentA.vue'
</script>

<template>
<ComponentA />
</template>

2.Props 声明

vue3.0中,prop可以使用props选项来声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script>
export default {
props: ['foo'],
// 或者用这种方式指类型与默认值
// props: {
// foo:{
// type: String,
// default: ''
// },
// },
setup(props) {
// setup() 接收 props 作为第一个参数
console.log(props.foo)
}
}
</script>

vue3.2组件中,props可以使用defineProps()宏来声明

1
2
3
4
5
6
7
8
9
10
<script setup>
const props = defineProps(['foo'])
// 或者
const propsOther = defineProps({
title: String,
likes: Number
})

console.log(props.foo)
</script>

注意事项:所有的 props 都遵循着单向绑定原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递,这意味着你不应该在子组件中去更改一个 prop。

3.计算属性

我们一般使用计算属性来描述依赖响应式状态的复杂逻辑。说白了就是这个计算属性的值依赖于其他响应式属性的值,依赖的属性发生变化,那么这个计算属性的值就会进行重新计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script setup>
import { ref, computed } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

const fullName = computed({
// getter
get() {
return firstName.value + ' ' + lastName.value
},
// setter
set(newValue) {
// 注意:我们这里使用的是解构赋值语法
[firstName.value, lastName.value] = newValue.split(' ')
}
})
</script>

当调用fullName.value = 'John Doe'时,setter会被调用,而firstNamelastName会被更新,在vue3.2中我们可以直接在<template>标签中使用它,不在需要return返回。

  • 不要在计算函数中做异步请求或者更改 DOM!
  • 一个计算属性仅会在其响应式依赖更新时才重新计算,如果他依赖的是个非响应式的依赖,及时其值发生变化,计算属性也不会更新。
  • 相比于方法而言,计算属性值会基于其响应式依赖被缓存,一个计算属性仅会在其响应式依赖更新时才重新计算

4. watch

在组合式API中,我们可以使用watch函数在每次响应式状态发生变化时触发回调函数,watch的第一个参数可以是不同形式的“数据源”:它可以是一个 ref(包括计算属性)、一个响应式对象、一个 getter 函数、或多个数据源组成的数组:
watch()是懒执行的:仅当数据源变化时,才会执行回调,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script setup>
import { ref,watch } from 'vue';

const props = defineProps({
title: String,
itemList: {
type: Array,
default: () => [{
text: 'title',
value: 0
}]
}
})

watch(() => props.itemList.length,(newValue,oldValue) => {
console.log('newValue===',newValue);
console.log('oldValue===',oldValue);
})
</script>

这里监听props.itemList.length,当传入的itemList数量发生变化时,后面的回调方法会被调用。当然wacth()还有第三个可选参数:否开启深监听(deep), 如果这里这样写:

1
2
3
4
5
6
7
8
<script setup>
import { ref,watch } from 'vue';
...
watch(() => props.itemList,(newValue,oldValue) => {
console.log('newValue===',newValue);
console.log('oldValue===',oldValue);
})
</script>

当传入的itemList数量发生改变时,回调函数不会触发,正确的写法是加上其第三个参数deep:true

1
2
3
4
5
6
7
8
<script setup>
import { ref,watch } from 'vue';
...
watch(() => props.itemList,(newValue,oldValue) => {
console.log('newValue===',newValue);
console.log('oldValue===',oldValue);
},{deep:true})
</script>

watch也可以同时监听多个属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script setup>
import { ref,watch } from 'vue';

const props = defineProps({
title: String,
itemList: {
type: Array,
default: () => [{
text: 'title',
value: 0
}]
}
})
// 同时监听多个属性
watch(() => [props.itemList,props.title],(newValue,oldValue) => {
console.log('newValue===',newValue);
console.log('oldValue===',oldValue);
},{deep:true})

</script>

5. watchEffect()

watch()的懒执行不同的是,watchEffect()会立即执行一遍回调函数,如果这时函数产生了副作用,Vue会自动追踪副作用的依赖关系,自动分析出响应源。上面的例子可以重写为:

1
2
3
4
5
6
7
<script setup>
...
watchEffect(() => {
console.log('itemList===',props.itemList.length);
console.log('title===',props.title);
})
</script>

这个例子中,回调会立即执行。在执行期间,它会自动追踪props.itemList.length作为依赖(和计算属性的行为类似)。每当传入的itemList.length变化时,回调会再次执行。

如果要清除watchEffect()的的监听,只需要显示的调用watchEffect()的返回函数就可以了,例如:

1
2
3
4
5
6
7
8
<script setup>
...
const stopEffect = watchEffect(() => {
console.log('itemList===',props.itemList.length);
console.log('title===',props.title);
})
stopEffect()
</script>

watch 只追踪明确侦听的数据源。它不会追踪任何在回调中访问到的东西。另外,仅在数据源确实改变时才会触发回调。我们能更加精确地控制回调函数的触发时机。
watchEffect,则会在副作用发生期间追踪依赖。它会在同步执行过程中,自动追踪所有能访问到的响应式属性。

6.组件的事件调用

6.1 子组件调用父组件的方法

vue3.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
25
26
27
28
29
30
31
32
33
34
子组件
<script>
export default {
emits: ['inFocus', 'submit'],
setup(props, ctx) {
ctx.emit('submit',params)
}
}
// 或者将可以将emit解构使用
export default {
setup(props,{emit}) {
emit('submit',params)
}
}
</script>
父组件
<template>
<Children @submit="submitHandel"/>
</div>
</template>

<script>
export default {
name: 'TodoItem',
setup(props, { emit }) {
const submitHandel = () => {
console.log('子组件调用了父组件的submitHandel方法');
}
return {
submitHandel,
}
}
};
</script>

vue3.2语法糖中,子组件要触发的事件需要显式地通过 defineEmits() 宏来声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
子组件
<script setup>
const emit = defineEmits(['inFocus', 'submit'])

function buttonClick(parmas) {
emit('submit', parmas)
}
</script>
父组件
<template>
<Children @submit="submitHandel"/>
</div>
</template>

<script setup>
const submitHandel = () => {
console.log('子组件调用了父组件的submitHandel方法');
}
};
</script>
6.2 父组件调用子组件的方法或是属性

vue3.0中如果父组件触发子组件的方法或是属性,直接在return函数中返回就可以,数据都是默认隐式暴露给父组件的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script>
// 子组件
setup(props, { emit }) {
const isShow = ref(false)
// 父组件调用这个方法
const showSubComponent = () => {
isShow.value = !isShow.value
}
return {
// return 返回
showSubComponent,
}
}
</script>

父组件中通过ref获取到子组件,并对子组件暴露的方法进行访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
父组件
<template>
<div class="todo-list">
<TodoItemVue :itemList="itemList" @clickItemHandel="clickItemHandel" ref="todoItemVueRef" />
</div>
</template>
<script>
import { ref } from 'vue';
export default {
setup(props, { emit }) {
//获取子组件ref
const todoItemVueRef = ref(null)
// 调用子组件的方法
const callItemFuncHandel = () => {
todoItemVueRef.value.showSubComponent()
}
return {
todoItemVueRef
}
}
};
</script>

vue3.2语法中,父组件的调用方式相同,子组件通过defineExpose()将方法或是属性暴露出去

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
子组件
<script setup>
const isShow = ref(false)
// 父组件调用这个方法
const showSubComponent = () => {
isShow.value = !isShow.value
}
// 通过defineExpose将方法暴露出去
defineExpose({
showSubComponent
})
</script>
父组件
<template>
<div class="todo-list">
<TodoItemVue :itemList="itemList" @clickItemHandel="clickItemHandel" ref="todoItemVueRef" />
</div>
</template>
<script setup>
import { ref } from 'vue';
//获取子组件ref
const todoItemVueRef = ref(null)
// 调用子组件的方法
const callItemFuncHandel = () => {
todoItemVueRef.value.showSubComponent()
}
</script>

7.Vuex的使用

vue3.0vue3.2中创建Vuex没有区别,只不过在<template>模板中使用Vuex的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
import { createStore } from 'vuex';
import { ADD_ITEM_LIST, REDUCE_ITEM_LIST, CHANGE_ITEM_LIST_ASYNC } from './constants';

export default createStore({
state: {
itemList: [
{ text: 'Learn JavaScript', done: true },
{ text: 'Learn Vue', done: false },
{ text: 'Build something awesome', done: false },
],
},
getters: {
doneItemList: (state) => state.itemList.filter((todo) => todo.done),
},
mutations: {
// 使用ES2015风格的计算属性命名功能 来使用一个常量作为函数名
[ADD_ITEM_LIST](state, item) {
console.log('增加数据', item);
state.itemList.push(item);
},
[REDUCE_ITEM_LIST](state) {
console.log('减少数据');
state.itemList.pop();
},
},
actions: {
[CHANGE_ITEM_LIST_ASYNC]({ commit, state }, todoItem) {
/// 模拟网络请求
setTimeout(() => {
commit(ADD_ITEM_LIST, todoItem);
console.log('state===', state);
}, 1000);
},
},
modules: {
},
});

vue3.0中我们一般在return中对store.state进行解构,然后可以直接在<template>中使用state中的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<template>
<div class="todo-item">
<ol>
<li v-for="(item,index) in itemList" :key="index" class="todos" @click="clickItem(index)">
{{ item.text }}
</li>
</ol>
</div>
</template>
<script>
export default {
name: 'TodoItem',
setup(props, { emit }) {
return {
// 对store.state进行解构
...store.state,
clickItem,
count,
isShow,
showSubComponent,
}
}
};
</script>

vue3.2中没有了return,需要我们显示的获取要使用的stare的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<div class="todo-item">
<ol>
<li v-for="(item,index) in itemList" :key="index" class="todos" @click="clickItem(index)">
{{ item.text }}
</li>
</ol>
</div>
</template>
<script setup>
import { useStore } from 'vuex';
const store = useStore()
// 获取后在<template>中使用
const itemList = store.state.itemList
</script>

8. <style>中的 v-bind

<style>中的 v-bind: 用于在 SFC <style> 标签中启用组件状态驱动的动态 CSS 值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup>
import { ref, watchEffect } from 'vue';
const color = ref('black')
const callChangeColorHandel = () => {
if(color.value === 'black') {
color.value = 'red'
}else {
color.value = 'black'
}
}
</script>
<style lang="scss" scoped>
.todo-list {
color: v-bind(color);
}
</style>

触发callChangeColorHandel 函数,在<style>中的v-bind指令可以动态绑定的响应式状态。

三. 总结

整体来说,setup语法糖的引入简化了使用Composition API时冗长的模板代码,也就是让代码更加简洁,可读性也更高。并且官方介绍vue3.2在界面渲染的速度以及内存的使用量上都进行了优化,本文只是对setup语法糖的常用方式进行了总结,更多vue3.2新特性可以去官方文档查看。

一些参考:

Vue3.2

Vuex

从Vuex到Pinia

一. 概述

在开发Vue项目时,我们一般使用Vuex来进行状态管理,但是在使用Vuex时始终伴随着一些痛点。比如:需要使用Provide/Inject来定义类型化的InjectionKey以便支持TypeScript,模块的结构嵌套、命名空间以及对新手比较难理解的流程规范等。Pinia的出现很好的解决了这些痛点。本质上Pinia也是Vuex团队核心成员开发的,在Vuex的基础上提出了一些改进。与Vuex相比,Pinia去除了Vuex中对于同步函数Mutations和异步函数Actions的区分。并且实现了在Vuex5中想要的大部分内容。

二.使用

在介绍Pinia之前我们先来回顾一下Vuex的使用流程

1.Vuex

Vuex是一个专为Vue.js应用程序开发的状态管理库。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。它主要用来解决多个组件状态共享的问题。

Pic

主流程: 在Store中创建要共享的状态state,修改state流程:Vue Compontents dispatch Actions(在Actions中定义异步函数),Action commit Mutations,在Mutations中我们定义直接修改state的纯函数,state修改促使Vue compontents 做响应式变化。

(1) 核心概念
  • State: 就是组件所依赖的状态对象。我们可以在里面定义我们组件所依赖的数据。可以在Vue组件中通过this.$store.state.xxx获取state里面的数据.

  • Getter:从 store 中的 state 派生出的一些状态,可以把他理解为是store的计算属性.

  • Mutation:更改 store 中状态的唯一方法是提交 mutation,我们通过在mutation中定义方法来改变state里面的数据.

在Vue组件中,我们通过store.commit('方法名'),来提交mutation需要注意的是,Mutation 必须是同步函数

  • Action: action类似于 mutation,不同在于:

​ Action 提交的是 mutation,而不是直接变更状态.

​ Action 可以包含任意异步操作.

  • Module: 当我们的应用较大时,为了避免所有状态会集中到一个比较大的对象中,Vuex允许我们将 store 分割成模块(module),你可以把它理解为Redux中的combineReducer的作用.
(2) 在组合式API中对TypeScript的支持

在使用组合式API编写Vue组件时候,我们希望使用useStore返回类型化的store,流程大概如下:

  1. 定义类型化的 InjectionKey
  2. store 安装到 Vue 应用时提供类型化的 InjectionKey
  3. 将类型化的 InjectionKey传给useStore方法并简化useStore用法
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
// store.ts
import { createStore, Store, useStore as baseUseStore } from 'vuex'
import { InjectionKey } from 'vue'

export interface IState {
count: number
}

// 1.定义 injection key
export const key: InjectionKey<Store<IState>> = Symbol()

export default createStore<IState>({
state: {
count: 0
},
mutations: {
addCount (state:IState) {
state.count++
}
},
actions: {
asyncAddCount ({ commit, state }) {
console.log('state.count=====>', state.count++)

setTimeout(() => {
commit('addCount')
}, 2000)
}
},
})

// 定义自己的 `useStore` 组合式函数
export function useStore () {
return baseUseStore(key)
}

main.ts

1
2
3
4
5
6
7
8
9
import { createApp } from 'vue'
import { store, key } from './store'

const app = createApp({ ... })

// 传入 injection key
app.use(store, key)

app.mount('#app')

组件中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<script lang="ts">
import { defineComponent, toRefs } from 'vue'
import { useStore } from '../store'

export default defineComponent({
setup () {
const store = useStore()
const clickHandel = () => {
console.log('====>')
store.commit('addCount')
}

const clickAsyncHandel = () => {
console.log('====>')
store.dispatch('asyncAddCount')
}
return {
...toRefs(store.state),
clickHandel,
clickAsyncHandel
}
}
})
</script>

Pinia 的使用

截屏2022-07-11 21.19.55

基本特点

Pinia同样是一个Vue的状态管理工具,在Vuex的基础上提出了一些改进。与vuex相比,Pinia 最大的特点是:简便。

  • 它没有mutation,他只有stategettersaction,在action中支持同步与异步方法来修改state数据
  • 类型安全,与 TypeScript 一起使用时具有可靠的类型推断支持
  • 模块化设计,通过构建多个存储模块,可以让程序自动拆分它们。
  • 非常轻巧,只有大约 1kb 的大小。
  • 不再有 modules 的嵌套结构,没有命名空间模块
  • Pinia 支持扩展,可以非常方便地通过本地存储,事物等进行扩展。
  • 支持服务器端渲染

安装与使用

安装

1
2
3
yarn add pinia
# 或者使用 npm
npm install pinia

核心概念:

store: 使用defineStore()函数定义一个store,第一个参数是应用程序中store的唯一id. 里面包含stategettersactions, 与Vuex相比没有了Mutations.
1
2
3
4
5
6
7
8
9
10
11
12
export const useStore = defineStore('main', {
state: () => {
return {
name: 'ming',
doubleCount: 2
}
},
getters: {
},
actions: {
}
})

注意:store 是一个用reactive 包裹的对象,这意味着不需要在getter 之后写.value,但是,就像setup 中的props 一样,我们不能对其进行解构.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export default defineComponent({
setup() {
const store = useStore()
// ❌ 这不起作用,因为它会破坏响应式
// 这和从 props 解构是一样的
const { name, doubleCount } = store

return {
// 一直会是 "ming"
name,
// 一直会是 2
doubleCount,
// 这将是响应式的
doubleValue: computed(() => store.doubleCount),
}
},
})

当然你可以使用computed来响应式的获取state的值(这与Vuex中需要创建computed引用以保留其响应性类似),但是我们通常的做法是使用storeToRefs响应式解构Store.

1
2
3
const store = useStore()
// 正确的响应式解构
const { name, doubleCount } = storeToRefs(store)
State: 在Pinia中,状态被定义为返回初始状态的函数.
1
2
3
4
5
6
7
8
9
10
11
12
import { defineStore } from 'pinia'

const useStore = defineStore('main', {
// 推荐使用 完整类型推断的箭头函数
state: () => {
return {
// 所有这些属性都将自动推断其类型
counter: 0,
name: 'Eduardo'
}
},
})
组件中state的获取与修改:

Vuex中我们修改state的值必须在mutation中定义方法进行修改,而在pinia中我们有多中修改state的方式.

  • 基本方法:
    1
    2
    const store = useStore()
    store.counter++
  • 重置状态:
    1
    2
    const store = useStore()
    store.$reset()
  • 使用$patch修改state
    [1] 使用部分state对象进行修改
1
2
3
4
5
const mainStore = useMainStore()
mainStore.$patch({
name: '',
counter: mainStore.counter++
})

​ [2] $patch方法也可以接受一个函数来批量修改集合内部分对象的值

1
2
3
4
cartStore.$patch((state) => {
state.counter++
state.name = 'test'
})
  • 替换state
    可以通过将其 $state 属性设置为新对象,来替换Store的整个状态:

    1
    mainStore.$state = { name: '', counter: 0 }
  • 访问其他模块的state

    • Vuex中我们要访问其他带命名空间的模块的state我们需要使用rootState
    1
    2
    3
    4
    5
    6
    7
    8
    addAsyncTabs ({ state, commit, rootState, rootGetters }:ActionContext<TabsState, RootState>, tab:ITab): void {
    /// 通过rootState 访问main的数据
    console.log('rootState.main.count=======', rootState.main.count)
    if (state.tabLists.some(item => item.id === tab.id)) { return }
    setTimeout(() => {
    state.tabLists.push(tab)
    }, 1000)
    },
    • Pinia 中访问其他storestate

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      import { useInputStore } from './inputStore'

      export const useListStore = defineStore('listStore', {
      state: () => {
      return {
      itemList: [] as IItemDate[],
      counter: 0
      }
      },
      getters: {
      },
      actions: {
      addList (item: IItemDate) {
      this.itemList.push(item)
      ///获取store,直接调用
      const inputStore = useInputStore()
      inputStore.inputValue = ''
      }
      })

Getter: Getter完全等同于Store状态的计算值.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const useStore = defineStore('main', {
state: () => ({
counter: 0,
}),
getters: {
// 自动将返回类型推断为数字
doubleCount(state) {
return state.counter * 2
},
// 返回类型必须明确设置
doublePlusOne(): number {
return this.counter * 2 + 1
},
},
})

如果需要使用this访问到 整个store的实例,在TypeScript需要定义返回类型.
setup()中使用:

1
2
3
4
5
6
7
8
export default {
setup() {
const store = useStore()

store.counter = 3
store.doubleCount // 6
},
}
  • 访问其他模块的getter

    • 对于Vuex而言如果要访问其他命名空间模块的getter,需要使用rootGetters属性

      1
      2
      3
      4
      5
      /// action 方法
      addAsyncTabs ({ state, commit, rootState, rootGetters }:ActionContext<TabsState, RootState>, tab:ITab): void {
      /// 通过rootGetters 访问main的数据
      console.log('rootGetters[]=======', rootGetters['main/getCount'])
      }
    • Pinia中访问其他store中的getter

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      import { useOtherStore } from './other-store'

      export const useStore = defineStore('main', {
      state: () => ({
      // ...
      }),
      getters: {
      otherGetter(state) {
      const otherStore = useOtherStore()
      return state.localData + otherStore.data
      },
      },
      })

Action:actions 相当于组件中的methods,使用defineStore()中的 actions 属性定义.

1
2
3
4
5
6
7
8
9
10
11
12
13
export const useStore = defineStore('main', {
state: () => ({
counter: 0,
}),
actions: {
increment() {
this.counter++
},
randomizeCounter() {
this.counter = Math.round(100 * Math.random())
},
},
})

pinia中没有mutation属性,我们可以在action中定义业务逻辑,action可以是异步的,可以在其中await 任何 API调用甚至其他操作.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
//定义一个action
asyncAddCounter () {
setTimeout(() => {
this.counter++
}, 1000)
}
...
///setup()中调用
export default defineComponent({
setup() {
const main = useMainStore()
// Actions 像 methods 一样被调用:
main.asyncAddCounter()
return {}
}
})
  • 访问其他store中的Action

    要使用另一个 store中的action ,可以直接在操作内部使用它:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    import { useAuthStore } from './auth-store'

    export const useSettingsStore = defineStore('settings', {
    state: () => ({
    // ...
    }),
    actions: {
    async fetchUserPreferences(preferences) {
    const auth = useAuthStore()
    ///调用其他store的action
    if (auth.isAuthenticated()) {
    this.preferences = await fetchPreferences()
    } else {
    throw new Error('User must be authenticated')
    }
    },
    },
    })

    Vuex中如果要调用另一个模块的Action,我们需要在当前模块中注册该方法为全局的Action

    1
    2
    3
    4
    5
    6
    7
    /// 注册全局Action
    globalSetCount: {
    root: true,/// 设置root 为true
    handler ({ commit }:ActionContext<MainState, RootState>, count:number):void {
    commit('setCount', count)
    }
    }

    在另一个模块中对其进行dispatch调用

    1
    2
    3
    4
    /// 调用全局命名空间的函数
    handelGlobalAction ({ dispatch }:ActionContext<TabsState, RootState>):void {
    dispatch('globalSetCount', 100, { root: true })
    }

三. 总结

与 Vuex 相比,Pinia 提供了一个更简单的 API,具有更少的操作,提供Composition API,最重要的是,在与TypeScript一起使用时具有可靠的类型推断支持,如果你正在开发一个新项目并且使用了TypeScript,可以尝试一下pinia,相信不会让你失望。

一些参考:

Vuex

Pinia

iOS-多线程使用总结

网络图片

一.概述与实现方案

1. 线程与进程

多线程在iOS中有着举足轻重的地位,是每一位开发者都必备的技能,当然也是面试常考的技术点,本文主要是探究我们实际开发或者面试中遇到的多线程问题。比如什么是线程?它跟进程是什么关系,队列跟线程什么关系,同步、异步、并发(并行)、串行这些概念又怎么来理解,iOS有哪些常用多线程方案,以及线程同步技术有哪些等等。

线程(英语:thread)是操作系统能够进行运算调度的最小单位。大部分情况下,它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。 — 维基百科

这里又多了一个 进程,那什么是进程呢,说白了就是是指在操作系统中正在运行的一个应用程序,如微信、支付宝app等都是一个进程。线程是就是进程的基本执行单元,一个进程的所有任务都在线程中执行。也就是说 一个进程最少要有一个线程,这个线程就是主线程。当然在我们实际使用过程中不可能只有一条主线程,我们为提高程序的执行效率,往往需要开辟多条子线程去执行一些耗时任务,这里就引出了多线程的概念。

多线程(英语:multithreading),是指从软件或者硬件上实现多个线程并发执行的技术

根据操作系统与硬件的不同分为两类:软件多线程硬件多线程

  • 软件多线程: 即便CPU只能运行一个线程,操作系统也可以通过快速的在不同线程之间进行切换,由于时间间隔很小,来给用户造成一种多个线程同时运行的假象

  • 硬件多线程: 如果CPU有多个核心,操作系统可以让每个核心执行一条线程,从而具有真正的同时执行多个线程的能力,当然由于任务数量远远多于CPU的核心数量,所以,操作系统也会自动把很多任务轮流调度到每个核心上执行。
    以上都是google出来的一大堆东西,比较抽象,没关系我们来看下我们实际iOS开发中用到的多线程技术。

2.iOS中的多线程方案

iOS 中的多线程方案主要有四种 PThreadNSThreadGCDNSOperationPThread 是一套纯粹C语言的API,能适用于Unix\Linux\Windows等系统,线程生命周期需要程序员自己管理,使用难度较大,在我们的实际开发中几乎用不到,在这里我们不做过多介绍,感兴趣的直接去百度。我们着重介绍另外三中方案。

这里解释一下线程的生命周期,所谓的线程的生命周期就是线程从创建到死亡的过程。一般会经历:新建 - 就绪 - 运行 - 阻塞 - 死亡的过程。

  • 新建:就是初始化线程对象
  • 就绪:向线程对象发送start消息,线程对象被加入可调度线程池等待CPU调度。
  • 运行:CPU 负责调度可调度线程池中线程的执行,线程执行完成之前,状态可能会在就绪和运行之间来回切换。就绪和运行之间的状态变化由CPU负责,程序员不能干预。
  • 阻塞:当满足某个预定条件时,可以使用休眠或锁,阻塞线程执行
  • 死亡:线程执行完毕,退出,销毁。
(1) NSThread

NSThread是苹果官方提供面向对象操作线程的技术,简单方便,可以直接操作线程对象,不过需要自己控制线程的生命周期,我们看下苹果官方给出的方法。

[1] 初始化方法
  • 实例初始化方法
    1
    2
    3
    4
    - (instancetype)init API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)) NS_DESIGNATED_INITIALIZER;
    - (instancetype)initWithTarget:(id)target selector:(SEL)selector object:(nullable id)argument API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));
    - (instancetype)initWithBlock:(void (^)(void))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));

    对应的初始化方法:
1
2
3
4
5
6
//创建线程
NSThread *newThread = [[NSThread alloc]initWithTarget:self selector:@selector(demo:) object:@"Thread"];
NSThread *newThread = [[NSThread alloc]init];
NSThread *newThread = [[NSThread alloc]initWithBlock:^{
NSLog(@"Block");
}];

注意三种方法创建完成后都需要执行 [newThread start] 去启动线程。

  • 类初始化方法
    1
    2
    + (void)detachNewThreadWithBlock:(void (^)(void))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));
    + (void)detachNewThreadSelector:(SEL)selector toTarget:(id)target withObject:(nullable id)argument;

    注意这两个类方法创建后就可执行,不需手动开启

[2] 取消退出

既然有了创建,那就得有退出

1
2
3
4
// 实例方法 取消线程
- (void)cancel;
//类方法 退出
+ (void)exit;
[3] 线程执行状态
1
2
3
4
5
6
// 线程正在执行
@property (readonly, getter=isExecuting) BOOL executing API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));
// 线程执行结束
@property (readonly, getter=isFinished) BOOL finished API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));
// 线程是否可取消
@property (readonly, getter=isCancelled) BOOL cancelled API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));
[4] 线程间的通信方法
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
@interface NSObject (NSThreadPerformAdditions)
/*
* 去主线程执行指定方法
* aSelector: 方法
* arg: 参数
* wait:表示是否等待主线程做完事情后往下走,YES表示做完后执行下面事情,NO表示跟下面事情一起执行
*/
- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(nullable id)arg waitUntilDone:(BOOL)wait modes:(nullable NSArray<NSString *> *)array;
- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(nullable id)arg waitUntilDone:(BOOL)wait;
/*
* 去指定线程执行指定方法
* aSelector: 方法
* arg: 参数
* wait:表示是否等待本线程做完事情后往下走,YES表示做完后执行下面事,NO表示跟下面事一起执行
*/
- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait modes:(nullable NSArray<NSString *> *)array API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));
- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));
/*
* 去开启的子线程执行指定方法
* SEL: 方法
* arg: 参数
*/
- (void)performSelectorInBackground:(SEL)aSelector withObject:(nullable id)arg API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));

@end

我们常说的线程间的通信所用的方法其实就是上面的这几个方法,所有继承NSObject实例化对象都可调用。当然还有其他方法也可以实现线程间的通信,如:GCDNSOperationNSMachPort端口等形式,我们后面用到在做介绍。
举个简单的例子:我们在子线程中下载图片,然后去主线程展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
// 子线程执行下载方法
[self performSelectorInBackground:@selector(download) withObject:nil];
}
- (void)download{
//图片的网络路径
NSURL *url = [NSURL URLWithString:@"https://p3.ssl.qhimg.com/t011e94f0b9ed8e66b0.png"];
//下载图片数据
NSData *data = [NSData dataWithContentsOfURL:url];
//生成图片
UIImage *image = [UIImage imageWithData:data];
// 回主线程显示图片
[self performSelectorOnMainThread:@selector(showImage:) withObject:image waitUntilDone:YES];
}
- (void)showImage:(UIImage *)image{
self.imageView.image = image;
}
[5] 其他常用方法
  • +(void)currentThread 获取当前线程
  • +(BOOL)isMultiThreaded 判断当前是否运行在子线程
  • -(BOOL)isMainThread 判断是否在主线程
  • +(void)sleepUntilDate:(NSDate *)date;+ (void)sleepForTimeInterval:(NSTimeInterval)ti; 当前线程休眠时间
(3). GCD

在介绍GCD前我们先来了解下多线程中比较容易混淆的几个概念

[1]. 同步、异步、并发(并行)、串行
  • 同步和异步主要影响:能不能开启新的线程
    同步:在当前线程中执行任务,不具备开启新线程的能力
    异步:在新的线程中执行任务,具备开启新线程的能力

  • 并发和串行主要影响:任务的执行方式
    并发:也叫并行,也叫并行队列,多个任务并发(同时)执行
    串行:也叫串行队列,一个任务执行完毕后,再执行下一个任务

单纯的介绍概念比较抽象,我们还是结合实际使用来说明:

[2] GCD 中的同步、异步方法
  • 同步执行方法:dispatch_sync()
  • 异步执行方法:dispatch_async()
    使用方法:
    1
    2
    dispatch_sync(dispatch_queue_t queue, DISPATCH_NOESCAPE dispatch_block_t block);
    dispatch_async(dispatch_queue_t queue, dispatch_block_t block);
    可以看到这个两个方法需要两个参数,第一个参数需要传入一个dispatch_queue_t 类型的队列,第二个是执行的block。下面介绍一下GCD的队列
[3] GCD 中的队列

GCD中的队列有三种:串行队列、并行队列、主队列,创建方式也非常简单:

  • 串行队列
    1
    dispatch_queue_t queue1 = dispatch_queue_create("myQueue", DISPATCH_QUEUE_SERIAL);
    第一个参数是队列名称,第二个是一个宏定义,常用的两个宏 DISPATCH_QUEUE_SERIALDISPATCH_QUEUE_CONCURRENT分别表示串行队列和并行队列,除此之外,宏DISPATCH_QUEUE_SERIAL_INACTIVEDISPATCH_QUEUE_CONCURRENT_INACTIVE 分别表示初始化的串行队列和并行队列处于不可活动状态。看下它的底层实现
    1
    2
    3
    4
    5
    6
    7
    8
    9
    dispatch_queue_attr_t
    dispatch_queue_attr_make_initially_inactive(
    dispatch_queue_attr_t _Nullable attr);

    #define DISPATCH_QUEUE_SERIAL_INACTIVE \
    dispatch_queue_attr_make_initially_inactive(DISPATCH_QUEUE_SERIAL)

    #define DISPATCH_QUEUE_CONCURRENT_INACTIVE \
    dispatch_queue_attr_make_initially_inactive(DISPATCH_QUEUE_CONCURRENT)

    应当注意的是,初始化后处于不可活动状态的队列,添加到其中的任务要想开始执行,必须先调用 dispatch_activate()函数使其状态变更为可活动状态.

  • 并行队列
    并行队列有两种:

    第一种:全局并发队列创建方法,也是系统为我们创建好的并发队列,创建方式
1
2
3
4
5
6
7
8
9
/*  - QOS_CLASS_USER_INTERACTIVE
* - QOS_CLASS_USER_INITIATED
* - QOS_CLASS_DEFAULT
* - QOS_CLASS_UTILITY
* - QOS_CLASS_BACKGROUND
*/
//dispatch_get_global_queue(intptr_t identifier, uintptr_t flags);

dispatch_queue_t queue = dispatch_get_global_queue(0,0);

这里有两个参数,第一个参数标识线程执行优先级,第二个是苹果保留参数传参:0 就可以。

第二种:手动创建并发队列

1
2
// 串行执行,第一个参数是名称 ,第二个是标识:DISPATCH_QUEUE_CONCURRENT,并发队列标识
dispatch_queue_t queue = dispatch_queue_create("myQueue",DISPATCH_QUEUE_CONCURRENT);
  • 主队列
    主队列是一种特殊的串行队列
    1
    dispatch_queue_t queue = dispatch_get_main_queue();
    同步、异步以及队列的组合就可以实现对任务进行多线程编程的需求了。
  1. 同步串行队列

    1
    2
    3
    4
    5
    6
    7
      dispatch_queue_t queue1 = dispatch_queue_create("myQueue", DISPATCH_QUEUE_SERIAL);
    for(NSInteger i = 0; i < 10; i++){
    dispatch_sync(queue1, ^{
    NSLog(@"thread == %@ i====%ld",[NSThread currentThread],(long)i);
    });
    }
    //thread == <NSThread: 0x6000011b8880>{number = 1, name = main} i====n

    可以看到没有开启新的线程,都是在主线程中执行任务,并且是顺序执行的

  2. 同步并行队列

1
2
3
4
5
6
7
dispatch_queue_t queue1 = dispatch_queue_create("myQueue", DISPATCH_QUEUE_CONCURRENT);
for(NSInteger i = 0; i < 10; i++){
dispatch_sync(queue1, ^{
NSLog(@"thread == %@ i====%ld",[NSThread currentThread],(long)i);
});
}
// thread == <NSThread: 0x600001db8a00>{number = 1, name = main} i====n

也是在主线程中顺序执行。
3. 异步串行队列

1
2
3
4
5
6
dispatch_queue_t queue1 = dispatch_queue_create("myQueue", DISPATCH_QUEUE_SERIAL);
for(NSInteger i = 0; i < 10; i++){
dispatch_async(queue1, ^{
NSLog(@"thread == %@ i====%ld",[NSThread currentThread],(long)i);
});
}

开启子线程,顺序执行任务
4. 异步并发队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 dispatch_queue_t queue1 = dispatch_queue_create("myQueue", DISPATCH_QUEUE_CONCURRENT);
for(NSInteger i = 0; i < 10; i++){
dispatch_async(queue1, ^{
NSLog(@"thread == %@ i====%ld",[NSThread currentThread],(long)i);
});
}
/*
thread == <NSThread: 0x6000024f9440>{number = 4, name = (null)} i====0
thread == <NSThread: 0x6000024f5340>{number = 5, name = (null)} i====2
thread == <NSThread: 0x6000024a8780>{number = 3, name = (null)} i====3
thread == <NSThread: 0x6000024ac6c0>{number = 6, name = (null)} i====1
thread == <NSThread: 0x6000024f4a80>{number = 8, name = (null)} i====5
thread == <NSThread: 0x6000024b0b40>{number = 7, name = (null)} i====4
thread == <NSThread: 0x60000249cd00>{number = 9, name = (null)} i====6
thread == <NSThread: 0x6000024b0980>{number = 10, name = (null)} i====7
thread == <NSThread: 0x6000024cb900>{number = 11, name = (null)} i====8
thread == <NSThread: 0x6000024f5340>{number = 5, name = (null)} i====9
*/

开启了多个子线程,并且是并发执行任务。

注意 dispatch_async()具备开辟新线程的能力,但是不表示使用它就一定会开辟新的线程。 例如 传入的 queue 是主队列,就是在主线程中执行任务,没有开辟新线程。

1
2
3
4
5
6
7
8
  dispatch_queue_t queue1 = dispatch_get_main_queue();
for(NSInteger i = 0; i < 10; i++){
sleep(2);
dispatch_async(queue1, ^{
NSLog(@"thread == %@ i====%ld",[NSThread currentThread],(long)i);
});
}
//thread == <NSThread: 0x600002b24880>{number = 1, name = main} i====n

主队列是一种特殊的串行队列,从打印结果看出,这里执行方式是串行,而且没有开启新的线程。

具体任务的执行方式可以参考下面的表格
执行方式

[4] dispatch_ group_ t 队列组

dispatch_group_t是一个比较实用的方法,通过构造一个组的形式,将各个同步或异步提交任务都加入到同一个组中,当所有任务都完成后会收到通知,用于进一步处理.举个简单的例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
dispatch_group_t group = dispatch_group_create();

dispatch_group_async(group, concurrentQueue, ^{
for (int i = 0; i < 10; i++)
{
NSLog(@"Task1 %@ %d", [NSThread currentThread], i);
}
});
dispatch_group_async(group, dispatch_get_main_queue(), ^{
for (int i = 0; i < 10; i++)
{
NSLog(@"Task2 %@ %d", [NSThread currentThread], i);
}
});
dispatch_group_async(group, concurrentQueue, ^{
for (int i = 0; i < 10; i++)
{
NSLog(@"Task3 %@ %d", [NSThread currentThread], i);
}
});
dispatch_group_notify(group, concurrentQueue, ^{
NSLog(@"All Task Complete");
});
[5] diapatch_barrier_async 栅栏异步调用函数

有异步调用就也有同步调用函数diapatch_barrier_sync(),两者的区别:dispatch_barrier_sync 需要等待栅栏执行完才会执行栅栏后面的任务,而dispatch_barrier_async 无需等待栅栏执行完,会继续往下走,有什么用呢?其实栅栏函数用的最多的地方还是实现线程同步使用,比如我们有这样一个需求:怎么样利用GCD实现多读单写文件的IO操作?也就是怎么样实现多读单写,看代码:

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
@interface UserCenter()
{
// 定义一个并发队列
dispatch_queue_t concurrent_queue;

// 用户数据中心, 可能多个线程需要数据访问
NSMutableDictionary *userCenterDic;
}

// 多读单写模型
@implementation UserCenter

- (id)init
{
self = [super init];
if (self) {
// 通过宏定义 DISPATCH_QUEUE_CONCURRENT 创建一个并发队列
concurrent_queue = dispatch_queue_create("read_write_queue", DISPATCH_QUEUE_CONCURRENT);
// 创建数据容器
userCenterDic = [NSMutableDictionary dictionary];
}

return self;
}

- (id)objectForKey:(NSString *)key
{
__block id obj;
// 同步读取指定数据,立刻返回读取结果
dispatch_sync(concurrent_queue, ^{
obj = [userCenterDic objectForKey:key];
});

return obj;
}

- (void)setObject:(id)obj forKey:(NSString *)key
{
// 异步栅栏调用设置数据
dispatch_barrier_async(concurrent_queue, ^{
[userCenterDic setObject:obj forKey:key];
});
}

可以看到把写操作放入栅栏函数,可以实现线程同步效果
注意:使用dispatch_barrier_async ,该函数只能搭配自定义并发队列 dispatch_queue_t 使用。不能使用全局并发队列: dispatch_get_global_queue,否则 dispatch_barrier_async无作用。

[6] 线程死锁

先来看两个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
dispatch_queue_t queue = dispatch_get_main_queue();
dispatch_sync(queue, ^{
NSLog(@"执行任务2");
});// 往主线程里面 同步添加任务 会发生死锁现象

dispatch_queue_t myQueue = dispatch_queue_create("myQueue", DISPATCH_QUEUE_SERIAL);

dispatch_async(myQueue, ^{
NSLog(@"1111,thread====%@",[NSThread currentThread]);

dispatch_sync(myQueue, ^{
NSLog(@"2222,thread====%@",[NSThread currentThread]);
});
});
// 1111,thread====<NSThread: 0x6000022dd880>{number = 5, name = (null)}
// crash

上面的例子可以看出,不能向当前的串行队列,同步添加任务,否则会产生死锁导致crash。线程死锁的条件:使用sync函数往当前串行队列里面添加任务,会产生死锁。

(4). NSOperation

NSOperation 是苹果对GCD面向对象的封装,它的底层是基于GCD实现的,相比于GCD它添加了更多实用的功能

  • 可以添加任务依赖
  • 任务执行状态的控制
  • 设置最大并发数
    它有两个核心类分别是NSOperationNSOperationQueue,NSOperation就是对任务进行的封装,封装好的任务交给不同的NSOperationQueue即可进行串行队列的执行或并发队列的执行。
[1] NSOperation

NSOperation 是一个抽象类,并不能直接实用,必须使用它的子类,有三种方式:NSInvocationOperationNSBlockOperation自定义子类继承NSOperation,前两中是苹果为我们封装好的,可以直接使用,自定义子类,需要我们实现相应的方法。

  • NSBlockOperation & NSInvocationOperation
    使用:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    //创建一个NSBlockOperation对象,传入一个block
    NSBlockOperation *operation = [NSBlockOperation blockOperationWithBlock:^{
    for (int i = 0; i < 5; i++)
    {
    NSLog(@"Task1 %@ %d", [NSThread currentThread], i);
    }
    }];

    /*
    创建一个NSInvocationOperation对象,指定执行的对象和方法
    该方法可以接收一个参数即object
    */
    NSInvocationOperation *invocationOperation = [[NSInvocationOperation alloc] initWithTarget:self selector:@selector(task:) object:@"Hello, World!"];

    // 执行
    [operation start];
    [invocationOperation start];

    // 打印: Task1 <NSThread: 0x6000019581c0>{number = 1, name = main} 0
    可以看到创建这两个任务对象去执行任务,并没有开启新线程。NSBlockOperation 相比 NSInvocationOperation 多了个addExecutionBlock 追加任务的方法,
    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
     NSBlockOperation *operation = [NSBlockOperation blockOperationWithBlock:^{
    for (int i = 0; i < 5; i++)
    {
    NSLog(@"task1=====%@ %d", [NSThread currentThread], i);
    }
    }];

    [operation addExecutionBlock:^{

    NSLog(@"task2=====%@",[NSThread currentThread]);
    }];

    [operation addExecutionBlock:^{

    NSLog(@"task3=====%@",[NSThread currentThread]);
    }];

    [operation addExecutionBlock:^{

    NSLog(@"task4=====%@",[NSThread currentThread]);
    }];

    [operation start];
    /*
    task3=====<NSThread: 0x600000509840>{number = 6, name = (null)}
    task4=====<NSThread: 0x600000530200>{number = 3, name = (null)}
    task1=====<NSThread: 0x600000558880>{number = 1, name = main} 0
    task2=====<NSThread: 0x600000511680>{number = 5, name = (null)}
    task1=====<NSThread: 0x600000558880>{number = 1, name = main} 1
    task1=====<NSThread: 0x600000558880>{number = 1, name = main} 2
    task1=====<NSThread: 0x600000558880>{number = 1, name = main} 3
    task1=====<NSThread: 0x600000558880>{number = 1, name = main} 4
    */

    使用addExecutionBlock追加的任务是并发执行的,如果这个操作的任务数大于1那么会开启子线程并发执行任务,这里追加的任务不一定就是子线程,也有可能是主线程。

[2] NSOperationQueue

NSOperationQueue 有两种队列,一个是主队列通过[NSOperationQueue mainQueue] 获取,还有一个是自己创建的队列[[NSOperationQueue alloc] init],它同时具备并发跟串行的能力,可以通过设置最大并发数来决定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 NSBlockOperation *operation = [NSBlockOperation blockOperationWithBlock:^{
for (int i = 0; i < 5; i++)
{
NSLog(@"task1=====%@ %d", [NSThread currentThread], i);
}
}];

NSBlockOperation *operation2 = [NSBlockOperation blockOperationWithBlock:^{
for (int i = 0; i < 5; i++)
{
NSLog(@"Task2===== %@ %d", [NSThread currentThread], i);
}
}];

NSOperationQueue *queues = [[NSOperationQueue alloc] init];
[queues setMaxConcurrentOperationCount:2];//设置最大并发数,如果设置为1则串行执行
[queues addOperation:operation];
[queues addOperation:operation2];
/*
Task2===== <NSThread: 0x600000489940>{number = 4, name = (null)} 0
task1=====<NSThread: 0x6000004e15c0>{number = 5, name = (null)} 0
*/

这个例子有两个任务,如果设置最大并发数为2,则会开辟两个线程,并发执行这两个任务。如果设置为1,则会在新的线程中串行执行。

[3] 任务依赖

addDependency可以建立两个任务之间的依赖关系,如[operation2 addDependency:operation1]; 为任务2依赖任务1,必须等任务1执行完成后才会执行任务2,看个例子

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
  NSBlockOperation *operation = [NSBlockOperation blockOperationWithBlock:^{
for (int i = 0; i < 5; i++)
{
NSLog(@"task1=====%@ %d", [NSThread currentThread], i);
}
}];

NSBlockOperation *operation2 = [NSBlockOperation blockOperationWithBlock:^{
for (int i = 0; i < 5; i++)
{
NSLog(@"Task2===== %@ %d", [NSThread currentThread], i);
}
}];

NSOperationQueue *queues = [[NSOperationQueue alloc] init];
[queues setMaxConcurrentOperationCount:2];
//设置任务依赖
[operation addDependency:operation2];

[queues addOperation:operation];
[queues addOperation:operation2];
/*
Task2===== <NSThread: 0x6000005b5dc0>{number = 6, name = (null)} 0
Task2===== <NSThread: 0x6000005b5dc0>{number = 6, name = (null)} 1
Task2===== <NSThread: 0x6000005b5dc0>{number = 6, name = (null)} 2
Task2===== <NSThread: 0x6000005b5dc0>{number = 6, name = (null)} 3
Task2===== <NSThread: 0x6000005b5dc0>{number = 6, name = (null)} 4
task1=====<NSThread: 0x6000005b5dc0>{number = 6, name = (null)} 0
task1=====<NSThread: 0x6000005b5dc0>{number = 6, name = (null)} 1
task1=====<NSThread: 0x6000005b5dc0>{number = 6, name = (null)} 2
task1=====<NSThread: 0x6000005b5dc0>{number = 6, name = (null)} 3
task1=====<NSThread: 0x6000005b5dc0>{number = 6, name = (null)} 4
*/

还是已上面的例子,设置[operation addDependency:operation2];,可以看到任务2完成后才会执行任务1的操作。

[4] 自定义NSOperation

任务执行状态的控制是相对于自定义的NSOperation子类来说的。对于自定义NSOperation子类有两种类型:

  1. 重写main方法
    只重写operation的main方法,main方法里面写要执行的任务,系统底层控制变更任务执行完成状态,以及任务的退出。看个例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#import "TestOperation.h"

@interface TestOperation ()
@property (nonatomic, copy) id obj;

@end

@implementation TestOperation

- (instancetype)initWithObject:(id)obj{
if(self = [super init]){
self.obj = obj;
}
return self;
}

- (void)main{
NSLog(@"开始执行任务%@ thread===%@",self.obj,[NSThread currentThread]);
}

调用

1
2
3
4
5
6
7
8
  TestOperation *operation4 = [[TestOperation alloc] initWithObject:[NSString stringWithFormat:@"我是任务4"]];
[operation4 setCompletionBlock:^{
NSLog(@"执行完成 thread===%@",[NSThread currentThread]);
}];
[operation4 start];
// 打印
开始执行任务我是任务4 thread===<NSThread: 0x6000008d8880>{number = 1, name = main}
执行完成 thread===<NSThread: 0x60000089fa40>{number = 7, name = (null)}

可以看到任务operation的main方法执行是在主线程中的,只是最后完成后的回调setCompletionBlock是异步的,好像没什么用,别着急,我们把他放入队列中执行看下,还是上面的例子,加入队列执行

1
2
3
4
5
6
7
8
9
10
11
12
 NSOperationQueue *queue4 = [[NSOperationQueue alloc] init];
TestOperation *operation4 = [[TestOperation alloc] initWithObject:[NSString stringWithFormat:@"我是任务4"]];
TestOperation *operation5 = [[TestOperation alloc] initWithObject:[NSString stringWithFormat:@"我是任务5"]];
TestOperation *operation6 = [[TestOperation alloc] initWithObject:[NSString stringWithFormat:@"我是任务6"]];

[queue4 addOperation:operation4];
[queue4 addOperation:operation5];
[queue4 addOperation:operation6];
//打印:
开始执行任务我是任务6 thread===<NSThread: 0x600001fc8200>{number = 5, name = (null)}
开始执行任务我是任务4 thread===<NSThread: 0x600001fcc040>{number = 6, name = (null)}
开始执行任务我是任务5 thread===<NSThread: 0x600001fd7c80>{number = 7, name = (null)}

这时候可以看到任务的并发执行了,operation的main方法执行结束后就会调用各自的dealloc方法进行释放,任务的生命周期结束。如果我们想让任务4、5、6 倒序执行,可以添加任务依赖

1
2
3
4
5
6
 [operation4 addDependency:operation5];
[operation5 addDependency:operation6];
// 打印
开始执行任务我是任务6 thread===<NSThread: 0x600003d04680>{number = 6, name = (null)}
开始执行任务我是任务5 thread===<NSThread: 0x600003d04680>{number = 6, name = (null)}
开始执行任务我是任务4 thread===<NSThread: 0x600003d04680>{number = 6, name = (null)}

这样做貌似是可以的,但是如果我们的operation 中又存在异步任务(如网络请求),我们想让网络任务6请求完后调用任务5,任务5调用成功后调任务4,那该怎么办呢,我们先卖个关子,我们在第二节多个请求完成后继续进行下一个请求的方法总结中介绍。
2. 重写start方法
通过重写main方法可以实现任务的串行执行,如果要让任务并发执行,就需要重写start方法。两者还是有很大区别的:
如果只是重写main方法,方法执行完毕,那么整个operation就会从队列中被移除。如果你是一个自定义的operation并且它是某些类的代理,这些类恰好有异步方法,这时就会找不到代理导致程序出错了。然而start方法就算执行完毕,它的finish属性也不会变,因此你可以控制这个operation的生命周期了。然后在任务完成之后手动cancel掉这个operation即可。

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
@interface TestStartOperation : NSOperation
- (instancetype)initWithObject:(id)obj;
@property (nonatomic, copy) id obj;
@property (nonatomic, assign, getter=isExecuting) BOOL executing;
@property (nonatomic, assign, getter=isFinished) BOOL finished;
@end
@implementation TestStartOperation
@synthesize executing = _executing;
@synthesize finished = _finished;

- (instancetype)initWithObject:(id)obj{
if(self = [super init]){
self.obj = obj;
}
return self;
}
- (void)start{

//在任务开始前设置executing为YES,在此之前可能会进行一些初始化操作
self.executing = YES;
NSLog(@"开始执行任务%@ thread===%@",self.obj,[NSThread currentThread]);
/*
需要在适当的位置判断外部是否调用了cancel方法
如果被cancel了需要正确的结束任务
*/
if (self.isCancelled)
{
//任务被取消正确结束前手动设置状态
self.executing = NO;
self.finished = YES;
return;
}

NSString *str = @"https://www.360.cn";
NSURL *url = [NSURL URLWithString:str];
NSURLRequest *request = [NSURLRequest requestWithURL:url];
NSURLSession *session = [NSURLSession sharedSession];
__weak typeof(self) weakSelf = self;
NSURLSessionDataTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
// NSLog(@"response==%@",response);
NSLog(@"TASK完成:====%@ thread====%@",weakSelf.obj,[NSThread currentThread]);
//任务执行完成后手动设置状态
weakSelf.executing = NO;
weakSelf.finished = YES;
}];
[task resume];
}
- (void)setExecuting:(BOOL)executing
{
//手动调用KVO通知
[self willChangeValueForKey:@"isExecuting"];
_executing = executing;
//调用KVO通知
[self didChangeValueForKey:@"isExecuting"];
}
- (BOOL)isExecuting
{
return _executing;
}
- (void)setFinished:(BOOL)finished
{
//手动调用KVO通知
[self willChangeValueForKey:@"isFinished"];
_finished = finished;
//调用KVO通知
[self didChangeValueForKey:@"isFinished"];
}
- (BOOL)isFinished
{
return _finished;
}
- (BOOL)isAsynchronous
{
return YES;
}
- (void)dealloc{
NSLog(@"Dealloc %@",self.obj);
}

执行与结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
NSOperationQueue *queue4 = [[NSOperationQueue alloc] init];
TestStartOperation *operation4 = [[TestStartOperation alloc] initWithObject:[NSString stringWithFormat:@"我是任务4"]];
TestStartOperation *operation5 = [[TestStartOperation alloc] initWithObject:[NSString stringWithFormat:@"我是任务5"]];
TestStartOperation *operation6 = [[TestStartOperation alloc] initWithObject:[NSString stringWithFormat:@"我是任务6"]];
//设置任务依赖
[operation4 addDependency:operation5];
[operation5 addDependency:operation6];
[queue4 addOperation:operation4];
[queue4 addOperation:operation5];
[queue4 addOperation:operation6];
/*打印
开始执行任务我是任务6 thread===<NSThread: 0x600002bb8480>{number = 6, name = (null)}
TASK完成:====我是任务6 thread====<NSThread: 0x600002bd4d80>{number = 8, name = (null)}
开始执行任务我是任务5 thread===<NSThread: 0x600002bb0300>{number = 5, name = (null)}
TASK完成:====我是任务5 thread====<NSThread: 0x600002bb0300>{number = 5, name = (null)}
开始执行任务我是任务4 thread===<NSThread: 0x600002bfb080>{number = 7, name = (null)}
TASK完成:====我是任务4 thread====<NSThread: 0x600002bfb080>{number = 7, name = (null)}
2021-06-22 17:57:56.436591+0800 Interview01-打印[15994:9172130] Dealloc 我是任务4
2021-06-22 17:57:56.436690+0800 Interview01-打印[15994:9172130] Dealloc 我是任务5
2021-06-22 17:57:56.436784+0800 Interview01-打印[15994:9172130] Dealloc 我是任务6
*/

在这个例子中我们在任务请求完成后,手动设置其self.executingself.finished状态,并且手动触发KVO,队列会监听任务的执行状态。由于我们设置了任务依赖,当任务6请求完成后才会执行任务5,任务5请求完成后 才会执行任务4。最后对各自任务进行移除队列并释放。其实这样也变相解决了上面重写main方法中无法解决的问题。

二.实际应用

执行

多个请求完成后继续进行下一个请求的方法总结

在我们的工作中经常会遇到这样的请求:一个请求依赖另一个请求的结果,或者多个请求一起发出然后再获取所有的结果后继续后续操作。根据这几种情况总结常用的方法:

1. 使用GCDdispatch_group_t实现

需求:请求顺序执行,执行完成后回调结果

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
 NSString *str = @"https://www.360.cn";
NSURL *url = [NSURL URLWithString:str];
NSURLRequest *request = [NSURLRequest requestWithURL:url];
NSURLSession *session = [NSURLSession sharedSession];

dispatch_group_t downloadGroup = dispatch_group_create();
for (int i=0; i<10; i++) {
dispatch_group_enter(downloadGroup);
NSURLSessionDataTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {

NSLog(@"执行完请求=%d",i);
dispatch_group_leave(downloadGroup);
}];

[task resume];
}
dispatch_group_notify(downloadGroup, dispatch_get_main_queue(), ^{
NSLog(@"end");
});
/*
2021-06-22 18:37:56.786878+0800 Interview01-打印[17121:9352056] 请求结束:0
2021-06-22 18:37:56.787770+0800 Interview01-打印[17121:9352057] 请求结束:1
2021-06-22 18:37:56.788492+0800 Interview01-打印[17121:9352057] 请求结束:2
2021-06-22 18:37:56.789148+0800 Interview01-打印[17121:9352057] 请求结束:3
2021-06-22 18:37:56.789837+0800 Interview01-打印[17121:9352057] 请求结束:4
2021-06-22 18:37:56.790433+0800 Interview01-打印[17121:9352059] 请求结束:5
2021-06-22 18:37:56.791117+0800 Interview01-打印[17121:9352059] 请求结束:6
2021-06-22 18:37:56.791860+0800 Interview01-打印[17121:9352059] 请求结束:7
2021-06-22 18:37:56.792614+0800 Interview01-打印[17121:9352059] 请求结束:8
2021-06-22 18:37:56.793201+0800 Interview01-打印[17121:9352059] 请求结束:9
2021-06-22 18:37:56.804529+0800 Interview01-打印[17121:9351753] end*/

主要方法:

  • dispatch_group_t downloadGroup = dispatch_group_create();创建队列组
  • dispatch_group_enter(downloadGroup); 每次执行请求前调用
  • dispatch_group_leave(downloadGroup); 请求完成后调用离开方法
  • dispatch_group_notify() 所有请求完成后回调block
  • 对于enter和leave必须配合使用,有几次enter就要有几次leave

2. GCD信号量dispatch_semaphore_t

(1).需求:顺序执行多个请求,都执行完成后回调给end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
NSString *str = @"https://www.360.cn";
NSURL *url = [NSURL URLWithString:str];
NSURLRequest *request = [NSURLRequest requestWithURL:url];
NSURLSession *session = [NSURLSession sharedSession];

dispatch_semaphore_t sem = dispatch_semaphore_create(0);

for (int i=0; i<10; i++) {

NSURLSessionDataTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {

NSLog(@"请求结束:%d",i);
dispatch_semaphore_signal(sem);
}];
[task resume];
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
}
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"end");
});

主要方法

1
2
3
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
dispatch_semaphore_signal(sem);
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);

dispatch_semaphore信号量为基于计数器的一种多线程同步机制,dispatch_semaphore_signal(sem);表示为计数+1操作,dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); 信号量-1,遇到dispatch_semaphore_wait如果信号量的值小于0,就一直阻塞线程,不执行后面的所有程序,直到信号量大于等于0;当第一个for循环执行后dispatch_semaphore_wait堵塞线程,直到执行到dispatch_semaphore_signal后继续下一个for循环进行请求,以此类推完成顺序请求。

(2).需求:多个请求同时进行,都执行完成后回调给end
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
NSString *str = @"https://www.360.cn";
NSURL *url = [NSURL URLWithString:str];
NSURLRequest *request = [NSURLRequest requestWithURL:url];
NSURLSession *session = [NSURLSession sharedSession];

dispatch_semaphore_t sem = dispatch_semaphore_create(0);
__block int count = 0;
for (int i=0; i<10; i++) {

NSURLSessionDataTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {

NSLog(@"%d---%d",i,i);
count++;
if (count==10) {
dispatch_semaphore_signal(sem);
count = 0;
}
}];

[task resume];
}
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);

dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"end");
});
/*
2021-06-23 09:47:49.723576+0800 Interview01-打印[21740:9823752] 请求完成:0
2021-06-23 09:47:49.741118+0800 Interview01-打印[21740:9823751] 请求完成:1
2021-06-23 09:47:49.756781+0800 Interview01-打印[21740:9823752] 请求完成:3
2021-06-23 09:47:49.765250+0800 Interview01-打印[21740:9823752] 请求完成:2
2021-06-23 09:47:49.773008+0800 Interview01-打印[21740:9823756] 请求完成:4
2021-06-23 09:47:49.797809+0800 Interview01-打印[21740:9823751] 请求完成:5
2021-06-23 09:47:49.801775+0800 Interview01-打印[21740:9823751] 请求完成:6
2021-06-23 09:47:49.805542+0800 Interview01-打印[21740:9823751] 请求完成:7
2021-06-23 09:47:49.814714+0800 Interview01-打印[21740:9823751] 请求完成:8
2021-06-23 09:47:49.850517+0800 Interview01-打印[21740:9823753] 请求完成:9
2021-06-23 09:47:49.864394+0800 Interview01-打印[21740:9823591] end
*/

这个也比较好理解,for循环运行后堵塞当前线程(当前是主线程,你也可以把这段代码放入子线程中去执行),当10个请求全部完成后发送信号,继续下面的流程。

3. 使用NSOperationGCD结合使用

需求:两个网络请求,第一个依赖第二个的回调结果

通过自定义operation实现,我们重写其main方法

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
@interface CustomOperation : NSOperation
@property (nonatomic, copy) id obj;
- (instancetype)initWithObject:(id)obj;
@end
@implementation CustomOperation

- (instancetype)initWithObject:(id)obj{
if(self = [super init]){
self.obj = obj;
}
return self;
}

- (void)main{

//创建信号量并设置计数默认为0
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
NSLog(@"开始执行任务%@",self.obj);
NSString *str = @"https://www.360.cn";
NSURL *url = [NSURL URLWithString:str];
NSURLRequest *request = [NSURLRequest requestWithURL:url];
NSURLSession *session = [NSURLSession sharedSession];

NSURLSessionDataTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
NSLog(@"TASK完成:====%@ thread====%@",self.obj,[NSThread currentThread]);
//请求成功 计数+1操作
dispatch_semaphore_signal(sema);
}];

[task resume];

//若计数为0则一直等待
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);

}

调用与结果

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
 NSOperationQueue *queue3 = [[NSOperationQueue alloc] init];
[queue3 setMaxConcurrentOperationCount:2];
CustomOperation *operation0 = [[CustomOperation alloc] initWithObject:@"我是任务0"];
CustomOperation *operation1 = [[CustomOperation alloc] initWithObject:@"我是任务1"];
CustomOperation *operation2 = [[CustomOperation alloc] initWithObject:@"我是任务2"];
CustomOperation *operation3 = [[CustomOperation alloc] initWithObject:@"我是任务3"];

[operation0 addDependency:operation1];
[operation1 addDependency:operation2];
[operation2 addDependency:operation3];

[queue3 addOperation:operation0];
[queue3 addOperation:operation1];
[queue3 addOperation:operation2];
[queue3 addOperation:operation3];
/**打印结果
开始执行任务我是任务3
TASK完成:====我是任务3 thread====<NSThread: 0x6000039c3340>{number = 5, name = (null)}
开始执行任务我是任务2
TASK完成:====我是任务2 thread====<NSThread: 0x6000039ece80>{number = 7, name = (null)}
开始执行任务我是任务1
TASK完成:====我是任务1 thread====<NSThread: 0x6000039c3340>{number = 5, name = (null)}
开始执行任务我是任务0
TASK完成:====我是任务0 thread====<NSThread: 0x6000039c3d00>{number = 6, name = (null)}
*/
  • 设置任务依赖并且添加到队列后是可以满足我们的需求
  • 由于任务内部是异步回调,可以看到任务内部的执行还是依赖于dispatch_semaphore_t来实现的
  • 也可以通过重写start方法实现,在上面章节我们已经介绍过了,这里不再赘述。

三. 总结

本文的篇幅有点长了,但是还有一些内容没有覆盖到,比如iOS中常用的线程锁、NSOperationQueue的暂停与取消等,我们会在后面的文章中逐步完善补充。

由于作者水平有限,难免出现纰漏,如有问题还请不吝赐教。

参考资料:

苹果官方——并发编程指南:Operation Queues

iOS GCD之dispatch_semaphore(信号量)

前端Vue入门总结

一. 概述

在目前的互联网大环境下,如果只有一门技术傍身,不足以胜任市场对研发的要求,于是想学习大前端技术栈。笔者接触前端是从开发微信小程序开始的,它的数据双向绑定机制,让写习惯了客户端的我叹为观止。目前我入门前端的技术路径是:客户端—微信小程序—混合AppH5开发—Web前端。一些我自己的经验总结出来,希望对你有所帮助。当然阅读这篇文章的前提是,你已经了解了基本的Html、CSS、JS语法。

二. 环境与工具

1. 前端环境搭建

笔者使用的Mac电脑,所有的环境搭建工作都是基于Mac电脑来操作的。首先安装node.jsnpm:

node.js

node 是一个基于 V8 引擎的 Javascript 运行环境,它使得 Javascript 可以运行在服务端,直接与操作系统进行交互,与文件控制、网络交互、进程控制等。简单的说node.js就是运行在服务端的 JavaScript。你可能会有疑问,我写前端页面为甚么需要一个运行在服务端的的JS环境。其实我们这里使用node最关键是需要安装npm.

npm

npm是node.js的包管理工具(package manager),为啥我们需要一个包管理工具呢?因为我们在Web开发时,会用到很多别人写的JavaScript代码。如果我们要使用别人写的某个包,每次都根据名称搜索一下官方网站,下载代码、解压、再使用,非常繁琐。于是一个集中管理的工具应运而生:大家都把自己开发的模块打包后放到npm官网上,如果要使用,直接通过npm安装就可以直接用。他类似于iOS开发中的Cocoapods,Android开发中的Maven,这样就好理解了。
node下载地址点击这里,按照步骤安装完成后,终端输入

1
2
node -v
npm -v

查看安装版本,出现下面的版本号说明安装成功。
node

注意如果提示-bash: node: command not found,说明还需要配置一下环境变量。配置方式也很简单,在Finder中查找文件夹,输入路径/private/etc,找到profile文件,加上一下语句

pic

1
2
export NODE_HOME="node安装路径(bin路径的父级路径)" 
export PATH=$PATH:$NODE_HOME/bin

node安装路径(bin路径的父级路径):你可以通过命令which node 命令来查看。例如我的本地路径是/usr/local/bin/node,那么可以这样设置

1
2
export NODE_HOME="/usr/local"
export PATH=$PATH:$NODE_HOME/bin

重新保存文件后,再次输入node -v 验证一下。下面是一些npm常用命令:

1
2
3
4
5
6
7
8
9
10
11
12
// 本地安装模块
npm install <Module Name>
// 全局安装模块
npm install <Module Name> -g
// 搜索模块
npm search <Module Name>
// 更新模块
npm update <Module Name>
//卸载模块
npm uninstall <Module Name>
// 安装项目的全部依赖
npm install
yarn:

yarn是一个由 Facebook 贡献的 Javascript 包管理器。yarn是为了弥补npm的一些缺陷而出现的。在日常开发中你可以使用npm也可以使用yarn进行包的管理,只不过相比npm而言,它的速度更快,并提供了离线模式。关于它我们不会过多的介绍,你可以去它的中文网站查看.它的安装方式也很简单,直接通过Homebrew进行安装,命令 brew install yarn. 它的一些常用指令:

1
2
3
4
5
6
7
8
9
10
11
12
// 初始化一个新项目
yarn init
// 添加依赖包
yarn add [package]
// 添加依赖包的某个版本
yarn add [package]@[version]
// 升级依赖包
yarn upgrade [package]
// 移除依赖包
yarn remove [package]
// 安装项目的全部依赖
yarn install 或者 yarn

可以在项目中混用yarnnpm,但是最好不要这样。

2. 开发工具选择

pic1
前端的开发工具基本就两个选择Visual Studio Code 或者 WebStorm,两者选择哪一个都可以,我个人更喜欢Visual Studio Code,其实选择它最主要原因是免费且开源,而且有强大的插件库。

VSCode安装插件:
chanjian

选择[扩展]-[搜索插件]-安装即可
下面是我使用的一些常用的VSCode插件:

(1) vue vscode snippets
它是Vue项目代码的骨架插件,例如输入vbase,会直接生成以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<div>

</div>
</template>

<script>
export default {

}
</script>

<style lang="scss" scoped>

</style>

相似的还有vdatavmethodvfor等操作
(2) Auto Close Tag 自动闭合HTML标签
(3) Auto Rename Tag 修改HTML标签时,自动修改匹配的标签
(4) Color Highlight 颜色值在代码中高亮显示
(5) HTML CSS Class Completion CSS class提示插件
(6) Vetur Vue多功能集成插件,包括:语法高亮,智能提示,emmet,错误提示,格式化,自动补全,debugger。vscode官方钦定Vue插件,Vue开发者必备
(7) VSCode-icons 文件图标,方便定位文件
(8) Color Highlight 颜色值在代码中高亮显示
(9) HTML CSS Support 智能提示CSS类名以及id
(10) Beautify 格式化代码工具,美化javascript,JSON,CSS,Sass,和HTML
(11) Open in Browser 直接在浏览器中打开你当前的文件

三.Vue项目搭建与开发事项

目前前端几大主流框架ReactVueAngular,三个框架各有优劣,个人感觉Vue的入门难度最小,而且有良好的中文教程和广泛的第三方支持,如果要入门前端技术,从Vue入手是比较明智的。

1.Vue项目搭建

使用Vue CLI脚手架构建Vue项目

(1). 使用 npm 或 yarn 全局安装 Vue CLI
1
2
3
npm install -g @vue/cli
# OR
yarn global add @vue/cli

如果页面报错如下
error
说明执行权限不够,可以在在前面加sudo

1
sudo npm install -g @vue/cli

执行完成后输入命令vue -V查看Vue/Cli 版本,出现如下提示说明安装成功。
pic

(2). 在工程所在目录创建项目

执行以下命令

1
2
3
vue create my-project
# OR
vue ui

vue create 是使用命令行创建项目,vue ui是以图形化界面创建和管理项目。

(3). 配置工程

输入创建命令后提示:
pic

此时会判断你的npm/yarn源的连接速度,询问你是否切换至淘宝镜像,我们输入n,继续

pic

提示:项目是使用默认配置(Vue2还是Vue3 都包含babel, eslint)还是手动选择,我们选择手动配置,继续

pic

  • Choose Vue version: 选择Vue的版本 选择
  • Babel :将脚手架中浏览器不认识的一切东西翻译为浏览器认识的 选择
  • TypeScript : 强类型的 JaveScript
  • Progressive Web App (PWA) Support : 渐进式App,主要用于兼容移动端
  • Router : Vue 路由管理 选择
  • Vuex: Vue的状态管理器 选择
  • CSS Pre-processors : CSS 预处理器,可选择使用 less、sass、stylus等预处理器 选择
  • Linter / Formatter :代码检测和格式化
  • Unit Testing: 单元测试
  • E2E Testing: 端到端测试
    选中好后继续

    按方向键是进行上下移动,空格是选中/取消,回车是确定当前所选内容,继续下一步操作

pic

提示:选择Vue版本,我们选择Vue3版本,继续

pic

提示:路由方式是否使用history模式。一般都是单页面开发不选择history,输入n继续

pic

提示:选择CSS预处理器

  • node-sass 是自动编译,实时的
  • dart-sass 需要保存后才会生效
  • Less 最终会通过编译处理输出css到浏览器,Less 既可以在客户端上运行,也可在服务端运行 (借助 node.js)
  • Stylus 主要用来给node项目进行CSS预处理支持,Stylus功能上更为强壮,和Js联系更加紧密,可创建健壮的、动态的的CSS

我们选择 Sass/SCSS (with node-sass),继续

pic

提示:Babel, ESLint是使用独立文件,还是在package.json一个文件中保存所有配置信息。选择第一个,继续

pic

提示:是否为以后创建的项目保存我们当前所选的这些配置,我们选择,开始自动创建项目

pic

项目创建完成后,cd到当前工程目录下,执行yarn serve,就可以运行当前项目了。

pic

在浏览器中输入上面的地址就可看到我们当前创建的Vue工程了

pic

2.Vue基础语法

(1).Vue的基础语法

Vue.js 的核心是一个允许采用简洁的模板语法来声明式地将数据渲染进 DOM 的系统:
About.vue

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
<template>
<div class="about">
<div>{{counter}}</div>
<button @click="handleClick">按钮</button>
<div>
<span v-bind:title="message">
鼠标悬停几秒钟查看此处动态绑定的提示信息!
</span>
<div v-if="isShow">组件按照条件进行展示</div>
<div class="input-binding">
<p>{{ inputMsg }}</p>
<input v-model="inputMsg" @input="handleChange"/>
</div>
<div>
<TodoItem :itemList="todos"/>
</div>
</div>
</div>
</template>

<script>
import TodoItem from '../components/TodoItem.vue';

export default {
name: 'About',
components: {
TodoItem,
},
data() {
return {
counter: 0,
message: '鼠标悬停消息',
inputMsg: '',
isShow: false,
todos: [
{ text: 'Learn JavaScript' },
{ text: 'Learn Vue' },
{ text: 'Build something awesome' },
],
};
},
methods: {
handleClick() {
console.log('handleClick--->');
this.counter += 1;
this.isShow = !this.isShow;
},
handleChange() {
console.log('--->', this.inputMsg);
},
},
};
</script>

<style lang="scss" scoped>
.about {
font-size: 20px;

span {
font-size: 14px;
}
}
</style>

TodoItem.vue

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
<template>
<div class="todo-item">
<ol>
<li v-for = "(item,index) in itemList" :key="index" class="todos">
{{ item.text }}
</li>
</ol>
</div>
</template>

<script>
export default {
name: 'TodoItem',
props: {
itemList: {
type: Array,
},
},
};
</script>

<style lang="scss" scoped>
.todos {
text-align: left;
}
</style>
  • Vue 框架将data中的数据和 DOM 进行了关联,所有内容都是响应式的。改变data中返回对象的数据,可以直接在Dom中进行响应。
  • 为了让用户和应用进行交互,我们可以用 v-on 指令添加一个事件监听器,通过它调用在实例中定义的方法,指令v-on 可以简写为@
  • 使用v-model指令,实现表单输入和应用状态之间的双向绑定。
  • 控制一个元素是否显示使用v-if指令,他可以配合v-else使用,也可以使用v-show指令来控制元素是否显示。
  • v-for指令可以绑定数组的数据,来渲染一个项目列表.使用v-for指令必须设置其key值。

    以上只是最基本的Vue语法简介,其他语法比如:组件的生命周期、组件之间的数据传递、事件监听、插槽、动态组件&异步组件、组合式API(setup)的使用等等,需要你去学习官方Vue文档

(2).Vue Router的使用

Vue RouterVue.js 的官方路由。它与Vue.js核心深度集成,让用Vue.js构建单页应用变得轻而易举。
安装

1
npm install vue-router@4

使用
main.js中挂载router

1
2
3
4
5
6
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import store from './store';
//创建并挂载根实例,整个应用支持路由。
createApp(App).use(store).use(router).mount('#app');

router/index.js中调用createRouter()

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
import { createRouter, createWebHistory } from 'vue-router';
import Home from '../views/Home.vue';

const routes = [
{
path: '/',
name: 'Home',
component: Home,
},
{
path: '/about',
name: 'About',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
},
];

const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes,
});

export default router;

App.vue

1
2
3
4
5
6
7
<template>
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
<router-view/>
</template>
  • 通过调用 app.use(router),我们可以在任意组件中以this.$router的形式访问它,并且以this.$route的形式访问当前路由
  • 我们可以在Dom中使用<router-link to="/" />来执行导航跳转,也可以使用编程式导航,router-view将显示与 url 对应的组件。你可以把它放在任何地方,以适应你的布局。
  • 嵌套路由也是我们经常使用的方式,例如
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    const routes = [
    {
    path: '/detail/:userId',
    name: 'Detail',
    component: () => import(/* webpackChunkName: "about" */ '../views/Detail.vue'),
    children: [
    { // 默认显示
    path: '',
    component: Profile,
    },
    {
    path: 'profile',
    component: Profile,
    },
    {
    path: 'posts',
    component: Posts,
    },
    ],
    },
    ]
    Detail.vue
1
2
3
4
5
<div>
<router-link :to="'/detail/'+userId+'/posts'">posts</router-link>|
<router-link :to="'/detail/'+userId+'/profile'">profile</router-link>
<router-view></router-view>
</div>

跳转到Detail页面

1
2
const userId = '1001';
this.$router.push(`/detail/${userId}`);

上面的例子中,首先使用了动态路由匹配功能,传递参数userId,此时的路径为/detail/1001,默认展示Profile组件,在Detail页面有两个子路由:ProfilePosts,当点击Link标签时,匹配路径为/detail/1001/profile时候展示Profile,当匹配路径为/detail/1001/posts,展示posts组件,设置path: '',表示进入/detail/userId页面默认显示的组件。

  • 如果你想同时在Detail中显示ProfilePosts两个组件,可以使用命名视图,它主要用于同时 (同级) 展示多个视图,而不是嵌套展示。

Detail.vue

1
2
3
4
5
6
<div>
<router-link :to="'/detail/'+userId+'/content'">content</router-link>
<router-view></router-view>
<!-- 设置 router-view 的name,在router中进行命名 -->
<router-view name="Posts"></router-view>
</div>

router.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const routes = [
{
path: '/detail/:userId',
name: 'Detail',
component: () => import(/* webpackChunkName: "about" */ '../views/Detail.vue'),
children: [
{ // 默认显示
path: '',
component: Profile,
},
{
path: 'content',
components: {
default: Profile,//默认展示的组件
Posts,// ES6 语法,Posts:Posts
},
},
],
},
]
  • **在 Vue 实例中,你可以通过 $router 访问路由实例。因此你可以调用 this.$router.push()**,当你点击 <router-link> 时,内部会调用这个方法,所以点击 <router-link :to="..."> 相当于调用 router.push(...)
  • 其他语法比如:路由组件传参、导航守卫、过渡动效等,需要你去学习官方Vue Router文档
(3).Vuex的使用

Vuex是一个专为 Vue.js 应用程序开发的状态管理模式 + 库。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。它主要用来解决多个组件共享状态的问题。

Pic

VuexRedux的数据管理模式很相似,如果理解Redux,那么Vuex也很容易理解了,只不过Vuex 是专门为 Vue.js 设计的状态管理库,使用起来要更加方便。

  • 安装

    1
    2
    3
    npm install vuex@next --save
    OR
    yarn add vuex@next --save
  • 核心概念

    • State: 就是组件所依赖的状态对象。我们可以在里面定义我们组件所依赖的数据。可以在Vue组件中通过this.$store.state.XXX获取state里面的数据.

    • Getter:从 store 中的 state 中派生出一些状态,可以把他理解为是store的计算属性.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      const store = createStore({
      state: {
      todos: [
      { id: 1, text: '...', done: true },
      { id: 2, text: '...', done: false }
      ]
      },
      getters: {
      doneTodos: (state) => {
      return state.todos.filter(todo => todo.done)
      }
      }
      })

      例如我们定义了上面的store,在Vue组价中通过store.getters.doneTodos访问它的getter.

    • Mutation:更改 Vuex 的 store 中状态的唯一方法是提交 mutation,我们通过在mutation中定义方法来改变state里面的数据。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      const store = createStore({
      state: {
      count: 1
      },
      mutations: {
      increment (state) {
      // 变更状态
      state.count++
      }
      }
      })

      在Vue组件中,我们通过store.commit('increment'),来提交。需要注意的是,Mutation 必须是同步函数。在实际使用中我们一般使用常量替代 Mutation 事件类型。例如上面的例子

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      export const INCREMENT_MUTATION = 'INCREMENT_MUTATION'

      const store = createStore({
      state: {
      count: 1
      },
      mutations: {
      // 我们可以使用 ES2015 风格的计算属性命名功能来使用一个常量作为函数名
      [INCREMENT_MUTATION] (state) {
      // 变更状态
      state.count++
      }
      }
      })
    • Action: Action 类似于 mutation,不同在于:

      • Action 提交的是 mutation,而不是直接变更状态。
      • Action 可以包含任意异步操作。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      const store = createStore({
      state: {
      count: 0
      },
      mutations: {
      increment (state) {
      state.count++
      }
      },
      actions: {
      incrementAsync ({ commit }) {
      setTimeout(() => {
      commit('increment')
      }, 1000)
      }
      }
      })

      在组件中我们通过store.dispatch('incrementAsync')触发action。

    • Module: 当我们的应用较大时,为了避免所有状态会集中到一个比较大的对象中,Vuex 允许我们将 store 分割成模块(module)

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      const moduleA = {
      state: () => ({ ... }),
      mutations: { ... },
      actions: { ... },
      getters: { ... }
      }
      const moduleB = {
      state: () => ({ ... }),
      mutations: { ... },
      actions: { ... }
      }

      const store = createStore({
      modules: {
      a: moduleA,
      b: moduleB
      }
      })

在Vue组件中我们使用store.state.a, store.state.b 来分别获取两个模块的状态.

更多关于Vuex的用法:辅助函数(mapState、mapGetters、mapMutations、mapActions)、命名空间、组合式API中调用等可以阅读[Vuex]文档.

3.第三方库使用总结

(1).移动Web布局库

我们在进行移动Web页面开发时,需要对页面进行布局,常用的布局方式有用rem来作单位,配合h5新的meta属性来适配屏幕做开发的,也有直接使用三方库postcss-px-to-viewport来进行页面布局的。我们直接使用第二种方式:
使用yarn进行安装,cd 到工程目录后执行

1
$ yarn add -D postcss-px-to-viewport

执行完成后,在postcss.config.js中进行参数配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module.exports = {
plugins: {'postcss-px-to-viewport': {
unitToConvert: 'px',// 要转化的单位
viewportWidth: 375,// UI设计稿的宽度
unitPrecision: 5,// 转换后的精度,即小数点位数
propList: ['!*'],// 指定转换的css属性的单位,*代表全部css属性的单位都进行转换
viewportUnit: 'vw',// 指定需要转换成的视窗单位,默认vw
fontViewportUnit: 'vw',// 指定字体需要转换成的视窗单位,默认vw
selectorBlackList: [],// 指定不转换为视窗单位的类名,
minPixelValue: 1,// 默认值1,小于或等于1px则不进行转换
mediaQuery: false,// 是否在媒体查询的css代码中也进行转换,默认false
replace: true,// 是否转换后直接更换属性值
exclude: [],// 设置忽略文件,用正则做目录名匹配
landscape: false,// 是否处理横屏情况
landscapeUnit: 'vw',//横屏单位
landscapeWidth: 568//横屏宽度
}}
}
(2). UI库

pic

在进行移动Web开发中,第三方UI库的使用是少不了的,我们最常用的有vantElement UI等,我们以vant来说明,进行工程目录执行

1
2
3
4
5
6
7
8
# Vue 2 项目,安装 Vant 2:
npm i vant -S

# Vue 3 项目,安装 Vant 3:
npm i vant@next -S

# 通过 yarn 安装vant3
yarn add vant@next

注意对于Vue2和Vue3项目引入方式是不一样的,我们是Vue3项目,因此执行npm i vant@next -S或者yarn add vant@next

安装完成后就可以引入组件了,也需要注意,Vue2与Vue3的配置方式也是不同的,具体可以去查看Vant官网查看,这里不在赘述。

(3). 网络请求库

在项目中进行网络请求时,最常用的第三方库是axios,他的引入方式也很简单:

1
2
3
$ npm install axios
OR
$ yarn add axios

对于Vue2项目而言安装完成后在mian.js中引用axios,并绑到原型链上。

1
2
3
import Vue from 'vue'
import axios from ‘axios’
Vue.prototype.$http = axios

它的用法很简单:

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
// get请求
axios.get('/user?ID=12345')
.then(function (response) {
console.log(response);
})
.catch(function (error) {
console.log(error);
});
// post 请求
axios.post('/user', {
firstName: 'Fred',
lastName: 'Flintstone'
})
.then(function (response) {
console.log(response);
})
.catch(function (error) {
console.log(error);
});
// 执行多个并发请求
function getUserAccount() {
return axios.get('/user/12345');
}

function getUserPermissions() {
return axios.get('/user/12345/permissions');
}

axios.all([getUserAccount(), getUserPermissions()])
.then(axios.spread(function (acct, perms) {
// 两个请求现在都执行完成
}));

但是对于Vue3项目而言,除了引入axios外,还需要引入vue-axios,引入方式:

1
$ npm install --save axios vue-axios

vue-axios是将axios集成到Vue.js的小包装器,可以像插件一样进行安装。在mian.js中引用axios,vue-axios,通过全局方法Vue.use()使用插件:

1
2
3
4
5
6
import { createApp } from 'vue'
import axios from 'axios'
import VueAxios from 'vue-axios'// Vue3 使用 axios 需要配合 vue-axios 一起使用

//创建并挂载根实例
createApp(App).use(VueAxios, axios).mount('#app');

使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default {
name: 'App',
methods: {
getList() {
this.axios.get(api).then((response) => {
console.log(response.data)
})
// or
this.$http.get(api).then((response) => {
console.log(response.data)
})
}
}
}
(4).项目中的矢量图片使用

在开发项目时,免不了要使用到图片,目前项目常用的矢量图片库非阿里的iconfont莫属了,它最大的好处是你可以像调整文字一样,设置图片的颜色和大小,而不用担心图片失真问题。它的使用方式也很简单:
把我们要使用的图片添加到项目中,然后点击下载到本地
pic
它有三种引入方式分别是:unicode 引用font-class 引用symbol 引用,我们只介绍symbol 引用,这也是官方最推荐的引用方式,相比前两种有如下特点:

  • 支持多色图标了,不再受单色限制。
  • 通过一些技巧,支持像字体那样,通过font-size,color来调整样式。
  • 兼容性较差,支持 ie9+,及现代浏览器。
  • 浏览器渲染svg的性能一般,还不如png。
    使用步骤如下:
第一步:拷贝下载文件iconfont.js到项目目录

在需要用到iconfont 的地方引入这个js文件目录

1
import '../utils/iconfont';
第二步:加入通用css代码(引入一次就行)
1
2
3
4
5
6
7
8
<style type="text/css">
.icon {
width: 1em; height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
</style>
第三步:挑选相应图标并获取类名,应用于页面:
1
2
3
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-xxx"></use>
</svg>

#icon-xxx 就是你选的图片类名
如果要调整大小、颜色、位置等

1
2
3
4
5
6
7
8
.icon {
font-size: 30px;
color: orange;
position:relative;
display:inline-block;
top: calc(12px + 0.15rem);
right: 6px;
}

注意:加入的图片最好是去掉填充色的,然后你才能修改颜色,否则设置颜色不生效。

4.项目上线

开发完成后,需要将项目进行上线,我们知道,客户端开发完成后,将打包好的apk或ipa包发布到对应的平台就好了,前端开发完成后也需要将代码进行编译打包。执行命令:

1
2
3
npm run build
OR
yarn build

打包完成后,会在项目所在目录下生成dist文件夹,将其发给后端小伙伴就可以了。如果你没有配合的后端,或是想发布一个自己专属的网站,那么你大概需要以下步骤:

  • 购买云端服务器(阿里云、腾讯云、华为云)
  • 对服务进行一些基本操作与 nginx 配置,可以参考:CentOS(7.6)基本操作与Nginx配置
  • 通过 scp 命令将dist文件夹上传到服务,完成后用服务IP可以访问到我们的网站了
  • 购买域名(域名注册)
  • 对域名进行实名认证 +备案
  • 对域名进行解析 (IP 与 域名绑定) ,完成后通过域名可以访问到网站了
  • 借助CDN对网站访问速度进行加速

四.总结

做前端开发这段时间给我的感觉是,入门相对简单,但如果想精通,那确实需要认真学习不断沉淀。有时候接触的知识越多,越感觉自己的浅薄,这篇文章只是给想学习前端开发的同学一点思路,让你在学习繁杂的前端知识时不至于迷失方向。当然海量的知识这里是没涉及的,比如:Webpack原理浏览器的基本工作原理TypeScriptbabelNode.jsWeb安全攻防前端开发者必备的Nginx知识Canvas APISVG等绘制高性能的动画、虚拟Dom与Diff算法等等,只能说革命尚未成功,同志仍需努力啊!

参考:
Vue 中文文档
postcss-px-to-viewport
element UI
axios
iocnfont
https://v3.cn.vuejs.org/
https://cli.vuejs.org/zh/guide/

Web开发-React-Redux与Vuex使用对比

一. 概述

ReactVue是我们熟悉的两大前端主流框架,来自官方的解释,Vue是一套用于构建用户界面的渐进式框架,React是一个用于构建用户界面的JavaScript库,两个框架都使用各自的语法,专注于用户UI界面的构建.那我们会有疑问,这两个框架都专注于UI界面的构建,但是随着JavaScript单页应用开发日趋复杂,我们如何进行更多数据的管理呢?比如网络请求的数据、缓存数据、本地生成尚未持久化到服务器的数据,UI状态数据,激活的路由,被选中的标签等等. 基于上面的疑问,两个框架都有各自的解决方案:React-ReduxVuex.

二.使用

1.Redux

使用react-redux之前我们先来了解一下ReduxRedux是 JavaScript 状态容器,提供可预测化的状态管理,ReduxFlux演变而来,当然除了和React一起用外,还支持其它界面库,不过我们这里主要介绍它配合React进行使用.先来了解下它的几个核心概念:

(1) 核心概念
  • State: 所谓的state就是React组件所依赖的状态对象。你可以在里面定义任何组件所依赖的状态。比如一个简单的todo应用的state可能是这样

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    {
    todos: [{
    text: 'Eat food',
    completed: true
    }, {
    text: 'Exercise',
    completed: false
    }],
    visibilityFilter: 'SHOW_COMPLETED'
    }
  • Action: action就是一个普通JavaScript对象,用来描述发生了什么.比如

    1
    2
    3
    { type: 'ADD_TODO', text: 'Go to swimming pool' }
    { type: 'TOGGLE_TODO', index: 1 }
    { type: 'SET_VISIBILITY_FILTER', filter: 'SHOW_ALL' }

    你可以把action理解为一个描述发生了什么的指示器。在实际应用中,我们会dispatch(action),通过派发action来达到修改state的目的。这样做的好处是可以清晰地知道应用中到底发生了什么。

  • Reducer:reducer的作用是用来初始化整个Store,并且串起stateaction, 它是一个接收stateaction并返回新state的函数.我们可以通过区分不通的action类型,来处理并返回不同的state.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    const StoreAction = (state = defaultState,action) => {
    switch (action.type) {
    case HOME_ACTION_UPDATE_STATE:
    return {...state,...action.data};
    case ADD_ARTICLELIST_STATE:
    let newState = JSON.parse(JSON.stringify(state));/// 深拷贝
    newState.articleList = newState.articleList.concat(action.data);
    return newState;
    default:
    return state;
    }
    }
(2) 使用原则

使用Redux进行数据管理时有三个原则需要注意

  • 单一数据源
    整个应用的state被储存在一棵object tree中,并且这个object tree只存在于唯一一个store中。

  • State 是只读的
    唯一改变state的方法就是触发actionaction是一个用于描述已发生事件的普通对象。

  • 使用纯函数来执行修改
    我们通过reducer接收先前的stateaction,并返回新的state, Reducer必须是一个纯函数,所谓的纯函数就是一个函数的返回结果只依赖于它的参数,并且在执行过程中没有副作用。

(3)React Redux

react-reduxRedux官方提供的React绑定库.他的使用也遵循上面的redux原则。

  • 安装

    1
    npm install --save react-redux
  • 流程
    pic
    通过上面的流程图可以很清晰的明确Redux的使用:

    React组件首先调用ActionCreators里事先定义好的方法,得到一个actoion,通过dispatch(action)达到派发actionReducer的目的。Reducer通过接受的不同的action来对state数据进行处理,处理完成后,返回一个新的state,state变化后React组件进行重新渲染。

  • 使用

    • 入口文件index.js

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      import React from 'react'
      import { render } from 'react-dom'
      import { Provider } from 'react-redux'
      import { createStore } from 'redux'
      import todoApp from './reducers'
      import App from './components/App'

      let store = createStore(todoApp)

      render(
      <Provider store={store}>
      <App />
      </Provider>,
      document.getElementById('root')
      )
    • 创建store/reducer.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
      import { ADD_TODO_LIST_VALUE } from "./actionTypes";

      /// 初始化数据
      const defaultState = {
      todos: [{
      text: 'Eat food',
      completed: true
      }, {
      text: 'Exercise',
      completed: false
      }],
      visibilityFilter: true,
      }
      /// Reducer 可以接受state,但是不能修改State !
      export default (state = defaultState , action) => {
      switch (action.type) {
      case ADD_TODO_LIST_VALUE:
      const newState = JSON.parse(JSON.stringify(state));///将原来的state 做一次深拷贝
      newState.todos.push(action.value);
      return newState;
      default:
      return state;
      }
      }
    • 根据reducer创建store/index.js,

      1
      2
      3
      4
      5
      import { createStore,compose,applyMiddleware } from 'redux';
      import reducer from './reducer';

      const store = createStore(reducer);
      export default store;
    • 创建actionCreatorsstore/actionCreators.js

      1
      2
      3
      4
      5
      6
      import { ADD_TODO_LIST_VALUE } from "./actionTypes"

      export const addItemListAction = (value) => ({
      type:ADD_TODO_LIST_VALUE,
      value
      })
    • 创建actionTypes专门用来存储action的type值

      1
      export const ADD_TODO_LIST_VALUE = 'add_todo_list_value';
    • React组件中使用

      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
      import React, { Component } from 'react';
      import { addItemListAction } from '../pages/home/store/actionCreators';
      import {connect} from 'react-redux';

      class Customtodolist extends Component {
      render() {
      return (
      <div className='todo-list' onClick={()=>{this.props.addListItem()}}>
      <ul>
      {
      this.props.todos.map((item,index) =>
      <li key={index}>{index}:{item}</li>
      )
      }
      </ul>
      </div>
      );
      }
      }
      const mapStateToProps = (state) => {
      return {
      todos: state.todos
      }
      }

      const mapDispatchToProps = (dispatch) => {
      return {
      addListItem: () => {
      const item = {text: 'Eat food',completed: true}
      const actionCreator = addItemListAction(item);
      dispatch(actionCreator);
      }
      }
      }

      export default connect(mapStateToProps, mapDispatchToProps)(Customtodolist);

      我们通过react-reduxconnect方法,将mapStateToProps与mapDispatchToProps 方法与组件链接,然后直接在类组件中通过this.props.XXX的方式进行访问Store中的state.

    • React hooks中使用

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      /// hooks 中使用react-redux
      import { useSelector, useDispatch } from 'react-redux';
      const Home = (props)=> {
      // hook 获取store state 方式
      const storeCount = useSelector(state => state.home.count);
      // 获取actionCreator方法
      const dispatch = useDispatch();

      return (
      <div className={style['home-content']}>
      <div className='home-content-detail'>
      StoreCount数据展示{storeCount}
      <div>
      {/* addStoreCount是在actionCreators中定义的方法 */}
      <button onClick={()=> {dispatch(addStoreCount(0))}}>点击storeCount+1</button>
      </div>
      </div>
      </div>
      )
      }
  • redux-thunk的使用

    redux-thunkredux中间件.他的主要作用是可以使用异步派发action。例如我们要进行网络请求,那么可以在actionCreators里面异步派发action.

    安装与使用

    1
    npm install --save redux-thunk

    (1).在store/index.js 中添加引入

    1
    import thunk from "redux-thunk";/// redux-thunk 中间件 需要引入的

    (2). 使用thunk初始化store

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /// 下面的代码是固定写法 /// redux-thunk 中间件 需要引入的
    const composeEnhancers =
    typeof window === 'object' &&
    window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ?
    window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({
    }) : compose;

    /// 中间件都需要用这个方法
    const enhancer = composeEnhancers(
    applyMiddleware(thunk),/// redux-thunk 中间件 需要引入的
    );
    /// 创建
    const store = createStore(
    reducer,
    enhancer//使用Redux-thunk 中间件
    );

    (3).在actionCreater.js 中添加异步派发函数,注意:在获取到异步处理的结果后,我们任然需要调用actionCreater.js中的其他创建action方法,来对其进行dispatch.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    export const initListAction = (data) => ({
    type:INIT_LIST_ACTION,
    data
    })

    /// 将异步请求 放在 Action 中执行
    export const getToList = () => {
    /// 这里返回一个函数,能获取到 dispatch 方法 这就是 redux-thunk 的作用,可以返回一个函数
    return (dispatch) => {
    axios.get('/api/todolist').then((res) => {
    // alert(res.data);
    const data = res.data;
    const action = initListAction(data);
    dispatch(action);
    }).catch((error)=>{
    console.log('网络请求错误了---thunk----》');
    })
    }
    }
  • reducer的拆分与合并

    随着项目功能模块越来越多,如果只有一个reducer来维护state,会使其变动越来越大,从而导致难以维护。combineReducer应运而生, 它将根reducer分拆成多个 reducer,拆分之后的 reducer 都是相同的结构(state, action),并且每个函数独立负责管理该特定切片 state 的更新。多个拆分之后的reducer可以响应一个 action,在需要的情况下独立的更新他们自己的切片 state,最后组合成新的 state。

    使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    import { combineReducers } from 'redux';/// 将小的Reducer 合并成大的reducers
    /// 需要拆分
    import headerReducer from '../common/header/store/reducer'
    import mainReducer from './mainReducer';
    import {reducer as homeReducer} from '../pages/home/store';
    import {reducer as loginReducer} from '../pages/login/store';

    /// 进行 reducer的合并
    const reducer = combineReducers({
    header:headerReducer,
    main:mainReducer,
    login:loginReducer,
    home:homeReducer,
    })

    export default reducer;

    react组件中使用,要加上reducer名称,例如我们在Home组件中这样获取其state

    1
    2
    3
    4
    5
    const mapStateToProps = (state, ownProps) => {
    return {
    showScroll: state.home.showScroll,//state后面添加reducer名称
    }
    }

2.Vuex

Vuex是一个专为 Vue.js 应用程序开发的状态管理模式 + 库。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。它主要用来解决多个组件共享状态的问题。

Pic

VuexRedux的数据管理模式很相似,如果理解Redux,那么Vuex也很容易理解了,只不过Vuex 是专门为 Vue.js 设计的状态管理库,使用起来要更加方便。

(1) 核心概念
  • State: 就是组件所依赖的状态对象。我们可以在里面定义我们组件所依赖的数据。可以在Vue组件中通过this.$store.state.XXX获取state里面的数据.

  • Getter:从 store 中的 state 中派生出一些状态,可以把他理解为是store的计算属性.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const store = createStore({
    state: {
    todos: [
    { id: 1, text: '...', done: true },
    { id: 2, text: '...', done: false }
    ]
    },
    getters: {
    doneTodos: (state) => {
    return state.todos.filter(todo => todo.done)
    }
    }
    })

    例如我们定义了上面的store,在Vue组价中通过store.getters.doneTodos访问它的getter.

  • Mutation:更改 Vuex 的 store 中状态的唯一方法是提交 mutation,我们通过在mutation中定义方法来改变state里面的数据。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const store = createStore({
    state: {
    count: 1
    },
    mutations: {
    increment (state) {
    // 变更状态
    state.count++
    }
    }
    })

    在Vue组件中,我们通过store.commit('increment'),来提交。需要注意的是,Mutation 必须是同步函数。在实际使用中我们一般使用常量替代 Mutation 事件类型。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    export const INCREMENT_MUTATION = 'INCREMENT_MUTATION'

    const store = createStore({
    state: {
    count: 1
    },
    mutations: {
    // 我们可以使用 ES2015 风格的计算属性命名功能来使用一个常量作为函数名
    [INCREMENT_MUTATION] (state) {
    // 变更状态
    state.count++
    }
    }
    })
  • Action: Action 类似于 Mutation,不同在于:

    • Action 提交的是 mutation,而不是直接变更状态。
    • Action 可以包含任意异步操作。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    const store = createStore({
    state: {
    count: 0
    },
    mutations: {
    increment (state) {
    state.count++
    }
    },
    actions: {
    incrementAsync ({ commit }) {
    setTimeout(() => {
    commit('increment')
    }, 1000)
    }
    }
    })

    在组件中我们通过store.dispatch('incrementAsync')触发action。

  • Module: 当我们的应用较大时,为了避免所有状态会集中到一个比较大的对象中,Vuex 允许我们将 store 分割成模块(module),你可以把它理解为Redux中的combineReducer的作用.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    const moduleA = {
    state: () => ({ ... }),
    mutations: { ... },
    actions: { ... },
    getters: { ... }
    }
    const moduleB = {
    state: () => ({ ... }),
    mutations: { ... },
    actions: { ... }
    }

    const store = createStore({
    modules: {
    a: moduleA,
    b: moduleB
    }
    })

在Vue组件中我们使用store.state.a, store.state.b 来分别获取两个模块的状态.

(2) 使用
  • 安装

    1
    2
    3
    npm install vuex@next --save
    OR
    yarn add vuex@next --save
  • main.js中挂载store

    1
    2
    3
    4
    5
    6
    import { createApp } from 'vue';
    import App from './App.vue';
    import router from './router';
    import store from './store';

    createApp(App).use(store).use(router).mount('#app');
  • 创建store/index.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
    import { createStore } from 'vuex';
    import { ADD_ITEM_LIST, REDUCE_ITEM_LIST, CHANGE_ITEM_LIST_ASYNC } from './constants';

    export default createStore({
    state: {
    itemList: [
    { text: 'Learn JavaScript', done: true },
    { text: 'Learn Vue', done: false },
    { text: 'Build something awesome', done: false },
    ],
    },
    getters: {
    doneItemList: (state) => state.itemList.filter((todo) => todo.done),
    },
    mutations: {
    // 使用ES2015风格的计算属性命名功能 来使用一个常量作为函数名
    [ADD_ITEM_LIST](state, item) {
    console.log('增加数据', item);
    state.itemList.push(item);
    },
    [REDUCE_ITEM_LIST](state) {
    console.log('减少数据');
    state.itemList.pop();
    },
    },
    actions: {
    [CHANGE_ITEM_LIST_ASYNC]({ commit, state }, todoItem) {
    /// 模拟网络请求
    setTimeout(() => {
    commit(ADD_ITEM_LIST, todoItem);
    console.log('state===', state);
    }, 1000);
    },
    },
    modules: {
    },
    });

注意我们这里仍然使用常量来作actionsmutations的方法名,使用时候要加上[].

  • 在选项式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
    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
    <template>
    <div class="about">
    <div>{{counter}}</div>
    <button @click="handleClick">按钮</button>
    <div>
    <div>
    <TodoItem :itemList="$store.state.itemList"/>
    </div>
    <div>
    完成的Todo
    <TodoItem :itemList="$store.getters.doneItemList"/>
    </div>
    <div class="btn-content">
    <button @click="addClick">增加Item</button>
    <button @click="reduceClick">减少Item</button>
    <button @click="changeClickAsync">调用Action</button>
    </div>
    </div>
    </div>
    </template>

    <script>
    import TodoItem from '../components/TodoItem.vue';
    import {
    ADD_ITEM_LIST, REDUCE_ITEM_LIST, CHANGE_ITEM_LIST_ASYNC,
    } from '../store/constants';

    export default {
    name: 'About',
    components: {
    TodoItem,
    },
    data() {
    return {
    counter: 0,
    };
    },
    computed: {
    todos() {
    return this.$store.getters.doneItemList;
    },
    },
    methods: {
    handleClick() {
    console.log('handleClick--->');
    this.counter += 1;
    },
    addClick() {
    const item = { text: 'add_item_list success!', done: true };
    /// 提交mutations
    this.$store.commit(ADD_ITEM_LIST, item);
    },
    reduceClick() {
    this.$store.commit(REDUCE_ITEM_LIST);
    },
    changeClickAsync()
    const item = { text: 'async_add_item_list success!', done: true };
    ///派发actions
    this.$store.dispatch(CHANGE_ITEM_LIST_ASYNC, item);
    },
    },
    };
    </script>
  • 在组合式API中使用

    在组合式API中通过调用 useStore 函数,来在 setup 钩子函数中访问 store。这与在组件中使用选项式 API 访问 this.$store 是等效的.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    import { useStore } from 'vuex'
    import { computed } from 'vue'

    export default {
    setup () {
    const store = useStore()
    return {
    // 在 computed 函数中访问 state
    count: computed(() => store.state.count),

    // 在 computed 函数中访问 getter
    double: computed(() => store.getters.double)

    // 使用 mutation
    increment: () => store.commit('increment'),

    // 使用 action
    asyncIncrement: () => store.dispatch('asyncIncrement')
    }
    }
    }

三. 总结

通过对比React ReduxVuex可以发现,两者的封装与使用有很大的相似性,它们都借鉴了 Flux的设计思想,通过使用对比可以让我们更容易掌握他们。

一些参考:

Vuex

Redux中文文档