全栈系列-搭建typescript全栈开发环境

1,809 阅读7分钟

本文将会带领大家一步一步搭建自己的typescript全栈脚手架。

项目基本结构为

├── client          // 纯前端代码
├── server          // nodejs代码
├── .gitignore      // git忽略文件名单
├── .prettierrc     // 格式化插件prettier配置文件
├── README.md       // 项目介绍

搭建client层typescript开发环境

近几年,前端市场百花齐放,各类脚手架层出不穷,比较有代表性的有facebook的 create-react-app、vue家族的 vue-cli, 还有国内厂家自研的 umi 等, 本文选择了基于react+antd+dva的脚手架 antd-admin

1. 初始化项目

进入项目根目录, 执行

git clone https://github.com/zuiidea/antd-admin.git client

如果下载很慢, 也可以选择直接下载压缩文件, 然后解压到client文件夹下面

2. 安装依赖

推荐使用yarn, yarn与npm功能类似, 但是更快。

cd client

yarn

3. 启动服务

yarn start

用浏览器打开http://localhost:7000将会看到以下界面

client层脚手架搭建完成, 接下来完成server层脚手架

搭建server层typescript开发环境

1. 初始化项目

进入server文件夹, 执行

cd ../server
yarn init

将会生成package.json

2. 安装依赖

yarn add typescript ts-node-dev express 
yarn add -D @types/express

一般情况下,在nodejs环境下, 如果想运行一个ts文件, 首先要将ts文件编译成js

# 监听文件变化并编译ts文件
tsc -w --locale zh-CN

然后运行编译后的文件

nodemon dist/app.js

现在, 使用ts-node, 可以直接运行ts文件, 不用再生成中间文件

ts-node src/app.ts

如果想在开发环境, 自动监听文件变化, 并重启服务, 可以使用ts-node-dev

ts-node-dev --respawn src/app.ts

3. 创建typescript配置文件

在根目录下新建tsconfig.json文件, typescript编译器会读取这个文件。内容如下

{
  "compilerOptions": {
    "checkJs": true, // 检查数据类型
    "target": "ES2015", // Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'.
    "module": "commonjs", // Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'.
    "lib": [
      "es2015"
    ], // Specify library files to be included in the compilation.
    "allowJs": true, // Allow javascript files to be compiled.
    "jsx": "react", // Specify JSX code generation: 'preserve', 'react-native', or 'react'.
    "resolveJsonModule": true,
    "sourceMap": true, // Generates corresponding '.map' file.
    "outDir": "dist", // Redirect output structure to the directory.
    "strict": true, // Enable all strict type-checking options.
    "noImplicitAny": true, // Raise error on expressions and declarations with an implied 'any' type.
    "strictNullChecks": true, // Enable strict null checks.
    "strictFunctionTypes": true, // Enable strict checking of function types.
    "strictPropertyInitialization": true, // Enable strict checking of property initialization in classes.
    "moduleResolution": "node", // Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6).
    "baseUrl": "./src", // Base directory to resolve non-absolute module names.
    "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
    "paths": {
      "*": [
        "node_modules//",
        "./typings//"
      ],
      "~/src/*": [
        "*"
      ]
    },
    "allowSyntheticDefaultImports": true, // Allow default imports from modules with no default export. This does not affect code emit, just typechecking.
    "esModuleInterop": true, // Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
    "experimentalDecorators": true, // Enables experimental support for ES7 decorators.
    "emitDecoratorMetadata": true // Enables experimental support for emitting type metadata for decorators.
  },
  "include": [
    "./src"
  ],
  "exclude": [
    "node_modules",
    "./src/public"
  ]
}

tsconfig.json配置可参考TypeScript中文手册

4. 创建入口文件

入口文件路径为src/app.ts。 接下来, 让我们完成一个简单的例子 Hello world example

import express from 'express'

const app = express()
const port = 3000

app.get('/', (req, res) => res.send("Hello World!"))

app.listen(port, () => console.log(`Example app listening on port ${port}!`))

5. 启动服务

package.jsonscripts中添加启动命令

{
    "start": "ts-node-dev --respawn src/app.ts"
}

在命令行输入

yarn start

用浏览器打开http://localhost:3000/, 可以看到Hello World!

6. 渲染html

选择 ejs 作为渲染引擎

yarn add ejs 
yarn add -D @types/ejs

修改app.ts

import express from "express"
import path from "path"

const app = express()
const port = 3000

// 设置存放模板引擎目录
app.set("views", path.join(__dirname, "./public"))
// 设置模板引擎为ejs
app.set("view engine", "ejs")

app.get("/", (req, res) =>
  // 渲染模版/public/index.ejs
  res.render("index", { content: "Hello Typescript!" })
)

app.listen(port, () => console.log(`Example app listening on port ${port}!`))

创建src/public文件夹, 在该文件夹下创建一个index.ejs文件, 内容如下

<!DOCTYPE html>
<html lang="zh-cn">

<head>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta charset="utf-8" />
  <meta name="referrer" content="origin">
  <title>typescript + node </title>
</head>

<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <div id="root">
    <%=content%>
  </div>
</body>

</html>

在浏览器刷新页面, 可以看到页面内容为Hello Typescript!

7. 路由最佳实践

路由最佳实践参考 routing-controllers

安装依赖

yarn add routing-controllers reflect-metadata body-parser cookie-parser multer lodash moment module-alias cors class-transformer class-validator 
yarn add -D @types/body-parser @types/multer @types/cookie-parser @types/lodash @types/cors

修改tsconfig.jsoncompilerOptions配置

{
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true
}

修改文件目录结构为

src
├── config          // 配置文件
│   ├── app.ts      
│   └── env.ts      
├── controller      // 控制器
│   ├── api
│   │   └── user.ts
│   ├── base.ts
│   └── index.ts
├── library
│   └── utils
│       └── network.ts
├── middleware      // 中间件
├── model           // 模块
│   ├── user.ts
│   └── validator.ts
├── public          // 静态资源
│    └── index.ejs
└── app.ts          // 项目入口

src/config/** 变量配置

src/config/env.ts 环境定义文件

let env: "dev" | "test" | "prod" = "test"
switch (process.env.NODE_ENV) {
  case "dev":
  case "test":
  case "prod":
    env = process.env.NODE_ENV
    break
  default:
    env = "test"
}

export default env

src/config/app.ts 项目配置文件

import env from "~/src/config/env"

const dev = {
  // 服务启动端口
  port: 7001,
}

const test = {
  port: 9575,
}

const prod = {
  port: 6666,
}

const config = {
  dev,
  test,
  prod,
}

export default config[env]

src/controller/** 控制器

src/controller/base.ts 控制器基类, 封装公共方法, 供其子类使用

/**
 * 项目接口格式约定
 * {
 *    "status": 0,
 *    "message": "success",
 *    "data": {}
 * }
 *
 *  1.  status 请求成功状态码
 *      1.  0 => 响应正常
 *      2.  1 => 响应异常
 *      3.  408 => 未登录
 *      4.  403 => 无权限
 *      5.  404 => 找不到页面
 *      6.  500 => 服务器错误
 *
 *  2.  message 请求状态描述
 *      1.  为空 => 略过该逻辑
 *      2.  存在内容 => 前端打印响应内容
 *
 *  3.  data 请求内容
 */

// 目前的路由最佳实践
// 文档地址 => https://github.com/typestack/routing-controllers

class BaseController {
  protected showResult(data = {}, message = "操作成功", status = 0) {
    return {
      data,
      message,
      status,
    }
  }

  protected showError(message = "操作失败", data = {}, status = 1) {
    return this.showResult(data, message, status)
  }
}
export default BaseController

src/controller/index.ts 控制器入口文件

约定所有的控制器, 通过这个文件导出

const ControllerList: Function[] = []

export default ControllerList

src/library/** 工具类

src/library/utils/network.ts 封装了网络相关的方法

import os from "os"

class Tool {
  /**
   * 获取本机ip via https://stackoverflow.com/a/8440736
   */
  static getLocalIpList() {
    let networkInterfaceList = os.networkInterfaces()

    let localIpList = ["127.0.0.1"]

    for (let networkInterface of Object.keys(networkInterfaceList)) {
      for (let interfaceInfo of networkInterfaceList[
        networkInterface
      ] as os.NetworkInterfaceInfo[]) {
        if (
          interfaceInfo.family !== "IPv4" ||
          interfaceInfo.internal !== false
        ) {
          // skip over internal (i.e. 127.0.0.1) and non-ipv4 addresses
          continue
        }
        let ip = interfaceInfo.address
        localIpList.push(ip)
      }
    }
    return localIpList
  }
}

export default Tool

src/model/** 模块

将通用模块, 抽取到这个文件夹下面,以功能作为类名。例如

  • user.ts 封装user相关的方法
  • validator.ts 封装验证器相关方法

src/public/** 静态资源

静态资源文件夹

9. 开始开发

接下来, 写一个user相关的增、删、改、查接口.

user模块

为了简化示例, 用本地变量代替数据库的功能

新建src/model/user.ts文件

import _ from "lodash"

type IUser = {
  id: number
  name: string
  sex: string
}

export default class MUser {
  // 用户列表
  static list: IUser[] = [
    {
      id: 1234,
      name: "丹妮莉丝·坦格利安",
      sex: "女",
    },
    {
      id: 1235,
      name: "珊莎·史塔克",
      sex: "女",
    },
    {
      id: 1235,
      name: "艾莉亚·史塔克",
      sex: "女",
    },
  ]

  /**
   * 获取用户详情
   */
  static async getDetail(id: string) {
    return this.list.find((v) => v.id === +id)
  }

  /**
   * 获取用户列表
   */
  static async getList() {
    return this.list
  }

  /**
   * 修改用户信息
   */
  static async update(id: string, options: Partial<Omit<IUser, "id">>) {
    this.list = this.list.map((v) => {
      if (v.id === +id) {
        Object.assign(v, options)
      }
      return v
    })
  }

  /**
   * 删除用户信息
   */
  static async delete(id: string) {
    this.list = this.list.filter((v) => v.id !== +id)
  }
}

user控制器

新建src/controller/api/user.ts文件

import { Request, Response } from "express"
import { JsonController, Req, Res, Get, Post } from "routing-controllers"

import BaseController from "~/src/controller/base"
import MUser from "~/src/model/user"
import MValidator from "~/src/model/validator"

@JsonController()
class Controller extends BaseController {
  @Get("/api/user/info")
  async info(@Req() request: Request, @Res() response: Response) {
    const { id } = request.query
    MValidator.checkRequiredFields(["id"], request.query)
    const res = await MUser.getDetail(id as string)
    return this.showResult(res)
  }

  @Get("/api/user/list")
  async list(@Req() request: Request, @Res() response: Response) {
    const res = await MUser.getList()
    return this.showResult(res)
  }

  @Post("/api/user/update")
  async update(@Req() request: Request, @Res() response: Response) {
    const { id, name } = request.body
    MValidator.checkRequiredFields(["id", "name"], request.body)
    await MUser.update(id, { name })
    return this.showResult({ id, name }, `更新成功`)
  }

  @Post("/api/user/delete")
  async delete(@Req() request: Request, @Res() response: Response) {
    const { id } = request.body
    MValidator.checkRequiredFields(["id"], request.body)
    await MUser.delete(id)
    return this.showResult({ id }, `删除成功`)
  }
}

export default Controller

上面的代码用到了装饰器语法

  • JsonController 让接口返回值转换为JSON, 并且设置响应头Content-Type: application/json
  • Get 匹配method为get请求
  • Post 匹配method为post请求
  • Req express request 对象
  • Res express response 对象

使用控制器

src/controller/index.ts引入user.ts

import ApiUser from "~/src/controller/api/user"

const ControllerList: Function[] = [ApiUser]

export default ControllerList

修改src/app.ts

// 路径别名
require("module-alias").addAlias("~/src", __dirname + "/")

// this shim is required
import "reflect-metadata" 
import express from "express"
import { useExpressServer } from "routing-controllers"
import cookieParser from "cookie-parser"
import bodyParser from "body-parser"
import path from "path"
import _ from "lodash"
import moment from "moment"
import chalk from "chalk"

import ControllerList from "~/src/controller/index"
import NetworkUtil from "~/src/library/utils/network"
import appConfig from "~/src/config/app"

let server
const startup = () => {
  // 需要先创建express实例, 注册完中间层之后再将路由交给routing-controllers
  // 否则后续的中间层代码都拿不到cookie/body, 导致程序异常
  const app = express()
  // 设置body-parser
  app.use(bodyParser.urlencoded({ extended: false }))
  // 解析json请求
  app.use(bodyParser.json())
  // 设置cookie-parse
  app.use(cookieParser())
  // 设置存放模板引擎目录
  app.set("views", path.join(__dirname, "./public"))
  // 设置模板引擎为ejs
  app.set("view engine", "ejs")

  /* 添加静态路径 */
  app.use("/public", express.static(path.join(__dirname, "public")))
  app.use(
    "/favicon.ico",
    express.static(path.resolve(__dirname, "./public/favicon.ico"))
  )

  useExpressServer(app, {
    // 支持跨域
    cors: {
      origin: function (origin: any, callback: any) {
        callback(null, origin)
      },
      methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"],
      credentials: false,
      allowedHeaders: [
        "Origin",
        "X-Requested-With",
        "X-Custom-Session",
        "X-Hetu-Token",
        "Content-Type",
        "Accept",
      ],
    },
    controllers: ControllerList,
    middlewares: [],
    defaultErrorHandler: false,
  })

  let serverStartAtYmdHis = moment().format("YYYY-MM-DD HH:mm:ss")
  console.log(chalk.magenta(`服务启动于=>${serverStartAtYmdHis}`))
  let ipList = NetworkUtil.getLocalIpList()
  server = app.listen(appConfig.port, function () {
    console.log(`listening on port ${appConfig.port}`)
    console.log(`↓↓↓↓↓点击任意链接打开调试界面↓↓↓↓↓`)
    console.log(``)
    for (let ip of ipList) {
      let uri = `http://${ip}:${appConfig.port}/api/user/info`
      console.log(chalk.green(`${uri}`))
    }
    console.log(``)
  })
}

startup()

重启服务

yarn start

使用Postman测试上面的增、删、改、查的接口

  • Get http://127.0.0.1:7001/api/user/info?id=1234
  • Get http://127.0.0.1:7001/api/user/list
  • Post http://127.0.0.1:7001/api/user/update
  • Post http://127.0.0.1:7001/api/user/delete

参考