万字长文详解如何搭建一个属于自己的博客(纯手工搭建💪💪)
前言
因为自己以前就搭建了自己的博客系统,那时候博客系统前端基本上都是基于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下来再打包,也可麻烦😂😂,所以这个也是打算集成上去的。
作为一个非科班的野路子过来人,基本都是自己摸索过河的。对于很多东西也是一知半解,但是我尽量会在自己了解的范围进行讲解,可能会出现技术上的一些问题理解不正确。还有博客功能基本是自己搭的,很多东西不一定全面,包括也没做太多的测试,难免会有很多不足之处,如有错误之处,希望大家指出,我会尽量完善这些缺陷,谢谢。
我自己新创建了一个相互学习的群,大家如果有不懂的,我能知道的,我会尽量解答。如果我有不懂的地方,也希望大家指教。