前言: 众所周知,Vue
很优秀,TypeScript
也很优秀,但是Vue
+ TypeScript
就会出现各种奇奇怪怪的问题。本文就将介绍我在「CNode 社区」这个项目开发的过程中遇到一些问题和解决办法。希望对你在Vue
中使用TypeScript
有所帮助。
项目源码及预览地址
项目简介
仿CNode社区,使用Vue
+ TypeScript
+ TSX
等相关技术栈实现了原社区的看帖、访问用户信息、查看回复列表、查看用户信息、博客列表页分页查看等功能。
后端接口调用的是CNode 官方提供的api。
本项目中的所有组件都使用了Vue的渲染函数render
以及 TSX
。
项目安装及启动
yarn install
yarn serve
技术栈
Vue @2.6.11
TypeScript
TSX
SCSS
Vue + TypeScript 和 Vue的常规写法有什么不同
起手式
- 首先我们要把
<script>
标签的lang
属性改为ts,即<script lang="ts">
- 要在Vue项目中引入
vue-property-decorator
,后续很多操作都需要引用这个库里面的属性(包括Vue
,Component
等)。
shims-tsx.d.ts
、shims-vue.d.ts
的作用
如果用vue-cli 直接生成一个「Vue + TS」的项目,我们会发现在 src 目录下出现了这两个文件,那么它们的作用是什么呢?
-
shims-vue.d.ts
-
shims-tsx.d.ts
基于class的组件
- TypeScript 版本
<script lang="ts"> import { Component, Vue } from 'vue-property-decorator' @Component export default class HelloWorld extends Vue { } </script>
- JavaScript 版本
<script> export default { name: 'HelloWorld' } </script>
引入组件 import component
- TypeScript 版本
<template> <div class="main"> <project /> </div> </template> <script lang="ts"> import { Component, Vue } from 'vue-property-decorator' import Project from '@/components/Project.vue' @Component({ components: { project } }) export default class HelloWorld extends Vue { } </script>
- JavaScript 版本
<template> <div class="main"> <project /> </div> </template> <script> import Project from '@/components/Project.vue' export default { name: 'HelloWorld', components: { project } }) </script>
Data 数据
- TypeScript 版本
@Component export default class HelloWorld extends Vue { private msg: string = "welcome to my app" private list: Array<object> = [ { name: 'Preetish', age: '26' }, { name: 'John', age: '30' } ] }
- JavaScript 版本
export default { data() { return { msg: "welcome to my app", list: [ { name: 'Preetish', age: '26' }, { name: 'John', age: '30' } ] } }
Computed 计算属性
- TypeScript 版本
export default class HelloWorld extends Vue { get fullName(): string { return this.first+ ' '+ this.last } set fullName(newValue: string) { let names = newValue.split(' ') this.first = names[0] this.last = names[names.length - 1] } }
- JavaScript 版本
computed: { fullName: { // getter get: function () { return this.firstName + ' ' + this.lastName }, // setter set: function (newValue) { var names = newValue.split(' ') this.firstName = names[0] this.lastName = names[names.length - 1] } } }
Methods 方法
在TS里面写methods,就像写class
中的方法一样,有一个可选的修饰符。
- TypeScript 版本
export default class HelloWorld extends Vue { public clickMe(): void { console.log('clicked') console.log(this.addNum(4, 2)) } public addNum(num1: number, num2: number): number { return num1 + num2 } }
- JavaScript 版本
export default { methods: { clickMe() { console.log('clicked') console.log(this.addNum(4, 2)) } addNum(num1, num2) { return num1 + num2 } } }
生命周期钩子
生命周期钩子的写法和上一条写methods
是一样的。Vue组件具有八个生命周期挂钩,包括created
,mounted
等,并且每个挂钩使用相同的TypeScript语法。这些被声明为普通类方法。由于生命周期挂钩是自动调用的,因此它们既不带参数也不返回任何数据。因此,我们不需要访问修饰符,键入参数或返回类型。
- TypeScript 版本
export default class HelloWorld extends Vue { mounted() { //do something } beforeUpdate() { // do something } }
- JavaScript 版本
export default { mounted() { //do something } beforeUpdate() { // do something } }
Props
我们可以在Vue的组件里面使用@Prop
装饰器来替代 props
,在Vue中,我们能给props提供额外的属性,比如required
, default
, type
。如果用TypeScript,我们首先需要从vue-property-decorator
引入Prop
装饰器。我们甚至可以用TS提供的readonly
来避免在代码中不小心修改了props
。
(备注:TypeScript中的赋值断言。!:
表示一定存在, ?:
表示可能不存在。)
- TypeScript 版本
import { Component, Prop, Vue } from 'vue-property-decorator' @Component export default class HelloWorld extends Vue { @Prop() readonly msg!: string @Prop({default: 'John doe'}) readonly name: string @Prop({required: true}) readonly age: number @Prop(String) readonly address: string @Prop({required: false, type: String, default: 'Developer'}) readonly job: string }
- JavaScript 版本
export default { props: { msg, name: { default: 'John doe' }, age: { required: true, }, address: { type: String }, job: { required: false, type: string, default: 'Developer' } } }
Ref
在Vue中我们经常会使用this.$refs.xxx
来调用某个组件中的方法,但是在使用TS的时候,有所不同:
<Loading ref="loading" />
export default class Article extends Mixins(LoadingMixin) {
$refs!: {
loading: Loading;
};
}
在$refs
里面声明之后,TS就可以识别到 ref 属性了,调用方式和JS一样:this.$refs.loading.showLoading();
Watch
要想用watch
侦听器的话,在TS中就要使用@Watch
装饰器(同样从vue-property-decorator
引入)。
- TypeScript 版本
我们还可以给@Watch('name') nameChanged(newVal: string) { this.name = newVal }
watch
添加immediate
和deep
属性:@Watch('name') nameChanged(newVal: string) { this.name = newVal }
- JavaScript 版本
watch: { person: { handler: 'projectChanged', immediate: true, deep: true } } methods: { projectChanged(newVal, oldVal) { // do something } }
Emit
这里同样要从vue-property-decorator
引入装饰器@Emit
-
TypeScript 版本
@Emit() addToCount(n: number) { this.count += n } @Emit('resetData') resetCount() { this.count = 0 } @Emit('getCount') getCount(){ return this.count }
在上面这个例子中,
addToCount
方法回自动转换成kebab-case命名,即中划线命名,这和Vue的 emit 工作方式十分类似。
而resetCount
方法则不会自动转换成中划线命名,因为我们给@Emit
传入了一个参数resetCount
作为方法名。
getCount
这个方法可以向父组件传递参数,就像在JS中写成this.$emit("getCount", this.count)
一样。 -
JavaScript 版本
<some-component add-to-count="someMethod" /> <some-component reset-data="someMethod" /> //Javascript Equivalent methods: { addToCount(n) { this.count += n this.$emit('add-to-count', n) }, resetCount() { this.count = 0 this.$emit('resetData') } }
Mixin
想要在Vue+TypeScript中使用mixin,首先我们先创建一个mixin文件:
import { Component, Vue } from 'vue-property-decorator'
@Component
class ProjectMixin extends Vue {
public projName: string = 'My project'
public setProjectName(newVal: string): void {
this.projName = newVal
}
}
export default ProjectMixin
想要使用上面代码中的mixin,我们需要从vue-property-decorator
中引入 Mixins
以及 包含上述代码的mixins 文件,具体写法如下,主要不同就是组件不继承自Vue
,而是继承自Mixins
:
<template>
<div class="project-detail">
{{ projectDetail }}
</div>
</template>
<script lang="ts">
import { Component, Vue, Mixins } from 'vue-property-decorator'
import ProjectMixin from '@/mixins/ProjectMixin'
@Component
export default class Project extends Mixins(ProjectMixin) {
get projectDetail(): string {
return this.projName + ' ' + 'Preetish HS'
}
}
</script>
Vuex
Vuex是大多数Vue.js应用程序中使用的官方状态管理库。最好将store
分为 namespaced modules,即带命名空间的模块。我们将演示如何在TypeScript中编写Vuex。
- 首先,我们要安装两个流行的第三方库:
npm install vuex-module-decorators -D npm install vuex-class -D
- 在
store
文件夹下,创建一个module
文件夹用来放置不同的模块文件。比如创建一个拥有用户状态的文件user.ts
:
在// store/modules/user.ts import { VuexModule, Module, Mutation, Action } from 'vuex-module-decorators' @Module({ namespaced: true, name: 'test' }) class User extends VuexModule { public name: string = '' @Mutation public setName(newName: string): void { this.name = newName } @Action public updateName(newName: string): void { this.context.commit('setName', newName) } } export default User
vuex-module-decorators
库中提供了Module
,Mutation
和Action
装饰器,对于Actions
,在Mutations
和context
中,我们不需要将状态作为我们的第一个参数,这个第三方库库会处理这些。这些方法已经自动注入了。 - 在store文件夹下,我们需要创建一个
index.ts
来初始化vuex
以及注册这个module
:import Vue from 'vue' import Vuex from 'vuex' import User from '@/store/modules/user' Vue.use(Vuex) const store = new Vuex.Store({ modules: { User } }) export default store
- 在组件中使用 Vuex
要使用Vuex,我们可以利用第三方库vuex-class
。该库提供装饰器使得在我们的Vue组件中绑定State
,Getter
,Mutation
和Action
。
由于我们正在使用命名空间的Vuex模块,因此我们首先从vuex-class
引入namespace
,然后传递模块名称以访问该模块。<template> <div class="details"> <div class="username">User: {{ nameUpperCase }}</div> <input :value="name" @keydown="updateName($event.target.value)" /> </div> </template> <script lang="ts"> import { Component, Vue } from 'vue-property-decorator' import { namespace } from 'vuex-class' const user = namespace('user') @Component export default class User extends Vue { @user.State public name!: string @user.Getter public nameUpperCase!: string @user.Action public updateName!: (newName: string) => void } </script>
Axios 封装
在Vue的项目中,我们使用 axios 来发送 AJAX 请求,我在项目里写了 axios 的统一拦截器,这里的拦截器写法和 JS 没有任何区别,但是在使用该拦截器发送请求的方法会有一些不同之处,具体代码可以参考项目中的api请求代码 。下面我贴一段代码简单介绍一下:
export function getTopicLists(
params?: TopicListParams
): Promise<Array<TopicListEntity>> {
return request.get("topics", {
params
});
}
使用TypeScript,最重要的就是类型,所以在上述代码中,传进来的参数规定类型为TopicListParams
,而函数返回的参数是Promise<Array<TopicListEntity>>
,这样我们在调用getTopicLists
的时候,就可以写成这样:
// 使用await
const response = await getTopicLists(); // response 即返回的Array<TopicListEntity>
// 或使用promise.then
await getTopicLists({
limit: 40,
page
}).then(response => {
// response 即返回的Array<TopicListEntity>
})
});
另外:一般来说后端传给前端的响应体,我们应该添加一个interface
类型来接收,就上面代码中的TopicListEntity
,如果后端传过来的响应数据很多,手写interface
就很麻烦,所以给大家推荐一个工具,可以根据 json 自动生成 TypeScript 实体类型:json to ts。
在Vue中写TSX有哪些需要注意的地方
v-html
使用domPropsInnerHTML
来替代v-html
<main
domPropsInnerHTML={this.topicDetail.content}
class="markdown-body"
>
loading??
</main>
v-if
使用三元操作符来替代v-if
{this.preFlag ? <button class="pageBtn">......</button> : ""}
v-for
使用map
遍历替代v-for
{this.pageBtnList.map(page => {
return (
<button
onClick={this.changePageHandler.bind(this, page)}
class={[{ currentPage: page === this.currentPage }, "pageBtn"]}
>
{page}
</button>
);
})}
render
注意:在render函数中的组件名一定用kebab-case命名
protected render() {
return (
<footer>
<hello-word />
<p>
© 2020 Designed By Enoch Qin
<a href="https://github.com/dreamqyq/cnode-community" target="_blank">
源码链接 GitHub >>
</a>
</p>
</footer>
);
}
onClick事件传值(TSX)
使用template
的时候,如果用v-on
绑定事件,想要传参的话,可以直接这么写:
<button @click="clickHandle(params)">click me</button>
但是在TSX中,如果直接这么写,就相当于立即执行了clickHandle函数:
render(){
// 这样写是不行的!!
return <button onClick={this.clickHandler(params)}>click me</button>
}
因此,我们不得不使用bind()
来绑定参数的形式传参:
render(){
return <button onClick={this.clickHandler.bind(this, params)}>click me</button>
}
开发过程中遇到的问题及解决
Router history模式
原CNode社区的url是没有#的history模式,但是这需要后端支持,所以本项目中使用了hash模式。
- Vue Router 默认模式是hash模式,页面url长这样: localhost:9090/#/payIn
如果改成history模式,url就变成了(没有了#) localhost:9090/payIn - vue-router 默认 hash 模式 —— 使用 URL 的 hash 来模拟一个完整的 URL,于是当 URL 改变时,页面不会重新加载。
如果不想要很丑的 hash,我们可以用路由的 history 模式,这种模式充分利用 history.pushState API 来完成 URL 跳转而无须重新加载页面。const router = new VueRouter({ mode: 'history', routes: [...] })
- 当你使用 history 模式时,URL 就像正常的 url,例如 http://yoursite.com/user/id,也好看!
不过这种模式要玩好,还需要后台配置支持。因为我们的应用是个单页客户端应用,如果后台没有正确的配置,当用户在浏览器直接访问 http://oursite.com/user/id 就会返回 404,这就不好看了。
所以呢,你要在服务端增加一个覆盖所有情况的候选资源:如果 URL 匹配不到任何静态资源,则应该返回同一个 index.html 页面,这个页面就是你 app 依赖的页面。
publicPath 部署应用包时的基本URL
- 默认情况下【 / 】,Vue CLI 会假设你的应用是被部署在一个域名的根路径上,例如 https://www.my-app.com/。
- 如果应用被部署在一个子路径上,你就需要用这个选项指定这个子路径。例如,如果你的应用被部署在 https://www.my-app.com/my-app/,则设置 publicPath 为 【/my-app/】。
- 这个值也可以被设置为空字符串【 (‘')】 或是相对路径【 ('./‘)】,这样所有的资源都会被链接为相对路径,这样打出来的包可以被部署在任意路径,也可以用在类似 Cordova hybrid 应用的文件系统中。
<base> 标签
在项目最开始开发的时候,出现了子页面无法刷新(刷新就会报错:Uncaught SyntaxError: Unexpected token '<‘
),并且子页面用到的图片资源找不到的问题。通过stack overflow的这个问题的答案,使用<base>
标签成功解决了这个问题。
<base>
标签是用来指定一个HTML页中所有的相对路径的根路径,在/public/index.html
中添加标签<base href="./" />
,设置 href为相对路径,在本地调试和打包上线的时候,资源就都不会出问题啦。
Axios withCredentials
在本项目中,后端调用的是 cnode 提供的后端接口,所有接口的都设置了Access-Control-Allow-Origin: *
,用来放置跨域。但是如果我们将axios 的 withCredentials(表示跨域请求时是否需要使用凭证)设置成true,会包CORS跨域错误:
原因是:Access-Control-Allow-Origin
不可以为 *
,因为 *
会和 Access-Control-Allow-Credentials:true
产生冲突,需配置指定的地址。
因此在项目中,withCredentials
设置成false
即可。
Github-markdown-css
在项目中使用到了github-markdown-css
这个库用于展示markdown的样式。用法如下:
- 在
main.ts
引入import "github-markdown-css"
- 在
App.vue
中添加如下样式:.markdown-body { box-sizing: border-box; min-width: 200px; max-width: 1400px; margin: 0 auto; padding: 45px; } @media (max-width: 767px) { .markdown-body { padding: 15px; } }
- 在包含markdown内容的父标签添加class:
markdown-body
总结
翻译:现在,您知道了在创建Vue.js + TypeScript应用程序的过程中,如何使用几个官方库和第三方库所需的所有基本信息,以充分利用类型和自定义装饰器。已经发布了公测版本的Vue 3.0开箱即用将更好地支持TypeScript,并且整个Vue.js的项目代码都使用TypeScript进行了重写,以提高可维护性。
刚开始使用TypeScript似乎有点让人不知所措,但是当您习惯了它之后,您的代码中的错误将大大减少,并且,在同一个项目中可以和其他开发者更好的协同工作。
(完)