万字长文详解如何搭建一个属于自己的博客(纯手工搭建)

前言

因为自己以前就搭建了自己的博客系统,那时候博客系统前端基本上都是基于vue的,而现在用的react偏多,于是用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 // 添加css样式前缀之类的东西
    |-- .prettierrc.js // 格式代码用的,统一风格
    |-- .sentryclirc // 项目监控Sentry
    |-- .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 // 工具类方法
    |   |-- 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 // 工具类方法
    |   |-- 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"

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')
}

设置路径别名,因为目前各端没有拆分,所以建立别名(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]
}

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 baseClientConfig

然后分别来处理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()

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
}

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

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

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

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

然后启动打包出来的入口文件 cross-env NODE_ENV=production node dist/server/entry.js

这块主要就是webpack的配置,这些配置文件可以直接点击这里进行查看

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')
  • 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给资源设置强缓跟协商缓存
    ...
*/
middleware(app)
/* 
  对api进行管理
*/
router(app)
/* 
  启动数据库,搭建SSR配置
*/
Promise.all([startMongodb(), SSR(app)])
  .then(() => {
     
    // 开启服务
    https.createServer(serverConfig.httpsOptions, app.callback()).listen(rootConfig.app.server.httpsPort, '0.0.0.0')
  })
  .catch((err) => {
     
    process.exit()
  })

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`
        })
      )
    }
  }
}

2. authTokenMiddleware的实现

万字长文详解如何搭建一个属于自己的博客(纯手工搭建)_第1张图片
// 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 authTokenMiddleware

以上是对中间件的处理。

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的收集。
      // 会把这些文件里面的所有请求收集到 metaData 里面的。下面会说到 metaData
      require(`@root/src/server/controllers${
       fileName}`)
    })
    // 绑定router
    generateRoutes(router, metadata, options)
  })
}

以上就是引入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

来到@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: {
     }
}

4. @Controller实现

// 示例, 所有TestController内部的请求都会带上`/test`前缀 => /api/test/example
// @Controller(['/test', '/test1'])也可以是数组,那样就会创建两个请求 /api/test/example 跟 /api/test1/example
@Controller('/test')
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
  }
}

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
}

上面便是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
}

上面就是对于每个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添加中间件
      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()]
            }
          }
        }
      }
    */
  }
}

以上的装饰器基本就把整个请求进行的包装记录在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)
  })
}

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[]))
  })
}

上面就是解析路由的大概流程,里面有个方法 _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)} 参数错误`)
}

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
    }
  }
}

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

4. SSR的实现

SSR同构的代码其实讲解挺多的,基本随便在搜索引擎搜索就能有很多教程,我这里贴一个简单的流程图帮助大家理解下,顺便讲下我的流程思路
万字长文详解如何搭建一个属于自己的博客(纯手工搭建)_第2张图片

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

  • @loadable/component: 用于动态加载组件
  • @loadable/server: 收集服务端的脚本和样式文件,插入服务端直出的html中,用于客户端的再次渲染。
  • @loadable/babel-plugin: 生成json文件,统计依赖文件

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')
  )
})

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

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组件模板
// 通过插入style标签的样式防止首屏加载样式错乱
// 把store里面的数据注入到 window.__PRELOADED_STATE__ 对象上,然后在客户端进行获取,同步store数据
const HTML = ({ children, helmetContext: { helmet }, scripts, styles, inlineStyle, links, state, favicon }: Props) => {
return (



{hasTitle ? titleComponents : {rootConfig.head.title}}
{helmet.base.toComponent()}
{metaComponents}
{helmet.link.toComponent()}
{helmet.script.toComponent()}
{links}

// 此处直接传入style标签的样式,避免首次进入样式错误的问题
{inlineStyle}
// 在此处实现数据注水,把store中的数据赋值到window.__PRELOADED_STATE__上


你可能感兴趣的:(reactjs,typescript,node.js,webpack,javascript)