同构
同构(isomorph)这个概念应该来源于数学。同构是在数学对象之间定义的一类映射,若两个数学结构之间存在同构映射,那么这两个结构叫做“是同构的”。如果两个结构是同构的,那么其上的对象会有相似的属性和操作,对某个结构成立的命题在另一个结构上也就成立。
在大前端,同构JavaScript应用指的是,用JavaScript编写的应用能够同时运行于客户端和服务器。因此,只需编写一次代码,在服务器上执行它来渲染静态页面,同时执行于客户端以允许快速的交互。
大前端演变的过程:
纯后端渲染 -> 单页面应用(前端渲染和交互) -> 同构(后端渲染和前端交互)
现在,同构和服务端渲染(server side render, SSR)基本是同义词。
SSR的优缺点
优点:
- 更好的SEO
- 更快的页面呈现速度
缺点:
- 更复杂的开发,开发的代码需要兼容前后端的runtime
- 更复杂的构建和部署
- 加重服务器负载
同构会不会使前后端的代码耦合在一起?答案是不会。同构只是同构SPA代码,提供API的后台代码完全可以独立成一个项目,可以和同构代码一起部署,也可以分开部署。分开部署的缺点是会多一次http请求,一个客户端请求会先请求同构的后端代码,而同构的后端代码又会请求提供API的应用来获取渲染需要的数据。如果同构的后端代码和供API的代码部署在同一台服务器,多出来的这一次请求基本不会影响性能。也可以一起部署,比如提供API的应用是用node编写的,只用把同构的后端代码的render部分插入到提供API的应用的listen部分就好了。
相关框架
基于React的SSR框架Next.js现在有19k的star,发展的比较好。而基于Vue的SSR框架Nuxt.js只有200多个star,比较惨淡。
看了一下Nuxt.js,写了简单的demo。Nuxt.js其实做了很多工作,主要是在vue.js的内核外又包了一层SSR相关的东西。个人觉得主要的问题有两个。第一是把现有的使用vue的项目迁移到Nuxt.js会很麻烦;第二是Nuxt.js把很多配置(比如webpack的配置)都封装起来了,极大地简化了项目的配置,但是带来的问题是,当项目需要非大众化的配置时,根本就无从下手,因为找不到对应的配置文件。
除了Nuxt.js,vue有一个插件vue-server-renderer来实现SSR。
vue-server-renderer
一个简单的项目文件结构:
├──src
│ ├── components
│ │ ├── Foo.vue
│ │ ├── Bar.vue
│ │ └── Baz.vue
│ ├── App.vue
│ ├── app.js # universal entry
│ ├── entry-client.js # runs in browser only
│ ├── entry-server.js # runs on server only
│ └── index.template.html
├──server.js
app.js是前后端共同的入口,一般返回vue实例的工厂函数。不能像SPA那样直接创建vue实例,因为直接创建的vue实例在服务端会被所有请求复用,从而造成状态污染。
import Vue from 'vue'
import App from './App.vue'
export function createApp () {
const app = new Vue({
render: h => h(App)
})
return { app }
}
entry-client.js是浏览器端的入口,创建vue实例,并挂载到DOM上。
import { createApp } from './app'
const { app } = createApp()
app.$mount('#app')
entry-server.js主要export一个供服务端的renderer使用的函数。
import { createApp } from './app'
export default context => {
const { app } = createApp()
return app
}
注意上面的import并不能在服务端运行,所以要用webpack进行打包。
index.template.html是首页渲染的模板文件,服务端渲染app得到的页面会替换掉<!–vue-ssr-outlet–>。注意这里没有id等于app的元素,而浏览器hydrate的挂载点是’#app’元素,这个元素将会由服务端渲染出的页面给出。
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ title }}</title>
<link rel="manifest" href="/manifest.json">
<style>
#skip a { position:absolute; left:-10000px; top:auto; width:1px; height:1px; overflow:hidden; }
#skip a:focus { position:static; width:auto; height:auto; }
</style>
</head>
<body>
<p>lance</p>
<div id="skip"><a href="#app">skip to content</a></div>
<!--vue-ssr-outlet-->
</body>
</html>
server.js获取构建好的相关文件,然后处理请求:
// server.js
const createApp = require('/path/to/built-server-bundle.js')
server.get('*', (req, res) => {
const context = { url: req.url }
createApp(context).then(app => {
renderer.renderToString(app, (err, html) => {
if (err) {
if (err.code === 404) {
res.status(404).end('Page not found')
} else {
res.status(500).end('Internal Server Error')
}
} else {
res.end(html)
}
})
})
})
vue-hackernews-2.0
vue-hackernews-2.0是官方给出的一个SSR Demo。然而这个拥有6k多个star的demo跑不起来。调试了好久定位到了问题。问题出在demo获取数据的api是托管在firebase上的。
Firebase是一家实时后端数据库创业公司,它能帮助开发者很快的写出Web端和移动端的应用。Firebase在2014年10月被Google收购后,就和Google的云服务结合比较紧密。
由于在国内不翻墙是没办法访问google的网站的,所以获取数据的api访问不了。demo就卡在下面这段代码这里了:
// src/api/create-api-server.js
import Firebase from 'firebase'
export function createAPI ({ config, version }) {
let api
if (process.__API__) {
api = process.__API__
} else {
Firebase.initializeApp(config)
api = process.__API__ = Firebase.database().ref(version)
//卡在下面
;['top', 'new', 'show', 'ask', 'job'].forEach(type => {
api.child(`${type}stories`).on('value', snapshot => {
api.cachedIds[type] = snapshot.val()
})
})
}
return api
}
当api访问不了时,在服务器端和浏览器端都没有输出任何错误提示,这应该算一个bug。由于对Firebase的用法不熟,所以暂时没修复这个bug。
如果要使demo跑起来,就要改写或干掉所有asyncData函数(asyncData是一个ssr获取异步数据的钩子函数,所有获取异步数据的逻辑都写在里面)。
在干掉asyncData函数后,来看看demo的运行结果。为了更好地分析build文件,不要使用npm run dev,因为里面加了很多调试功能相关的代码。直接运行 npm run build。会调用webpack –config build/webpack.client.config.js和webpack –config build/webpack.server.config.js分别对客户端和服务端的代码进行打包。客户端代码打包的文件为:
- app.403a9442d21cef255283.js
- manifest.9ceee00d01801404ed42.js
- vue-ssr-client-manifest.json
服务端打包的文件如下:
- vue-ssr-server-bundle.json
可以看到,服务端打包的文件只有一个json文件,并没有重复打包应用的js文件。因为应用的js文件是同构的,所以后端也能用,不用重复打包。vue-ssr-server-bundle.json用来创建服务端的renderer。renderer会为每个请求渲染出一个静态页面。
返回的静态页面示例:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Vue HN 2.0</title>
<meta charset="utf-8">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="apple-touch-icon" sizes="120x120" href="/public/logo-120.png">
<meta name="viewport" content="width=device-width, initial-scale=1, minimal-ui">
<link rel="shortcut icon" sizes="48x48" href="/public/logo-48.png">
<meta name="theme-color" content="#f60">
<link rel="manifest" href="/manifest.json">
<style>
#skip a { position:absolute; left:-10000px; top:auto; width:1px; height:1px; overflow:hidden; }
#skip a:focus { position:static; width:auto; height:auto; }
</style>
</head>
<body>
<p>lance</p>
<div id="skip"><a href="#app">skip to content</a></div>
<div id="app" data-server-rendered="true">
<header class="header">
<nav class="inner">
<a href="https://github.com/vuejs/vue-hackernews-2.0" target="_blank" rel="noopener" class="github">Built with Vue.js</a>
</nav>
</header>
<p>hello world !</p>
</div>
<script>window.__INITIAL_STATE__={"activeType":null,"itemsPerPage":20,"items":{},"users":{},"lists":{"top":[],"new":[],"show":[],"ask":[],"job":[]},"route":{"path":"\u002Ftop","hash":"","query":{},"params":{},"fullPath":"\u002Ftop","meta":{},"from":{"name":null,"path":"\u002F","hash":"","query":{},"params":{},"fullPath":"\u002F","meta":{}}}};(function(){var s;(s=document.currentScript||document.scripts[document.scripts.length-1]).parentNode.removeChild(s);}());
</script>
</body>
</html>
注意上面的data-server-rendered=”true”属性将告诉客户端以hydrate模式渲染页面。window.INITIAL_STATE是后端渲染时注入的状态,避免前端重复获取。
使用SSR的Vue的运行流程
代码在部署后端,当有一个请求过来时,服务器会新建一个vue实例,渲染(render)出需要显示的页面的html,把得到的页面以字符串的形式返回给客户端。同时把相关的js文件也返回(首次请求时返回vue的runtime、webpack的runtime和app.js等文件,非首次请求返回按需加载的js文件),返回的js文件和单页面应用(SPA)返回的差不多。浏览器接收到这些文件后,通过js文件把静态页面的字符串hydrate成可以交互的应用。个人理解,这里的hydrate的工作包括处理事件、运行生命周期钩子函数、实现视图和数据的双向绑定等。
和SPA相比,SSR返回的数据就是多了个静态页面(字符串形式)。