Vue + TypeScript + Element 搭建簡潔時尚的部落格網站及踩坑記

  • 2019 年 10 月 3 日
  • 筆記

前言

本文講解如何在 Vue 項目中使用 TypeScript 來搭建並開發項目,並在此過程中踩過的坑 。

TypeScript 具有類型系統,且是 JavaScript 的超集,TypeScript 在 2018年 勢頭迅猛,可謂遍地開花。

Vue3.0 將使用 TS 重寫,重寫後的 Vue3.0 將更好的支援 TS。2019 年 TypeScript 將會更加普及,能夠熟練掌握 TS,並使用 TS 開發過項目,將更加成為前端開發者的優勢。

所以筆者就當然也要學這個必備技能,就以 邊學邊實踐 的方式,做個部落格項目來玩玩。

此項目是基於 Vue 全家桶 + TypeScript + Element-UI 的技術棧,且已經開源,github 地址 blog-vue-typescript

因為之前寫了篇純 Vue 項目搭建的相關文章 基於vue+mint-ui的mobile-h5的項目說明 ,有不少人加我微信,要源碼來學習,但是這個是我司的項目,不能提供原碼。

所以做一個不是我司的項目,且又是 vue 相關的項目來練手並開源吧。

1. 效果

效果圖:

  • pc 端

  • 移動端

完整效果請看:https://biaochenxuying.cn

2. 功能

已經完成功能

  • [x] 登錄
  • [x] 註冊
  • [x] 文章列表
  • [x] 文章歸檔
  • [x] 標籤
  • [x] 關於
  • [x] 點贊與評論
  • [x] 留言
  • [x] 歷程
  • [x] 文章詳情(支援程式碼語法高亮)
  • [x] 文章詳情目錄
  • [x] 移動端適配
  • [x] github 授權登錄

待優化或者實現

  • [ ] 使用 vuex-class
  • [ ] 更多 TypeScript 的優化技巧
  • [ ] 伺服器渲染 SSR

3. 前端主要技術

所有技術都是當前最新的。

  • vue: ^2.6.6
  • typescript : ^3.2.1
  • element-ui: 2.6.3
  • vue-router : ^3.0.1
  • webpack: 4.28.4
  • vuex: ^3.0.1
  • axios:0.18.0
  • redux: 4.0.0
  • highlight.js: 9.15.6
  • marked:0.6.1

4. 5 分鐘上手 TypeScript

如果沒有一點點基礎,可能沒學過 TypeScript 的讀者會看不懂往下的內容,所以先學點基礎。

TypeScript 的靜態類型檢查是個好東西,可以避免很多不必要的錯誤, 不用在調試或者項目上線的時候才發現問題 。

  • 類型註解

TypeScript 里的類型註解是一種輕量級的為函數或變數添加約束的方式。變數定義時也要定義他的類型,比如常見的 :

// 布爾值  let isDone: boolean = false; // 相當於 js 的 let isDone = false;  // 變數定義之後不可以隨便變更它的類型  isDone = true // 不報錯  isDone = "我要變為字元串" // 報錯
// 數字  let decLiteral: number = 6; // 相當於 js 的 let decLiteral = 6;
// 字元串  let name: string = "bob";  // 相當於 js 的 let name = "bob";
// 數組   // 第一種,可以在元素類型後面接上 [],表示由此類型元素組成的一個數組:  let list: number[] = [1, 2, 3]; // 相當於 js 的let list = [1, 2, 3];  // 第二種方式是使用數組泛型,Array<元素類型>:  let list: Array<number> = [1, 2, 3]; // 相當於 js 的let list = [1, 2, 3];
// 在 TypeScript 中,我們使用介面(Interfaces)來定義 對象 的類型。  interface Person {      name: string;      age: number;  }  let tom: Person = {      name: 'Tom',      age: 25  };  // 以上 對象 的程式碼相當於  let tom = {      name: 'Tom',      age: 25  };
// Any 可以隨便變更類型 (當這個值可能來自於動態的內容,比如來自用戶輸入或第三方程式碼庫)  let notSure: any = 4;  notSure = "我可以隨便變更類型" // 不報錯  notSure = false;  // 不報錯
// Void 當一個函數沒有返回值時,你通常會見到其返回值類型是 void  function warnUser(): void {      console.log("This is my warning message");  }
// 方法的參數也要定義類型,不知道就定義為 any  function fetch(url: string, id : number, params: any): void {      console.log("fetch");  }

以上是最簡單的一些知識點,更多知識請看 TypeScript 中文官網

5. 5 分鐘上手 Vue +TypeScript

  • vue-class-component 
    vue-class-component 對 Vue 組件進行了一層封裝,讓 Vue 組件語法在結合了 TypeScript 語法之後更加扁平化:
<template>    <div>      <input v-model="msg">      <p>prop: {{propMessage}}</p>      <p>msg: {{msg}}</p>      <p>helloMsg: {{helloMsg}}</p>      <p>computed msg: {{computedMsg}}</p>      <button @click="greet">Greet</button>    </div>  </template>    <script>  import Vue from 'vue'  import Component from 'vue-class-component'    @Component({    props: {      propMessage: String    }  })  export default class App extends Vue {    // initial data    msg = 123      // use prop values for initial data    helloMsg = 'Hello, ' + this.propMessage      // lifecycle hook    mounted () {      this.greet()    }      // computed    get computedMsg () {      return 'computed ' + this.msg    }      // method    greet () {      alert('greeting: ' + this.msg)    }  }  </script>

上面的程式碼跟下面的程式碼作用是一樣的:

<template>    <div>      <input v-model="msg">      <p>prop: {{propMessage}}</p>      <p>msg: {{msg}}</p>      <p>helloMsg: {{helloMsg}}</p>      <p>computed msg: {{computedMsg}}</p>      <button @click="greet">Greet</button>    </div>  </template>    <script>  export default {    // 屬性    props: {      propMessage: {        type: String      }    },    data () {      return {        msg: 123,        helloMsg: 'Hello, ' + this.propMessage      }    },    // 聲明周期鉤子    mounted () {      this.greet()    },    // 計算屬性    computed: {      computedMsg () {        return 'computed ' + this.msg      }    },    // 方法    methods: {      greet () {        alert('greeting: ' + this.msg)      }    },  }  </script>

vue-property-decorator 是在 vue-class-component 上增強了更多的結合 Vue 特性的裝飾器,新增了這 7 個裝飾器:

  • @Emit
  • @Inject
  • @Model
  • @Prop
  • @Provide
  • @Watch
  • @Component (從 vue-class-component 繼承)

在這裡列舉幾個常用的@Prop/@Watch/@Component, 更多資訊,詳見官方文檔

import { Component, Emit, Inject, Model, Prop, Provide, Vue, Watch } from 'vue-property-decorator'    @Component  export class MyComponent extends Vue {      @Prop()    propA: number = 1      @Prop({ default: 'default value' })    propB: string      @Prop([String, Boolean])    propC: string | boolean      @Prop({ type: null })    propD: any      @Watch('child')    onChildChanged(val: string, oldVal: string) { }  }

上面的程式碼相當於:

export default {    props: {      checked: Boolean,      propA: Number,      propB: {        type: String,        default: 'default value'      },      propC: [String, Boolean],      propD: { type: null }    }    methods: {      onChildChanged(val, oldVal) { }    },    watch: {      'child': {        handler: 'onChildChanged',        immediate: false,        deep: false      }    }  }
  • vuex-class
    vuex-class :在 vue-class-component 寫法中 綁定 vuex
import Vue from 'vue'  import Component from 'vue-class-component'  import {    State,    Getter,    Action,    Mutation,    namespace  } from 'vuex-class'    const someModule = namespace('path/to/module')    @Component  export class MyComp extends Vue {    @State('foo') stateFoo    @State(state => state.bar) stateBar    @Getter('foo') getterFoo    @Action('foo') actionFoo    @Mutation('foo') mutationFoo    @someModule.Getter('foo') moduleGetterFoo      // If the argument is omitted, use the property name    // for each state/getter/action/mutation type    @State foo    @Getter bar    @Action baz    @Mutation qux      created () {      this.stateFoo // -> store.state.foo      this.stateBar // -> store.state.bar      this.getterFoo // -> store.getters.foo      this.actionFoo({ value: true }) // -> store.dispatch('foo', { value: true })      this.mutationFoo({ value: true }) // -> store.commit('foo', { value: true })      this.moduleGetterFoo // -> store.getters['path/to/module/foo']    }  }

6. 用 vue-cli 搭建 項目

筆者使用最新的 vue-cli 3 搭建項目,詳細的教程,請看我之前寫的 vue-cli3.x 新特性及踩坑記,裡面已經有詳細講解 ,但文章裡面的配置和此項目不同的是,我加入了 TypeScript ,其他的配置都是 vue-cli 本來配好的了。詳情請看 vue-cli 官網

6.1 安裝及構建項目目錄

安裝的依賴:

安裝過程選擇的一些配置:

搭建好之後,初始項目結構長這樣:

├── public                          // 靜態頁面    ├── src                             // 主目錄        ├── assets                      // 靜態資源        ├── components                  // 組件        ├── views                       // 頁面        ├── App.vue                     // 頁面主入口        ├── main.ts                     // 腳本主入口        ├── router.ts                   // 路由        ├── shims-tsx.d.ts              // 相關 tsx 模組注入        ├── shims-vue.d.ts              // Vue 模組注入        └── store.ts                    // vuex 配置    ├── tests                           // 測試用例    ├── .eslintrc.js                    // eslint 相關配置    ├── .gitignore                      // git 忽略文件配置    ├── babel.config.js                 // babel 配置    ├── postcss.config.js               // postcss 配置    ├── package.json                    // 依賴    └── tsconfig.json                   // ts 配置  

奔著 大型項目的結構 來改造項目結構,改造後 :

  ├── public                          // 靜態頁面    ├── src                             // 主目錄        ├── assets                      // 靜態資源        ├── filters                     // 過濾        ├── store                       // vuex 配置        ├── less                        // 樣式        ├── utils                       // 工具方法(axios封裝,全局方法等)        ├── views                       // 頁面        ├── App.vue                     // 頁面主入口        ├── main.ts                     // 腳本主入口        ├── router.ts                   // 路由        ├── shime-global.d.ts           // 相關 全局或者插件 模組注入        ├── shims-tsx.d.ts              // 相關 tsx 模組注入        ├── shims-vue.d.ts              // Vue 模組注入, 使 TypeScript 支援 *.vue 後綴的文件    ├── tests                           // 測試用例    ├── .eslintrc.js                    // eslint 相關配置    ├── postcss.config.js               // postcss 配置    ├── .gitignore                      // git 忽略文件配置    ├── babel.config.js                 // preset 記錄    ├── package.json                    // 依賴    ├── README.md                       // 項目 readme    ├── tsconfig.json                   // ts 配置    └── vue.config.js                   // webpack 配置  

tsconfig.json 文件中指定了用來編譯這個項目的根文件和編譯選項。
本項目的 tsconfig.json 配置如下 :

{      // 編譯選項    "compilerOptions": {      // 編譯輸出目標 ES 版本      "target": "esnext",      // 採用的模組系統      "module": "esnext",      // 以嚴格模式解析      "strict": true,      "jsx": "preserve",      // 從 tslib 導入外部幫助庫: 比如__extends,__rest等      "importHelpers": true,      // 如何處理模組      "moduleResolution": "node",      // 啟用裝飾器      "experimentalDecorators": true,      "esModuleInterop": true,      // 允許從沒有設置默認導出的模組中默認導入      "allowSyntheticDefaultImports": true,      // 定義一個變數就必須給它一個初始值      "strictPropertyInitialization" : false,      // 允許編譯javascript文件      "allowJs": true,      // 是否包含可以用於 debug 的 sourceMap      "sourceMap": true,      // 忽略 this 的類型檢查, Raise error on this expressions with an implied any type.      "noImplicitThis": false,      // 解析非相對模組名的基準目錄      "baseUrl": ".",      // 給錯誤和消息設置樣式,使用顏色和上下文。      "pretty": true,      // 設置引入的定義文件      "types": ["webpack-env", "mocha", "chai"],      // 指定特殊模組的路徑      "paths": {        "@/*": ["src/*"]      },      // 編譯過程中需要引入的庫文件的列表      "lib": ["esnext", "dom", "dom.iterable", "scripthost"]    },    // ts 管理的文件    "include": [      "src/**/*.ts",      "src/**/*.tsx",      "src/**/*.vue",      "tests/**/*.ts",      "tests/**/*.tsx"    ],    // ts 排除的文件    "exclude": ["node_modules"]  }  

更多配置請看官網的 tsconfig.json 的 編譯選項

本項目的 vue.config.js:

const path = require("path");  const sourceMap = process.env.NODE_ENV === "development";    module.exports = {    // 基本路徑    publicPath: "./",    // 輸出文件目錄    outputDir: "dist",    // eslint-loader 是否在保存的時候檢查    lintOnSave: false,    // webpack配置    // see https://github.com/vuejs/vue-cli/blob/dev/docs/webpack.md    chainWebpack: () => {},    configureWebpack: config => {      if (process.env.NODE_ENV === "production") {        // 為生產環境修改配置...        config.mode = "production";      } else {        // 為開發環境修改配置...        config.mode = "development";      }        Object.assign(config, {        // 開發生產共同配置        resolve: {          extensions: [".js", ".vue", ".json", ".ts", ".tsx"],          alias: {            vue$: "vue/dist/vue.js",            "@": path.resolve(__dirname, "./src")          }        }      });    },    // 生產環境是否生成 sourceMap 文件    productionSourceMap: sourceMap,    // css相關配置    css: {      // 是否使用css分離插件 ExtractTextPlugin      extract: true,      // 開啟 CSS source maps?      sourceMap: false,      // css預設器配置項      loaderOptions: {},      // 啟用 CSS modules for all css / pre-processor files.      modules: false    },    // use thread-loader for babel & TS in production build    // enabled by default if the machine has more than 1 cores    parallel: require("os").cpus().length > 1,    // PWA 插件相關配置    // see https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-pwa    pwa: {},    // webpack-dev-server 相關配置    devServer: {      open: process.platform === "darwin",      host: "localhost",      port: 3001, //8080,      https: false,      hotOnly: false,      proxy: {        // 設置代理        // proxy all requests starting with /api to jsonplaceholder        "/api": {          // target: "https://emm.cmccbigdata.com:8443/",          target: "http://localhost:3000/",          // target: "http://47.106.136.114/",          changeOrigin: true,          ws: true,          pathRewrite: {            "^/api": ""          }        }      },      before: app => {}    },    // 第三方插件配置    pluginOptions: {      // ...    }  };  

6.2 安裝 element-ui

本來想搭配 iview-ui 來用的,但後續還想把這個項目搞成 ssr 的,而 vue + typescript + iview + Nuxt.js 的服務端渲染還有不少坑, 而 vue + typescript + element + Nuxt.js 對 ssr 的支援已經不錯了,所以選擇了 element-ui 。

安裝:

npm i element-ui -S

按需引入, 藉助 babel-plugin-component,我們可以只引入需要的組件,以達到減小項目體積的目的。

npm install babel-plugin-component -D

然後,將 babel.config.js 修改為:

module.exports = {    presets: ["@vue/app"],    plugins: [      [        "component",        {          libraryName: "element-ui",          styleLibraryName: "theme-chalk"        }      ]    ]  };

接下來,如果你只希望引入部分組件,比如 Button 和 Select,那麼需要在 main.js 中寫入以下內容:

import Vue from 'vue';  import { Button, Select } from 'element-ui';  import App from './App.vue';    Vue.component(Button.name, Button);  Vue.component(Select.name, Select);  /* 或寫為   * Vue.use(Button)   * Vue.use(Select)   */    new Vue({    el: '#app',    render: h => h(App)  });

6.3 完善項目目錄與文件

route

使用路由懶載入功能。

export default new Router({    mode: "history",    routes: [      {        path: "/",        name: "home",        component: () => import(/* webpackChunkName: "home" */ "./views/home.vue")      },      {        path: "/articles",        name: "articles",        // route level code-splitting        // this generates a separate chunk (articles.[hash].js) for this route        // which is lazy-loaded when the route is visited.        component: () =>          import(/* webpackChunkName: "articles" */ "./views/articles.vue")      },    ]  });

utils

  • utils/utils.ts 常用函數的封裝, 比如 事件的節流(throttle)與防抖(debounce)方法:
// fn是我們需要包裝的事件回調, delay是時間間隔的閾值  export function throttle(fn: Function, delay: number) {    // last為上一次觸發回調的時間, timer是定時器    let last = 0,      timer: any = null;    // 將throttle處理結果當作函數返回    return function() {      // 保留調用時的this上下文      let context = this;      // 保留調用時傳入的參數      let args = arguments;      // 記錄本次觸發回調的時間      let now = +new Date();      // 判斷上次觸發的時間和本次觸發的時間差是否小於時間間隔的閾值      if (now - last < delay) {        // 如果時間間隔小於我們設定的時間間隔閾值,則為本次觸發操作設立一個新的定時器        clearTimeout(timer);        timer = setTimeout(function() {          last = now;          fn.apply(context, args);        }, delay);      } else {        // 如果時間間隔超出了我們設定的時間間隔閾值,那就不等了,無論如何要回饋給用戶一次響應        last = now;        fn.apply(context, args);      }    };  }
  • utils/config.ts 配置文件,比如 github 授權登錄的回調地址、client_id、client_secret 等。
const config = {    'oauth_uri': 'https://github.com/login/oauth/authorize',    'redirect_uri': 'https://biaochenxuying.cn/login',    'client_id': 'XXXXXXXXXX',    'client_secret': 'XXXXXXXXXX',  };    // 本地開發環境下  if (process.env.NODE_ENV === 'development') {    config.redirect_uri = "http://localhost:3001/login"    config.client_id = "502176cec65773057a9e"    config.client_secret = "65d444de381a026301a2c7cffb6952b9a86ac235"  }  export default config;

如果你的生產環境也要 github 登錄授權的話,請在 github 上申請一個 Oauth App ,把你的 redirect_uri,client_id,client_secret 的資訊填在 config 裡面即可。具體詳情請看我寫的這篇文章 github 授權登錄教程與如何設計第三方授權登錄的用戶表

  • utils/urls.ts 請求介面地址,統一管理。
// url的鏈接  export const urls: object = {    login: "login",    register: "register",    getArticleList: "getArticleList",  };  export default urls;
  • utils/https.ts axios 請求的封裝。
import axios from "axios";    // 創建axios實例  let service: any = {};  service = axios.create({      baseURL: "/api", // api的base_url      timeout: 50000 // 請求超時時間    });    // request攔截器 axios的一些配置  service.interceptors.request.use(    (config: any) => {      return config;    },    (error: any) => {      // Do something with request error      console.error("error:", error); // for debug      Promise.reject(error);    }  );    // respone攔截器 axios的一些配置  service.interceptors.response.use(    (response: any) => {      return response;    },    (error: any) => {      console.error("error:" + error); // for debug      return Promise.reject(error);    }  );    export default service;

把 urls 和 https 掛載到 main.ts 裡面的 Vue 的 prototype 上面。

import service from "./utils/https";  import urls from "./utils/urls";    Vue.prototype.$https = service; // 其他頁面在使用 axios 的時候直接  this.$http 就可以了  Vue.prototype.$urls = urls; // 其他頁面在使用 urls 的時候直接  this.$urls 就可以了

然後就可以統一管理介面,而且調用起來也很方便啦。比如下面 文章列表的請求。

async handleSearch() {      this.isLoading = true;      const res: any = await this.$https.get(this.$urls.getArticleList, {        params: this.params      });      this.isLoading = false;      if (res.status === 200) {        if (res.data.code === 0) {          const data: any = res.data.data;          this.articlesList = [...this.articlesList, ...data.list];          this.total = data.count;          this.params.pageNum++;          if (this.total === this.articlesList.length) {            this.isLoadEnd = true;          }        } else {          this.$message({            message: res.data.message,            type: "error"          });        }      } else {        this.$message({          message: "網路錯誤!",          type: "error"        });      }    }

store ( Vuex )

一般大型的項目都有很多模組的,比如本項目中有公共資訊(比如 token )、 用戶模組、文章模組。

├── modules                         // 模組        ├── user.ts                     // 用戶模組        ├── article.ts                 // 文章模組    ├── types.ts                        // 類型    └── index.ts                        // vuex 主入口
  • store/index.ts 存放公共的資訊,並導入其他模組
import Vue from "vue";  import Vuex from "vuex";  import * as types from "./types";  import user from "./modules/user";  import article from "./modules/article";    Vue.use(Vuex);  const initPageState = () => {    return {      token: ""    };  };  const store = new Vuex.Store({    strict: process.env.NODE_ENV !== "production",    // 具體模組    modules: {      user,      article    },    state: initPageState(),    mutations: {      [types.SAVE_TOKEN](state: any, pageState: any) {        for (const prop in pageState) {          state[prop] = pageState[prop];        }      }    },    actions: {}  });    export default store;
  • types.ts
// 公共 token  export const SAVE_TOKEN = "SAVE_TOKEN";    // 用戶  export const SAVE_USER = "SAVE_USER";
  • user.ts
import * as types from "../types";    const initPageState = () => {    return {      userInfo: {        _id: "",        name: "",        avator: ""      }    };  };  const user = {    state: initPageState(),    mutations: {      [types.SAVE_USER](state: any, pageState: any) {        for (const prop in pageState) {          state[prop] = pageState[prop];        }      }    },    actions: {}  };    export default user;  

7. markdown 渲染

markdown 渲染效果圖:

markdown 渲染效果圖

markdown 渲染 採用了開源的 marked, 程式碼高亮用了 highlight.js 。

用法:

第一步:npm i marked highlight.js –save

npm i marked highlight.js --save

第二步: 導入封裝成 markdown.js,將文章詳情由字元串轉成 html, 並抽離出文章目錄。

marked 的封裝 得感謝這位老哥。

const highlight = require("highlight.js");  const marked = require("marked");  const tocObj = {    add: function(text, level) {      var anchor = `#toc${level}${++this.index}`;      this.toc.push({ anchor: anchor, level: level, text: text });      return anchor;    },    // 使用堆棧的方式處理嵌套的ul,li,level即ul的嵌套層次,1是最外層    // <ul>    //   <li></li>    //   <ul>    //     <li></li>    //   </ul>    //   <li></li>    // </ul>    toHTML: function() {      let levelStack = [];      let result = "";      const addStartUL = () => {        result += '<ul class="anchor-ul" id="anchor-fix">';      };      const addEndUL = () => {        result += "</ul>n";      };      const addLI = (anchor, text) => {        result +=          '<li><a class="toc-link" href="#' + anchor + '">' + text + "<a></li>n";      };        this.toc.forEach(function(item) {        let levelIndex = levelStack.indexOf(item.level);        // 沒有找到相應level的ul標籤,則將li放入新增的ul中        if (levelIndex === -1) {          levelStack.unshift(item.level);          addStartUL();          addLI(item.anchor, item.text);        } // 找到了相應level的ul標籤,並且在棧頂的位置則直接將li放在此ul下        else if (levelIndex === 0) {          addLI(item.anchor, item.text);        } // 找到了相應level的ul標籤,但是不在棧頂位置,需要將之前的所有level出棧並且打上閉合標籤,最後新增li        else {          while (levelIndex--) {            levelStack.shift();            addEndUL();          }          addLI(item.anchor, item.text);        }      });      // 如果棧中還有level,全部出棧打上閉合標籤      while (levelStack.length) {        levelStack.shift();        addEndUL();      }      // 清理先前數據供下次使用      this.toc = [];      this.index = 0;      return result;    },    toc: [],    index: 0  };    class MarkUtils {    constructor() {      this.rendererMD = new marked.Renderer();      this.rendererMD.heading = function(text, level, raw) {        var anchor = tocObj.add(text, level);        return `<h${level} id=${anchor}>${text}</h${level}>n`;      };      highlight.configure({ useBR: true });      marked.setOptions({        renderer: this.rendererMD,        headerIds: false,        gfm: true,        tables: true,        breaks: false,        pedantic: false,        sanitize: false,        smartLists: true,        smartypants: false,        highlight: function(code) {          return highlight.highlightAuto(code).value;        }      });    }      async marked(data) {      if (data) {        let content = await marked(data); // 文章內容        let toc = tocObj.toHTML(); // 文章目錄        return { content: content, toc: toc };      } else {        return null;      }    }  }    const markdown = new MarkUtils();    export default markdown;

第三步: 使用

import markdown from "@/utils/markdown";    // 獲取文章詳情  async handleSearch() {      const res: any = await this.$https.post(        this.$urls.getArticleDetail,        this.params      );      if (res.status === 200) {        if (res.data.code === 0) {          this.articleDetail = res.data.data;         // 使用 marked 轉換          const article = markdown.marked(res.data.data.content);          article.then((response: any) => {            this.articleDetail.content = response.content;            this.articleDetail.toc = response.toc;          });        } else {          // ...      } else {       // ...      }    }    // 渲染  <div id="content"         class="article-detail"         v-html="articleDetail.content">  </div>

第四步:引入 monokai_sublime 的 css 樣式

<link href="http://cdn.bootcss.com/highlight.js/8.0/styles/monokai_sublime.min.css" rel="stylesheet">

第五步:對 markdown 樣式的補充

如果不補充樣式,是沒有黑色背景的,字體大小等也會比較小,圖片也不會居中顯示

/*對 markdown 樣式的補充*/  pre {      display: block;      padding: 10px;      margin: 0 0 10px;      font-size: 14px;      line-height: 1.42857143;      color: #abb2bf;      background: #282c34;      word-break: break-all;      word-wrap: break-word;      overflow: auto;  }  h1,h2,h3,h4,h5,h6{      margin-top: 1em;      /* margin-bottom: 1em; */  }  strong {      font-weight: bold;  }    p > code:not([class]) {      padding: 2px 4px;      font-size: 90%;      color: #c7254e;      background-color: #f9f2f4;      border-radius: 4px;  }  p img{      /* 圖片居中 */      margin: 0 auto;      display: flex;  }    #content {      font-family: "Microsoft YaHei",  'sans-serif';      font-size: 16px;      line-height: 30px;  }    #content .desc ul,#content .desc ol {      color: #333333;      margin: 1.5em 0 0 25px;  }    #content .desc h1, #content .desc h2 {      border-bottom: 1px solid #eee;      padding-bottom: 10px;  }    #content .desc a {      color: #009a61;  }

8. 注意點

  • 關於 頁面

對於 關於 的頁面,其實是一篇文章來的,根據文章類型 type 來決定的,資料庫裡面 type 為 3
的文章,只能有一篇就是 部落客介紹 ;達到了想什麼時候修改內容都可以。

所以當 當前路由 === ‘/about’ 時就是請求類型為 部落客介紹 的文章。

type: 3,  // 文章類型: 1:普通文章;2:是部落客簡歷;3 :是部落客簡介;
  • 移動端適配
    移動端使用 rem 單位適配。
// 螢幕適配( window.screen.width / 移動端設計稿寬 * 100)也即是 (window.screen.width / 750 * 100)  ——*100 為了方便計算。即 font-size 值是手機 deviceWidth 與設計稿比值的 100 倍  document.getElementsByTagName('html')[0].style.fontSize = window.screen.width / 7.5 + 'px';

如上:通過查詢螢幕寬度,動態的設置 html 的 font-size 值,移動端的設計稿大多以寬為 750 px 來設置的。

比如在設計圖上一個 150 * 250 的盒子(單位 px):

原本在 css 中的寫法:

width: 150px;  heigth: 250px;

通過上述換算後,在 css 中對應的 rem 值只需要寫:

width: 1.5rem; // 150 / 100 rem  heigth: 2.5rem; // 250 / 100 rem

如果你的移動端的設計稿是以寬為 1080 px 來設置的話,就用 window.screen.width / 10.8 吧。

9. 踩坑記

  • 1. 讓 vue 識別全局方法/變數
  1. 我們經常在 main.ts 中給 vue.prototype 掛載實例或者內容,以方便在組件裡面使用。
import service from "./utils/https";  import urls from "./utils/urls";    Vue.prototype.$https = service; // 其他頁面在使用 axios 的時候直接  this.$http 就可以了  Vue.prototype.$urls = urls; // 其他頁面在使用 urls 的時候直接  this.$urls 就可以了

然而當你在組件中直接 this.$http 或者 this.$urls 時會報錯的,那是因為 $http 和 $urls 屬性,並沒有在 vue 實例中聲明。

  1. 再比如使用 Element-uI 的 meesage。
import { Message } from "element-ui";    Vue.prototype.$message = Message;

之前用法如下圖:

  this.$message({      message: '恭喜你,這是一條成功消息',      type: 'success'    })

然而還是會報錯的。

再比如 監聽路由的變化:

import { Vue, Watch } from "vue-property-decorator";  import Component from "vue-class-component";  import { Route } from "vue-router";    @Component  export default class App extends Vue {      @Watch("$route")    routeChange(val: Route, oldVal: Route) {        //  do something    }  }

只是這樣寫的話,監聽 $route 還是會報錯的。

想要以上三種做法都正常執行,就還要補充如下內容:

在 src 下的 shims-vue.d.ts 中加入要掛載的內容。 表示 vue 裡面的 this 下有這些東西。

import VueRouter, { Route } from "vue-router";    declare module "vue/types/vue" {    interface Vue {      $router: VueRouter; // 這表示this下有這個東西      $route: Route;      $https: any; // 不知道類型就定為 any 吧(偷懶)      $urls: any;      $Message: any;    }  }
  • 2. 引入的模組要聲明

比如 在組件裡面使用 window.document 或者 document.querySelector 的時候會報錯的,npm run build 不給通過。

再比如:按需引用 element 的組件與動畫組件:

import { Button } from "element-ui";  import CollapseTransition from "element-ui/lib/transitions/collapse-transition";

npm run serve 時可以執行,但是在 npm run build 的時候,會直接報錯的,因為沒有聲明。

正確做法:

我在 src 下新建一個文件 shime-global.d.ts ,加入內容如下:

// 聲明全局的 window ,不然使用 window.XX 時會報錯  declare var window: Window;  declare var document: Document;    declare module "element-ui/lib/transitions/collapse-transition";  declare module "element-ui";

當然,這個文件你加在其他地方也可以,起其他名字都 OK。

但是即使配置了以上方法之後,有些地方使用 document.XXX ,比如 document.title 的時候,npm run build 還是通過不了,所以只能這樣了:

<script lang="ts">  // 在用到 document.XXX  的文件中聲明一下即可  declare var document: any;  // 此處省略 XXXX 多的程式碼  </script>
  • 3. this 的類型檢查

比如之前的 事件的節流(throttle)與防抖(debounce)方法:

export function throttle(fn: Function, delay: number) {    return function() {      // 保留調用時的 this 上下文      let context = this;  }

function 裡面的 this 在 npm run serve 時會報錯的,因為 tyescript 檢測到它不是在類(class)裡面。

正確做法:

在根目錄的 tsconfig.json 裡面加上 "noImplicitThis": false ,忽略 this 的類型檢查。

// 忽略 this 的類型檢查, Raise error on this expressions with an implied any type.  "noImplicitThis": false,
  • 4. import 的 .vue 文件

import .vue 的文件的時候,要補全 .vue 的後綴,不然 npm run build 會報錯的。

比如:

import Nav from "@/components/nav"; // @ is an alias to /src  import Footer from "@/components/footer"; // @ is an alias to /src

要修改為:

import Nav from "@/components/nav.vue"; // @ is an alias to /src  import Footer from "@/components/footer.vue"; // @ is an alias to /src
  • 5. 裝飾器 @Component

報錯。

<script lang="ts">  import { Vue, Component } from "vue-property-decorator";  export default class LoadingCustom extends Vue {}  </script>

以下才是正確,因為這裡的 Vue 是從 vue-property-decorator import 來的。

<script lang="ts">  import { Vue, Component } from "vue-property-decorator";    @Component  export default class LoadingCustom extends Vue {}  </script>
  • 6. 路由的組件導航守衛失效

vue-class-component 官網裡面的路由的導航鉤子的用法是沒有效果的 Adding Custom Hooks

路由的導航鉤子不屬於 Vue 本身,這會導致 class 組件轉義到配置對象時導航鉤子無效,因此如果要使用導航鉤子需要在 router 的配置里聲明(網上別人說的,還沒實踐,不確定是否可行)。

  • 7. tsconfig.json 的 strictPropertyInitialization 設為 false,不然你定義一個變數就必須給它一個初始值。

  • position: sticky;

本項目中的文章詳情的目錄就是用了 sticky。

.anchor {    position: sticky;    top: 213px;    margin-top: 213px;  }

position:sticky 是 css 定位新增屬性;可以說是相對定位 relative 和固定定位 fixed 的結合;它主要用在對 scroll 事件的監聽上;簡單來說,在滑動過程中,某個元素距離其父元素的距離達到 sticky 粘性定位的要求時(比如 top:100px );position:sticky 這時的效果相當於 fixed 定位,固定到適當位置。

用法像上面那樣用即可,但是有使用條件:

1、父元素不能 overflow:hidden 或者 overflow:auto 屬性。
2、必須指定 top、bottom、left、right 4 個值之一,否則只會處於相對定位
3、父元素的高度不能低於 sticky 元素的高度
4、sticky 元素僅在其父元素內生效

  • 8. eslint 報找不到文件和裝飾器的錯

App.vue 中只是寫了引用文件而已,而且 webpack 和 tsconfig.josn 裡面已經配置了別名了的。

import Nav from "@/components/nav.vue"; // @ is an alias to /src  import Slider from "@/components/slider.vue"; // @ is an alias to /src  import Footer from "@/components/footer.vue"; // @ is an alias to /src  import ArrowUp from "@/components/arrowUp.vue"; // @ is an alias to /src  import { isMobileOrPc } from "@/utils/utils";

但是,還是會報如下的錯:

只是程式碼不影響文件的打包,而且本地與生產環境的程式碼也正常,沒報錯而已。

這個 eslint 的檢測目前還沒找到相關的配置可以把這些錯誤去掉。

  • 9. 路由模式修改為 history

因為文章詳情頁面有目錄,點擊目錄時定位定相應的內容,但是這個目錄定位內容是根據錨點來做的,如果路由模式為 hash 模式的話,本來文章詳情頁面的路由就是 #articleDetail 了,再點擊目錄的話(比如 #title2 ),會在 #articleDetail 後面再加上 #title2,一刷新會找不到這個頁面的。

10. Build Setup

 # clone  git clone https://github.com/biaochenxuying/blog-vue-typescript.git
# cd  cd  blog-vue-typescript
# install dependencies  npm install
# Compiles and hot-reloads for development  npm run serve
# Compiles and minifies for production  npm run build
### Run your tests  npm run test
### Lints and fixes files  npm run lint
### Run your unit tests  npm run test:unit

如果要看有後台數據完整的效果,是要和後台項目 blog-node 一起運行才行的,不然介面請求會失敗。

雖然引入了 mock 了,但是還沒有時間做模擬數據,想看具體效果,請穩步到我的網站上查看 https://biaochenxuying.cn

11. 項目地址與系列相關文章

基於 Vue + TypeScript + Element 的 blog-vue-typescript 前台展示: https://github.com/biaochenxuying/blog-vue-typescript

基於 react + node + express + ant + mongodb 的部落格前台,這個是筆者之前做的,效果和這個類似,地址如下:
blog-react 前台展示: https://github.com/biaochenxuying/blog-react

推薦閱讀 :

本部落格系統的系列文章:

12. 最後

筆者也是初學 TS ,如果文章有錯的地方,請指出,感謝。

一開始用 Vue + TS 來搭建時,我也是挺抵觸的,因為踩了好多坑,而且很多類型檢查方面也挺煩人。後面解決了,明白原理之後,是越用越爽,哈哈。

權衡

如何更好的利用 JS 的動態性和 TS 的靜態特質,我們需要結合項目的實際情況來進行綜合判斷。一些建議:

  • 如果是中小型項目,且生命周期不是很長,那就直接用 JS 吧,不要被 TS 束縛住了手腳。
  • 如果是大型應用,且生命周期比較長,那建議試試 TS。
  • 如果是框架、庫之類的公共模組,那更建議用 TS 了。

至於到底用不用TS,還是要看實際項目規模、項目生命周期、團隊規模、團隊成員情況等實際情況綜合考慮。

其實本項目也是小項目來的,其實並不太適合加入 TypeScript ,不過這個項目是個人的項目,是為了練手用的,所以就無傷大大雅。

未來,class-compoent 也將成為主流,現在寫 TypeScript 以後進行 3.0 的遷移會更加方便。

每天下班後,用幾個晚上的時間來寫這篇文章,碼字不易,如果您覺得這篇文章不錯或者對你有所幫助,請給個贊或者星吧,你的點贊就是我繼續創作的最大動力。

參考文章:

  1. vue + typescript 項目起手式

  2. TypeScript + 大型項目實戰

  3. Vue全家桶+TypeScript使用總結