关于node.js:万字长文详解如何搭建一个属于自己的博客纯手工搭建💪💪

前言

因为本人以前就搭建了本人的博客零碎,那时候博客零碎前端基本上都是基于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的实现

// 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同构的代码其实解说挺多的,根本轻易在搜索引擎搜寻就能有很多教程,我这里贴一个简略的流程图帮忙大家了解下,顺便讲下我的流程思路

下面流程图这只是一个大略的流程,具体外面数据的获取,数据的注水,优化首屏款式等等,我会在下方用局部代码进行阐明
此处有用到插件@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>
  )
)
// 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. 执行流程

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

4. Token的解决

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

我尝试了已下几种办法:

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

代码实现

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

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

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

基本上服务端的性能大略就是这些,还有一些别的性能点就不节约篇幅进行解说了。

Client端源码解析

1. 路由解决

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

PC过渡动画

Mobile过渡动画

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

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

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

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

路由切换逻辑

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

2. 主题换肤

通过css变量来做换肤成果,在theme文件外面申明多个主题款式

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

而后抉择一个款式赋值到style标签外面作为全局css变量款式,在服务端渲染的时候,在HTML模板外面插入了一条id=style-variables的style标签。
能够通过JS来管制style标签外面的内容,间接替换就好,比拟不便的进行主题切换,不过这玩意不兼容IE,如果你想用他,又须要兼容ie,能够应用css-vars-ponyfill来解决css变量。

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

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

不过博客没有做主题切换,主题切换倒是简略,反正我也不打算兼容ie什么的,原本想做来着,然而搭配色彩切实对我有点艰难😢😢,寻思一下临时不思考了。原本UI也是各种看他人难看的博客怎么设计的,本人也是仿着他人的设计,在加上本人的一点点设计。才弄出的UI。失常能看就挺好了,就没搞主题了,当前再加,哈哈。

3. 应用Sentry做我的项目监控

Sentry地址

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

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

export default Sentry

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

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

服务端同理,通过Sentry.captureException来提交谬误,申明对应的中间件进行谬误拦挡而后提交谬误就行

4. 前端局部性能点

简略介绍下其余的性能点,有些就不进行解说了,根本都比较简单,间接看博客源码就行

1. ReactDom.createPortal

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

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

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

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

export default Portal

2. 罕用hook的封装

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

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

  • 遮罩层底下需不需要禁止滚动。
  • 遮罩层需不需要禁止滚动。
  • 遮罩层禁止滚动了,外面内容如果有滚动,如何让其能够滚动。不会因为触底或触顶导致触发遮罩层底部的滚动。

代码实现

import { useEffect } from 'react'

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

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

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

client端还有一些别的性能点就不进行解说了,因为博客须要搭建的模块也不多。能够间接去观看博客源码

6. Admin端源码解析

后盾治理端其实跟客户端差不多,我用的antdUI框架进行搭建的,间接用UI框架布局就行。基本上没有太多可说的,因为模块也不多。
原本还想做用户模块,派发不同权限的,寻思集体博客也就我本人用,切实用不上。如果大家有须要,我会在后盾治理增加一个对于权限调配的模块,来实现对于菜单,按钮的权限管制。
次要说下上面两个性能点

1.用户登录拦挡的实现

配合我下面所说的authTokenMiddleware中间件,能够实现用户登录拦挡,已登录的话,不在须要登录间接跳转首页,未登录拦挡进入登录页面。

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

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

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

export default AuthRoute

2. 上传文件以及文件夹

上传文件都是通过FormData进行对立上传,后盾通过busboy模块进行接管,uploadFile代码地址

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

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

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

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

7. HTTPS创立

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

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

  1. 生成CA私钥 openssl genrsa -out ca.key 4096
  2. 生成证书签名申请 openssl req -new -key ca.key -out ca.csr
  3. 证书签名,生成根证书 openssl x509 -req -in ca.csr -signkey ca.key -out ca.crt

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

下面咱们就把本人变成了CA,接下为咱们的server服务申请证书

  1. 创立两个配置文件

    • server.csr.conf
    # server.csr.conf
    # 生成证书签名申请的配置文件
    [req]
    default_bits = 4096
    prompt = no
    distinguished_name = dn
    
    [dn]
    CN = localhost # Common Name 域名
  • v3.ext,这里在[alt_names]上面填入你以后的ip,因为在代码中的我会通过ip拜访在本地手机拜访。所以我打包的时候是通过ip拜访的一些文件。
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names

[alt_names]
DNS.1 = localhost
DNS.2 = 127.0.0.1
IP.1 = 192.168.0.47
  1. 申请证书

    • 生成服务器的私钥 openssl genrsa -out server.key 4096
    • 生成证书签名申请 openssl req -new -out server.csr -key server.key -config <( cat server.csr.conf )
    • CA对csr签名 openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -sha256 -days 365 -extfile v3.ext

生成的所有文件

在node服务引入证书

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

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

至此,本地的https证书搭建实现,你就能够高兴的在本地开启https之旅了

结语

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

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

博客其实还有不少有缺点的。还有一些我想好要弄还没弄下来的货色。

  • 后盾治理独自拆分进去。
  • 服务端api模块独自拆分进去,建设一个治理api相干的服务。
  • 共用的工具类,包含客户端跟治理后盾有不少共用的组件和hooks,对立放到私服上,毕竟到时候这几个端都要拆分的。
  • 用Docker来搭建部署,因为新人买服务器便宜么,我买了几次,而后到期就得迁徙,每次都是各种环境配置,可麻烦,前面据说有docker能够解决这写问题,我就简略的钻研过一下,所以这次也打算应用docker,次要是服务器也快到期了,续费也不便宜😭😭。以前双十一间接买的,当初续费,还挺贵。我都寻思是不是换个服务器。所以换上docker的话,应该能省点事
  • CI/CD继续集成,我当初开发都是上传git,而后进入服务器,pull下来再打包,也可麻烦😂😂,所以这个也是打算集成下来的。

Github残缺代码地址

博客在线地址

作为一个非科班的野路子过来人,根本都是本人摸索过河的。对于很多货色也是只知其一;不知其二,然而我尽量会在本人理解的范畴进行解说,可能会呈现技术上的一些问题了解不正确。还有博客性能根本是本人搭的,很多货色不肯定全面,包含也没做太多的测试,难免会有很多不足之处,如有谬误之处,心愿大家指出,我会尽量欠缺这些缺点,谢谢。

我本人新创建了一个互相学习的群,大家如果有不懂的,我能晓得的,我会尽量解答。如果我有不懂的中央,也心愿大家指教。

QQ群:810018802, 点击退出

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理