Vue.js 是一个用于创建用户界面的开源 JavaScript 框架,也是一个创建单页应用的 Web 应用框架。目前正式版迭代到了 v2。值得一提的是,Vue 的作者是国人尤雨溪,最大的好处就是文档的中文支持非常快,目前还在 v3.x-beta 的教程仅支持中英文。

Vuetify 是一个纯手工精心打造的 Material 样式的 Vue UI 组件库。目前迭代到了 v2。

我开发的第一个(目前也是唯一一个) Vue 应用使用的组件库就是 Vuetify。可以说,Vue + Vuetify 让我入了前端的门。在本文中,我将聊聊作为一个前端萌新,在使用 Vue 和 Vuetify 的过程中,学到的前端技巧。

Vue 教程?

零基础开始学习 Vue,我自然想到的是看官方教程。然而官方教程并不是那么好懂:刚看到安装部分,教程给出的数种安装方法就让我傻了眼,无从下手。

于是尝试去找别的教程,居然发现了尤雨溪在知乎上给出的学习路线:新手向:Vue 2.0 的建议学习顺序,其中第二点的 就只用最简单的 <script>,把教程里的例子模仿一遍,理解用法。不推荐上来就直接用 vue-cli 构建项目,尤其是如果没有 Node/Webpack 基础。 这句,对真·零基础新手非常友好。

Vue 开发环境

我刚入坑时,想试试使用 Typescript 来开发 Vue。有人说 WebStorm 对 TS 的支持不行,得用 VS Code,于是我就用 VS Code 写了几天 Typescript。最后,还没写完账户管理部分,就放弃 Typescript 了。

Typescript 的静态类型确实很不错,但是写 Typescript 的时候,是各种 TSLint 报类型错。我也尝试写 .d.ts 文件,但是又遇到了没法引入等等问题。还有就是 TSLint 认为 this.$routerundefined,所以 this.$router.push() 等等都会报错。

上面这些问题或许有方法解决,但是问题实在太多,我最后还是选择使用 JavaScript + WebStorm 开发。

回来发现,WebStorm 还对 Vue Router 和 Vuex 有支持,输入 store.commit() 的时候居然能够提示函数名。真香。

WebStorm 对 Vuex 的支持
WebStorm 对 Vuex 的支持

部署 Vue 应用

如果你的 Vue App 使用了 vue-router 的 history 模式,目前 (2021.2) 是不能使用 GitHub Pages 部署的。

从 uri 的角度理解,是因为 GitHub Pages 服务器会默认将对 /activities/create 的请求理解为 /activities/create/index.html,而 vue-router history 模式想要服务器理解为 /index.html。于是, GitHub Pages 会返回 404。

我尝试了网上的 hack 404.html200.html 的方法,但均无效。

一个解决方案是改用 hash 模式,此模式下链接会变为 /#/activities/create,这个 #(hash) 的存在,会让服务器将这个链接理解为 /index.html#/activities/create。但此法会让链接变丑。

另一个解决方案是用一台服务器来部署前端。Vue Router 文档中针对不同服务器提供了部署方法。不过对于 Caddy,我使用 Vue Router 文档提供的 rewrite 法不能正常运行。于是改用了 try_files,只对 Not Found 的文件进行 rewrite:

1
2
3
4
5
example.com {
root * /path/to/dist
try_files {path} /index.html
file_server
}

最后一个解决方案是我最后采用的,就是白嫖 Azure 的静态 Web 应用。Microsoft Learn 还写了一篇 教程 供大家参考。与 GitHub Pages 不同的是,Azure Static Web App 允许使用路由,只需要在开发 Vue 应用时,将以下 route.json 放在 /public/ 目录下即可(抄 MS Learn 的作业真爽)。

1
2
3
4
5
6
7
8
9
{
"routes": [
{
"route": "/*",
"serve": "/index.html",
"statusCode": 200
}
]
}

后来了解到,Vercel App 也支持部署。

Vue 数据传递

页面间需要传递数据,该怎么办呢?

1. Vuex

Vuex

Vuex.Store 类似于全局变量,可以在不同的页面中使用、更改这些里面的数据。

2. 路由时添加 params 参数

可以传递任何对象。

发送方:

1
2
3
4
5
6
7
let profile = {id: 1};
$this.router.push({
name: 'NextPage',
params: {
user_profile: profile
}
})

接收方:

1
$this.route.params.user_profile // {id: 1}

3. 路由时添加 query 参数

和上一种方法类似,但是这种方法的参数会出现在 url 中,且只能传递字符串。

发送方:

1
2
3
4
5
6
$this.router.push({
name: 'NextPage',
query: {
id: '1'
}
})

接收方:

1
$this.route.params.query.id // 1

同时链接会变为:原url?id=1

Vuetify 网格系统

Vuetify 网格深受 Bootstrap 网格的启发,所以如果有 Bootstrap 基础,Vuetify 上手应该会很快,反之亦然。可惜我都没有

Vuetify 网格系统包含四个核心子组件:

  • v-container 代表一个网格
  • v-row 代表一行 (row)
  • v-col 代表一列 (column)。注意 v-col 必须是 v-row 的子组件,即一定是 v-container > v-row > v-col 的顺序
  • v-spacer:可以在 v-row 之间或 v-col 之间放置一个 v-spacer,这样父子组件之间的剩余宽度就会被分配到这里

下面我将借助两个实际的例子聊聊网格系统的作用。


网格系统 例 1
网格系统 例 1

问:上图中用个几个网格 v-container

答案是两个。

我刚接触 Vuetify 的时候,也觉得只需要一个网格将表单框起来、每个 row 对应一个输入项就可以了。

然而,当我只使用一个 v-container 时,效果图是这样的:

使用一个网格系统的效果
使用一个网格系统的效果

可以看到,最外层的 v-card 和边框合为一体了。而如果在 v-card 外面加一层 v-container v-row v-col,就可以把 v-card 框在中间的位置。

第一张图的部分代码如下:

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
<template>
<!-- 外层的 container、row、col 是为了限制 card 的布局 -->
<v-container>
<v-row class="justify-center">
<v-col xs="12" md="8">
<v-card>
<v-card-text>
<v-form @submit.prevent="register">
<!-- 内层的 container、col 是为了限制 form 的布局 -->
<v-container>

<v-col>
<v-text-field/>
</v-col>

<!-- 更多 text-field -->

<v-col>
<v-text-field/>
</v-col>

</v-container>
</v-form>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>

完整代码

顺便一提,Vuetify 网格系统文档中介绍到,Vuetify 配备了一个使用 flexbox 构建的 12 格网格系统。<v-col xs="12" md="8"> 表示,对于 md 的屏幕,这列将占 8 格,也就是 2/3;对于 xs 的屏幕,这列将占据 12 格,也就是全部。

这和 Bootstrap 是也类似的,所以如果在 Vuetify 中遇到不懂的概念,除了在 Vuetify 文档中搜索,还可以尝试在 Bootstrap 里搜索。


再来一个例子,下图中用了几个网格系统?

网格系统 例 2
网格系统 例 2

答案是 3 个,在下图中有标出:

三个网格系统
三个网格系统

完整代码:

把别人的组件封装成自己的

无脑复制

大多数后端项目的缩进都是 4 spaces,但前端则是使用 2 spaces。因为前端实在是太容易套娃了,随随便便就能搞个五层、十层以上。上面的例子用了三个网格系统,每一个网格系统对应一个 v-containerv-rowv-column,光是网格系统也有九层缩进了……

所以,将别人的组件封装成自己的,不仅能减少重复代码量,还能大幅减少缩进。

比如,上面的网格部分提到,最外层的 v-card 都需要加一个网格系统。所以我做了如下封装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- /src/components/ui/base/simple-card.vue -->
<template>
<v-container>
<v-row class="justify-center">
<v-col xs="12" md="8">
<v-card>
<v-card-text>
<slot/> <!-- 这里会把调用 SimpleCard 处的 <SimpleCard> </SimpleCard> 之间的代码插入 -->
</v-card-text>
</v-card>
</v-col>
</v-row>

</v-container>
</template>

<script>
export default {
};
</script>

这样,网格系统例 1 的部分代码就可以缩减为如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<template>
<SimpleCard>
<v-form @submit.prevent="register">
<!-- 内层的 container、col 是为了限制 form 的布局 -->
<v-container>

<v-col>
<v-text-field/>
</v-col>

<!-- 更多 text-field -->

<v-col>
<v-text-field/>
</v-col>

</v-container>
</v-form>
</SimpleCard>
</template>

<script>
import SimpleCard from "@/components/ui/base/simple-card";

export default {
components: {SimpleCard},
}
</script>

直接少了 4 层缩进。

通过父组件 props 修改子组件 props

现在的需求是,有些页面不想用 xs="8",而想用 xs="6"。用编程中的函数来讲,就是想要默认参数。我们想要使用:

1
2
3
4
5
6
7
8
<template>
<
SimpleCard
md="6"
>
<!-- ... -->
<SimpleCard>
</template>

就能在子组件中渲染出 <v-col md="6">。而在其他时候,依然渲染 <v-col md="8">

只需要使用在子组件的 component 定义中添加 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
<!-- /src/components/ui/base/simple-card.vue -->
<template>
<v-container>
<v-row class="justify-center">
<v-col :xs="xs" :md="md">
<v-card>
<v-card-text>
<slot/> <!-- 这里会把调用 SimpleCard 处的 <SimpleCard> </SimpleCard> 之间的代码插入 -->
</v-card-text>
</v-card>
</v-col>
</v-row>

</v-container>
</template>

<script>
export default {
props: {
xs: {
type: Number,
default: 12
},
md: {
type: Number,
default: 8
}
}
};
</script>

props 就类似于函数的参数,default 即是默认参数。

将父组件所有多余的 props 传给子组件

这个问题出现在我想要封装 tooltipsfab 的时候。

首先我们依照 Vuetify 给的模板编写的可用的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<!-- 鼠标放置按钮之上可以看到提示 -->
<v-tooltip top>
<template v-slot:activator="{ on, attrs }">
<v-btn
fab
large
buttom
absolute
right
v-bind="attrs"
v-on="on"
>
<v-icon>mdi-home</v-icon>
</v-btn>
</template>
<span>Tooltips</span>
</v-tooltip>
</template>

我们先按上文,将 Tooltips 和 图标的内容改为 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
31
32
33
34
<!-- /src/components/ui/base/floating-action-button.vue -->
<template>
<v-tooltip bottom>
<template v-slot:activator="{ on, attrs }">
<v-btn
fab
large
buttom
absolute
right
v-bind="attrs"
v-on="on"
>
<v-icon>mdi-home</v-icon>
</v-btn>
</template>
<span>{{ tooltip }}</span>
</v-tooltip>
</template>

<script>
export default {
props: {
icon: {
type: String,
default: 'mdi-plus'
},
tooltip: {
type: String,
default: ''
}
},
};
</script>

这样就可以通过下面的形式调用这个组件了。

1
2
3
4
5
6
7
8
9
10
11
<template>
<FloatingActionButton />
</template>

<script>
import FloatingActionButton from "@/components/ui/base/floating-action-button";

export default {
components: {FloatingActionButton},
}
</script>

自定义了 tooltipicon 后我们发现,由于 v-btn 上能设置的属性可就太多了(如 disabled color loading 等等),把这些一个一个写到 props 里,实在不美观。有没有简洁的方法,使得我写 <FloatingActionButton loading disabled/> 就能把这两个参数传给子组件的 v-btn 中呢?


Vue 文档中提到了这种情况。Vue 把这里的 disabled color 等称为非 Prop 的 Attribute

一个非 prop 的 attribute 是指传向一个组件,但是该组件并没有相应 prop 定义的 attribute。

文档指出,这些非 prop 的 attribute,会默认替换/合并根组件已有的 attribute。不巧的是,我们的 <FloatingActionButton> 的根组件是 <v-tooltips>,所以这些 attribute 被默认放到了 <v-tooltips> 上。

为了打破这种默认情况,我们需要先在 component 定义中加入 inheritAttrs: false 禁止继承给根组件 <v-tooltips>,然后手动给需要的组件绑定 $attrs,类似于下面:

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
<!-- /src/components/ui/base/floating-action-button.vue -->
<template>
<!-- 鼠标放置按钮之上可以看到提示 -->
<v-tooltip top>
<template v-slot:activator="{ on, attrs }">
<v-btn
fab
large
buttom
absolute
right
v-bind="attrs"
v-bind="$attrs"
v-on="on"
>
<v-icon>{{ icon }}</v-icon>
</v-btn>
</template>
<span>{{ tooltip }}</span>
</v-tooltip>
</template>

<script>
export default {
inheritAttrs: false,
// 不让组件的根元素继承 attribute,而手动将 $attrs 赋给 v-btn
props: {
icon: {
type: String,
default: 'mdi-plus'
},
tooltip: {
type: String,
default: ''
}
},
};
</script>

对于其他组件,就是这个效果。然而,对于这个组件,问题在于 Vuetify 的模板代码包含了一个 v-bind="attrs",和我们的 v-bind 冲突了。我们需要把这两个 v-bind 合并。

而对于 JS,可以使用 {...$attrs, ...attrs} 的语法合并这两个 Object 为一个。于是,我们将 template 部分代码改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- /src/components/ui/base/floating-action-button.vue -->
<template>
<!-- 鼠标放置按钮之上可以看到提示 -->
<v-tooltip top>
<template v-slot:activator="{ on, attrs }">
<v-btn
fab
large
buttom
absolute
right
v-bind="{...$attrs, ...attrs}"
v-on="on"
>
<v-icon>{{ icon }}</v-icon>
</v-btn>
</template>
<span>{{ tooltip }}</span>
</v-tooltip>
</template>

就可以使用了。

1
2
3
4
5
6
<FloatingActionButton
icon="mdi-pencil"
color="primary"
tooltip="编辑"
@click="gotoEditUserDetail"
/>

让子组件 click 事件能触发父组件的 v-on:click

还是接着上面的 tooltips + fab。

其实非常简单,只需要在 template 中添加一行 @click="$emit('click')",让子组件 <v-btn>v-on:click 事件设置为触发 click 事件,让其向上传递即可,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- /src/components/ui/base/floating-action-button.vue -->
<template>
<!-- 鼠标放置按钮之上可以看到提示 -->
<v-tooltip top>
<template v-slot:activator="{ on, attrs }">
<v-btn
fab
large
buttom
absolute
right
v-bind="{...attrs, ...$attrs}"
v-on="on"
@click="$emit('click')"
>
<v-icon>{{ icon }}</v-icon>
</v-btn>
</template>
<span>{{ tooltip }}</span>
</v-tooltip>
</template>

<script> 部分则完全不需要修改。

Vuetify 居中

对于我来说,居中、向右对齐一直是个头疼的问题。各种组件的对齐方法都不同,况且对齐还分为了上下对齐和左右对齐。网上似乎也没有什么总结性的帖子,就只好记录一下自己编码的时候遇到的坑了。

网格系统

网格系统中,如果想要上下对齐,可以使用 <v-row>alignalign-content 属性;想要左右对齐,可以使用 <v-row>justify 属性。文档

对于 alignalign-content 的区别,请看:知乎 - 弹性盒子 align-items 与 align-content 的区别。简单来说,align 或 CSS 中的 align-items 控制当前行中的列的行为,而 align-content 控制的是所有行的行为。

<v-col> 也提供了一个 align-self,不过不知道有什么用。

编辑页面弹窗阻止用户退出

直接 Google 没搜到结果,过几天换了一个关键词搜,居然就搜到了。

文档

文档说的很清楚了。放在 Vue 里面,如果放弃老浏览器的兼容,直接在 activated 函数加一行即可。另外,退出页面时也记得取消这个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script>
export default {
//...
activated() {
window.onbeforeunload = () => '系统可能不会保存您所做的更改。';

// 其他 activated 触发的事件
},

deactivated() {
window.onbeforeunload = null;
},
}
</script>

顺便一提,由于安全问题,弹窗显示的内容在较新的浏览器中都不允许自定义。

to 和 href

对于超链接和路由,可以使用 Vue Router 提供的 to 属性,也可以使用 html 提供的 href 属性。to 属性经过编译后也会变成 href 属性。

二者的区别在于:

  1. to 只能针对 Vue 内的页面,href 对 Vue 内外的页面都可以使用;
  2. to 跳转 Vue 内页面不会刷新、不会丢失 Store 数据,而 href 会;

二者对不同 url 的表现如下:

属性-值 to href
/ 开头的页内链接,如 /foo/bar 跳转 跳转
完整 url,如 https://google.com/ 强行解释为站内链接,错误错误 跳转
Vue Router 语法,如 {name: 'foo'} 跳转 不跳转

所以,结论就是:对站内的链接用 to,对站外的链接用 href

如果一个 Array(比如侧边栏)包含跳转到站内的 item,也包含跳转到站外的 item 呢?

答案是:混用 tohref

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
<!-- 部分 html -->
<template v-for="item in items">
<v-list-item
:to="item.to"
:href="item.href"
ripple
active-class="grey lighten-3"
>
<v-list-item-action>
<v-icon>{{ item.icon }}</v-icon>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</template>

<script>
export default {
data() {
return {
items: [
{
title: '活动',
icon: 'mdi-compass',
to: '/activity',
},
{
title: '用户',
icon: 'mdi-account-multiple',
to: '/user',
},
{
title: '相册',
icon: 'mdi-image-multiple',
to: '/gallery',
},
{
title: '云盘',
icon: 'mdi-cloud',
href: 'https://drive.google.com/',
}
]
}
}
}

编译出来后,每个标签都包含且包含一个 href 属性。