美文网首页
vue-hackernews-2.0 源码解析

vue-hackernews-2.0 源码解析

作者: osan | 来源:发表于2017-07-04 19:46 被阅读717次

    前言

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

    相关文章

      网友评论

          本文标题:vue-hackernews-2.0 源码解析

          本文链接:https://www.haomeiwen.com/subject/elixhxtx.html