由於本身之前就搭建了本身的博客系統,那時候博客系統前端基本上都是基於vue
的,而如今用的react
偏多,因而用react
對整個博客系統進行了一次重構,還有對之前存在的不少問題進行了更改與優化。系統都進行了服務端渲染SSR
的處理。javascript
博客地址傳送門css
本項目完整的代碼:GitHub 倉庫html
本文篇幅較長,會從如下幾個方面進行展開介紹:前端
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
模塊等等一些功能,我會下面各個功能模塊展開篇幅進行講解。vue
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模板
以上就是項目大概的文件目錄,上面已經描述了文件的基本做用,下面我會詳細博客功能的實現過程。目前博客系統各端沒有拆分出來,接下里會有這個打算。java
確保你的node
版本在10.13.0 (LTS)
以上,由於Webpack 5
對 Node.js
的版本要求至少是 10.13.0 (LTS)
node
首先從入口文件開始:react
"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"
// 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)
好查找文件。webpack
首先導出webpack
各端的各自環境的配置文件。ios
// 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中的處理等,這裏我只提供了大致思路,能夠具體細看源碼。
對於生成環境的下搭建,處理就比較少了,直接經過webpack
打包就行
webpack([clientWebpackConfig, serverWebpackConfig, adminWebpackConfig], (err, stats) => { spinner.stop() if (err) { throw err } // ... })
而後啓動打包出來的入口文件 cross-env NODE_ENV=production node dist/server/entry.js
這塊主要就是webpack
的配置,這些配置文件能夠直接點擊這裏進行查看
由上面的配置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')
/src/client/main.tsx
/src/server/main.ts
由於項目用到了SSR
,咱們從server端
來進行逐步分析。
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() })
對於中間件主要就講一講日誌處理中間件loggerMiddleware
和權限中間件authTokenMiddleware
,別的中間件沒有太多東西,就不浪費篇幅介紹了。
日誌打印主要用到了log4js
這個庫,而後基於這個庫作的上層封裝,經過不一樣類型的Logger來建立不一樣的日誌文件。
封裝了全部請求的日誌打印,api的日誌打印,一些第三方的調用的日誌打印
// 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` }) ) } } }
// 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
以上是對中間件的處理。
下面是關於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
的方法,下面就是裝飾器的如何處理接口以及參數。
對於裝飾器有幾個須要注意的點:
javascript.implicitProjectConfig.experimentalDecorators: true
,如今好像不須要了,會自動檢測tsconfig.json文件,若是須要就加上['@babel/plugin-proposal-decorators', { legacy: true }]
跟babel-plugin-parameter-decorator
這兩個插件,由於@babel/plugin-proposal-decorators
這個插件沒法解析@Arg,因此還要加上babel-plugin-parameter-decorator
插件用來解析@Arg來到@server/decorators
文件下,分別定義瞭如下裝飾器
@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下面來講下這些裝飾器是如何進行處理的
// 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: {} }
// 示例, 全部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 } }
這幾個裝飾器的實現方式基本一致,就列舉一個進行演示
// 示例,把@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
的綁定
由於這些裝飾都是裝飾方法參數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
綁定的實現
@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上。
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) }) }
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
用來解析參數
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)} 參數錯誤`) }
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
綁定
SSR同構的代碼其實講解挺多的,基本隨便在搜索引擎搜索就能有不少教程,我這裏貼一個簡單的流程圖幫助你們理解下,順便講下個人流程思路
上面流程圖這只是一個大概的流程,具體裏面數據的獲取,數據的注水,優化首屏樣式等等,我會在下方用部分代碼進行說明
此處有用到插件@loadable/server
,@loadable/component
,@loadable/babel-plugin
@loadable/component
: 用於動態加載組件@loadable/server
: 收集服務端的腳本和樣式文件,插入服務端直出的html中,用於客戶端的再次渲染。@loadable/babel-plugin
: 生成json文件,統計依賴文件/* 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') ) })
前端須要的邏輯大概就是這些,重點仍是在服務端的處理
// 由@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
@loadable/babel-plugin
打包出來的loadable-stats.json
文件肯定依賴@loadable/server
中的ChunkExtractor
來解析這個文件,返回直接操做的對象ChunkExtractor.collectChunks
關聯組件,獲取js跟樣式文件_init
方法獲取到的數據,注水到window.__PRELOADED_STATE__
中window.__PRELOADED_STATE__
數據同步到客戶端的store裏面作SSR
的時候用戶進行登陸還會扯出一個關於token的問題。登陸完後會把token
存到cookie
中。到時候直接經過token
獲取我的信息
正常來講不作SSR
,正常先後端分離進行接口請求,都是從 client端 => server端
,因此接口中的cookie
每次都會攜帶token
,每次也都能在接口中取到token
。
可是在作SSR
的時候,首次加載時在服務端進行的,因此接口請求是在服務端進行的,這個時候你在接口中是獲取不到token
的。
我嘗試了已下幾種方法:
token
獲取到,而後存入store
,在進行用戶信息獲取的時候,取出store中的token傳入url,就像這樣: /api/user?token=${token}
,可是這樣的話,假若有好多接口須要token,那我不是每一個都要傳。那也太麻煩了。代碼實現
/* @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') || '' })
基本上服務端的功能大概就是這些,還有一些別的功能點就不浪費篇幅進行講解了。
由於有的路由有layout佈局,像首頁,博客詳情等等頁面,都有公共的導航之類的。而像404頁面,錯誤頁面是沒有這些佈局的。
因此區分了的這兩種路由,由於也配套了兩套loading動畫。
基於layout部分的過渡的動畫,也區分了pc 跟 mobile的過渡方式,
PC過渡動畫
Mobile過渡動畫
過渡動畫是由 react-transition-group
實現的。
經過路由的前進後退來改變不一樣的className來執行不一樣的動畫。
router-forward
: 前進,進入新頁面router-back
: 返回router-fade
: 透明度變化,用於頁面replaceconst 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
執行各自的路由過渡動畫。經過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。正常能看就挺好了,就沒搞主題了,之後再加,哈哈。
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
來提交錯誤,聲明對應的中間件進行錯誤攔截而後提交錯誤就行
簡單介紹下其他的功能點,有些就不進行講解了,基本都比較簡單,直接看博客源碼就行
經過 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
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端
還有一些別的功能點就不進行講解了,由於博客須要搭建的模塊也很少。能夠直接去觀看博客源碼
後臺管理端其實跟客戶端差很少,我用的antd
UI框架進行搭建的,直接用UI框架佈局就行。基本上沒有太多可說的,由於模塊也很少。
原本還想作用戶模塊,派發不一樣權限的,尋思我的博客也就我本身用,實在用不上。若是你們有須要,我會在後臺管理添加一個關於權限分配的模塊,來實現對於菜單,按鈕的權限控制。
主要說下下面兩個功能點
配合我上面所說的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
上傳文件都是經過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) }) }
由於博客也所有遷移到了https
,這裏就講解一下如何在本地生成證書,在本地進行https
開發。
經過openssl
頒發證書
文章參考 搭建Node.js本地https服務
咱們在src/servers/ssl
文件下建立咱們的證書
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 # 生成證書籤名請求的配置文件 [req] default_bits = 4096 prompt = no distinguished_name = dn [dn] CN = localhost # Common Name 域名
[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 )
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配置來創建不一樣的服務。
博客其實還有很多有缺陷的。還有一些我想好要弄還沒弄上去的東西。
做爲一個非科班的野路子過來人,基本都是本身摸索過河的。對於不少東西也是隻知其一;不知其二,可是我儘可能會在本身瞭解的範圍進行講解,可能會出現技術上的一些問題理解不正確。還有博客功能基本是本身搭的,不少東西不必定全面,包括也沒作太多的測試,不免會有不少不足之處,若有錯誤之處,但願你們指出,我會盡可能完善這些缺陷,謝謝。
我本身新建立了一個相互學習的羣,你們若是有不懂的,我能知道的,我會盡可能解答。若是我有不懂的地方,也但願你們指教。