LorneNote

通往互联网自由之路


  • 首页

  • 归档

极简Webpack | 手写打包器

发表于 2019-12-29

Webpack是现代JavaScript应用的静态模打包器。它能够内建一个被称为dependency graph的依赖关系图并生成一个或多个包。作为前端开发者,我们经常和它打交道,理解它如何工作可以使我们更好的处理我们的代码。今天我们通过一个简化版的模块打包器来理解一些它的底层逻辑。

模块打包器摘要步骤

官网给了我们一个简化版的模块打包器的例子,大体上分为三个步骤:

  1. 查找资源依赖:
    通过JavaScript parsers生成的抽象语法树(AST- abstract syntax tree),来读取代码的内容和依赖。
    这里面会做一些设置模块唯一标识(a unique Identifier)以及使用Babel将ECMAScript模块语法转化为能在当前浏览器运行的语法等操作。
  2. 绘制依赖关系图
    取出模块的依赖包以及该依赖包所依赖的其他依赖。找出这个关系的过程称为the dependency graph。
    这里会从入口文件开始,使用for循环依次遍历它的依赖,直到为空,找出每一个依赖的相对路径,拼接为完整路径,找出该路径下的资源,依次把他们添加到关系图当中的队列里。
  3. 将依赖封包
    使用我们创建的依赖关系图生成可以在浏览器环境下运行的包。完整内容查看Detailed Explanation of a Simple Module Bundler,视频链接:Live Coding a Simple Module Bundler。

手写一个简单的Webpack

首先找一个地方创建项目文件夹,随便命名比如:minipack-demo
而后在文件夹内创建一个package.json文件,文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"name": "minipack",
"version": "1.0.0",
"description": "",
"author": "Lorne Zhang",
"license": "MIT",
"dependencies": {
"babel-core": "^6.26.0",
"babel-preset-env": "^1.6.1",
"babel-preset-es2015": "^6.24.1",
"babel-traverse": "^6.26.0",
"babylon": "^6.18.0",
"eslint": "^4.17.0",
"eslint-config-airbnb-base": "^12.1.0",
"eslint-plugin-import": "^2.8.0"
},
"devDependencies": {
"eslint-config-prettier": "^2.9.0",
"eslint-plugin-prettier": "^2.6.0",
"prettier": "^1.10.2"
}
}

而后在控制台执行npm install安装依赖。

依赖安装完成后,我们就有了开发环境,接下来我们写一个简单的模拟程序,首先创建三个文件JS文件,名称和内容如下:

name.js

1
export const name = 'world';

message.js

1
2
3
import {name} from './name.js';

export default `hello ${name}!`;

entry.js

1
2
3
import message from './message.js';

console.log(message);

如上这三个文件是互相依赖的关系。

而后,我们创建文件bundle.js,用于写我们的打包代码:整个项目的目录结构看起来是这样的:
Alt text

到此我们正式写我们的核心逻辑–打包器代码:

bundle.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
/**
* 模块打包器将小的程序段编译成浏览器可以执行的更大更复杂的程序。
* 这些小块仅仅是Javascript文件和模块之间的依赖。
* (https://webpack.js.org/concepts/modules)。
*
* 模块打包器有一个入口文件的概念。代替在浏览器中添加一些脚本标签
* 并且让他们运行,我们告诉打包器哪个文件是应用的主文件,这是引导
* 整个应用程序的文件。
*
* 我们的打包器将从这个入口文件开始,它尝试去理解文件直接的依赖。
* 然后,它尝试去理解文件依赖的依赖。它会持续的这么做直到算出应用
* 的每个模块以及和其他模块的依赖关系。
*
* 这种理解工程的方式称为 dependency graph(依赖关系图).
*
* 在这个例子里,我们讲创建一个依赖关系图并且将它的所有模块打包到
* 一个包里。
*
* 让我们开始吧
*
* 请注意:这是一个非常简单的例子。像依赖循环、捕获模块导出,解析
* 每个模块我们仅仅处理一次,以便让例子尽可能的简单。
*/


const fs = require('fs')
const path = require('path')
const babylon = require('babylon')
const traverse = require('babel-traverse').default
const babel = require('babel-core')

let ID = 0;
// 我们从创建一个函数开始,它会接受一个文件路径,读取它的内容
// 并且取出它的依赖
function createAsset(filename) {
// 读取文件内容作为一个字符串
const content = fs.readFileSync(filename, 'utf-8');

// 现在,我们尝试计算出这个文件所依赖的文件。我们可以通过它的字符串导入
// 的方式来查看。然后,这是笨方法,因此,我们可以使用一个JavaScript解析器。
//
// JavaScript解析器是一个可以读取和理解JavaScript代码的工具。它可以
// 帮我们把代码生成一个更抽象的模型称为AST(abstract syntax tree--抽象语法树)。
//
// 我强烈建议你使用AST Explorer(http://astexplorer.net)看看AST的样子。
// AST 包含了我们代码的很多信息。我们可以通过查询它来理解我们的代码试图做什么。
const ast = babylon.parse(content, {
sourceType: 'module',
});

// 这个数组将管理这个模块所依赖的模块的相对路径
const dependencies = [];

// 我们遍历AST,试图理解这个模块依赖了哪些模块,为此,
// 我们检查AST上的每一个重要声明。
traverse(ast,{
// EcmaScript 模块是相当容易的,因为他们是静态的。这意味着你并不需要导入一
// 个变量, 或可选的导入另一个模块。每次我们看到一个导入声明,我们仅仅接纳
// 它的值作为一个依赖.
ImportDeclaration:({node})=> {
// 我们把这个值放进一个依赖数组中。
dependencies.push(node.source.value);
},
});
// 通过创建一个简单的累加器,我们为这个模块分配一个唯一标识符。
const id = ID++;
// 我们使用的ECMASript模块和其他的JavaScript功能可能并没有被所有的浏览器支持。
// 为了确保我们的包在所有的浏览器都可以运行,我们将用babel转换它
//(看https://babeljs.io)
//
// ‘presets’操作性是一个规则集合,它告诉babel怎么样转换我们的代码。我们用
// ‘babel-preset-env’来把我们的代码转换成大多数浏览器可以运行的代码。
const {code} = babel.transformFromAst(ast, null, {
presets: ['env'],
});

// 返回关于这个模块的所有信息
return {
id,
filename,
dependencies,
code,
};
}

// 现在我们能够提取一个模块的依赖,我们会继续提取入口文件的依赖。
//
// 然后,我们继续提取它的依赖的每一个依赖。我们依次进行直到计算出
// 应用程序的每一个依赖并且他们如何依赖其他的模块。这个理解过程称
// 依赖图。
function createGraph(entry) {
// 从解析依赖的入口文件开始
const mainAsset = createAsset(entry);

// 我们使用一个队列去解析每一个资源的依赖。为此我们使用入口资源
// 定义一个数组。
const queue = [mainAsset];
// 我们使用一个‘for ... of’循环去迭代这个队列。最初的队列只有
// 一个资源,但是随着我们的迭代它将加入新资源到队列中。当队列为
// 空循环终止。
for(const asset of queue) {
// 我们的每一个资源有它所依赖的模块的相对路径列表。我们要对他
// 们进行迭代,解析他们用我们的‘createAsset()’函数,追踪这
// 个模块在此对象中的依赖。
asset.mapping = {};
// 这是这个模块所在的目录
const dirname = path.dirname(asset.filename);
// 我们遍历其依赖项的相对路径列表。
asset.dependencies.forEach(relationPath => {
// 我们的‘createAsset()’函数希望一个绝对路径。依赖数组是
// 一个相对路径依赖数组。这些路径相对于导入他们的文件。我们
// 通过加入他的父资源的目录路径可以把它的相对路径转换为绝对
// 路径。
const absolutePath = path.join(dirname, relationPath);
// 解析资源,读取它的内容,提取它的依赖。
const child = createAsset(absolutePath);
// 知道资源所依赖的'子资源'对我们来说是必要的。我们通过给
// 'mapping'对象用子资源的id添加一个新属性来表达这种关系。
asset.mapping[relationPath] = child.id;

// 最后,我们添加子资源到我们的队列,因此它的依赖项也将被迭
// 代和解析。
queue.push(child);
});
}

// 此时,队列只是包含目标应用程序中每个模块的组数。
return queue;
}

// 接下来,我们定义一个函数,将使用我们的依赖图并且返回一个浏览器可以
// 运行的包。
//
// 我们的包仅仅是一个自我调用的函数:
//
// (function() {})()
//
// 函数将接收仅仅一个参数:携带我们关系图中每一个模块信息的对象
function bundle(graph) {
let modules = '';

// 在我们得到我们的函数体之前,我们讲构造这种参数。请注意我们
// 正在构建的这个字符串被两个花括号包装。因此对于每一个模块,
// 我们添加一个这种形式的字符串:‘key:value’。
graph.forEach(mod => {
// 关系图中的每一模块在这个对象中都有一个入口。我们使用模块
// id作为key,使用一个数组作为值(我们的每个模块有两个值)
//
// 第一个值是用函数封装的每一个模块的代码,这是因为模块的作
// 用域应该是:在一个模块中定义的变量不应该影响其他的作用域
// 或全局作用域。
//
// 我们的模块,我们转换他们之后,使用CommonJS 模块系统:
// 他们希望一个‘require’,一个‘module’和一个'exports'
// 对象可用。这些在浏览器中通常是不可用的,因此我们将实现
// 他们并且把他们注入我们的函数封装器。

// 对于第二个值,我们字符串化了模块和它的依赖之间的映射。这
// 是一个对象,看起来像这样:
// {'./relative/path':1 }。
//
// 这是因为被转换的模块代码以及调用了携带相对路径的‘require’.
// 当这个函数被调用时,我们应该能够知道和模块一致的这个模块的
// 相对路径。
modules += `${mod.id}: [
function (require, module, exports) {
${mod.code}
},
${JSON.stringify(mod.mapping)},
],`;
})

// 最后,我们实现这个自我调用函数。
//
// 我们通过创建‘require()’函数开始:它接受一个模块id并且查找它
// 在我们之前构造的模块对象。我们讲解构我两个值的数组来得到我们的
// 函数封装器和映射对象。
//
// 我们的模块代码调用携带相对路径的‘require()’代替模块的id。我
// 们的require函数需要模块ids。此外,两个模块可能‘require()’
// 相同的相对路径但意味着不同的模块。
//
// 为了处理这个,但一个模块被需要的时候我们创建一个新的,使用专用
// 的‘require’函数让它去使用。它将特定于该模块,并且知道如何使用
// 模块的映射对象转换相对路径为模块的映射对象。
//
// 最后,用CommonJS,当一个模块被需要时,它能够通过可变的它的
// ‘exports’导出它的值.'exports'对象,它通过模块代码更改之后,
// 被通过'require()'函数返回。
const result = `
(function(modules) {
function require(id) {
const [fn, mapping] = modules[id];

function localRequire(relationPath) {
return require(mapping[relationPath]);
}

const module = { exports: {} };

fn(localRequire,module,module.exports);

return module.exports;
}

require(0);
})({${modules}})
`;

// 我们只是返回结果,OK!
return result;
}

const graph = createGraph('./example/entry.js');
const result = bundle(graph);

console.log(result);

不再多说,每一步注解已非常详细。在控制台执行:

1
$ node bundle.js

生成如下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
(function(modules) {
function require(id) {
const [fn, mapping] = modules[id];

function localRequire(relationPath) {
return require(mapping[relationPath]);
}

const module = { exports: {} };

fn(localRequire,module,module.exports);

return module.exports;
}

require(0);
})({0: [
function (require, module, exports) {
"use strict";

var _message = require("./message.js");

var _message2 = _interopRequireDefault(_message);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

console.log(_message2.default);
},
{"./message.js":1},
],1: [
function (require, module, exports) {
"use strict";

Object.defineProperty(exports, "__esModule", {
value: true
});

var _name = require("./name.js");

exports.default = "hello " + _name.name + "!";
},
{"./name.js":2},
],2: [
function (require, module, exports) {
"use strict";

Object.defineProperty(exports, "__esModule", {
value: true
});
var name = exports.name = 'world';
},
{},
],})

如上,这是可以在浏览器下直接运行的编译后代码,将这段代码丢进浏览器的控制台可以直接查看运行结果:
Alt text

That’s OK!中文版完整程序查看minipack-demo。

nvm & npm 使用教程

发表于 2019-11-23

nvm

这里推荐使用nvm进行node的版本管理,进行多版本切换比较方便。

What | 是什么

node version manager – node版本管理工具,可以同时切换node的多个版本在本地运行。

How | 如何使用

1.安装nvm
以curl为例,打开终端,复制粘贴如下命令后,按回车键等待安装完成即可。

1
curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.31.3/install.sh | bash

2.安装node.js
使用nvm install <version> [<arch>]命令下载需要的版本。arch参数表示系统位数,默认是64位,如果是32位操作系统,需要执行命令:nvm install 10.0.1 32

1
$ nvm install 10.0.1

3.使用nvm管理node版本

使用特定node版本
执行nvm use <version> [<arch>] 命令开始使用特定版本。比如:nvm use 6.9.0或者nvm use 6.9.0 32

1
$ nvm use 6.9.0

切换node使用版本
刚刚下载了node 6.9.0版本并且成功使用,现在我们下载一个6.10.3版本,然后切换并使用。

1
2
3
$ nvm use 6.9.0
$ nvm install 6.10.3
$ nvm use 6.10.3

参考:
nvm官网
nvm的介绍及使用
Install nvm on Mac OSX

npm

What | 是什么

npm有几重含义,npm是世界最大的软件仓库,仓库地址为:https://www.npmjs.com,同时,npm是node的模块管理器,全称node version manager。

作为软件仓库,它由三部分构成:

  • 网站:发现包、访问和管理软件包
  • CLI:开发者与npm交互的方式
  • 注册表:JavaScript代码公共数据库和元信息

How | 如何使用

下载和安装Node.js和npm

为了可以发布和安装包,我们需要一个Node版本管理器,官方建议使用Node version manager去安装Node.js和npm。

使用nvm安装Node.js和npm
nvm可以在系统安装和切换多个Node.js和npm版本,来确保它们可以在不同的版本中使用。

  • nvm
  • n

前面我们已经介绍过nvm的安装和使用。

下载最新版本的npm

1
$ [sudo] npm install npm -g

检查npm和Node.js的版本

1
2
$ node -v
$ npm -v

npm CLI 版本
npm有两个版本:
稳定版lastest release 和预发布版 next release

安装Node.js的时候npm会被自动安装,然而npm比Node.js的发布要频繁,安装最新稳定版:

1
$ npm install npm@lastest -g

安装预发布版本:

1
$ npm install npm@next -g

不一定安装成功,取决于开发周期,所以可能仍然安装的是稳定版。

使用npm安装模块

因为有了npm,只需要一条命令,就能安装别人写好的模块。

1
$ npm install

安装package.json文件内的依赖模块到node_modules目录。

如果想要安装某个特定的模块,使用:

1
$ npm install <packageName>

安装前,npm install 会检查node_modules目录之中是否已经存在指定模块。如果存在,就不再重新安装了,即使远程仓库有一个新版本,也是如此。

如果你希望,一个模块不管是否安装过,npm都强制重新安装,可以使用-f或--force参数。

1
$ npm install <packageName> --force

包和模块介绍

软件包和他们的元信息存放在npm的注册表里,
模块是node_modules目录中可以由Node.js require()函数加载的任何文件或目录。

关于贡献包到注册表的语意版本控制
语义化版本规格是一套规范,来帮助其他开发者理解你代码的变动程度。

它以1.0.0作为新版的开始。

阶段 代码状态 规则 版本样例
新品 第一次发布 1.0.0开始 1.0.0
补丁发布 向后兼容bug修复 增加第三个数字 1.0.1
小版本 向后兼容新功能 增长中间的数字并重置最后一个数字为0 1.1.0
主要发布 改变破坏了向后兼容 增长第一个数字并且将中间和最后一个数字重置为0 2.0.0

你可以使用语义版本去指定你包能接受的更新类型

你可以从依赖你包的package.json文件里指定你的包的更新类型。

比如,为了指定可以接受的版本范围上限是1.0.4,使用如下语法:

  • 补丁发布: 1.0 或1.0.x 或 ~1.0.4
  • 小版本:1 或 1.x 或 ^1.0.4
  • 主要发布: * 或 x

例如:

1
2
3
4
"dependencies": {
"my_dep": "^1.0.0",
"another_dep": "~2.2.0"
}

对于语意版本语法的更多信息,看npm 语意化版本计算

从注册表获取包

包的搜索和下载

打开npm官网搜索框搜索对应包的名称即可,搜索完对应的包名后,左边有四个筛选条件:

Popularity:流行度,表示该包多少次被下载,代表多少人受益了。
Quality:品质,包含了一些注意事项如果存在README文件,如稳定性,测试、最新依赖、自定义网站,代码复杂性
Maintenance:维护性,从开发人员对包的关注度来排名,例如:经常维护可能会更好。
Optimal:理想的,可以理解为前三项的综合评估,一般用这个标准就可以,这也是默认排序

安装一个没有作用域的包

这类包总是公共的,可以被任何人下载、安装。

1
$ npm install <pageage_name>

在当前目录创建node_modules(如果没有)并且下载该包到当前目录。就是在自己的项目里依赖自己的包,这也是npm install的默认行为。

注意:
如果没有本地没有package.json文件,会安装最新版本,否则,安装package.json文件中声明的满足语意规则的最新版。

安装一个作用域公共包
作用域包的下载需要引用一下作用域的名字

1
npm install @scope/package-name

测试包的安装
在你的安装目录下检查是否有node_modules包存在,这里包含了你安装包的目录

1
ls node_moudules

使用dist-tags安装包
默认是安装包的最新版,可以使用npm install <package_name>@覆写这个行为,比如安装example-package被标记为beta的版本。

1
npm install example-package@beta

下载安装全局包

顾名思义,全局包的作用域是整个电脑系统级别的。即可以在各个项目中使用它。

1
npm install -g <package_name>

注意
如果npm的版本大于等于5.2,建议使用npx来安装全局包
关于npx,看这篇文章npx介绍

如果全局安装出现了EACCES权限错误,推荐重新安装npm,或手动改变npm的默认目录,具体查看解决全局安装包EACCES权限错误

下载包的更新

更新本地安装包

  1. 找到项目根目录确保包含package.json文件:

    1
    $ cd /path/to/project
  2. 运行更新命令

    1
    $ npm update

它会先到远程仓库查询最新版本,然后查询本地版本。如果本地版本不存在,或者远程版本较新,就会安装。
单独更新某一个包,使用:

1
$ npm update <packageName>

  1. 测试更新,不应该有任何输出
    1
    $ npm outdated

更新全局安装包

决定哪个全局包需要更新

1
$ npm outdated -g --depth=0

更新某一个全局包

1
$ npm update -g <package_name>

更新所有全局包

1
$ npm update -g

注册表

npm update命令怎么知道每个模块的最新版本呢?

答案是npm模块仓库提供了一个查询服务,叫做registry。以npmjs.com为例,它的查询服务地址是https://registry.npmjs.org/。

这个网址后面跟上模块名,就会得到一个JSON对象,里面是该模块的所有版本信息。比如,访问https://registry.npmjs.org/react,就会看到react模块所有版本的信息。

它跟下面命令的效果是一样的。

1
2
3
4
5
6
$ npm view react

// npm view 的别名
$ npm info react
$ npm show react
$ npm v react

registry网址的模块名后面,还可以跟上版本号或者标签,用来查询某个具体版本的信息。比如,访问 https://registry.npmjs.org/react/v0.14.6,就可以看到React的0.14.6版。

返回的JSON对象里面,有一个dist.tarball属性,是该版本的压缩包的网址。

1
2
3
dist
.tarball https://registry.npm.taobao.org/element/download/element-0.1.4.tgz
.shasum: 1642279061328a574a692e0e5184e62c180a6685

到这个网址下载压缩包,在本地解压,就得到了模块的代码。npm install和npm update命令,都是通过这种方式安装模块的。

包和依赖的卸载

卸载本地包

从node_modules目录删除本地包

1
2
3
4
// 没有作用域的包
npm uninstall <package_name>
// 包含作用域的包
npm uninstall <@scope/package_name>

例如:

1
npm uninstall lodash

从package.json依赖删除本地包
从package.json的依赖里删除包,使用 –save标志。

1
2
3
4
// 非作用域包
npm uninstall --save <package_name>
// 作用域包
npm uninstall --save <@scope/package_name>

例如

1
npm uninstall --save lodash

注意
如果安装包作为一个“devDependency”(i.e. 用 –save-dev),使用 –save-dev 去卸载它: npm uninstall –save-dev package_name

删除确认
查看包是否已删除

  • (OS X): ls node_modules

卸载全局包

需要加一个g标志

1
2
3
4
// 非作用域包
npm uninstall -g <package_name>
// 作用域包
npm uninstall -g <@scope/package_name>

其他

问题排查

我们可以在安装包的使用生成一个debug.log文件,该文件会在安装失败的时候生成,帮我们排查错误,如果需要生成这个文件,安装包时,使用如下命令:

1
$ npm install --timing

debug.log文件位于.npm目录。可以使用npm config get cache发现这个目录。

npm 设置淘宝镜像

参考:如果npm太慢,设置淘宝npm镜像使用方法

因为npm服务器在国外,如果觉得慢,可以换成淘宝NPM镜像

将npm的registry设置为淘宝的镜像源,以下为几种常用方法:

  1. 临时使用

    1
    npm --registry https://registry.npm.taobao.org install express
  2. 持久使用

    1
    npm config set registry https://registry.npm.taobao.org

回复原地址使用:

1
npm config set registry https://registry.npmjs.org/

配置成功后验证:
npm config get registry 或 npm info express

  1. 通过cnpm使用
    1
    npm install -g cnpm --registry=https://registry.npm.taobao.org

使用样例

1
cnpm install express

缓存目录

npm install或npm update命令,从registry下载压缩包之后,都存放在本地缓存目录。

这个缓存目录,在Linux或Mac默认是用户主目录下的.npm目录,在Windows默认是%AppData%/npm-cache。通过配置命令,可以查看这个目录的具体位置。

1
2
$ npm config get cache
/Users/zhangfeilong/.npm

你最好浏览一下这个目录。

1
$ ls ~/.npm

可以使用如下命令查看该目录下内存大小:

1
du -sh *

.npm目录存放着大量文件,清空它的命令。

1
2
3
$ rm -rf ~/.npm/*
# 或者
$ npm cache clean

模块的安装过程

总结一下,Node模块的安装过程是这样的:

  1. 发出npm install命令
  2. npm向registry查询压缩包的网址
  3. 下载压缩包,存放在~/.npm目录
  4. 解压压缩包到当前目录的node_modules目录

注意,一个模块安装以后,本地其实保存了两份。一份是 ~/.npm目录下的压缩包,另一份是node_modules目录下解压后的代码。

但是,运行npm insall的时候,只会检查node_modules目录,而不会检查~/.npm目录。也就是说,如果该模块在~/.npm下有压缩包,但是没有安装在node_modules目录中,npm依然会从远程仓库下载一次新的压缩包。

这种行为固然可以保证总是取得最新的代码,但有时并不是我们想要的。最大的问题是,它会极大的影响安装速度。即使某个模块的压缩包就在缓存目录中,也要去远程仓库下载,这怎么可能不慢呢?

另外,有些场合没有网络(比如飞机上),但是你想安装的模块,明明就在缓存目录之中,这时也无法安装。

–cache-min参数

为了解决这些问题,npm提供了一个 --cache-min参数,用于从缓存目录安装模块。

--cache-min参数指定一个时间(单位为分钟),只有超过这个时间的模块,才会从registry下载。

1
$ npm install --cache-min 99999 <package-name>

上面命令指定,只有超过99999分钟的模块,才从registry下载。实际上就是指定,所有模块从缓存安装,这样就大大加快了下载速度。

它还有另一种写法。

1
$ npm install --cache-min Infinity <package-name>

但是,这并不等于离线模式,这是仍然需要网络连接。因为现在的--cache-min实现有一些问题。

(1) 如果指定模块不在缓存目录,那么npm会连接registry,下载最新版。这没有问题,但是如果指定模块在缓存目录之中,npm也会连接registry,发出指定模块的etag,服务器返回状态码304,表示不需要重新下载压缩包。
(2) 如果某个模块已经在缓存之中,但是版本低于要求,npm会直接报错,而不是去registry下载最新版本。

npm团队知道存在这些问题,正在重写cache。并且,将来也会提供一个--offline参数,使用npm可以在离线情况下使用。

不过,这些改进没有日程表。所以,当前使用 --cache-min改进安装速度,是有问题的。


参考:
npm模块安装机制简介
npm官网

Vue 系列 | Vue-Router

发表于 2019-11-15

注意: 本文所有示例代码详见:vue-rouer-demo

What | 什么是Vue Router

Vue Router是Vue.js提供的官方路由管理器,它和Vue.js深度集成,使构建单页面应用非常方便。

Why | 为什么要使用Vue Router

大家打开LorneNote个网站,这是我的一个blog网站,采用传统的开发模式,鼠标右击,在出现的菜单里选择View Page Source 查看资源文件,大家会看到它对应的是一个HTML文件,然后你回到网站里点击归档栏,再次右击查看源文件,你会看到整个页面被重新加载,对应归档的HTML文件,即:

  • https://lornenote.com —–> LorneNote HTML文件
  • https://lornenote.com/archives/ —–> 归档 | LorneNote HTML文件

也就是说每一个URL对应一个HTML文件,这样每次切换页面的时候我们都需要重新加载我们的页面,非常影响用户体验。

然后就诞生了单页面形式SPA(single page applications)。在单页面模式下,不管我们访问什么页面,都返回index.html,类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html lang="en">
<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="/favicon.ico">
<title>hello-world</title>
<link href="/app.js" rel="preload" as="script"></head>
<body>
<noscript>
<strong>We're sorry but hello-world doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
<script type="text/javascript" src="/app.js"></script></body>
</html>

在单文件模式里,我们依然可以跳到不同的HTML文件,但我们一般不会这样做,一个index.html就满足了我们的需求,用户切换URL的时候,不再是重新加载我们的页面,而是根据URL的变化,执行相应的逻辑,数据会通过接口的形式返回给我们,而后做页面更新。Vue Router就是为了解决这样的事情,它可以做这些事情:

  • 提供URL和组件之间的映射关系,并在URL变化的时候执行对象逻辑
  • 提供多种方式改变URL的API(URL的变化不会导致浏览器刷新)

How | Vue Router 如何使用

核心使用步骤

  1. 安装并导入VueRouter,调用Vue.use(VueRouter),这样的话可以使用<router-view>和<router-link>等全局组件
1
2
3
4
5
6
7
// 安装命令
$ npm install vue-router

// main.js
import App from './App.vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
  1. 定义路由组件入口
1
2
3
4
5
6
7
// App.vue
<template>
<div id="app">
<img alt="Vue logo" src="./assets/logo.png">
<router-view></router-view>
</div>
</template>
  1. 使用定义好的路由组件配置路由列表
1
2
3
4
5
6
7
// routes.js
import HelloWorld from './components/HelloWorld'
const routes = [
{path: '/', component: HelloWorld, name:'home'}
]

export default routes
  1. 创建路由实例,并通过路由列表初始化路由实例
1
2
3
4
5
// main.js
import routes from'./routes'
const router = new VueRouter({
routes
})
  1. 将创建好的路由实例挂载到跟实例,这样我们就可以使用this.$route全局钩子来操作路由管理器为我们提供的一些属性。
1
2
3
4
5
// main.js
new Vue({
router,
render: h => h(App),
}).$mount('#app')

注意
this.$router代表我们刚刚实例化的全局路由实例,它和我们导入router相同,只是为了使用方便,不用在组件中导入了。
this.$route,写法上少了一个r,代表任何组件的当前路由。

路由动态匹配

有时候我们需要映射不同的路由给相同的组件

1
2
3
4
5
// routes.js
{ path:'/user/:id', component:User}

// HelloWorld.vue
<router-link to="/user/123">张三-single params</router-link><br>

在User组件中,可以以this.$route.params的形式接收参数id

1
2
3
4
5
<template>
<div>User
{{ this.$route.params.id }} // 123
</div>
</template>

动态匹配是以冒号区隔的,我们可以有多个动态段:

1
2
3
4
5
6
7
8
9
10
11
12
// routes.js
{ path:'/user/:userName/userId/:id', component:User},

// HelloWorld.vue
<router-link to="/user/王五/userId/789">王五-mutiple params</router-link>

// User.vue
<template>
<div>User
{{ this.$route.params }} // { "userName": "王五", "id": "789" }
</div>
</template>

路由routes与path、$route.params三者的关系见下表:

路由模式 匹配path $route.params
/user/:userName/userId/:id /user/王五/userId/789 { “userName”: “王五”, “id”: “789” }

除了$route.parmas,$route还封装了其他的信息如:
$route.query:用于获取URL中的查询,
$route.hash:用于获取url中#之后的信息(自己写的路径带#,值会包含#,如果是history模式下自动追加的#,则值不包含这个#),如果没有,值为空,如果URL中有多个#,从最后一个开始。

我们重写一下上文的例子:

1
2
3
4
5
6
7
8
9
10
11
// HelloWorld.vue
<router-link to="/user/Query/userId/101?code=123">Query</router-link><br>
<router-link to="/user/Hash/userId/102#hash=123">Hash</router-link><br>

// User.vue
<template>
<div>User
{{ this.$route.query }} // { "code": "123" }
{{ this.$route.hash }} // #hash=123 原路径:http://localhost:8081/#/user/Hash/userId/102#hash=123
</div>
</template>

由于vue-router使用 path-to-regexp作为路径path的匹配引用,所以我们可以使用正则表达式作为路径:

1
2
3
4
5
6
7
8
9
10
11
12
// routes.js
{ path:'/icon-:flower(\\d+).png', component:User},

// HelloWorld.vue
<router-link to="/icon-flower123">正则匹配</router-link><br>

// User.vue
<template>
<div>User
{{ this.$route.hash }} // /icon-flower123 原路径:http://localhost:8081/#/icon-flower123
</div>
</template>

我们可以使用星号(*)来代表通配符,只有*则匹配所有,常代表404页面,因为路由的优先级是优先配置优先匹配,相同的组件谁在前面先匹配谁。所以404页面通常写在最后一个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// routes.js
const routes = [
// ...
{ path:'/user-*', component:User},
{ path:'*', component:User}
]

// HelloWorld.vue
<router-link to="/user-admin">*Admin</router-link><br>
<router-link to="/none">404</router-link><br>

// User.vue--*Admin
<template>
<div>User
{{ this.$route.params.pathMatch }} // admin
</div>
</template>

// User.vue--404
<template>
<div>User
{{ this.$route.params.pathMatch }} // /none
</div>
</template>

如上:
当使用*号路由时,一个参数pathMatch会自动追加给$route.params,它匹配星号后面的部分。

嵌套路由

真实的应用中常常用到组件之间的嵌套,使用vue-router可以很简单的表达这种嵌套关系,我们可以在子组件中嵌套使用<router-view>,如:

1
2
3
4
5
6
7
8
9
10
// NestedRoutes.vue
<template>
<div>
<a href="/">←返回</a><br>
<router-link to="/nestedRoutes/profile">profile</router-link><br>
<router-link to="/nestedRoutes/archive">archive</router-link><br>
<p>NestedRoutes</p>
<router-view></router-view>
</div>
</template>

为了做嵌套渲染,我们使用children操作符在路由中做结构配置:

1
2
3
4
5
6
7
// routes.js 嵌套路由
{path:'/nestedroutes', component: NestedRoutes, name: 'nestedroutes',
children: [
{path:'profile', component: resolve => require(['./components/nested-routes/Profile'],resolve)}, // 匹配 /nestedroutes/profile
{path:'archive', component: resolve => require(['./components/nested-routes/Archive'],resolve)} // 匹配 /nestedroutes/archive
]
},

嵌套路由以/为根路径,拼接路径的剩余部分,children其实和routes的最外层一样,是数组,所以我们可以根据需要继续嵌套。

当我们访问/nestedroutes的时候,不会在这里渲染任何内容,因为没有子路由,如果我们想要渲染,可以在路由里配置一个空的path的子路由:

1
2
3
4
5
6
7
8
// 嵌套路由
{path:'/nestedroutes', component: NestedRoutes, name: 'nestedroutes',
children: [
{path:'', component: resolve => require(['./components/nested-routes/NestedRoutesHome'],resolve)},
{path:'profile', component: resolve => require(['./components/nested-routes/Profile'],resolve)},
{path:'archive', component: resolve => require(['./components/nested-routes/Archive'],resolve)}
]
},

编程式的导航

除了以<router-link>的标签形式导航之外,我们还可以使用编程的方式导航,vue-router为我们提供了对应的实例方法this.$router.push。

这个方法会将URL页面推到历史堆栈,所以当我们点击浏览器的返回按钮的时候指向前一个URL。当我们点击<router-link>的时候,内部其实调用的是router.push(...),所以这两个形式功能是等同的。

1
router.push(location, onComplete?, onAbort?)

location:导航位置,是一个字符串path或位置描述符对象,
第二个和第三个为可选参数,导航执行完成会执行这两个参数。
onComplete导航执行完会执行这个参数(所有的异步钩子都被执行完之后)
使用方式如下:

1
2
3
4
5
6
7
8
 <!--path-->
<button @click="$router.push('programmatic-navigation')">Path</button><br>
<!--object-->
<button @click="$router.push({path:'programmatic-navigation'})">Object</button><br>
<!-- named route-->
<button @click="$router.push({name:'programmatic-navigation', params: { id:123 }})">NamedRoute</button><br>
<!--with query reuslting in /programmatic-navigation?id=123-->
<button @click="$router.push({path:'programmatic-navigation', query:{ id:123 }})">Query</button><br>

注意:
如果使用了path,params就被忽略了,但上面的query不会被忽略。所以如果我们要传递参数,可以用两种方式,一种是提供一个name路由,一种是在path里面手动拼接参数:

1
2
3
4
5
6
7
// routes.js
{ path:'/programmatic-navigation/params=/:id', component: resolve => require(['./components/programmatic-navigation/ProgrammaticNavigation'],resolve), name: 'programmatic-navigation-params' },

// ProgrammaticNavigation.vue
<!--传递参数 返回 /programmatic-navigation/params=/123-->
<button @click="$router.push({ name:'programmatic-navigation-params', params: { id }})">WidthParmas01</button><br>
<button @click="$router.push({ path:`/programmatic-navigation/params=/${ id }`})">WidthParmas02</button><br>

这个规则也同样适用于router-link的to属性。

如果我们的当前路径和跳转路径相同,那么我们需要在beforeRouteUpdate方法中响应数据更新:

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
ProgrammaticNavigation.vue
<template>
<div>

<a href="/">←返回</a><br>
<p>ProgrammaticNavigation</p>
<pre>{{ JSON.stringify(params) }}</pre>

<button @click="$router.push({ path:`/programmatic-navigation/params=/${ 456 }`})">WidthParmas03</button><br>
</div>
</template>

<script>

export default {
name: "ProgrammaticNavigation",
data() {
return {
params:this.$route.params
}
},
beforeRouteUpdate (to, from, next) {
if (to.path == '/programmatic-navigation/params=/456') {
this.params = to.params
}
}
}
</script>

router.replace

router.replace与router.push类似,唯一不同的是它不是推送到一个新的历史页,而是替换当前页。

router.go(n)

这个方法和window.history.go(n)类似,n用整数表示,表示在历史页中向前后向后走多少页。

1
2
3
4
5
6
// 后退一页
router.go(-1)
// 前进一页
router.go(1)
// 如果没有,导航失败,页面保持不变
router.go(10)

操作History

路由的router.push,router,replace和router.go对应window.history.pushState, window.history.replaceState 和 window.history.go, 他们模仿的是window.historyAPIs。
Vue Router的导航方法(push, replace, go)在所有的路由模式下都可以工作(history,hash和abstract)。

命名路由

有时候给路由一个名字更方便,用法也很简单:

1
2
3
4
5
6
7
8
9
10
// routes.js
{
path:'/named-routes/:id',
component: resolve => require(['./components/named-routes/NamedRoutes'],resolve),
name:'named-routes'
},

// HelloWorld.vue
<router-link :to="{name: 'named-routes', params: { id:123 }}">named-routes router-link</router-link><br>
<button @click="$router.push({name: 'named-routes', params: { id:123 }})">named-routes router.push</button>

这两种方式都是对象传递,注意to前面要加冒号的,表示内部是对象表达式而不是单纯的字符串。

命名视图

有时候我们可能需要在同一页展示多个视图,而不是做视图嵌套,比如说主页面或者是sidebar侧边栏。这个时候我们就会用到命名视图,也就是说在同一页面下使用多个<router-view>,给每个<router-view>一个不同的名字,如果没给名字,默认名为default。

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
  // routes.js 命名View
{
path:'/named-views',
component: resolve => require(['./components/named-views/NamedViews'],resolve),
children: [
{
path:'',
components: {
default:resolve => require(['./components/named-views/ViewA'],resolve),
b: resolve => require(['./components/named-views/ViewB'],resolve),
c: resolve => require(['./components/named-views/ViewC'],resolve),
},
}
]
},

// NamedViews.vue
<template>
<div>
<a href="/">←返回</a><br>
<p>NamedViews</p>
<router-view></router-view>
<router-view name="b"></router-view>
<router-view name="c"></router-view>
</div>
</template>

嵌套命名视图

我们可以在嵌套视图里使用命名视图创造更复杂的布局。我们来看一个例子:

1
2
3
4
5
6
7
8
9
// NestedNamedViews.vue
<template>
<div>
<p>NestedNamedViews</p>
<ViewNav/>
<router-view></router-view>
<router-view name="b"></router-view>
</div>
</template>

这里:

  • NestedNamedViews本身是View组件
  • ViewNav是一个常规的组件
  • router-view内部是被嵌套的视图组件

路由配置这样来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// routes.js
{ path: '/nested-named-views',
component: resolve => require(['./components/named-views/NestedNamedViews'], resolve),
children: [
{
path:'profile',
component: resolve => require(['./components/nested-routes/Profile'],resolve)
},
{
path:'archive',
components: {
default:resolve => require(['./components/named-views/ViewA'],resolve),
b: resolve => require(['./components/named-views/ViewB'],resolve),
}
}
]
},

转发和别名

转发
是我们可以在访问a的时候跳转b。
支持路径和命名访问两种形式:

1
2
3
4
5
6
7
// routes.js
{
path:'/orignal01' ,redirect: '/forward'
},
{
path:'/orignal02' ,redirect: {name: 'forward'}
},

别名是这样的,如果/b是组件a的别名,那意味着我们访问/b的时候,匹配的组件依然是a:

1
2
3
4
5
6
7
// routes.js
{
path: '/forward',
name: 'forward',
component: resolve => require(['./components/redirect-views/RedirectViews'], resolve),
alias: '/alias'
},

传递Props给路由组件

为了使路由的组件更加灵活,vue支持在路由组件中传递Props,使用props操作符。支持三种模式:
布尔值模式

当props设置为true的时候,route.params将被设置作为组件的props。

1
2
3
4
5
6
// routes.js
{
path: '/props-to-route/:id', // http://10.221.40.28:8080/props-to-route/123
component: resolve => require(['./components/props-route/PropsRoute'], resolve),
props: true
},

对象模式

当props是一个对象的时候,这个对象也一样会被设置给组件的props,这在props为固定值的时候很有用:

1
2
3
4
5
6
// routes.js
{
path: '/static', // http://10.221.40.28:8080/static
component: resolve => require(['./components/props-route/PropsRoute'], resolve),
props: {name: 'static'}
},

函数模式

我们可以创建一个函数返回props,这可以让你转化参数为其他类型,经静态值与基于路由的值结合,等等。

1
2
3
4
5
6
// routes.js
{
path: '/function-mode', // http://10.221.40.28:8080/function-mode?keyword=%27beijing%27
component: resolve => require(['./components/props-route/PropsRoute'], resolve),
props: (route) => ({ query: route.query.keyword})
},

如果URL为/function-mode?keyword='beijing'将传递{query: 'beijing'}作为组件的props。

注意:
props函数是无状态的,仅仅计算路由的改变。如果需要状态去定义props,那么Vue官方建议封装一个组件,这样vue就能够对状态做出反应。

路由进阶

导航警卫

这是vue-router提供的一些控制路由进程的函数,有三种表现方式:全局定义,路由内定义和组件中定义。

全局定义

分为前置警卫、解析警卫和后置钩子。我们依次看一下:

1.前置警卫

1
2
3
router.beforeEach((to, from, next) => {
// ...
})

无论哪个导航先触发之前都会先调用它,警卫的解析可能是异步的,所以在所有的钩子解析完成之前,导航处于悬停状态(pending)。实践中经常在这里判断是否携带了进入页面的必要信息,否则做跳转。(比如通过URL地址栏手动输入地址非法进入子页面,需要跳转到登录页让用户登录)

注意:别忘了写next函数,否则钩子函数将不会被解析。

2.全局解析警卫

1
2
3
router.afterEach((to, from) => {
// ...
})

和前置警卫一样,只不过是在导航确认之前,所有组件内警卫和异步路由组件被解析之后会立即调用。

3.全局后置钩子
这些钩子和警卫不同的是没有next函数,并且不影响导航。

1
2
3
router.afterEach((to, from) => {
// ...
})

路由独享的警卫

我们可以在路由的配置对象里配置:beforeEnter

1
2
3
4
5
6
7
8
9
10
11
const router = new VueRouter({
routes: [
{
path: '/foo',
component: Foo,
beforeEnter: (to, from, next) => {
// ...
}
}
]
})

这个和全局前置警卫作用相同。

组件内警卫

在组件内可以定义一些路由导航警卫(会被传递给路由配置表):

  • beforeRouteEnter
  • beforeRouteUpdate
  • beforeRouteLeave

beforeRouteEnter不能访问this,因为组件还没有被创建。然后我们可以在next回调里访问,在导航被确认时,组件实例会被当做参数传递给这个回调:

1
2
3
4
5
beforeRouteEnter (to, from, next) {
next(vm => {
// access to component instance via `vm`
})
}

而在beforeRouteUpdate和beforeRouteLeave,this已经可用。所以next回调没有必要因此也就不支持了。

1
2
3
4
5
beforeRouteUpdate (to, from, next) {
// just use `this`
this.name = to.params.name
next()
}

离开警卫常常用于阻止用户意外离开未经保存的内容,导航可用通过next(false)取消。

1
2
3
4
5
6
7
8
beforeRouteLeave (to, from, next) {
const answer = window.confirm('Do you really want to leave? you have unsaved changes!')
if (answer) {
next()
} else {
next(false)
}
}

完整的导航解析流程

  1. 导航被触发
  2. beforeRouteLeave警卫在失活组件被调用
  3. 全局的beforeEach警卫被调用
  4. 在可复用的组件内调用beforeRouteUpdate
  5. 在路由配置中调用beforeEnter
  6. 解析异步路由组件
  7. 在激活组件中调用beforeRouterEnter
  8. 调用全局beforeResolve警卫
  9. 导航被确认
  10. 调用全局afterEach钩子
  11. DOM更新被触发
  12. 用创建好的实例调用beforeRouteEnter守卫中传给next的回调函数。

路由Meta字段

定义路由的时候可以包含一个meta字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const router = new VueRouter({
routes: [
{
path: '/foo',
component: Foo,
children: [
{
path: 'bar',
component: Bar,
// a meta field
meta: { requiresAuth: true }
}
]
}
]
})

怎么样使用这个字段呢?

首先,在routes配置里的每一个路由对象称为路由记录。路由可能会被嵌套,因此当一个路由被匹配的时候,可能会匹配超过一个记录。

例如,上面的路由配置,/foo/bar将匹配父路由记录和子路由记录两个路由配置。

所有的被匹配的路由记录都被导出在$route对象(也在导航警卫的路由对象里)作为$route.matched数组。因此我们需要迭代这个数组来找到路由记录里的meta字段。

在全局导航警卫中检查meta字段的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
router.beforeEach((to, from, next) => {
if (to.matched.some(record => record.meta.requiresAuth)) {
// this route requires auth, check if logged in
// if not, redirect to login page.
if (!auth.loggedIn()) {
next({
path: '/login',
query: { redirect: to.fullPath }
})
} else {
next()
}
} else {
next() // make sure to always call next()!
}
})

过渡

简单说就是给路由加一些过渡效果。有几种方式可以实现:
1.在统一的router-view入口给一个一致的过渡

1
2
3
<transition>
<router-view></router-view>
</transition>

查看所有的过渡APIs

2.在每一个组件内部给一个特别的过渡

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const Foo = {
template: `
<transition name="slide">
<div class="foo">...</div>
</transition>
`
}

const Bar = {
template: `
<transition name="fade">
<div class="bar">...</div>
</transition>
`
}

3.还可以根据当前路由和目标路由之间的关系设计动态过渡

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<template>
<div>
<div>
<router-link to="subviewa">subviewa</router-link><br>
<router-link to="subview-b">subview-b</router-link><br>
<p>Home</p>
<transition :name="transitionName">
<router-view></router-view>
</transition>
</div>
</div>
</template>

<script>
export default {
name: "Home",
data() {
return {
transitionName: 'slide-left'
}
},
beforeRouteUpdate (to, from, next) {
this.transitionName = to.path < from.path ? 'slide-right' : 'slide-left'
next()
},
}
</script>

<style scoped>
.slide-left-enter, .slide-right-leave-active {
opacity: 0;
transform: translateX(10px);
}
.slide-left-leave-active, .slide-right-enter {
opacity: 0;
transition: all .8s cubic-bezier(1.0, 0.5, 0.8, 1.0);
}
</style>

数据获取

在实践中,很多时候在进入一个新的路由页面时,我们都是需要从服务器请求数据的,有两种方式:

  • 在导航之后获取:首先导航到新的页面,在导航的生命周期钩子中(比如created方法)做数据获取。
  • 在导航之前获取:在路由导航进入警卫之前获取数据,数据获取完成之后执行导航。

技术上都可以实现,用哪种取决于用户体验的目标。

导航之后获取数据

使用这种方式,我们会立即导航到新的页面,渲染组件,在组件的created钩子中渲染组件。当获取数据是我们可以展示一个loading的状态,并且每一页可以有自己的loading视图。

我们看一个代办事项的例子:

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
<template>
<div>
<div v-if="loading">
Loading
</div>
<div v-if="error">
{{ error }}
</div>
<div v-if="list" >
<div v-for=" item in list" :key="item.id">
<p>{{ item.title }}</p>
</div>
</div>
</div>
</template>

<script>
import TodoAPI from '../api/todo';
export default {
name: "TodoList",
data () {
return {
loading: false,
list: null,
error: null
}
},
created () {
// 组件创建时获取数据
this.fetchData()
},
watch: {
// 路由改变的时候再次调用方法
'$route': 'fetchData'
},
methods: {
fetchData () {
this.error = this.list = null
// 模拟网络请求
TodoAPI.getTodoList(
(todolist) => {
this.loading = false,
this.list = todolist
},
(error)=>{
this.loading = false,
this.error = error
})
}
}
}
</script>

// 网络请求API -- todo.js
const _todoList = [
{"id": 1, "title": "购物"},
{"id": 2, "title": "回复邮件" }
]

export default ({
getTodoList (cb,errorCb) {
setTimeout(() => {
Math.random() > 0.5
? cb(_todoList)
: errorCb("数据请求失败")
},1000)
}
})

滚动行为

当我们使用vue-router的时候,我们可能想要打开新页再在一个位置,或使返回历史页保持在上次浏览的位置。vue-router允许我们自定义导航行为。

注意
这个功能仅仅在浏览器支持history.pushState的时候可以用。

当我们创建一个路由实例的时候,我们可以创建一个scrollBehavior函数。

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
const scrollBehavior = function (to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
const postion = {}

if (to.hash) {
postion.selector = to.hash
if (to.hash === '#anchor2') {
postion.offset = {y: 100 }
}
if (/^#\d/.test(to.hash) || document.querySelector(to.hash)) {
return postion
}
return false
}

return new Promise(resolve => {
if (to.matched.some(m => m.meta.scrollToTop)) {
postion.x = 0
postion.y = 0
}

this.app.$root.$once('triggerScroll', () => {
resolve(postion)
})
})
}
}

const router = new VueRouter({
mode: 'history',
scrollBehavior,
routes,
})

这里面接收to,from两个路由对象,第三个参数savedPosition,第三个参数仅仅在popstate导航中可用(浏览器的向前/后退按钮被触发的时候)。

这个对象返回滚动位置对象,格式如:

  • {x: nubmer, y:number }
  • {selector: string, offset? : { x: number, y: number}}(offset 仅仅被支持在2.6.0+以上)

如果是空值或无效值,则不会发出滚动。

1
2
3
4
5
6
7
scrollBehavior (to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { x: 0, y: 0 }
}
}

如果返回savedPosition,和我们使用浏览器的向前向后按钮一下,如果有历史滚动页,会返回到历史页查看的位置,如果返回{ x: 0, y: 0 }则滚动到顶部。

在开始的例子里,我们可以这样模拟滚动到某处的行为:

1
2
3
4
5
6
7
8
9
if (to.hash) {
postion.selector = to.hash

if (to.hash === '#anchor2') {
postion.offset = {y: 100 }
}

// ...
}

以下我们返回了一个Promise,来返回滚动的位置

1
2
3
4
5
6
7
8
9
10
11
12
return new Promise(resolve => {
// 如果匹配scrollToTop,我们就返回顶部
if (to.matched.some(m => m.meta.scrollToTop)) {
postion.x = 0
postion.y = 0
}

// 如果空值或无效值,返回当前滚动位置
this.app.$root.$once('triggerScroll', () => {
resolve(postion)
})
})

懒加载路由

当使用一个捆绑器构建应用时,JavaScript包会变得非常大,因此影响页面加载的时间。如果我们可以把每一个路由组件分离单独的块,等浏览页面时再加载他们会效率比较高。

Vue的异步组件功能和webpack的代码分离功能可以很容易的做到这一点。

首先,可以将异步组件定义为返回Promise的工厂函数(应解析为组件本身):

1
const Foo = () => Promise.resolve({ /* component definition */ })

其次,在webpack2,我们可以使用动态导入语法去表示代码分离点:

1
import('./Foo.vue') // returns a Promise

注意
如果你使用Babel,你需要添加syntax-dynamic-import以便Babel可以正确解析。

把这两个步骤联合起来,我们就可以通过webpack自定定义一个异步的代码分离组件,所以我们可以把我们的路由配置修改为:

1
2
3
{
path: '/lazy-route', component: () => import('./components/lazy-route/LazyHome')
}

在同一个块中分组组件

有时,我们想要把所有组件分组到相同的异步块中,为了实现这个,在webpack > 2.4中,我们可以使用特殊的注释语法提供命名块:

1
2
3
4
5
6
{
path: '/lazyTwo', component: () => import(/* webpackChunkName: "lazy" */ './components/lazy-route/LazyTwo'),
children: [
{path: 'lazyThree', component: () => import (/* webpackChunkName: "lazy" */ './components/lazy-route/LazyThree')}
]
},

webpack将使用相同块名称的任何异步模块分组到相同的异步块中。


参考:Vue Router

Mac命令行的使用

发表于 2019-11-15

Shell & Bash

命名行在程序员的常用的技能,命令行由Shell提供,传递给操作系统内核。学习命令行就是在学Shell指令,Mac-OS 下的默认shell是bash。

Shell
Shell:命名语言解释器(command-language-interpreter),你和操作系统内核之间的接口程序,内建了shell指令集。
Bash
一个命令处理器,全称:Bourne-Again Shell,Bash是Bourne shell后继兼容版本与开放源代码版本,Bourne shell 是一个交换式的命令解释器和命令编程语言。bash的命令语法是Bourne shell命令语法的超集。bash 与 Bourne shell 完全向后兼容,并且在 Bourne shell 的基础上增加和增强了很多特性。bash 也包含了很多 C 和 Korn shell 里的优点。bash 有很灵活和强大的编程接口,同时又有很友好的用户界面。更多细节查看bash与shell的关系。

Mac下命令行的基本使用

上面我们提到Mac下默认使用bash shell,初次接触使用bash即可,安装其他的第三方shell并学习一些花样语法没有太多必要,找出和自己工作相关的常用命令用以提高工作效率,熟练之后再去探索诸如提升体验的配置皮肤、自定义快捷键、或熟悉其他第三方shell。以下列出Mac比较常用的命令。

快捷键

1. 文件和文件夹自动补全

1
Tab: 文件和文件夹名自动补全

2. 光标位置控制

1
2
3
4
Ctrl + A :跳到当前行的开始
Ctrl + E :跳到当前行的结束
Option + -> : 向前移动一个词 // windows 键盘使用Alt键
Option + <- : 向后移动一个词

3. 文字内容控制

1
2
3
4
Ctrl + L :清空屏幕
Ctrl + U :清空光标前的行
Ctrl + K :清空光标后的行
Ctrl + W :删除光标前的词(注意与`Ctrl + U`的区别,前者删除一个词,后者清空整行命令)

4.进程控制

1
2
3
Ctrl + C : 关闭你正在运行的任何程序
Ctrl + D :退出当前命令行窗口
Ctrl + Z : 暂停当前程序的运行(注意`Ctrl + C`相当于closed,而`Ctrl + Z`相当于stoped)

特别说明
如果你Ctrl + Z将程序挂起到后台,那么如何恢复前台运行呢
Alt text
看上面我启动了一个监听任务:http://localhost:8084
然后按Ctrl + Z,暂停监听
bg(background)查询后台挂起的任务,当前只有一个
fd(front desk)查询前台运行的任务,默认执行最后一个,也就是将挂起任务激活执行。 也可以指定 fd npm run dev
另外jobs命令查看前后台所有任务
参考:在LINUX中,用Ctrl+Z挂起的命令怎么切回到原任务的命令窗口?

基本指令

1. 目录路径操作

1
2
3
4
5
6
7
8
9
10
11
/ : 顶层目录
. : 当前目录
.. : 父级目录
~ : Home目录
cd : Home 目录
cd ~ : Home 目录
cd / : 驱动的根节点
cd - : 你浏览的上一个文件夹或目录
pwd : 展示当前目录路径
cd .. : 移动到父级目录
cd ../.. : 移动两层父级目录

2.列表目录内容

1
2
3
4
5
6
7
8
ls:显示目录中文件和子目录的名字
ls -1 : 以每行一个的格式显示文件列表
ls -F : 在每个目录路径后立即显示/(斜杠),在可执行程序或脚本后显示*(星号),在符号链接后显示@
ls -S : 按文件尺寸从小到大对文件或目录进行排序
ls -l : 长格式的列表。包含文件模式、所有者和组名、修改的日期和时间文件、路径名等
ls -lt : 以文件的修改顺序列出文件(最近修改的在前)
ls -lh : 长清单,以KB、MB或GB表示人类可读的文件大小
ls -la :列出目录详情,包括隐藏文件、文件时间戳

3.文件尺寸和磁盘空间

1
2
3
4
5
6
7
8
9
// du:disk utility 磁盘实用工具
du : 列出子目录的磁盘使用情况
du * : 列表所有子文件夹和文件的磁盘使用情况
du -s [file/folder]: 显示指定文件或文件夹的磁盘使用情况,如果没有指定显示当前目录所在文件的磁盘总尺寸,`-s`:(display)显示指定文件的条目
du -sh : 与`du -s`的区别是多加了一个h,(human-readable),以可读方式输出,即输出会加上单位,比如Byte,KB,GB等
du -sh * : 查看当前文件夹内文件和目录的大小
du -sh * | sort -nr : 查看当前文件夹内文件和目录的大小并从大到小排序显示
df -h : 计算系统剩余磁盘空间(disk free)
df -H : 使用以10为底的大小(而不是1024)计算磁盘剩余空间,最终数字是三位或更少

关于磁盘使用情况单位及命令的解释

使用du命令查看磁盘使用情况的时候,默认是没有显示单位,只显示数字,而这个数字的单位是512byte,而非1024byte,也就是说,假如你使用du -s [file]命令显示的大小为536,那么du -sh [file] 加上单位的显示为268k。而关于-h、-s等指令的具体含义,可以使用man du命令查看du的使用手册。
参考:
Mac OS 命令du单位问题
Mac du 笔记

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
26
27
28
29
30
open + 文件 : 打开一个文件

// 创建
mkdir <dir> : 创建名称为<dir>的新文件夹
mkdir -p <dir>/<dir> : 创建嵌套文件夹
mkdir <dir1><dir2><dir3> : 一次创建多个文件夹
mkdir ' <dir>' : 创建一个有空格的文件夹
rmdir <dir> : 删除文件夹(只对空文件夹有效,如果非空文件夹,用下一条命令)
rm -R <dir> : 删除文件夹和它的内容

touch <file> : 以任何拓展名创建文件

// 拷贝
cp <file><dir> : 拷贝一个文件到一个目录
cp <file> <newfile> : 复制当前文件夹下的指定文件
cp <file>~/<dir>/<newfile> : 拷贝当前文件到一个新的文件夹下并重新命名
cp -i <file><dir> : 如果覆写文件会给一个提醒
cp <file1> <file2> /Users/<dir> : 拷贝多个文件到一个文件夹

// 删除
rm <file> : 删除指定文件(永久删除,谨慎使用)
rm -i <file> : 删除文件时给你一个确认
rm -f <file> : 强制删除不给提醒
rm <file1> <file2> <file3> : 删除多个文件

// 移动
mv <file> <newfilename> : 移动/重命名
mv <file><dir> : 移动一个文件到文件夹,可能覆写已经存在的文件
mv -i <file><dir> : 移动文件到一个文件夹,覆写文件之前会给提示,如果没有覆写直接移动
mv *.png ~/<dir> : 移动所有的PNG图片从当前文件夹到一个不同的文件夹

5.权限控制

1
2
3
4
5
sudo x : 以超级用户的权限运行命令
ls -ld : 默认显示home目录的权限
ls -ld <dir> : 显示一个文件夹的读写和访问权限
chmod 755 <file> : 改变一个文件的权限为755,chmod本身表示修改文件权限的意思
chmod -R 600 <dir> : 改变一个文件夹和它的内容的权限为600

关于文件权限数字和用户的对应关系

Alt text
Alt text
上图出自How to set File Permissions in Mac OS X,作者的这两个图已经把数字和权限的对应关系表现的一目了然。所有的权限由0-7表示,其实是一个八进制数字。

Mac一般会有三类用户:
owner(所有者)
staff(组–与文件所有者同在一个用户组的用户)
everyone(其他用户组的用户):

而权限的三个数字分别对应如上三类用户,比如755,分别对应如上三种权限,7 代表读写和执行权限(为owner所拥有)、第一个5可读可执行(为staff所拥有)、第二个5为可读可执行(为everyone所拥有)

参考:
linux chmod 755

6.进程查看

1
2
3
4
5
6
ps -ax : 显示操作系统当前运行的所有进程,这里 `a`展示所有用户进程,`x`展示所有未连接到终端的进程
top : 显示操作系统当前运行程序的时实信息
top -ocpu -s 5 : 按CPU使用情况排序进程,每5秒更新一次
top -o rsize : 根据内存使用情况进行排序
kill PID : 使用ID<PID>退出进程,可以通过活动监视器查看进程的PID
ps -ax | grep <appname> : 通过名字或PID查找一个进程,`grep`为UNIX工具程序,可做文件内的字符串查找

7.网络相关

1
2
3
4
5
ping <主机> : Ping主机和显示状态,如ping www.cnblogs.com
whois <域名> : 输出一个域名的查询信息
curl -O <url/to/file> : 下载文件通过HTTP,HTPS,or FTP
ssh <username>@<host> : 建立SSH连接给<host>用<username>这个用户
scp <file> <user>@<host>:/remote/path : 复制文件给一个远程主机

8.搜索文件和文本

1
2
3
find <dir> -name <"file"> : 发现目录<dir>内文件名为<file>的所有文件.可以使用(*)去搜索文件名部分,如:`find demo01 -name "*.png"`
grep "<text>" <file> : 输出在file中出现的所有<text>文本(添加 -i大小写不敏感)
grep -rl "<text>" <dir> : 搜索<dir>包含<text>文本的所有文件

9.输出文件内容

1
2
3
cat <file> : 输出文件的内容
less <file> : 使用less命令输出<file>内容,支持分页和更多
head <file> : 输出前10行

10.历史命令查找

1
2
3
4
Ctrl + R : 搜索之前使用的命令
history n : 展出之前使用过的命令,n为指定查询的条数
![value] : 执行以一个值开头的最后一个被写的命令
!! : 执行最后一个命令

11.查看帮助

1
2
[command] -h : 帮助文档,如git -h
man [command] :展示该命令的帮助手册 ,进入帮助手册后按`wq`退出


参考:The Mac Terminal Commands Cheat Sheet

WebStorm | 一分钟入门WebStorm

发表于 2019-10-06

WebStorm是JetBranins开发的JavaScript IDE。

Alt text

对照上图,我们依次来解释:

查找

1
2
3
4
5
Double ⇧:搜索任何东西
⇧⌘O:查找具体的文件名称
⌘E:最近打开的文件
⌘1:工程目录快捷键
⌘↑:打开导航栏

以上命令配合箭头键(定位文件)+ 回车键(进入文件)来快速锁定资源位置。

右侧边栏

Project:打开工程目录,功能同⌘1

Structure:展示当前文件结构(注意需要在具体文件下面才能展示,此功能在文件较大时,快速定位方法和标签位置比较方便)

npm:包管理器,点开后界面:

Alt text

  • 双击serve启动本地项目,对应命令:npm run serve
  • 双击build执行项目打包命令,对应命令:npm run build

    如果双击没反应,右击选择编辑菜单栏,看一下Command,Scripts,Node interpreter,Package mananger都配对了吗,如下图:
    Alt text

下侧边栏

TODO:查找代办事项

在实际开发中可能经常碰到这样的情况,就是写到一半因为其他原因中断,再回来我们需要知道上次写到哪里了,或声明一些东西一会做更好的优化。我们需要声明TODO代办事项,以便以后可以一下就能找到。这个TODO可以使用小写todo。

如:
我们在HelloWorld.vue下声明代办事项:

1
2
3
4
5
6
7
export default {
name: 'HelloWorld',
// TODO:find a better way
props: {
msg: String
}
}

在TODO下会有如下提示,双击TODO项即可打开文件所在位置,非常方便。
Alt text

Version Control:代码版本控制工具
Alt text
使用GitHub,码云等代码管理工具,配置后可以在这里提交自己的代码。点击第一排列第二个小突触提交代码到对应的版本控制工具,快捷键⌘K。

Terminal:IDE下集成的命令行工具

高频应用,我们在里面执行构建、打包、第三方插件安装等命令

文件内操作

1
2
⌘F:文件内字符查找
⌘R:执行字符替换或批量替换

OVER

真的结束了吗,是的,根据80/20法则,我们并不用也没有必要一上来就了解所有的使用细节。我们需要找到那20%的高频操作,而后快速用起来,就是以上这些了。至于其他工具怎么用以及更细节的操作,以后慢慢研究吧。

Vue系列 | Vuex状态管理器

发表于 2019-10-05

What 什么是Vuex

官方的定义是这样的:

1
Vuex is a state management pattern + library for Vue.js applications.

显然它是Vue官方为Vue.js提供的库,功能是数据状态管理。它是在一个应用的层级对所有组件做集中状态管理的工具。来看一下Vuex在应用中的结构:
Alt text

定义中提到了state management pattern,即状态管理模式,那问题就来了为什么需要这种设计模式呢,普通的模式什么样呢,多加一层有什么好处呢,软件开发中有一条不成文的规律:多加一层。难度就下降一倍。我们来看看这样的设计是否达到了这样的效果。

我们来看一下正常情况下的模式:
Alt text
即所谓的State–View–Action模式:

这是一个简化的自包含系统,遵循单向数据流的核心原则。

View:用于接收用户输入

Actions:响应用户输入来改变状态

State:接收用户数据,并把数据传递到View层来显示

然而当多个View共享一个State时,这种平衡被破坏了。

  • 多个view可能依赖相同的state
  • Actions来自不同的View需要改变相同的state
    对于问题一, 如果是层级嵌套的组件View的状态更新我们可以通过props来传递,但是层级过多的时候很麻烦而同级组件不起作用。
    对于问题二,我们经常需要查询父子空间的应用重新存储State,或通过事件来更改和同步多个使用状态的地方,会导致代码脆弱,不可维护。

基于以上原因,我们抽象出了图一所谓的状态管理模式,即:把共享的状态取出来,变成一个全局单例。无论是访问状态还是追踪改变,都通过这个单例进行。

这就是Vuex背后的核心思想,通过定义和分离状态管理的概念,使views和states保持了独立,从而给了我们更多的代码结构和维护性。

Vuex是专门为Vue.js定制开发的,利用的Vue.js的响应式系统和高效更新的优势,是Vue项目下状态管理插件的首选。

Why 为什么要使用Vuex

其实有时候知道了what,我们也就知道了why。自己定义一个全局的单例貌似也可以实现同样的效果,我们来具体说说为什么Vuex和全局单例是不同的:

  1. Vuex作为应用的存储中心,它的数据是响应式的。如果状态发生改变,界面会进行高效自动更新。
  2. 你并不能够直接改变存储的状态,我们其实是通过committing mutations的声明来改变状态的,这使得我们的改变是可以被如devtools工具追踪到,有助于bug调试。

When 何时使用

Vuex虽然帮我们共享了状态管理,但也额外引入了更多的概念和样板文件。如果你的应用比较简单,你就用store pattern,一种追踪变化和实时响应的存储模式。如果你是中大型项目,尤其是跨层级的组件间共享状态你自然可以尝试Vuex。

How 如何使用

  1. 安装npm install vuex --save
  2. 引用

    1
    2
    import Vuex from 'vuex'
    Vue.use(Vuex)
  3. 创建一个存储实例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    const store = new Vuex.Store({
    state: {
    count: 0,
    todos: [
    { id: 1, text: '...', done: true },
    { id: 2, text: '...', done: false}
    ]
    },
    mutations: {
    increment (state, n) {
    state.count += n
    }
    },
    actions: {
    increment({commit}) {
    setTimeout(() => {
    commit('increment', 2)
    },3000)
    }
    },
    getters: {
    doneTodos: state => {
    return state.todos.filter(todo => todo.done)
    },
    doneTodosCount: (state, getters) => {
    return getters.doneTodos.length
    },
    getTodoById: (state) => (id) => {
    return state.todos.find(todo => todo.id === id)
    }
    }
    })

大家看到上面声明了一些对象关键字如state、mutations、getters,先别急,后面我们一个一个讲清楚。

  1. 挂载到Vue实例下面

    1
    2
    3
    4
    5
    // main.js 入口文件
    new Vue({
    store,
    render: h => h(App),
    }).$mount('#app')
  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
34
35
36
37
38
39
40
41
42
43
44
45
// App.vue
<template>
<div id="app">
<div>
{{ count }}
<button @click="$store.commit('increment',2)">count++</button>
<button @click="$store.dispatch('increment')">count++</button>
</div>
<div>
<div>
<span>
doneTodos:
</span>
{{ $store.getters.doneTodos }}
</div>
<div>
<span>
doneTodosCount:
</span>
{{ doneTodosCount }}
</div>
<div>
<span>
getTodoById(2):
</span>
{{ $store.getters.getTodoById(2) }}
</div>
</div>
</div>
</template>

<script>
export default {
name: 'app',
computed: {
count () {
return this.$store.state.count
},
doneTodosCount () {
return this.$store.getters.doneTodosCount
},

}
}
</script>

核心概念

State

State是一个全局唯一的状态树

如何在Vue组件中获取Vuex的状态

方法一:由于state的存储是响应式的,所以我们可以在合适的计算属性里使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const store = new Vuex.Store({
state: {
count: 1
}
})
const Counter = {
template: `<div> {{ count }}</div>`,
computed: {
count () {
return store.state.count
}
}
}
var vm = new Vue({
el:'#example',
components: {Counter},
template:`<counter></counter>`
})

进一步优化

这样每当在其他组件使用的时候,我们都需要导入store,Vue为我们提供了一个store操作符,可以全局注入所有的子组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const Counter = {
template: `<div> {{ count }}</div>`,
computed: {
count () {
return this.$store.state.count
}
}
}
var vm = new Vue({
el:'#example',
store,
components: {Counter},
template:`<counter></counter>`
})

在创建Vue根实例的时候声明store,而后使用this.$store的方式访问。

注意:
我这里是通过<script>标签对导入的Vuex,如果你是通过包管理工具下载而后通过import命令导入的包,还需要声明Vue.use(Vuex)

mapState 助手

在组件中访问多个属性或getters的时候,它可以帮我们简化写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script>
import { mapState } from 'vuex'
export default {
name: 'HelloWorld',
data () {
return {
localCount: 5
}
},
computed: mapState({
// 可以使用箭头函数访问
count: state => state.count,
// 给count起一个别名
countAlias: 'count',
// 当使用本地数据的时候必须使用函数
countPlusLocalState (state) {
return state.count + this.localCount
}
})
}
</script>

在被映射的计算属性和state的子树名称相同时,可以传递一个字符串数组:

1
2
3
computed: mapState([
'count'
])

对象拓展运算符

1
2
3
4
5
6
computed: {
localComputed () { return 1 },
...mapState({
count: state => state.count
})
}

可以将mapState返回的对象结合其他的本地属性使用。

Getters

有时候我们需要基于state做一些计算,例如从一个代办事项里过滤已经做完的事情:

1
2
3
4
5
computed: {
doneTodosCount () {
return this.$store.state.todos.filter(todo => todo.done).length
}
}

如果多个地方用到这个功能,我们需要写多个函数或取出封装为一个公共组件在其他函数里导入,都不是好主意。

getters相当于Vue组件的计算属性,可以缓存它的依赖数据,当依赖改变的时候才会重新计算:

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

怎么访问Getters

  1. 属性方式访问

getters通过store.getters返回对象,所以我们可以直接在组件中使用:

1
2
3
4
5
computed: {
doneTodos() {
return this.$store.getters.doneTodos // [ { "id": 1, "text": "...", "done": true } ]
}
}

getters可以接收其他的getters作为第二个参数:

1
2
3
4
5
6
7
8
getters: {
doneTodos: state => {
return state.todos.filter(todo => todo.done)
},
doneTodosCount: (state, getters) => {
return getters.doneTodos.length // 1
}
}

  1. 方法方式访问

我们可以返回一个函数,这在查询数组的时候尤其有用

1
2
3
getTodoById: (state) => (id) => {
return state.todos.find(todo => todo.id === id)
}

1
this.$store.getters.getTodoById(2) //  { "id": 2, "text": "...", "done": false }

注意:
通过方法调用没办法缓存,每一次都会重新计算

mapGetters助手

它负责把store getters映射到本地属性,所以使用时代码我们可以简化如下:

1
2
3
4
5
6
computed: {
...mapGetters([
'doneTodos',
'doneTodosCount'
])
}

可以用以下方式起一个别名:

1
2
3
...mapGetters({
doneCount: 'doneTodosCount'
})

Mutations

mutations其实是Vuex里面改变state的唯一方式,也就是说你必须通过提交一个mutation来改变state的状态,不能直接操作state。它和事件比较像,有一个字符串的类型和一个处理器,这个处理器函数来执行实际state的改变。它接收state作为第一个参数。

1
2
3
4
5
6
7
8
9
10
const store = new Vuex.Store({
state: {
count: 0
},
mutations: {
increment (state) {
state.count ++
}
}
})

需要注意的是你不能直接调用它,它更像是一个事情注册,当类型为increment的mutation函数被触发时,调用这个处理器。为了调用这个处理器,我们需要使用store.commit('increment')。

1
2
3
4
5
<template>
<div>
<button @click="$store.commit('increment')">++</button> // 1
</div>
</template>

使用Payload

你可以传递给store.commit一个额外的参数,payload将会被调用。

1
2
3
4
5
mutations: {
increment (state, n) {
state.count += n
}
}

1
<button @click="$store.commit('increment',2)">++</button>

一般payload可能包含多个字段,这样的话我们可以更具体的访问:

1
2
3
4
5
mutations: {
increment (state, payload) {
state.count += payload.amount
}
}

1
<button @click="$store.commit('increment',{amount: 2})">++</button>

对象方式提交

我们可以采用对象方式提交:

1
store.commit({type:'increment', amount: 2})

此时,整个对象会被传递给payload,所以,mutation的处理方式是相同的:

1
2
3
4
5
mutations: {
increment (state, payload) {
state.count += payload.amount
}
}

对于mutation的类型使用常量

对于mutation的类型我们可以使用常量,这样的话可以使用语法高亮,不容易拼写错误,而且把他们单独放在一个文件中,对于团队开发会比较方便,直接能在一个文件里面看到所有的mutation,这是一个常见的使用惯例。

1
2
// mution-type.js
export const INCREMENT = 'increment'

1
2
3
4
5
6
import { INCREMENT } from './mutaion-types'
mutations: {
[INCREMENT] (state, payload) {
state.count += payload.amount
}
}

Mutations必须是同步的

由于devtools里面调试时需要抓取state改变前后的对比数据,但是异步函数不知道什么时候调用完成,所以没有办法追踪。

在组件中使用Mutations

我们可以使用this.$store.commit(‘xxxx’)来正常提交,也可以使用mapMutations助手(必须把store注入根节点)做映射:

1
2
3
4
5
mutations: {
[INCREMENT] (state,payload) {
state.count += payload.amount
}
}

1
2
3
4
5
6
7
8
// HTML
<button @click="increment({amount: 3})">++</button>
// JS
methods: {
...mapMutations([
'increment'
])
}

还可以设置别名:

1
2
3
4
5
6
7
8
// HTML
<button @click="add({amount: 3})">++</button>
// JS
methods: {
...mapMutations({
add: 'increment'
})
}

Actions

为异步操作而生

  • 提交操作给mutations而不是直接改变state
  • 可以执行任何异步操作

先看一段声明:

1
2
3
4
5
actions: {
increment (context) {
context.commit('increment')
}
}

context包括了所有store上下文的所有方法和属性,所以他可以访问context.commit,context.getters,context.state。

在实践中我们使用ES2015的参数解构来简化我们的代码,尤其是需要调用commit多次时:

1
2
3
4
5
actions: {
increment ({commit}) {
commit('increment')
}
}

发送Actions

我们使用store.dispatch方法来触发Actions对象。

1
store.dispatch('increment')

之所以搞的这么麻烦是因为Actions支持异步操作:

1
2
3
4
5
incrementAsync({ commit }) {
setTimeout(() => {
commit('increment')
}, 1000)
}

它在使用的时候和Mutations支持相同的payload和对象格式:

1
2
3
4
5
// payload格式
<button @click="$store.dispatch('increment', {amount: 3})">++</button>

// 对象格式
<button @click="$store.dispatch({type: 'increment', amount: 3})">++</button>

1
2
3
4
5
actions: {
increment ({commit}, payload) {
commit('increment', payload)
},
}

它在组件中的用法和Mutations相同,不在赘述。

1
2
3
4
5
6
7
// JS
import { mapActions } from 'vuex'
methods: {
...mapActions({
add: 'increment'
})
}

组合Actions

Actions既然是异步的,那么我们怎么知道它什么时候执行完呢,Actions的处理器可以接收返回一个Promise:

1
2
3
4
5
6
7
8
actionA({commit}, payload) {
return new Promise((resolve, reject) => {
setTimeout(() => {
commit('increment', payload)
resolve('finish')
}, 1000)
})
}

所以现在可以这样接收:

1
2
3
this.$store.dispatch({type: 'actionA', amount: 3}).then((res)=> {
console.log(res) // finish
})

我们还可以在另一个action里嵌套:

1
2
3
4
5
actionB({dispatch,commit},payload) {
return dispatch('actionA',payload).then(() => {
commit('incrementOne')
})
}

1
this.$store.dispatch('actionB',{amount: 4})

当然,我们可以使用async/wait组合的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
incrementValue({state, commit}, payload) {
return new Promise((resolve, reject) => {
// setTimeout(() => {
commit('increment', payload)
resolve('finish')
}, 1000)
},
async actionA({dispatch, commit}, payload) {
await dispatch('incrementValue', payload)
},
async actionB({dispatch,commit},payload) {
await dispatch('actionA',payload)
setTimeout(() => {
commit('incrementOne')
},1000)

}

1
this.$store.dispatch('actionB',{amount: 4})

有时候我们action处理器来自不同的模块,我们需要等到所有的action处理完毕后返回一个Promise。

模块

为了防止store在一个文件中越来越大,Vuex支持模块划分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import ModuleA from './moduleA'
import ModuleB from './moduleB'
const store = new Vuex.Store({
state: {
count: 0,
todos: [
{ id: 1, text: '...', done: true },
{ id: 2, text: '...', done: false}
]
},
modules: {
a:ModuleA,
b:ModuleB
}
})

1
2
3
4
5
6
7
8
9
10
11
12
// ModuleA.js
export default{
state: {
count:10
}
}
// ModuleB.js
export default {
state: {
count:20
}
}
1
2
3
4
5

<div>
{{ $store.state.a }} // { "count": 10 }
{{ $store.state.b }} // { "count": 20 }
</div>

在模块内部,mutations和getters里的参数是接收文件内自己的状态,actions的context.state也是一样的,如果我们要访问根节点上的状态,我们需要通过context.rootState导出:

1
2
3
4
5
6
7
8
actions: {
increment({ state , commit, rootState}) {
console.log(rootState.count)
if (rootState.count === 0) {
commit('increment')
}
}
}

默认情况下,模块内外的事件都是被注册到全局命名空间的,所以访问actions/mutaions的时候,模块间的相同事件是同时响应的,如果想要自包含,需要添加namespaced: true,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ModulesA.js
export default {
namespaced: true,
state: {
count:10
},
mutations: {
increment (state) {
state.count++
}
},

actions: {
someAction ({state, commit,rootState,rootGetters}) {
dispatch('someOtherAction') // moduleA/someOtherAction
dispatch('someOtherAction', null, {root: true}) // someOtherAction
},
someOtherActon(ctx, payload) {
// ...
}
}

访问的时候加上路径就可以:

1
<button @click="$store.dispatch('a/increment')">moduleA++</button>

模块间也是可以继承和嵌套的,具体看:vuex-modlues。

在命名空间下访问全局资源

如果想使用全局的state和getters,那么rootState和rootGetters作为getter函数的第三个和四个参数,他们也被封装到context上下文传递给 aciton 函数。
如果想要dispatch或commit到全局命名空间,需要添加{root: true}为第三个参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
getters: {
someGetter(state, getters, rootState, rootGetters){
getters.someOtherGetter // mudlueA/someOtherGetter
rootGetters.someOtherGetter // someOtherGetter
},
someOtherGetter: state => { //... }
},
actions: {
someAction ({state, commit,rootState,rootGetters}) {
dispatch('someOtherAction') // moduleA/someOtherAction
dispatch('someOtherAction', null, {root: true}) // someOtherAction
},
someOtherActon(ctx, payload) {
// ...
},
}

在命名空间下注册全局资源

在actions对象内,声明action名称对象,在该对象内使用root:true,并把原 action 内的事件处理给到函数handler。

1
2
3
4
5
6
7
8
9
10
// ModuleA.js
actions: {
add:{
root: true,
handler ({state, commit}, payload) {
commit('increment') // moduleA/increment
commit ('increment', payload, {root: true}) // increment
}
}
}

命名空间如何绑定助手

通常情况下,这样来写(以state为例):

1
2
3
4
5
6
import { mapState } from 'vuex'
computed: {
...mapState({
namespacedCount: state => state.a.count
}),
}

如果mapState里面的对象比较多,写起来有点啰嗦,所以我们可以把命名空间模块写为mapState的第一个参数:

1
2
3
4
5
computed: {
...mapState('a',{
namespacedCount: state => state.count
}),
}

如果mapState,mapGetters,mapMutions都用到这个命名空间,我们没有必要在每一个里面都写一个空间命名参数,可以使用createNamespacedHelpers做进一步简化:

1
2
3
4
5
6
7
import { createNamespacedHelpers } from 'vuex';
const { mapState } = createNamespacedHelpers('a')
computed: {
...mapState({
namespacedCount: state => state.count
}),
}

总结

What | 首先我们介绍了什么是Vuex,它是Vue官方提供的状态管理工具,利用了Vue的响应式特性。

Why | 进而我们指出了为什么要使用Vuex,基于它的响应式更新机制及辅助调试。

When | 我们也介绍了你应该什么时候使用Vuex,如何权衡。

How | 我们花了90%以上的篇幅介绍的Vuex的使用细节:

  • 如何安装
  • 如何声明
  • 如何访问

接着我们介绍了Vuex的六大核心概念,他们分别是:

  1. State
    全局状态树,类比组件中的data属性。
  2. Getters
    类比组件中的computed属性,可以缓存数据,用于计算逻辑。
  3. Mutations
    操作state的唯一方式,类比组件中的method。
  4. Actions
    做异步事件处理,只能提交给mutations,不能直接操作state,同样类比组件中的method。
  5. Mudules
    用于复杂场景下的模块划分,可以理解为封装。

参考:
Vuex

Vue系列 | 渲染函数 & JSX

发表于 2019-10-04

基础

Vue推荐我们在大部分情况下使用templates,但有时候我们需要JavaScript的完整编程能力,此时我们可以用templates的替代品–render function。

我们看一下锚定标题

1
<h1>Hello world!</h1>

对于以上HTML,我们可以使用组件接口实现:

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
<body>
<h1>Hello world!</h1>
<div id='app'>
<anchored-heading :level="3">Hello world!</anchored-heading>
</div>

<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script type="text/x-template" id="anchored-heading-template">
<h1 v-if="level === 1">
<slot></slot>
</h1>
<h2 v-else-if="level === 2">
<slot></slot>
</h2>
<h3 v-else-if="level === 3">
<slot></slot>
</h3>
<h4 v-else-if="level === 4">
<slot></slot>
</h4>
<h5 v-else-if="level === 5">
<slot></slot>
</h5>
<h6 v-else-if="level === 6">
<slot></slot>
</h6>
</script>
<script type="text/javascript">
Vue.component('anchored-heading', {
template: '#anchored-heading-template',
props: {
level: {
type: Number,
required: true
}
}
})
new Vue({
el: '#app',
})
</script>
</body>

这个模板语法感觉并不是很漂亮,不仅啰嗦,而且我们每一个标题头标签都会重复,并且每添加一个标题头都必须执行同样的动作。

模板在大多数情况下很好,但有时候不是这样,我们用渲染函数重写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Vue.component('anchored-heading', {
render: function (createElement) {
return createElement(
'h' + this.level,
this.$slots.default
)
},
props: {
level: {
type: Number,
required: true
}
}
})

这种方法比较简洁,但需要熟悉Vue实例属性的一些知识。在这里你必须知道当你传递数据给没有v-slot的子组件时,属性的实例存储在$slots.default中。因此如果不写this.$slots.default,组件间的文字Hello world!是无法显示的。

Nodes,Trees,和Virtual DOM

在我们继续研究渲染函数之前,我们先来回顾一下浏览器是如何工作的:

1
2
3
4
5
<div>
<h1>Hello</h1>
Coentent
<!-- <Todo:add line> -->
</div>

浏览器会把以上代码构建一个DOM节点树,来追踪每一个节点的改变。

高效更新nodes是比较困难的,这里Vue帮我们做了这件事,我们只需要告诉Vue页面是什么就可以了。
可以写在template:

1
<h1>{{ title }}</h1>

或用渲染函数:

1
2
3
4
5
6
render: function (createElement) {
return createElement(
'h1',
this.title
)
},

以上两种写法,在更改title的时候,页面将会自动更新。

The Virtual DOM

Vue是通过虚拟DOM来实现这一点的,我们看这一行:

1
2
3
4
return createElement(
'h1',
this.title
)

createElement返回的东西不是真实的DOM元素,更为形象的表示是创建节点的描述,我们将这个节点描述称为:virtual node,缩写:VNode。而Virtual DOM就是通过Vue构建的正式VNodes树。

v-model

渲染函数中没有v-model的对标物,所以我们需要自己手动实现一个:

1
2
3
4
// 在HTML
<div id='app'>
<my-input v-model="message"></my-input>
</div>

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
// JS
<script type="text/javascript">
Vue.component('my-input', {
props:['value'],
render: function (createElement) {
var self = this
return createElement('div',[
createElement('input', {
domProps: {
value: self.value
},
on: {
input: function (event) {
self.$emit('input', event.target.value)
self.message = event.target.value
}
}
}),
createElement('p','value:' + self.message)

])
},
data () {
return {
message: ''
}
}
})

new Vue({
el: '#app',
data() {
return {
message: '',
}
}
})
</script>

Alt text

上面我们在渲染函数中实现v-model的效果,可以看到v-model其实是domProps:value 和input: function (value)相结合下的语法糖。

Slots

使用this.$slots实例属性来访问数据作为虚拟节点数组:

1
2
3
4
5
6
// JS
Vue.component('slot-div', {
render: function (createElement) {
return createElement('div', this.$slots.default)
}
})

1
2
3
// HTML
<slot-div>hello</slot-div>
// -> hello

从this.$scopedSlots访问scoped slots返回VNodes作为函数:

1
2
3
4
5
6
7
8
9
10
11
// JS
Vue.component('scoped-div', {
props: ['message'],
render: function (createElement) {
return createElement('div',[
this.$scopedSlots.default({
text: this.message
})
])
}
})

1
2
3
4
5
6
7
// HTML
<scoped-div message="scopedSlotsExample">
<template slot-scope="pipe">
{{ pipe.text }}
</template>
</scoped-div>
// -> scopedSlotsExample

JSX

如果你写过许多渲染函数,你可能对这样的写法感到头疼:

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
// JS
<script type="text/javascript">
var AnchoredHeading = Vue.component('anchored-heading', {
render: function (createElement) {
return createElement(
'h' + this.level,
this.$slots.default
)
},
props: {
level: {
type: Number,
required: true
}
}
})
Vue.component('my-div', {
render: function (createElement) {
return createElement('anchored-heading', {
props: {
level: 1
}
}, [
createElement('span', 'Hello'),
' world!'
]
)
}
})
var vm = new Vue({
el:'#example',
})
</script>

1
2
3
4
5
// HTML
<div id="example">
<my-div></my-div>
</div>
// ->Hello world!

其实核心部分就是这样一段简单的模板代码:

1
2
3
<anchored-heading :level="1">
<span>Hello</span> world!
</anchored-heading>

Vue中使用了一个Babel插件去使用JSX,使语法更接近templates,让我们看一下使用JSX的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// AnchoredHeading.vue
<script>
export default {
render: function(createElement) {
return createElement("h" + this.level, this.$slots.default);
},
props: {
level: {
type: Number,
required: true
}
}
};
</script>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import Vue from "vue";
import AnchoredHeading from "./components/AnchoredHeading";

new Vue({
el: "#app",
render: function(h) {
return (
<AnchoredHeading level={1}>
<span>Hello</span> world!
</AnchoredHeading>
);
}
});
// -> Hello world!

注意
上面渲染函数的h参数是必须的,其实它代表createElement,化名为h是一种使用惯例,这个是Babel 插件的3.4.0版本在任何方法和getter中自动注入的const h = this.$createElement,所以如果是以前的版本,可以去掉h,但如果版本不允许,会报错:[Vue warn]: Error in render: "ReferenceError: h is not defined"。

这个JSX将代码自动映射为JavaScript代码,具体参考vuejs/jsx

函数式组件

早期我们写的锚定标题组件比较简单,它没有管理任何状态,也不用检测传递给它的状态,并且没有生命周期方法。其实,它只是写的props的函数。

像这种情况,我们可以将组件标记一个functional关键字,意味着这样的组件无状态(没有响应式数据)、无实例(没有this上下文)。函数式组件看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
// JS
Vue.component('my-component',{
// props是可选的
props: {
// ...
},
// 为了弥补缺少的实例
// 我们提供了第二个context参数
render: function (createElement, context) {
// ...
}
})

让我们来改写一下我们之前的锚定标题组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<body>
<div id="example">
<anchored-heading level="2">Hello world!</anchored-heading>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script type="text/javascript">
Vue.component('anchored-heading',{
functional: true,
props: {
level: ''
},
render: function (createElement,context) {
return createElement('h' + context.props.level, context.children)
}
})
var vm = new Vue({
el:'#example',
})
</script>
</body>
// -> Hello world!

这里面主要是添加了functional: true,渲染函数添加了context参数,context参数封装了组件的相关信息,该参数的具体内容请查看官方文档Functional Components,将this.$slots.default 更新为 context.children, 然后将 this.level 更新为 context.props.level。

由于函数式组件仅仅是函数,它的渲染成本更低。

如果你使用的是单文件组件,基于函数是组件模板可以被声明为:

1
2
3
4
5
6
7
// Vue Template

import AnchoredHeading from './components/AnchoredHeading'

<template functonal>
<AnchoredHeading level="3">Hello</AnchoredHeading>
</template>

1
2
3
4
5
6
7
8
9
10
11
12
13
// AnchoredHeading.vue
<script>
export default {
name: "AnchoredHeading",
functional: true,
props: {
level: ''
},
render: function (createElement,context) {
return createElement('h' + context.props.level, context.children)
}
}
</script>

函数式组件在封装组件的时候比较有用,比如你需要:

  • 以程序代码的方式来选择其他的组件代理
  • 将children,props,或data传递到子组件之前操作他们

我们来完善一下官网的这个例子:smart-list组件根据情况代理到更具体的组件,依赖props被传递的值:

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
/*
先创建四个组件
*/
// EmptyList.vue
<template>
<div>
emptyList component
</div>
</template>

// TableList.vue
<template>
<div>
tableList component
</div>
</template>

// OrderList.vue
<template>
<div>
orderList component
</div>
</template>

// UnderList.vue
<template>
<div>
unorderList component
</div>
</template>

创建函数式组件SmartList来负责代理到具体组件

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
<script>
import Order from './OrderList'
import Empty from './EmptyList'
import Table from './TableList'
import Unorder from './UnorderList'
export default {
name: "SmartList",

functional: true,
props: {
items: {
type: Array,
required: true
},
isOrdered: Boolean
},
render: function (createElement, context) {
function appropriateListCompoent() {
var items = context.props.items
if (items.length === 0) return Empty
if (typeof items[0] === 'object') return Table
if (context.props.isOrdered) return Order

return Unorder
}
return createElement(
appropriateListCompoent(),
context.data,
context.children
)
}
}
</script>

在主组件中使用,根据输入数据展示对于组件:

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 SmartList from './components/SmartList'

// HTML case1
<template>
<smart-list :items="[]"></smart-list>
</template>
// -> emptyList component

// HTML case2
<template>
<smart-list :items="[1]"></smart-list>
</template>
// -> unorderList component

// HTML case3
<template>
<smart-list :items="[1]" :is-ordered="true"></smart-list>
</template>
// -> orderList component

// HTML case4
<template>
<smart-list :items="[{'name': 'wang'}]" :is-ordered="true"></smart-list>
</template>
// -> tableList component

slots() vs children

我们可能会比较疑惑为什么同时需要slots()和children,难道slots().default和children不相同吗?在某些情况下是的,但我们看下面这个函数式组件的节点:

使用slots().default

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// HTML
<my-functional-component>
<template v-slot:foo>
<p>
first
</p>
</template>
<p>second</p>
</my-functional-component>

Vue.component('my-functional-component',{
render: function (createElement) {
return createElement('div', this.$slots.default)
}
})
// -> second

如果我们把上面的this.$slots.default改为this.$slots.foo,那么打印first。

如果我们使用children:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// HTML
<my-functional-component>
<!-- <template v-slot:foo> -->
<p>
first
</p>
<!-- </template> -->
<p>second</p>
</my-functional-component>

// JS
Vue.component('my-functional-component',{
functional: true,
render: function (createElement, context) {
return createElement('div', context.children)
}
})
// -> first
// -> second

也就是说,children可以打印这两段,slots().default打印第二段,slots().foo打印第一段内容,所以children和slots的存在允许你去选择基于插槽系统还是通过children负责代理到其他的组件。


参考:Render Functions & JSX

Vue系列 | 插槽Slot

发表于 2019-10-01

Slot背景知识

slot:在英文中名词有位置,入口的意思,而动词有插入的意思。在W3C的webcomponents,给出了slot的定义:

1
a defined locaiton in a shadow tree,Represented by the <slot> element。

表面理解插槽即位置,是用来在阴影树中定义位置的。表现形式为标签形式,即<slot>

slot name:the name of a slot 插槽的名字

default slot:a slot for assigning nodes without a slot name 即分配到节点上的匿名插槽。

Slotting Algorithm : 将阴影树中的主机节点分配到slots中。具体内容可以查看原文档。

Vue中的Slot

参考:Slots

有了刚刚的背景知识,我们就比较好理解Vue中的插槽了。即Vue中的内容分发API,表现形式也依然是以<slot>元素的形式作为内容分发的出口。即它是用来做内容分发的,具体怎么做。我们来看:

实例参考:Vue插槽详解

我们创建一个HTML文件,引入Vue,而后创建子组件child-component,并声明使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<html>
<header>
</header>
<body>
<div id="app">
<child-component>你好</child-component>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js"></script>
<script type="text/javascript">
Vue.component('child-component',{
template:'<div>Hello world!</div>'
})
new Vue({
el:'#app',
})
</script>
</body>
</html>
// 打印:Hello world!

主页子组件中的“你好”没显示,现在我们在子组件中做如下修改:

1
2
3
4
Vue.component('child-component',{
template:'<div>Hello world! <slot></slot></div>'
})
// 打印:Hello world!你好

这就是插槽的作用,这就是所谓的内容分发,通过<slot>元素的形式,把子组件之间的内容分发到slot所在的位置。

具名插槽

就是给插槽的元素起个名字,而使用的时候要使用<template slot="xxx">的形式引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<body>
<div id="app">
<child-component>
<template slot="girl">
女号
</template>
你好
</child-component>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js"></script>
<script type="text/javascript">
Vue.component('child-component',{
template:'<div>Hello world!<slot name="girl"></slot> <slot></slot></div>'
})
new Vue({
el:'#app',
})
</script>
</body>
// 打印:Hello world! 女号 你好

作用域插槽

即子组件slot元素上的属性可以在引用该组件的嵌套范围内使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<body>
<div id="app">
<child-component>
<template slot="girl">
女号
</template>
<template slot-scope="test">
{{ test }}
</template>
你好
</child-component>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js"></script>
<script type="text/javascript">
Vue.component('child-component',{
template:'<div>Hello world!<slot name="girl"></slot> <slot say="吃了吗"></slot></div>'
})
new Vue({
el:'#app',
})
</script>
</body>
// 打印:Hello world! 女号 { "say": "吃了吗" }

即作用域插槽将slot内的属性以键值对的方式打印出来了。

这是一个有用的功能,我们可以利用拿到的数据写一些逻辑了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<child-component>
<template slot="girl">
女号
</template>
<template slot-scope="test">
<div v-if="test.say == '吃了吗'">
吃了
</div>
</template>
你好
</child-component>
// 打印 :
// Hello world! 女号
// 吃了

是不是很熟悉呢,这就是ElementUI中Table的操作区的传值用法。

1
2
3
4
5
6
7
8
9
10
<el-table-column>
<template slot-scope="scope">
<el-button
@click.native.prevent="deleteRow(scope.$index, rowData)"
type="text"
size="small">
Remove
</el-button>
</template>
</el-table-column>

Vue系列 | 自定义指令

发表于 2019-08-31
  • 操作底层DOM

代码复用和抽象的主要形式是组件,有时候需要自定义指令来操作底层DOM。
让我们看一个例子:

1
2
// HTML
<input v-focus/>

1
2
3
4
5
6
7
8
9
10
// 在组件的JS代码中自定义指令focus
export default {
directives: {
focus: {
// directive definition
inserted: function (el) {
el.focus()
}
}
}

以上代码的作用是进入页面的时候输入框会自动获取焦点,即处于输入状态,注意,在使用的时候,Vue会自动在我们的指令前面加上前缀v-,所以使用是指令表示为v-focus。

钩子函数与钩子参数

与组件的操作过程类似,钩子用于处理底层DOM,所以天生携带组件的生命体征,即我们也会像操作组件一样来处理我们的指令,找到对应的时机:包含绑定指令,插入元素,数据更新,组件更新和解绑指令,这些表现为函数形式,操作我们的数据,表现为在相应的函数里传递和操作参数。

具体函数如下:
bind:顾名思义,绑定我们定义的指令到元素上,而且整个生命周期中只会绑定一次,我们可以在这里做一些一次性的创建工作。
inserted:当绑定元素已经插入到它的父节点的时候调用(它只保证父节点存在,不一定在文档中)。
update:包含组队的VNode被更新的时候调用,但是可能会在子组件更新之前调用。指令的值可能改变也可能不改变。
componentUpdated:包含组件的VNode和VNodes的子组件更新后调用。
unbind:仅调用一次,和bind相对,自定义指令从元素解绑时调用。

有了这些函数,我们就可以在对应的阶段执行一些处理DOM的操作。要操作DOM我们还需要这些函数传递的参数,我们通过一个例子来认识这些参数:

1
2
// HTML
<div v-demo:foo.a.b="message"></div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// JS
export default {
directives: {
demo: {
bind: function (el, binding, vnode) {
var s = JSON.stringify
el.innerHTML =
'name:' + s(binding.name) + '<br>' +
'value:' + s(binding.value) + '<br>' +
'expression:' + s(binding.expression) + '<br>' +
'argument:' + s(binding.arg) + '<br>' +
'modifiers:' + s(binding.modifiers) + '<br>' +
'vnode keys:' + Object.keys(vnode).join(', ')
}
}
},
data() {
return {
message: 'hello!',

}
}
}

打印结果:
name:”demo”
value:”hello!”
expression:”message”
argument:”foo”
modifiers:{“a”:true,”b”:true}
vnode keys:tag, data, children, text, elm, ns, context, fnContext, fnOptions, fnScopeId, key, componentOptions, componentInstance, parent, raw, isStatic, isRootInsert, isComment, isCloned, isOnce, asyncFactory, asyncMeta, isAsyncPlaceholder


结合上面的例子,我们来认识这些参数:
el:指令绑定的元素,可以用它来直接操作DOM
vnode:通过Vue的编译器产生的虚拟DOM节点
oldVnode:之前的虚拟节点,仅在update和componentUpdated函数可用

binding:一个对象,包含如下属性:

name:指令名称,没有-v前缀,如”demo”
value: 传递给指令的值,如”hello!”
expression:绑定的表达式,用字符串表示,如上:”message”
arg:传递给指令的参数,如”foo”
modifiers:一个包含修饰符的对象,如上的修饰符对象是{“a”:true,”b”:true}
oldValue:之前的值,仅在update和componentUpdated函数可用,无论值是否改变它都是可用的。

注意:
除了el,其他的要素不要修改,作为只读属性。

动态指令参数

1
2
3
// HTML
<p>向下滚动页面</p>
<p v-pin:[direction]="200">让我从页面的200像素处开始</p>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// JS
export default {
directives: {
pin: {
bind: function (el, binding) {
el.style.position = 'fixed'
var s = (binding.arg == 'left' ? 'left':'top')
el.style[s] = binding.value + 'px'
}
}
},
data() {
return {
direction: 'top'

}
}
}

Alt text

这样我们就可以根据direction的值来做页面的动态调整了。

对象字面值

可以使用JavaScript的字符串给指令传递对象字面值,例如:

1
2
// HTML
<div v-demo="{color: 'white', text: 'hello'}"></div>

1
2
3
4
5
6
7
8
9
10
11
// JS
export default {
directives: {
demo: {
bind: function (el, binding) {
console.log(binding.value.color) // => "white"
console.log(binding.value.text) // => "hello"
}
}
},
}

参考:
自定义指令

Vue系列 | 计算属性和监听器

发表于 2019-08-20

计算属性

  • 减少模板中的计算逻辑

我们来看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<body>
<div id="example">
{{ message.split('').reverse().join('') }}
</div>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script type="text/javascript">
new Vue({
el:'#example',
data: {
message: 'Hello'
}
})
</script>
</body>

结果:

1
olleH

下面我们来改写一下,使用计算属性来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<body>
<div id="example">
{{ reversedMessage }}
</div>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script type="text/javascript">
var vm = new Vue({
el:'#example',
data: {
message: 'Hello'
},
computed: {
// 一个计算属性的getter
reversedMessage: function () {
// `this`指的是vm实例
return this.message.split('').reverse().join('')
}
}
})
</script>
</body>

对比一下:
首先,模板中的写法精简,起到了减少模板中的数据逻辑的作用;
其次,如果模板更多的地方包括了这个反转消息,我们只需计算一次,而如果把算法逻辑写到模板里,每次渲染,每个使用的地方都需要重新计算一次,耗费性能。而计算属性里面只需要计算一次,直到message改变的时候,才会重新计算;
再次,很显然写到计算属性里的代码更容易维护。

计算属性 VS 方法

  • 可以缓存数据计算的结果

我们可以使用方法来完成同样的表达:

1
2
3
4
//HTML
<div id="example">
{{ reversedMessage() }}
</div>

1
2
3
4
5
6
7
// 在组件的JS
methods: {
reversedMessage: function () {
// `this`指的是vm实例
return this.message.split('').reverse().join('')
}
}

两个方式的执行结果相同,但是计算属性可以基于数据的响应依赖来缓存数据。这里有两个重点:

  1. 计算属性可以缓存计算结果;
  2. 只有响应式属性才能被缓存。

意思是说只有响应式数据被改变的时候才重新计算。这意味着如果message没有变化,访问reversedMessage将会立刻返回计算结果而不再执行reversedMessage function()函数。

对比之下,方法在渲染发生的时候总是会被执行。

为什么我们需要缓存呢?
如果我们有一个属性A,它要遍历一个很大的数组来执行很多的计算,然后又其他的属性依赖它的计算结果。如果有缓存的话,下次访问A的时候我们直接使用就好,无需执行多余的计算,这就是缓存的意义,所以在不需要缓存的情况下,我们可以使用方法method代替。

计算属性 VS 监听属性

watch 属性是Vue实例上面观察和响应数据改变的更为通用的方式。

  • 当一些数据的变化依赖另外其他一些数据的时候,使用computed比watch更好。来看一个例子:

首先看计算属性的实现:

1
2
3
4
// HTML
<div id="example">
{{ fullName }}
</div>

1
2
3
4
5
6
7
8
9
10
11
12
13
// JS
var vm = new Vue({
el:'#example',
data: {
firstName: 'Foo',
lastName: 'Bar'
},
computed: {
fullName: function () {
return this.firstName + ' ' + this.lastName
}
},
})

接下来我们使用watch实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var vm = new Vue({
el:'#example',
data: {
firstName: 'Foo',
lastName: 'Bar',
fullName: 'Foo Bar'
},
watch: {
firstName: function (val) {
this.fullName = val + ' ' + this.lastName
},
lastName: function (val) {
this.fullName = this.firstName + ' ' + val
}
},
})

对比之下,哪个更好一目了然。

计算属性Setter

计算属性默认只提供了getter,如果需要你可以提供一个setter:

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 type="text/javascript">
var vm = new Vue({
el:'#example',
data: {
firstName: 'Foo',
lastName: 'Bar',
},
computed: {
fullName: {
// getter
get: function () {
return this.firstName + ' ' + this.lastName
},
// setter
set: function (newValue) {
console.log('set function')
var names = newValue.split(' ')
this.firstName = names[0]
this.lastName = names[names.length - 1]
}
}
},
})
vm.fullName = 'John Doe'
console.log(vm.firstName)
console.log(vm.lastName)
</script>

现在执行vm.fullName = 'John Doe',setter会被调用来分别更新vm.firstName和vm.lastName。

监听器用例

大部分情况使用计算属性是合适的,但当你想执行异步或耗时的操作去响应数据的改变时,watch比较合适:

1
2
3
4
5
6
7
8
// HTML
<div id="example">
<p>
Ask a yes/no question:
<input v-model="question">
</p>
<p>{{ answer }}</p>
</div>

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
<script src="https://cdn.jsdelivr.net/npm/axios@0.12.0/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lodash@4.13.1/lodash.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script type="text/javascript">
var vm = new Vue({
el:'#example',
data: {
question: '',
answer: '直到你问一个问题,我才能回答你'
},
watch: {
question: function (newQuestion, oldQuestion) {
this.answer = 'Waiting for you to stop typing...'
this.debouncedGetAnswer()
}
},
created: function () {
this.debouncedGetAnswer = _.debounce(this.getAnswer,500)
},
methods: {
getAnswer: function () {
if (this.question.indexOf('?') === -1) {
this.answer = 'Questions usually contain a question mark.'
return
}
this.answer = "Think..."
var vm = this
axios.get('https://yesno.wtf/api')
.then(function (response) {
vm.answer = _.capitalize(response.data.answer)
})
.catch(function (error) {
va.answer = 'Erorr!' + error
})
}
}
})
</script>

结果:
Alt text

以上,watch允许我们去执行一个异步的操作(访问API),设置执行操作的频率,并设置中间状态,直到我们得到答案。这个计算属性是做不到的。

watch 操作项

watch几乎可以接受任何类型:对象、数组、函数。
对象的键是要监听的表达式,值是相应的回调,值可以是方法名字符串,或包含其他操作项的对象,Vue将在实例化时为对象的每一个条目调用$watch()方法。
用法如下:

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
// HTML
<script type="text/javascript">
var vm = new Vue({
el:'#example',
data: {
a: 1,
b: 2,
c: 3,
d: 4,
e: {
f: {
g: 5
}
}
},
watch: {
a: function (val, oldVal) {
console.log('new: %s, old: %s', val, oldVal)
},
// 字符串方法名
b: 'someMethod',
// 无论嵌套的深度为何,只要所监督的属性发生变化,回调将被调用
c: {
handler: function (val, oldVal) {
console.log('I am c')
},
deep: true
},
// 该回调函数将在进入watch对象后后第一个调用
d: {
handler: 'startObservationMethod',
immediate: true
},
e: [
'handle1',
function handle2 (val, oldVal) {
// ...
console.log('I am handle2')
},
{
handler: function handle3 (val, oldVal) {
// ...
console.log('I am handle3')
},
// ...
}
],
// 监听 vm.e.f的值: {g: 5}
'e.f': function (val, oldVal) {
console.log('g' +' ' + 'update')
}
},
methods: {
someMethod () {
console.log('I am someMethod')
},
startObservationMethod () {
console.log('I am startObservationMethod')
},
handle1 () {
console.log('I am handle1')
}
}
})
vm.a = 2
vm.b = 3
vm.c = 4
vm.e = 6
vm.g = 8
</script>

依次打印结果:
I am startObservationMethod
new: 2, old: 1
I am someMethod
I am c
I am handle1
I am handle2
I am handle3
g update


参考:Computed Properties and Watchers

123
Lorne Zhang

Lorne Zhang

日拱一卒

25 日志
© 2019 - 2022 Lorne Zhang
由 Hexo 强力驱动
主题 - NexT.Muse