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

  • shims-tsx.d.ts

    shims-tsx.d.ts 文件,這個文件主要是方便你使用在 ts 中使用 jsx 語法的,如果不使用 jsx 語法,可以無視這個,但是強烈建議使用 jsx 語法,畢竟模板是沒法獲得靜態類型提示的,當然,如果你境界高的話,直接用 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 數據

  • 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  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

總結

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

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


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

(完)

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,825評論 6 546
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,814評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,980評論 0 384
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 64,064評論 1 319
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,779評論 6 414
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,109評論 1 330
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,099評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,287評論 0 291
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,799評論 1 338
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,515評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,750評論 1 375
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,221評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,933評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,327評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,667評論 1 296
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,492評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,703評論 2 380