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

前言

由于本身之前就搭建了本身的博客系统,那时候博客系统前端基本上都是基于vue的,而如今用的react偏多,因而用react对整个博客系统进行了一次重构,还有对之前存在的不少问题进行了更改与优化。系统都进行了服务端渲染SSR的处理。javascript

博客地址传送门css

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

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

  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模块等等一些功能,我会下面各个功能模块展开篇幅进行讲解。vue

package.json 配置文件地址java

目录结构详解

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

项目环境启动

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

执行脚本,启动项目

首先从入口文件开始:webpack

"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)好查找文件。ios

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中间件的处理逻辑

// 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同构的代码其实讲解挺多的,基本随便在搜索引擎搜索就能有不少教程,我这里贴一个简单的流程图帮助你们理解下,顺便讲下个人流程思路 SSR同构

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

1. 前端部分代码

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

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

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

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

复制代码

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

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过渡动画 pc过渡动画

Mobile过渡动画 mobile过渡动画

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

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

动画前进后退的实现由于涉及到浏览器自己的前进后退,不单纯只是页面内咱们操控的前进后退。 因此就须要记录路由变化,来肯定是前进仍是后退,不能只靠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

生成CA私钥

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

生成证书签名请求

  1. 证书签名,生成根证书 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

CA对csr签名

生成的全部文件

生成的全部文件

在node服务引入证书

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

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

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

结语

整个博客流程大概就是这些了,还有一些没有作太多讲解,主要是掘金字数超出了😭😭,只是贴了个大概的代码。因此想看具体的话,直接去看源码就行。

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

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

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

Github完整代码地址

文章地址

博客在线地址

做为一个非科班的野路子过来人,基本都是本身摸索过河的。对于不少东西也是只知其一;不知其二,可是我尽可能会在本身了解的范围进行讲解,可能会出现技术上的一些问题理解不正确。还有博客功能基本是本身搭的,不少东西不必定全面,包括也没作太多的测试,不免会有不少不足之处,若有错误之处,但愿你们指出,我会尽可能完善这些缺陷,谢谢。

我本身新建立了一个相互学习的群,你们若是有不懂的,我能知道的,我会尽可能解答。若是我有不懂的地方,也但愿你们指教。

QQ群:810018802, 点击加入

QQ群:810018802

相关文章
相关标签/搜索