0
点赞
收藏
分享

微信扫一扫

使用Vue + TypeScript + TSX 实现 CNode社区

前言: 众所周知,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的常规写法有什么不同

起手式

  1. 首先我们要把<script>标签的lang属性改为ts,即<script lang="ts">
  2. 要在Vue项目中引入 vue-property-decorator,后续很多操作都需要引用这个库里面的属性(包括VueComponent 等)。

shims-tsx.d.tsshims-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组件具有八个生命周期挂钩,包括createdmounted等,并且每个挂钩使用相同的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添加immediatedeep属性:
    @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, MutationAction装饰器,对于Actions,在 Mutationscontext中,我们不需要将状态作为我们的第一个参数,这个第三方库库会处理这些。这些方法已经自动注入了。
  • 在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组件中绑定 StateGetterMutationAction
    由于我们正在使用命名空间的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>
        &copy; 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似乎有点让人不知所措,但是当您习惯了它之后,您的代码中的错误将大大减少,并且,在同一个项目中可以和其他开发者更好的协同工作。


(完)

举报

相关推荐

0 条评论