萬字長文詳解如何搭建一個屬於本身的博客(純手工搭建💪💪)

前言

由於本身之前就搭建了本身的博客系統,那時候博客系統前端基本上都是基於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

相關文章
相關標籤/搜索