前言
1. 什么是CSR?
全称为Client Side Render,即客户端渲染。服务器在首次响应客户端的请求时仅会返回一部分必须的html结构,一般来说只有一个主体的空容器(此时网站内容对用户不可见),客户端需要在加载js资源后再渲染出主要的html内容。这种渲染方式意味着网站主体内容是在客户端渲染。
2. 什么是SSR ?
全称为Server Side Render,即服务端渲染。服务器预先准备好了完整的html内容,在首次响应客户端的请求时就会全部返回,因此客户端首次加载网站时不需要额外的请求就能渲染出完整的html内容,用户能很快就看到网站的内容。网站主体内容是在服务端渲染。
- 传统服务端渲染: 传统服务端渲染比如jsp,php+js这种,前端代码属于静态资源只需要处理页面的渲染,但服务端的代码跟前端代码是完全隔离的,对于开发者而言实际上是前后分离的。
- 同构渲染: 整个应用的大部分代码是同时运行在服务端和客户端的,对于开发者而言,开发是一致的(虽然存在少量的环境相关的特点代码,如浏览器的特定api无法在服务端使用)。
3. CSR vs. SSR
SSR
- 优点: 首屏加载更快 (对首屏加载速度与转化率相关的应用来说,尤其重要) SEO友好,搜索引擎可以抓取到完整的页面内容 开发一致性(同构渲染)
- 缺点: 更多的构建和部署相关的要求,服务端渲染的应用需要一个nodejs运行环境 更高服务器负载,因为服务端渲染是一个完整的nodejs应用,需要更多的CPU资源 开发时需要针对特定的环境处理环境相关的代码
CSR
- 优点: 前后分离,前端无需关心服务端的开发, 前端可使用任意框架开发 页面渲染都在客户端完成,服务器没有更多压力,页面可部署到任意一个静态资源服务器 用户后续操作交互体验会更好(初次渲染加载了全部的js)
- 缺点: 首屏加载慢 SEO不友好
4. 什么是SSG
静态站点生成,也被称为预渲染。这种技术的关键词是静态,它输出的是静态的html和资源,跟SSR类似也会输出全部的html内容给到客户端,因此首屏加载也够快;但SSG只能输出静态数据,也就是在构建时就是已知的数据,部署完成后的数据变化它不再感知,每次数据变化都需要重新构建部署。所以它也更适合构建内容网站(网站部署后数据基本不变),如文档,博客等。同时SSG构建的网站也能被部署到任意的静态服务器上(因为它只是一些静态文件)。
构建工具推荐:VitePress
Vite SSR
Vite 提供了完整的SSR支持,下面我们会创建一个完整的SSR应用(同构)来解析vite在其中发挥的作用。
TIP
渲染流程:fetch data (server) → render to HTML (server) → load code (client) → hydrate (client)
1. ssr应用的一般文件结构 (本文示例中的前端框架是vue)
.
├── index.html **html模板**
├── public
│ └── favicon.ico
├── server.js **开启服务器,服务器运行入口文件**
├── src
│ ├── App.vue
│ ├── api
│ ├── assets 静态资源
│ ├── components
│ ├── entry-client.js **客户端渲染入口**
│ ├── entry-server.js **服务端渲染入口**
│ ├── main.js **环境无关的通用代码**
│ ├── pages 页面路由
│ ├── router
│ ├── stores 状态管理
│ └── utils
└── vite.config.js
2. 主要文件
server.js
主要分为开发环境和生产环境两部分逻辑;此文示例中生产环境中会使用koa来运行服务及托管静态文件;开发环境则会使用vite来托管静态文件,vite也会提供hmr,处理各类文件(如vue,jsx等文件)等更好的开发体验。
下面主要解析vite在开发环境提供的能力支持
点击查看代码
import Koa from 'koa'
import koaConnect from 'koa-connect'
import koaStatic from 'koa-static'
import koaCompress from 'koa-compress'
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import os from 'node:os'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const resolve = (p) => path.resolve(__dirname, p)
const isProd = process.env.NODE_ENV === 'production'
let vite
async function createServer(
root = process.cwd()
) {
const app = new Koa()
// 生产环境由koa完全接管
if (isProd) {
app.use(koaCompress())
app.use(koaStatic(resolve('dist/client'), {
index: false,
}))
} else {
// 以中间件模式创建 Vite 应用,并将 appType 配置为 'custom'
// 这将禁用 Vite 自身的 HTML 服务逻辑
// 并让上级服务器接管控制
vite = await (await import('vite')).createServer({
root,
server: { middlewareMode: true },
appType: 'custom'
})
app.use(koaConnect(vite.middlewares))
}
app.use(renderHtml)
return app
}
// 渲染&响应html内容
async function renderHtml(ctx, next) {
const url = ctx.originalUrl
try {
let template = ''
let render = null
let manifest = {}
if (isProd) {
template = await fs.readFile(resolve('dist/client/index.html'), 'utf-8')
render = (await import('./dist/server/entry-server.js')).render
// 生产环境有资源映射清单
manifest = await fs.readFile(resolve('dist/client/.vite/ssr-manifest.json'), 'utf-8')
manifest = JSON.parse(manifest)
} else {
// 1. 读取 index.html
template = await fs.readFile(resolve('index.html'), 'utf-8')
// 2. 应用 Vite HTML 转换。这将会注入 Vite HMR 客户端,
// 同时也会从 Vite 插件应用 HTML 转换。
template = await vite.transformIndexHtml(url, template)
// 3. 加载服务器入口。vite.ssrLoadModule 将自动转换
// 你的 ESM 源码使之可以在 Node.js 中运行!无需打包
// 并提供类似 HMR 的根据情况随时失效。
render = (await vite.ssrLoadModule('/src/entry-server.js')).render
}
// 4. 渲染应用的 HTML。这假设 entry-server.js 导出的 `render`
// 函数调用了适当的 SSR 框架 API。
// 例如 ReactDOMServer.renderToString()
const { html, preloadLinks, state } = await render(url, manifest)
// 5. 注入渲染后的应用程序 HTML 到模板中。
const htmlStr = template
.replace('<!--preload-links-->', preloadLinks)
.replace('<!--app-html-->', html)
.replace('<!--pinia_state-->', state)
// 6. 返回渲染后的 HTML。
ctx.body = htmlStr
ctx.type = 'text/html'
ctx.status = 200
} catch (e) {
// 如果捕获到了一个错误,让 Vite 来修复该堆栈,这样它就可以映射回
// 你的实际源码中。
vite?.ssrFixStacktrace(e)
console.log(e.stack)
next(e)
}
}
function getIPv4Address() {
const interfaces = os.networkInterfaces()
for (const devName in interfaces) {
const iface = interfaces[devName]
for (let i = 0; i < iface.length; i++) {
const alias = iface[i]
if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) {
return alias.address
}
}
}
}
const IP = getIPv4Address()
const port = 8888
createServer().then(app => {
app.listen(port, () => {
console.log(`Server started at http://${IP}:${port} \n\n`)
})
})
1. 开发环境下,vite作为中间件模式提供功能支持,此文件为vite创建服务器的流程(即调用vite.createServer())
点击查看代码
export interface ViteDevServer {
/**
* A connect app instance.
* - Can be used to attach custom middlewares to the dev server.
* - Can also be used as the handler function of a custom http server
* or as a middleware in any connect-style Node.js frameworks
*
* https://github.com/senchalabs/connect#use-middleware
*/
middlewares: Connect.Server
/**
* Apply vite built-in HTML transforms and any plugin HTML transforms.
*/
transformIndexHtml(
url: string,
html: string,
originalUrl?: string,
): Promise<string>
/**
* Load a given URL as an instantiated module for SSR.
*/
ssrLoadModule(
url: string,
opts?: { fixStacktrace?: boolean },
): Promise<Record<string, any>>
/**
* Mutates the given SSR error by rewriting the stacktrace
*/
ssrFixStacktrace(e: Error): void
}
export function createServer(
inlineConfig: InlineConfig = {},
): Promise<ViteDevServer> {
return _createServer(inlineConfig, { ws: true })
}
export async function _createServer(
inlineConfig: InlineConfig = {},
options: { ws: boolean },
): Promise<ViteDevServer> {
const config = await resolveConfig(inlineConfig, 'serve')
const { root, server: serverConfig } = config
const httpsOptions = await resolveHttpsConfig(config.server.https)
const { middlewareMode } = serverConfig
const resolvedWatchOptions = resolveChokidarOptions(config, {
disableGlobbing: true,
...serverConfig.watch,
})
const middlewares = connect() as Connect.Server
// 监听目录文件变化
const watcher = chokidar.watch(
// config file dependencies and env file might be outside of root
[root, ...config.configFileDependencies, config.envDir],
resolvedWatchOptions,
) as FSWatcher
// 根据url来构建各模块依赖关系
const moduleGraph: ModuleGraph = new ModuleGraph((url, ssr) =>
container.resolveId(url, undefined, { ssr }),
)
// 创建插件容器,将vite/rollup插件运行所需要的各类信息注入到插件运行的钩子函数中
const container = await createPluginContainer(config, moduleGraph, watcher)
// 实例化vite开发服务器
const server: ViteDevServer = {
config,
middlewares,
watcher,
pluginContainer: container,
ws,
moduleGraph,
transformIndexHtml: null!, // to be immediately set
async ssrLoadModule(url, opts?: { fixStacktrace?: boolean }) {
if (isDepsOptimizerEnabled(config, true)) {
await initDevSsrDepsOptimizer(config, server)
}
if (config.legacy?.buildSsrCjsExternalHeuristics) {
await updateCjsSsrExternals(server)
}
return ssrLoadModule(
url,
server,
undefined,
undefined,
opts?.fixStacktrace,
)
},
}
// 注入transformIndexHtml方法
server.transformIndexHtml = createDevHtmlTransformFn(server)
// hmr更新
const onHMRUpdate = async (file: string, configOnly: boolean) => {
if (serverConfig.hmr !== false) {
try {
await handleHMRUpdate(file, server, configOnly)
} catch (err) {
ws.send({
type: 'error',
err: prepareError(err),
})
}
}
}
// 监听文件改的并重启hmr
watcher.on('change', async (file) => {
file = normalizePath(file)
// invalidate module graph cache on file change
moduleGraph.onFileChange(file)
await onHMRUpdate(file, false)
})
// main transform middleware
middlewares.use(transformMiddleware(server))
// serve static files
middlewares.use(serveRawFsMiddleware(server))
middlewares.use(serveStaticMiddleware(root, server))
// httpServer.listen can be called multiple times
// when port when using next port number
// this code is to avoid calling buildStart multiple times
let initingServer: Promise<void> | undefined
let serverInited = false
const initServer = async () => {
if (serverInited) return
if (initingServer) return initingServer
initingServer = (async function () {
await container.buildStart({})
// start deps optimizer after all container plugins are ready
if (isDepsOptimizerEnabled(config, false)) {
await initDepsOptimizer(config, server)
}
initingServer = undefined
serverInited = true
})()
return initingServer
}
await initServer()
return server
}
2. 转换html文件,核心是调用一系列的html转换插件,来解析html里的所有合法的标签节点,最后会返回string
点击查看代码
export function createDevHtmlTransformFn(
server: ViteDevServer,
): (url: string, html: string, originalUrl: string) => Promise<string> {
const [preHooks, normalHooks, postHooks] = resolveHtmlTransforms(
server.config.plugins,
)
return (url: string, html: string, originalUrl: string): Promise<string> => {
return applyHtmlTransforms(
html,
[
preImportMapHook(server.config),
...preHooks,
htmlEnvHook(server.config),
devHtmlHook,
...normalHooks,
...postHooks,
postImportMapHook(),
],
{
path: url,
filename: getHtmlFilename(url, server),
server,
originalUrl,
},
)
}
}
// 调用html转换插件
export async function applyHtmlTransforms(
html: string,
hooks: IndexHtmlTransformHook[],
ctx: IndexHtmlTransformContext,
): Promise<string> {
for (const hook of hooks) {
const res = await hook(html, ctx)
if (!res) {
continue
}
if (typeof res === 'string') {
html = res
} else {
let tags: HtmlTagDescriptor[]
if (Array.isArray(res)) {
tags = res
} else {
html = res.html || html
tags = res.tags
}
const headTags: HtmlTagDescriptor[] = []
const headPrependTags: HtmlTagDescriptor[] = []
const bodyTags: HtmlTagDescriptor[] = []
const bodyPrependTags: HtmlTagDescriptor[] = []
for (const tag of tags) {
if (tag.injectTo === 'body') {
bodyTags.push(tag)
} else if (tag.injectTo === 'body-prepend') {
bodyPrependTags.push(tag)
} else if (tag.injectTo === 'head') {
headTags.push(tag)
} else {
headPrependTags.push(tag)
}
}
html = injectToHead(html, headPrependTags, true)
html = injectToHead(html, headTags)
html = injectToBody(html, bodyPrependTags, true)
html = injectToBody(html, bodyTags)
}
}
return html
}
3. 导入服务端渲染入口文件, 并运行render函数返回html字符串等内容
服务端渲染入口文件 entry-server.js
点击查看代码
import { renderToString } from 'vue/server-renderer'
import { basename } from 'node:path'
import { createApp } from './main'
export async function render(url, manifest) {
const { app, router, pinia } = createApp()
// set the router to the desired URL before rendering
await router.push(url)
await router.isReady()
// 匹配当前路由
const matchedComponents = router.currentRoute.value.matched.flatMap(route =>
Object.values(route.components)
)
await Promise.all(
matchedComponents.map(component => {
// 执行组件中定义的preFetch函数, 预先获取数据
if (component.preFetch && typeof component.preFetch === 'function') {
return component.preFetch({
pinia
})
}
return []
})
)
// passing SSR context object which will be available via useSSRContext()
// @vitejs/plugin-vue injects code into a component's setup() that registers
// itself on ctx.modules. After the render, ctx.modules would contain all the
// components that have been instantiated during this render call.
const ctx = {}
const html = await renderToString(app, ctx)
// ctx.modules写入了被当前页面加载引用的各个组件的路径id,此过程由@vitejs/plugin-vue完成
// 这个路径在build时生成的manifest资源映射文件中可作为key查找
/*
ctx.modules: {
'src/App.vue',
'src/pages/Home.vue',
'src/components/index.vue',
'src/components/header.vue',
'src/components/GetCoupon/base/index.vue',
'src/components/GetCoupon/base/components/index.vue',
'src/components/GetCoupon/base/components/coupon-group.vue',
'src/components/GetCoupon/base/components/coupon-card.vue'
}
*/
// the SSR manifest generated by Vite contains module -> chunk/asset mapping
// which we can then use to determine what files need to be preloaded for this
// request.
const preloadLinks = renderPreloadLinks(ctx.modules, manifest)
const state = JSON.stringify(pinia.state.value)
return { html, preloadLinks, state }
}
function renderPreloadLinks(modules, manifest) {
let links = ''
const seen = new Set()
modules.forEach((id) => {
const files = manifest[id]
if (files) {
files.forEach((file) => {
// 依赖去重,不同模块依赖相同文件时仅生成一次
if (!seen.has(file)) {
seen.add(file)
const filename = basename(file)
// 处理嵌套依赖情况,即被依赖的文件A也依赖于B,C...
if (manifest[filename]) {
for (const depFile of manifest[filename]) {
links += renderPreloadLink(depFile)
seen.add(depFile)
}
}
links += renderPreloadLink(file)
}
})
}
})
return links
}
// 处理各类文件预加载
function renderPreloadLink(file) {
if (file.endsWith('.js')) {
return `<link rel="modulepreload" crossorigin href="${file}">`
} else if (file.endsWith('.css')) {
return `<link rel="stylesheet" href="${file}">`
} else if (file.endsWith('.woff')) {
return ` <link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
} else if (file.endsWith('.ttf')) {
return ` <link rel="preload" href="${file}" as="font" type="font/ttf" crossorigin>`
} else if (file.endsWith('.gif')) {
return ` <link rel="preload" href="${file}" as="image" type="image/gif">`
} else if (file.endsWith('.jpg') || file.endsWith('.jpeg')) {
return ` <link rel="preload" href="${file}" as="image" type="image/jpeg">`
} else if (file.endsWith('.png')) {
return ` <link rel="preload" href="${file}" as="image" type="image/png">`
} else {
return ''
}
}
@vitejs/plugin-vue 在各组件的setup函数中插入了ctx.modules写值的逻辑,因此各组件渲染完成后ctx.modules里就有了服务器渲染调用期间使用到的组件模块 ID
if (ssr) {
const normalizedFilename = normalizePath(
path.relative(options.root, filename),
)
output.push(
`import { useSSRContext as __vite_useSSRContext } from 'vue'`,
`const _sfc_setup = _sfc_main.setup`,
`_sfc_main.setup = (props, ctx) => {`,
` const ssrContext = __vite_useSSRContext()`,
` ;(ssrContext.modules || (ssrContext.modules = new Set())).add(${JSON.stringify(
normalizedFilename,
)})`,
` return _sfc_setup ? _sfc_setup(props, ctx) : undefined`,
`}`,
)
}
客户端渲染入口文件 entry-client.js
点击查看代码
// 在浏览器挂载激活dom
import { createApp } from './main'
const { app, router, pinia } = createApp()
if (window.__INITIAL_STATE__) {
pinia.state.value = JSON.parse(window.__INITIAL_STATE__)
}
// wait until router is ready before mounting to ensure hydration match
router.isReady().then(() => {
app.mount('#app')
})
app.mount()调用,判断需要激活,执行hydrate
点击查看代码
mount(
rootContainer: HostElement,
isHydrate?: boolean,
namespace?: boolean | ElementNamespace,
): any {
if (!isMounted) {
// #5571
if (__DEV__ && (rootContainer as any).__vue_app__) {
warn(
`There is already an app instance mounted on the host container.\n` +
` If you want to mount another app on the same host container,` +
` you need to unmount the previous app by calling \`app.unmount()\` first.`,
)
}
const vnode = createVNode(rootComponent, rootProps)
if (isHydrate && hydrate) {
hydrate(vnode as VNode<Node, Element>, rootContainer as any)
} else {
render(vnode, rootContainer, namespace)
}
isMounted = true
app._container = rootContainer
// for devtools and telemetry
;(rootContainer as any).__vue_app__ = app
}
},
vue激活核心逻辑: 主要是查找已存在的dom节点,通过匹配组件和dom节点来添加交互事件。
点击查看代码
export function createHydrationFunctions(
rendererInternals: RendererInternals<Node, Element>,
) {
const {
mt: mountComponent,
p: patch,
o: {
patchProp,
createText,
nextSibling,
parentNode,
remove,
insert,
createComment,
},
} = rendererInternals
const hydrate: RootHydrateFunction = (vnode, container) => {
if (!container.hasChildNodes()) {
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
warn(
`Attempting to hydrate existing markup but container is empty. ` +
`Performing full mount instead.`,
)
patch(null, vnode, container)
flushPostFlushCbs()
container._vnode = vnode
return
}
hasMismatch = false
hydrateNode(container.firstChild!, vnode, null, null, null)
flushPostFlushCbs()
container._vnode = vnode
if (hasMismatch && !__TEST__) {
// this error should show up in production
console.error(`Hydration completed but contains mismatches.`)
}
}
const hydrateNode = (
node: Node,
vnode: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
slotScopeIds: string[] | null,
optimized = false,
): Node | null => {
const isFragmentStart = isComment(node) && node.data === '['
const onMismatch = () =>
handleMismatch(
node,
vnode,
parentComponent,
parentSuspense,
slotScopeIds,
isFragmentStart,
)
const { type, ref, shapeFlag, patchFlag } = vnode
let domType = node.nodeType
vnode.el = node
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
if (!('__vnode' in node)) {
Object.defineProperty(node, '__vnode', {
value: vnode,
enumerable: false,
})
}
if (!('__vueParentComponent' in node)) {
Object.defineProperty(node, '__vueParentComponent', {
value: parentComponent,
enumerable: false,
})
}
}
if (patchFlag === PatchFlags.BAIL) {
optimized = false
vnode.dynamicChildren = null
}
let nextNode: Node | null = null
switch (type) {
case Text:
if (domType !== DOMNodeTypes.TEXT) {
// #5728 empty text node inside a slot can cause hydration failure
// because the server rendered HTML won't contain a text node
if (vnode.children === '') {
insert((vnode.el = createText('')), parentNode(node)!, node)
nextNode = node
} else {
nextNode = onMismatch()
}
} else {
if ((node as Text).data !== vnode.children) {
hasMismatch = true
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
warn(
`Hydration text mismatch in`,
node.parentNode,
`\n - rendered on server: ${JSON.stringify(
(node as Text).data,
)}` +
`\n - expected on client: ${JSON.stringify(vnode.children)}`,
)
;(node as Text).data = vnode.children as string
}
nextNode = nextSibling(node)
}
break
case Comment:
if (isTemplateNode(node)) {
nextNode = nextSibling(node)
// wrapped <transition appear>
// replace <template> node with inner child
replaceNode(
(vnode.el = node.content.firstChild!),
node,
parentComponent,
)
} else if (domType !== DOMNodeTypes.COMMENT || isFragmentStart) {
nextNode = onMismatch()
} else {
nextNode = nextSibling(node)
}
break
case Static:
if (isFragmentStart) {
// entire template is static but SSRed as a fragment
node = nextSibling(node)!
domType = node.nodeType
}
if (domType === DOMNodeTypes.ELEMENT || domType === DOMNodeTypes.TEXT) {
// determine anchor, adopt content
nextNode = node
// if the static vnode has its content stripped during build,
// adopt it from the server-rendered HTML.
const needToAdoptContent = !(vnode.children as string).length
for (let i = 0; i < vnode.staticCount!; i++) {
if (needToAdoptContent)
vnode.children +=
nextNode.nodeType === DOMNodeTypes.ELEMENT
? (nextNode as Element).outerHTML
: (nextNode as Text).data
if (i === vnode.staticCount! - 1) {
vnode.anchor = nextNode
}
nextNode = nextSibling(nextNode)!
}
return isFragmentStart ? nextSibling(nextNode) : nextNode
} else {
onMismatch()
}
break
case Fragment:
if (!isFragmentStart) {
nextNode = onMismatch()
} else {
nextNode = hydrateFragment(
node as Comment,
vnode,
parentComponent,
parentSuspense,
slotScopeIds,
optimized,
)
}
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
if (
(domType !== DOMNodeTypes.ELEMENT ||
(vnode.type as string).toLowerCase() !==
(node as Element).tagName.toLowerCase()) &&
!isTemplateNode(node)
) {
nextNode = onMismatch()
} else {
nextNode = hydrateElement(
node as Element,
vnode,
parentComponent,
parentSuspense,
slotScopeIds,
optimized,
)
}
} else if (shapeFlag & ShapeFlags.COMPONENT) {
// when setting up the render effect, if the initial vnode already
// has .el set, the component will perform hydration instead of mount
// on its sub-tree.
vnode.slotScopeIds = slotScopeIds
const container = parentNode(node)!
// Locate the next node.
if (isFragmentStart) {
// If it's a fragment: since components may be async, we cannot rely
// on component's rendered output to determine the end of the
// fragment. Instead, we do a lookahead to find the end anchor node.
nextNode = locateClosingAnchor(node)
} else if (isComment(node) && node.data === 'teleport start') {
// #4293 #6152
// If a teleport is at component root, look ahead for teleport end.
nextNode = locateClosingAnchor(node, node.data, 'teleport end')
} else {
nextNode = nextSibling(node)
}
mountComponent(
vnode,
container,
null,
parentComponent,
parentSuspense,
getContainerType(container),
optimized,
)
// #3787
// if component is async, it may get moved / unmounted before its
// inner component is loaded, so we need to give it a placeholder
// vnode that matches its adopted DOM.
if (isAsyncWrapper(vnode)) {
let subTree
if (isFragmentStart) {
subTree = createVNode(Fragment)
subTree.anchor = nextNode
? nextNode.previousSibling
: container.lastChild
} else {
subTree =
node.nodeType === 3 ? createTextVNode('') : createVNode('div')
}
subTree.el = node
vnode.component!.subTree = subTree
}
} else if (shapeFlag & ShapeFlags.TELEPORT) {
if (domType !== DOMNodeTypes.COMMENT) {
nextNode = onMismatch()
} else {
nextNode = (vnode.type as typeof TeleportImpl).hydrate(
node,
vnode as TeleportVNode,
parentComponent,
parentSuspense,
slotScopeIds,
optimized,
rendererInternals,
hydrateChildren,
)
}
} else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
nextNode = (vnode.type as typeof SuspenseImpl).hydrate(
node,
vnode,
parentComponent,
parentSuspense,
getContainerType(parentNode(node)!),
slotScopeIds,
optimized,
rendererInternals,
hydrateNode,
)
} else if (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) {
warn('Invalid HostVNode type:', type, `(${typeof type})`)
}
}
if (ref != null) {
setRef(ref, null, parentSuspense, vnode)
}
return nextNode
}
const hydrateElement = (
el: Element,
vnode: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
slotScopeIds: string[] | null,
optimized: boolean,
) => {
optimized = optimized || !!vnode.dynamicChildren
const { type, props, patchFlag, shapeFlag, dirs, transition } = vnode
// #4006 for form elements with non-string v-model value bindings
// e.g. <option :value="obj">, <input type="checkbox" :true-value="1">
// #7476 <input indeterminate>
const forcePatch = type === 'input' || type === 'option'
// skip props & children if this is hoisted static nodes
// #5405 in dev, always hydrate children for HMR
if (__DEV__ || forcePatch || patchFlag !== PatchFlags.HOISTED) {
if (dirs) {
invokeDirectiveHook(vnode, null, parentComponent, 'created')
}
// handle appear transition
let needCallTransitionHooks = false
if (isTemplateNode(el)) {
needCallTransitionHooks =
needTransition(parentSuspense, transition) &&
parentComponent &&
parentComponent.vnode.props &&
parentComponent.vnode.props.appear
const content = (el as HTMLTemplateElement).content
.firstChild as Element
if (needCallTransitionHooks) {
transition!.beforeEnter(content)
}
// replace <template> node with inner children
replaceNode(content, el, parentComponent)
vnode.el = el = content
}
// children
if (
shapeFlag & ShapeFlags.ARRAY_CHILDREN &&
// skip if element has innerHTML / textContent
!(props && (props.innerHTML || props.textContent))
) {
let next = hydrateChildren(
el.firstChild,
vnode,
el,
parentComponent,
parentSuspense,
slotScopeIds,
optimized,
)
let hasWarned = false
while (next) {
hasMismatch = true
if (
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
!hasWarned
) {
warn(
`Hydration children mismatch on`,
el,
`\nServer rendered element contains more child nodes than client vdom.`,
)
hasWarned = true
}
// The SSRed DOM contains more nodes than it should. Remove them.
const cur = next
next = next.nextSibling
remove(cur)
}
} else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
if (el.textContent !== vnode.children) {
hasMismatch = true
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
warn(
`Hydration text content mismatch on`,
el,
`\n - rendered on server: ${el.textContent}` +
`\n - expected on client: ${vnode.children as string}`,
)
el.textContent = vnode.children as string
}
}
// props
if (props) {
if (
__DEV__ ||
forcePatch ||
!optimized ||
patchFlag & (PatchFlags.FULL_PROPS | PatchFlags.NEED_HYDRATION)
) {
for (const key in props) {
// check hydration mismatch
if (__DEV__ && propHasMismatch(el, key, props[key], vnode)) {
hasMismatch = true
}
if (
(forcePatch &&
(key.endsWith('value') || key === 'indeterminate')) ||
(isOn(key) && !isReservedProp(key)) ||
// force hydrate v-bind with .prop modifiers
key[0] === '.'
) {
patchProp(
el,
key,
null,
props[key],
undefined,
undefined,
parentComponent,
)
}
}
} else if (props.onClick) {
// Fast path for click listeners (which is most often) to avoid
// iterating through props.
patchProp(
el,
'onClick',
null,
props.onClick,
undefined,
undefined,
parentComponent,
)
}
}
// vnode / directive hooks
let vnodeHooks: VNodeHook | null | undefined
if ((vnodeHooks = props && props.onVnodeBeforeMount)) {
invokeVNodeHook(vnodeHooks, parentComponent, vnode)
}
if (dirs) {
invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
}
if (
(vnodeHooks = props && props.onVnodeMounted) ||
dirs ||
needCallTransitionHooks
) {
queueEffectWithSuspense(() => {
vnodeHooks && invokeVNodeHook(vnodeHooks, parentComponent, vnode)
needCallTransitionHooks && transition!.enter(el)
dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
}, parentSuspense)
}
}
return el.nextSibling
}
const hydrateChildren = (
node: Node | null,
parentVNode: VNode,
container: Element,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
slotScopeIds: string[] | null,
optimized: boolean,
): Node | null => {
optimized = optimized || !!parentVNode.dynamicChildren
const children = parentVNode.children as VNode[]
const l = children.length
let hasWarned = false
for (let i = 0; i < l; i++) {
const vnode = optimized
? children[i]
: (children[i] = normalizeVNode(children[i]))
if (node) {
node = hydrateNode(
node,
vnode,
parentComponent,
parentSuspense,
slotScopeIds,
optimized,
)
} else if (vnode.type === Text && !vnode.children) {
continue
} else {
hasMismatch = true
if (
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
!hasWarned
) {
warn(
`Hydration children mismatch on`,
container,
`\nServer rendered element contains fewer child nodes than client vdom.`,
)
hasWarned = true
}
// the SSRed DOM didn't contain enough nodes. Mount the missing ones.
patch(
null,
vnode,
container,
null,
parentComponent,
parentSuspense,
getContainerType(container),
slotScopeIds,
)
}
}
return node
}
const hydrateFragment = (
node: Comment,
vnode: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
slotScopeIds: string[] | null,
optimized: boolean,
) => {
const { slotScopeIds: fragmentSlotScopeIds } = vnode
if (fragmentSlotScopeIds) {
slotScopeIds = slotScopeIds
? slotScopeIds.concat(fragmentSlotScopeIds)
: fragmentSlotScopeIds
}
const container = parentNode(node)!
const next = hydrateChildren(
nextSibling(node)!,
vnode,
container,
parentComponent,
parentSuspense,
slotScopeIds,
optimized,
)
if (next && isComment(next) && next.data === ']') {
return nextSibling((vnode.anchor = next))
} else {
// fragment didn't hydrate successfully, since we didn't get a end anchor
// back. This should have led to node/children mismatch warnings.
hasMismatch = true
// since the anchor is missing, we need to create one and insert it
insert((vnode.anchor = createComment(`]`)), container, next)
return next
}
}
const handleMismatch = (
node: Node,
vnode: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
slotScopeIds: string[] | null,
isFragment: boolean,
): Node | null => {
hasMismatch = true
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
warn(
`Hydration node mismatch:\n- rendered on server:`,
node,
node.nodeType === DOMNodeTypes.TEXT
? `(text)`
: isComment(node) && node.data === '['
? `(start of fragment)`
: ``,
`\n- expected on client:`,
vnode.type,
)
vnode.el = null
if (isFragment) {
// remove excessive fragment nodes
const end = locateClosingAnchor(node)
while (true) {
const next = nextSibling(node)
if (next && next !== end) {
remove(next)
} else {
break
}
}
}
const next = nextSibling(node)
const container = parentNode(node)!
remove(node)
patch(
null,
vnode,
container,
next,
parentComponent,
parentSuspense,
getContainerType(container),
slotScopeIds,
)
return next
}
// looks ahead for a start and closing comment node
const locateClosingAnchor = (
node: Node | null,
open = '[',
close = ']',
): Node | null => {
let match = 0
while (node) {
node = nextSibling(node)
if (node && isComment(node)) {
if (node.data === open) match++
if (node.data === close) {
if (match === 0) {
return nextSibling(node)
} else {
match--
}
}
}
}
return node
}
const replaceNode = (
newNode: Node,
oldNode: Node,
parentComponent: ComponentInternalInstance | null,
): void => {
// replace node
const parentNode = oldNode.parentNode
if (parentNode) {
parentNode.replaceChild(newNode, oldNode)
}
// update vnode
let parent = parentComponent
while (parent) {
if (parent.vnode.el === oldNode) {
parent.vnode.el = parent.subTree.el = newNode
}
parent = parent.parent
}
}
const isTemplateNode = (node: Node): node is HTMLTemplateElement => {
return (
node.nodeType === DOMNodeTypes.ELEMENT &&
(node as Element).tagName.toLowerCase() === 'template'
)
}
return [hydrate, hydrateNode] as const
}
环境无关通用代码 main.js
对每次请求都需要创建全新的应用实例:
以下引至 :vuejs服务端渲染
在 SSR 环境下,应用模块通常只在服务器启动时初始化一次。同一个应用模块会在多个服务器请求之间被复用,而我们的单例状态对象也一样。如果我们用单个用户特定的数据对共享的单例状态进行修改,那么这个状态可能会意外地泄露给另一个用户的请求。我们把这种情况称为跨请求状态污染。
点击查看代码
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './assets/reset.css'
import { createRouter } from './router'
// SSR requires a fresh app instance per request, therefore we export a function
// that creates a fresh app instance. If using Vuex, we'd also be creating a
// fresh store here.
export function createApp() {
const app = createSSRApp(App)
const router = createRouter()
app.use(router)
const pinia = createPinia()
app.use(pinia)
return { app, router, pinia }
}
3. 初始状态共享
在客户端我们想要在所有组件中共享一份数据很容易,因为渲染环境是单一的,数据可以很简单的流转。
ssr渲染中由于渲染环境的差异,如果我们需要在服务端渲染和客户端渲染中共享一份数据,就需要保证双端数据的一致性。
所以在服务端渲染期间我们将需要共享的数据经过转义后在首次请求时返回给客户端,客户端渲染时需要保证能拿到同一份数据,如果共享状态时用到了某些状态管理库就需要利用这些库提供的api来做相应的处理。 本文示例使用pinia处理状态共享。
// index.html
// 共享数据模板占位
<script>
window.__INITIAL_STATE__ = '<!--pinia_state-->'
</script>
*/
// entry-server.js
const state = JSON.stringify(pinia.state.value)
// server.js
const htmlStr = template.replace('<!--pinia_state-->', state)
// entry-client.js 客户端同步数据
if (window.__INITIAL_STATE__) {
pinia.state.value = JSON.parse(window.__INITIAL_STATE__)
}
4. 服务端数据预取
在开发ssr应用时,服务端初次响应时返回的内容都是已组装好的; 所以当应用需要一些异步数据时,我们为了在最终返回的内容里就带有这些数据就需要在应用服务端渲染前处理好这些数据。 同样在客户端渲染的时候我们也期望可以直接使用这份数据,并且渲染时不重复执行获取数据的逻辑,因为这会带来数据双端之间不匹配的问题。
数据预取的逻辑完成有几个注意点
控制执行的时机: 在应用渲染前执行;
- 在组件render前就需要执行,避免数据不匹配
确定执行的位置:只在需要预取数据的位置执行
- 只执行路由/组件里已注册的预取数据逻辑
本文示例使用pinia处理状态共享。
// src/pages/Home.vue
<script setup>
import Index from '../components/index.vue'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
// 定义预取数据的静态方法,供服务端渲染时消费
defineOptions({
async preFetch({ pinia }) {
const userStore = useUserStore(pinia)
await userStore.getUserInfo(2)
},
})
const userStore = useUserStore()
const { userData } = storeToRefs(userStore)
</script>
//entry-server.js
// 匹配当前路由的组件
const matchedComponents = router.currentRoute.value.matched.flatMap(route =>
Object.values(route.components)
)
await Promise.all(
matchedComponents.map(component => {
// 执行组件中定义的preFetch函数, 预先获取数据
if (component.preFetch && typeof component.preFetch === 'function') {
return component.preFetch({
pinia
})
}
return []
})
)
5. ssrManifest&静态资源预加载
服务端渲染响应请求时只会返回hmtl内容,而其他静态资源(js,css,png......)是不会在初次响应请求的内容里的,在浏览器加载这些静态资源前,网页内容只能显示文本,显然这对用户体验来说是很不好的; 我们需要在html准备好的同时也准备好静态资源,此时必须处理静态资源的预加载; 并且我们需要在服务端渲染期间就将预加载的资源写入到响应的html里,供客户端渲染使用。 借助vite的指定构建模式(--ssrManifest),我们可以在客户端构建的过程中就得到一份各模块ID和它们所使用到的资源的map,这个map可以帮助我们在服务端渲染期间就能知道各个路由所匹配到的组件对应的资源,也就可以对这些资源去使用预加载。
点击查看代码
// /vite/src/node/ssr/ssrManifestPlugin.ts
export function ssrManifestPlugin(config: ResolvedConfig): Plugin {
// module id => preload assets mapping
const ssrManifest: Record<string, string[]> = {}
const base = config.base // TODO:base
return {
name: 'vite:ssr-manifest',
generateBundle(_options, bundle) {
for (const file in bundle) {
const chunk = bundle[file]
if (chunk.type === 'chunk') {
for (const id in chunk.modules) {
const normalizedId = normalizePath(relative(config.root, id))
const mappedChunks =
ssrManifest[normalizedId] ?? (ssrManifest[normalizedId] = [])
if (!chunk.isEntry) {
mappedChunks.push(joinUrlSegments(base, chunk.fileName))
// <link> tags for entry chunks are already generated in static HTML,
// so we only need to record info for non-entry chunks.
chunk.viteMetadata!.importedCss.forEach((file) => {
mappedChunks.push(joinUrlSegments(base, file))
})
}
chunk.viteMetadata!.importedAssets.forEach((file) => {
mappedChunks.push(joinUrlSegments(base, file))
})
}
if (chunk.code.includes(preloadMethod)) {
// generate css deps map
const code = chunk.code
let imports: ImportSpecifier[]
try {
imports = parseImports(code)[0].filter((i) => i.n && i.d > -1)
} catch (e: any) {
const loc = numberToPos(code, e.idx)
this.error({
name: e.name,
message: e.message,
stack: e.stack,
cause: e.cause,
pos: e.idx,
loc: { ...loc, file: chunk.fileName },
frame: generateCodeFrame(code, loc),
})
}
if (imports.length) {
for (let index = 0; index < imports.length; index++) {
const { s: start, e: end, n: name } = imports[index]
// check the chunk being imported
const url = code.slice(start, end)
const deps: string[] = []
const ownerFilename = chunk.fileName
// literal import - trace direct imports and add to deps
const analyzed: Set<string> = new Set<string>()
const addDeps = (filename: string) => {
if (filename === ownerFilename) return
if (analyzed.has(filename)) return
analyzed.add(filename)
const chunk = bundle[filename] as OutputChunk | undefined
if (chunk) {
chunk.viteMetadata!.importedCss.forEach((file) => {
deps.push(joinUrlSegments(base, file)) // TODO:base
})
chunk.imports.forEach(addDeps)
}
}
const normalizedFile = normalizePath(
join(dirname(chunk.fileName), url.slice(1, -1)),
)
addDeps(normalizedFile)
ssrManifest[basename(name!)] = deps
}
}
}
}
}
this.emitFile({
fileName:
typeof config.build.ssrManifest === 'string'
? config.build.ssrManifest
: 'ssr-manifest.json',
type: 'asset',
source: jsonStableStringify(ssrManifest, { space: 2 }),
})
},
}
}
ssr-manifest.json
点击查看代码
{
"\u0000plugin-vue:export-helper": [
],
"\u0000vite/modulepreload-polyfill": [
],
"\u0000vite/preload-helper": [
],
"index.html": [
],
"node_modules/@vue/reactivity/dist/reactivity.esm-bundler.js": [
],
"node_modules/@vue/runtime-core/dist/runtime-core.esm-bundler.js": [
],
"node_modules/@vue/runtime-dom/dist/runtime-dom.esm-bundler.js": [
],
"node_modules/@vue/shared/dist/shared.esm-bundler.js": [
],
"node_modules/pinia/dist/pinia.mjs": [
],
"node_modules/pinia/node_modules/vue-demi/lib/index.mjs": [
],
"node_modules/vue-router/dist/vue-router.mjs": [
],
"src/App.vue": [
],
"src/api/user.js": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/assets/images/bg.png": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/assets/mock/get-coupon.js": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/assets/reset.css": [
],
"src/components/GetCoupon/base/components/coupon-card.vue": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/components/GetCoupon/base/components/coupon-card.vue?vue&type=style&index=0&scoped=b5a43c85&lang.scss": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/components/GetCoupon/base/components/coupon-group.vue": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/components/GetCoupon/base/components/coupon-group.vue?vue&type=style&index=0&scoped=11dfec70&lang.scss": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/components/GetCoupon/base/components/index.vue": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/components/GetCoupon/base/components/index.vue?vue&type=style&index=0&scoped=c4121ce0&lang.scss": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/components/GetCoupon/base/index.vue": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/components/GetCoupon/base/model/props.js": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/components/GetCoupon/common/components/coupon-detail.vue": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/components/GetCoupon/common/components/coupon-detail.vue?vue&type=style&index=0&scoped=257285af&lang.scss": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/components/header.vue": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/components/header.vue?vue&type=style&index=0&scoped=d881cb8d&lang.scss": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/components/index.vue": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/components/index.vue?vue&type=style&index=0&scoped=0b983d59&lang.scss": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/entry-client.js": [
],
"src/main.js": [
],
"src/pages/Home.vue": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/pages/Home.vue?vue&type=style&index=0&scoped=02917652&lang.scss": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
],
"src/pages/Second.vue": [
"/assets/Second-d74157ab.js",
"/assets/Second-86e9f1d2.css"
],
"src/pages/Second.vue?vue&type=style&index=0&scoped=03ec9f2e&lang.scss": [
"/assets/Second-d74157ab.js",
"/assets/Second-86e9f1d2.css"
],
"src/router/index.js": [
],
"src/stores/user.js": [
"/assets/Home-79a81da4.js",
"/assets/Home-b5c09263.css",
"/assets/bg-0dfee035.png",
"/assets/MF-YuanHei-Regular-d5a458fc.ttf"
]
}
entry-server.js
点击查看代码
// modules:当前路由匹配到的全部模块
// manifest:各个模块和它们所使用资源的映射关系
function renderPreloadLinks(modules, manifest) {
let links = ''
const seen = new Set()
modules.forEach((id) => {
const files = manifest[id]
if (files) {
files.forEach((file) => {
// 依赖去重,不同模块依赖相同文件时仅生成一次
if (!seen.has(file)) {
seen.add(file)
const filename = basename(file)
// 处理嵌套依赖情况,即被依赖的文件A也依赖于B,C...
if (manifest[filename]) {
for (const depFile of manifest[filename]) {
links += renderPreloadLink(depFile)
seen.add(depFile)
}
}
links += renderPreloadLink(file)
}
})
}
})
return links
}
// 针对各类静态资源渲染对应预加载标签
function renderPreloadLink(file) {
if (file.endsWith('.js')) {
return `<link rel="modulepreload" crossorigin href="${file}">`
} else if (file.endsWith('.css')) {
return `<link rel="stylesheet" href="${file}">`
} else if (file.endsWith('.woff')) {
return ` <link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
} else if (file.endsWith('.ttf')) {
return ` <link rel="preload" href="${file}" as="font" type="font/ttf" crossorigin>`
} else if (file.endsWith('.gif')) {
return ` <link rel="preload" href="${file}" as="image" type="image/gif">`
} else if (file.endsWith('.jpg') || file.endsWith('.jpeg')) {
return ` <link rel="preload" href="${file}" as="image" type="image/jpeg">`
} else if (file.endsWith('.png')) {
return ` <link rel="preload" href="${file}" as="image" type="image/png">`
} else {
return ''
}
}
6. Lighthouse跑分 SSR vs. CSR
SSR
CSR
拓展阅读
SSR目前存在的问题
- 客户端渲染前,服务端必须准备好所有数据,包括异步获取的数据
- 在客户端激活dom前,必须加载完所有的资源
- 在网页变的可交互前,必须激活所有dom节点