前段時間有寫過一個TypeScript在node專案中的實踐。
在裡邊有解釋了為什麼要使用TS
,以及在Node
中的一個專案結構是怎樣的。
但是那僅僅是一個純介面專案,碰巧趕上近期的另一個專案重構也由我來主持,經過上次的實踐以後,嚐到了TS
所帶來的甜頭,毫不猶豫的選擇用TS
+React
來重構這個專案。
這次的重構不僅包括Node
的重構(之前是Express
的專案),同時還包括前端的重構(之前是由jQuery
驅動的多頁應用)。
專案結構
因為目前專案是沒有做前後分離的打算的(一個內部工具平臺類的專案),所以大致結構就是基於上次Node
專案的結構,在其之上新增了一些FrontEnd
的目錄結構:
.
├── 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-dist
與views
都是通過webpack
生成的,實際的原始碼檔案都在client-src
下。就這個結構拆分前後分離其實沒有什麼成本
在下邊分了大概這樣的一些資料夾:
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
的是,這裡的程式碼是前後端共享的,所以這裡邊的函式一定要是完全的不包含任何環境依賴,不包含任何業務邏輯的。
類似的數字千分位,日期格式化,抑或是服務監聽的埠號,這些不包含任何邏輯,也對環境沒有強依賴的程式碼,我們都可以放在這裡。
這也是沒有做前後分離帶來的一個小甜頭吧,前後可以共享一部分程式碼。
要實現這樣的配置,基於上述專案需要修改如下幾處:
src
下的utils
和config
部分程式碼遷移到common
資料夾下,主要是用於區分是否可前後通用- 為了將對之前
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
複製程式碼
- 然後是配置
webpack
的alias
屬性,用於webpack
能夠正確的找到其路徑
// client-src/webpack/base.js
module.exports = {
resolve: {
alias: {
'@Common': path.resolve(__dirname, '../../src/common'),
}
}
}
複製程式碼
- 同時我們還需要配置
tsconfig.json
用於vs code
可以找到對應的目錄,不然會在編輯器中提示can't find module XXX
// client-src/tsconfig.json
{
"compilerOptions": {
"paths": {
// 用於引入某個`module`
"@Common/*": [
"../src/common/*"
]
}
}
}
複製程式碼
- 最後在
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
的規則:
"eslint.validate": [
"javascript",
"javascriptreact",
{
"language": "typescript",
"autoFix": true
},
{
"language": "typescriptreact",
"autoFix": true
}
]
複製程式碼
webpack的配置
因為在前端使用了React
,按照目前的主流,webpack
肯定是必不可少的。
並沒有選擇成熟的cra
(create-react-app)來進行環境搭建,原因有下:
webpack
更新到4以後並沒有嘗試過,想自己耍一耍- 結合著
TS
以及公司內部的東西,會有一些自定義配置情況的出現,擔心二次開發太繁瑣
但是其實也沒有太多的配置,本次重構選用的UI框架為Google Material的實現:material-ui
而他們採用的是jss 來進行樣式的編寫,所以也不會涉及到之前慣用的scss
的那一套loader
了。
webpack
分了大概如下幾個檔案:
file | desc |
---|---|
common.js |
公共的webpack 配置,類似env 之類的選項 |
dll.js |
用於將一些不會修改的第三方庫進行提前打包,加快開發時編譯效率 |
base.js |
可以理解為是webpack 的基礎配置檔案,通用的loader 以及plugins 在這裡 |
pro.js |
生產環境的特殊配置(程式碼壓縮、資源上傳) |
dev.js |
開發環境的特殊配置(source-map ) |
dll
是一個很早之前的套路了,大概需要修改這麼幾處:
- 建立一個單獨的
webpack
檔案,用於生成dll
檔案 - 在普通的
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
,針對ts
、tsx
檔案我們使用了兩個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的配置
最近這段時間,我們團隊基於airbnb
的ESLint
規則進行了一些自定義,建立了自家的eslint-config-blued
同時還存在了react和typescript的兩個衍生版本。
關於ESLint
的配置檔案.eslintrc
,在本專案中存在兩份。一個是根目錄的blued-typescript
,另一個是client-src
下的blued-react
+ blued-typescript
。
因為根目錄的更多用於node
專案,所以沒必要把react
什麼的依賴也裝進來。
# .eslintrc
extends: blued-typescript
# client-src/.eslintrc
extends:
- blued-react
- blued-typescript
複製程式碼
一個需要注意的小細節
因為我們的react
與typescript
實現版本中都用到了parser
。
react
使用的是babel-eslint,typescript
使用的是typescript-eslint-parser。
但是parser
只能有一個,從option
的命名中就可以看出extends
、plugins
、rules
,到了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',
}))
複製程式碼
如果是多個頁面,那就建立多個用來Render
的ts
檔案就好了
深坑,注意
目前的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
歡迎砸簡歷