前言
因為自己以前就搭建了自己的部落格系統,那時候部落格系統前端基本上都是基於vue
的,而現在用的react
偏多,於是用react
對整個部落格系統進行了一次重構,還有對以前存在的很多問題進行了更改與優化。系統都進行了服務端渲染SSR
的處理。
本專案完整的程式碼:GitHub 倉庫
本文篇幅較長,會從以下幾個方面進行展開介紹:
核心技術棧
React 17.x
(React 全家桶)Typescript 4.x
Koa 2.x
Webpack 5.x
Babel 7.x
Mongodb
(資料庫)eslint
+stylelint
+prettier
(進行程式碼格式控制)husky
+lint-staged
+commitizen
+commitlint
(進行 git 提交的程式碼格式校驗跟 commit 流程校驗)
核心大概就是以上的一些技術棧,然後基於部落格的各種需求進行功能開發。像例如授權用到的jsonwebtoken
,@loadable
,log4js
模組等等一些功能,我會下面各個功能模組展開篇幅進行講解。
目錄結構詳解
|-- 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 5
對 Node.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
然後分別來處理admin
和client
和server
端的webpack
配置檔案
以上幾個點需要注意:
admin
端跟client
端分別開了一個服務處理webpack的檔案,都打包在記憶體中。client
端需要注意打包出來檔案的引用路徑,因為是SSR
,需要在服務端獲取檔案直接渲染,我把服務端跟客戶端打在不同的兩個服務,所以在服務端引用client
端檔案的時候需要注意引用路徑。server
端程式碼直接打包在dist
檔案下,用於啟動,並沒有打在記憶體中。
const WEBPACK_URL = `${__WEBPACK_HOST__}:${__WEBPACK_PORT__}`
const [clientWebpackConfig, serverWebpackConfig, adminWebpackConfig] = getConfig(process.env.NODE_ENV as ENV)
// 構建client 跟 server
const start = async () => {
// 因為client指向的另一個服務,所以重寫publicPath路徑,不然會404
clientWebpackConfig.output.publicPath = serverWebpackConfig.output.publicPath = `${WEBPACK_URL}${clientWebpackConfig.output.publicPath}`
clientWebpackConfig.entry.main = [`webpack-hot-middleware/client?path=${WEBPACK_URL}/__webpack_hmr`, clientWebpackConfig.entry.main]
const multiCompiler = webpack([clientWebpackConfig, serverWebpackConfig])
const compilers = multiCompiler.compilers
const clientCompiler = compilers.find((compiler) => compiler.name === 'client') as webpack.Compiler
const serverCompiler = compilers.find((compiler) => compiler.name === 'server') as webpack.Compiler
// 通過compiler.hooks用來監聽Compiler編譯情況
const clientCompilerPromise = setCompilerTip(clientCompiler, clientWebpackConfig.name)
const serverCompilerPromise = setCompilerTip(serverCompiler, serverWebpackConfig.name)
// 用於建立服務的方法,在此建立client端的服務,至此,client端的程式碼便打入這個服務中, 可以通過像 https://192.168.0.47:3012/js/lib.js 訪問檔案
createService({
webpackConfig: clientWebpackConfig,
compiler: clientCompiler,
port: __WEBPACK_PORT__
})
let script: any = null
// 重啟
const nodemonRestart = () => {
if (script) {
script.restart()
}
}
// 監聽server檔案更改
serverCompiler.watch({ ignored: /node_modules/ }, (err, stats) => {
nodemonRestart()
if (err) {
throw err
}
// ...
})
try {
// 等待編譯完成
await clientCompilerPromise
await serverCompilerPromise
// 這是admin編譯情況,admin端的編譯情況差不太多,基本也是執行`webpack(config)`進行編譯,通過`createService`生成一個服務用來訪問打包的程式碼。
await startAdmin()
closeCompiler(clientCompiler)
closeCompiler(serverCompiler)
logMsg(`Build time ${new Date().getTime() - startTime}`)
} catch (err) {
logMsg(err, 'error')
}
// 啟動server端編譯出來的入口檔案來啟動專案服務
script = nodemon({
script: path.join(serverWebpackConfig.output.path, 'entry.js')
})
}
start()
createService
方法用來生成服務, 程式碼大概如下
export const createService = ({webpackConfig, compiler}: {webpackConfig: Configurationcompiler: Compiler}) => {
const app = new Koa()
...
const dev = webpackDevMiddleware(compiler, {
publicPath: webpackConfig.output.publicPath as string,
stats: webpackConfig.stats
})
app.use(dev)
app.use(webpackHotMiddleware(compiler))
http.createServer(app.callback()).listen(port, cb)
return app
}
開發(development
)環境下的webpack
編譯情況的大體邏輯就是這樣,裡面會有些webpack-dev-middle
這些中介軟體在koa中的處理等,這裡我只提供了大體思路,可以具體細看原始碼。
3. 生成環境production環境的搭建
對於生成環境的下搭建,處理就比較少了,直接通過webpack
打包就行
webpack([clientWebpackConfig, serverWebpackConfig, adminWebpackConfig], (err, stats) => {
spinner.stop()
if (err) {
throw err
}
// ...
})
然後啟動打包出來的入口檔案 cross-env NODE_ENV=production node dist/server/entry.js
這塊主要就是webpack
的配置,這些配置檔案可以直接點選這裡進行檢視
Server端原始碼解析
由上面的配置webpack配置延伸到他們的入口檔案
// client入口
const clientPath = utils.resolve('src/client')
const clientEntryPath = path.join(clientPath, 'main.tsx')
// server入口
const serverPath = utils.resolve('src/server')
const serverEntryPath = path.join(serverPath, 'main.ts')
- client端的入口是
/src/client/main.tsx
- server端的入口是
/src/server/main.ts
因為專案用到了SSR
,我們從server端
來進行逐步分析。
1. /src/server/main.ts入口檔案
import Koa from 'koa'
...
const app = new Koa()
/*
中介軟體:
sendMidddleware: 對ctx.body的封裝
etagMiddleware:設定etag做快取 可以參考koa-etag,我做了下簡單修改,
conditionalMiddleware: 判斷快取是否是否生效,通過ctx.fresh來判斷就好,koa內部已經封裝好了
loggerMiddleware: 用來列印日誌
authTokenMiddleware: 許可權攔截,這是admin端對api做的攔截處理
routerErrorMiddleware:這是對api進行的錯誤處理
koa-static: 對於靜態檔案的處理,設定max-age讓檔案強緩,配置etag或Last-Modified給資源設定強緩跟協商快取
...
*/
middleware(app)
/*
對api進行管理
*/
router(app)
/*
啟動資料庫,搭建SSR配置
*/
Promise.all([startMongodb(), SSR(app)])
.then(() => {
// 開啟服務
https.createServer(serverConfig.httpsOptions, app.callback()).listen(rootConfig.app.server.httpsPort, '0.0.0.0')
})
.catch((err) => {
process.exit()
})
2.中介軟體的處理
對於中介軟體主要就講一講日誌處理中介軟體loggerMiddleware
和許可權中介軟體authTokenMiddleware
,別的中介軟體沒有太多東西,就不浪費篇幅介紹了。
日誌列印主要用到了log4js
這個庫,然後基於這個庫做的上層封裝,通過不同型別的Logger來建立不同的日誌檔案。
封裝了所有請求的日誌列印,api的日誌列印,一些第三方的呼叫的日誌列印
1. loggerMiddleware的實現
// log.ts
const createLogger = (options = {} as LogOptions): Logger => {
// 配置項
const opts = {
...serverConfig.log,
...options
}
// 配置檔案
log4js.configure({
appenders: {
// stout可以用於開發環境,直接列印出來
stdout: {
type: 'stdout'
},
// 用multiFile型別,通過變數生成不同的檔案,我試了別的幾種type。感覺都沒這種方便
multi: { type: 'multiFile', base: opts.dir, property: 'dir', extension: '.log' }
},
categories: {
default: { appenders: ['stdout'], level: 'off' },
http: { appenders: ['multi'], level: opts.logLevel },
api: { appenders: ['multi'], level: opts.logLevel },
external: { appenders: ['multi'], level: opts.logLevel }
}
})
const create = (appender: string) => {
const methods: LogLevel[] = ['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'mark']
const context = {} as LoggerContext
const logger = log4js.getLogger(appender)
// 重寫log4js方法,生成變數,用來生成不同的檔案
methods.forEach((method) => {
context[method] = (message: string) => {
logger.addContext('dir', `/${appender}/${method}/${dayjs().format('YYYY-MM-DD')}`)
logger[method](message)
}
})
return context
}
return {
http: create('http'),
api: create('api'),
external: create('external')
}
}
export default createLogger
// loggerMiddleware
import createLogger, { LogOptions } from '@server/utils/log'
// 所有請求列印
const loggerMiddleware = (options = {} as LogOptions) => {
const logger = createLogger(options)
return async (ctx: Koa.Context, next: Next) => {
const start = Date.now()
ctx.log = logger
try {
await next()
const end = Date.now() - start
// 正常請求日誌列印
logger.http.info(
logInfo(ctx, {
responseTime: `${end}ms`
})
)
} catch (e) {
const message = ErrorUtils.getErrorMsg(e)
const end = Date.now() - start
// 錯誤請求日誌列印
logger.http.error(
logInfo(ctx, {
message,
responseTime: `${end}ms`
})
)
}
}
}
2. authTokenMiddleware的實現
// authTokenMiddleware.ts
const authTokenMiddleware = () => {
return async (ctx: Koa.Context, next: Next) => {
// api白名單: 可以把 登入 註冊介面之類的設入白名單,允許訪問
if (serverConfig.adminAuthApiWhiteList.some((path) => path === ctx.path)) {
return await next()
}
// 通過 jsonwebtoken 來檢驗token的有效性
const token = ctx.cookies.get(rootConfig.adminTokenKey)
if (!token) {
throw {
code: 401
}
} else {
try {
jwt.verify(token, serverConfig.adminJwtSecret)
} catch (e) {
throw {
code: 401
}
}
}
await next()
}
}
export default authTokenMiddleware
以上是對中介軟體的處理。
3. Router的處理邏輯
下面是關於router
這塊的處理,api
這塊主要是通過裝飾器來進行請求的處理
1. 建立router,載入api檔案
// router.ts
import { bootstrapControllers } from '@server/controllers'
const router = new KoaRouter<DefaultState, Context>()
export default (app: Koa) => {
// 進行api的繫結,
bootstrapControllers({
router, // 路由物件
basePath: '/api', // 路由字首
controllerPaths: ['controllers/api/*/**/*.ts'], // 檔案目錄
middlewares: [routerErrorMiddleware(), loggerApiMiddleware()]
})
app.use(router.routes()).use(router.allowedMethods())
// api 404
app.use(async (ctx, next) => {
if (ctx.path.startsWith('/api')) {
return ctx.sendCodeError(404)
}
await next()
})
}
// bootstrapControllers方法
export const bootstrapControllers = (options: ControllerOptions) => {
const { router, controllerPaths } = options
// 引入檔案, 進而觸發裝飾器繫結controllers
controllerPaths.forEach((path) => {
// 通過glob模組查詢檔案
const files = glob.sync(Utils.resolve(`src/server/${path}`))
files.forEach((file) => {
/*
通過別名引入檔案
Why?
因為直接webpack打包引用變數無法找到模組
webpack打包出來的檔案都得到打包出來的引用路徑裡面去找,並不是實際路徑(__webpack_require__)
所以直接引入路徑會有問題。用別名引入。
有個問題還待解決,就是他會解析字串拼接的那個路徑下面的所有檔案
例如: require(`@root/src/server/controllers${fileName}`) 會解析@root/src/server/controllers下的所有檔案,
目前定位在這個檔案下可以防止解析過多的檔案導致node記憶體不夠,
這個問題待解決
*/
const p = Utils.resolve('src/server/controllers')
const fileName = file.replace(p, '')
// 直接require引入對應的檔案。直接引入便可以了,到時候會自動觸發裝飾器進行api的收集。
// 會把這些檔案裡面的所有請求收集到 metaData 裡面的。下面會說到 metaData
require(`@root/src/server/controllers${fileName}`)
})
// 繫結router
generateRoutes(router, metadata, options)
})
}
以上就是引入api
的方法,下面就是裝飾器的如何處理介面以及引數。
對於裝飾器有幾個需要注意的點:
- vscode需要開啟裝飾器
javascript.implicitProjectConfig.experimentalDecorators: true
,現在好像不需要了,會自動檢測tsconfig.json檔案,如果需要就加上 - 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方法的實現
ctx
,session
,cookie
,token
,query
,params
,body
這個引數沒法直接通過ctx[source]
獲取,所以單獨處理- 其餘可以通過
ctx[source]
獲取,就直接獲取了
// 對引數進行處理跟驗證
const _determineArgument = (ctx: Context, { options, source }: MetaDataArguments, opts: ControllerOptions) => {
let result
// 特殊處理的引數, `ctx`, `session`, `cookie`, `token`, `query`, `params`, `body`
if (_argumentInjectorTranslations[source]) {
result = _argumentInjectorTranslations[source](ctx, options, source)
} else {
// 普通能直接ctx獲取的,例如header, @header() -> ctx['header'], @Header('Content-Type') -> ctx['header']['Content-Type']
result = ctx[source]
if (result && options && typeof options === 'string') {
result = result[options]
}
}
return result
}
// 需要檢驗的引數,單獨處理
const _argumentInjectorTranslations = {
ctx: (ctx: Context) => ctx,
session: (ctx: Context, options: argumentOptions) => {
if (typeof options === 'string') {
return ctx.session[options]
}
return ctx.session
},
cookie: (ctx: Context, options: argumentOptions) => {
if (typeof options === 'string') {
return ctx.cookies.get(options)
}
return ctx.cookies
},
token: (ctx: Context, options: argumentOptions) => {
if (typeof options === 'string') {
return ctx.cookies.get(options) || ctx.header[options]
}
return ''
},
query: (ctx: Context, options: argumentOptions, source: argumentSource) => {
return _argumentInjectorProcessor(source, ctx.query, options)
},
params: (ctx: Context, options: argumentOptions, source: argumentSource) => {
return _argumentInjectorProcessor(source, ctx.params, options)
},
body: (ctx: Context, options: argumentOptions, source: argumentSource) => {
return _argumentInjectorProcessor(source, ctx.request.body, options)
}
} as Record<argumentSource, (...args: any) => any>
// 驗證操作返回值
const _argumentInjectorProcessor = (source: argumentSource, data: any, options: argumentOptions) => {
if (!options) {
return data
}
if (typeof options === 'string' && Type.isObject(data)) {
return data[options]
}
if (typeof options === 'object') {
if (options.value) {
const val = data[options.value]
// 必填,但是值為空,報錯
if (options.required && Type.isEmpty(val)) {
ErrorUtils.error(`[${source}] [${options.value}]引數不能為空`)
}
return val
}
// require陣列校驗
if (options.requiredList && Type.isArray(options.requiredList) && Type.isObject(data)) {
for (const key of options.requiredList) {
if (Type.isEmpty(data[key])) {
ErrorUtils.error(`[${source}] [${key}]引數不能為空`)
}
}
return data
}
if (options.required) {
if (Type.isEmptyObject(data)) {
ErrorUtils.error(`${source}中有必填引數`)
}
return data
}
}
ErrorUtils.error(`[${source}] ${JSON.stringify(options)} 引數錯誤`)
}
10. Router Controller檔案整體預覽
import {
Get,
Post,
Put,
Patch,
Delete,
Query,
Params,
Body,
Ctx,
Header,
Req,
Request,
Res,
Response,
Session,
Cookie,
Controller,
Middleware
} from '@server/decorators'
import { Context, Next } from 'koa'
import { IncomingHttpHeaders } from 'http'
const TestMiddleware = () => {
return async (ctx: Context, next: Next) => {
console.log('start TestMiddleware')
await next()
console.log('end TestMiddleware')
}
}
const ExampleMiddleware = () => {
return async (ctx: Context, next: Next) => {
console.log('start ExampleMiddleware')
await next()
console.log('end ExampleMiddleware')
}
}
@Middleware([TestMiddleware()])
@Controller('/test')
export class TestController {
@Middleware([ExampleMiddleware()])
@Get('/example')
async getExample(
@Ctx() ctx: Context,
@Header() header: IncomingHttpHeaders,
@Request() request: Request,
@Req() req: Request,
@Response() response: Response,
@Res() res: Response,
@Session() session: any,
@Cookie('token') Cookie: any
) {
console.log(ctx.response)
return {
ctx,
header,
request,
response,
Cookie,
session
}
}
@Get('/get/:name/:age')
async getFn(
@Query('id') id: string,
@Query({ required: true }) query: any,
@Params('name') name: string,
@Params('age') age: string,
@Params() params: any
) {
return {
method: 'get',
id,
query,
name,
age,
params
}
}
@Post('/post/:name/:age')
async getPost(
@Query('id') id: string,
@Params('name') name: string,
@Params('age') age: string,
@Params() params: any,
@Body('sex') sex: string,
@Body('hobby', true) hobby: any,
@Body() body: any
) {
return {
method: 'post',
id,
name,
age,
params,
sex,
hobby,
body
}
}
@Put('/put/:name/:age')
async getPut(
@Query('id') id: string,
@Params('name') name: string,
@Params('age') age: string,
@Params() params: any,
@Body('sex') sex: string,
@Body('hobby', true) hobby: any,
@Body() body: any
) {
return {
method: 'put',
id,
name,
age,
params,
sex,
hobby,
body
}
}
@Patch('/patch/:name/:age')
async getPatch(
@Query('id') id: string,
@Params('name') name: string,
@Params('age') age: string,
@Params() params: any,
@Body('sex') sex: string,
@Body('hobby', true) hobby: any,
@Body() body: any
) {
return {
method: 'patch',
id,
name,
age,
params,
sex,
hobby,
body
}
}
@Delete('/delete/:name/:age')
async getDelete(
@Query('id') id: string,
@Params('name') name: string,
@Params('age') age: string,
@Params() params: any,
@Body('sex') sex: string,
@Body('hobby', true) hobby: any,
@Body() body: any
) {
return {
method: 'delete',
id,
name,
age,
params,
sex,
hobby,
body
}
}
}
以上就是整個router
相關的action
繫結
4. SSR的實現
SSR同構的程式碼其實講解挺多的,基本隨便在搜尋引擎搜尋就能有很多教程,我這裡貼一個簡單的流程圖幫助大家理解下,順便講下我的流程思路
上面流程圖這只是一個大概的流程,具體裡面資料的獲取,資料的注水,優化首屏樣式等等,我會在下方用部分程式碼進行說明
此處有用到外掛@loadable/server
,@loadable/component
,@loadable/babel-plugin
@loadable/component
: 用於動態載入元件@loadable/server
: 收集服務端的指令碼和樣式檔案,插入服務端直出的html中,用於客戶端的再次渲染。@loadable/babel-plugin
: 生成json檔案,統計依賴檔案
1. 前端部分程式碼
/* home.tsx */
const Home = () => {
return Home
}
// 該元件需要依賴的介面資料
Home._init = async (store: IStore, routeParams: RouterParams) => {
const { data } = await api.getData()
store.dispatch(setDataState({ data }))
return
}
/* router.ts */
const routes = [
{
path: '/',
name: 'Home',
exact: true,
component: _import_('home')
},
...
]
/* app.ts */
const App = () => {
return (
<Switch location={location}>
{routes.map((route, index) => {
return (
<Route
key={`${index} + ${route.path}`}
path={route.path}
render={(props) => {
return (
<RouterGuard Com={route.component} {...props}>
{children}
</RouterGuard>
)
}}
exact={route.exact}
/>
)
})}
<Redirect to="/404" />
</Switch>
)
}
// 路由攔截判斷是否需要由前端發起請求
const RouterGuard = ({ Com, children, ...props }: any) => {
useEffect(() => {
const isServerRender = store.getState().app.isServerRender
const options = {
disabled: false
}
async function load() {
// 因為前面我們把頁面的介面資料放在元件的_init方法中,直接呼叫這個方法就可以獲取資料
// 首次進入,資料是交由服務端進行渲染,所以在客戶端不需要進行呼叫。
// 滿足非服務端渲染的頁面,存在_init函式,呼叫發起資料請求,便可在前端發起請求,獲取資料
// 這樣就能前端跟服務端共用一份程式碼發起請求。
// 這有很多實現方法,也有把介面函式繫結在route上的,看個人愛好。
if (!isServerRender && Com._init && history.action !== 'POP') {
setLoading(true)
await Com._init(store, routeParams.current, options)
!options.disabled && setLoading(false)
}
}
load()
return () => {
options.disabled = true
}
}, [Com, store, history])
return (
<div className="page-view">
<Com {...props} />
{children}
</div>
)
}
/* main.tsx */
// 前端獲取後臺注入的store資料,同步store資料,客戶端進行渲染
export const getStore = (preloadedState?: any, enhancer?: StoreEnhancer) => {
const store = createStore(rootReducers, preloadedState, enhancer) as IStore
return store
}
const store = getStore(window.__PRELOADED_STATE__, window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__())
loadableReady(() => {
ReactDom.hydrate(
<Provider store={store}>
<BrowserRouter>
<HelmetProvider>
<Entry />
</HelmetProvider>
</BrowserRouter>
</Provider>,
document.getElementById('app')
)
})
前端需要的邏輯大概就是這些,重點還是在服務端的處理
2. 服務端處理程式碼
// 由@loadable/babel-plugin外掛打包出來的loadable-stats.json路徑依賴表,用來索引各個頁面依賴的js,css檔案等。
const getStatsFile = async () => {
const statsFile = path.join(paths.buildClientPath, 'loadable-stats.json')
return new ChunkExtractor({ statsFile })
}
// 獲取依賴檔案物件
const clientExtractor = await getStatsFile()
// store每次載入時,都得重新生成,不能是單例,否則所有使用者都會共享一個store了。
const store = getStore()
// 匹配當前路由對應的route物件
const { route } = matchRoutes(routes, ctx.path)
if (route) {
const match = matchPath(decodeURI(ctx.path), route)
const routeParams = {
params: match?.params,
query: ctx.query
}
const component = route.component
// @loadable/component動態載入的元件具有load方法,用來載入元件的
if (component.load) {
const c = (await component.load()).default
// 有_init方法,等待呼叫,然後資料會存入Store中
c._init && (await c._init(store, routeParams))
}
}
// 通過ctx.url生成對應的服務端html, clientExtractor獲取對應路徑依賴
const appHtml = renderToString(
clientExtractor.collectChunks(
<Provider store={store}>
<StaticRouter location={ctx.url} context={context}>
<HelmetProvider context={helmetContext}>
<App />
</HelmetProvider>
</StaticRouter>
</Provider>
)
)
/*
clientExtractor:
getInlineStyleElements:style標籤,行內css樣式
getScriptElements: script標籤
getLinkElements: Link標籤,包括預載入的js css link檔案
getStyleElements: link標籤的樣式檔案
*/
const inlineStyle = await clientExtractor.getInlineStyleElements()
const html = createTemplate(
renderToString(
<HTML
helmetContext={helmetContext}
scripts={clientExtractor.getScriptElements()}
styles={clientExtractor.getStyleElements()}
inlineStyle={inlineStyle}
links={clientExtractor.getLinkElements()}
favicon={`${
serverConfig.isProd ? '/' : `${scriptsConfig.__WEBPACK_HOST__}:${scriptsConfig.__WEBPACK_PORT__}/`
}static/client_favicon.ico`}
state={store.getState()}
>
{appHtml}
</HTML>
)
)
// HTML元件模板
// 通過插入style標籤的樣式防止首屏載入樣式錯亂
// 把store裡面的資料注入到 window.__PRELOADED_STATE__ 物件上,然後在客戶端進行獲取,同步store資料
const HTML = ({ children, helmetContext: { helmet }, scripts, styles, inlineStyle, links, state, favicon }: Props) => {
return (
<html data-theme="light">
<head>
<meta charSet="utf-8" />
{hasTitle ? titleComponents : <title>{rootConfig.head.title}</title>}
{helmet.base.toComponent()}
{metaComponents}
{helmet.link.toComponent()}
{helmet.script.toComponent()}
{links}
<style id="style-variables">
{`:root {${Object.keys(theme.light)
.map((key) => `${key}:${theme.light[key]};`)
.join('')}}`}
</style>
// 此處直接傳入style標籤的樣式,避免首次進入樣式錯誤的問題
{inlineStyle}
// 在此處實現資料注水,把store中的資料賦值到window.__PRELOADED_STATE__上
<script
dangerouslySetInnerHTML={{
__html: `window.__PRELOADED_STATE__ = ${JSON.stringify(state).replace(/</g, '\\u003c')}`
}}
/>
<script async src="//at.alicdn.com/t/font_2062907_scf16rx8d6.js"></script>
</head>
<body>
<div id="app" className="app" dangerouslySetInnerHTML={{ __html: children }}></div>
{scripts}
</body>
</html>
)
}
ctx.type = 'html'
ctx.body = html
3. 執行流程
- 通過
@loadable/babel-plugin
打包出來的loadable-stats.json
檔案確定依賴 - 通過
@loadable/server
中的ChunkExtractor
來解析這個檔案,返回直接操作的物件 ChunkExtractor.collectChunks
關聯元件,獲取js跟樣式檔案- 把獲取的js,css檔案賦值到HTML模板上去,返回給前端,
- 用行內樣式style標籤渲染首屏的樣式,避免首屏出現樣式錯誤。
- 把通過呼叫元件
_init
方法獲取到的資料,注水到window.__PRELOADED_STATE__
中 - 前端獲取
window.__PRELOADED_STATE__
資料同步到客戶端的store裡面 - 前端取到js檔案,重新執行渲染流程。繫結react事件等等
- 前端接管頁面
4. Token的處理
做SSR
的時候使用者進行登入還會扯出一個關於token的問題。登入完後會把token
存到cookie
中。到時候直接通過token
獲取個人資訊
正常來說不做SSR
,正常前後端分離進行介面請求,都是從 client端 => server端
,所以介面中的cookie
每次都會攜帶token
,每次也都能在介面中取到token
。
但是在做SSR
的時候,首次載入時在服務端進行的,所以介面請求是在服務端進行的,這個時候你在介面中是獲取不到token
的。
我嘗試了已下幾種方法:
- 在請求過來的時候,把
token
獲取到,然後存入store
,在進行使用者資訊獲取的時候,取出store中的token傳入url,就像這樣:/api/user?token=${token}
,但是這樣的話,假如有好多介面需要token,那我不是每個都要傳。那也太麻煩了。 - 然後我就尋思能不能把store裡面的token傳到axios的header裡面,那樣不就不需要每個都寫了。但我想了好幾種辦法,都沒有想到怎麼把store裡面的token放到請求header中,因為store是要隔離的。我生成store之後,只能把他傳到元件裡面,最多就是在元件裡面呼叫請求的時候,傳參傳下去,那不還是一樣每個都要寫麼。
- 最後我也忘了是在哪看到一篇文章,可以把token存到請求的例項上,我用的axios,所以我就想把他賦值到axios例項上,作為一個屬性。但是要注意一個問題,axios這個時候在服務端就得做隔離了。不然就所有使用者就共用了。
程式碼實現
/* @client/utils/request.ts */
class Axios {
request() {
// 區分是服務端,還是瀏覽器端,服務端把token存在 axios例項屬性token上, 瀏覽器端就直接從cookie中獲取token就行
const key = process.env.BROWSER_ENV ? Cookie.get('token') : this['token']
if (key) {
headers['token'] = key
}
return this.axios({
method,
url,
[q]: data,
headers
})
}
}
import Axios from './Axios'
export default new Axios()
/* ssr.ts */
// 不要在外部引入,那樣就所有使用者共用了
// import Axios from @client/utils/request
// ssr程式碼實現
app.use(async (ctx, next) => {
...
// 在此處引入axios, 給他新增token屬性,這個時候每次請求都可以在header中放入token了,就解決了SSR token的問題
const request = require('@client/utils/request').default
request['token'] = ctx.cookies.get('token') || ''
})
基本上服務端的功能大概就是這些,還有一些別的功能點就不浪費篇幅進行講解了。
Client端原始碼解析
1. 路由處理
因為有的路由有layout佈局,像首頁,部落格詳情等等頁面,都有公共的導航之類的。而像404頁面,錯誤頁面是沒有這些佈局的。
所以區分了的這兩種路由,因為也配套了兩套loading動畫。
基於layout部分的過渡的動畫,也區分了pc 跟 mobile的過渡方式,
PC過渡動畫
Mobile過渡動畫
過渡動畫是由 react-transition-group
實現的。
通過路由的前進後退來改變不同的className來執行不同的動畫。
router-forward
: 前進,進入新頁面router-back
: 返回router-fade
: 透明度變化,用於頁面replace
const RenderLayout = () => {
useRouterEach()
const routerDirection = getRouterDirection(store, location)
if (!isPageTransition) {
// 手動或者Link觸發push操作
if (history.action === 'PUSH') {
classNames = 'router-forward'
}
// 瀏覽器按鈕觸發,或主動pop操作
if (history.action === 'POP') {
classNames = `router-${routerDirection}`
}
if (history.action === 'REPLACE') {
classNames = 'router-fade'
}
}
return (
<TransitionGroup appear enter exit component={null} childFactory={(child) => React.cloneElement(child, { classNames })}>
<CSSTransition
key={location.pathname}
timeout={500}
>
<Switch location={location}>
{layoutRoutes.map((route, index) => {
return (
<Route
key={`${index} + ${route.path}`}
path={route.path}
render={(props) => {
return (
<RouterGuard Com={route.component} {...props}>
{children}
</RouterGuard>
)
}}
exact={route.exact}
/>
)
})}
<Redirect to="/404" />
</Switch>
</CSSTransition>
</TransitionGroup>
)
}
動畫前進後退的實現因為涉及到瀏覽器本身的前進後退,不單純只是頁面內我們操控的前進後退。
所以就需要記錄路由變化,來確定是前進還是後退,不能只靠history的action來判斷
history.action === 'PUSH'
肯定是算前進,因為這是我們觸發點選進入新頁面才會觸發history.action === 'POP'
有可能是history.back()觸發,也有可能是瀏覽器系統自帶的前進,後退按鈕觸發,- 接下來要做的就是如何區分瀏覽器系統的前進和後退。程式碼實現就在
useRouterEach
這個hook和getRouterDirection
方法裡面。 useRouterEach
hook函式
// 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
來記錄這個路由是新的還是舊的,新的就push
到navigations
裡面,如果已經存在這條記錄,就可以直接擷取這條記錄以前的路由記錄就行,然後把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'
}
}
路由切換邏輯
history.action === 'PUSH'
證明是前進- 如果是
history.action === 'POP'
,通過location.key
去記錄好的navigations
來判斷這個頁面是新的頁面,還是已經到過的頁面。來區分是前進還是後退 - 通過獲取的
forward
或back
執行各自的路由過渡動畫。
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做專案監控
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的封裝
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端原始碼解析
後臺管理端其實跟客戶端差不多,我用的antd
UI框架進行搭建的,直接用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
檔案下建立我們的證照
-
生成CA私鑰
openssl genrsa -out ca.key 4096
-
生成證照籤名請求
openssl req -new -key ca.key -out ca.csr
-
證照籤名,生成根證照
openssl x509 -req -in ca.csr -signkey ca.key -out ca.crt
通過上面的步驟生成的根證照ca.crt,雙擊匯入這個證照,設為始終信任
上面我們就把自己變成了CA
,接下為我們的server
服務申請證照
- 建立兩個配置檔案
- 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
- 申請證照
-
生成伺服器的私鑰
openssl genrsa -out server.key 4096
-
生成證照籤名請求
openssl req -new -out server.csr -key server.key -config <( cat server.csr.conf )
-
CA對csr簽名
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -sha256 -days 365 -extfile v3.ext
生成的所有檔案
在node服務引入證照
const serverConfig.httpsOptions = {
key: fs.readFileSync(path.resolve(paths.serverPath, `ssl/server.key`)),
cert: fs.readFileSync(path.resolve(paths.serverPath, `ssl/server.crt`))
}
https.createServer(serverConfig.httpsOptions, app.callback()).listen(rootConfig.app.server.httpsPort, '0.0.0.0', () => {
console.log('專案啟動啦~~~~~')
})
至此,本地的https證照搭建完成,你就可以快樂的在本地開啟https
之旅了
結語
整個部落格流程大概就是這些了,還有一些沒有做太多講解,只是貼了個大概的程式碼。所以想看具體的話,直接去看原始碼就行。
這篇文章講的主要是本地進行專案的開發,後續還有如何把本地服務放到線上。因為發表部落格有文字長度限制,這篇文章我就沒有介紹如何把開發環境的專案釋出到生成環境上。後續我會發表一篇如何在阿里雲上搭建一個服務,https免費證照以及解析域名進行nginx配置來建立不同的服務。
部落格其實還有不少有缺陷的。還有一些我想好要弄還沒弄上去的東西。
- 後臺管理單獨拆分出來。
- 服務端api模組單獨拆分出來,建立一個管理api相關的服務。
- 共用的工具類,包括客戶端跟管理後臺有不少共用的元件和hooks,統一放到私服上,畢竟到時候這幾個端都要拆分的。
- 用Docker來搭建部署,因為新人買伺服器便宜麼,我買了幾次,然後到期就得遷移,每次都是各種環境配置,可麻煩,後面聽說有docker可以解決這寫問題,我就簡單的研究過一下,所以這次也打算使用docker,主要是伺服器也快到期了,續費也不便宜??。以前雙十一直接買的,現在續費,還挺貴。我都尋思是不是換個伺服器。所以換上docker的話,應該能省點事
- CI/CD持續整合,我現在開發都是上傳git,然後進入伺服器,pull下來再打包,也可麻煩??,所以這個也是打算整合上去的。
作為一個非科班的野路子過來人,基本都是自己摸索過河的。對於很多東西也是一知半解,但是我儘量會在自己瞭解的範圍進行講解,可能會出現技術上的一些問題理解不正確。還有部落格功能基本是自己搭的,很多東西不一定全面,包括也沒做太多的測試,難免會有很多不足之處,如有錯誤之處,希望大家指出,我會盡量完善這些缺陷,謝謝。
我自己新建立了一個相互學習的群,大家如果有不懂的,我能知道的,我會盡量解答。如果我有不懂的地方,也希望大家指教。