vue-hackernews-2.0 源码解析

前言

HackerNews是基于 HN 的官方 firebase API 、Vue 2.0 、vue-router 和 vuex 来构建的,使用服务器端渲染。

vue-hackernews项目,涉及知识点及技术栈非常全面,对于初学者来说,直接阅读该项目,极具挑战。这也是写这个项目解读的初衷,希望为阅读该项目提供一些指引。

结构概览

hn-architecture.png

项目结构图上显示,有两个入口文件,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

然后发生了什么?我们来看一张图。

rundev.png

上述执行dev属性对应的脚本:node servernode 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的模块依赖关系图,只看项目自文件依赖即可(黄色)

serverjs.png

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,这个模板稍后在服务端渲染时就会动态填充预取数据到模板中。

Tips: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状态变更流程

vuex_state.jpg

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组件细节解读

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容

  • 在实现 egg + vue 服务端渲染工程化实现之前,我们先来看看前面两篇关于Webpack构建和Egg的文章: ...
    hubcarl阅读 5,999评论 0 19
  • 作者:威威(沪江前端开发工程师)本文原创,转载请注明作者及出处。 背景 最近, 产品同学一如往常笑嘻嘻的递来需求文...
    iKcamp阅读 3,246评论 0 33
  • 你美颜一笑倾国倾城 曲终人散但你的歌声滞留永远 春节转身瞬间 那对视时的笑容始终放不下 我们的故事终究是那美丽的神...
    9陆海空9阅读 422评论 2 8
  • 我欣赏这样的女性,当孩子的降临,让她变得更加努力。当孩子还小时,她选择工作,并且想为孩子创造更好的条件。 我欣赏这...
    Haley少玢阅读 316评论 0 2
  • Scrum简介 Scrum是一种用于开发创新产品和服务的敏捷方式。在敏捷方式中,首先建立一个产品列表(一个按优先级...
    壹颗阅读 483评论 1 3