注意:这是笔者用于记录自己学习SSR的一篇文章,用于梳理将一个项目进行服务器渲染的过程,本文仅是读者根据demo得出的理解,如果您想通过本文来学习如何部署ssr,笔者建议您查阅其他更权威的资料进行学习;当然,如果您发现了本文中有任何描述不恰当的地方,还恳请您指出更正。

首先在此感谢以下文章及其作者,为笔者在学习SSR时提供了必不可少的帮助:
浅谈服务端渲染(SSR);
浅谈Vue SSR中的Bundle;
【VueSSR系列二】clientManifest与bundle的处理流程解读

1. 什么是服务器渲染?

我们现在有同一个项目,当访问8000端口时,我们是客户端渲染,当访问3333端口时,我们是服务器渲染
http://localhost:8000/footer

http://localhost:3333/footer

从页面的显示来看,其实并没有什么区别,但是如果我们查看源码,我们就会发现很大的不同:
view-source:http://localhost:8000/header,这是我们访问未使用服务器渲染的页面源码:

view-source:http://localhost:3333/footer,这是我们访问服务器渲染的页面源码:

1.1 服务端渲染 vs 客户端渲染

1.1.1 服务端渲染(SSR)的优势

从这两份源码我们可以知道,服务器渲染后返回到浏览器的源码中,已经包含了我们页面中的节点信息,也就是说网络爬虫可以抓取到完整的页面信息,所以,服务器渲染更利于SEO
首屏的渲染是通过node发送过来的html字符串,而并不依赖js文件,更利于首屏渲染,这会使用户更快的看到网页内容,尤其是针对大型的单页面应用,打包后的文件体积比较大,破铜客户端渲染加载所有所需文件时间比较长,首页会有一个很长的白屏时间。

1.1.2 服务端渲染的局限性

  1. 服务器负荷更高:
    传统模式下通过客户端完成渲染,现在统一到了服务端node去完成。尤其是高并发访问的情况,会大量占用服务器CPU资源
  2. 开发环境受限:
    在服务端渲染中,只会执行到componentDidMount之前的生命周期钩子,因此项目引用的第三方的库也不可用其它生命周期钩子,这对引用库的选择产生了很大的限制
    注意,这并不意味者我们不能使用其他生命周期钩子函数,这里的意思是只有 beforeCreate 和 created 会在服务器端渲染(SSR)过程中被调用。这就是说任何其他生命周期钩子函数中的代码(例如 beforeMount 或 mounted),只会在客户端执行。
  3. 学习成本较高
    除了对webpack、Vue要熟悉,还需要掌握node、Koa等相关技术。相对于客户端渲染,项目构建、部署过程更加复杂。这也意味着维护成本也会相应地增加。

1.2 服务器渲染和客户端渲染的行为比较

以下两张图来源于浅谈服务端渲染(SSR);

服务端渲染是先向后端服务器请求数据,然后生成完整首屏html返回给浏览器;而客户端渲染是等js代码下载、加载、解析完成后再请求数据渲染,等待的过程页面是什么都没有的,就是用户看到的白屏。就是服务端渲染不需要等待js代码下载完成并请求数据,就可以返回一个已有完整数据的首屏页面。
作者:coder_Lucky
链接:https://www.jianshu.com/p/10b6074d772c
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

2. 服务器渲染的简单Demo

服务端渲染需要生成完整的首屏html返回给浏览器,并且当用户加载首屏完成之后,客户端仍然是一个单页面应用。接下来,我们尝试理解SSR的构建流程:

  1. 我们需要将app.js按照不同方式进行打包,Server Entry用于打包服务器渲染需要的代码Server BundleClient Entry 用于打包客户端渲染需要的代码Client Bundle
  2. Server Bundle用于构建Bundle RendererBundle Renderer根据用户的请求生成首屏Html代码;
  3. Client Bundle用于支持浏览器上的单页面需求。这里可能有点难以理解,Hydrate在Vue官网上面被译作水合,我们思考,如果没有Client BundleHydrate 到客户端的html中,此时的客户端上html中还不存在接管单页面应用的逻辑js,若点击了某一个路由,由于没有处理路由的js函数,客户端将重新发起请求到服务端,服务端根据用户请求再次渲染出相应的html返回。所以,我们还需要将Client Bundle加载到html中用于单页面应用的逻辑处理(注意:此处是笔者的个人理解,如果有误,希望指出修正。)。
    接下来,我们按照上图,一步一步将项目应用到服务器渲染。

2.1 createApp.js

这里的createApp.js就是上图中的app.js
如果我们不进行服务端渲染,那么我们的vue项目打包的入口文件一般情况下是main.js,这个文件会实例化一个Vue,然后被挂载到浏览器端。
app.jsmain.js的功能类似,但是,我们在这里还需要返回Vue实例所使用的router,store等实例。

import Vue from 'vue'
import VueRouter from 'vue-router'import App from './app.vue'
import createRouter from './router'Vue.use(VueRouter)export default () => {const router = createRouter()const app = new Vue({router,render: h => h(App)})return { app, router }
}

注意:现在这个网页应用仅仅包括了最基本的单页面路由,没有使用Vuex,axios等

2.2 clientEntry.js

这个文件用于打包客户端渲染的文件,当我们不使用服务器渲染时,这个文件就是一般情况下的main.js,对应上图中的Client entry:

import createApp from './createApp'const { app } = createApp()app.$mount('#root')

我们可以看到,这个clientEntry.js只做了一件事,那就是从createApp中拿到Vue实例,然后徐将Vue实例挂载到浏览器的root组件上。

2.3 serverEntry.js

serverEntry.js抛出了一个函数,我们接收到一个context对象,这个函数返回一个promise对象,在这个promise对象中,我们我们为context添加了一些属性。

import createApp from './createApp'export default context => {return new Promise((resolve, reject) => {const { app, router } = createApp()router.push(context.url)router.onReady(() => {const matchedComponents = router.getMatchedComponents()if (!matchedComponents.length) {return reject(new Error('no component matched'))}context.router = routerresolve(app)})})
}

看到这里的时候,大家可能并不知道serverEntry.js做了什么,但是我们需要知道:打包后生成的文件存在一个函数可以被我们调用,这个函数为context添加了一些属性,并且函数中包含了完整的的app应用

2.4 webpack的配置文件

webpack.client.js是webpack打包clientEntry.js的配置文件,webpack.server.js是webpack打包serverEntry.js的配置文件,官方还推荐我们使用一个webpack.base.js抽离出前两个配置文件的公共部分。

2.4.1 webpack.base.js

webpack.base.js中是一些公共配置:

const createVueLoaderOptions = require('./vue-loader.config')
const isDev = process.env.NODE_ENV === 'development'
const config = {resolve: {extensions: ['.js', '.vue']},module: {rules: [{test: /\.(vue|js|jsx)$/,loader: 'eslint-loader',exclude: /node_modules/,enforce: 'pre'},{test: /\.vue$/,loader: 'vue-loader',options: createVueLoaderOptions(isDev)},{test: /\.jsx$/,loader: 'babel-loader'},{test: /\.js$/,loader: 'babel-loader',exclude: /node_modules/},{test: /\.(gif|jpg|jpeg|png|svg)$/,use: [{loader: 'url-loader',options: {limit: 1024,name: 'resources/[path][name].[hash:8].[ext]'}}]}]}
}
module.exports = config

2.4.2 webpack.client.js

const path = require('path')
const HTMLPlugin = require('html-webpack-plugin')
const webpack = require('webpack')
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base')
const VueClientPlugin = require('vue-server-renderer/client-plugin')const isDev = process.env.NODE_ENV === 'development'const defaultPluins = [new webpack.DefinePlugin({'process.env': {NODE_ENV: isDev ? '"development"' : '"production"'}}),new HTMLPlugin({template: path.join(__dirname, 'template.html')}),new VueClientPlugin()
]const devServer = {port: 7999,host: '0.0.0.0',overlay: {errors: true},headers: { 'Access-Control-Allow-Origin': '*' },historyApiFallback: {index: '/public/index.html'},proxy: {'/api': 'http://127.0.0.1:3332','/user': 'http://127.0.0.1:3332'},hot: true
}let configif (isDev) {config = merge(baseConfig, {target: 'web',entry: path.join(__dirname, '../src/clientEntry.js'),output: {filename: 'bundle.[hash:8].js',path: path.join(__dirname, '../public'),publicPath: 'http://127.0.0.1:7999/public/'},devtool: '#cheap-module-eval-source-map',module: {rules: [{test: /\.(sc|sa|c)ss/,use: ['vue-style-loader','css-loader','sass-loader',{loader: 'postcss-loader',options: {sourceMap: true}}]}]},devServer,plugins: defaultPluins.concat([new webpack.HotModuleReplacementPlugin(),new webpack.NoEmitOnErrorsPlugin()])})
}module.exports = config

webpack.client.js和我们不使用服务器渲染时唯一(注意:这里的【唯一】仅仅是对于这个简单demo来说)的不同就是我们还使用了vue-server-renderer/client-plugin,这个plugin的作用是生成一个名为vue-ssr-client-manifest.json的文件。这个文件将在我们做服务端渲染的时候用到。

2.4.3 webpack.server.js

const path = require('path')
const ExtractPlugin = require('extract-text-webpack-plugin')
const webpack = require('webpack')
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base')
const VueServerPlugin = require('vue-server-renderer/server-plugin')let configconst isDev = process.env.NODE_ENV === 'development'const plugins = [new ExtractPlugin('styles.[contentHash:8].css'),new webpack.DefinePlugin({'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),'process.env.VUE_ENV': '"server"'})
]if (isDev) {plugins.push(new VueServerPlugin())
}config = merge(baseConfig, {target: 'node',entry: path.join(__dirname, '../src/serverEntry.js'),devtool: 'source-map',output: {libraryTarget: 'commonjs2',filename: 'server-entry.js',path: path.join(__dirname, '../server-build')},externals: Object.keys(require('../package.json').dependencies),module: {rules: [{test: /\.(sc|sa|c)ss/,use: ExtractPlugin.extract({fallback: 'vue-style-loader',use: ['css-loader','sass-loader',{loader: 'postcss-loader',options: {sourceMap: true}}]})}]},plugins
})
module.exports = config

为了方便读者联系上下文,这里直接贴上了全部的webpack.server.js,但是,最值得注意的是这一段配置:

...
target: 'node',
entry: path.join(__dirname, '../src/serverEntry.js'),
devtool: 'source-map',
output: {libraryTarget: 'commonjs2',filename: 'server-entry.js',path: path.join(__dirname, '../server-build')
},...

因为这个打包后的文件需要在node端运行,所以我们需要更改targetlibraryTarget
同样的,在打包serverEntry也使用了一个和vue-server-renderer/client-plugin类似的vue-server-renderer/server-plugin,这个插件用于生成一个名为vue-ssr-server-bundle.json的文件。

3. vue-ssr-client-manifest.json和vue-ssr-server-bundle.json

这两个文件在我们之后的服务器渲染时都会使用到。为了之后我们能更加理解ssr,我们先来看看这两个文件是什么:

3.1 vue-ssr-client-manifest.json

vue-ssr-client-manifest.json是我们打包客户端渲染时使用vue-server-renderer/client-plugin 生成的文件。
其文件内容:

从这个json文件我们可以明显看出,借助client-plugin,将应用使用的文件进行了分类,publicPath是公共路径,all 是所有的文件,initial是入口文件依赖的js和css,async是首屏不需要的异步的js。所以,我们能够通过vue-ssr-client-manifest.json做什么呢?其最重要的作用就是我们能根据initial拿到客户端渲染的js代码。

3.2 vue-ssr-server-bundle.json

vue-ssr-server-bundle.json是我们打包serverEntry.js通过vue-server-renderer/server-plugin生成的。
其文件内容(vue-ssr-server-bundle.json文件很大,为了方便观察,我将每个键对应的值都进行了删减):

entry是服务款入口的文件,files是服务端依赖的文件列表,maps是sourcemaps文件列表。
这里,我们主要观察files的内容,如果我们将files展开,我们会看到一堆文件名:value,我们看一下下图中的value值:

是的,你没有看错,这里面全部都是js代码。而这些js代码,就是我们在服务端根据用户请求来生成完整html需要使用到的代码。

3. node服务端

我们需要在node端做以下行为:
创建服务端,接收用户请求,根据用户请求来生成一个完整的Html界面,并将客户端渲染需要的js文件Hydrate到该html文件中。
我们使用koa来处理服务端的工作。

3.1 ssr-router.js

既然需要我们根据用户请求来生成对应的html文件,我们继续要构建一个和前端路由功能类似的ssr-router.js用于服务器渲染时的路由匹配,其实说成匹配并不恰当,但关键是,根据用户请求来生成一个完整的html。

const Router = require('koa-router')
const axios = require('axios')
const path = require('path')
const fs = require('fs')
const MemoryFS = require('memory-fs')
const webpack = require('webpack')
const VueServerRenderer = require('vue-server-renderer')const serverRender = require('./server-render')
const serverConfig = require('../../build/webpack.server')const serverCompiler = webpack(serverConfig)
const mfs = new MemoryFS()
serverCompiler.outputFileSystem = mfslet bundle
//使用配置webpack.server.js来调用了webpack进行打包
//其实在这里我们也可以像打包客户端一样在外部打包,但这样更方便我们开发。
serverCompiler.watch({}, (err, stats) => {//当监听到文件变化时,我们重新打包if (err) throw errstats = stats.toJson()stats.errors.forEach(err => console.log(err))stats.warnings.forEach(warn => console.warn(err))const bundlePath = path.join(serverConfig.output.path,'vue-ssr-server-bundle.json')bundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8'))console.log('new bundle generated')
})// ctx 包含了用户的请求路径信息
const handleSSR = async (ctx) => {//当服务端打包还未完成时,如果这时候用户发起请求,直接returnif (!bundle) {ctx.body = '你等一会,别着急......'return}//获取到clientEntry打包生成的vue-ssr-client-manifest.jsonconst clientManifestResp = await axios.get('http://127.0.0.1:7999/public/vue-ssr-client-manifest.json')const clientManifest = clientManifestResp.data//获取到模板,用于填充网页内容const template = fs.readFileSync(path.join(__dirname, '../server.template.ejs'),'utf-8')//构建一个渲染器,这个渲染器如何工作,后文有较为详细的叙述const renderer = VueServerRenderer.createBundleRenderer(bundle, {inject: false,clientManifest })//调用渲染方法,在这一步中,会向ctx中添加一个完整的html信息await serverRender(ctx, renderer, template)
}const router = new Router()
//koa-router的get方法,调用handleSSR时向其中传入ctx,并向用户返回执行完handleSSR之后的ctx
router.get('*', handleSSR)module.exports = router

3.2 server-render.js

这个函数其实可以写在ssr-router.js内部,因为它其实是完成ssr-router.js的主要步骤。
但我们这里将它抽离成单独的js文件。

const ejs = require('ejs')module.exports = async (ctx, renderer, template) => {ctx.headers['Content-Type'] = 'text/html'const context = { url: ctx.path }try {const appString = await renderer.renderToString(context)if (context.router.currentRoute.fullPath !== ctx.path) {return ctx.redirect(context.router.currentRoute.fullPath)}const html = ejs.render(template, {appString,style: context.renderStyles(),scripts: context.renderScripts()})ctx.body = html  //将完整的html赋值给ctx} catch (err) {console.log('render error', err)throw err}
}

我们现在结合3.13.2来说明这个完整的html是如何生成的。
ssr-router.js中我们这样创建了VueServerRenderer

     const renderer = VueServerRenderer.createBundleRenderer(bundle, {inject: false,clientManifest })await serverRender(ctx, renderer, template)

server-render.js中我们调用了renderer.renderToString(context)

 const appString = await renderer.renderToString(context)

如果我们能去阅读vue-server-renderer的源码createBundleRenderer部分,我们就能知道这里传入的bundle是如何根据ctx来生成html的了,这是将bundle的处理过程当中的关键步骤流程图:

在renderToString()阶段,会执行runner(context):
我们之前分析了bundle(即vue-ssr-server-bundle.json)的内容,bundle中存在entry。当执行createBundleRunner()时,在内部会执行compileModule(),生成一个处理编译后源码的函数evaluate。evaluate函数会将编译后文件源码包装成module对象,而后返回module.exports.defualt,它就是封装了文件源码的函数,执行这个函数就相当于执行文件源码。当这个文件是入口文件时,返回的就是entry入口文件源码的封装函数,也就是runner,那么执行runner(context)至关于执行entry-server.js导出的函数。我们可以再次返回到2.2 serverEntry.js,加深我们对客户端渲染入口文件返回一个函数的理解。

run = context => {return new Promise((resolve, reject) => {const { app, router } = createApp()router.push(context.url)router.onReady(() => {const matchedComponents = router.getMatchedComponents()if (!matchedComponents.length) {return reject(new Error('no component matched'))}context.router = routerresolve(app)})})
}

在执行runner(context)的时候,因为 const context = { url: ctx.path },所以我们就可以根据用户的请求路径,通过router.push(context.url)获取到相应的路由实例,然后router.onReady()意味着我们将此路由下的所有同步/异步组件都已经加载完毕,向其中添加了一个回调函数,在这个函数中,我们把这个加载完成的路由实例添加到context对象上:context.router = router,此时,context就已经拿到了渲染一个完整html的所有数据
然后,我们在将context的数据引入到模板上,得到一个html:

const html = ejs.render(template, {appString,style: context.renderStyles(),scripts: context.renderScripts()})

又因为我们最后返回给浏览器的是ctx,所以:

ctx.body = html //将完整的html赋值给ctx

在renderToString()阶段,执行玩runner(context)后,还会执行render(app),这里的app其实就是我们执行了runner(context)之后拿到的vue实例。这时候,就是clientManifest发挥作用的时候了:
clientManifest中记录着资源加载信息,经过运行app获得context对象中_registedComponents拿到moduleIds,而后获得usedAsyncFiles(组件依赖的文件)。其与preloadFiles(clientManifest中的initial文件数组)的并集就是初始渲染的预加载的资源列表,与prefetchFiles(clientManifest中的async文件数组)的差集就是预取的资源列表。 也就是在这个时候,context的scripts中增加了接管单页面应用所需要的js文件。

4. 创建服务端:

server.js:用于启动服务

const Koa = require('koa')
const send = require('koa-send')
const path = require('path')
const staticRouter = require('./routers/static')
const app = new Koa()const isDev = process.env.NODE_ENV === 'development'app.use(async (ctx, next) => {try {console.log(`request with path ${ctx.path}`)await next()} catch (err) {console.log(err)ctx.status = 500if (isDev) {ctx.body = err.message} else {ctx.bosy = 'please try again later'}}
})app.use(async (ctx, next) => {if (ctx.path === '/favicon.ico') {await send(ctx, '/favicon.ico', { root: path.join(__dirname, '../') })} else {await next()}
})app.use(staticRouter.routes()).use(staticRouter.allowedMethods())let pageRouter
if (isDev) {pageRouter = require('./routers/dev-ssr')
}
app.use(pageRouter.routes()).use(pageRouter.allowedMethods())const HOST = process.env.HOST || '0.0.0.0'
const PORT = process.env.PORT || 3332app.listen(PORT, HOST, () => {console.log(`server is listening on ${HOST}:${PORT}`)
})

server.js用于启动服务端,如果有需要,也可以在其中设置一下路由拦截

5. package.json

添加运行脚本:

"scripts": {"dev:client": "cross-env NODE_ENV=development webpack-dev-server --config build/webpack.client.js","dev:server": "nodemon server/server.js","dev": "concurrently \"npm run dev:client\" \"npm run dev:server\""}

然后我们在命令台:

npm run dev

最后,访问localhost:3332即可访问服务端渲染的网页。

6. 结语:

ssr需要花一定时间才能更好地理解,这里是笔者的demo地址,如有需要,可以自行下载。

Vue项目使用SSR服务器渲染相关推荐

  1. 修改vue项目到服务器端渲染,现有vue-cli3搭建的vue项目改ssr服务器渲染

    项目简介 vue+node+koa2 安装ssr依赖 npm install vue-server-renderer webpack-node-externals cross-env --save-d ...

  2. 【Nuxt3 ssr 服务器渲染 】

    Nuxt3 ssr 服务器渲染 前言 大家肯定遇到这样的面试题vue怎样seo?vue怎样提高首页速度?那么答案之一就是ssr服务器渲染. vue的ssr技术最好的选择就是nuxt了!因为迁移起来非常 ...

  3. 浅谈ssr服务器渲染、客户端渲染和预渲染以及前端打包部署

    浅谈ssr服务器渲染.客户端渲染和预渲染以及前端打包部署 1.客户端渲染: 2.服务器渲染(SSR) 3.预渲染 前端打包文件dist结合nginx和node原理图(个人见解) 今天下班在地铁上直到现 ...

  4. vue项目设置服务器地址,vue项目配置后端服务器地址

    vue项目配置后端服务器地址 内容精选 换一换 查询负载均衡器状态树.可通过该接口查询负载均衡器关联的监听器.后端云服务器组.后端云服务器.健康检查.转发策略.转发规则的主要信息,了解负载均衡器下资源 ...

  5. vue项目部署到服务器后浏览器标签上的小图标消失不见

    背景: 最近在开发项目过程中发现一个问题,项目部署到服务器后在浏览器打开,会发现浏览器标签上的小图标消失不见了.百度查找问题,网上给出了许多解决的方案,例如清除浏览器缓存.把图标的相对路径改成绝对路径 ...

  6. Vue项目部署到服务器(ubuntu)

    Vue项目部署到服务器(ubuntu) 工具:WinSCP.PuTTy(可能不是专业的工具,是本人上操作系统的课用到的软件,直接用来部署了) 打包项目,npm run build 执行npm run ...

  7. vue ssr服务器渲染笔记

    服务器端server 和 客户端client 服务端渲染主要使用vue-server-renderer插件 https://www.cnblogs.com/aliwa/p/8505284.html 通 ...

  8. vue项目转换服务器端渲染,vue-server-renderer实现vue项目改造服务端渲染

    这是一篇教程,从创建项目到改造项目 vue-cli创建一个项目 在放你做demo的地方,创建一个项目 vue create vue-ssr // 如果你安装了vue-cli4,选择vue2的版本,以下 ...

  9. 宝塔面板部署vue项目到云服务器上(Nginx服务器)

    前言: 之前使用终端安装nginx,后来崩了 因为自己宝塔也安了 后来服务器重装 决定只用宝塔的nginx部署 步骤: 1.填加站点 2.第一行随便写一行域名 后面删掉就行 第二行ip:端口 php版 ...

  10. vue项目如何放到服务器上,Vue项目怎么上传到云服务器

    Vue项目怎么上传到云服务器 内容精选 换一换 本章节以Linux操作系统为例,指导您通过弹性云服务器内网方式连接GaussDB(for Redis)实例.使用内网连接GaussDB(for Redi ...

最新文章

  1. Weblogic Admin Console
  2. 【BZOJ1934】善意的投票(网络流)
  3. deinstall 卸载grid_oracle 11g RAC手动卸载grid,no deinstall   .
  4. 生成模型和判别模型_生成模型和判别模型简介
  5. 计算机网络 DNS协议 FTP DHCP
  6. Unity 碰撞器和触发器的理解
  7. 关于svn的安装配置开启服务过程和 eclipse安装SVN插件的方法
  8. 想通过好的商业模式赚钱,应该钻研“道”还是“术”呢?
  9. Python数据结构与算法笔记(八):数据结构——树,二叉树和AVL树
  10. 3.等待和通知(Waiting and Notification)
  11. Lumen开发:lumen源码解读之初始化(5)——注册(register)与启动(boot)
  12. 平方方程应该都有整数解
  13. eclipse 初始需要修改的内容
  14. 【夏虫语冰】visio2013安装出错,您输入的产品密钥无法在此计算机上使用,错误25004
  15. alienfx无法与计算机,戴尔G3无法检测到AlienFX设备怎么办
  16. 6.5一些keil编程错误总结
  17. Unity零基础入门 - 打砖块(Unity 2017)
  18. linux查看进程limits解释,linux中/etc/security/limits.conf配置文件说明
  19. Coap在Andorid中的简单应用
  20. C# DateTime:日期、日期差、时间、时间差

热门文章

  1. tiny6410烧录
  2. 服务器怎么改成gpt分区支持,硬盘mbr分区更改成gpt分区的方法
  3. oracle 范鑫_20集 JAVA数据库连接视频教程 JAVA能力提升专题视频教程 JDBC动力节点视频教程,全套视频教程学习资料通过百度云网盘下载...
  4. 1194: 总成绩排序(结构体专题)
  5. 公司邮箱怎么申请注册?电子邮箱注册教程来了
  6. 基于JSP动漫论坛的设计与实现
  7. python写身份证_python 关于身份证号码的相关操作
  8. uniapp AES加密解密
  9. 什么是透明背景格式logo?Logo白底变透明工具测评
  10. Dialog去掉默认白色背景