从这个章节开始,就可以来看看真正用 Vue.js 的优势了,也就是所谓的中大型 / 模块化 / 规模化项目,原生 JavaScript 的部分并没有深入来看这个方面,因为那个更多的需要 Node.js 的相关知识,所以我个人倾向于放在后面结合 Node.js 来看。
不过 Vue.js 的最重要的部分也是绕不过的,所以一起来看看吧,如果有更多涉及了 Node.js 以及 webpack 之类的部分,对它们不太了解的可以先跳过,或者翻看网上所有不计其数的相关教程(当然我后面也要放进来专栏的)。
今天的部分,主要先来看看 Vue.js 的单文件组件以及单元测试。
1 单文件组件
在很多 Vue 项目中,我们使用 Vue.component 来定义全局组件,紧接着用 new Vue({ el: '#container '}) 在每个页面内指定一个容器元素。
这种方式在很多中小规模的项目中运作的很好,在这些项目里 JavaScript 只被用来加强特定的视图;但当在更复杂的项目中,或者前端完全由 JavaScript 驱动的时候,下面这些缺点将变得非常明显:
- 全局定义(Global definitions),强制要求每个 component 中的命名不得重复
- 字符串模板(String templates),缺乏语法高亮,在 HTML 有多行的时候,需要用到丑陋的
- 不支持 CSS,意味着当 HTML 和 JavaScript 组件化时,CSS 明显被遗漏
- 没有构建步骤(No build step),限制只能使用 HTML 和 JavaScript,而不能使用预处理器,如 Pug(前身是 Jade)和 Babel
以上这些部分,在模块化项目的单文件组件(Single-file components)当中,就可以被很好的解决,譬如在 Vue.js 当中文件扩展名为 .vue 的单文件组件,并且还可以使用 webpack 或 Browserify 等构建工具。
这个插一句, .js 的纯 JavaScript 文件其性质和 .vue 不同,是无法直接写入 HTML 以及 CSS 的,所以这种通常需要和其他文件结合起来进行页面的渲染和展示,而 .vue 更像是简化了的 HTML,关注点在模板 / 组件上面,仍然可以分别包含 HTML、 CSS 以及 JavaScript,所以要注意这个区别。
这是一个文件名为 Hello.vue 的简单实例:
从上面的截图我们也可以明显地看出,它的优势包括:
- 完整语法高亮
- CommonJS 模块
- f="https://vue-loader.vuejs.org/zh-cn/features/scoped-css.html">组件作用域的 CSS
正如我们说过的,我们可以使用预处理器来构建简洁和功能更丰富的组件,比如 Pug,Babel和 Stylus 等等…… 只需要在标签当中声明需要引入的工具名称即可:
比如上面代码当中的 lang="pug" 就是明显的例子。
这些特定的语言只是例子,我们也可以只是简单地使用 Babel,TypeScript,SCSS,PostCSS,或者其他任何能够帮助提高生产力的预处理器;如果搭配 vue-loader 使用 webpack,它也能为 CSS 模块提供良好支持。
1.1 关注点分离
一个重要的事情值得注意,关注点分离不等于文件类型分离。
在现代 UI 开发中,我们已经发现相比于把代码库分离成三个大的层次并将其相互交织起来,把它们划分为松散耦合的组件再将其组合起来更合理一些,在一个组件里,其模板、逻辑和样式是内部耦合的,并且把他们搭配在一起实际上使得组件更加内聚且更可维护。
即便不喜欢单文件组件,我们仍然可以把 JavaScript、CSS 分离成独立的文件然后做到热重载和预编译。
<!-- my-component.vue -->
<template>
<div>This will be pre-compiled</div>
</template>
<script src="./my-component.js"></script>
<style src="./my-component.css"></style>
1.2 例子
下面是个非常简单的例子,由官网的 ref="https://codesandbox.io/s/o29j95wx9">todo 应用修改而来,当然也可以点击链接直接查看 codesandbox 上面的原始例子代码。
项目结构:
这里要留意的是,因为我是使用 Vue-CLI 自动生成的项目,所以其中还包含了 logo、HelloWorld.vue 这些与当前项目没有关系的文件,可以忽略。
其中最重要的部分就是 main.js、App.vue、app.scss 和 components 目录下面的 BaseInputText.vue、TodoList.vue、TodoListItem.vue,我们来按照逻辑顺序,分别看看它们的代码。
main.js(引入框架和组件,告知 Node.js 页面渲染的主体是 #app,模板是 App):
import Vue from 'vue';
import App from './App.vue';
Vue.config.productionTip = false;
new Vue({
render: h => h(App),
template: '<App/>',
components: { App }
}).$mount('#app');
App.vue(主模板,真正渲染到页面上的框架):
<template>
<div id="app">
<h1>My Todo App!</h1>
<TodoList/>
</div>
</template>
<script>
import TodoList from './components/TodoList.vue'
export default {
components: {
TodoList
}
}
</script>
<style lang="scss">
@import './app.scss';
*, *::before, *::after {
box-sizing: border-box;
}
#app {
max-width: 400px;
margin: 0 auto;
line-height: 1.4;
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: $vue-blue;
}
h1 {
text-align: center;
}
</style>
app.scss(这就是两种配色,主要就是演示了如何引入预处理器,所以代码很少):
$vue-blue: #32485F;
$vue-green: #00C185;
component/TodoList.vue(由 App.vue 引入的单页面组件):
<template>
<div>
<BaseInputText
v-model="newTodoText"
placeholder="New todo"
@keydown.enter="addTodo"
/>
<ul v-if="todos.length">
<TodoListItem
v-for="todo in todos"
:key="todo.id"
:todo="todo"
@remove="removeTodo"
/>
</ul>
<p v-else>
Nothing left in the list. Add a new todo in the input above.
</p>
</div>
</template>
<script>
import BaseInputText from './BaseInputText.vue';
import TodoListItem from './TodoListItem.vue';
let nextTodoId = 1;
export default {
components: {
BaseInputText, TodoListItem
},
data () {
return {
newTodoText: '',
todos: [
{
id: nextTodoId++,
text: 'Learn Vue'
},
{
id: nextTodoId++,
text: 'Learn about single-file components'
},
{
id: nextTodoId++,
text: 'Fall in love'
}
]
}
},
methods: {
addTodo () {
const trimmedText = this.newTodoText.trim();
if (trimmedText) {
this.todos.push({
id: nextTodoId++,
text: trimmedText
});
this.newTodoText = '';
}
},
removeTodo (idToRemove) {
this.todos = this.todos.filter(todo => {
return todo.id !== idToRemove;
});
}
}
}
</script>
component/TodoListItem.vue(由 TodoList.vue 引入的单页面组件):
<template>
<li>
{{ todo.text }}
<button @click="$emit('remove', todo.id)">
X
</button>
</li>
</template>
<script>
export default {
props: {
todo: {
type: Object,
required: true
}
}
}
</script>
component/BaseInputText.vue(由 TodoList.vue 引入的单页面组件):
<template>
<input
type="text"
class="input"
:value="value"
v-on="listeners"
>
</template>
<script>
export default {
props: {
value: {
type: String,
default: '',
}
},
computed: {
listeners () {
return {
// Pass all component listeners directly to input
...this.$listeners,
// Override input listener to work with v-model
input: event => this.$emit('input', event.target.value)
}
}
}
}
</script>
<style lang="scss" scoped>
@import '../app.scss';
.input {
width: 100%;
padding: 8px 10px;
border: 1px solid $vue-blue;
}
</style>
完成之后,可以在项目路径下使用命令 npm run serve 来启动开发服务器,看到结果:
1.3 刚开始接触 JavaScript 模块开发的路径
有了 .vue 组件,我们就进入了高级 JavaScript 应用领域,对于刚刚开始接触 JavaScript 模块开发的人来说,还需要学会使用一些附加的工具:
- Node Package Manager (NPM):阅读 Getting Started guide 中关于如何获取包的章节
- Modern JavaScript with ES 6:一部分内容可以回看本专栏最开始的 JavaScript从零开始,也可以阅读 Babel 的 Learn ES2015 guide;当然并不需要立刻记住每一个方法,但是可以保留些内容以便后期参考。
在花时间了解这些资源之后,也需要参考 Vue-CLI;只要遵循指示,我们就能很快地运行一个带有 .vue 组件、ES 6、webpack 和热重载(hot-reloading)的 Vue 项目!
1.4 老手
用 Vue-CLI 吧,它会搞定大多数工具的配置问题,同时也支持细粒度自定义配置项。
如果想从零搭建自己的构建工具,这时我们需要通过 Vue Loader 手动配置 webpack;关于学习更多 webpack 的内容,请查阅其官方文档和 Webpack Academy,当然后面我也会具体看看它的。
2 单元测试
Vue CLI 拥有开箱即用的通过 Jest 或 Mocha 进行单元测试的内置选项,我们还有官方的 Vue Test Utils 提供更多详细的指引和自定义设置。
虽然测试方面我之前也没有涉及到,不过对这方面不了解的话,这里可以先看看大概内容,详细的会在 Node.js 部分一起看看的。
2.1 简单断言
我们大可不必为了可测性在组件中做任何特殊的操作,导出原始设置就可以了:
<template>
<span>{{ message }}</span>
</template>
<script>
export default {
data () {
return {
message: 'hello!'
}
},
created () {
this.message = 'bye!'
}
}
</script>
然后随着 Vue Test Utils 导入组件,可以使用许多常见的断言 (这里我们使用的是 Jest 风格的 expect 断言作为示例):
// 导入 Vue Test Utils 内的 shallowMount 和待测试的组件
import { shallowMount } from '@vue/test-utils';
import MyComponent from './MyComponent.vue';
// 挂载这个组件
const wrapper = shallowMount(MyComponent);
// 这里是一些 Jest 的测试,也可以使用自己喜欢的任何断言库或测试
describe('MyComponent', () => {
// 检查原始组件选项
it('has a created hook', () => {
expect(typeof MyComponent.created).toBe('function');
});
// 评估原始组件选项中的函数的结果
it('sets the correct default data', () => {
expect(typeof MyComponent.data).toBe('function');
const defaultData = MyComponent.data();
expect(defaultData.message).toBe('hello!');
});
// 检查 mount 中的组件实例
it('correctly sets the message when created', () => {
expect(wrapper.vm.$data.message).toBe('bye!');
});
// 创建一个实例并检查渲染输出
it('renders the correct message', () => {
expect(wrapper.text()).toBe('bye!');
});
});
2.2 编写可测试组件
很多组件的渲染输出由它的 props 决定,事实上,如果一个组件的渲染输出完全取决于它的 props,那么它会让测试变得简单,就好像断言不同参数的纯函数的返回值。
下面是个例子:
<template>
<p>{{ msg }}</p>
</template>
<script>
export default {
props: ['msg']
}
</script>
我们可以使用 Vue Test Utils 来在输入不同 prop 时为渲染输出下断言:
import { shallowMount } from '@vue/test-utils';
import MyComponent from './MyComponent.vue';
// 挂载元素并返回已渲染的组件的工具函数
function getMountedComponent(Component, propsData) {
return shallowMount(Component, {
propsData
});
}
describe('MyComponent', () => {
it('renders correctly with different props', () => {
expect(
getMountedComponent(MyComponent, {
msg: 'Hello'
}).text()
).toBe('Hello');
expect(
getMountedComponent(MyComponent, {
msg: 'Bye'
}).text()
).toBe('Bye');
});
});
2.3 断言异步更新
由于 Vue 存在异步更新 DOM 的情况,一些依赖 DOM 更新结果的断言必须在 vm.$nextTick() resolve 之后进行:
// 在状态更新后检查生成的 HTML
it('updates the rendered message when wrapper.message updates', async () => {
const wrapper = shallowMount(MyComponent);
wrapper.setData({ message: 'foo' });
// 在状态改变后和断言 DOM 更新前等待一刻
await wrapper.vm.$nextTick();
expect(wrapper.text()).toBe('foo');
});
关于更深入的 Vue 单元测试的内容,请参考官方有关 Vue Test Utils 以及关于 href="https://cn.vuejs.org/v2/cookbook/unit-testing-vue-components.html">Vue 组件的单元测试的 cookbook 文章。
本质上说,单元测试其实就是在原有模块化代码的基础上,加入了检查错误的一些语句,从而进行更完整的功能性和语法性的判断,使得代码的错误更少,强壮性也更好,还是值得好好学习一下的。