萬字長文詳解如何搭建一個屬於自己的部落格(純手工搭建??)

dongzi發表於2021-04-24

前言

因為自己以前就搭建了自己的部落格系統,那時候部落格系統前端基本上都是基於vue的,而現在用的react偏多,於是用react對整個部落格系統進行了一次重構,還有對以前存在的很多問題進行了更改與優化。系統都進行了服務端渲染SSR的處理。

部落格地址傳送門

本專案完整的程式碼:GitHub 倉庫

本文篇幅較長,會從以下幾個方面進行展開介紹:

  1. 核心技術棧
  2. 目錄結構詳解
  3. 專案環境啟動
  4. Server端原始碼解析
  5. Client端原始碼解析
  6. Admin端原始碼解析
  7. HTTPS建立

核心技術棧

  1. React 17.x (React 全家桶)
  2. Typescript 4.x
  3. Koa 2.x
  4. Webpack 5.x
  5. Babel 7.x
  6. Mongodb (資料庫)
  7. eslint + stylelint + prettier (進行程式碼格式控制)
  8. husky + lint-staged + commitizen +commitlint (進行 git 提交的程式碼格式校驗跟 commit 流程校驗)

核心大概就是以上的一些技術棧,然後基於部落格的各種需求進行功能開發。像例如授權用到的jsonwebtoken,@loadable,log4js模組等等一些功能,我會下面各個功能模組展開篇幅進行講解。

package.json 配置檔案地址

目錄結構詳解

|-- blog-source
    |-- .babelrc.js   // babel配置檔案
    |-- .commitlintrc.js // git commit格式校驗檔案,commit格式不通過,禁止commit
    |-- .cz-config.js // cz-customizable的配置檔案。我採用的cz-customizable來做的commit規範,自己自定義的一套
    |-- .eslintignore // eslint忽略配置
    |-- .eslintrc.js // eslint配置檔案
    |-- .gitignore // git忽略配置
    |-- .npmrc // npm配置檔案
    |-- .postcssrc.js // 新增css樣式字首之類的東西
    |-- .prettierrc.js // 格式程式碼用的,統一風格
    |-- .sentryclirc // 專案監控Sentry
    |-- .stylelintignore // style忽略配置
    |-- .stylelintrc.js // stylelint配置檔案
    |-- package.json
    |-- tsconfig.base.json // ts配置檔案
    |-- tsconfig.json // ts配置檔案
    |-- tsconfig.server.json // ts配置檔案
    |-- build // Webpack構建目錄, 分別給client端,admin端,server端進行區別構建
    |   |-- paths.ts
    |   |-- utils.ts
    |   |-- config
    |   |   |-- dev.ts
    |   |   |-- index.ts
    |   |   |-- prod.ts
    |   |-- webpack
    |       |-- admin.base.ts
    |       |-- admin.dev.ts
    |       |-- admin.prod.ts
    |       |-- base.ts
    |       |-- client.base.ts
    |       |-- client.dev.ts
    |       |-- client.prod.ts
    |       |-- index.ts
    |       |-- loaders.ts
    |       |-- plugins.ts
    |       |-- server.base.ts
    |       |-- server.dev.ts
    |       |-- server.prod.ts
    |-- dist // 打包output目錄
    |-- logs // 日誌列印目錄
    |-- private // 靜態資源入口目錄,設定了多個
    |   |-- third-party-login.html
    |-- publice // 靜態資源入口目錄,設定了多個
    |-- scripts // 專案執行指令碼,包括啟動,打包等等
    |   |-- build.ts
    |   |-- config.ts
    |   |-- dev.ts
    |   |-- start.ts
    |   |-- utils.ts
    |   |-- plugins
    |       |-- open-browser.ts
    |       |-- webpack-dev.ts
    |       |-- webpack-hot.ts
    |-- src // 核心原始碼
    |   |-- client // 客戶端程式碼
    |   |   |-- main.tsx // 入口檔案
    |   |   |-- tsconfig.json // ts配置
    |   |   |-- api // api介面
    |   |   |-- app // 入口元件
    |   |   |-- appComponents // 業務元件
    |   |   |-- assets // 靜態資源
    |   |   |-- components // 公共元件
    |   |   |-- config // 客戶端配置檔案
    |   |   |-- contexts // context, 就是用useContext建立的,用來元件共享狀態的
    |   |   |-- global // 全域性進入client需要進行呼叫的方法。像類似window上的方法
    |   |   |-- hooks // react hooks
    |   |   |-- pages // 頁面
    |   |   |-- router // 路由
    |   |   |-- store // Store目錄
    |   |   |-- styles // 樣式檔案
    |   |   |-- theme // 樣式主題檔案,做換膚效果的
    |   |   |-- types // ts型別檔案
    |   |   |-- utils // 工具類方法
    |   |-- admin // 後臺管理端程式碼,同客戶端差不太多
    |   |   |-- .babelrc.js
    |   |   |-- app.tsx
    |   |   |-- main.tsx
    |   |   |-- tsconfig.json
    |   |   |-- api
    |   |   |-- appComponents
    |   |   |-- assets
    |   |   |-- components
    |   |   |-- config
    |   |   |-- hooks
    |   |   |-- pages
    |   |   |-- router
    |   |   |-- store
    |   |   |-- styles
    |   |   |-- types
    |   |   |-- utils
    |   |-- models // 介面模型
    |   |-- server // 服務端程式碼
    |   |   |-- main.ts // 入口檔案
    |   |   |-- config // 配置檔案
    |   |   |-- controllers // 控制器
    |   |   |-- database // 資料庫
    |   |   |-- decorators // 裝飾器,封裝了@Get,@Post,@Put,@Delete,@Cookie之類的
    |   |   |-- middleware // 中介軟體
    |   |   |-- models // mongodb模型
    |   |   |-- router // 路由、介面
    |   |   |-- ssl // https證照,目前我是本地開發用的,線上如果用nginx的話,在nginx處配置就行
    |   |   |-- ssr // 頁面SSR處理
    |   |   |-- timer // 定時器
    |   |   |-- utils // 工具類方法
    |   |-- shared // 多端共享的程式碼
    |   |   |-- loadInitData.ts
    |   |   |-- type.ts
    |   |   |-- config
    |   |   |-- utils
    |   |-- types // ts型別檔案
    |-- static // 靜態資源
    |-- template // html模板

以上就是專案大概的檔案目錄,上面已經描述了檔案的基本作用,下面我會詳細部落格功能的實現過程。目前部落格系統各端沒有拆分出來,接下里會有這個打算。

專案環境啟動

確保你的node版本在10.13.0 (LTS)以上,因為Webpack 5Node.js 的版本要求至少是 10.13.0 (LTS)

執行指令碼,啟動專案

首先從入口檔案開始:

"dev": "cross-env NODE_ENV=development TS_NODE_PROJECT=tsconfig.server.json ts-node --files scripts/start.ts"
"prod": "cross-env NODE_ENV=production TS_NODE_PROJECT=tsconfig.server.json ts-node --files scripts/start.ts"

1. 執行入口檔案scripts/start.js

// scripts/start.js
import path from 'path'
import moduleAlias from 'module-alias'

moduleAlias.addAliases({
  '@root': path.resolve(__dirname, '../'),
  '@server': path.resolve(__dirname, '../src/server'),
  '@client': path.resolve(__dirname, '../src/client'),
  '@admin': path.resolve(__dirname, '../src/admin'),
})

if (process.env.NODE_ENV === 'production') {
  require('./build')
} else {
  require('./dev')
}

設定路徑別名,因為目前各端沒有拆分,所以建立別名(alias)好查詢檔案。

2. 由入口檔案進入開發development環境的搭建

首先匯出webpack各端的各自環境的配置檔案。

// dev.ts
import clientDev from './client.dev'
import adminDev from './admin.dev'
import serverDev from './server.dev'
import clientProd from './client.prod'
import adminProd from './admin.prod'
import serverProd from './server.prod'
import webpack from 'webpack'

export type Configuration = webpack.Configuration & {
  output: {
    path: string
  }
  name: string
  entry: any
}
export default (NODE_ENV: ENV): [Configuration, Configuration, Configuration] => {
  if (NODE_ENV === 'development') {
    return [clientDev as Configuration, serverDev as Configuration, adminDev as Configuration]
  }
  return [clientProd as Configuration, serverProd as Configuration, adminProd as Configuration]
}

webpack的配置檔案,基本不會有太大的區別,目前就貼一段簡單的webpack配置,分別有 server,client,admin 不同環境的配置檔案。具體可以看部落格原始碼

import webpack from 'webpack'
import merge from 'webpack-merge'
import { clientPlugins } from './plugins' // plugins配置
import { clientLoader } from './loaders' // loaders配置
import paths from '../paths'
import config from '../config'
import createBaseConfig from './base' // 多端預設配置

const baseClientConfig: webpack.Configuration = merge(createBaseConfig(), {
  mode: config.NODE_ENV,
  context: paths.rootPath,
  name: 'client',
  target: ['web', 'es5'],
  entry: {
    main: paths.clientEntryPath,
  },
  resolve: {
    extensions: ['.js', '.json', '.ts', '.tsx'],
    alias: {
      '@': paths.clientPath,
      '@client': paths.clientPath,
      '@root': paths.rootPath,
      '@server': paths.serverPath,
    },
  },
  output: {
    path: paths.buildClientPath,
    publicPath: paths.publicPath,
  },
  module: {
    rules: [...clientLoader],
  },
  plugins: [...clientPlugins],
})
export default baseClientConfig

然後分別來處理adminclientserver端的webpack配置檔案

以上幾個點需要注意:

  • admin端跟client端分別開了一個服務處理webpack的檔案,都打包在記憶體中。
  • client端需要注意打包出來檔案的引用路徑,因為是SSR,需要在服務端獲取檔案直接渲染,我把服務端跟客戶端打在不同的兩個服務,所以在服務端引用client端檔案的時候需要注意引用路徑。
  • server端程式碼直接打包在dist檔案下,用於啟動,並沒有打在記憶體中。
const WEBPACK_URL = `${__WEBPACK_HOST__}:${__WEBPACK_PORT__}`
const [clientWebpackConfig, serverWebpackConfig, adminWebpackConfig] = getConfig(process.env.NODE_ENV as ENV)
// 構建client 跟 server
const start = async () => {
  // 因為client指向的另一個服務,所以重寫publicPath路徑,不然會404
  clientWebpackConfig.output.publicPath = serverWebpackConfig.output.publicPath = `${WEBPACK_URL}${clientWebpackConfig.output.publicPath}`
  clientWebpackConfig.entry.main = [`webpack-hot-middleware/client?path=${WEBPACK_URL}/__webpack_hmr`, clientWebpackConfig.entry.main]
  const multiCompiler = webpack([clientWebpackConfig, serverWebpackConfig])
  const compilers = multiCompiler.compilers
  const clientCompiler = compilers.find((compiler) => compiler.name === 'client') as webpack.Compiler
  const serverCompiler = compilers.find((compiler) => compiler.name === 'server') as webpack.Compiler

  // 通過compiler.hooks用來監聽Compiler編譯情況
  const clientCompilerPromise = setCompilerTip(clientCompiler, clientWebpackConfig.name)
  const serverCompilerPromise = setCompilerTip(serverCompiler, serverWebpackConfig.name)

  // 用於建立服務的方法,在此建立client端的服務,至此,client端的程式碼便打入這個服務中, 可以通過像 https://192.168.0.47:3012/js/lib.js 訪問檔案
  createService({
    webpackConfig: clientWebpackConfig,
    compiler: clientCompiler,
    port: __WEBPACK_PORT__
  })
  let script: any = null
  // 重啟
  const nodemonRestart = () => {
    if (script) {
      script.restart()
    }
  }

  // 監聽server檔案更改
  serverCompiler.watch({ ignored: /node_modules/ }, (err, stats) => {
    nodemonRestart()
    if (err) {
      throw err
    }
    // ...
  })

  try {
    // 等待編譯完成
    await clientCompilerPromise
    await serverCompilerPromise
    // 這是admin編譯情況,admin端的編譯情況差不太多,基本也是執行`webpack(config)`進行編譯,通過`createService`生成一個服務用來訪問打包的程式碼。
    await startAdmin()

    closeCompiler(clientCompiler)
    closeCompiler(serverCompiler)
    logMsg(`Build time ${new Date().getTime() - startTime}`)
  } catch (err) {
    logMsg(err, 'error')
  }

  // 啟動server端編譯出來的入口檔案來啟動專案服務
  script = nodemon({
    script: path.join(serverWebpackConfig.output.path, 'entry.js')
  })
}
start()

createService方法用來生成服務, 程式碼大概如下

export const createService = ({webpackConfig, compiler}: {webpackConfig: Configurationcompiler: Compiler}) => {
  const app = new Koa()
  ...
  const dev = webpackDevMiddleware(compiler, {
    publicPath: webpackConfig.output.publicPath as string,
    stats: webpackConfig.stats
  })
  app.use(dev)
  app.use(webpackHotMiddleware(compiler))
  http.createServer(app.callback()).listen(port, cb)
  return app
}

開發(development)環境下的webpack編譯情況的大體邏輯就是這樣,裡面會有些webpack-dev-middle這些中介軟體在koa中的處理等,這裡我只提供了大體思路,可以具體細看原始碼。

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

對於生成環境的下搭建,處理就比較少了,直接通過webpack打包就行

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

然後啟動打包出來的入口檔案 cross-env NODE_ENV=production node dist/server/entry.js

這塊主要就是webpack的配置,這些配置檔案可以直接點選這裡進行檢視

Server端原始碼解析

由上面的配置webpack配置延伸到他們的入口檔案

// client入口
const clientPath = utils.resolve('src/client')
const clientEntryPath = path.join(clientPath, 'main.tsx')
// server入口
const serverPath = utils.resolve('src/server')
const serverEntryPath = path.join(serverPath, 'main.ts')
  • client端的入口是/src/client/main.tsx
  • server端的入口是/src/server/main.ts

因為專案用到了SSR,我們從server端來進行逐步分析。

1. /src/server/main.ts入口檔案

import Koa from 'koa'
...
const app = new Koa()
/* 
  中介軟體:
    sendMidddleware: 對ctx.body的封裝
    etagMiddleware:設定etag做快取 可以參考koa-etag,我做了下簡單修改,
    conditionalMiddleware: 判斷快取是否是否生效,通過ctx.fresh來判斷就好,koa內部已經封裝好了
    loggerMiddleware: 用來列印日誌
    authTokenMiddleware: 許可權攔截,這是admin端對api做的攔截處理
    routerErrorMiddleware:這是對api進行的錯誤處理
    koa-static: 對於靜態檔案的處理,設定max-age讓檔案強緩,配置etag或Last-Modified給資源設定強緩跟協商快取
    ...
*/
middleware(app)
/* 
  對api進行管理
*/
router(app)
/* 
  啟動資料庫,搭建SSR配置
*/
Promise.all([startMongodb(), SSR(app)])
  .then(() => {
    // 開啟服務
    https.createServer(serverConfig.httpsOptions, app.callback()).listen(rootConfig.app.server.httpsPort, '0.0.0.0')
  })
  .catch((err) => {
    process.exit()
  })

2.中介軟體的處理

對於中介軟體主要就講一講日誌處理中介軟體loggerMiddleware和許可權中介軟體authTokenMiddleware,別的中介軟體沒有太多東西,就不浪費篇幅介紹了。

日誌列印主要用到了log4js這個庫,然後基於這個庫做的上層封裝,通過不同型別的Logger來建立不同的日誌檔案。 封裝了所有請求的日誌列印,api的日誌列印,一些第三方的呼叫的日誌列印

1. loggerMiddleware的實現

// log.ts
const createLogger = (options = {} as LogOptions): Logger => {
  // 配置項
  const opts = {
    ...serverConfig.log,
    ...options
  }
  // 配置檔案
  log4js.configure({
    appenders: {
      // stout可以用於開發環境,直接列印出來
      stdout: {
        type: 'stdout'
      },
      // 用multiFile型別,通過變數生成不同的檔案,我試了別的幾種type。感覺都沒這種方便
      multi: { type: 'multiFile', base: opts.dir, property: 'dir', extension: '.log' }
    },
    categories: {
      default: { appenders: ['stdout'], level: 'off' },
      http: { appenders: ['multi'], level: opts.logLevel },
      api: { appenders: ['multi'], level: opts.logLevel },
      external: { appenders: ['multi'], level: opts.logLevel }
    }
  })
  const create = (appender: string) => {
    const methods: LogLevel[] = ['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'mark']
    const context = {} as LoggerContext
    const logger = log4js.getLogger(appender)
    // 重寫log4js方法,生成變數,用來生成不同的檔案
    methods.forEach((method) => {
      context[method] = (message: string) => {
        logger.addContext('dir', `/${appender}/${method}/${dayjs().format('YYYY-MM-DD')}`)
        logger[method](message)
      }
    })
    return context
  }
  return {
    http: create('http'),
    api: create('api'),
    external: create('external')
  }
}
export default createLogger


// loggerMiddleware
import createLogger, { LogOptions } from '@server/utils/log'
// 所有請求列印
const loggerMiddleware = (options = {} as LogOptions) => {
  const logger = createLogger(options)
  return async (ctx: Koa.Context, next: Next) => {
    const start = Date.now()
    ctx.log = logger
    try {
      await next()
      const end = Date.now() - start
      // 正常請求日誌列印
      logger.http.info(
        logInfo(ctx, {
          responseTime: `${end}ms`
        })
      )
    } catch (e) {
      const message = ErrorUtils.getErrorMsg(e)
      const end = Date.now() - start
      // 錯誤請求日誌列印
      logger.http.error(
        logInfo(ctx, {
          message,
          responseTime: `${end}ms`
        })
      )
    }
  }
}

2. authTokenMiddleware的實現

authTokenMiddleware中介軟體的處理邏輯

// 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私鑰

  2. 生成證照籤名請求 openssl req -new -key ca.key -out ca.csr 生成證照籤名請求

  3. 證照籤名,生成根證照 openssl x509 -req -in ca.csr -signkey ca.key -out ca.crt 證照籤名,生成根證照

通過上面的步驟生成的根證照ca.crt,雙擊匯入這個證照,設為始終信任 始終信任

上面我們就把自己變成了CA,接下為我們的server服務申請證照

  1. 建立兩個配置檔案
  • server.csr.conf
# server.csr.conf
# 生成證照籤名請求的配置檔案
[req]
default_bits = 4096
prompt = no
distinguished_name = dn

[dn]
CN = localhost # Common Name 域名
  • v3.ext,這裡在[alt_names]下面填入你當前的ip,因為在程式碼中的我會通過ip訪問在本地手機訪問。所以我打包的時候是通過ip訪問的一些檔案。
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names

[alt_names]
DNS.1 = localhost
DNS.2 = 127.0.0.1
IP.1 = 192.168.0.47
  1. 申請證照
  • 生成伺服器的私鑰 openssl genrsa -out server.key 4096 生成伺服器的私鑰

  • 生成證照籤名請求 openssl req -new -out server.csr -key server.key -config <( cat server.csr.conf ) 生成證照籤名請求

  • CA對csr簽名 openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -sha256 -days 365 -extfile v3.ext 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

相關文章