nuxt / rfcs

RFCs for changes to Nuxt.js
99 stars 2 forks source link

Nuxt 3: Introducing fetch() hook #27

Closed atinux closed 4 years ago

atinux commented 5 years ago

Summary

Vue 2.6 introduced the serverPrefetch hook on SSR. Allowing to have an asynchronous hook for components to be awaited before rendering the HTML.

The idea is to introduce a new hook called fetch() that will allow any component to handle asynchronous operation on both server-side and client-side.

Basic example

This is how a page component can look like:

pages/index.vue

<template>
  <div>
    <h1>Blog posts</h1>
    <p v-if="$isFetching">Fetching posts...</p>
    <ul v-else>
      <li v-for="post of posts" :key="post.id">
        <n-link :to="`/posts/${post.id}`">{{ post.title }}</n-link>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  data () {
    return {
      posts: []
    }
  },
  async fetch () {
    this.posts = await fetch('https://jsonplaceholder.typicode.com/posts').then((res) => res.json())
  }
}
</script>

You can see a more detailed example and documentation here: https://github.com/nuxt/nuxt.js/tree/feat/async-data/examples/v3/fetch

Motivation

The main motivation here is to remove the correlation between pages & asynchronous data. Each component could have its own async data logic.

This could also introduce a way for Nuxt modules author to create components to fetch data on particular endpoints.

Example:

<template>
  <Post :post-id="$route.params.id" v-slot="{ post }">
    <h1>{{ post.title }}</h1>
    <Author :user-id="post.userId" />
    <pre>{{ post.body }}</pre>
  </Post>
</template>

<script>
import Post from '~/components/Post.vue'
import Author from '~/components/Author.vue'

export default {
  components: {
    Post,
    Author
  }
}
</script>

Where ~/components/Post.vue is something like:

<template>
  <div v-if="$isFetching">Fetching post #{{ postId }}...</div>
  <div v-else>
    <slot v-bind:post="post">
      <h1>{{ post.title }}</h1>
      <pre>{{ post.body }}</pre>
    </slot>
  </div>
</template>

<script>
export default {
  props: {
    postId: {
      type: Number,
      required: true
    }
  },
  data () {
    return {
      post: {}
    }
  },
  async fetch() {
    this.post = await fetch(`https://jsonplaceholder.typicode.com/posts/${this.postId}`).then((res) => res.json())
  }
}
</script>

Detailed design

A schema is worth a thousand words πŸ˜„

Vue js_ fetch() hook by Nuxt js (3)

Drawbacks

Context

fetch hook does not receive any context as 1st argument anymore since it has access to this.

The context will be updated to be available through this.$ctx, this.$config and this.$nuxt, learn more on https://github.com/nuxt/rfcs/issues/25

Server-side

No drawbacks since Nuxt will wait for all fetch hooks to be finished before rendering the page.

Client-side

The main drawback of this current implementation if the UX between Nuxt 2 & Nuxt 3 when navigating from page to page.

Let's take an example of having two pages (A and B) with fetch used in both of these pages.

This implies to create placeholders to display something while fetch is being called. This is why $isFetching is introduced.

We could support the "old" behaviour by providing a Nuxt module (using Nuxt middleware + plugin), a POC has been made on https://github.com/nuxt/nuxt.js/tree/feat/async-data/examples/v3/async-data

Advantages

People coming from Vue applications should find the new usage of fetch easier.

Unresolved questions

Things left:

Gregg commented 5 years ago

I haven't built a Nuxt app before, but I'm in the middle of teaching these topics at Vue Mastery. Here are my feelings:

  1. I thought it was an intelligent convention that asyncData merges the return value into data. This felt like a nice shortcut and feature which this now removes. Now I need to remember to define my object in the data property. I agree with you though, that making this mirror the simplest Vue functionality is likely the best path for beginners.

This makes me wonder if you planning on keeping asyncData with the same functionality? This might be nice for those who want to upgrade to Nuxt 3 who are using it.

  1. I thought it was an intelligent convention that Nuxt (on the client side) waited for my API call to finish before loading my component. Given the built-in progress bar It works exactly as you'd expect. IMHO this is the optimal user experience, i.e. the new page showing up completely rendered and not in a loading state.

If your goal is to optimize for beginners, not having to deal with a loading state on the client side was quite nice. However, I read above that this is not possible?

Also, it might be nice to keep asyncData with the same functionality (of waiting for a return). Again, this might be nice for those who want to upgrade to Nuxt 3 who are using it.

atinux commented 5 years ago

Thanks @Gregg for your feedback.

We plan to have a Nuxt module to keep the current asyncData behaviour when migrating from Nuxt 2 to Nuxt 3, you can see my current implementation (POC) here: https://github.com/nuxt/nuxt.js/blob/feat/async-data/examples/v3/async-data/modules/nuxt-async-data/plugins/async-data.js

I wanted to get closer to Vue core by keeping only one data to:

I tried many ways to keep the same behaviour when navigating on client-side (ie: wait for all fetch calls before switching to the new route):

But these two were breaking other stuffs inside Vue internals (page transitions, keep-alive, etc) 😒

ttquoccuong commented 5 years ago

Hi @Atinux , i working on quasar-framework and i see have a same thing in quasar framework.

https://quasar-framework.org/guide/app-prefetch-feature.html

so, how about it?

atinux commented 5 years ago

Hi @ttquoccuong

Actually, the prefetch feature of Quasar is the same as the current fetch of Nuxt.js: it's called during router.beforeEach and you cannot have access to the component instance inside it.

henriqemalheiros commented 5 years ago

First of all, I love this and it will definitely be a huge improvement for most of my projects. But there are some projects which are simpler and that kind of client-side UX might be too much overhead. Maybe the data being fetched is really small that there wouldn't be significant perceived speed improvement and it would cause an annoying flickering of the placeholder as the data is fetched really fast.

It's also a huge breaking change, as the whole Nuxt data fetching logic revolves mainly around asyncData. The fetch hook is a new way of thinking about data fetching and most users would only really benefit from it if they split their asyncData into several fetch hooks (just like the separation between post content and post author in the example).

The asyncData module looks promising, but it gets in the way of the "avoid splitting component data into 2 hooks" principle (also, autocompletion would be nice). I want to use fetch, but I don't want to rethink data fetching for now, at least.

So, my suggestion is to add a awaitFetch property on page components. If set to true, the fetch hook of that page component would be awaited before changing routes, just like today's asyncData. The fetch hook on other components don't need to be awaited.

Defaulting awaitFetch to true, would allow a smooth migration from v2 to v3, since users can simply refactor their asyncData into fetch, keeping the current behaviour and incrementally adopting the new behaviour as they add fetch on other components or set awaitFetch to false. Then, maybe, the awaitFetch could default to false on v4 to encourage the new behaviour.

atinux commented 5 years ago

That was how I wanted to implement it @henriqemalheiros, exactly like this to have no breaking changes and a smooth upgrade to all users...But sadly Vue.js does not have any asynchronous hook before rendering the component data.

I tried to hack <router-view> but without success, I am waiting for @posva expertise to see what we can do to support the current Nuxt behaviour by default while having the new fetch (accessing this inside the hook).

henriqemalheiros commented 5 years ago

I was trying to find a way to achieve this and, from what I've found, it seems that the only way this could be done tidily would be through asynchronous lifecycle hooks. In the past, Evan said that there's hasn't been enough substantial benefits that justified implementing this feature. Maybe, with Nuxt on the scene, they could implement it in Vue 3?

One possible alternative solution would be tackling this question (small repro here). vue-router's inner workings were completely obscure to me until today and I haven't tried to code anything but, after some thought, it seems that if we could link the route instance registration (here and here) to beforeRouteEnter's callback function pooling, we could defer the route update function (which seems to actually update the component being rendered by router-view) and add the future awaited route as an another property (something like this.future, available via $router.futureRoute). But for this to work, router-view render logic would need to be rethinked, since it would need to register the future route instance too. I think this could be achieved with two slots, one for the current route and other for the future route, and a conditional render between the two. Again, this is just plain speculation and if I can find some spare time, I'll try it.

davestewart commented 5 years ago

Hello.

I've just seen issue this referenced in this issue I commented on...

One possible alternative solution would be tackling this question (small repro here)

...so perhaps I'll add my thoughts (though they may not be particularly relevant).

In brief, that issue relates to what might be loosely termed a "race condition" regarding fetched data and rendered component:

The issue is that because the data has not been made available in mount, both template and computed properties require v-ifs, conditionals or empty data to prevent errors.

I'm not so familiar with nuxt (just a couple of practice projects) but it seems that this proposal does not look to solve that, as the component is rendered first?

It seems to me that some way to merge the data before mounting would be the key to cleaner templates and no nextTick() funkiness or conditional cruft.

I made some suggestions in my comment on how this might be achieved, but as @henriqemalheiros noted, the code in Vue Router seems very complex / abstracted so it's something that would certainly need attention from @posva. I forked the repo and had a good dig about – to no avail!

henriqemalheiros commented 5 years ago

@davestewart your solution is good, but it achieves the same UX as the current Nuxt version. The problem we're discussing is accessing the component's instance before the route changes. Currently, vue-router does this:

If we could move the next() callback right after the created hook, we would have access to the component instance and somehow defer its mounting until some async data is fetched. So I suggested using slots in router-view or even (thinking about it later) something similar to the transition component. I tried messing around with things a bit, but with no success. It kind of works in the simplest scenario, but it's very brittle and falls apart quickly. I think the $futureRoute is the way to go, the problem being how to properly handle it in router-view.

davestewart commented 5 years ago

Yes, we're looking to solve the same problem.

My proposal looks to similar to asyncData() as you mention:

beforeRouteEnter
  data = getData()    <-- get the user data 
  next(data)          <-- I suggest passing `data` to next() here
                      <-- which will be merged by vue or vue router here
beforeCreate
                      <-- or maybe here
created
                      <-- you want to execute the callback here (which works for more functionality)
beforeMount
mounted
callback()            <-- current place callback is executed

https://jsfiddle.net/tsyav1up/2/

You mention:

if we could move the callback() right after the created hook, we would have access to the component instance and somehow defer its mounting until some async data is fetched"

The bits I don't understand:

  1. Why does mounting need to be delayed when we already got the data in beforeRouteEnter?
  2. Where does the extra complication with future routes and slots and so on come from?

Perhaps this is Nuxt internals I don't understand...

And ignore this if it's hijacking the RFC.

henriqemalheiros commented 5 years ago

@davestewart you're using getData() as an external function that relies in the route params, like this.

That's exactly what we currently have in Nuxt and that is not what this RFC is about. This RFC is introducing a new way to handle data fetching that is closer to what major SPAs do, like Facebook or YouTube. It also supports access to this, which is awesome in so many ways. It introduces a new DX and a new UX, the later being a breaking change.

As @Atinux said:

I tried to hack <router-view> but without success, I am waiting for @posva expertise to see what we can do to support the current Nuxt behaviour by default while having the new fetch (accessing this inside the hook).

So we want to use getData() as an internal function that relies on the component instance, like this (data fetching depends on the path prop). This way we could benefit from the new DX this RFC introduces without worrying about the breaking change in the UX.

AndrewBogdanovTSS commented 5 years ago

This is the topmost feature I wait for in Nuxt 3 πŸ˜‰

bf commented 5 years ago

I need this feature :-)

atinux commented 4 years ago

New PR is up -> https://github.com/nuxt/nuxt.js/pull/6880

hecktarzuli commented 4 years ago

Maybe my opinion will change at some point, but for sites that are pretty quick I think showing users a loaded page is better than showing them a quasi-broken page where they have to wait longer and watch items load.

This is very similar to sites who lazy load images when they enter the viewport (very annoying) vs just BEFORE they enter the viewport.

I'm glad to see there is a way to do it the 'classic' way in v3 (though it also seems there is a fetch polyfill for some reason, it would be nice to NOT have that if we aren't using this new feature.)

AndrewBogdanovTSS commented 4 years ago

@hecktarzuli I would disagree with this statement. One of the important parts of UX is loading speed and speaking from the personal experience, perceptive speed of loading process increases significantly when done in a new way so I would rather go the new way than the old one

hecktarzuli commented 4 years ago

@AndrewBogdanovTSS Good thing we have both options then eh :)

It's similar to pages like this. On a Desktop, go to https://realtruck.com/, click on a category, then go directly to another category (via top menu or whatever), then hit back/forward/back/forward. You see the content change, then load when you are already 'in' the page. It's probably a little picky, but it annoys me.

Yes we could put in the classic placeholder chunks everywhere, but it would actually be a worse experience. If we could just wait ~ 50-100ms and have everything perfect when you hit the page, that's where the sweet spot is.

I do understand having a loading state for slow mobile is also ideal, but looking at RUM, that's < 1% of our users.

homerjam commented 4 years ago

Surely it's a no-brainer β€” implement the new behaviour and polyfill the old behaviour. It's got to be relatively trivial versus doing it the other way around. Some kind of observer/listener on $isFetching in the beforeRouteEnter hook?

hecktarzuli commented 4 years ago

If I had a magic wand we'd have 1 fetch method and a way to tell Nuxt to wait XXms for fetches to resolve before starting to render the route.

If all fetches resolve before the wait, great! Wait no longer and the page is rendered as intended. If the wait expires, nav to the route and the widgets would show placeholders until they are filled.

It's the font-display:fallback of routing - the best of all worlds :)

atinux commented 4 years ago

Well that was my wish too, but not possible since we have to create the instance of the page (so showing it) to call fetch 😒

On SSR there is not placeholder since we can wait before rendering the HTML.

atinux commented 4 years ago

One good news, in full static mode, placeholders should be hidden since the data will be available for the rendered components πŸ˜„

hecktarzuli commented 4 years ago

Well that was my wish too, but not possible since we have to create the instance of the page (so showing it) to call fetch

Yep, hence the word 'magic'. πŸ˜„ You'd pretty much have to re-think this whole feature, and maybe even have to hack Vue/Vue-Router. A guy can DREAM!

negezor commented 4 years ago

Already, gradually in many projects there is a migration to composition api. Is it supposed that the major version of Nuxt 3 will be for Vue 2?

atinux commented 4 years ago

Composition API is optional @negezor

We will support it as soon as it actually better support SSR :)

chriscalo commented 4 years ago

@hecktarzuli, you described the perfect end-user experience:

  1. Wait about a half second to see if the data comes back quickly and then render in one pass. This minimizes layout shift and other undesirable render effects.
  2. After that time, if the data hasn't returned, we need to start rendering a loading state (not an empty state) to prevent the user from thinking something is broken.

The only means I could find to accomplish this today was with the transition option. I put the following in every page component:

<template>
  <div>…</div>
</template>

<script>
  export default {
    transition: { name: "page", mode: "" },
    // …
  };
</script>

This makes use of the following global CSS:

.page-leave-active {
  /* delay gives extra time for data fetching */
  transition-delay: 0.4s;
  transition-duration: 0;
}

This gets the job done, but I'd really like more programmatic control, such as:

husayt commented 4 years ago

@pi0 @Atinux I have been playing with the new fetch for last few days and came across some practical hurdles, the most painful of them is error handling. The way it currently implemented it allows only hiding the component through $fetchState.error variable. So if $fetchState.error===true you can only hide your component. But often I want to be taken to error page if data is not found and return 404 , as I would normally do with asyncdata via error method.

 <span v-if="$fetchState.pending" >loading</span>
 <span v-else-if="$fetchState.error" >error</span>
 <Quote :item="quote" />

.....

  async fetch() {
     try {
      this.quote = await this.$api.getQuote(this.$route.params.id)
    } catch (e) {
      this.$nuxt.error({
        statusCode: 404,
        message: "Quote not found " + this.$route.params.id
      })
      this.$fetchState.error = true
      // throw new Error("Aforizm tapΔ±lmadΔ±: " + this.$route.params.id)
    }
  },

So I try the above code and it works fine on client-side, but if error happens during SSR I get the following warning:

The client-side rendered virtual DOM tree is not matching server-rendered content

So how this most common error handling pattern should be implemented via new Fetch hook ? Thanks

husayt commented 4 years ago

@Atinux thanks replying here

atinux commented 4 years ago

Closing since it is available in Nuxt 2.12: https://nuxtjs.org/api/pages-fetch