使用Vue.js构建Web应用程序

16-11-22 banq
         

这篇文章中的示例将从api获取和显示数据。为了简单起见,我将使用Spotify的网络API,这样可以跳过后端并专注于Vue。

该项目是利用VUE-CLI初始化的WebPack简单的模板。安装了vuex作为状态管理,JSX插件,使用pug作为HTML模板 。

该应用程序业务需求是按名称搜索艺术家。 因此最好的开始是首先构建包含状态和如何操作的vuex存储。

状态:

const state = {
  artists: [],
  isBusy: false
}
<p>

管理状态:

export const requestSearchResults = (state) => {
  state.isBusy = true
}

export const receiveSearchResults = (state, {artists}) => {
  state.artists = artists
  state.isBusy = false
}
<p>

第一个段定义了应用程序的初始状态。 第二个定义如何改变状态。 重要的是注意改变必须是同步的。 因此,我只是改变状态isBusy属性,以反映api调用正在发生很忙。 实际上需要异步api调用,我们需要一个动作action:

export const searchByArtistName = ({commit}, {name}) => {
  commit('requestSearchResults')
  return fetch('https://api.spotify.com/v1/search?q=' + name + '&type=artist&limit=10')
    .then(res => {
      return res.json()
    }).then(res => {
      commit('receiveSearchResults', {artists: res.artists.items})
    })
}
<p>

在vuex中,store中的action接受上下文作为第一个参数。上面的代码使用es6解构来定义来自上下文对象的函数。 第二个参数是我的有效载荷对象,这里就是用户正在搜索的艺术家的名字。最后提交函数告诉vuex哪些改变对应哪个状态。这里是管理状态代码中的“receiveSearchResults”。

export const artists = state => state.artists

export const isBusy = state => state.isBusy
<p>

组件将需要一种方法来访问状态上的属性,也就是getter方法的所在地方。Getters只是将状态上的属性暴露给组件。

现在我已经覆盖了vuex的Store的所有部分,让我们来看一个组件以及该组件如何使用store。

import {mapGetters} from 'vuex'

import Search from './components/Search.vue'

export default {
  name: 'app',
  components: [
    Search
  ],
  computed: mapGetters({
    artists: 'artists',
    isBusy: 'isBusy'
  }),
  render (h) {
    return (
      <div id='app'>
        <Search />
        { this.isBusy
          ? <h1>Loading...</h1>
          : this.artists.map((item) => <p>{item.name}</p>)
        }
      </div>
    )
  }
}

<p>

这里我使用mapGetters访问我想要的状态的属性。在render函数中,显示搜索组件,并有条件地呈现“正在忙”或艺术家列表。

Vue提供使用jsx和html模板两个选项,你也可使用两者其中一个,而且两者可以互换使用。这里是搜索组件。

<template lang='pug'>
  div.container
    input(type='text' v-model='name')
    button(@click='search(name)') Search
</template>


export default {
  name: 'search',
  data () {
    return {
      name: ''
    }
  },
  methods: {
    search (name) {
      if(name.length <= 0) return
      this.$store.dispatch('searchByArtistName', {name})
    }
  }
}


<p>

这是一个包含一个作业的简单组件,当用户单击搜索按钮时,将action分派到vuex store。

有了这段代码,你应该有一个想法,如何将这一切结合在一起?当按下搜索按钮时,搜索组件分发一个action,向store提取一些数据。在该action中,应用程序组件在抓取了某个内容会被通知,并显示忙碌指示符。 之后一旦api返回数据,应用程序组件将删除忙碌指示符并显示艺术家列表。

显然现在这一切对于这样一个简单的应用程序是过度设计了。然而,这意味着为更大的应用程序打下基础工作。

Building web apps with Vue.js – Frank A Wilkerson

         

3
banq
2016-11-22 08:56

下面,我将添加了按类型(如艺术家,专辑和轨道)以及分页的搜索功能。虽然功能变化很小,我将使用它们来演示vue / vuex如何保持用户界面不同部分的同步。

这个应用程序仍然有一个基本的工作,获取和显示信息。 现在的工作只是稍微复杂一点。 下面看看获取数据的函数代码:

const base = 'https://api.spotify.com/v1/search?q='

export const fetchByType = (query, type, offset = 0, limit = 10) => {
  return fetch(base + query + '&type=' + type + '&offset=' + offset + '&limit=' + limit)
    .then(res => {
      return res.json()
    })
}
<p>

api现在接受来自用户界面的多个输入。因此,当用户单击下一页按钮时,需要跟踪用户输入的查询字符串以及他们正在搜索的类型。 让我们来看看pager组件,看看它是怎么做的。

import {mapActions, mapGetters, mapState} from 'vuex'
import actions from '../store/actionTypes'
export default {
  name: 'pager',
  computed: {
    ...mapState([
      'page'
    ]),
    ...mapGetters([
      'disableNextPage',
      'disablePreviousPage'
    ])
  },
  methods: {
    ...mapActions([
      actions.NEXT_PAGE,
      actions.PREVIOUS_PAGE
    ])
  },
  render (h) {
    return (
      <div class='pager'>
        <div class='btn-group right-spacing'>
          <button on-click={this.PREVIOUS_PAGE} disabled={this.disablePreviousPage}
            class='right-spacing'>
            <i class='fa fa-chevron-left' />
          </button>
          <span class='right-spacing'>{this.page}</span>
          <button on-click={this.NEXT_PAGE} disabled={this.disableNextPage}>
            <i class='fa fa-chevron-right' />
          </button>
        </div>
      </div>
    )
  }
}


<style scoped>
button[disabled] {
  box-shadow: none;
  cursor: not-allowed;
  opacity: 0.65;
}
.pager {
  background: #303030;
  padding: 0.25em 0;
  width: 100%;
  z-index: 1;
}
.right-spacing {
  margin-right: 0.25em;
}
</style>
<p>

这里pager组件并不包含任何信息!它是要求vuex获取它的store不同部分的属性。“下一个”按钮的点击被绑定到NEXT_PAGE属性,该属性对应在vuex存储中注册的action。那么,让我们看看NEXT_PAGE action正在做什么。

import {mapActions, mapGetters, mapState} from 'vuex'
import actions from '../store/actionTypes'
export default {
  name: 'pager',
  computed: {
    ...mapState([
      'page'
    ]),
    ...mapGetters([
      'disableNextPage',
      'disablePreviousPage'
    ])
  },
  methods: {
    ...mapActions([
      actions.NEXT_PAGE,
      actions.PREVIOUS_PAGE
    ])
  },
  render (h) {
    return (
      <div class='pager'>
        <div class='btn-group right-spacing'>
          <button on-click={this.PREVIOUS_PAGE} disabled={this.disablePreviousPage}
            class='right-spacing'>
            <i class='fa fa-chevron-left' />
          </button>
          <span class='right-spacing'>{this.page}</span>
          <button on-click={this.NEXT_PAGE} disabled={this.disableNextPage}>
            <i class='fa fa-chevron-right' />
          </button>
        </div>
      </div>
    )
  }
}


<style scoped>
button[disabled] {
  box-shadow: none;
  cursor: not-allowed;
  opacity: 0.65;
}
.pager {
  background: #303030;
  padding: 0.25em 0;
  width: 100%;
  z-index: 1;
}
.right-spacing {
  margin-right: 0.25em;
}
</style>
<p>

事情开始搞在一起了。 当pager调度NEXT_PAGE action时,状态的页面属性将被增加。然后帮助函数queryApi被调用。纵观queryApi工作,你会发现它会所有状态信息。 如果一个action应导致一个新的查询,该action只需要提交其导致状态的更改动作,然后调用queryApi,由此queryApi现在有了所有最新的参数。

源码

banq
2016-11-22 09:03

现在将向您展示如何使用vuerouter重建以前的应用程序,替代vuex以更方便管理应用程序的状态。 当然你肯定同时使用vuex和vue-router。 这里决定使用bootstrap来设计这个应用程序的样式,主要是大部人对它更熟悉,而且要更快地构建这个新版本。 点击在这里

在单页应用程序中使用路由器库的第一个原因是让用户能访问浏览器的导航按钮。这是在以前的应用程序中非常缺乏。 要获得此行为,必须将路由附加到URL上。在传统应用程序中,路由表示服务器上的html视图。 而在单页应用中,路由是在JavaScript中,并不是后端服务器,并且每个路由被访问时将匹配到应该渲染呈现的组件。

import Vue from 'vue'
import VueRouter from 'vue-router'
import SearchResults from '../components/SearchResults'
import ResultDetail from '../components/ResultDetail'

Vue.use(VueRouter)

export default new VueRouter({
  mode: 'hash',
  base: __dirname,
  routes: [
    {
      path: '/:type/detail/:id',
      component: ResultDetail
    },
    {
      path: '/:type/:query?',
      component: SearchResults
    },
    {
      path: '*',
      redirect: '/Artist/'
    }
  ]
})

<p>

这里我定义我的两个用例的路由。 第一个路由用于详细视图,它将搜索类型和搜索结果ID作为路由参数。 第二个路由代表搜索结果视图,它包含搜索类型和用户输入的查询字符串作为路由参数。 这些路由参数将有关如何管理应用程序的状态。 当路由更新时,组件将根据这些参数呈现自身。 要使用我在这里定义的路由,需要将它们添加到主vue实例。

// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import App from './App'
import router from './router'

import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap/dist/css/bootstrap-theme.css'

/* eslint-disable no-new */
new Vue({
  el: '#app',
  router,
  render: h => h(App)
})
view raw
<p>

无论是否使用路由我总是要渲染显示应用程序组件,因此vue实例将在div中呈现输出。当路由改变时,应用组件将负责切换其他组件。 为了实现这一点,App组件需要对由vue-router库提供的路由器视图组件router-view进行渲染。

<template lang="pug">
  div#app
    search-box
    router-view
</template>


import SearchBox from './components/SearchBox'
export default {
  name: 'app',
  components: {
    SearchBox
  }
}


<style>
body {
  padding-top: 4em;
  padding-bottom: 2em;
  scroll-behavior: smooth;
}
</style>

<p>

随着路由代码的到位,下一步将是确定何时路由改变,以及组件如何对路由改变做出反应。

搜索框是用户与应用程序交互的主要方式。 因此,当点击搜索按钮或改变搜索类型时,路由被更新以反映用户的输入,并且搜索结果组件重新呈现结果输出。

li(v-for='type in searchTypes')
  router-link(:to="'/' + type + '/' + query") {{ type }}
<p>

这段来自SearchBox.vue模板的代码段显示了如何使用vue-router提供的router-link组件更改路由。

searchByType () {
    this.$router.push('/' + this.activeType + '/' + encodeURI(this.query) || '')
  }
<p>

也可以编程方式更改路由。当将上面配置的路由器被添加到vue实例,Vue使得$router变得可用了。 调用push是将新路由放入浏览器的历史堆栈,并使路由发生变动。

现在,让我们来看看搜索结果组件,看看它对路由更改的反应。

<template lang="pug">
  div.container
    ul.list-group
      li.list-group-item(v-for='result in searchResults' @click='goToDetail(result)')
        div.media
          div.media-left
            img.media-object(v-if='result.imageUrl', : height='64px' width='64px')
          div.media-body
            h4.media-heading {{ result.name }}
            p {{ resultInfo(result) }}
    button.btn.btn-default.center-block(v-if='hasMoreResults' @click='loadMoreResults()')
      span Show more results
</template>


import {fetchByType} from '../api'
const UP = 'UP'

// TODO: Add busy status and indicator.
export default {
  name: 'search-results',
  created () {
    this.routeChanged()
  },
  data () {
    return {
      hasMoreResults: false,
      searchResults: [],
      type: null,
      query: null,
      offset: 0
    }
  },
  methods: {
    routeChanged () {
      // reset query params
      this.type = this.$route.params.type
      this.query = this.$route.params.query
      this.offset = 0
      if (!this.query) {
        this.searchResults = []
        return
      }
      fetchByType(this.query, this.type)
        .then(res => {
          this.hasMoreResults = res.length >= 10
          this.searchResults = res
          this.handleScroll(UP)
        })
    },
    loadMoreResults () {
      this.offset += 10
      fetchByType(this.query, this.type, this.offset)
        .then(res => {
          this.hasMoreResults = res.length >= 10
          this.searchResults = [
            ...this.searchResults,
            ...res
          ]
        })
    },
    goToDetail (result) {
      switch (this.type) {
        case 'Artist':
        case 'Album':
          this.$router.push('/' + this.type + '/detail/' + result.id)
          break
        case 'Track':
          this.$router.push('/' + this.type + '/detail/' + result.album.id)
          break
        default:
          return
      }
    },
    handleScroll (direction) {
      this.$nextTick(() => {
        switch (direction) {
          case UP:
            window.scrollTo(0, 0)
            break
          default:
            window.scrollTo(0, document.body.scrollHeight)
        }
      })
    },
    resultInfo (result) {
      switch (this.type) {
        case 'Artist':
          return result.genres ? result.genres.join(', ') : null
        case 'Album':
          return result.artists ? result.artists[0].name : null
        case 'Track':
          return result.album && result.artists
            ? result.album.name + ' by ' + result.artists[0].name
            : null
        default: null
      }
    }
  },
  watch: {
    '$route': 'routeChanged'
  }
}


<style scoped>
li {
  cursor: pointer;
}
</style>

<p>

在上面created的生命周期钩子中我调用一个routeChanged函数。此函数从路由参数获取用户正在搜索的类型和查询,然后根据这些参数获取数据。这还不够,只有当搜索结果添加到DOM时才会调用created。 要使搜索结果注意到路由更改,这里使用watch对象来监视$route。现在当应用程序的路由更新时,将调用搜索结果routeChanged函数。

希望本文帮助您了解如何设置和使用vue-router在组件之间导航和保持它们同步。