Preface
Because I built my own blog system before. At that time, the front end of the blog system was basically based on vue
, but now I use a react
, so I used react
to reconstruct the entire blog system once, as well as the previous ones. Many of the issues have been changed and optimized. The system has performed the processing SSR
Blog address portalThe complete code of this project: GitHub repository
This article is a long one and will be introduced from the following aspects:
- core technology stack
- directory structure
- project environment start
- Server-side source code analysis
- Client-side source code analysis
- Admin side source code analysis
- HTTPS create
Core technology stack
React 17.x
(React Family Bucket)Typescript 4.x
Koa 2.x
Webpack 5.x
Babel 7.x
Mongodb
(database)eslint
+stylelint
+prettier
(for code format control)husky
+lint-staged
+commitizen
+commitlint
(check the code format submitted by git and check the commit process)
The core is probably some of the above technology stacks, and then functional development based on the various needs of the blog. For example, some functions such as the jsonwebtoken
@loadable
, log4js
modules used in authorization, I will expand the length of each functional module to explain.
package.json configuration file address
Detailed directory structure
|-- 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模板
The above is the approximate file directory of the project. The basic functions of the files have been described above. Below I will detail the implementation process of the blog function. At present, the blog system has not been separated from each end, and there will be this plan next.
Project environment start
Make sure your node
version 10.13.0 (LTS)
above, because Webpack 5
of Node.js
version requires at least 10.13.0 (LTS)
Execute the script, start the project
First start with the entry file:
"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. Execute the entry file 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')
}
Set the path alias, because there is currently no split at each end, so create an alias (alias)
to find the file.
2. Enter the establishment of the development environment from the entry file
First export the configuration files of the respective environments of each end of 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
is basically not much different. At present, I posted a simple webpack
configuration, which has configuration files for different environments of server, client, and admin. For details, please see the blog source code
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
Then separately processed admin
and client
and server
end webpack
profile
The above points need to be noted:
admin
client
have opened a service to process webpack files, which are packed in memory.client
side needs to pay attention to the reference path of the packaged file. Because it isSSR
, the file needs to be directly rendered on the server side. I put the server side and the client side in two different services, so I need toclient
Pay attention to the reference path.server
end code is directly packaged in thedist
file for startup, and it is not stored in the memory.
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
method is used to generate the service, the code is roughly as follows
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
}
The general logic of the webpack
compilation under the development ( development
) environment is like this. There will be some webpack-dev-middle
middleware in koa, etc. Here I only provide the general idea, you can take a closer look at the source code.
3. Build the production environment
For the construction of the generation environment, there is less processing, just pack it webpack
webpack([clientWebpackConfig, serverWebpackConfig, adminWebpackConfig], (err, stats) => {
spinner.stop()
if (err) {
throw err
}
// ...
})
Then start the packaged entry file cross-env NODE_ENV=production node dist/server/entry.js
This piece is mainly webpack
, these configuration files can be directly Click here to view
Server-side source code analysis
The webpack configuration extends from the above configuration to their entry file
// 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')
- The entrance on the client side is
/src/client/main.tsx
- The entrance on the server side is
/src/server/main.ts
Because the project uses SSR
, we will analyze it step by step server side.
1. /src/server/main.ts entry file
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. Middleware processing
For middleware, I will mainly talk about log processing middleware loggerMiddleware
and permission middleware authTokenMiddleware
. Other middlewares don’t have much, so I won’t waste space on the introduction.
Log printing mainly uses the log4js
library, and then based on the upper layer encapsulation of this library, different log files are created through different types of Loggers.
Encapsulates log printing of all requests, api log printing, and log printing of some third-party calls
1. Implementation of 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. Implementation of 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
The above is the processing of middleware.
3. Router processing logic
Here is router
process of this, api
this processing request is carried out primarily by the decorator
1. Create router, load api file
// 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)
})
}
The above is api
, and the following is how to deal with the interface and parameters of the decorator.
There are several points to note about decorators:
- Vscode needs to open the decorator
javascript.implicitProjectConfig.experimentalDecorators: true
, which seems to be unnecessary now, it will automatically detect the tsconfig.json file, and add it if necessary - babel need to configure
['@babel/plugin-proposal-decorators', { legacy: true }]
withbabel-plugin-parameter-decorator
two plug-ins, because@babel/plugin-proposal-decorators
this plug-in can not be resolved @Arg, so plusbabel-plugin-parameter-decorator
plug-ins used to resolve @Arg
Come to the @server/decorators
file, the following decorators are defined respectively
2. Summary of decorators
@Controller
api, such as@Controller('/user) => /api/user
@Get
Get request@Post
Post request@Delete
Delete request@Put
Put request@Patch
Patch request@Query
Query parameters such ashttps://localhost:3000?a=1&b=2 => {a: 1, b: 2}
@Body
The parameters passed into the Body@Params
Params parameters such ashttps://localhost:3000/api/user/123 => /api/user/:id => @Params('id') id:string => 123
@Ctx
object@Header
Header object can also obtain a value in the Header separately@Header() Get the entire object of the header,
@Header('Content-Type') Get the value of the Content-Type attribute in the header
@Req
Req object@Request
Request object@Res
Res object@Response
Response object@Cookie
Cookie object can also obtain a value in Cookie separately@Session
Session object can also obtain a value in the Session separately@Middleware
binding middleware, can be accurate to a certain request@Token
Get the token value, the definition of this is mainly to facilitate the acquisition of the token
Let's talk about how these decorators are processed
3. Create metadata 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 implementation
// 示例, 所有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 implementation
The implementation of these decorators is basically the same, just list one for demonstration
// 示例,把@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
}
The above is the binding of action
6. @Query,@Body,@Params,@Ctx,@Header,@Req,@Request,@Res,@Response,@Cookie,@Session implementation
Because these decorations are the decoration method parameters arguments
, they can also be processed uniformly
// 示例 /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
}
For each of the above is action
on arguments
bound to achieve
7. @Middleware implementation
@Middleware
decorator should not only be able to be Controller
, but also on a certain 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()]
}
}
}
}
*/
}
}
The above decorator basically records the packaging of the entire request in metadata
,
We return to bootstrapControllers
method inside generateRoutes
on,
Here is used to parse the metadata
data, and then bind these data to the router.
8. Parse metadata and bind 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. Implementation of generateRoutes method
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[]))
})
}
The above is the approximate process of parsing routing, there is a method _determineArgument
to parse parameters
9. Implementation of _determineArgument method
ctx
,session
,cookie
,token
,query
,params
,body
This parameter cannot be obtained directly throughctx[source]
, so it is handled separately- The rest can be obtained through
ctx[source]
, just get it directly
// 对参数进行处理跟验证
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. Overall preview of Router Controller file
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
}
}
}
The above is the entire router
related action
binding
4. Implementation of SSR
SSR isomorphic code is actually explained a lot. Basically, you can find many tutorials in search engines. I will post a simple flowchart here to help you understand, and by the way, I will talk about my process ideas.
The above flow chart is just a general process, the specific data acquisition, data injection, optimization of the first screen style, etc., I will use part of the code below to explain
Here are the plug-ins @loadable/server
, @loadable/component
, @loadable/babel-plugin
-
@loadable/component
: used to dynamically load components -
@loadable/server
: Collect scripts and style files from the server, insert them into the html output from the server, and use them for re-rendering on the client. -
@loadable/babel-plugin
: Generate json file, statistics dependent files
1. Front end part of the code
/* 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')
)
})
The logic required for the front-end is probably these, the focus is still on the processing of the server
2. Server-side processing code
// 由@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. Execution process
- By
@loadable/babel-plugin
packed outloadable-stats.json
file to determine the dependence - Parse the file through
@loadable/server
inChunkExtractor
, and return the directly manipulated object ChunkExtractor.collectChunks
related components, get js and style files- Assign the obtained js and css files to the HTML template and return it to the front end,
- Use the inline style style tag to render the style of the first screen to avoid style errors on the first screen.
_init
the data obtained by calling the componentwindow.__PRELOADED_STATE__
- The front-end obtains
window.__PRELOADED_STATE__
data and synchronizes it to the client's store - The front end fetches the js file and re-executes the rendering process. Binding react events, etc.
- Front-end takeover page
4. Token processing
When doing SSR
, when the user logs in, a question about the token will be raised. token
in, cookie
will be saved to 0608537802baca. When the time token
obtain personal information directly through 0608537802bacc
Normally not SSR
, normal request interface separating front and rear ends, are from Client end => server side, so the interface
cookie
each time carrying token
, also can be accessible to each interface in token
.
But when doing SSR
, the first load was done on the server side, so the interface request was done on the server side. At this time, you can't get token
in the interface.
I have tried several methods:
- When the request comes, get
token
, and then store it instore
. When acquiring user information, take out the token in the store and pass in the URL, like this:/api/user?token=${token}
, but in this case, if there are many interfaces that require tokens, Then I don’t have to pass it all. That's too much trouble. - Then I wondered if I could pass the token in the store to the header of axios, so that I didn't need to write everything. But I thought of several ways, but I didn't think of how to put the token in the store into the request header, because the store is to be isolated. After I generate the store, I can only pass it to the component. At most, when the request is called in the component, the parameters are passed on. Then, isn't it the same as writing each one.
- Finally, I also forgot where I saw an article. I can save the token to the requested instance. I use axios, so I want to assign it to the axios instance as an attribute. But there is a problem to pay attention to, axios has to be isolated on the server at this time. Otherwise, it will be shared by all users.
Code
/* @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') || ''
})
Basically, the functions of the server are about these, and there are some other function points without wasting space to explain.
Client-side source code analysis
1. Routing processing
Because some routes have layout layouts, such as the homepage, blog details, etc. pages, all have public navigation and the like. And like the 404 page, the error page does not have these layouts.
Therefore, these two routes are distinguished because they are also equipped with two sets of loading animations.
Based on the transition animation of the layout part, it also distinguishes the transition mode of pc and mobile.
PC transition animation
Mobile transition animation
The transition animation is implemented react-transition-group
Change different classNames to perform different animations through the forward and backward routing of the route.
router-forward
: forward, enter a new pagerouter-back
: returnrouter-fade
: Transparency change, used for page replacement
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>
)
}
The realization of forward and backward animation involves the forward and backward of the browser itself, not just the forward and backward control of the page.
Therefore, it is necessary to record routing changes to determine whether to move forward or backward. You cannot judge by the action of history alone.
history.action === 'PUSH'
must be considered forward, because this is when we trigger the click to enter a new page.history.action === 'POP'
may be triggered by history.back(), or it may be triggered by the forward and back buttons that come with the browser system.- The next thing to do is how to distinguish between the forward and backward of the browser system. Code implementation in
useRouterEach
the hook andgetRouterDirection
inside method. useRouterEach
hook function
// useRouterEach
export const useRouterEach = () => {
const location = useLocation()
const dispatch = useDispatch()
// 更新导航记录
useEffect(() => {
dispatch(
updateNaviagtion({
path: location.pathname,
key: location.key || ''
})
)
}, [location, dispatch])
}
updateNaviagtion
has made a route record addition, deletion, andlocation.key
, because every time you enter a new page, 0608537802be18 will generate a newkey
, we can usekey
to record whether this route is new or old, and the new one is inpush
tonavigations
, if it is already If this record exists, you can directly intercept the previous routing record of this record, and then updatenavigations
What is done here is the record of the entire navigation
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
method, get the data ofnavigations
location.key
to determine whether the route is innavigations
, if it is, it is proved to return, if it is not, it is to go forward. In this way, it can be distinguished whether the browser is entering a new page forward, or an old page returning back.
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'
}
}
Routing switch logic
history.action === 'PUSH'
proves to be forward- If it is
history.action === 'POP'
, uselocation.key
to record thenavigations
to determine whether this page is a new page or a page that has already been visited. To distinguish whether it is going forward or backward - Execute the respective route transition animation through the acquired
forward
orback
2. Theme change
Use css variable to make skinning effect, declare multiple theme styles
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'
}
Then select a style and assign it to the style tag as a global css variable style. When rendering on the server side, insert a style tag of id=style-variables
You can control the content in the style tag through JS, just replace it directly, it is more convenient to switch the theme, but this thing is not compatible with IE, if you want to use it and need to be compatible with IE, you can use css-vars-ponyfill To deal with css variables.
<style id="style-variables">
{`:root {${Object.keys(theme.light)
.map((
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。