著名哲学家康德说:人是目的,而不是手段 一位不知名的小伙阿梦说:技术是手段,而不是目的
小技巧:学习之前先了解前端渲染的历史背景,有助于我们更易理解渲染方式
了解一下:分层的目的是为了解耦,明确分工。
M:model层 --- 跟数据库打交道
V:view层 --- 跟视图打交道
C:controller层 --- 跟业务打交道
没有什么是加一层架构无法解决的,如果有,就加两层
这是一个没有前端工程师的年代。网页也特别简单,页面都是由JSP、PHP等服务端生成后,交给浏览器渲染。基本上是服务器给浏览器什么,浏览器就展示什么。几乎没有交互。 这种模式只适合小型简单的项目,当业务复杂之后,JSP的代码维护性会越来越差,因为JSP混合了前后端逻辑,没有做分层处理
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>购物车</title>
<link href="<%=cssUrl%>bootstrap.min.css" rel="stylesheet">
<link href="<%=cssUrl%>index.css" rel="stylesheet">
<link href="<%=cssUrl%>cart.css" rel="stylesheet">
<script type="text/javascript" src="<%=jsUrl%>jquery-3.2.1.min.js"></script>
<script type="text/javascript" src="<%=jsUrl%>bootstrap.min.js"></script>
<script type="text/javascript" src="<%=jsUrl%>cartValidate.js"></script>
<%@ include file="/commons/queryCondition.jsp" %>
</head>
<body>
<%@ include file="/commons/header.jsp"%>
<c:choose>
<c:when test="${ !empty sessionScope.ShoppingCart.computers }">
<br><br>
<div class="container">
<div class="container">
<div class="alert alert-success tip-success" id="computerNumber">您的购物车中共有 <b>${sessionScope.ShoppingCart.computerNumber } </b>件商品</div>
<table class="table table-striped">
<tr>
<td class="col-md-6">商品名</td>
<td class="col-md-2 text-center">数量</td>
<td class="col-md-2 text-center">价格</td>
<td class="col-md-2 text-center">操作</td>
</tr>
<c:forEach items = "${sessionScope.ShoppingCart.items }" var = "item">
<h4>User: ${user.username }</h4>
<tr>
<td class="col-md-6 ">
<img alt="${item.computer.id }" src="${item.computer.url }"/ style="width:180px;height:180px;">
${item.computer.brand } ${item.computer.model }
</td>
<td class="col-md-2 cartItem text-center" style="height:100px;line-height: 200px;">
<input class="cartItemNum" step="${item.quantity }" type="text" size="1" name="${item.computer.id }" value="${item.quantity }" style="width:50px;height:30px;"/>
</td>
<td class="col-md-2 text-center">¥ <b>${item.computer.price }</b></td>
<td class="col-md-2 text-center">
<a class="btn btn-danger delete" href="computerServlet?method=remove&pageNo=${param.pageNo }&id=${item.computer.id }">删除</a>
</td>
</tr>
</c:forEach>
</table>
<div id="totalMoney" style="font-weight:bold;">总金额:¥ ${sessionScope.ShoppingCart.totalMoney }</span>
</div>
</div>
<div class="container">
<div class="row">
<div class="col-xs-6 col-md-8"></div>
<div class="col-xs-12 col-md-4 text-right" style="padding-left:30px;">
<a href="computerServlet?method=getComputers&pageNo=${param.pageNo }" class="btn btn-default" role="button">继续购物</a>
<a href="computerServlet?method=clear" class="btn btn-danger" role="button">清空购物车</a>
<a href="computerServlet?method=forwardPage&page=cash" class="btn btn-primary" role="button">结账</a>
</div>
</div>
</div>
</div>
</c:when>
<c:otherwise>
<jsp:forward page="/WEB-INF/pages/emptycart.jsp" />
</c:otherwise>
</c:choose>
<%@ include file="/commons/footer.jsp"%>
</body>
</html>
Ajax的全称是 Asynchronous JavaScript And XML
前端工程师伴随Ajax一起出现了
Ajax是浏览器内置的功能,通过 xmlHttpRequest
,可以向服务端发起请求,而不需要刷新页面。Ajax的出现是革命性的,它就是上面图中问题的答案,Ajax解耦了view层和服务端,成为了中间接口层
function ajaxExample() {
const xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function () {
if (this.readyState == 4 && this.status == 200) {
document.getElementById('foo').innerHTML = this.responseText; // 响应
}
};
xhttp.open('GET', 'https://www.bar.com/interface', true);
xhttp.send();
}
那么… 接下来,客户端渲染就当当当登场了
view层从MVC框架中脱离出来独立成为了前端工程师耕耘之地 随着业务复杂、技术发展,渐渐的view层又被拆分成了 MVVM
架构(不赘述了,讲起来又是一大堆) 前端框架也是在这个阶段出现的:
客户端渲染如今依然是最主流的渲染方式,其优缺点都非常明显
优点
Dynamic Rendering
)诶?怎么又回到服务端渲染了,为什么需要服务端渲染?
服务端渲染最主要就是为了解决客户端渲染的缺点
此SSR非彼MVC。以前的服务端渲染是用JAVA、PHP这些后端语言来做的, 现如今的服务端渲染普遍使用 前端框架 + nodejs 实现,还是跟服务端解耦的
用一句话概括服务端渲染原理: 使用前端框架,在服务端把页面渲染成搜索引擎良好的格式,然后返回给客户端激活(hydration)
但是不要低估任何看起来简单的事,因为简单的事总是会变得复杂
目前有许多流行的SSR方案,选择一个适合项目的即可,不必纠结(做好技术选型)
pnpm create vite ssr-react-demo --template react-ts
import ReactDOMServer from 'react-dom/server'
export function render() {
const html = ReactDOMServer.renderToString(<div id="root">Hello world</div>)
return html
}
入口文件写好了,现在我们需要一个服务来执行这个入口
import express from 'express'
// 创建http服务
const app = express()
// 为了方便各位理解,我们暂时不考虑正式环境
// 开发环境下添加vite服务中间件
const { createServer } = await import('vite')
const vite = await createServer({
server: {
middlewareMode: true, // 以中间件模式启动vite开发服务
},
appType: 'custom',
})
app.use(vite.middlewares)
// 拦截路由(* 通配符拦截所有请求)
app.use('*', async (req, res) => {
console.log(req);
return res.send('Hello, world')
})
const PORT = 9527
app.listen(PORT, () => {
console.log(`Server started at http://localhost:${PORT}`)
})
先启动看看
ok,把vite作为中间件启动本地服务成功了,我们现在要把React组件在服务端渲染好,然后以字符串html的形式返回给客户端
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<!-- 这里新增一个head标签的占位 -->
<!--app-head-->
</head>
<body>
<!-- 这里新增一个服务端返回内容的占位 -->
<div id="root"><!--app-html--></div>
</body>
</html>
ssrLoadModule
解析入口文件app.use('*', async (req, res) => {
try {
const url = req.originalUrl
// 在这里可以返回服务端渲染的内容给客户端
let template = await fs.readFile('./index.html', 'utf-8')
template = await vite.transformIndexHtml(url, template)
let render = (await vite.ssrLoadModule('/src/entry-server.tsx')).render
const rendered = await render(url)
const html = template
.replace(`<!--app-head-->`, rendered.head ?? '')
.replace(`<!--app-html-->`, rendered.html ?? '')
res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
} catch(e) {
vite?.ssrFixStacktrace(e)
res.status(500).end(e.stack)
}
})
这里有两个重点:
transformIndexHtml
先处理html模板,vite会为我们添加一些客户端需要的脚本ssrLoadModule
解析ssr相关的文件我们现在实现了把html字符串返回给客户端,这些字符串是静态的,不具有交互性的。 需要在客户端“激活”字符串并添加脚本,使其可交互
我们先直接启动,看看服务端返回的静态网页长什么样子
首先,需要保证服务端返回的内容跟客户端激活的内容是一致的。所以我们需要稍微改造一下服务端入口文件
import ReactDOMServer from 'react-dom/server'
import App from './App'
export function render() {
// 把内容抽离到App中
const html = ReactDOMServer.renderToString(<App/>)
return { html }
}
然后新建一个客户端入口文件,渲染跟服务端一样的内容(重命名一下main.tsx即可)
import './index.css'
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.hydrateRoot(
document.getElementById('root') as HTMLElement,
<React.StrictMode>
<App />
</React.StrictMode>
)
最后,把客户端的脚本注入到html中即可
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<!-- 这里新增一个head标签的占位 -->
<!--app-head-->
</head>
<body>
<!-- 这里新增一个服务端返回内容的占位 -->
<div id="root"><!--app-html--></div>
<!-- 注入客户端入口文件,vite会去解析 -->
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>
这里是不是就跟咱们的SPA渲染差不多:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
最后,我们看一下可交互的网页 这些是客户端入口带来的资源,这些资源使得我们的网页有了交互性和css样式
我们在服务端入口、客户端入口,都引入了一个名为 App.tsx
的文件,这个文件我们称之为同构文件, 它会在客户端、服务端都渲染,我们知道客户端和服务端是两个完全不同的环境,最典型的是: 客户端没有node,服务端没有window 所以我们在编写同构代码时,需要考虑环境问题
至此,我们完成了一个最基础的ssr,也是最核心的原理。下文都是在以上核心上做扩展 在继续讲解之前,我们看看如何使用vue完成上文类似的ssr基础项目,或许可以帮助各位更好的理解
思路跟react版本大同小异,我再快速重复一次,加深印象
pnpm create vite ssr-vue-demo --template vue-ts
import { createSSRApp } from 'vue'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
return { app }
}
import { renderToString } from 'vue/server-renderer'
import { createApp } from './main'
export async function render() {
const { app } = createApp()
const ctx = {}
const html = await renderToString(app, ctx)
return { html }
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
<!--app-head-->
</head>
<body>
<div id="app"><!--app-html--></div>
</body>
</html>
import express from 'express'
import fs from 'fs/promises'
// 创建http服务
const app = express()
// 为了方便各位理解,我们暂时不考虑正式环境
// 开发环境下添加vite服务中间件
const { createServer } = await import('vite')
const vite = await createServer({
server: {
middlewareMode: true, // 以中间件模式启动vite开发服务
},
appType: 'custom',
})
app.use(vite.middlewares)
// 拦截路由(* 通配符拦截所有请求)
app.use('*', async (req, res) => {
try {
const url = req.originalUrl
// 在这里可以返回服务端渲染的内容给客户端
let template = await fs.readFile('./index.html', 'utf-8')
template = await vite.transformIndexHtml(url, template)
let render = (await vite.ssrLoadModule('/src/entry-server.ts')).render
const rendered = await render(url)
const html = template
.replace(`<!--app-head-->`, rendered.head ?? '')
.replace(`<!--app-html-->`, rendered.html ?? '')
res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
} catch(e) {
vite?.ssrFixStacktrace(e)
console.log(e.stack)
res.status(500).end(e.stack)
}
})
const PORT = 9527
app.listen(PORT, () => {
console.log(`Server started at http://localhost:${PORT}`)
})
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
<!--app-head-->
</head>
<body>
<div id="app"><!--app-html--></div>
+ <script type="module" src="/src/entry-client.ts"></script>
</body>
</html>
启动服务
之后都用React做例子了,因为Vue、React的表现层是类似的,只是底层原理不同。
实际上Vue和React完全是两种设计理念
Vue是非经典的MVVM模型
React是函数模型
框架的模型差异很大,其内部实现完全不同
比如Vue3出了组合式编程,看起来好像跟React的hook模型一样,
但因为两者本质模型不同、思想不同,就注定了他们的实现方式一定是不一样的
所以尽量不要把先入为主的思想加于另一门语言
既然我们提出了“正式环境”的概念,则需要一个变量来控制环境,在node项目中,普遍使用NODE_ENV
来控制环境
因为我们有一个服务端入口函数、一个客户端入口函数,所以打包自然也需要打两个
"scripts": {
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --ssrManifest --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server"
},
我们分析下这两个命令行 build:client
设置了2个参数:
build:server
设置了2个参数:
我们打包看看,得到的结果是否如我们所愿 看起来没什么问题,我们接下来使用node来服务这些静态文件
说到服务,我们就要想到把关注点放在server.js上 这里粘一下之前的纯开发环境的serverjs
import express from 'express'
import fs from 'fs/promises'
// 创建http服务
const app = express()
// 为了方便各位理解,我们暂时不考虑正式环境
// 开发环境下添加vite服务中间件
const { createServer } = await import('vite')
const vite = await createServer({
server: {
middlewareMode: true, // 以中间件模式启动vite开发服务
},
appType: 'custom',
})
app.use(vite.middlewares)
// 拦截路由(* 通配符拦截所有请求)
app.use('*', async (req, res) => {
try {
const url = req.originalUrl
// 在这里可以返回服务端渲染的内容给客户端
let template = await fs.readFile('./index.html', 'utf-8')
template = await vite.transformIndexHtml(url, template)
let render = (await vite.ssrLoadModule('/src/entry-server.tsx')).render
const rendered = await render(url)
const html = template
.replace(`<!--app-head-->`, rendered.head ?? '')
.replace(`<!--app-html-->`, rendered.html ?? '')
res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
} catch(e) {
vite?.ssrFixStacktrace(e)
console.log(e.stack)
res.status(500).end(e.stack)
}
})
const PORT = 9527
app.listen(PORT, () => {
console.log(`Server started at http://localhost:${PORT}`)
})
现在我们添加正式环境相关的处理进去
import express from 'express'
import fs from 'fs/promises'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const resolve = (p) => path.resolve(__dirname, p)
// 创建http服务
const app = express()
// 判断环境
const isProduction = process.env.NODE_ENV === 'production'
let vite
if(!isProduction) {
// 开发环境下添加vite服务中间件
const { createServer } = await import('vite')
vite = await createServer({
server: {
middlewareMode: true, // 以中间件模式启动vite开发服务
},
appType: 'custom',
})
app.use(vite.middlewares)
} else {
// 正式环境下不需要vite的server中间件了
// 我们需要自行操作如何服务静态资源
app.use((await import('compression')).default())
app.use(
(await import('serve-static')).default(resolve('dist/client'), {
index: false,
}),
)
}
// html模板
const templateHtml = isProduction
? await fs.readFile(resolve('./dist/client/index.html'), 'utf-8')
: ''
// ssr渲染需要的静态资源清单
const ssrManifest = isProduction
? await fs.readFile(resolve('./dist/client/ssr-manifest.json'), 'utf-8')
: undefined
// 拦截路由(* 通配符拦截所有请求)
app.use('*', async (req, res, next) => {
try {
const url = req.originalUrl
let template
let render
if(!isProduction) {
// 在这里可以返回服务端渲染的内容给客户端
template = await fs.readFile('./index.html', 'utf-8')
template = await vite.transformIndexHtml(url, template)
render = (await vite.ssrLoadModule('/src/entry-server.tsx')).render
} else {
// 读取client的html模板
template = templateHtml
render = (await import('./dist/server/entry-server.js')).render
}
const rendered = await render(url, ssrManifest)
const html = template
.replace(`<!--app-head-->`, rendered.head ?? '')
.replace(`<!--app-html-->`, rendered.html ?? '')
res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
} catch(e) {
vite?.ssrFixStacktrace(e)
console.log(e.stack)
res.status(500).end(e.stack)
}
})
const PORT = 9527
app.listen(PORT, () => {
console.log(`Server started at http://localhost:${PORT}`)
})
我们启动脚本试试
一个完整的项目还需要哪些功能呢?
每个点拿出来都可以说很久,但篇幅有限为了便于大家理解,我只会落地它们偏简单的实现
预渲染,也称为静态渲染。也就是说,页面在打包的时候被渲染好,不必在客户端请求时再被渲染一次 预渲染的好处是可以极大程度上减轻服务器的压力,并且可以更好利用服务器和CDN的缓存
要实现预渲染,首先我们需要预先定义好页面路由规则,然后我们才能根据规则,把组件预先渲染成HTML 我们假设src/pages
目录下的为页面组件,那么我们可以使用react-router
做多页面路由
先写客户端入口文件
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { App } from './App'
ReactDOM.hydrateRoot(
document.getElementById('app')!,
<BrowserRouter>
<App />
</BrowserRouter>,
)
服务端入口文件
import ReactDOMServer from 'react-dom/server'
import { StaticRouter } from 'react-router-dom/server'
import { App } from './App'
export function render(url: string) {
return {
html: ReactDOMServer.renderToString(
<StaticRouter location={url}>
<App />
</StaticRouter>,
)
}
}
同构文件app.tsx
import { Link, Route, Routes } from 'react-router-dom'
const pages = import.meta.glob('./pages/*.tsx', { eager: true }) as Record<string, any>
const routes = Object.keys(pages).map((path:string) => {
const name = path.match(/\.\/pages\/(.*)\.tsx$/)?.[1]
return {
name,
path: `/${name?.toLowerCase()}`,
component: pages[path].default,
}
})
export function App() {
return (
<>
<nav>
<ul>
{routes.map(({ name, path }) => {
return (
<li key={path}>
<Link to={path}>{name}</Link>
</li>
)
})}
</ul>
</nav>
<Routes>
{routes.map(({ path, component: RouteComp }) => {
return <Route key={path} path={path} element={<RouteComp />}></Route>
})}
</Routes>
</>
)
}
然后我们随便在pages中新建页面组件 server.js还是使用原来的即可(一点都不需要改) 然后我们启动本地服务试试看
本地服务其实跟预渲染没什么关系,因为预渲染本质上是一种构建和正式服务上的优化 接下来我们实现预渲染 前文说到了,预渲染是 把组件预先渲染成HTML 所以我们需要
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const toAbsolute = (p) => path.resolve(__dirname, p)
// 获取基础模板
const template = fs.readFileSync(toAbsolute('dist/static/index.html'), 'utf-8')
// 获取服务端渲染函数
const { render } = await import('./dist/server/entry-server.js')
// 根据约定好的规则,找到需要预渲染的路由
const routesToPrerender = fs
.readdirSync(toAbsolute('src/pages'))
.map((file) => {
const name = file.replace(/\.tsx$/, '').toLowerCase()
return `/${name}`
})
// 调用渲染函数,把组件渲染成html字符串,保存在dist中
;(async () => {
for (const url of routesToPrerender) {
const context = {}
const appHtml = await render(url, context).html
const html = template.replace(`<!--app-html-->`, appHtml)
const filePath = `dist/static${url === '/' ? '/index' : url}.html`
fs.writeFileSync(toAbsolute(filePath), html)
}
})()
先吸收好基础知识,下次我们再讲 进阶的原理和应用