前言
HackerNews是基于 HN 的官方 firebase API 、Vue 2.0 、vue-router 和 vuex 来构建的,使用服务器端渲染。
vue-hackernews项目,涉及知识点及技术栈非常全面,对于初学者来说,直接阅读该项目,极具挑战。这也是写这个项目解读的初衷,希望为阅读该项目提供一些指引。
结构概览
项目结构图上显示,有两个入口文件,entry-server.js 和 entry-client.js, 分别是服务端渲染和客户端渲染的实现入口,webpack 将两个入口文件分别打包成给服务端用的 server bundle 和给客户端用的 client bundle.
服务端:当 Node Server 收到来自Browser的请求后,会创建一个 Vue 渲染器 BundleRenderer,这个 bundleRenderer 会读取上面生成的 server bundle 文件(即entry-server.js),并且执行它,而 server bundle 实现了数据预取并返回已填充数据的Vue实例,接下来Vue渲染器内部就会将 Vue 实例渲染进 html 模板,最后把这个完整的html发送到浏览器。
客户端:Browser收到HTML后,客户端加载了 client bundle(即entry-client.js) ,通过app.$mount('#app')
挂载Vue实例到服务端渲染的 DOM 上,并会和服务端渲染的HTML 进行Hydration(合并)
目录概览
│ manifest.json # progressive web apps配置文件
│ package.json # 项目配置文件
│ server.js # 服务端渲染
│
├─public # 静态资源
│ logo-120.png
│ logo-144.png
│ logo-152.png
│ logo-192.png
│ logo-384.png
│ logo-48.png
│
└─src
│ app.js # 整合 router,filters,vuex 的入口文件
│ App.vue # 根 vue 组件
│ entry-client.js # client 的入口文件
│ entry-server.js # server 的入口文件
│ index.template.html # html 模板
│
├─api
│ create-api-client.js # Client数据源配置
│ create-api-server.js # server数据源配置
│ index.js # 数据请求API
│
├─components
│ Comment.vue # 评论组件
│ Item.vue #
│ ProgressBar.vue # 进度条组件
│ Spinner.vue # 加载提示组件
│
├─router
│ index.js # router配置
│
├─store # Vue store模块
│ actions.js # 根级别的 action
│ getters.js # 属性接口
│ index.js # 我们组装模块并导出 store 的地方
│ mutations.js # 根级别的 mutation
│
├─util
│ filters.js # 过滤器
│ title.js # 工具类
│
└─views
CreateListView.js # 动态生成列表界面的工厂方法
ItemList.vue # List界面组件
ItemView.vue # 单List项组件
UserView.vue # 用户界面组件
本项目包含开发环境及生产环境,我们先学习开发环境。
开发环境的服务端渲染流程
让我们从node环境下执行命令开始。
# serve in dev mode, with hot reload at localhost:8080
$npm run dev
然后发生了什么?我们来看一张图。
上述执行dev属性对应的脚本:node server
即node server.js
,即执行server.js
···
const app = express()
// 服务端渲染的HTML模板
const template = fs.readFileSync(resolve('./src/index.template.html'), 'utf-8')
function createRenderer (bundle, options) {
// https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/README.md#why-use-bundlerenderer
// 调用vue-server-renderer的createBundleRenderer方法创建渲染器,并设置HTML模板,以后后续将服务端预取的数据填充至模板中
return createBundleRenderer(bundle, Object.assign(options, {
template,
···
}))
}
let renderer
let readyPromise
if (isProd) {
// 生产环境下,webpack结合vue-ssr-webpack-plugin插件生成的server bundle
const bundle = require('./dist/vue-ssr-server-bundle.json')
//client manifests是可选项,但他允许渲染器自动插入preload/prefetch特性至后续渲染的HTML中,以改善客户端性能
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
//vue-server-renderer创建bundle渲染器并绑定server bundle
renderer = createRenderer(bundle, {
clientManifest
})
} else {
// 开发环境下,使用dev-server来通过回调把生成在内存中的bundle文件传回
// 通过dev server的webpack-dev-middleware和webpack-hot-middleware实现客户端代码的热更新
//以及通过webpack的watch功能实现服务端代码的热更新
readyPromise = require('./build/setup-dev-server')(app, (bundle, options) => {
// 基于热更新,回调生成最新的bundle渲染器
renderer = createRenderer(bundle, options)
})
}
//依次装载一系列Express中间件,用来处理静态资源,数据压缩等
···
app.use(···)
···
function render (req, res) {
···
// 设置请求的url
const context = {
title: 'Vue HN 2.0', // default title
url: req.url
}
// 为渲染器绑定的server bundle(即entry-server.js)设置入参context
renderer.renderToString(context, (err, html) => {
···
res.end(html)
···
})
}
//启动一个服务并监听从 8080 端口进入的所有连接请求。
app.get('*', isProd ? render : (req, res) => {
readyPromise.then(() => render(req, res))
})
const port = process.env.PORT || 8080
app.listen(port, () => {
console.log(`server started at localhost:${port}`)
})
Tips
1.vue-server-renderer(Vue服务端渲染,同时支持prefetch、prerender特性)
2.webpack-dev-server(webpack-dev-middleware/webpack-hot-middleware)
3.此项目全面使用ES6语法,包括箭头函数,解构赋值,Promise等特性。
server.js
最终监听8080端口等待处理客户端请求,此时在浏览器访问localhost:8080
请求经由express路由接收后,执行处理逻辑:readyPromise.then(() => render(req, res))
沿着Promise的调用链处理:
开发环境下
1.调用setup-dev-server.js 模块,根据上图中webpack config文件实现入口文件打包,热替换功能实现。
最终通过回调把生成在内存中的server bundle传回。
2.创建渲染器,绑定server bundle,设置渲染模板,缓存等
3.依次装载一系列Express中间件,用来处理静态资源,数据压缩等
4.最后将渲染好的HTML写入http响应体,传回浏览器。
接下来分解解读下这几个的实现。
setup-dev-server
看一张server.js的模块依赖关系图,只看项目自文件依赖即可(黄色)
build/setup-dev-server.js
// setup-dev-server.js
const clientConfig = require('./webpack.client.config')
const serverConfig = require('./webpack.server.config')
module.exports = function setupDevServer (app, cb) {
let bundle, clientManifest
let resolve
const readyPromise = new Promise(r => { resolve = r })
const ready = (...args) => {
resolve()
cb(...args)
}
// 在client webpack结合vue-ssr-webpack-plugin完成编译后,获取devMiddleware的fileSystem
// 读取内存中的bundle 并通过传入的回调更新server.js中的bundle
clientCompiler.plugin('done', () => {
const fs = devMiddleware.fileSystem
const readFile = file => fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
clientManifest = JSON.parse(readFile('vue-ssr-client-manifest.json'))
if (bundle) {
ready(bundle, {
clientManifest
})
}
})
// hot middleware
app.use(require('webpack-hot-middleware')(clientCompiler))
// watch and update server renderer
const serverCompiler = webpack(serverConfig)
// 获取基于memory-fs创建的内存文件系统对象
const mfs = new MFS()
serverCompiler.outputFileSystem = mfs
// 设置文件重新编译监听并通过传入的回调更新server.js中的bundle
serverCompiler.watch({}, (err, stats) => {
if (err) throw err
stats = stats.toJson()
stats.errors.forEach(err => console.error(err))
stats.warnings.forEach(err => console.warn(err))
const readFile = file => mfs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
// read bundle generated by vue-ssr-webpack-plugin
bundle = JSON.parse(readFile('vue-ssr-server-bundle.json'))
if (clientManifest) {
ready(bundle, {
clientManifest
})
}
})
return readyPromise
}
build/webpack.base.config.js
// build/webpack.base.config.js
module.exports = {
// 开发环境下,开启代码调试map,方便调试断点时代码寻址,推荐模式选择:cheap-module-source-map
devtool: isProd
? false
: '#cheap-module-source-map',
// 打包输出配置
output: {
path: path.resolve(__dirname, '../dist'),
publicPath: '/dist/',
filename: '[name].[chunkhash].js'
},
resolve: {
alias: {
'public': path.resolve(__dirname, '../public')
}
},
module: {
···
// 一系列加载器
},
plugins:[
// 压缩js的插件
new webpack.optimize.UglifyJsPlugin({
compress: { warnings: false }
}),
// 从bundle中提取出特定的text到一个文件中,可以把css从js中独立抽离出来
new ExtractTextPlugin({
})
]
}
build/webpack.client.config.js
// build/webpack.client.config.js
// 基于webpack-merge工具合并base以及client特定配置项
const config = merge(base, {
// 配置编译的入口文件
entry: {
app: './src/entry-client.js'
},
// 在alias设置客户端数据请求API为create-api-client.js模块
resolve: {
alias: {
'create-api': './create-api-client.js'
}
},
plugins: [
// 设置环境变量
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"client"'
}),
// 设置打包时公共模块的提取规则
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function (module) {
// a module is extracted into the vendor chunk if...
return (
// it's inside node_modules
/node_modules/.test(module.context) &&
// and not a CSS file (due to extract-text-webpack-plugin limitation)
!/\.css$/.test(module.request)
)
}
}),
// 因为 webpack 在编译打包时都会生成一个 webpack runtime 代码,因为 wepack 允许设置一个未指定的name,
// 来独立提取 runtime 代码,从而避免每次编译都会导致 vendor chunk hash 值变更
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest'
}),
new VueSSRClientPlugin()
]
})
bulid/webpack.server.config.js
// build/webpack.server.config.js
module.exports = merge(base, {
// 指定生成后的运行环境在node
target: 'node',
// 设置代码调试map
devtool: '#source-map',
// 配置编译的入口文件
entry: './src/entry-server.js',
// 设置输出文件名,并设置模块导出为commonjs2类型
output: {
filename: 'server-bundle.js',
libraryTarget: 'commonjs2'
},
// 在alias设置好服务端数据请求API为create-api-server.js模块
resolve: {
alias: {
'create-api': './create-api-server.js'
}
},
// 设置不打包排除规则
externals: nodeExternals({
// do not externalize CSS files in case we need to import it from a dep
whitelist: /\.css$/
}),
plugins: [
// 设置环境变量
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"server"'
}),
//设置VueSSRServerPlugin插件
new VueSSRServerPlugin()
]
})
如上,基于 webpack config 的setup-dev-server
就到这里,接下来说创建渲染器
。
创建渲染器
function createRenderer (bundle, options) {
// https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/README.md#why-use-bundlerenderer
console.log(`createRenderer`)
return createBundleRenderer(bundle, Object.assign(options, {
template,
···
}))
}
创建渲染器时重点两件事:
1.绑定渲染用的server bundle至渲染器,这个bundle是在setup-dev-server.js中将服务端入口文件entry-server.js
打包生成的。
当渲染器调用renderer.renderToString
开始渲染时,会执行该入口文件的默认方法。
2.传入了一个html模板index.template.html
,这个模板稍后在服务端渲染时就会动态填充预取数据到模板中。
顺着readyPromise.then的调用链,接下来调用render方法
function render (req, res) {
···
renderer.renderToString(context, (err, html) => {
res.end(html)
})
}
renderer.renderToString
方法内部会先调用入口模块entry-server.js
的默认方法,我们看下entry-server.js
主要做了什么
// This exported function will be called by `bundleRenderer`.
// This is where we perform data-prefetching to determine the
// state of our application before actually rendering it.
// Since data fetching is async, this function is expected to
// return a Promise that resolves to the app instance.
export default context => {
return new Promise((resolve, reject) => {
const s = isDev && Date.now()
const { app, router, store } = createApp()
// set router's location
// 手动路由切换到请求的url,即'/'
router.push(context.url)
// wait until router has resolved possible async hooks
router.onReady(() => {
// 获取该url路由下的所有Component,这些组件定义在Vue Router中。 /src/router/index.js
const matchedComponents = router.getMatchedComponents()
// no matched routes
if (!matchedComponents.length) {
reject({ code: 404 })
}
// Call fetchData hooks on components matched by the route.
// A preFetch hook dispatches a store action and returns a Promise,
// which is resolved when the action is complete and store state has been
// updated.
// 使用Promise.all执行匹配到的Component的asyncData方法,即预取数据
Promise.all(matchedComponents.map(component => {
return component.asyncData && component.asyncData({
store,
route: router.currentRoute
})
})).then(() => {
isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
// After all preFetch hooks are resolved, our store is now
// filled with the state needed to render the app.
// Expose the state on the render context, and let the request handler
// inline the state in the HTML response. This allows the client-side
// store to pick-up the server-side state without having to duplicate
// the initial data fetching on the client.
// 把vuex的state设置到传入的context.initialState上
context.state = store.state
// 返回state, router已经设置好的Vue实例app
resolve(app)
}).catch(reject)
}, reject)
})
}
entry-server.js
的主要工作:
0.返回一个函数,该函数接受一个从服务端传递过来的 context 的参数,将 vue 实例通过 Promise 返回。 context 一般包含 当前页面的url。
1.手动路由切换到请求的url,即'/'
2.找到该路由对应要渲染的组件,并调用组件的asyncData方法来预取数据
3.同步vuex的state数据至传入的context.initialState上,后面会把这些数据直接发送到浏览器端与客户端的vue 实例进行数据(状态)同步,以避免客户端首屏重新加载数据(在客户端入口文件entry-client.js)
Tips:下一章节我们会详细介绍这部分内容实现 稍后见于:
服务端渲染时的数据预取流程
还记得index.template.html
被设置到template
属性中吗?
此时Vue渲染器内部就会将Vue实例渲染进我们传入的这个html模板,那么Vue render内部是如何知道把Vue实例插入到模板的什么位置呢?
<body>
<!--vue-ssr-outlet-->
</body>
就是这里,这个``Vue渲染器就是根据这个自动替换插入,所以这是个固定的placeholder。
如果改动,服务端渲染时会有错误提示:Error: Content placeholder not found in template.
接下来,Vue渲染器会回调callback方法,我们回到server.js
function render (req, res) {
···
renderer.renderToString(context, (err, html) => {
res.end(html)
···
})
}
此时只需要将渲染好的html
写入http响应体就结束了,浏览器客户端就可以看到页面了。
接下来我们看看服务端数据预取的实现
服务端渲染时的数据预取流程
上文提到,服务端渲染时,会手动将路由导航到请求地址即'/'
下,然后调用该路由组件的asyncData方法来预取数据
那么我们看看路由配置
// /src/router/index.js
Vue.use(Router)
// route-level code splitting
const createListView = id => () => System.import('../views/CreateListView').then(m => m.default(id))
const ItemView = () => System.import('../views/ItemView.vue')
const UserView = () => System.import('../views/UserView.vue')
export function createRouter () {
return new Router({
mode: 'history',
scrollBehavior: () => ({ y: 0 }),
routes: [
{ path: '/top/:page(\\d+)?', component: createListView('top') },
{ path: '/new/:page(\\d+)?', component: createListView('new') },
{ path: '/show/:page(\\d+)?', component: createListView('show') },
{ path: '/ask/:page(\\d+)?', component: createListView('ask') },
{ path: '/job/:page(\\d+)?', component: createListView('job') },
{ path: '/item/:id(\\d+)', component: ItemView },
{ path: '/user/:id', component: UserView },
{ path: '/', redirect: '/top' }
]
})
}
地址'/'
是做了redirect到'/top'
,其实就是默认地址就是到top页面,在看第一条路由配置,'/top'
路由对应的组件是createListView('top')
// /src/views/CreateListView.js
export default function createListView (type) {
return {
name: `${type}-stories-view`,
asyncData ({ store }) {
console.log(`createListView asyncData`)
return store.dispatch('FETCH_LIST_DATA', { type })
},
title: camelize(type),
render (h) {
console.log(`createListView render`)
return h(ItemList, { props: { type }})
}
}
}
Tips: Vuex状态管理
1.dispatch对应Action,commit对应mutation
2.Action 类似于 mutation,不同在于:Action是异步事件,mutation是同步事件。
Vuex state状态变更流程
asyncData方法被调用,通过store.dispatch分发了一个数据预取的事件,接下来我们可以看到通过FireBase的API获取到Top分类的数据,然后又做了一系列的内部事件分发,保存数据状态到Vuex store,获取Top页面的List子项数据,最后处理并保存数据到store.
最后数据就都保存在store这里了。
// /src/store/index.js
export function createStore () {
return new Vuex.Store({
state: {
activeType: null,
itemsPerPage: 20,
items: {/* [id: number]: Item */},
users: {/* [id: string]: User */},
lists: {
top: [/* number */],
new: [],
show: [],
ask: [],
job: []
}
},
actions,
mutations,
getters
})
}
然后将开始通过Render 函数创建HTML。
// /src/views/CreateListView.js
render (h) {
console.log(`createListView render`)
return h(ItemList, { props: { type }})
}
// /src/views/ItemList.vue
···
<template>
<div class="news-view">
<div class="news-list-nav">
<router-link v-if="page > 1" :to="'/' + type + '/' + (page - 1)">< prev</router-link>
<a v-else class="disabled">< prev</a>
<span>{{ page }}/{{ maxPage }}</span>
<router-link v-if="hasMore" :to="'/' + type + '/' + (page + 1)">more ></router-link>
<a v-else class="disabled">more ></a>
</div>
<transition :name="transition">
<div class="news-list" :key="displayedPage" v-if="displayedPage > 0">
<transition-group tag="ul" name="item">
<item v-for="item in displayedItems" :key="item.id" :item="item">
</item>
</transition-group>
</div>
</transition>
</div>
</template>
···
这样创建完HTML Body部分,前面提到的Vue渲染器会自动把这部分内容插入index.template.html中,替换对应的``,然后就又回到前面的流程了,server.js将整个html写入http响应体,浏览器就得到了整个html页面,整个首次访问过程完成。
Tips:
后续更新内容规划:
1.生产环境下的服务端渲染逻辑流程
2.客户端渲染逻辑流程
3.客户端vue组件细节解读