vue-async-properties
Version:
Vue Component Plugin for asynchronous data and computed properties.
792 lines (636 loc) • 19.6 kB
Markdown
# vue-async-properties
> Vue Component Plugin for asynchronous data and computed properties.
**A Marketdial Project**
<p>
<a href="http://marketdial.com">
<img src="https://cdn.rawgit.com/marketdial/vue-async-properties/master/marketdial-logo.svg" alt="MarketDial logo" title="MarketDial" width="35%">
</a>
</p>
---
```js
new Vue({
props: {
articleId: Number
},
asyncData: {
article() {
return this.axios.get(`/articles/${this.articleId}`)
}
},
data: {
query: ''
},
asyncComputed: {
searchResults: {
get() {
return this.axios.get(`/search/${this.query}`)
},
watch: 'query'
debounce: 500,
}
}
})
```
```pug
#article(
v-if="!article$error",
:class="{ 'loading': article$loading }")
h1 {{article.title}}
.content {{article.content}}
#article(v-else)
| There was an error while loading the article!
| {{article$error.message}}
button(="article$refresh")
| Refresh the Article
input.search(v-model="query")
span(v-if="searchResults$pending")
| Waiting for you to stop typing...
span(v-if="searchResults$error")
| There was an error while making your search!
| {{searchResults$error.message}}
#search-results(:class="{'loading': searchResults$loading}")
.search-result(v-for="result in results")
p {{result.text}}
```
Has convenient features for:
- loading, pending, and error flags
- ability to refresh data
- debouncing, with `cancel` and `now` functions
- defaults
- response transformation
- error handling
The basic useage looks like this.
```bash
npm install --save vue-async-properties
```
```js
// main.js
import Vue from 'vue'
// you can use whatever http library you prefer
import axios from 'axios'
import VueAxios from 'vue-axios'
Vue.use(VueAxios, axios)
Vue.axios.defaults.baseURL = '... whatever ...'
import VueAsyncProperties from 'vue-async-properties'
Vue.use(VueAsyncProperties)
```
Now `asyncData` and `asyncComputed` options are available on your components. What's the difference between the two?
- `asyncData` only runs once automatically, during the component's `onCreated` hook.
- `asyncComputed` runs automatically every time any of the things it depends on changes, with a default debounce of `1000` milliseconds.
## `asyncData`
You can simply pass a function that returns a promise.
```js
// in component
new Vue({
props: ['articleId'],
asyncData: {
// when the component is created
// a request will be made to
// http://api.example.com/v1/articles/articleId
// (or whatever baseURL you've configured)
article() {
return this.axios.get(`/articles/${this.articleId}`)
}
},
})
```
```pug
//- in template (using the pug template language)
#article(
v-if="!article$error",
:class="{ 'loading': article$loading }")
h1 {{article.title}}
.content {{article.content}}
#article(v-else)
| There was an error while loading the article!
| {{article$error.message}}
button(="article$refresh")
| Refresh the Article
```
## `asyncComputed`
You have to provide a `get` function that returns a promise, and a `watch` parameter that's either a [string referring to a property on the vue instance, or a function that refers to the properties you want tracked](https://vuejs.org/v2/api/#vm-watch).
```js
// in component
new Vue({
data: {
query: ''
},
asyncComputed: {
// whenever query changes,
// a request will be made to
// http://api.example.com/v1/search/articleId
// (or wherever)
// debounced by 1000 miliseconds
searchResults: {
// the function that returns a promise
get() {
return this.axios.get(`/search/${this.query}`)
},
// the thing to watch for changes
watch: 'query'
// ... or ...
watch() {
// do this if you need to watch multiple things
this.query
}
}
}
})
```
```pug
//- in template (using the pug template language)
input.search(v-model="query")
span(v-if="searchResults$pending")
| Waiting for you to stop typing...
span(v-if="searchResults$error")
| There was an error while making your search!
| {{searchResults$error.message}}
#search-results(v-else, :class="{'loading': searchResults$loading}")
.search-result(v-for="result in results")
p {{result.text}}
```
You might be asking "Why is the `watch` necessary? Why not just pass a function that's reactively watched?" Well, in order for Vue to reactively track a function, it has to invoke that function up front when you create the watcher. Since we have a function that performs an expensive async operation, which we also want to debounce, we can't really do that.
## Meta Properties
Properties to show the status of your requests, and methods to manage them, are automatically added to the component.
- `prop$loading`: if a request is currently in progress
- `prop$error`: the error of the last request
- `prop$default`: the default value you provided, if any
**For `asyncData`**
- `prop$refresh()`: perform the request again
**For `asyncComputed`**
- `prop$pending`: if a request is *queued*, but not yet sent because of debouncing
- `prop$cancel()`: cancel any debounced requests
- `prop$now()`: immediately perform the latest debounced request
## Debouncing
It's always a good idea to debounce asynchronous functions that rely on user input. You can configure this both globally and at the property level.
By default, anything you pass to `debounce` only applies to `asyncComputed`, since it's the only one that directly relies on input.
```js
// global configuration
Vue.use(VueAsyncProperties, {
// if the value is just a number, it's used as the wait time
debounce: 500,
// you can pass an object for more complex situations
debounce: {
wait: 500,
// these are the same options used in lodash debounce
// https://lodash.com/docs/#debounce
leading: false, // default
trailing: true, // default
maxWait: null // default
}
})
// property level configuration
new Vue({
asyncComputed: {
searchResults: {
get() { /* ... */ },
watch: '...'
// this will be 1000
// instead of the globally configured 500
debounce: 1000
}
}
})
```
It is also allowed to pass `null` to debounce, to specify that no debounce should be applied. If this is done, `property$pending`, `property$cancel`, and `property$now` will not exist. The same rules that apply to other options holds here; the global setting will set all components, but it can be overridden by the local settings.
```js
// no components will debounce
Vue.use(VueAsyncProperties, {
debounce: null
})
// just this component won't have a debounce
new Vue({
asyncComputed: {
searchResults: {
get() { /* ... */ },
watch: '...'
debounce: null
// this however would debounce,
// since the local overrides the global
debounce: 500
}
}
})
```
This should only be done when the `asyncComputed` only watches values that aren't changed frequently by the user, otherwise a huge number of requests will be sent out.
### `watchClosely`
Sometimes the method should debounce when some values change (things like key inputs or anything that might change rapidly), and *not debounce* when other values change (things like boolean switches that are more discrete, or things that are only changed programmatically).
For these situations, you can set up a separate watcher called `watchClosely` that will trigger an immediate, undebounced invocation of the `asyncComputed`.
```js
new Vue({
data: {
query: '',
includeInactiveResults: false
},
asyncComputed: {
searchResults: {
get() {
if (this.includeInactiveResults)
return this.axios.get(`/search/all/${this.query}`)
else
return this.axios.get(`/search/${this.query}`)
},
// the normal, debounced watcher
watch: 'query',
// whenever includeInactiveResults changes,
// the method will be invoked immediately
// without any debouncing
watchClosely: 'includeInactiveResults'
}
}
})
```
Obviously, if you pass `debounce: null`, then `watchClosely` will be ignored, since invoking immediately without any debounce is the default behavior.
Also, if you only pass `watchClosely`, that will automatically infer that debouncing should never be done.
```js
new Vue({
data: {
showOldPosts: false
},
asyncComputed: {
searchResults: {
// a change to showOldPosts
// should always immediately
// retrigger a request
watchClosely: 'showOldPosts',
get() {
if (this.showOldPosts) return this.axios.get('/posts')
else return this.axios.get('/posts/new')
}
}
}
})
```
## Returning a Value Rather Than a Promise
If you don't want a request to be performed, you can directly return a value instead of a promise.
```js
new Vue({
props: ['articleId'],
asyncData: {
article: {
get() {
// if you return null
// the default will be used
// and no request will be performed
if (!this.articleId) return null
// ... or ...
// doing this will directly set the value
// and no request will be performed
if (!this.articleId) return {
title: "No Article ID!",
content: "There's nothing there."
}
else
return this.axios.get(`/articles/${this.articleId}`)
},
// this will be used if null or undefined
// are returned either by the get method
// or by the request it returns
// or if there's an error
default: {
title: "Default Title",
content: "Default Content"
}
}
}
})
```
## Lazy and Eager
`asyncData` allows the `lazy` param, which tells it to not perform its request immediately on creation, and instead set the property as `null` or the default if you've provided one. It will instead wait for the `$refresh` method to be called.
```js
new Vue({
asyncData: {
article: {
get() { /* ... */ },
// won't be triggered until article$refresh is called
lazy: true, // default 'false'
// if a default is provided,
// it will be used until article$refresh is called
default: {
title: "Default Title",
content: "Default content"
}
}
}
})
```
`asyncComputed` allows an `eager` param, which tells it to immediately perform its request on creation, rather than waiting for some user input.
```js
new Vue({
data: {
query: 'initial query'
},
asyncComputed: {
searchResults: {
get() { /* ... */ },
watch: 'query',
// will be triggered right away with 'initial query'
eager: true // default 'false'
}
}
})
```
## Transformation Functions
Pass a `transform` function if you have some processing you'd always like to do with request results. This is convenient if you'd rather not chain `then` onto promises. You can provide this globally and locally.
**Note:** this function will only be called if a request is actually made. So if you directly return a value rather than a promise from your `get` function, `transform` won't be called.
```js
Vue.use(VueAsyncProperties, {
// this is the default
transform(result) {
return result.data
}
// ... or ...
// doing this will prevent any transforms
// from being applied in any properties
transform: null
})
new Vue({
asyncData: {
article: {
get() { /* ... */ },
// this will override the global transform
transform(result) {
return doSomeTransforming(result)
},
// ... or ...
// doing this will prevent any transforms
// from being applied to this property
transform: null
}
}
})
```
## Pagination
Normal pagination is easy with this library, you just need to use some sort of limit and offset in your requests.
With `asyncData`:
```js
new Vue({
data() {
return {
pageSize: 10,
pageNumber: 0
}
},
asyncData: {
posts() {
return this.axios.get(`/posts`, {
params: {
limit: this.pageSize,
offset: this.pageSize * this.pageNumber,
}
})
}
},
methods: {
goToPage(page) {
this.pageNumber = page
this.posts$refresh()
}
}
})
```
... and with `asyncComputed`:
```js
const pageSize = 10,
new Vue({
data() {
return {
pageNumber: 0
}
},
asyncComputed: {
posts: {
get() {
return this.axios.get(`/posts`, {
params: {
limit: pageSize,
offset: pageSize * this.pageNumber,
}
})
},
watchClosely: 'pageNumber'
}
}
})
```
## Load More
Doing a "load more" is interesting though, since you need to append new results onto the old ones.
To make a load more situation, pass a `more` option to your property, giving a method that gets more results to add to the old ones. A `$more` method will be added to your component that you can call whenever you want to get more results.
```js
const pageSize = 5
new Vue({
data() {
return { filter: '' }
},
asyncData: {
posts: {
get() {
return this.axios.get(`/posts/${this.filter}`, {
params: {
limit: pageSize,
}
})
},
// this method will get results that will be appended to the old ones
// it's triggered by the `posts$more` method
more() {
return this.axios.get(`/posts/${this.filter}`, {
params: {
limit: pageSize,
offset: this.posts.length,
}
})
}
// since sometimes the way you add new results
// to the property won't be a basic array concat
// you can pass a static concat method that
// returns a collection with the new results added to it
more: {
// this is the default
concat: (posts, newPosts) => posts.concat(newPosts),
get() {
const pageSize = 5
return this.axios.get(`/posts/${this.filter}`, {
params: {
limit: pageSize,
offset: this.posts.length,
}
})
}
}
}
}
})
```
Here's an example template:
```pug
input.search(v-model="filter")
.posts
.post(v-for="post in posts") {{ post.title }}
button.load-more(="posts$more") Get more posts
```
For `asyncComputed`, the `watch` and `watchClosely` parameters will still trigger a complete reset of the collection. Only the `$more` method appends new results.
```js
const pageSize = 5
new Vue({
data() {
return { filter: '' }
},
asyncComputed: {
posts: {
get() {
return this.axios.get(`/posts/${this.filter}`, {
params: {
limit: pageSize,
}
})
},
watch: 'filter',
more() {
return this.axios.get(`/posts/${this.filter}`, {
params: {
limit: pageSize,
offset: this.posts.length,
}
})
}
}
}
})
```
All the other options like `transform`, `error`, `debounce`, will still work the same.
### `$more` Returns Last Response
If you need to do some logic based on what the last load more request returned, you can wrap the `$more` method and get the last response the `$more` received. This returned value is the raw response, without the `transform` function called on it.
```js
const pageSize = 10
new Vue({
data() {
return { noMoreResults: false }
},
asyncData: {
posts: {
get() {
return this.axios.get(`/posts/${this.filter}`, {
params: {
limit: pageSize,
}
})
},
more() {
return this.axios.get(`/posts/${this.filter}`, {
params: {
limit: pageSize,
offset: this.posts.length,
}
})
}
}
},
async moreHandler() {
// `$more` handles appending the results,
// so don't worry about doing that here
// this just allows you to inspect the last result
let lastResponse = await this.posts$more()
this.noMoreResults = lastResponse.data.length < pageSize
}
})
```
### Watching For Reset Events
Since you might need to be notified when the collection resets based on a `watch` or `watchClosely`, you can watch for a `propertyName$reset` event. It passes the response that came for the reset.
```js
const pageSize = 5
new Vue({
data() {
return {
noResultsReturned: false
}
},
asyncData: {
posts: {
get() {
return this.axios.get(`/posts/${this.filter}`, {
params: {
limit: pageSize,
}
})
},
more() {
return this.axios.get(`/posts/${this.filter}`, {
params: {
limit: pageSize,
offset: this.posts.length,
}
})
}
}
},
created() {
// whenever a watch or watchClosely resets the collection,
// it will $emit this event
this.$on('posts$reset', (resettingResponse) => {
// here you can perform whatever logic
// you need to with the resetttingResponse
if (resettingResponse.data.length == 0) {
this.noResultsReturned = true
}
this.resetScoller() // or whatever
})
}
})
```
## Error Handling
You can set up error handling, either globally (maybe you have some sort of notification tray or alerts), or at the property level.
```js
Vue.use(VueAsyncProperties, {
error(error) {
Notification.error({
title: "error",
message: error.message,
})
}
})
new Vue({
asyncData: {
article: {
get() { /* ... */ },
// this will override the error handler
error(error) {
this.doErrorStuff(error)
}
}
}
})
```
There is a global default, which simply logs the error to the console:
```js
Vue.use(VueAsyncProperties, {
error(error) {
console.error(error)
}
})
```
### Different naming for Meta Properties
The default naming strategy for the meta properties like `loading` and `pending` is `propName$metaName`. You may prefer a different naming strategy, and you can pass a function for a different one in the global config.
```js
Vue.use(VueAsyncProperties, {
// for "article" and "loading"
// "article__Loading"
meta: (propName, metaName) =>
`${propName}__${myCapitalize(metaName)}`,
// ... or ...
// "$loading_article"
meta: (propName, metaName) =>
'$' + metaName + '_' + propName,
// the default is:
meta: (propName, metaName) =>
`${propName}$${metaName}`,
})
```
## Contributing
This package has testing set up with [mocha](https://mochajs.org/) and [chai expect](http://chaijs.com/api/bdd/). Since many of the tests are on the functionality of Vue components, the [vue testing docs](https://vuejs.org/v2/guide/unit-testing.html) are a good place to look for guidance.
If you'd like to contribute, perhaps because you uncovered a bug or would like to add features:
- fork the project
- clone it locally
- write tests to either to reveal the bug you've discovered or cover the features you're adding (write them in the `test` directory, and take a look at existing tests as well as the mocha, chai expect, and vue testing docs to understand how)
- run those tests with `npm test` (use `npm test -- -g "text matching test description"` to only run particular tests)
- once you're done with development and all tests are passing (including the old ones), submit a pull request!