从0搭建一套属于自己的博客系统

595
4月23日 · 2021年

前言

因为本身自己几年前就搭建了自己的博客系统,那时候博客系统前端基本上都是基于vue的,而现在用的react偏多一点,所有我对整个博客系统进行了一次重构,还有对以前存在的很多问题进行了更改与优化。系统都进行了服务端渲染SSR的处理。

博客地址传送门

本项目完整的代码:GitHub 仓库

本文篇幅较长,会从以下几个方面进行展开介绍:

  1. 核心技术栈
  2. 目录结构详解
  3. 项目架构搭建
  4. Server端源码解析
  5. Client端源码解析
  6. Admin端源码解析
  7. HTTPS创建

核心技术栈

  1. React 17.x (React 全家桶)
  2. Typescript 4.x
  3. Koa 2.x
  4. Webpack 5.x
  5. Babel 7.x
  6. Mongodb (数据库)
  7. eslint + stylelint + prettier (进行代码格式控制)
  8. husky + lint-staged + commitizen +commitlint (进行 git 提交的代码格式校验跟 commit 流程校验)

核心大概就是以上的一些技术栈,然后基于博客的各种需求进行功能开发

像例如授权用到的jsonwebtoken,@loadable,log4js模块等等一些功能,我会下面各个功能模块展开篇幅进行讲解。

package.json 配置文件地址

目录结构详解

|-- blog-source
    |-- .babelrc.js   // babel配置文件
    |-- .commitlintrc.js // git commit格式校验文件,commit格式不通过,禁止commit
    |-- .cz-config.js // cz-customizable的配置文件。我采用的cz-customizable来做的commit规范,自己自定义的一套
    |-- .eslintignore // eslint忽略配置
    |-- .eslintrc.js // eslint配置文件
    |-- .gitignore // git忽略配置
    |-- .npmrc // npm配置文件
    |-- .postcssrc.js // 添加样式前缀之类的东西
    |-- .prettierrc.js // 格式代码用的,统一风格
    |-- .sentryclirc // 项目埋点监控,开源的
    |-- .stylelintignore // style忽略配置
    |-- .stylelintrc.js // stylelint配置文件
    |-- package.json
    |-- tsconfig.base.json // ts配置文件
    |-- tsconfig.json // ts配置文件
    |-- tsconfig.server.json // ts配置文件
    |-- build // Webpack构建目录, 分别给client端,admin端,server端进行区别构建
    |   |-- paths.ts
    |   |-- utils.ts
    |   |-- config
    |   |   |-- dev.ts
    |   |   |-- index.ts
    |   |   |-- prod.ts
    |   |-- webpack
    |       |-- admin.base.ts
    |       |-- admin.dev.ts
    |       |-- admin.prod.ts
    |       |-- base.ts
    |       |-- client.base.ts
    |       |-- client.dev.ts
    |       |-- client.prod.ts
    |       |-- index.ts
    |       |-- loaders.ts
    |       |-- plugins.ts
    |       |-- server.base.ts
    |       |-- server.dev.ts
    |       |-- server.prod.ts
    |-- dist // 打包output目录
    |-- logs // 日志打印目录
    |-- private // 静态资源入口目录,设置了多个
    |   |-- third-party-login.html
    |-- publice // 静态资源入口目录,设置了多个
    |-- scripts // 项目执行脚本,包括启动,打包等等
    |   |-- build.ts
    |   |-- config.ts
    |   |-- dev.ts
    |   |-- start.ts
    |   |-- utils.ts
    |   |-- plugins
    |       |-- open-browser.ts
    |       |-- webpack-dev.ts
    |       |-- webpack-hot.ts
    |-- src // 核心源码
    |   |-- client // 客户端代码
    |   |   |-- main.tsx // 入口文件
    |   |   |-- tsconfig.json // ts配置
    |   |   |-- api // api接口
    |   |   |-- app // 入口组件
    |   |   |-- appComponents // 业务组件
    |   |   |-- assets // 静态资源
    |   |   |-- components // 公共组件
    |   |   |-- config // 客户端配置文件
    |   |   |-- contexts // context, 就是用useContext创建的,用来组价共享状态的
    |   |   |-- global // 全局进入client需要进行调用的方法。像类似window上的方法
    |   |   |-- hooks // react hooks
    |   |   |-- pages // 页面
    |   |   |-- router // 路由
    |   |   |-- store // Store目录
    |   |   |-- styles // 样式文件
    |   |   |-- theme // 样式主题文件,做换肤效果的
    |   |   |-- types // ts类型文件
    |   |   |-- utils // client端的工具方法
    |   |-- admin // 后台管理端代码
    |   |   |-- .babelrc.js
    |   |   |-- app.tsx
    |   |   |-- main.tsx
    |   |   |-- tsconfig.json
    |   |   |-- api
    |   |   |-- appComponents
    |   |   |-- assets
    |   |   |-- components
    |   |   |-- config
    |   |   |-- hooks
    |   |   |-- pages
    |   |   |-- router
    |   |   |-- store
    |   |   |-- styles
    |   |   |-- types
    |   |   |-- utils
    |   |-- models // 接口模型
    |   |-- server // 服务端代码
    |   |   |-- main.ts // 入口文件
    |   |   |-- config // 配置文件
    |   |   |-- controllers // 控制器
    |   |   |-- database // 数据库
    |   |   |-- decorators // 装饰器,封装了@Get,@Post,@Put,@Delete,@Cookie之类的
    |   |   |-- middleware // 中间件
    |   |   |-- models // mongodb模型
    |   |   |-- router // 路由、接口
    |   |   |-- ssl // https证书,目前我是本地开发用的,线上如果用nginx的话,在nginx处配置就行
    |   |   |-- ssr // 页面ssr处理
    |   |   |-- timer // 定时器
    |   |   |-- utils // server端的工具方法
    |   |-- shared // 多端共享的代码
    |   |   |-- loadInitData.ts
    |   |   |-- type.ts
    |   |   |-- config
    |   |   |-- utils
    |   |-- types // ts类型文件
    |-- static // 静态资源
    |-- template // html模板
复制代码

以上就是项目大概的文件目录,文件大概介绍基本说明了,下面我会详细博客功能的实现过程。目前博客系统各端没有拆分出来,接下里有这个打算。

项目架构搭建

确保你的node版本在10.13.0 (LTS)以上,因为Webpack 5Node.js 的版本要求至少是 10.13.0 (LTS)

执行脚本,启动项目

首先从入口文件开始:

"dev": "cross-env NODE_ENV=development TS_NODE_PROJECT=tsconfig.server.json ts-node --files scripts/start.ts"
"prod": "cross-env NODE_ENV=production TS_NODE_PROJECT=tsconfig.server.json ts-node --files scripts/start.ts"json 复制代码

1. 执行入口文件scripts/start.js

// scripts/start.js
import path from 'path'
import moduleAlias from 'module-alias'

moduleAlias.addAliases({
  '@root': path.resolve(__dirname, '../'),
  '@server': path.resolve(__dirname, '../src/server'),
  '@client': path.resolve(__dirname, '../src/client'),
  '@admin': path.resolve(__dirname, '../src/admin'),
})

if (process.env.NODE_ENV === 'production') {
  require('./build')
} else {
  require('./dev')
}typescript 复制代码

设置路径别名,因为目前各端没有拆分,所以建立别名(alias)好查找文件。

2. 由入口文件进入开发development环境的搭建

首先导出webpack各端的各自环境的配置文件。

// dev.ts
import clientDev from './client.dev'
import adminDev from './admin.dev'
import serverDev from './server.dev'
import clientProd from './client.prod'
import adminProd from './admin.prod'
import serverProd from './server.prod'
import webpack from 'webpack'

export type Configuration = webpack.Configuration & {
  output: {
    path: string
  }
  name: string
  entry: any
}
export default (NODE_ENV: ENV): [Configuration, Configuration, Configuration] => {
  if (NODE_ENV === 'development') {
    return [clientDev as Configuration, serverDev as Configuration, adminDev as Configuration]
  }
  return [clientProd as Configuration, serverProd as Configuration, adminProd as Configuration]
}
typescript 复制代码

webpack的配置文件,基本不会有太大的区别,目前就贴一段简单的webpack配置,分别有 server,client,admin 不同环境的配置文件。具体可以看博客源码

import webpack from 'webpack'
import merge from 'webpack-merge'
import { clientPlugins } from './plugins' // plugins配置
import { clientLoader } from './loaders' // loaders配置
import paths from '../paths'
import config from '../config'
import createBaseConfig from './base' // 多端默认配置

const baseClientConfig: webpack.Configuration = merge(createBaseConfig(), {
  mode: config.NODE_ENV,
  context: paths.rootPath,
  name: 'client',
  target: ['web', 'es5'],
  entry: {
    main: paths.clientEntryPath,
  },
  resolve: {
    extensions: ['.js', '.json', '.ts', '.tsx'],
    alias: {
      '@': paths.clientPath,
      '@client': paths.clientPath,
      '@root': paths.rootPath,
      '@server': paths.serverPath,
    },
  },
  output: {
    path: paths.buildClientPath,
    publicPath: paths.publicPath,
  },
  module: {
    rules: [...clientLoader],
  },
  plugins: [...clientPlugins],
})
export default baseClientConfigtypescript 复制代码

然后分别来处理adminclientserver端的webpack配置文件

以上几个点需要注意:

  • admin端跟client端分别开了一个服务处理webpack的文件,都打包在内存中。
  • client端需要注意打包出来文件的引用路径,因为是SSR,需要在服务端获取文件直接渲染,服务端跟客户端在不同的两个服务,所以在服务端引用client端文件的时候需要注意引用路径。
  • server端代码直接打包在dist文件下,用于启动,并没有打在内存中。
const WEBPACK_URL = `${__WEBPACK_HOST__}:${__WEBPACK_PORT__}`
const [clientWebpackConfig, serverWebpackConfig, adminWebpackConfig] = getConfig(process.env.NODE_ENV as ENV)
// 构建client 跟 server
const start = async () => {
  // 因为client指向的另一个服务,所以重写publicPath路径,不然会404
  clientWebpackConfig.output.publicPath = serverWebpackConfig.output.publicPath = `${WEBPACK_URL}${clientWebpackConfig.output.publicPath}`
  clientWebpackConfig.entry.main = [`webpack-hot-middleware/client?path=${WEBPACK_URL}/__webpack_hmr`, clientWebpackConfig.entry.main]
  const multiCompiler = webpack([clientWebpackConfig, serverWebpackConfig])
  const compilers = multiCompiler.compilers
  const clientCompiler = compilers.find((compiler) => compiler.name === 'client') as webpack.Compiler
  const serverCompiler = compilers.find((compiler) => compiler.name === 'server') as webpack.Compiler

  // 通过compiler.hooks用来监听Compiler编译情况
  const clientCompilerPromise = setCompilerTip(clientCompiler, clientWebpackConfig.name)
  const serverCompilerPromise = setCompilerTip(serverCompiler, serverWebpackConfig.name)

  // 用于创建服务的方法,在此创建client端的服务,至此,client端的代码便打入这个服务中, 可以通过像 https://192.168.0.47:3012/js/lib.js 访问文件
  createService({
    webpackConfig: clientWebpackConfig,
    compiler: clientCompiler,
    port: __WEBPACK_PORT__
  })
  let script: any = null
  // 重启
  const nodemonRestart = () => {
    if (script) {
      script.restart()
    }
  }

  // 监听server文件更改
  serverCompiler.watch({ ignored: /node_modules/ }, (err, stats) => {
    nodemonRestart()
    if (err) {
      throw err
    }
    // ...
  })

  try {
    // 等待编译完成
    await clientCompilerPromise
    await serverCompilerPromise
    // 这是admin编译情况,admin端的编译情况差不太多,基本也是运行`webpack(config)`进行编译,通过`createService`生成一个服务用来访问打包的代码。
    await startAdmin()

    closeCompiler(clientCompiler)
    closeCompiler(serverCompiler)
    logMsg(`Build time ${new Date().getTime() - startTime}`)
  } catch (err) {
    logMsg(err, 'error')
  }

  // 启动server端编译出来的入口文件来启动项目服务
  script = nodemon({
    script: path.join(serverWebpackConfig.output.path, 'entry.js')
  })
}
start()typescript 复制代码

createService方法用来生成服务, 代码大概如下

export const createService = ({webpackConfig, compiler}: {webpackConfig: Configurationcompiler: Compiler}) => {
  const app = new Koa()
  ...
  const dev = webpackDevMiddleware(compiler, {
    publicPath: webpackConfig.output.publicPath as string,
    stats: webpackConfig.stats
  })
  app.use(dev)
  app.use(webpackHotMiddleware(compiler))
  http.createServer(app.callback()).listen(port, cb)
  return app
}typescript 复制代码

开发(development)环境下的webpack编译情况的大体逻辑就是这样,里面会有些webpack-dev-middle这些中间件在koa中的处理等,可以具体细看源码。

3. 生成环境production环境的搭建

对于生成环境的下搭建,处理就比较少了,直接通过webpack打包就行

webpack([clientWebpackConfig, serverWebpackConfig, adminWebpackConfig], (err, stats) => {
    spinner.stop()
    if (err) {
      throw err
    }
    // ...
  })typescript 复制代码

然后启动server打包出来的入口文件

cross-env NODE_ENV=production node dist/server/entry.js

Server端源码解析

由上面的配置webpack配置延伸到他们的入口文件

// client入口
const clientPath = utils.resolve('src/client')
const clientEntryPath = path.join(clientPath, 'main.tsx')
// server入口
const serverPath = utils.resolve('src/server')
const serverEntryPath = path.join(serverPath, 'main.ts')typescript 复制代码
  • client端的入口是/src/client/main.tsx
  • server端的入口是/src/server/main.ts

因为项目用到了SSR,我们从server端来进行逐步分析。

1. /src/server/main.ts入口文件

import Koa from 'koa'
...
const app = new Koa()
/* 
  中间件:
    sendMidddleware: 对ctx.body的封装
    etagMiddleware:设置etag做缓存 可以参考koa-etag,我做了下简单修改,
    conditionalMiddleware: 判断缓存是否是否生效,通过ctx.fresh来判断就好,koa内部已经封装好了
    loggerMiddleware: 用来打印日志
    authTokenMiddleware: 权限拦截,这是admin端对api做的拦截处理
    routerErrorMiddleware:这是对api进行的错误处理
    koa-static: 对于静态文件的处理,设置max-age让文件强缓,配置etag或Last-Modified给资源设置强缓跟协商缓存
    koa-compress: 用于压缩
    koa-logger: 打印请求路径
    koa-bodyparser: body处理
*/
middleware(app)
/* 
  对api进行管理
*/
router(app)
/* 
  启动数据库,搭建SSR配置
*/
Promise.all([startMongodb(), SSR(app)])
  .then(() => {
    // 开启服务
    // 此处用了https,下面会有篇幅讲解如何在本地创建http证书。
    https.createServer(serverConfig.httpsOptions, app.callback()).listen(rootConfig.app.server.httpsPort, '0.0.0.0', () => {
      // log开启服务
    })
  })
  .catch((err) => {
    process.exit()
  })
typescript 复制代码

2.中间件的处理

对于中间件主要就讲一讲日志处理中间件loggerMiddleware和权限中间件authTokenMiddleware,别的中间件没有太多东西,就不浪费篇幅介绍了。

日志打印主要用到了log4js这个库,然后基于这个库做的上层封装,通过不同类型的Logger来创建不同的日志文件。
封装了所有请求的日志打印,api的日志打印,一些第三方的调用的日志打印

1. loggerMiddleware的实现

// log.ts
const createLogger = (options = {} as LogOptions): Logger => {
  // 配置项
  const opts = {
    ...serverConfig.log,
    ...options
  }
  // 配置文件
  log4js.configure({
    appenders: {
      // stout可以用于开发环境,直接打印出来
      stdout: {
        type: 'stdout'
      },
      // 用multiFile类型,通过变量生成不同的文件,我试了别的几种type。感觉都没这种方便
      multi: { type: 'multiFile', base: opts.dir, property: 'dir', extension: '.log' }
    },
    categories: {
      default: { appenders: ['stdout'], level: 'off' },
      http: { appenders: ['multi'], level: opts.logLevel },
      api: { appenders: ['multi'], level: opts.logLevel },
      external: { appenders: ['multi'], level: opts.logLevel }
    }
  })
  const create = (appender: string) => {
    const methods: LogLevel[] = ['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'mark']
    const context = {} as LoggerContext
    const logger = log4js.getLogger(appender)
    // 重写log4js方法,生成变量,用来生成不同的文件
    methods.forEach((method) => {
      context[method] = (message: string) => {
        logger.addContext('dir', `/${appender}/${method}/${dayjs().format('YYYY-MM-DD')}`)
        logger[method](message)
      }
    })
    return context
  }
  return {
    http: create('http'),
    api: create('api'),
    external: create('external')
  }
}
export default createLogger


// loggerMiddleware
import createLogger, { LogOptions } from '@server/utils/log'
// 所有请求打印
const loggerMiddleware = (options = {} as LogOptions) => {
  const logger = createLogger(options)
  return async (ctx: Koa.Context, next: Next) => {
    const start = Date.now()
    ctx.log = logger
    try {
      await next()
      const end = Date.now() - start
      // 正常请求日志打印
      logger.http.info(
        logInfo(ctx, {
          responseTime: `${end}ms`
        })
      )
    } catch (e) {
      const message = ErrorUtils.getErrorMsg(e)
      const end = Date.now() - start
      // 错误请求日志打印
      logger.http.error(
        logInfo(ctx, {
          message,
          responseTime: `${end}ms`
        })
      )
    }
  }
}typescript 复制代码

2. authTokenMiddleware的实现

authTokenMiddleware中间件的处理逻辑

// authTokenMiddleware.ts
const authTokenMiddleware = () => {
  return async (ctx: Koa.Context, next: Next) => {
    // api白名单: 可以把 登录 注册接口之类的设入白名单,允许访问
    if (serverConfig.adminAuthApiWhiteList.some((path) => path === ctx.path)) {
      return await next()
    }
    // 通过 jsonwebtoken 来检验token的有效性
    const token = ctx.cookies.get(rootConfig.adminTokenKey)
    if (!token) {
      throw {
        code: 401
      }
    } else {
      try {
        jwt.verify(token, serverConfig.adminJwtSecret)
      } catch (e) {
        throw {
          code: 401
        }
      }
    }
    await next()
  }
}
export default authTokenMiddlewaretypescript 复制代码

以上是对中间件的处理。

3. Router的处理逻辑

下面是关于router这块的处理,api这块主要是通过装饰器来进行请求的处理

1. 创建router,加载api文件

// router.ts
import { bootstrapControllers } from '@server/controllers'
const router = new KoaRouter<DefaultState, Context>()

export default (app: Koa) => {
  // 进行api的绑定, 
  bootstrapControllers({
    router, // 路由对象
    basePath: '/api', // 路由前缀
    controllerPaths: ['controllers/api/*/**/*.ts'], // 文件目录
    middlewares: [routerErrorMiddleware(), loggerApiMiddleware()]
  })
  app.use(router.routes()).use(router.allowedMethods())
  // api 404
  app.use(async (ctx, next) => {
    if (ctx.path.startsWith('/api')) {
      return ctx.sendCodeError(404)
    }
    await next()
  })
}


// bootstrapControllers方法
export const bootstrapControllers = (options: ControllerOptions) => {
  const { router, controllerPaths } = options
  // 引入文件, 进而触发装饰器绑定controllers
  controllerPaths.forEach((path) => {
    // 通过glob模块查找文件
    const files = glob.sync(Utils.resolve(`src/server/${path}`))
    files.forEach((file) => {
      /* 
        通过别名引入文件
        Why?
        因为直接webpack打包引用变量无法找到模块
        因为webpack打包出来的文件都得到打包出来的引用路径里面去找,并不是实际路径(__webpack_require__)
        所以直接引入路径会有问题。用别名引入。
        有个问题还待解决,就是他会解析字符串拼接的那个路径下面的所有文件
        例如: require(`@root/src/server/controllers${fileName}`) 会解析@root/src/server/controllers下的所有文件,
        目前定位在这个文件下可以防止解析过多的文件导致node内存不够,
        这个问题待解决
      */
      const p = Utils.resolve('src/server/controllers')
      const fileName = file.replace(p, '')
      // 直接require引入对应的文件。直接引入便可以了,到时候会自动触发装饰器进行api的收集。
      require(`@root/src/server/controllers${fileName}`)
    })
    // 绑定router
    generateRoutes(router, metadata, options)
  })
}typescript 复制代码

以上就是引入api的方法,下面就是装饰器的如何处理接口以及参数。

对于装饰器有几个需要注意的点:

  1. vscode需要开启装饰器javascript.implicitProjectConfig.experimentalDecorators: true,现在好像不需要了,会自动检测tsconfig.json文件,如果需要就加上
  2. babel需要配置['@babel/plugin-proposal-decorators', { legacy: true }]babel-plugin-parameter-decorator这两个插件,因为@babel/plugin-proposal-decorators这个插件无法解析@Arg,所以还要加上babel-plugin-parameter-decorator插件用来解析@Arg

@Arg -> fu(a, b, c)就是形容这些参数的 a, b, c

来到@server/decorators文件下,分别定义了以下装饰器

2. 装饰器的汇总

  • @Controller api下的某个模块 例如@Controller('/user) => /api/user
  • @Get Get请求
  • @Post Post请求
  • @Delete Delete请求
  • @Put Put请求
  • @Patch Patch请求
  • @Query Query参数 例如https://localhost:3000?a=1&b=2 => {a: 1, b: 2}
  • @Body 传入Body的参数
  • @Params Params参数 例如 https://localhost:3000/api/user/123 => /api/user/:id => @Params('id') id:string => 123
  • @Ctx Ctx对象
  • @Header Header对象 也可以单独获取Header中某个值 @Header() 获取header整个的对象, @Header('Content-Type') 获取header里面的Content-Type属性值
  • @Req Req对象
  • @Request Request对象
  • @Res Res对象
  • @Response Response对象
  • @Cookie Cookie对象 也可以单独获取Cookie中某个值
  • @Session Session对象 也可以单独获取Session中某个值
  • @Middleware 绑定中间件,可以精确到某个请求
  • @Token 获取token值,定义这个主要是方便获取token

下面来说下这些装饰器是如何进行处理的

3. 创建元数据metaData

// MetaData的数据格式
export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete'
export type argumentSource = 'ctx' | 'query' | 'params' | 'body' | 'header' | 'request' | 'req' | 'response' | 'res' | 'session' | 'cookie' | 'token'
export type argumentOptions =
  | string
  | {
      value?: string
      required?: boolean
      requiredList?: string[]
    }
export type MetaDataArguments = {
  source: argumentSource
  options?: argumentOptions
}
export interface MetaDataActions {
  [k: string]: {
    method: Method
    path: string
    target: (...args: any) => void
    arguments?: {
      [k: string]: MetaDataArguments
    }
    middlewares?: Koa.Middleware[]
  }
}
export interface MetaDataController {
  actions: MetaDataActions
  basePath?: string | string[]
  middlewares?: Koa.Middleware[]
}
export interface MetaData {
  controllers: {
    [k: string]: MetaDataController
  }
}
/* 
  声明一个数据源,用来把所有api的方式,url,参数记录下来
  在上面bootstrapControllers方面里面有个函数`generateRoutes(router, metadata, options)`
  就是解析metaData数据然后绑定到router上的
*/
export const metadata: MetaData = {
  controllers: {}
}typescript 复制代码

4. @Controller实现

// 示例, 所有TestController内部的请求都会带上`/test`前缀 => /api/test/example
@Controller('/test')
// @Controller(['/test', '/test1'])也可以是数组,那样就会创建两个请求 /api/test/example 跟 /api/test1/example
export class TestController{
  @Get('/example')
  async getExample() {
    return 'example'
  }
}
// 代码实现,绑定class controller到metaData上,
/* 
  metadata.controllers = {
    TestController: {
      basePath: '/test'
    }
  }
*/
export const Controller = (basePath: string | string[]) => {
  return (classDefinition: any): void => {
    // 获取类名,作为metadata.controllers中每个controller的key名,所以要保证控制器类名的唯一,免得有冲突
    const controller = metadata.controllers[classDefinition.name] || {}
    // basePath就是上面的 /test
    controller.basePath = basePath
    metadata.controllers[classDefinition.name] = controller
  }
}typescript 复制代码

5. @Get,@Post,@put,@Patch,@Delete实现

这几个装饰器的实现方式基本一致,就列举一个进行演示

// 示例,把@Get装饰器声明到指定的方法前面就行了。每个方法作为一个请求(action)
export class TestController{
  // @Post('/example')
  // @put('/example')
  // @Patch('/example')
  // @Delete('/example')
  @Get('/example') // => 会生成Get请求 /example
  async getExample() {
    return 'example'
  }
}
// 代码实现
export const Get = (path: string) => {
  // 装饰器绑定方法会获取两个参数,实例对象,跟方法名
  return (object: any, methodName: string) => {
    _addMethod({
      method: 'get',
      path: path,
      object,
      methodName
    })
  }
}
// 绑定到指定controller上
const _addMethod = ({ method, path, object, methodName }: AddMethodParmas) => {
  // 获取该方法对应的controller
  const controller = metadata.controllers[object.constructor.name] || {}
  const actions = controller.actions || {}
  const o = {
    method,
    path,
    target: object[methodName].bind(object)
  }
  /* 
    把该方法绑定controller.action上,方法名为key,变成以下格式
    controller.actions = {
      getExample: {
        method: 'get', // 请求方式
        path: '/example', // 请求路径
        target: () { // 该方法函数体
          return 'example'
        }
      }
    }
    在把controller赋值到metadata中的controllers上,记录所有请求。
  */
  actions[methodName] = {
    ...(actions[methodName] || {}),
    ...o
  }
  controller.actions = actions
  metadata.controllers[object.constructor.name] = controller
}typescript 复制代码

上面便是action的绑定

6. @Query,@Body,@Params,@Ctx,@Header,@Req,@Request,@Res,@Response,@Cookie,@Session实现

因为这些装饰都是装饰方法参数arguments的,所以也可以统一处理

// 示例  /api/example?a=1&b=3
export class TestController{
  @Get('/example') // => 会生成Get请求 /example
  async getExample(@Query() query: {[k: string]: any}, @Query('a') a: string) {
    console.log(query) // -> {a: 1, b: 2}
    console.log(a) // -> 1
    return 'example'
  }
}
// 其余装饰器用法类似

// 代码实现
export const Query = (options?: string | argumentOptions, required?: boolean) => {
  // 示例 @Query('id): options => 传入 'id'  
  return (object: any, methodName: string, index: number) => {
    _addMethodArgument({
      object,
      methodName,
      index,
      source: 'query',
      options: _mergeArgsParamsToOptions(options, required)
    })
  }
}
// 记录每个action的参数
const _addMethodArgument = ({ object, methodName, index, source, options }: AddMethodArgumentParmas) => {
  /* 
    object -> class 实例: TestController
    methodName -> 方法名: getExample
    index -> 参数所在位置 0
    source -> 获取类型: query
    options -> 一些选项必填什么的
  */
  const controller = metadata.controllers[object.constructor.name] || {}
  controller.actions = controller.actions || {}
  controller.actions[methodName] = controller.actions[methodName] || {}
  // 跟前面一个一样,获取这个方法对应的action, 往这个action上面添加一个arguments参数
  /* 

      getExample: {
        method: 'get', // 请求方式
        path: '/example', // 请求路径
        target: () { // 该方法函数体
          return 'example'
        },
        arguments: {
          0: {
            source: 'query',
            options: 'id'
          }
        }
      }
  */
  const args = controller.actions[methodName].arguments || {}
  args[String(index)] = {
    source,
    options
  }
  controller.actions[methodName].arguments = args
  metadata.controllers[object.constructor.name] = controller
}typescript 复制代码

上面就是对于每个action上的arguments绑定的实现

7. @Middleware实现

@Middleware这个装饰器,不仅应该能在Controller上绑定,还能在某个action上绑定

// 示例 执行流程
// router.get('/api/test/example', TestMiddleware(), ExampleMiddleware(), async (ctx, next) => {})

@Middleware([TestMiddleware()])
@Controller('/test')
export class TestController{
  @Middleware([ExampleMiddleware()])
  @Get('/example')
  async getExample() {
    return 'example'
  }
}

// 代码实现
export const Middleware = (middleware: Koa.Middleware | Koa.Middleware[]) => {
  const middlewares = Array.isArray(middleware) ? middleware : [middleware]
  return (object: any, methodName?: string) => {
    // object是function, 证明是在给controller加中间件
    if (typeof object === 'function') {
      const controller = metadata.controllers[object.name] || {}
      controller.middlewares = middlewares
    } else if (typeof object === 'object' && methodName) { // 存在methodName证明是给action添加中间件
      // 给action加
      const controller = metadata.controllers[object.constructor.name] || {}
      controller.actions = controller.actions || {}
      controller.actions[methodName] = controller.actions[methodName] || {}
      controller.actions[methodName].middlewares = middlewares
      metadata.controllers[object.constructor.name] = controller
    }
    /* 
      代码格式
      metadata.controllers = {
        TestController: {
          basePath: '/test',
          middlewares: [TestMiddleware()],
          actions: {
            getExample: {
              method: 'get', // 请求方式
              path: '/example', // 请求路径
              target: () { // 该方法函数体
                return 'example'
              },
              arguments: {
                0: {
                  source: 'query',
                  options: 'id'
                }
              },
              middlewares: [ExampleMiddleware()]
            }
          }
        }
      }
    */
  }
}
typescript 复制代码

以上的装饰器基本就把整个请求进行的包装记录在metadata中,
我们回到bootstrapControllers方法里面的generateRoutes上,
这里是用来解析metadata数据,然后把这些数据绑定到router上。

8. 解析metadata元数据,绑定router

export const bootstrapControllers = (options: ControllerOptions) => {
  const { router, controllerPaths } = options
  // 引入文件, 进而触发装饰器绑定controllers
  controllerPaths.forEach((path) => {
    // require()引入文件之后,就会触发装饰器进行数据收集
    require(...)
    // 这个时候metadata数据就是收集好所有action的数据结构
    // 数据结构是如下样子, 以上面的举例
    metadata.controllers = {
      TestController: {
        basePath: '/test',
        middlewares: [TestMiddleware()],
        actions: {
          getExample: {
            method: 'get', // 请求方式
            path: '/example', // 请求路径
            target: () { // 该方法函数体
              return 'example'
            },
            arguments: {
              0: {
                source: 'query',
                options: 'id'
              }
            },
            middlewares: [ExampleMiddleware()]
          }
        }
      }
    }
    // 执行绑定router流程
    generateRoutes(router, metadata, options)
  })
}typescript 复制代码

9. generateRoutes方法的实现

export const generateRoutes = (router: Router, metadata: MetaData, options: ControllerOptions) => {
  const rootBasePath = options.basePath || ''
  const controllers = Object.values(metadata.controllers)
  controllers.forEach((controller) => {
    if (controller.basePath) {
      controller.basePath = Array.isArray(controller.basePath) ? controller.basePath : [controller.basePath]
      controller.basePath.forEach((basePath) => {
        // 传入router, controller, 每个action的url前缀(rootBasePath + basePath)
        _generateRoute(router, controller, rootBasePath + basePath, options)
      })
    }
  })
}


// 生成单个路由
const _generateRoute = (router: Router, controller: MetaDataController, basePath: string, options: ControllerOptions) => {
  // 把action置反,后加的action会添加到前面去,置反使其解析正确,按顺序加载,避免以下情况
  /* 
    @Get('/user/:id')
    @Get('/user/add')
    所以路由加载顺序要按照你书写的顺序执行,避免冲突
  */
  const actions = Object.values(controller.actions).reverse()
  actions.forEach((action) => {
    // 拼接action的全路径
    const path =
      '/' +
      (basePath + action.path)
        .split('/')
        .filter((i) => i.length)
        .join('/')
    // 给每个请求添加上middlewares,按照顺序执行
    const midddlewares = [...(options.middlewares || []), ...(controller.middlewares || []), ...(action.middlewares || [])]
    /* 
      router['get'](
        '/api', // 请求路径
        ...(options.middlewares || []), // 中间件
        ...(controller.middlewares || []), // 中间件
        ...(action.middlewares || []), // 中间件
        async (ctx, next) => {  // 执行最后的函数,返回数据等等
          ctx.send(....)
        }
      )
    */
    midddlewares.push(async (ctx) => {
      const targetArguments: any[] = []
      // 解析参数
      if (action.arguments) {
        const keys = Object.keys(action.arguments)
        // 每个位置对应的argument数据
        for (const key of keys) {
          const argumentData = action.arguments[key]
          // 解析参数的函数,下面篇幅说明
          targetArguments[Number(key)] = _determineArgument(ctx, argumentData, options)
        }
      }
      // 执行 action.target 函数,获取返回的数据,在通过ctx返回出去
      const data: any = await action.target(...targetArguments)
      // data === 'CUSTOM' 自定义返回,例如下载文件等等之类的
      if (data !== 'CUSTOM') {
        ctx.send(data === undefined ? null : data)
      }
    })
    router[action.method](path, ...(midddlewares as Middleware[]))
  })
}
typescript 复制代码

上面就是解析路由的大概流程,里面有个方法 _determineArgument用来解析参数

9. _determineArgument方法的实现

  1. ctx, session, cookie, token, query, params, body 这个参数没法直接通过ctx[source]获取,所以单独处理
  2. 其余可以通过ctx[source]获取,就直接获取了
// 对参数进行处理跟验证
const _determineArgument = (ctx: Context, { options, source }: MetaDataArguments, opts: ControllerOptions) => {
  let result
  // 特殊处理的参数, `ctx`, `session`, `cookie`, `token`, `query`, `params`, `body`
  if (_argumentInjectorTranslations[source]) {
    result = _argumentInjectorTranslations[source](ctx, options, source)
  } else {
    // 普通能直接ctx获取的,例如header, @header() -> ctx['header'], @Header('Content-Type') -> ctx['header']['Content-Type']
    result = ctx[source]
    if (result && options && typeof options === 'string') {
      result = result[options]
    }
  }
  return result
}

// 需要检验的参数,单独处理
const _argumentInjectorTranslations = {
  ctx: (ctx: Context) => ctx,
  session: (ctx: Context, options: argumentOptions) => {
    if (typeof options === 'string') {
      return ctx.session[options]
    }
    return ctx.session
  },
  cookie: (ctx: Context, options: argumentOptions) => {
    if (typeof options === 'string') {
      return ctx.cookies.get(options)
    }
    return ctx.cookies
  },
  token: (ctx: Context, options: argumentOptions) => {
    if (typeof options === 'string') {
      return ctx.cookies.get(options) || ctx.header[options]
    }
    return ''
  },
  query: (ctx: Context, options: argumentOptions, source: argumentSource) => {
    return _argumentInjectorProcessor(source, ctx.query, options)
  },
  params: (ctx: Context, options: argumentOptions, source: argumentSource) => {
    return _argumentInjectorProcessor(source, ctx.params, options)
  },
  body: (ctx: Context, options: argumentOptions, source: argumentSource) => {
    return _argumentInjectorProcessor(source, ctx.request.body, options)
  }
} as Record<argumentSource, (...args: any) => any>

// 验证操作返回值
const _argumentInjectorProcessor = (source: argumentSource, data: any, options: argumentOptions) => {
  if (!options) {
    return data
  }
  if (typeof options === 'string' && Type.isObject(data)) {
    return data[options]
  }
  if (typeof options === 'object') {
    if (options.value) {
      const val = data[options.value]
      // 必填,但是值为空,报错
      if (options.required && Type.isEmpty(val)) {
        ErrorUtils.error(`[${source}] [${options.value}]参数不能为空`)
      }
      return val
    }
    // require数组校验
    if (options.requiredList && Type.isArray(options.requiredList) && Type.isObject(data)) {
      for (const key of options.requiredList) {
        if (Type.isEmpty(data[key])) {
          ErrorUtils.error(`[${source}] [${key}]参数不能为空`)
        }
      }
      return data
    }
    if (options.required) {
      if (Type.isEmptyObject(data)) {
        ErrorUtils.error(`${source}中有必填参数`)
      }
      return data
    }
  }
  ErrorUtils.error(`[${source}] ${JSON.stringify(options)} 参数错误`)
}typescript 复制代码

10. Router Controller文件整体预览

import {
  Get,
  Post,
  Put,
  Patch,
  Delete,
  Query,
  Params,
  Body,
  Ctx,
  Header,
  Req,
  Request,
  Res,
  Response,
  Session,
  Cookie,
  Controller,
  Middleware
} from '@server/decorators'
import { Context, Next } from 'koa'
import { IncomingHttpHeaders } from 'http'

const TestMiddleware = () => {
  return async (ctx: Context, next: Next) => {
    console.log('start TestMiddleware')
    await next()
    console.log('end TestMiddleware')
  }
}
const ExampleMiddleware = () => {
  return async (ctx: Context, next: Next) => {
    console.log('start ExampleMiddleware')
    await next()
    console.log('end ExampleMiddleware')
  }
}

@Middleware([TestMiddleware()])
@Controller('/test')
export class TestController {
  @Middleware([ExampleMiddleware()])
  @Get('/example')
  async getExample(
    @Ctx() ctx: Context,
    @Header() header: IncomingHttpHeaders,
    @Request() request: Request,
    @Req() req: Request,
    @Response() response: Response,
    @Res() res: Response,
    @Session() session: any,
    @Cookie('token') Cookie: any
  ) {
    console.log(ctx.response)
    return {
      ctx,
      header,
      request,
      response,
      Cookie,
      session
    }
  }
  @Get('/get/:name/:age')
  async getFn(
    @Query('id') id: string,
    @Query({ required: true }) query: any,
    @Params('name') name: string,
    @Params('age') age: string,
    @Params() params: any
  ) {
    return {
      method: 'get',
      id,
      query,
      name,
      age,
      params
    }
  }
  @Post('/post/:name/:age')
  async getPost(
    @Query('id') id: string,
    @Params('name') name: string,
    @Params('age') age: string,
    @Params() params: any,
    @Body('sex') sex: string,
    @Body('hobby', true) hobby: any,
    @Body() body: any
  ) {
    return {
      method: 'post',
      id,
      name,
      age,
      params,
      sex,
      hobby,
      body
    }
  }
  @Put('/put/:name/:age')
  async getPut(
    @Query('id') id: string,
    @Params('name') name: string,
    @Params('age') age: string,
    @Params() params: any,
    @Body('sex') sex: string,
    @Body('hobby', true) hobby: any,
    @Body() body: any
  ) {
    return {
      method: 'put',
      id,
      name,
      age,
      params,
      sex,
      hobby,
      body
    }
  }
  @Patch('/patch/:name/:age')
  async getPatch(
    @Query('id') id: string,
    @Params('name') name: string,
    @Params('age') age: string,
    @Params() params: any,
    @Body('sex') sex: string,
    @Body('hobby', true) hobby: any,
    @Body() body: any
  ) {
    return {
      method: 'patch',
      id,
      name,
      age,
      params,
      sex,
      hobby,
      body
    }
  }
  @Delete('/delete/:name/:age')
  async getDelete(
    @Query('id') id: string,
    @Params('name') name: string,
    @Params('age') age: string,
    @Params() params: any,
    @Body('sex') sex: string,
    @Body('hobby', true) hobby: any,
    @Body() body: any
  ) {
    return {
      method: 'delete',
      id,
      name,
      age,
      params,
      sex,
      hobby,
      body
    }
  }
}
typescript 复制代码

以上就是整个router相关的action绑定

4. SSR的实现

SSR同构的代码其实讲解挺多的,基本随便在搜索引擎搜索就能有很多教程,我这里贴一个简单的流程图帮助大家理解下,顺便讲下我的流程思路
SSR同构

上面流程图这只是一个大概的流程,具体里面数据的获取,数据的注水,优化首屏样式等等,我会在下方用部分代码进行说明
此处有用到插件@loadable/server@loadable/component@loadable/babel-plugin

1. 前端部分代码

/* home.tsx */
const Home = () => {
  return Home
}
// 该组件需要依赖的接口数据
Home._init = async (store: IStore, routeParams: RouterParams) => {
  const { data } = await api.getData()
  store.dispatch(setDataState({ data }))
  return
}

/* router.ts */
const routes = [
  {
    path: '/',
    name: 'Home',
    exact: true,
    component: _import_('home')
  },
  ...
]

/* app.ts */
const App = () => {
  return (
    <Switch location={location}>
      {routes.map((route, index) => {
        return (
          <Route
            key={`${index} + ${route.path}`}
            path={route.path}
            render={(props) => {
              return (
                <RouterGuard Com={route.component} {...props}>
                  {children}
                </RouterGuard>
              )
            }}
            exact={route.exact}
          />
        )
      })}
      <Redirect to="/404" />
    </Switch>
  )
}
// 路由拦截判断是否需要由前端发起请求
const RouterGuard = ({ Com, children, ...props }: any) => {
  useEffect(() => {
    const isServerRender = store.getState().app.isServerRender
    const options = {
      disabled: false
    }
    async function load() {
      // 因为前面我们把页面的接口数据放在组件的_init方法中,直接调用这个方法就可以获取数据
      // 首次进入,数据是交由服务端进行渲染,所以在客户端不需要进行调用。
      // 满足非服务端渲染的页面,存在_init函数,调用发起数据请求,便可在前端发起请求,获取数据
      // 这样就能前端跟服务端共用一份代码发起请求。
      // 这有很多实现方法,也有把接口函数绑定在route上的,看个人爱好。
      if (!isServerRender && Com._init && history.action !== 'POP') {
        setLoading(true)
        await Com._init(store, routeParams.current, options)
        !options.disabled && setLoading(false)
      }
    }
    load()
    return () => {
      options.disabled = true
    }
  }, [Com, store, history])
  return (
    <div className="page-view">
      <Com {...props} />
      {children}
    </div>
  )
}

/* main.tsx */
// 前端获取后台注入的store数据,同步store数据,客户端进行渲染
export const getStore = (preloadedState?: any, enhancer?: StoreEnhancer) => {
  const store = createStore(rootReducers, preloadedState, enhancer) as IStore
  return store
}
const store = getStore(window.__PRELOADED_STATE__, window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__())
loadableReady(() => {
  ReactDom.hydrate(
    <Provider store={store}>
      <BrowserRouter>
        <HelmetProvider>
          <Entry />
        </HelmetProvider>
      </BrowserRouter>
    </Provider>,
    document.getElementById('app')
  )
})
typescript 复制代码

前端需要的逻辑大概就是这些,重点还是在服务端的处理

2. 服务端处理代码

// 由@loadable/babel-plugin插件打包出来的loadable-stats.json路径依赖表,用来索引各个页面依赖的js,css文件等。
const getStatsFile = async () => {
  const statsFile = path.join(paths.buildClientPath, 'loadable-stats.json')
  return new ChunkExtractor({ statsFile })
}
// 获取依赖文件对象
const clientExtractor = await getStatsFile()

// store每次加载时,都得重新生成,不能是单例,否则所有用户都会共享一个store了。
const store = getStore()
// 匹配当前路由对应的route对象
const { route } = matchRoutes(routes, ctx.path)
if (route) {
  const match = matchPath(decodeURI(ctx.path), route)
  const routeParams = {
    params: match?.params,
    query: ctx.query
  }
  const component = route.component
  // @loadable/component动态加载的组件具有load方法,用来加载组件的
  if (component.load) {
    const c = (await component.load()).default
    // 有_init方法,等待调用,然后数据会存入Store中
    c._init && (await c._init(store, routeParams))
  }
}
// 通过ctx.url生成对应的服务端html, clientExtractor获取对应路径依赖
const appHtml = renderToString(
  clientExtractor.collectChunks(
    <Provider store={store}>
      <StaticRouter location={ctx.url} context={context}>
        <HelmetProvider context={helmetContext}>
          <App />
        </HelmetProvider>
      </StaticRouter>
    </Provider>
  )
)

/* 
  clientExtractor:
    getInlineStyleElements:style标签,行内css样式
    getScriptElements: script标签
    getLinkElements: Link标签,包括预加载的js css link文件
    getStyleElements: link标签的样式文件
*/
const inlineStyle = await clientExtractor.getInlineStyleElements()
const html = createTemplate(
  renderToString(
    <HTML
      helmetContext={helmetContext}
      scripts={clientExtractor.getScriptElements()}
      styles={clientExtractor.getStyleElements()}
      inlineStyle={inlineStyle}
      links={clientExtractor.getLinkElements()}
      favicon={`${
        serverConfig.isProd ? '/' : `${scriptsConfig.__WEBPACK_HOST__}:${scriptsConfig.__WEBPACK_PORT__}/`
      }static/client_favicon.ico`}
      state={store.getState()}
    >
      {appHtml}
    </HTML>
  )
)
// HTML组件模板
// 通过插入style标签的样式防止首屏加载样式错乱
// 把store里面的数据注入到 window.__PRELOADED_STATE__ 对象上,然后在客户端进行获取,通过store数据
const HTML = ({ children, helmetContext: { helmet }, scripts, styles, inlineStyle, links, state, favicon }: Props) => {
  return (
    <html data-theme="light">
      <head>
        <meta charSet="utf-8" />
        {hasTitle ? titleComponents : <title>{rootConfig.head.title}</title>}
        {helmet.base.toComponent()}
        {metaComponents}
        {helmet.link.toComponent()}
        {helmet.script.toComponent()}
        {links}
        <style id="style-variables">
          {`:root {${Object.keys(theme.light)
            .map((key) => `${key}:${theme.light[key]};`)
            .join('')}}`}
        </style>
        // 此处直接传入style标签的样式,便面首次进入样式错误的问题
        {inlineStyle}
        // 在此处实现数据注水,把store中的数据赋值到window.__PRELOADED_STATE__上
        <script
          dangerouslySetInnerHTML={{
            __html: `window.__PRELOADED_STATE__ = ${JSON.stringify(state).replace(/</g, '\\u003c')}`
          }}
        />
        <script async src="//at.alicdn.com/t/font_2062907_scf16rx8d6.js"></script>
      </head>
      <body>
        <div id="app" className="app" dangerouslySetInnerHTML={{ __html: children }}></div>
        {scripts}
      </body>
    </html>
  )
}
ctx.type = 'html'
ctx.body = htmltypescript 复制代码

3. 执行流程

  • 通过@loadable/babel-plugin打包出来的loadable-stats.json文件确定依赖
  • 通过@loadable/server中的ChunkExtractor来解析这个文件,返回直接操作的对象
  • ChunkExtractor.collectChunks关联组件,获取js跟样式文件
  • 把获取的js,css文件赋值到HTML模板上去,返回给前端,
  • 用行内样式style标签渲染首屏的样式,避免首屏出现样式错误。
  • 把通过调用组件_init方法获取到的数据,注水注入到window.__PRELOADED_STATE__
  • 前端获取window.__PRELOADED_STATE__数据同步到客户端的store里面
  • 前端取到js文件,重新执行渲染流程。绑定react事件等等
  • 前端接管页面

4. Token的处理

SSR的时候用户进行登录还会扯出一个关于token的问题。登录完后会把token存到cookie中。到时候直接通过token获取个人信息
正常来说不做SSR,正常前后端分离进行接口请求,都是从 client端 => server端,所以接口中的cookie每次都会携带token,每次也都能在接口中取到token
但是在做SSR的时候,首次加载时在服务端进行的,所以接口请求是在服务端进行的,这个时候你在接口中是获取不到token的。

我尝试了已下几种方法:

  • 在请求过来的时候,把token获取到,然后存入store,在进行用户信息获取的时候,取出store中的token传入url,就像这样: /api/user?token=${token},但是这样的话,假如有好多接口需要token,那我不是每个都要传。那也太麻烦了。
  • 然后我就寻思能不能把store里面的token传到axios的header里面,那样不就不需要每个都写了。但我想了好几种办法,都没有想到怎么把store里面的token放到请求header中,因为store是要隔离的。我生成store之后,只能把他传到组件里面,最多就是在组件里面调用请求的时候,传参传下去,那不还是一样每个都要写么。
  • 最后我也忘了是在哪看到一篇文章,可以把token存到请求的实例上,我用的axios,所以我就想把他赋值到axios实例上,作为一个属性。但是要注意一个问题,axios这个时候在服务端就得做隔离了。不然就所有用户就共用了。

代码实现

/* @client/utils/request.ts */
class Axios {
  request() {
    // 区分是服务端,还是浏览器端,服务端把token存在 axios实例属性token上, 浏览器端就直接从cookie中获取token就行
    const key = process.env.BROWSER_ENV ? Cookie.get('token') : this['token']
    if (key) {
      headers['token'] = key
    }
    return this.axios({
      method,
      url,
      [q]: data,
      headers
    })
  }
}
import Axios from './Axios'
export default new Axios()

/* ssr.ts */
// 不要在外部引入,那样就所有用户共用了
// import Axios from @client/utils/request

// ssr代码实现
app.use(async (ctx, next) => {
  ...
  // 在此处引入axios, 给他添加token属性,这个时候每次请求都可以在header中放入token了,就解决了SSR token的问题
  const request = require('@client/utils/request').default
  request['token'] = ctx.cookies.get('token') || ''
})
typescript 复制代码

基本上服务端的功能大概就是这些,还有一些别的功能点就不浪费篇幅进行讲解了。

Client端源码解析

1. 路由处理

因为有的路由有layout布局,像首页,博客详情等等页面,都有公共的导航之类的。而像404页面,错误页面是没有这些布局的。
所以区分了的这两种路由,因为也配套了两套loading动画。
基于layout部分的过渡的动画,也区分了pc 跟 mobile的过渡方式,

PC过渡动画
pc过渡动画

Mobile过渡动画
mobile过渡动画

过渡动画是由 react-transition-group 实现的。
通过路由的前进后退来改变不同的className来执行不同的动画。

  • router-forward: 前进,进入新页面
  • router-back: 返回
  • router-fade: 透明度变化,用于页面replace
const RenderLayout = () => {
  useRouterEach()
  const routerDirection = getRouterDirection(store, location)
  if (!isPageTransition) {
    // 手动或者Link触发push操作
    if (history.action === 'PUSH') {
      classNames = 'router-forward'
    }
    // 浏览器按钮触发,或主动pop操作
    if (history.action === 'POP') {
      classNames = `router-${routerDirection}`
    }
    if (history.action === 'REPLACE') {
      classNames = 'router-fade'
    }
  }
  return (
    <TransitionGroup appear enter exit component={null} childFactory={(child) => React.cloneElement(child, { classNames })}>
      <CSSTransition
        key={location.pathname}
        timeout={500}
      >
        <Switch location={location}>
          {layoutRoutes.map((route, index) => {
            return (
              <Route
                key={`${index} + ${route.path}`}
                path={route.path}
                render={(props) => {
                  return (
                    <RouterGuard Com={route.component} {...props}>
                      {children}
                    </RouterGuard>
                  )
                }}
                exact={route.exact}
              />
            )
          })}
          <Redirect to="/404" />
        </Switch>
      </CSSTransition>
    </TransitionGroup>
  )
}typescript 复制代码

动画前进后退的实现因为涉及到浏览器本身的前进后退,不单纯只是页面内我们操控的前进后退。
所以就需要记录路由变化,来确定是前进还是后退,不能只靠history的action来判断

  • history.action === 'PUSH'肯定是算前进,因为这是我们触发点击进入新页面才会触发
  • history.action === 'POP'有可能是history.back()触发,也有可能是浏览器系统自带的前进,后退按钮触发,
  • 接下来要做的就是如何区分浏览器系统的前进和后退。代码实现就在useRouterEach这个hook和getRouterDirection方法里面。
  • useRouterEachhook函数
// useRouterEach
export const useRouterEach = () => {
  const location = useLocation()
  const dispatch = useDispatch()
  // 更新导航记录
  useEffect(() => {
    dispatch(
      updateNaviagtion({
        path: location.pathname,
        key: location.key || ''
      })
    )
  }, [location, dispatch])
}typescript 复制代码
  • updateNaviagtion里面做了一个路由记录的增删改,因为每次进入新页面location.key会生成一个新的key,我们可以用key来记录这个路由是新的还是旧的,新的就pushnavigations里面,如果已经存在这条记录,就可以直接截取这条记录以前的路由记录就行,然后把navigations更新。这里做的是整个导航的记录
const navigation = (state = INIT_STATE, action: NavigationAction): NavigationState => {
  switch (action.type) {
    case UPDATE_NAVIGATION: {
      const payload = action.payload
      let navigations = [...state.navigations]
      const index = navigations.findIndex((p) => p.key === payload.key)
      // 存在相同路径,删除
      if (index > -1) {
        navigations = navigations.slice(0, index + 1)
      } else {
        navigations.push(payload)
      }
      Session.set(navigationKey, navigations)
      return {
        ...state,
        navigations
      }
    }
  }
}typescript 复制代码
  • getRouterDirection方法,获取navigations数据,通过location.key来判断这个路由是否在navigations里面,在的话证明是返回,如果不在的证明是前进。这样便能区分浏览器是在前进进入的新页面,还是后退返回的旧页面。
export const getRouterDirection = (store: Store<IStoreState>, location: Location) => {
  const state = store.getState()
  const navigations = state.navigation?.navigations
  if (!navigations) {
    return 'forward'
  }
  const index = navigations.findIndex((p) => p.key === (location.key || ''))
  if (index > -1) {
    return 'back'
  } else {
    return 'forward'
  }
}typescript 复制代码

路由切换逻辑

  1. history.action === 'PUSH' 证明是前进
  2. 如果是history.action === 'POP',通过location.key去记录好的navigations来判断这个页面是新的页面,还是已经到过的页面。来区分是前进还是后退
  3. 通过获取的 forwardback 执行各自的路由过渡动画。

2. 主题换肤

通过css变量来做换肤效果,在theme文件里面声明多个主题样式

|-- theme
    |-- dark
    |-- light
    |-- index.ts复制代码
// dark.ts
export default {
  '--primary': '#20a0ff',
  '--analogous': '#20baff',
  '--gray': '#738192'
  '--red': '#E6454A'
}
// light.ts
export default {
  '--primary': '#20a0ff',
  '--analogous': '#20baff',
  '--gray': '#738192'
  '--red': '#E6454A'
}typescript 复制代码

然后选择一个样式赋值到style标签里面作为全局css变量样式,在服务端渲染的时候,在HTML模板里面插入了一条id=style-variables的style标签。
可以通过JS来控制style标签里面的内容,直接替换就好,比较方便的进行主题切换,不过这玩意不兼容IE,如果你想用他,又需要兼容ie,可以使用css-vars-ponyfill来处理css变量。

<style id="style-variables">
  {`:root {${Object.keys(theme.light)
    .map((key) => `${key}:${theme.light[key]};`)
    .join('')}}`}
</style>

const onChangeTheme = (type = 'dark') => {
  const dom = document.querySelector('#style-variables')
  if (dom) {
    dom.innerHTML = `
    :root {${Object.keys(theme[type])
      .map((key) => `${key}:${theme[type][key]};`)
      .join('')}}
    `
  }
}javascript 复制代码

不过博客没有做主题切换,主题切换倒是简单,反正我也不打算兼容ie什么的,本来想做来着,但是搭配颜色实在对我有点困难😢😢,寻思一下暂时不考虑了。本来UI也是各种看别人好看的博客怎么设计的,自己也是仿着别人的设计,在加上自己的一点点设计。才弄出的UI。正常能看就挺好了,就没搞主题了,以后再加,哈哈。

3. 使用Sentry做项目监控

Sentry地址

import * as Sentry from '@sentry/react'
import rootConfig from '@root/src/shared/config'

Sentry.init({
  dsn: rootConfig.sentry.dsn,
  enabled: rootConfig.openSentry
})

export default Sentry

/* aap.ts */
<ErrorBoundary>
  <Switch>
    ...
  </Switch>
</ErrorBoundary>

// 错误上报,因为没有对应的 componentDidCatch hook所以创建class组件来捕获错误
class ErrorBoundary extends React.Component<Props, State> {
  componentDidCatch(error: Error, errorInfo: any) {
    // 你同样可以将错误日志上报给服务器
    Sentry.captureException(error)
    this.props.history.push('/error')
  }
  render() {
    return this.props.children
  }
}
typescript 复制代码

服务端同理,通过Sentry.captureException来提交错误,声明对应的中间件进行错误拦截然后提交错误就行

4. 前端部分功能点

简单介绍下其余的功能点,有些就不进行讲解了,基本都比较简单,直接看博客源码就行

1. ReactDom.createPortal

通过 ReactDom.createPortal 来做全局弹窗,提示之类,ReactDom.createPortal可以渲染在父节点以外的dom上,所以可以直接把弹窗什么的挂载到body上。
可以封装成组件

import { useRef } from 'react'
import ReactDom from 'react-dom'
import { canUseDom } from '@/utils/app'

type Props = {
  children: any
  container?: any
}
interface Portal {
  (props: Props): JSX.Element | null
}

const Portal: Portal = ({ children, container }) => {
  const containerRef = useRef<HTMLElement>()
  if (canUseDom()) {
    if (!container) {
      containerRef.current = document.body
    } else {
      containerRef.current = container
    }
  }
  return containerRef.current ? ReactDom.createPortal(children, containerRef.current) : null
}

export default Portaltypescript 复制代码

2. 常用hook的封装

  1. useResize, 屏幕宽度变化
  2. useQuery, query参数获取
    ...等等一些常用的hook,就不做太多介绍了。稍微讲解一下遮罩层滚动的hook

useDisabledScrollByMask作用:在有遮罩层的时候控制滚动

  • 遮罩层底下需不需要禁止滚动。
  • 遮罩层需不需要禁止滚动。
  • 遮罩层禁止滚动了,里面内容加入有滚动,如何让其可以滚动。不会因为触底或触顶导致触发遮罩层底部的滚动。

代码实现

import { useEffect } from 'react'

export type Options = {
  show: boolean // 开启遮罩层
  disabledScroll?: boolean // 禁止滚动, 默认: true
  maskEl?: HTMLElement | null // 遮罩层dom
  contentEl?: HTMLElement | null // 滚动内容dom
}
export const useDisabledScrollByMask = ({ show, disabledScroll = true, maskEl, contentEl }: Options = {} as Options) => {
  // document.body 滚动禁止,给body添加overflow: hidden;样式,禁止滚动
  useEffect(() => {
    /* 
      .disabled-scroll {
        overflow: hidden;
      }
    */
    if (disabledScroll) {
      if (show) {
        document.body.classList.add('disabled-scroll')
      } else {
        document.body.classList.remove('disabled-scroll')
      }
    }
    return () => {
      if (disabledScroll) {
        document.body.classList.remove('disabled-scroll')
      }
    }
  }, [disabledScroll, show])

  // 遮罩层禁止滚动
  useEffect(() => {
    if (disabledScroll && maskEl) {
      maskEl.addEventListener('touchmove', (e) => {
        e.preventDefault()
      })
    }
  }, [disabledScroll, maskEl])
  // 内容禁止滚动
  useEffect(() => {
    if (disabledScroll && contentEl) {
      const children = contentEl.children
      const target = (children.length === 1 ? children[0] : contentEl) as HTMLElement
      let targetY = 0
      let hasScroll = false // 是否有滚动的空间
      target.addEventListener('touchstart', (e) => {
        targetY = e.targetTouches[0].clientY
        const scrollH = target.scrollHeight
        const clientH = target.clientHeight

        // 判断滚动高单独跟元素高度来判断这个元素是不是有需要滚动的需求
        hasScroll = scrollH - clientH > 0
      })
      // 通过监听元素
      target.addEventListener('touchmove', (e) => {
        if (!hasScroll) {
          return e.cancelable && e.preventDefault()
        }
        const newTargetY = e.targetTouches[0].clientY
        // distanceY > 0, 下拉;distanceY < 0, 上拉
        const distanceY = newTargetY - targetY
        const scrollTop = target.scrollTop
        const scrollH = target.scrollHeight
        const clientH = target.clientHeight
        // 下拉的时候, scrollTop = 0的时候,证明元素滚动到顶部了,所以调用preventDefault禁止滚动,防止这个滚动触发底部body的滚动
        if (distanceY > 0 && scrollTop <= 0) {
          // 下拉到顶
          return e.cancelable && e.preventDefault()
        }
        // 上拉同理
        if (distanceY < 0 && scrollTop >= scrollH - clientH) {
          // 上拉到底
          return e.cancelable && e.preventDefault()
        }
      })
    }
  }, [disabledScroll, contentEl])
}
typescript 复制代码

client端还有一些别的功能点就不进行讲解了,因为博客需要搭建的模块也不多。可以直接去观看博客源码

6. Admin端源码解析

后台管理端其实跟客户端差不多,我用的antdUI框架进行搭建的,直接用UI框架布局就行。基本上没有太多可说的,因为模块也不多。
本来还想做用户模块,派发不同权限的,寻思个人博客也就我自己用,实在用不上。如果大家有需要,我会在后台管理添加一个关于权限分配的模块,来实现对于菜单,按钮的权限控制。
主要说下下面两个功能点

1.用户登录拦截的实现

配合我上面所说的authTokenMiddleware中间件,可以实现用户登录拦截,已登录的话,不在需要登录直接跳转首页,未登录拦截进入登录页面。

通过一个权限组件AuthRoute来控制

const signOut = () => {
  Cookie.remove(rootConfig.adminTokenKey)
  store.dispatch(clearUserState())
  history.push('/login')
}
const AuthRoute: AuthRoute = ({ Component, ...props }) => {
  const location = useLocation()
  const isLoginPage = location.pathname === '/login'
  const user = useSelector((state: IStoreState) => state.user)
  // 没有用户信息且不是登录页面
  const [loading, setLoading] = useState(!user._id && !isLoginPage)
  const token = Cookie.get(rootConfig.adminTokenKey)
  const dispatch = useDispatch()
  useEffect(() => {
    async function load() {
      if (token && !user._id) {
        try {
          setLoading(true)
          /* 
            通过token获取信息
            1. 如果token过期,会在axios里面进行处理,跳转到登录页
              if (error.response?.status === 401) {
                Modal.warning({
                  title: '退出登录',
                  content: 'token过期',
                  okText: '重新登录',
                  onOk: () => {
                    signOut()
                  }
                })
                return
              }

            2. 正常返回值,便会获取到信息,设loading为false,进入下边流程渲染
          */
          const { data } = await api.user.getUserInfoByToken()
          dispatch(setUserState(data))
          setLoading(false)
        } catch (e) {
          signOut()
        }
      }
    }
    load()
  }, [token, user._id, dispatch])
  
  // 有token没有用户信息,进入loading,通过token去获取用户信息
  if (loading && token) {
    return <LoadingPage />
  }
  // 有token的时候
  if (token) {
    // 在登录页,跳转到首页去
    if (isLoginPage) {
      return <Redirect exact to="/" />
    }
    // 非登录页,直接进入
    return <Component {...props} />
  } else {
    // 没有token的时候
    // 不是登录页,跳转登录页
    if (!isLoginPage) {
      return <Redirect exact to="/login" />
    } else {
      // 是登录页,直接进入
      return <Component {...props} />
    }
  }
}

export default AuthRoutetypescript 复制代码

2. 上传文件以及文件夹

上传文件都是通过FormData进行统一上传,后台通过busboy模块进行接收,uploadFile代码地址

// 前端通过append传入formData
const formData = new FormData()
for (const key in value) {
  const val = value[key]
  // 传多个文件的话,字段名后面要加 [], 例如: formData.append('images[]', val)
  formData.append(key, val)
}

// 后台通过busboy来接收
type Options = {
  oss?: boolean // 是否上传oss
  rename?: boolean // 是否重命名
  fileDir?: string // 文件写入目录
  overlay?: boolean // 文件是否可覆盖
}
const uploadFile = <T extends AnyObject>(ctx: Context, options: Options | Record<string, Options> = File.defaultOptions) => {
  const busboy = new Busboy({
    headers: ctx.req.headers
  })
  console.log('start uploading...')
  return new Promise<T>((resolve, reject) => {
    const formObj: AnyObject = {}
    const promiseFiles: Promise<any>[] = []
    busboy.on('file', async (fieldname, file, filename, encoding, mimetype) => {
      console.log('File [' + fieldname + ']: filename: ' + filename)
      /* 
        在这里接受文件,
        通过options选项来判断文件写入方式
        这里要注意下,因为上传文件夹是
      */

      /* 
        这里每次只会接受一个文件,如果传了多张图片,要截取一下字段,设置值的,也不要被覆盖。
        const index = fieldname.lastIndexOf('[]')
        // 列表上传
        formObj[fieldname.slice(0, index)] = [...(formObj[fieldname.slice(0, index)] || []), val]
      */
      const realFieldname = fieldname.endsWith('[]') ? fieldname.slice(0, -2) : fieldname
    })

    busboy.on('field', (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) => {
      // 普通字段
    })
    busboy.on('finish', async () => {
      try {
        if (promiseFiles.length > 0) {
          await Promise.all(promiseFiles)
        }
        console.log('finished...')
        resolve(formObj as T)
      } catch (e) {
        reject(e)
      }
    })
    busboy.on('error', (err: Error) => {
      reject(err)
    })
    ctx.req.pipe(busboy)
  })
}typescript 复制代码

7. HTTPS创建

因为博客也全部迁移到了https,这里就讲解一下如何在本地生成证书,在本地进行https开发。
通过openssl颁发证书

文章参考搭建Node.js本地https服务
我们在src/servers/ssl文件下创建我们的证书

  1. 生成CA私钥 openssl genrsa -out ca.key 4096
    生成CA私钥

  2. 生成证书签名请求 openssl req -new -key ca.key -out ca.csr
    生成证书签名请求

  3. 证书签名,生成根证书 openssl x509 -req -in ca.csr -signkey ca.key -out ca.crt
    证书签名,生成根证书

通过上面的步骤生成的根证书ca.crt,双击导入这个证书,设为始终信任
始终信任

上面我们就把自己变成了CA,接下为我们的server服务申请证书

  1. 创建两个配置文件
  • server.csr.conf
# server.csr.conf
# 生成证书签名请求的配置文件
[req]
default_bits = 4096
prompt = no
distinguished_name = dn

[dn]
CN = localhost # Common Name 域名conf 复制代码
  • v3.ext,这里在[alt_names]下面填入你当前的ip,因为在代码中的我会通过ip访问在本地手机访问。所以我打包的时候是通过ip访问的一些文件。
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names

[alt_names]
DNS.1 = localhost
DNS.2 = 127.0.0.1
IP.1 = 192.168.0.47conf 复制代码
  1. 申请证书
  • 生成服务器的私钥 openssl genrsa -out server.key 4096
    生成服务器的私钥

  • 生成证书签名请求 openssl req -new -out server.csr -key server.key -config <( cat server.csr.conf )
    生成证书签名请求

  • CA对csr签名 openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -sha256 -days 365 -extfile v3.ext
    CA对csr签名

生成的所有文件

生成的所有文件

在node服务引入证书

const serverConfig.httpsOptions = {
  key: fs.readFileSync(path.resolve(paths.serverPath, `ssl/server.key`)),
  cert: fs.readFileSync(path.resolve(paths.serverPath, `ssl/server.crt`))
}

https.createServer(serverConfig.httpsOptions, app.callback()).listen(rootConfig.app.server.httpsPort, '0.0.0.0', () => {
  console.log('项目启动啦~~~~~')
})typescript 复制代码

至此,本地的https证书搭建完成,你就可以快乐的在本地开启https之旅了

结语

整个博客流程大概就是这些了,还有一些没有做太多讲解,只是贴了个大概的代码。所以想看具体的话,直接去看源码就行。

这篇文章讲的主要是本地进行项目的开发,后续还有如何把本地服务放到线上。因为发表博客有文字长度限制,这篇文章我就没有介绍如何把开发环境的项目发布到生成环境上。后续我会发表一篇如何在阿里云上搭建一个服务,https免费证书以及解析域名进行nginx配置来建立不同的服务。

博客其实还有不少有缺陷的。还有一些我想好要弄还没弄上去的东西。

  • 后台管理单独拆分出来。
  • 服务端api模块单独拆分出来,建立一个管理api相关的服务。
  • 共用的工具类,包括客户端跟管理后台有不少共用的组件和hooks,统一放到私服上,毕竟到时候这几个端都要拆分的。
  • 用Docker来搭建部署,因为新人买服务器便宜么,我买了几次,然后到期就得迁移,每次都是各种环境配置,可麻烦,后面听说有docker可以解决这写问题,我就简单的研究过一下,所以这次也打算使用docker,主要是服务器也快到期了,续费也不便宜😭😭。以前双十一直接买的,现在续费,还挺贵。我都寻思是不是换个服务器。所以换上docker的话,应该能省点事
  • CI/CD持续集成,我现在开发都是上传git,然后进入服务器,pull下来再打包,也可麻烦😂😂,所以这个也是打算集成上去的。

Github完整代码地址

文章地址

博客在线地址

作为一个非科班的野路子过来人,基本都是自己摸索过河的。对于很多东西也是一知半解,但是我尽量会在自己了解的范围进行讲解,可能会出现技术上的一些问题理解不正确。还有博客功能基本是自己搭的,很多东西不一定全面,包括也没做太多的测试,难免会有很多不足之处,如有错误之处,希望大家指出,我会尽量完善这些缺陷,谢谢。

我自己新创建了一个相互学习的群,大家如果有不懂的,我能知道的,我会尽量解答。如果我有不懂的地方,也希望大家指教。

QQ群:810018802, 点击加入
QQ群:810018802

  • 表情
  • 预览
可使用部分markdown语法

大神到访数:25186