TypeScript在react項目中的實踐

前段時間有寫過一個TypeScript在node項目中的實踐
在裏邊有解釋了爲何要使用TS,以及在Node中的一個項目結構是怎樣的。
可是那僅僅是一個純接口項目,碰巧遇上近期的另外一個項目重構也由我來主持,通過上次的實踐之後,嚐到了TS所帶來的甜頭,堅決果斷的選擇用TS+React來重構這個項目。
此次的重構不只包括Node的重構(以前是Express的項目),同時還包括前端的重構(以前是由jQuery驅動的多頁應用)。javascript

項目結構

由於目前項目是沒有作先後分離的打算的(一個內部工具平臺類的項目),因此大體結構就是基於上次Node項目的結構,在其之上添加了一些FrontEnd的目錄結構:css

.
  ├── README.md
  ├── copy-static-assets.ts
  ├── nodemon.json
  ├── package.json
+ ├── client-dist
+ │   ├── bundle.js
+ │   ├── bundle.js.map
+ │   ├── logo.png
+ │   └── vendors.dll.js
  ├── dist
  ├── src
  │   ├── config
  │   ├── controllers
  │   ├── entity
  │   ├── models
  │   ├── middleware
  │   ├── public
  │   ├── app.ts
  │   ├── server.ts
  │   ├── types
+ │   ├── common
  │   └── utils
+ ├── client-src
+ │   ├── components
+ │ │ └── Header.tsx
+ │   ├── conf
+ │ │ └── host.ts
+ │   ├── dist
+ │   ├── utils
+ │   ├── index.ejs
+ │   ├── index.tsx
+ │   ├── webpack
+ │   ├── package.json
+ │   └── tsconfig.json
+ ├── views
+ │ └── index.ejs
  ├── tsconfig.json
  └── tslint.json
複製代碼

其中標綠(也多是一個+號顯示)的文件爲本次新增的。
其中client-distviews都是經過webpack生成的,實際的源碼文件都在client-src下。就這個結構拆分先後分離其實沒有什麼成本
在下邊分了大概這樣的一些文件夾:html

dir/file desc
index.ejs 項目的入口html文件,採用ejs做爲渲染引擎
index.tsx 項目的入口js文件,後綴使用tsx,緣由有二:
1. 咱們會使用ts進行React程序的開發
2. .tsx文件在vs code上的icon比較好看 :p
tsconfig.json 是用於tsc編譯執行的一些配置文件
components 組件存放的目錄
config 各類配置項存放的位置,相似請求接口的host或者各類狀態的map映射之類的(能夠理解爲枚舉對象們都在這裏)
utils 一些公共函數存放的位置,各類可複用的代碼都應該放在這裏
dist 各類靜態資源的存放位置,圖片之類文件
webpack 裏邊存放了各類環境的webpack腳本命令以及dll的生成

先後端複用代碼的一個嘗試

實際上邊還漏掉了一個新增的文件夾,咱們在src目錄下新增了一個common目錄,這個目錄是存放一些公共的函數和公共的config,不一樣於utils或者config的是,這裏的代碼是先後端共享的,因此這裏邊的函數必定要是徹底的不包含任何環境依賴,不包含任何業務邏輯的。前端

相似的數字千分位,日期格式化,抑或是服務監聽的端口號,這些不包含任何邏輯,也對環境沒有強依賴的代碼,咱們均可以放在這裏。
這也是沒有作先後分離帶來的一個小甜頭吧,先後能夠共享一部分代碼。java

要實現這樣的配置,基於上述項目須要修改以下幾處:node

  1. src下的utilsconfig部分代碼遷移到common文件夾下,主要是用於區分是否可先後通用
  2. 爲了將對以前node結構方面的影響降至最低,咱們須要在common文件夾下新增一個index.ts索引文件,並在utils/index.ts下引用它,這樣對於node方面使用來說,並不須要關心這個文件是來自utils仍是common
// src/common/utils/comma.ts
export default (num: number): string => String(num).replace(/\B(?=(\d{3})+$)/g, ',')

// src/common/utils/index.ts
export { default as comma } from './comma'

// src/utils.index.ts
export * from '../common/utils'

// src/app.ts
import { comma } from './utils' // 並不須要關心是來自common仍是來自utils

console.log(comma(1234567)) // 1,234,567
複製代碼
  1. 而後是配置webpackalias屬性,用於webpack可以正確的找到其路徑
// client-src/webpack/base.js
module.exports = {
  resolve: {
    alias: {
       '@Common': path.resolve(__dirname, '../../src/common'),
    }
  }
}
複製代碼
  1. 同時咱們還須要配置tsconfig.json用於vs code能夠找到對應的目錄,否則會在編輯器中提示can't find module XXX
// client-src/tsconfig.json
{
  "compilerOptions": {
    "paths": {
      // 用於引入某個`module`
      "@Common/*": [
        "../src/common/*"
      ]
    }
  }
}
複製代碼
  1. 最後在client-src/utils/index.ts寫上相似server端的處理就能夠了
// client-src/utils/index.ts
export * from '@Common/utils'

// client-src/index.tsx
import { comma } from './utils'

console.log(comma(1234567)) // 1,234,567
複製代碼

環境的搭建

若是使用vs code進行開發,並且使用了ESLint的話,須要修改TS語法支持的後綴,添加typescriptreact的一些處理,這樣纔會自動修復一些ESLint的規則:react

"eslint.validate": [
  "javascript",
  "javascriptreact",
  {
    "language": "typescript",
    "autoFix": true
  },
  {
    "language": "typescriptreact",
    "autoFix": true
  }
]
複製代碼

webpack的配置

由於在前端使用了React,按照目前的主流,webpack確定是必不可少的。
並無選擇成熟的cra(create-react-app)來進行環境搭建,緣由有下:webpack

  1. webpack更新到4之後並無嘗試過,想本身耍一耍
  2. 結合着TS以及公司內部的東西,會有一些自定義配置狀況的出現,擔憂二次開發太繁瑣

可是其實也沒有太多的配置,本次重構選用的UI框架爲Google Material的實現:material-ui
而他們採用的是jss 來進行樣式的編寫,因此也不會涉及到以前慣用的scss的那一套loader了。git

webpack分了大概以下幾個文件:es6

file desc
common.js 公共的webpack配置,相似env之類的選項
dll.js 用於將一些不會修改的第三方庫進行提早打包,加快開發時編譯效率
base.js 能夠理解爲是webpack的基礎配置文件,通用的loader以及plugins在這裏
pro.js 生產環境的特殊配置(代碼壓縮、資源上傳)
dev.js 開發環境的特殊配置(source-map

dll是一個很早以前的套路了,大概須要修改這麼幾處:

  1. 建立一個單獨的webpack文件,用於生成dll文件
  2. 在普通的webpack文件中進行引用生成的dll文件
// dll.js
{
  entry: {
    // 須要提早打包的庫
    vendors: [
      'react',
      'react-dom',
      'react-router-dom',
      'babel-polyfill',
    ],
  },
  output: {
    filename: 'vendors.dll.js',
    path: path.resolve(__dirname, '../../client-dist'),
    // 輸出時不要少了這個option
    library: 'vendors_lib',
  },
  plugins: [
    new webpack.DllPlugin({
      context: __dirname,
      // 向外拋出的`vendors.dll.js`代碼的具體映射,引用`dll`文件的時候經過它來作映射關係的
      path: path.join(__dirname, '../dist/vendors-manifest.json'),
      name: 'vendors_lib',
    })
  ]
}

// base.js
{
  plugins: [
    new webpack.DllReferencePlugin({
      context: __dirname,
      manifest: require('../dist/vendors-manifest.json'),
    }),
  ]
}
複製代碼

這樣在watch文件時,打包就會跳過verdors中存在的那些包了。
有一點要注意的,若是最終須要上傳這些靜態資源,記得連帶着verdors.dll.js一併上傳

在本地開發時,vendors文件並不會自動注入到html模版中去,因此咱們有用到了另外一個插件,add-asset-html-webpack-plugin。 同時在使用中可能還會遇到webpack無限次數的從新打包,這個須要配置ignore來解決-.-:

// dev.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin')

{
  plugins: [
    // 將`ejs`模版文件放到目標文件夾,並注入入口`js`文件
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../index.ejs'),
      filename: path.resolve(__dirname, '../../views/index.ejs'),
    }),
    // 將`vendors`文件注入到`ejs`模版中
    new AddAssetHtmlPlugin({
      filepath: path.resolve(__dirname, '../../client-dist/vendors.dll.js'),
      includeSourcemap: false,
    }),
    // 忽略`ejs`和`js`的文件變化,避免`webpack`無限從新打包的問題
    new webpack.WatchIgnorePlugin([
      /\.ejs$/,
      /\.js$/,
    ]),
  ]
}
複製代碼

TypeScript相關的配置

TS的配置分了兩塊,一個是webpack的配置,另外一個是tsconfig的配置。

首先是webpack,針對tstsx文件咱們使用了兩個loader

{
  rules: [
    {
      test: /\.tsx?$/,
      use: ['babel-loader', 'ts-loader'],
      exclude: /node_modules/,
    }
  ],
  resolve: {
    // 必定不要忘記配置ts tsx後綴
    extensions: ['.tsx', '.ts', '.js'],
  }
}
複製代碼

ts-loader用於將TS的一些特性轉換爲JS兼容的語法,而後執行babel進行處理react/jsx相關的代碼,最終生成可執行的JS代碼。

而後是tsconfig的配置,ts-loader的執行是依託於這裏的配置的,大體的配置以下:

{
  "compilerOptions": {
    "module": "esnext",
    "target": "es6",
    "allowSyntheticDefaultImports": true,
    // import的相對起始路徑
    "baseUrl": ".",
    "sourceMap": true,
    // 構建輸出目錄,但由於使用了`webpack`,因此這個配置並無什麼卵用
    "outDir": "../client-dist",
    // 開啓`JSX`模式, 
    // `preserve`的配置讓`tsc`不會去處理它,而是使用後續的`babel-loader`進行處理
    "jsx": "preserve", 
    "strict": true,
    "moduleResolution": "node",
    // 開啓裝飾器的使用
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    // `vs code`所須要的,在開發時找到對應的路徑,真實的引用是在`webpack`中配置的`alias`
    "paths": {
      "@Common": [
        "../src/common"
      ],
      "@Common/*": [
        "../src/common/*"
      ]
    }
  },
  "exclude": [
    "node_modules"
  ]
}
複製代碼

ESLint的配置

最近這段時間,咱們團隊基於airbnbESLint規則進行了一些自定義,建立了自家的eslint-config-blued
同時還存在了reacttypescript的兩個衍生版本。

關於ESLint的配置文件.eslintrc,在本項目中存在兩份。一個是根目錄的blued-typescript,另外一個是client-src下的blued-react + blued-typescript
由於根目錄的更多用於node項目,因此不必把react什麼的依賴也裝進來。

# .eslintrc
extends: blued-typescript

# client-src/.eslintrc
extends: 
 - blued-react
 - blued-typescript
複製代碼

一個須要注意的小細節
由於咱們的reacttypescript實現版本中都用到了parser
react使用的是babel-eslinttypescript使用的是typescript-eslint-parser
可是parser只能有一個,從option的命名中就能夠看出extendspluginsrules,到了parser就沒有複數了。
因此這兩個插件在extends中的順序就變得很關鍵,babel如今並不能理解TS的語法,但好像babel開發者有支持TS意願
但就目前來講,必定要保證react在前,typescript在後,這樣parser纔會使用typescript-eslint-parser來進行覆蓋。

node層的修改

除了上邊提到的兩端公用代碼之外,還須要添加一個controller用於吐頁面,由於使用的是routing-controllers這個庫,渲染一個靜態頁面被封裝的很是棒,僅僅須要修改兩個頁面,一個用於設置render模版的根目錄,另外一個用來設置要吐出來的模版名稱:

// controller/index.ts
import {
  Get,
  Controller,
  Render,
} from 'routing-controllers'

@Controller('/')
export default class {
  @Get('/')
  @Render('index') // 指定一個模版的名字
  async router() {
    // 渲染頁面時的一些變量
    // 相似以前的 ctx.state = XXX
    return {
      title: 'First TypeScript React App',
    }
  }
}

// app.ts
import koaViews from 'koa-views'

// 添加模版所在的目錄
// 以及使用的渲染引擎、文件後綴
app.use(koaViews(path.join(__dirname, '../views'), {
  options: {
    ext: 'ejs',
  },
  extension: 'ejs',
}))
複製代碼

若是是多個頁面,那就建立多個用來Renderts文件就行了

深坑,注意

目前的routing-controller對於Koa的支持還不是很好,(原做者對Koa並非很瞭解,致使Render對應的接口被請求一次之後,後續全部的其餘的接口都會直接返回該模版文件,緣由是在負責模版渲染的URL觸發時,本應返回數據,可是目前的處理倒是添加了一箇中間件到Koa中,因此任何請求都會將該模版文件做爲數據來返回)因此@Render並不能適用於Koa驅動。
不過我已經提交了PR了,跑通了測試用例,坐等被合併代碼,可是這是一個臨時的修改方案,涉及到這個庫針對外部中間件註冊的順序問題,因此對於app.ts還要有額外的修改纔可以實現。

// app.ts 的修改
import 'reflect-metadata'
import Koa from 'koa'
import koaViews from 'koa-views'
import { useKoaServer } from 'routing-controllers'
import { distPath } from './config'

// 手動建立koa實例,而後添加`render`的中間件,確保`ctx.render`方法會在請求的頭部就被添加進去
const koa = new Koa()

koa.use(koaViews(path.join(__dirname, '../views'), {
  options: {
    ext: 'ejs',
  },
  extension: 'ejs',
}))

// 使用`useKoaServer`而不是`createKoaServer`
const app = useKoaServer(koa, {
  controllers: [`${__dirname}/controllers/**/*{.js,.ts}`],
})

// 後續的邏輯就都同樣了
export default app
複製代碼

固然,這個是新版發出之後的邏輯了,基於現有的結構也能夠繞過去,可是就不能使用@Render裝飾器了,拋開koa-views直接使用內部的consolidate

// controller/index.ts
// 這個修改不須要改動`app.ts`,能夠直接使用`createKoaServer`
import {
  Get,
  Controller,
} from 'routing-controllers'
import cons from 'consolidate'
import path from 'path'

@Controller()
export default class {
  @Get('/')
  async router() {
    // 直接在接口返回時獲取模版渲染後的數據
    return cons.ejs(path.resolve(__dirname, '../../views/index.ejs'), {
      title: 'Example For TypeScript React App',
    })
  }
}
複製代碼

目前的示例代碼採用的上邊的方案

小結

至此,一個完整的TS先後端項目架構就已經搭建完成了(剩下的任務就是往骨架裏邊填代碼了)。
我已經更新了以前的typescript-exmaple 在裏邊添加了本次重構所使用的一些前端TS+React的示例,還包括針對@Render的一些兼容。

TypeScript是一個很棒的想法,解決了N多javaScript種使人詬病的問題。
使用靜態語言來進行開發不只可以提升開發的效率,同時還能下降錯誤出現的概率。
結合着強大的vs code,Enjoy it.

若是在使用TS的過程當中有什麼問題、或者有什麼更好的想法,歡迎來溝通討論。

One more things

Blued前端/Node團隊招人。。初中高都有HC
座標帝都朝陽雙井,有興趣的請聯繫我:
wechat: github_jiasm
mail: jiashunming@blued.com

歡迎砸簡歷

相關文章
相關標籤/搜索