13
头图

Preface

Because I built my own blog system before. At that time, the front end of the blog system was basically based on vue , but now I use a react , so I used react to reconstruct the entire blog system once, as well as the previous ones. Many of the issues have been changed and optimized. The system has performed the processing SSR

Blog address portal

The complete code of this project: GitHub repository

This article is a long one and will be introduced from the following aspects:

  1. core technology stack
  2. directory structure
  3. project environment start
  4. Server-side source code analysis
  5. Client-side source code analysis
  6. Admin side source code analysis
  7. HTTPS create

Core technology stack

  1. React 17.x (React Family Bucket)
  2. Typescript 4.x
  3. Koa 2.x
  4. Webpack 5.x
  5. Babel 7.x
  6. Mongodb (database)
  7. eslint + stylelint + prettier (for code format control)
  8. husky + lint-staged + commitizen + commitlint (check the code format submitted by git and check the commit process)

The core is probably some of the above technology stacks, and then functional development based on the various needs of the blog. For example, some functions such as the jsonwebtoken @loadable , log4js modules used in authorization, I will expand the length of each functional module to explain.

package.json configuration file address

Detailed directory structure

|-- 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模板

The above is the approximate file directory of the project. The basic functions of the files have been described above. Below I will detail the implementation process of the blog function. At present, the blog system has not been separated from each end, and there will be this plan next.

Project environment start

Make sure your node version 10.13.0 (LTS) above, because Webpack 5 of Node.js version requires at least 10.13.0 (LTS)

Execute the script, start the project

First start with the entry file:

"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. Execute the entry file 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')
}

Set the path alias, because there is currently no split at each end, so create an alias (alias) to find the file.

2. Enter the establishment of the development environment from the entry file

First export the configuration files of the respective environments of each end of 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 is basically not much different. At present, I posted a simple webpack configuration, which has configuration files for different environments of server, client, and admin. For details, please see the blog source code

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

Then separately processed admin and client and server end webpack profile

The above points need to be noted:

  • admin client have opened a service to process webpack files, which are packed in memory.
  • client side needs to pay attention to the reference path of the packaged file. Because it is SSR , the file needs to be directly rendered on the server side. I put the server side and the client side in two different services, so I need to client Pay attention to the reference path.
  • server end code is directly packaged in the dist file for startup, and it is not stored in the memory.
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 method is used to generate the service, the code is roughly as follows

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
}

The general logic of the webpack compilation under the development ( development ) environment is like this. There will be some webpack-dev-middle middleware in koa, etc. Here I only provide the general idea, you can take a closer look at the source code.

3. Build the production environment

For the construction of the generation environment, there is less processing, just pack it webpack

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

Then start the packaged entry file cross-env NODE_ENV=production node dist/server/entry.js

This piece is mainly webpack , these configuration files can be directly Click here to view

Server-side source code analysis

The webpack configuration extends from the above configuration to their entry file

// 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')
  • The entrance on the client side is /src/client/main.tsx
  • The entrance on the server side is /src/server/main.ts

Because the project uses SSR , we will analyze it step by step server side.

1. /src/server/main.ts entry file

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. Middleware processing

For middleware, I will mainly talk about log processing middleware loggerMiddleware and permission middleware authTokenMiddleware . Other middlewares don’t have much, so I won’t waste space on the introduction.

Log printing mainly uses the log4js library, and then based on the upper layer encapsulation of this library, different log files are created through different types of Loggers.
Encapsulates log printing of all requests, api log printing, and log printing of some third-party calls

1. Implementation of 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. Implementation of 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 authTokenMiddleware

The above is the processing of middleware.

3. Router processing logic

Here is router process of this, api this processing request is carried out primarily by the decorator

1. Create router, load api file

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

The above is api , and the following is how to deal with the interface and parameters of the decorator.

There are several points to note about decorators:

  1. Vscode needs to open the decorator javascript.implicitProjectConfig.experimentalDecorators: true , which seems to be unnecessary now, it will automatically detect the tsconfig.json file, and add it if necessary
  2. babel need to configure ['@babel/plugin-proposal-decorators', { legacy: true }] with babel-plugin-parameter-decorator two plug-ins, because @babel/plugin-proposal-decorators this plug-in can not be resolved @Arg, so plus babel-plugin-parameter-decorator plug-ins used to resolve @Arg

Come to the @server/decorators file, the following decorators are defined respectively

2. Summary of decorators

  • @Controller api, such as @Controller('/user) => /api/user
  • @Get Get request
  • @Post Post request
  • @Delete Delete request
  • @Put Put request
  • @Patch Patch request
  • @Query Query parameters such as https://localhost:3000?a=1&b=2 => {a: 1, b: 2}
  • @Body The parameters passed into the Body
  • @Params Params parameters such as https://localhost:3000/api/user/123 => /api/user/:id => @Params('id') id:string => 123
  • @Ctx object
  • @Header Header object can also obtain a value in the Header separately @Header() Get the entire object of the header, @Header('Content-Type') Get the value of the Content-Type attribute in the header
  • @Req Req object
  • @Request Request object
  • @Res Res object
  • @Response Response object
  • @Cookie Cookie object can also obtain a value in Cookie separately
  • @Session Session object can also obtain a value in the Session separately
  • @Middleware binding middleware, can be accurate to a certain request
  • @Token Get the token value, the definition of this is mainly to facilitate the acquisition of the token

Let's talk about how these decorators are processed

3. Create metadata 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 implementation

// 示例, 所有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 implementation

The implementation of these decorators is basically the same, just list one for demonstration

// 示例,把@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
}

The above is the binding of action

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

Because these decorations are the decoration method parameters arguments , they can also be processed uniformly

// 示例  /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
}

For each of the above is action on arguments bound to achieve

7. @Middleware implementation

@Middleware decorator should not only be able to be Controller , but also on a certain 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()]
            }
          }
        }
      }
    */
  }
}

The above decorator basically records the packaging of the entire request in metadata ,
We return to bootstrapControllers method inside generateRoutes on,
Here is used to parse the metadata data, and then bind these data to the router.

8. Parse metadata and bind 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. Implementation of generateRoutes method

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

The above is the approximate process of parsing routing, there is a method _determineArgument to parse parameters

9. Implementation of _determineArgument method

  1. ctx , session , cookie , token , query , params , body This parameter cannot be obtained directly through ctx[source] , so it is handled separately
  2. The rest can be obtained through ctx[source] , just get it directly
// 对参数进行处理跟验证
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. Overall preview of Router Controller file

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

The above is the entire router related action binding

4. Implementation of SSR

SSR isomorphic code is actually explained a lot. Basically, you can find many tutorials in search engines. I will post a simple flowchart here to help you understand, and by the way, I will talk about my process ideas.
SSR同构

The above flow chart is just a general process, the specific data acquisition, data injection, optimization of the first screen style, etc., I will use part of the code below to explain
Here are the plug-ins @loadable/server , @loadable/component , @loadable/babel-plugin

1. Front end part of the code

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

The logic required for the front-end is probably these, the focus is still on the processing of the server

2. Server-side processing code

// 由@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 = html

3. Execution process

  • By @loadable/babel-plugin packed out loadable-stats.json file to determine the dependence
  • Parse the file through @loadable/server in ChunkExtractor , and return the directly manipulated object
  • ChunkExtractor.collectChunks related components, get js and style files
  • Assign the obtained js and css files to the HTML template and return it to the front end,
  • Use the inline style style tag to render the style of the first screen to avoid style errors on the first screen.
  • _init the data obtained by calling the component window.__PRELOADED_STATE__
  • The front-end obtains window.__PRELOADED_STATE__ data and synchronizes it to the client's store
  • The front end fetches the js file and re-executes the rendering process. Binding react events, etc.
  • Front-end takeover page

4. Token processing

When doing SSR , when the user logs in, a question about the token will be raised. token in, cookie will be saved to 0608537802baca. When the time token obtain personal information directly through 0608537802bacc
Normally not SSR , normal request interface separating front and rear ends, are from Client end => server side, so the interface cookie each time carrying token , also can be accessible to each interface in token .
But when doing SSR , the first load was done on the server side, so the interface request was done on the server side. At this time, you can't get token in the interface.

I have tried several methods:

  • When the request comes, get token , and then store it in store . When acquiring user information, take out the token in the store and pass in the URL, like this: /api/user?token=${token} , but in this case, if there are many interfaces that require tokens, Then I don’t have to pass it all. That's too much trouble.
  • Then I wondered if I could pass the token in the store to the header of axios, so that I didn't need to write everything. But I thought of several ways, but I didn't think of how to put the token in the store into the request header, because the store is to be isolated. After I generate the store, I can only pass it to the component. At most, when the request is called in the component, the parameters are passed on. Then, isn't it the same as writing each one.
  • Finally, I also forgot where I saw an article. I can save the token to the requested instance. I use axios, so I want to assign it to the axios instance as an attribute. But there is a problem to pay attention to, axios has to be isolated on the server at this time. Otherwise, it will be shared by all users.
Code
/* @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') || ''
})

Basically, the functions of the server are about these, and there are some other function points without wasting space to explain.

Client-side source code analysis

1. Routing processing

Because some routes have layout layouts, such as the homepage, blog details, etc. pages, all have public navigation and the like. And like the 404 page, the error page does not have these layouts.
Therefore, these two routes are distinguished because they are also equipped with two sets of loading animations.
Based on the transition animation of the layout part, it also distinguishes the transition mode of pc and mobile.

PC transition animation
pc过渡动画

Mobile transition animation
mobile过渡动画

The transition animation is implemented react-transition-group
Change different classNames to perform different animations through the forward and backward routing of the route.

  • router-forward : forward, enter a new page
  • router-back : return
  • router-fade : Transparency change, used for page replacement
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>
  )
}

The realization of forward and backward animation involves the forward and backward of the browser itself, not just the forward and backward control of the page.
Therefore, it is necessary to record routing changes to determine whether to move forward or backward. You cannot judge by the action of history alone.

  • history.action === 'PUSH' must be considered forward, because this is when we trigger the click to enter a new page.
  • history.action === 'POP' may be triggered by history.back(), or it may be triggered by the forward and back buttons that come with the browser system.
  • The next thing to do is how to distinguish between the forward and backward of the browser system. Code implementation in useRouterEach the hook and getRouterDirection inside method.
  • useRouterEach hook function
// useRouterEach
export const useRouterEach = () => {
  const location = useLocation()
  const dispatch = useDispatch()
  // 更新导航记录
  useEffect(() => {
    dispatch(
      updateNaviagtion({
        path: location.pathname,
        key: location.key || ''
      })
    )
  }, [location, dispatch])
}
  • updateNaviagtion has made a route record addition, deletion, and location.key , because every time you enter a new page, 0608537802be18 will generate a new key , we can use key to record whether this route is new or old, and the new one is in push to navigations , if it is already If this record exists, you can directly intercept the previous routing record of this record, and then update navigations What is done here is the record of the entire navigation
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
      }
    }
  }
}
  • getRouterDirection method, get the data of navigations location.key to determine whether the route is in navigations , if it is, it is proved to return, if it is not, it is to go forward. In this way, it can be distinguished whether the browser is entering a new page forward, or an old page returning back.
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'
  }
}
Routing switch logic
  1. history.action === 'PUSH' proves to be forward
  2. If it is history.action === 'POP' , use location.key to record the navigations to determine whether this page is a new page or a page that has already been visited. To distinguish whether it is going forward or backward
  3. Execute the respective route transition animation through the acquired forward or back

2. Theme change

Use css variable to make skinning effect, declare multiple theme styles 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'
}

Then select a style and assign it to the style tag as a global css variable style. When rendering on the server side, insert a style tag of id=style-variables
You can control the content in the style tag through JS, just replace it directly, it is more convenient to switch the theme, but this thing is not compatible with IE, if you want to use it and need to be compatible with IE, you can use css-vars-ponyfill To deal with css variables.

<style id="style-variables">
  {`:root {${Object.keys(theme.light)
    .map((

咚子
3.3k 声望12.5k 粉丝

一个前端