使用Vue + TypeScript + TSX 實現(xiàn) CNode社區(qū)

前言: 眾所周知,Vue很優(yōu)秀,TypeScript也很優(yōu)秀,但是Vue + TypeScript就會出現(xiàn)各種奇奇怪怪的問題。本文就將介紹我在「CNode 社區(qū)」這個項目開發(fā)的過程中遇到一些問題和解決辦法。希望對你在Vue中使用TypeScript有所幫助。


項目源碼及預(yù)覽地址

效果預(yù)覽

項目簡介

仿CNode社區(qū),使用Vue + TypeScript + TSX 等相關(guān)技術(shù)棧實現(xiàn)了原社區(qū)的看帖、訪問用戶信息、查看回復(fù)列表、查看用戶信息、博客列表頁分頁查看等功能。
后端接口調(diào)用的是CNode 官方提供的api。
本項目中的所有組件都使用了Vue的渲染函數(shù)render 以及 TSX。

項目安裝及啟動

yarn install
yarn serve

技術(shù)棧

  • Vue @2.6.11
  • TypeScript
  • TSX
  • SCSS

Vue + TypeScript 和 Vue的常規(guī)寫法有什么不同

起手式

  1. 首先我們要把<script>標簽的lang屬性改為ts,即<script lang="ts">
  2. 要在Vue項目中引入 vue-property-decorator,后續(xù)很多操作都需要引用這個庫里面的屬性(包括Vue,Component 等)。

shims-tsx.d.tsshims-vue.d.ts的作用

如果用vue-cli 直接生成一個「Vue + TS」的項目,我們會發(fā)現(xiàn)在 src 目錄下出現(xiàn)了這兩個文件,那么它們的作用是什么呢?

  • shims-vue.d.ts

    shims-vue.d.ts 這個文件,主要用于 TypeScript 識別.vue 文件,Ts 默認并不支持導(dǎo)入 vue 文件,這個文件告訴 ts 導(dǎo)入.vue 文件都按VueConstructor<Vue>處理,因此導(dǎo)入 vue 文件必須寫.vue 后綴,但是這樣同樣的也會造成,就算你寫的導(dǎo)入的 .vue 文件的路徑就算是錯的,靜態(tài)檢測也不會檢測到錯誤,如果你把鼠標放上面你會看到錯誤的路徑就是指向這個文件,因為你定義了這個模塊是所有 .vue 后綴的導(dǎo)入都會指向到這個文件,但是如果你的路徑是對的,ts 能讀出正確的 module。

  • shims-tsx.d.ts

    shims-tsx.d.ts 文件,這個文件主要是方便你使用在 ts 中使用 jsx 語法的,如果不使用 jsx 語法,可以無視這個,但是強烈建議使用 jsx 語法,畢竟模板是沒法獲得靜態(tài)類型提示的,當然,如果你境界高的話,直接用 vue render function。

基于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 數(shù)據(jù)

  • 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語法。這些被聲明為普通類方法。由于生命周期掛鉤是自動調(diào)用的,因此它們既不帶參數(shù)也不返回任何數(shù)據(jù)。因此,我們不需要訪問修飾符,鍵入?yún)?shù)或返回類型。

  • 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中我們經(jīng)常會使用this.$refs.xxx 來調(diào)用某個組件中的方法,但是在使用TS的時候,有所不同:

<Loading ref="loading" />

export default class Article extends Mixins(LoadingMixin) {
  $refs!: {
    loading: Loading;
  };
}

$refs里面聲明之后,TS就可以識別到 ref 屬性了,調(diào)用方式和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方法回自動轉(zhuǎn)換成kebab-case命名,即中劃線命名,這和Vue的 emit 工作方式十分類似。
    resetCount方法則不會自動轉(zhuǎn)換成中劃線命名,因為我們給@Emit傳入了一個參數(shù)resetCount作為方法名。
    getCount這個方法可以向父組件傳遞參數(shù),就像在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,首先我們先創(chuàng)建一個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是大多數(shù)Vue.js應(yīng)用程序中使用的官方狀態(tài)管理庫。最好將store分為 namespaced modules,即帶命名空間的模塊。我們將演示如何在TypeScript中編寫Vuex。

  • 首先,我們要安裝兩個流行的第三方庫:
    npm install vuex-module-decorators -D
    npm install vuex-class -D
    
  • store文件夾下,創(chuàng)建一個module文件夾用來放置不同的模塊文件。比如創(chuàng)建一個擁有用戶狀態(tài)的文件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中,我們不需要將狀態(tài)作為我們的第一個參數(shù),這個第三方庫庫會處理這些。這些方法已經(jīng)自動注入了。
  • 在store文件夾下,我們需要創(chuàng)建一個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,MutationAction。
    由于我們正在使用命名空間的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 來發(fā)送 AJAX 請求,我在項目里寫了 axios 的統(tǒng)一攔截器,這里的攔截器寫法和 JS 沒有任何區(qū)別,但是在使用該攔截器發(fā)送請求的方法會有一些不同之處,具體代碼可以參考項目中的api請求代碼 。下面我貼一段代碼簡單介紹一下:

export function getTopicLists(
  params?: TopicListParams
): Promise<Array<TopicListEntity>> {
  return request.get("topics", {
    params
  });
}

使用TypeScript,最重要的就是類型,所以在上述代碼中,傳進來的參數(shù)規(guī)定類型為TopicListParams ,而函數(shù)返回的參數(shù)是Promise<Array<TopicListEntity>>,這樣我們在調(diào)用getTopicLists的時候,就可以寫成這樣:

// 使用await
const response = await getTopicLists(); // response 即返回的Array<TopicListEntity>
// 或使用promise.then
await getTopicLists({
    limit: 40,
    page
  }).then(response => {
    // response 即返回的Array<TopicListEntity>
  })
});

另外:一般來說后端傳給前端的響應(yīng)體,我們應(yīng)該添加一個interface類型來接收,就上面代碼中的TopicListEntity,如果后端傳過來的響應(yīng)數(shù)據(jù)很多,手寫interface就很麻煩,所以給大家推薦一個工具,可以根據(jù) 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函數(shù)中的組件名一定用kebab-case命名

protected render() {
  return (
    <footer>
      <hello-word />
      <p>
        &copy; 2020 Designed By Enoch Qin
        <a  target="_blank">
          源碼鏈接 GitHub >>
        </a>
      </p>
    </footer>   
  );
}

onClick事件傳值(TSX)

使用template的時候,如果用v-on綁定事件,想要傳參的話,可以直接這么寫:

<button @click="clickHandle(params)">click me</button>

但是在TSX中,如果直接這么寫,就相當于立即執(zhí)行了clickHandle函數(shù):

render(){
  // 這樣寫是不行的??!
  return <button onClick={this.clickHandler(params)}>click me</button>
}

因此,我們不得不使用bind()來綁定參數(shù)的形式傳參:

render(){
  return <button onClick={this.clickHandler.bind(this, params)}>click me</button>
}

開發(fā)過程中遇到的問題及解決

Router history模式

原CNode社區(qū)的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 跳轉(zhuǎn)而無須重新加載頁面。
    const router = new VueRouter({
      mode: 'history',
      routes: [...]
    })
    
  • 當你使用 history 模式時,URL 就像正常的 url,例如 http://yoursite.com/user/id,也好看!
    不過這種模式要玩好,還需要后臺配置支持。因為我們的應(yīng)用是個單頁客戶端應(yīng)用,如果后臺沒有正確的配置,當用戶在瀏覽器直接訪問 http://oursite.com/user/id 就會返回 404,這就不好看了。
    所以呢,你要在服務(wù)端增加一個覆蓋所有情況的候選資源:如果 URL 匹配不到任何靜態(tài)資源,則應(yīng)該返回同一個 index.html 頁面,這個頁面就是你 app 依賴的頁面。

publicPath 部署應(yīng)用包時的基本URL

  • 默認情況下【 / 】,Vue CLI 會假設(shè)你的應(yīng)用是被部署在一個域名的根路徑上,例如 https://www.my-app.com/。
  • 如果應(yīng)用被部署在一個子路徑上,你就需要用這個選項指定這個子路徑。例如,如果你的應(yīng)用被部署在 https://www.my-app.com/my-app/,則設(shè)置 publicPath 為 【/my-app/】。
  • 這個值也可以被設(shè)置為空字符串【 (‘')】 或是相對路徑【 ('./‘)】,這樣所有的資源都會被鏈接為相對路徑,這樣打出來的包可以被部署在任意路徑,也可以用在類似 Cordova hybrid 應(yīng)用的文件系統(tǒng)中。

<base> 標簽

在項目最開始開發(fā)的時候,出現(xiàn)了子頁面無法刷新(刷新就會報錯:Uncaught SyntaxError: Unexpected token '<‘),并且子頁面用到的圖片資源找不到的問題。通過stack overflow的這個問題的答案,使用<base>標簽成功解決了這個問題。
<base>標簽是用來指定一個HTML頁中所有的相對路徑的根路徑,在/public/index.html中添加標簽<base href="./" />,設(shè)置 href為相對路徑,在本地調(diào)試和打包上線的時候,資源就都不會出問題啦。

Axios withCredentials

在本項目中,后端調(diào)用的是 cnode 提供的后端接口,所有接口的都設(shè)置了Access-Control-Allow-Origin: *,用來放置跨域。但是如果我們將axios 的 withCredentials(表示跨域請求時是否需要使用憑證)設(shè)置成true,會包CORS跨域錯誤:
原因是:Access-Control-Allow-Origin不可以為 *,因為 * 會和 Access-Control-Allow-Credentials:true 產(chǎn)生沖突,需配置指定的地址。
因此在項目中,withCredentials設(shè)置成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內(nèi)容的父標簽添加class:markdown-body

總結(jié)

Now you have all the basic information you need to create a Vue.js application completely in TypeScript using a few official and third-party libraries to fully leverage the typing and custom decorator features. Vue 3.0 will have better support for TypeScript out of the box, and the whole Vue.js code was rewritten in TypeScript to improve maintainability.
Using TypeScript might seem a bit overwhelming at first, but when you get used to it, you’ll have far fewer bugs in your code and smooth code collaboration between other developers who work on the same code base. (摘自How to write a Vue.js app completely in TypeScript

翻譯:現(xiàn)在,您知道了在創(chuàng)建Vue.js + TypeScript應(yīng)用程序的過程中,如何使用幾個官方庫和第三方庫所需的所有基本信息,以充分利用類型自定義裝飾器。已經(jīng)發(fā)布了公測版本的Vue 3.0開箱即用將更好地支持TypeScript,并且整個Vue.js的項目代碼都使用TypeScript進行了重寫,以提高可維護性。
剛開始使用TypeScript似乎有點讓人不知所措,但是當您習(xí)慣了它之后,您的代碼中的錯誤將大大減少,并且,在同一個項目中可以和其他開發(fā)者更好的協(xié)同工作。


本文參考資料:
??https://blog.logrocket.com/how-to-write-a-vue-js-app-completely-in-typescript/
??https://zhuanlan.zhihu.com/p/99343202
??TypeScript 支持 — Vue.js
??TypeScript 官網(wǎng)
??https://segmentfault.com/a/1190000016837020

(完)

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容