react-server-side-render最新学习与实践

写在前面

server side render(ssr)服务端渲染 ,亦即同构应用。主要有利于 seo 和首屏渲染,这是一篇比较新的可运行的结构设计,基于比较新的 react v16、react-router v5 的同构设计。结合了 redux(Flux 数据流实现)。

项目地址:react-ssr-starter

不务正业(搞前端)差不多已经一年了,学习 react 也是一年前的事情了,但是一直以来对于 react 服务端渲染,兴趣缺缺,毕竟 n 久以前就是服务端渲染(通过模板引擎),随着 ng、react、vue 等框架的兴起,前端渲染越来越火热,没想到发展发展,又绕回了服务端渲染,而且居然还多加了一个 node 中间层,把本来简单的结构一拆再拆,很多时候都是徒然增加复杂度。其实说起来可笑,我觉得服务端渲染在国内的火热百度绝对占有相当大一部分因素(搜索引擎技术万年不更新,广告倒是多了一大堆,233)。

好了,吐槽一段也该进入正题了。我等小菜没法改变现状,也只能适应现状了。在阅读了一大堆乱七八糟的 ssr 相关博文之后,终于找到Server Side Rendering in React/Redux (JS)和React16+Redux+Router4+Koa+Webpack 服务器端渲染(按需加载,热更新)让我暂时明白了其中一部分原理并开始进行自己的构建。

ssr 构想

传统的 react 为什么不利于 seo?其实说白了,就是因为在路由请求到页面时,页面是一个没有任何数据的 html,它的数据必须要运行渲染 dom/更改<head/>的 js 代码,而搜索引擎并不会执行这段代码,所以搜索引擎拿不到任何东西(google 已经提出了解决方案,百度仍然沉浸在十年前#雾)。

而 ssr 的结构无外乎就是把这一段需要运行的 js 放到 node 服务端去运行,然后直接向浏览器端输出 html。这样传统引擎就能够去爬取内容了。

为了实现 ssr。我觉得需要考虑的问题应该主要集中在这几点上:

  • 需要写那些方面的代码?

答:需要写两个方面的代码,一个是以前传统的客户端代码,一个是 node 服务端代码。如果你已经写完了一个客户端渲染的代码,那么指需要小小修改一些东西(如果代码组织结构较好,甚至只需要改动一个方法名,也就是说后面提到的 ReactDOM.render 改成 ReactDOM.hydrate),然后加入服务端代码,基本上就能够完成服务端渲染啦。迁移也是非常方便的。

  • node 中间层干什么?怎么干?

答:node 中间层主要任务是 获取用户请求,根据路由(更多是为了应对首屏渲染)准备初始数据(例如:去 api 端请求数据),把初始数据填充到组件中,把整个填充好的组件输出到响应体中。这方面比较好的实现是结合 react-router 匹配路由,结合 redux 的 store 填充数据。刚好与这两个库的思想吻合。请尽量注意,其实在我以前的想法里,服务端渲染应该是每个单页/单页的部分组成组件每次页面跳转都由服务端呈现的。但是在查看了这些博文,以及研究了相关代码之后,才了解,服务端渲染仅仅只针对首屏渲染,首屏渲染完成后,后续的页面跳转,api 请求等还是由前端自己管理,也就是说,其实 node 中间层只管刷新,当然这是因为我们用 react-router 以及 redux 结合所希望达成的最好的效果,实际上依靠 react 官方 api,我们是可以完全实现所有页面都由服务端渲染完成,由客户端去请求的,但是那样其实体验并不好(这也编程了纯服务端渲染,也就是说模板引擎干的事,显然,这不是我们想要的)。服务端渲染主要解决的应该是首屏白屏和 seo 的问题。

  • 浏览器端干什么?怎么干?

答:浏览器端主要是显示服务端渲染过来的 Html(当然,这不用我们管,浏览器干的事)。我们主要是要根据服务端提供的初始 state 和渲染的根节点,把每一层渲染的实际 dom 用 react 组件对应上(因为现在 html 变成了已经渲染成功的样子,但是客户端还什么都没做,客户端的 react 表示一脸懵逼,还不知道自己干了什么)。

而在 react 16 以前 react-dom 只提供了 render 方法,去对应根结点,这个方法会删除掉原来根节点本来已经由服务端渲染成功的子 dom,然后根据初始状态重新渲染,也就出现了渲染两次的问题(服务端渲染一次,把 dom 结构加载到根节点中,客户端拿到 html 页面,再根据初始状态再渲染一次 dom 结构),当然,这其实某种程度已经满足了我们的需求,搜索引擎爬到了初始页面,不执行 js,所有 dom 结构还在,能够获取需要的信息,而用户看到页面中,因为 react-dom 的 render 方法执行效率也还是很可观的,所以也没有什么问题(一般来说其实会有一点闪屏,因为 dom 擦除和重建)。

但是对于复杂的网页,或者追求用户体验的我们来说,这是真的不能忍,react 16 以前,大佬们使用各种方式来避免第二次渲染,但是在 react 16 之后,react 官方提供了一个新的方法来搞定这个问题啦,那就是 ReactDom.hydrate 方法。这个方法和 render 使用是一样的,但是它不会擦除和重建 dom,仅仅只是把 dom 结构和我们的虚拟 dom 结构对应上,简直是大大的方便啊。所以客户端会该 ReactDom.hydrate 方法代替 ReactDom.render 方法,其他写法与以前的客户端渲染一样哦。

  • 开发环境下怎么搞?

答:开发环境下,大致分为两种思路(其实也差不多)

第一种,使用 Webpack 的 devServer 做为开发服务器,当然这就无法完全重现服务端渲染的真实情况,但是问题不大,因为本来差别也不大,只有一个首屏问题。 第二种,完全模拟服务端渲染,使用 koa/express 自行封装,使用 babel 的 register 方法添加 node 对于 import 的支持(这种,去掉开发相关配置,其实完全可以用来直接做服务器)。

  • 生成环境下怎么搞? 答:生产环境其实主要也就是两个方面,一个是客户端代码的编译。另一个是服务端代码,这个可以选择两种,分别是使用 webpack 进行编译使其支持 import/es6/es7/jsx 等代码以及使用 babel.register 使其支持 import/es6/es7/jsx。各有优劣,我的选择是前者,也没什么特别的原因,任性!

  • 架构的基本思想

答:其实基本思想就是,因为服务端需要渲染一部分组件(用于初始化),也就是说服务端需要包含一部分 react 组件,而客户端也(当然)需要包含所有的组件。那么这一部分组件要想办法重用,这方面其实没那么复杂,说白了就是服务端能够在目录中抽取(import/require)到所需的组件,没什么特别的,主要是为了服务端代码,最大程度实现重用。另一方面是路由的匹配(服务端需要相关路由匹配以渲染对应组件),其实 koa/express 等有路由匹配相关的方法,但是同样是为了最大程度重用,我们要想办法能够统一匹配路由,这方面我推荐采用 react-router 搭配 react-router-config 食用。

服务端没有history,所以需要模拟一个 hisotry,正好,react-router 提供了 staticRouter 静态路由可以模拟,为了更好的食用,我使用history.js的 memeryHistory,这样,服务端和客户端又能够重用路由信息啦。

又想一想,还有什么能重用?对啦,是数据处理,包括数据请求,因为服务端需要初始化一部分的数据啊。我们结合 redux,也就成了相关的 action 和 reducer,这一部分也能够重用。在服务端和客户端都需要创建 store,所以把创建 store 的代码提供出来给大家食用,就又重用了一部分代码啦。

经过了以上代码的重用,然后发现,服务端除了监听 request 和渲染 html,其他什么都不用做,因为我们在写客户端代码的时候,就无形中搞定了服务端渲染。所以基本上,来说,脚手架一旦搭建完成,用户还是像以前那样开开心心的写客户端代码,而不用管服务端代码。想想还有点小激动呢~

核心代码

通过以上问题的抛出,其实我们心中已经能够有大体的思路,只是在实现上,我们就不得不各种找 api,各种想办法去对应上这些问题了,这是一个枯燥无聊的过程,如果你实在没有继续看下去的欲望,可以直接食用我的脚手架react-ssr-starter,开箱即用哦。

代码分离方案

食用 react-loadable 组件,也是一个开箱即用的库,结合 webpack 的import()方法,分分钟实现代码分离。示例

import React from 'react'
import Loadable from 'react-loadable'
import { homeInit } from './actions'const Loading = () => {return <div>Loading...</div>
}const routesConfig = [{path: '/',component: Loadable({loader: () => import(/* webpackChunkName: 'AppLayout'*/ './pages/AppLayout'),loading: Loading,}),routes: [{path: '/',exact: true,component: Loadable({loader: () => import(/* webpackChunkName: 'Home' */ './pages/Home'),loading: Loading,}),},{path: '/user',component: Loadable({loader: () => import(/* webpackChunkName: 'User'*/ './pages/User'),loading: Loading,}),},],},
]export default routesConfig

路由重用

我们使用 react-router+react-router-config 的方案实现路由重用,首先时需要导入一个 routesConfig。其实就是上面的代码。接下来我们需要能够在前后端都能加载这个 routesConfig。那么就要分别由前后端代码去读取,客户端需要解析出真正的 Route 节点,而服务端只需要匹配 url 即可

服务端代码:

import { matchRoutes } from 'react-router-config'
import Routes from './Routes'let branch = matchRoutes(Routes, ctx.req.url)let promises = branch.map(({ route }) => {return route.init ? route.init(store) : Promise.resolve(null)}).map(promise => {if (promise) {return new Promise(resolve => {promise.then(resolve).catch(resolve)})}})
await Promise.all(promises).catch(err => console.error(err))

客户端代码:

import { hydrate, render, unmountComponentAtNode } from 'react-dom'
import { ConnectedRouter } from 'react-router-redux'
import { renderRoutes } from 'react-router-config'
const renderApp = routes => {const renderMethod = process.env.NODE_ENV === 'development' ? render : hydraterenderMethod(<Provider store={store}><ConnectedRouter history={history}>{renderRoutes(routes)}</ConnectedRouter></Provider>,Root)
}
renderApp(Routes)

store 的重用设计

store 的重用设计非常简单,说白了就是获取初始状态,有就加进去,没有就直接初始化一个 store(服务端没有,客户端需要读取服务端的初始状态,所有有)。

整个 store 初始化方法

import { createStore, applyMiddleware, compose } from 'redux'
import thunkMiddleware from 'redux-thunk'
import createHistory from 'history/createMemoryHistory'
import { routerMiddleware } from 'react-router-redux'
import rootReducer from '../reducers'const routerReducers = routerMiddleware(createHistory())
const composeEnhancers = process.env.NODE_ENV == 'development' ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ : compose
const middleware = [thunkMiddleware, routerReducers]
let configureStore = initialState =>createStore(rootReducer, initialState, composeEnhancers(applyMiddleware(...middleware)))
export default configureStore

客户端使用(注意我们默认 node 服务端渲染的初始状态挂载到 window.__INITIAL_STATE__上面):

const initialState = window && window.\_\_INITIAL_STATE\_\_
import { Provider } from 'react-redux'
import configuraStore from './store/configureStore'
let store = configuraStore(initialState)//...
<Provider store={store}>
//...

服务端使用:

import configureStore from './store/configureStore'
let store = configureStore()

服务端渲染代码

这才是重中之重,服务端渲染代码,主要使用的时 ReactDom/Server.renderToString 方法。这样可以把组件转换成 string,接下来我们服务端需要做的工作就是继续拼接,把这个 node 装在到根节点下面,然后把整个页面给渲染出去,这里我还使用了一个 react-helmet 库,这是用来做<head/>的元素,例如 mata,title 等字段的。我们在服务端要把这些字段进行替换。另外最重要的是,别忘了把初始状态挂载在 html 结点中。

import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter, matchPath } from 'react-router-dom'
import { Provider } from 'react-redux'
import { renderRoutes } from 'react-router-config'
import { Helmet } from 'react-helmet'import { getBundles } from 'react-loadable/webpack'
import Loadable from 'react-loadable'const createTags = (modules, stats) => {let bundles = getBundles(stats, modules)let scriptfiles = bundles.filter(bundle => bundle.file.endsWith('.js'))let stylefiles = bundles.filter(bundle => bundle.file.endsWith('.css'))let scripts = scriptfiles.map(script => `<script src="/${script.file}"></script>`).join('\n')let styles = stylefiles.map(style => `<link href="/${style.file}" rel="stylesheet"/>`).join('\n')return { scripts, styles }
}const prepHtml = (data, { html, head, rootString, scripts, styles, initState }) => {data = data.replace('<html', `<html ${html}`)data = data.replace('</head>', `${head} \n ${styles}</head>`)data = data.replace('<div id="root"></div>', `<div id="root">${rootString}</div>`)data = data.replace('<body>', `<body> \n <script>window.__INITIAL_STATE__ =${JSON.stringify(initState)}</script>`)data = data.replace('</body>', `${scripts}</body>`)return data
}
export const make = ({ ctx, store, context, template, Routes, stats }) => {let modules = []const rootString = renderToString(<Loadable.Capture report={moduleName => modules.push(moduleName)}><Provider store={store}><StaticRouter location={ctx.req.url} context={context}>{renderRoutes(Routes)}</StaticRouter></Provider></Loadable.Capture>)const initState = store.getState()const { scripts, styles } = createTags(modules, stats)const helmet = Helmet.renderStatic()return prepHtml(template, {html: helmet.htmlAttributes.toString(),head: helmet.title.toString() + helmet.meta.toString() + helmet.link.toString(),rootString,scripts,styles,initState,})
}export const getMatch = (routesArray, url) => {return routesArray.some(router =>matchPath(url, {path: router.path,exact: router.exact,}))
}
import Routes from './Routes'
import Loadable from 'react-loadable'
import configureStore from './store/configureStore'
import { matchRoutes } from 'react-router-config'
import { getMatch, make } from './helpers/renderer'
import stats from '../dist/react-loadable.json'
import Koa from 'koa'
const server = new Koa()
const port = process.env.port || 3000,staticCache = require('koa-static-cache'),cors = require('koa2-cors')var fs = require('fs')
var path = require('path')server.use(cors())const clientRouter = async (ctx, next) => {let html = fs.readFileSync(path.join(path.resolve(process.cwd(), 'dist'), 'index.html'), 'utf-8')let store = configureStore()let branch = matchRoutes(Routes, ctx.req.url)let promises = branch.map(({ route }) => {return route.init ? route.init(store) : Promise.resolve(null)}).map(promise => {if (promise) {return new Promise(resolve => {promise.then(resolve).catch(resolve)})}})await Promise.all(promises).catch(err => console.error(err))let isMatch = getMatch(Routes, ctx.req.url)const context = {}if (isMatch) {let renderedHtml = await make({ctx,store,context,template: html,Routes,stats,})if (context.url) {ctx.status = 301ctx.redirect(context.url)} else {ctx.body = renderedHtml}} else {ctx.status = 404ctx.body = '未找到该页面'}await next()
}server.use(clientRouter)
server.use(staticCache(path.resolve(process.cwd(), 'dist'), {maxAge: 365 * 24 * 60 * 60,gzip: true,})
)console.log(`\n==> 🌎  Listening on port ${port}. Open up http://localhost:${port}/ in your browser.\n`)Loadable.preloadAll().then(() => {server.listen(port)
})

上面的代码,就基本把服务端的代码给写完了,没有想象中的那么长,但是也不算端。其中要注意几个点。

  • 我需要读取到 dist 目录的 index.html 目录,这里的目录读取方式有问题。但是大致意思差不多

  • 每个路由都由初始方法,我默认挂载到了 route 的 init 字段中,会把 store 传入进去,可以执行 store.dispatch 方法来改变数据。

客户端代码

客户端代码跟以前的客户端渲染差不多,只是需要根据环境不同切换 render 方法或者 hydrate 方法

import React from 'react'
import { hydrate, render, unmountComponentAtNode } from 'react-dom'
import { ConnectedRouter } from 'react-router-redux'
import Loadable from 'react-loadable'
import { renderRoutes } from 'react-router-config'
import Routes from './Routes'
const initialState = window && window.__INITIAL_STATE__
import { Provider } from 'react-redux'
import configuraStore from './store/configureStore'
import createHistory from 'history/createBrowserHistory'
const history = createHistory()
let store = configuraStore(initialState)
const Root = document.getElementById('root')
const renderApp = routes => {const renderMethod = process.env.NODE_ENV === 'development' ? render : hydraterenderMethod(<Provider store={store}><ConnectedRouter history={history}>{renderRoutes(routes)}</ConnectedRouter></Provider>,Root)
}Loadable.preloadReady().then(renderApp.bind(this, Routes))if (process.env.NODE_ENV === 'development') {if (module.hot) {module.hot.accept('./reducers/index.js', () => {let newReducer = require('./reducers/index.js').defaultstore.replaceReducer(newReducer)})module.hot.accept('./Routes.jsx', () => {unmountComponentAtNode(Root)var r = require('./Routes').defaultrenderApp(r)})}
}

开发环境与生产环境

开发环境下还是才用客户端渲染的方式,所以与平常的客户端渲染配置没多大区别,也不再赘述。 主要讲讲生产环境,生产环境下,我们需要变量两个包,分别时 server 和 client。client 包中配置,一定要加入 ReactLoadablePlugin,以提供给服务端读取组件代码。服务端打包,一定要把 target 设置为 node。就这亮点,配置为:

webpack.config.common.js

'use strict'
const path = require('path')
module.exports = {output: {filename: '[name].[hash].js',path: path.resolve(__dirname, 'dist'),publicPath: '/',chunkFilename: '[name].chunk.[hash:8].js',},context: path.resolve(__dirname, 'src'),resolve: {extensions: ['.js', '.jsx', '.json'],modules: [path.resolve(__dirname, 'src'), 'node_modules'],},
}

webpack.config.prod.js:

const path = require('path')
const webpack = require('webpack')
const CleanWebpackPlugin = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ManifestPlugin = require('webpack-manifest-plugin')
const { ReactLoadablePlugin } = require('react-loadable/webpack')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const common = require('./webpack.config.common')
const merge = require('webpack-merge')module.exports = merge(common, {entry: {client: 'client.jsx',},module: {rules: [{test: /\.jsx?$/,exclude: /node_modules/,include: path.resolve(__dirname, 'src'),use: {loader: 'babel-loader',options: {cacheDirectory: true,},},},{test: /\.(css|scss|less)$/,exclude: /node_modules/,include: path.resolve(__dirname, 'src'),use: ExtractTextPlugin.extract({fallback: 'style-loader', //style-loader 将css插入到页面的style标签use: [{loader: 'css-loader', //css-loader 是处理css文件中的url(),require()等options: {sourceMap: true,},},{loader: 'postcss-loader',options: {sourceMap: true,},},{loader: 'sass-loader',options: {sourceMap: true,},},{loader: 'less-loader',options: {sourceMap: true,},},],}),},{test: /\.(svg|woff2?|ttf|eot|jpe?g|png|gif)(\?.*)?$/i,exclude: /node_modules/,use: {loader: 'url-loader',options: {limit: 1024,name: 'img/[sha512<img class="emoji-icon" alt="emoji-hash" data-icon="emoji-hash" style="display: inline; margin: 0; position: relative; width: 20px" src="data:image/png;base64, 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" title="emoji-hash" />base64:7].[ext]',},},},],},plugins: [new ManifestPlugin(),new webpack.NoEmitOnErrorsPlugin(),new ExtractTextPlugin({filename: 'css/style.[hash].css',allChunks: true,}),new CopyWebpackPlugin([{ from: 'assets/z.png', to: 'favicon.ico' }]),new CleanWebpackPlugin(['./dist']),new webpack.DefinePlugin({'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),}),new webpack.optimize.OccurrenceOrderPlugin(),new HtmlWebpackPlugin({title: 'go-store-client',filename: 'index.html',template: './index.prod.html',}),new webpack.optimize.CommonsChunkPlugin({name: ['vendors', 'manifest'],minChunks: 2,}),new ReactLoadablePlugin({filename: path.join('./dist/react-loadable.json'),}),],externals: {react: 'React','react-dom': 'ReactDOM',},
})

webpack.config.server.js:

const path = require('path')
const webpack = require('webpack')
const CleanWebpackPlugin = require('clean-webpack-plugin')
const webpackNodeExternals = require('webpack-node-externals')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
module.exports = {entry: './src/server.js',output: {filename: 'server.build.js',path: path.resolve(__dirname, 'build'),},resolve: {extensions: ['.js', '.jsx', '.json'],modules: [path.resolve(__dirname, 'src'), 'node_modules'],},target: 'node',externals: [webpackNodeExternals()],module: {rules: [{test: /\.jsx?$/,exclude: /node_modules/,include: path.resolve(__dirname, 'src'),use: {loader: 'babel-loader',options: {cacheDirectory: true,},},},{test: /\.(css|scss|less)$/,exclude: /node_modules/,include: path.resolve(__dirname, 'src'),use: ExtractTextPlugin.extract({fallback: 'style-loader', //style-loader 将css插入到页面的style标签use: [{loader: 'css-loader', //css-loader 是处理css文件中的url(),require()等options: {sourceMap: true,},},{loader: 'postcss-loader',options: {sourceMap: true,},},{loader: 'sass-loader',options: {sourceMap: true,},},{loader: 'less-loader',options: {sourceMap: true,},},],}),},],},plugins: [new webpack.NoEmitOnErrorsPlugin(),new CleanWebpackPlugin(['./build']),new webpack.DefinePlugin({'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),}),new ExtractTextPlugin({filename: 'css/style.[hash].css',allChunks: true,}),new webpack.optimize.OccurrenceOrderPlugin(),],
}

写在最后

我的最新代码提交于 github,项目地址为react-ssr-starter,喜欢的可以拿去直接用~

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/diannao/18398.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

[书生·浦语大模型实战营]——在茴香豆 Web 版中创建自己领域的知识问答助手

茴香豆是一个基于LLM的领域知识助手&#xff0c;可以用于解答群聊中的问题。接下来是创建过程。 1.打开茴香豆Web版&#xff0c;创建自己的领域库。 地址&#xff1a;茴香豆Web版 这里类似于注册账号&#xff0c;你输入知识库的名称以及密码&#xff0c;然后它就会创建一个知识…

Vue3实战笔记(49)—Vue 3响应式魔法:ref vs reactive深入对决

文章目录 前言一、 ref 和 reactive主要差异总结 前言 Vue 3 中的 ref 和 reactive 都是用于创建响应式数据的工具&#xff0c;但它们之间存在一些重要的区别。今天聊聊它们之间的主要差异&#xff1a; 一、 ref 和 reactive主要差异 数据类型&#xff1a; ref 主要用于处理…

【微服务】部署mysql集群,主从复制,读写分离

两台服务器做如下操作 1.安装mysqldocker pull mysql:5.72.启动以及数据挂载 mkdir /root/mysql/data /root/mysql/log /root/mysql/conf touch my.conf //mysql的配置文件docker run --name mysql \ -e MYSQL_ROOT_PASSWORD123456 \ -v /root/mysql/data:/var/lib/mysql \ -v…

飞睿智能高精度、低功耗测距,无线室内定位UWB芯片如何改变智能家居

在数字化和智能化快速发展的今天&#xff0c;定位技术已经成为我们日常生活中不可或缺的一部分。然而&#xff0c;传统的GPS定位技术在室内环境中往往束手无策&#xff0c;给我们的生活带来了诸多不便。幸运的是&#xff0c;随着科技的不断进步&#xff0c;一种名为UWB&#xf…

智能座舱-车载声学技术训练营

语音交互赋能车载智能终端&#xff0c;成为智能座舱生态构建的核心功能 曾几何时&#xff0c;至少十年前&#xff0c;车内语音交互&#xff0c;大家都认为是“智障”阶段&#xff0c;基本上除了难用作为评价找不到其他的形容词做修饰。 但是随着技术的不断发展&#xff0c;特别…

STM32Cube系列教程11:使用STM32 RNG硬件随机数模块生成彩票号码

文章目录 配置RNG模块编写代码获取生成的随机数运行测试 今天写段代码测试一下STM32U083RC的(RNG)硬件随机数模块 顺便写个小demo生成7位真随机数的彩票号码&#xff0c;帮助那些买彩票还有选择困难症的人群 (doge)(手动狗头)。 全部代码以上传到github&#xff1a;https://gi…

【Unity】实现轮盘抽奖

简介 示例一&#xff1a;使用协程完成轮盘转动 using System; using System.Collections; using System.Collections.Generic; using UnityEngine;public class Lunpan : MonoBehaviour {[Tooltip("轮盘节点")]public Transform Roulette;[Tooltip("轮盘旋转的…

SpringBoot 微服务中怎么获取用户信息 token

SpringBoot 微服务中怎么获取用户信息 token 当我们写了一个A接口&#xff0c;这个接口需要调用B接口&#xff0c;但是B接口需要包含请求头内容&#xff0c;比如需要用户信息、用户id等内容&#xff0c;由于不在同一个线程中&#xff0c;使用ThreadLocal去获取数据是无法获取的…

如何高效测试防火墙的NAT64与ALG应用协议转换能力

在本文开始介绍如何去验证防火墙&#xff08;DUT&#xff09;支持NAT64 ALG应用协议转换能力之前&#xff0c;我们先要简单了解2个比较重要的知识点&#xff0c;即&#xff0c;NAT64和ALG这两个家伙到底是什么&#xff1f; 网络世界中的“翻译官” - NAT64技术 简而言之&…

如何批量提取pdf文件名?批量提取文件夹里的文件名,只要用对方法!

在数字化时代&#xff0c;PDF文件已经成为我们日常工作中不可或缺的一部分。然而&#xff0c;随着PDF文件数量的不断增加&#xff0c;如何高效地管理这些文件成为了一个挑战。批量提取PDF文件名&#xff0c;就是解决这一问题的关键所在。本文将为你介绍几种实用的方法&#xff…

长效IP和短效IP的使用指南分享

随着网络技术的发展&#xff0c;代理IP已经成为许多人在网络活动中不可或缺的工具。 代理IP不仅有助于保护用户的真实IP地址&#xff0c;保护用户的使用隐私&#xff0c;还可以帮助用户提升网络访问的速度等。 然而&#xff0c;在挑选代理IP时&#xff0c;用户常常会面临一个…

GDAL读取shp文件1

我们知道shp文件是一种gis文件,里面包含一张属性数据表,可以用GIS桌面软件打开; GDAL先初步读一下一个示例shp文件的信息, #include "stdafx.h" #include <ogrsf_frmts.h> #include <ogr_spatialref.h>int main() {// 为了使属性表字段支持中文,请…

图像分割模型LViT-- (Language meets Vision Transformer)

参考&#xff1a;LViT&#xff1a;语言与视觉Transformer在医学图像分割-CSDN博客 背景 标注成本过高而无法获得足够高质量标记数据医学文本注释被纳入以弥补图像数据的质量缺陷半监督学习&#xff1a;引导生成质量提高的伪标签医学图像中不同区域之间的边界往往是模糊的&…

笔记-Python读写文件

Python读写文件 1.open 使用open打开文件后一定要记得调用文件对象的close()方法。比如可以用try/finally语句来确保最后能关闭文件。 file_object open(‘thefile.txt’) try: all_the_text file_object.read( ) finally: file_object.close( ) 注&#xff1a;不能把open语…

Java | Leetcode Java题解之第118题杨辉三角

题目&#xff1a; 题解&#xff1a; class Solution {public List<List<Integer>> generate(int numRows) {List<List<Integer>> ret new ArrayList<List<Integer>>();for (int i 0; i < numRows; i) {List<Integer> row new…

嵌入式学习(Day:28 进程间通信2 -> 信号通信)

进程间通信 》信号通信 1. 64个信号 应用&#xff1a;异步通信。 中断&#xff0c;&#xff0c; &#xff08;PCBC块中&#xff0c;64个信号&#xff0c;大部分是&#xff1a;关闭&#xff0c;暂停&#xff0c;继续&#xff09; linuxubuntu:~$ kill -l &am…

防御恶意爬虫攻击

数据抓取爬虫 数据抓取爬虫是攻击者使用自动化脚本或工具在移动应用程序中抓取敏感数据的一种方式。这些爬虫可以定向抓取用户信息、产品列表、评论和评级等数据。攻击者可能会将这些数据用于非法目的&#xff0c;例如进行身份盗窃、诈骗活动或者卖给其他恶意方。 对于移动应用…

[机缘参悟-192] - 《道家-水木然人间清醒1》读书笔记 -15- 关系界限 - IT互联网时代下的真爱的形态

目录 前言&#xff1a; 1、 既独立又结盟&#xff0c;才是最好的关系 2、世间所有的好关系&#xff0c;一定要先谈钱 3、怎么建立高品质的关系 4、恋爱是情感组合&#xff0c;婚姻是价值组合 5、什么是成熟的爱情 6、婚姻的难点 7、这个时代稀缺的女性特质 8、恋爱和婚…

YD/T 2698-2014 《电信网和互联网安全防护基线配置要求及检测要求 网络设备》标准介绍

编写背景 随着互联网技术的飞速发展&#xff0c;网络设备的安全问题日益凸显。为了加强电信网和互联网的安全防护&#xff0c;保障网络环境的稳定和用户信息的安全&#xff0c;YD/T 2698-2014标准应运而生。此标准旨在为网络设备提供一套基线配置要求&#xff0c;以及相应的安…

pytorch要点和难点具体应用案例

PyTorch是一个开源的Python机器学习库&#xff0c;由Facebook人工智能研究院&#xff08;FAIR&#xff09;开发并维护。它基于 Torch&#xff0c;是一个为深度学习而设计的动态图计算框架&#xff0c;特别适用于自然语言处理&#xff08;NLP&#xff09;等应用。 PyTorch的主要…