vuejs / vue-router

🚦 The official router for Vue 2
http://v3.router.vuejs.org/
MIT License
18.99k stars 5.06k forks source link

add ability to associate routes with Vuex actions? #958

Closed indirectlylit closed 3 years ago

indirectlylit commented 7 years ago

Hi - thanks for a great web framework!

In general, we try to use Vuex state and actions to drive our views according to the flux pattern. This means that we have a pretty standard flow of information:

Async input events  =>  Actions  =>  Mutations  =>  View updates
 * user input
 * server data
 * timers

Routing events are asynchronous input events. Clicking a link or manually modifying or entering a URL are user-driven actions that can happen at any time, similar to clicking a button.

However, the <router-view> component behaves differently: it bypasses Vuex by associating user input (routing events) directly with view updates (showing a component). This gives rise to a different information flow:

Routing event  =>  View update
               v                v
       beforeRouteEnter    watch $route  => View update

This creates some complexity as views need to handle events and watch the $route for changes, e.g.:

It also makes vuex-router-sync somewhat strange, because the route info gets stored in Vuex independently of the Vue components being updated, whereas typically they're updated because of Vuex.

Instead of mapping routes to components, we've tried mapping routes to actions. In practice, this means having an unused <router-view> and doing something like:

Routing event  =>  no-op
               v
       beforeRouteEnter  =>  Action  =>  Mutation(s)  =>  View update(s)

This allows us to drive all loading, page-switching, and rendering state logic from the Vuex store rather than inside of components. It also makes it really easy to embed a lot of state in the URL, and then make updates by calling router.push or router.replace rather than directly triggering an action (e.g. to change the sort-order or filter options of a table).

It would be cool is if there was first-class support for associating Vuex actions with routes:

Routing event  =>  Action  =>  Mutation(s)  =>  View update(s)

In this setup many 'features' become redundant or unnecessary: e.g. <router-view>, route transitions, the $route attribute, navigation guards, and vuex-router-sync

Just speculation: perhaps there's also opportunity to associate nested routes with the new Vuex 2.0 nested modules...

thanks for your consideration!

yyx990803 commented 7 years ago

This is an interesting perspective - I think this is primarily because vue-router has to be designed to be usable without vuex, so it has to be able to control view updates on its own.

A vuex-first routing solution would probably look quite different indeed, although I doubt we can bend the current vue-router API design for that purpose (without introducing breaking changes).

I think it would be interesting to experiment as a separate project so that it is not constrained to how vue-router works now.

indirectlylit commented 7 years ago

Starting fresh would certainly allow it to be designed more cleanly. I'm very interested in what the API for a vuex-first router might look like, but I don't have any concrete ideas. (We're still on vuex 1.0, and are in the process of migrating to vue 2.0)

That said, my naive take is that bending the current vue-router API to support actions might be possible without breaking backward-compatibility?

Basically:

    const routes = [
      {
        path: '/some/url',
        handler: (toRoute, fromRoute) => {
          actions.showUserPage(store); // vuex 1.0
        },
      },
      ...

This handler function seems similar in behavior to the new handler being discussed in https://github.com/vuejs/vue-router/issues/938 in that it would be called even for parameter changes. Similarly, I would expect it to be called if we added a $route.reload(), to enable the authenticate-current-page use-case.

gbmhunter commented 7 years ago

I managed to get route changes to trigger vuex actions by:

  1. Installing https://github.com/vuejs/vuex-router-sync
  2. Adding a watcher on $store.state.route
  3. Calling actions from this watcher.

This allows you to ditch <router-view></router-view> completely, and the address bar path is now considered just another piece of state which drives the application.

nickmessing commented 7 years ago

I think that can be done as a part of vuex-router-sync to not introduce breaking changes.

Something like a sugar above what @gbmhunter, is doing atm.

indirectlylit commented 7 years ago

That's a good idea.

We ended up making a thin wrapper around vue-router which leverages the beforeEach function.

We use it like this:

const router = require('router.js');

const routes = [
  {
    name: PageNames.SOME_PAGE,
    path: '/somepage',
    handler: (toRoute, fromRoute) => {
      actions.showSomePage(store);
    },
  },
  // .......
];

this.rootvue = new Vue({
  el: 'rootvue',
  render: createElement => createElement(RootVue),
  router: router.init(routes),
});
dewdad commented 7 years ago

I'm new to Vue. Developing a Vue POC for our project right now and I need to factor in routable state. I have a table with a few multi-select filters on it. After the user has selected any permutation, I need to be able to share that via a link to show the exact state of the table after filtering. I'm not entirely sure on how to go about doing this with Vuex and Vue-Router. Any suggestions?

evertramos commented 7 years ago

Hello @indirectlylit,

Not sure if I the code below will get what you requested in first place, but here it is:

I have a regular getter with the returning the information from vuex:

const getters = {
    checkUserAccess: state => () => {
        return true;
    }   
}
export default getters;

I have some routes with default options...

In my main.js file I use router.app.$store, as of:

const app = new Vue({
    el: '#app',
    router,
    store,
});

router.beforeEach((to, from, next) => {
    console.log(router.app.$store.getters.checkUserAccess())
});

Just for the record, it will log true.

nkoterba commented 7 years ago

@gbmhunter Can you show a code example of adding a watch to $store.state.route?

I'm trying to update our app state/store, e.g., run vuex actions, when the application's route (and/or url query params) change.

We are using vuex-router-sync already but I'm trying to trigger these vuex updates outside of a specific component.

Did you just add the watch in the main app Vue? Or in a root component? Or somewhere else?

gbmhunter commented 7 years ago

@nkoterba Sorry it was a while ago when I implemented this, so the details are a tad sketchy. The code base can be found at https://github.com/mbedded-ninja/NinjaCalc.

Most of the route watcher code is in https://github.com/mbedded-ninja/NinjaCalc/blob/317f07c467ab9233704a9fcd2923ba6c07d03fd5/src/components/App/App.vue. A computed value called route is added which is just bound to $store.state.route.

computed: {
   route () {
     return this.$store.state.route
   }
},

There is also a handleRouteChange() function defined which is called when route changes via a watch.

watch: {
   route () {
     this.handleRouteChange()
   }
},
// This should be called everytime the $store.state.route object changes
// (i.e. whenever the route path changes)
// This function performs the state change required due to the route object changing
handleRouteChange () {
   var path = this.route.path
   ...
}

So to answer your question, yes, I did just add a watch in the main app. I hope this helps!

nkoterba commented 7 years ago

@gbmhunter Thanks for such a detailed explanation and code samples 👍

Just to make sure I'm not missing something, is there a reason you created both a computed value and a watch? Why not just pass the new route into the handleRouteChange function from the watch function (since watch callback receives the new value)?

I see you call handleRouteChange() in your mounted lifecycle event but couldn't you just pass this.$store.state.route into that function?

(Still new to Vue.js so may be missing something obvious here)

gbmhunter commented 7 years ago

@nkoterba thanks!

I think I created both a computed value and a watch because I couldn't work out how to watch a variable that wasn't part of the same module the watch was in (perhaps you can do this, I was/am pretty new to vue.js also!)

I call handleRouteChange() from mounted() so that all the route change functionality is contained within the one function, but yes, you could just access this.$store.state.route from mounted() and act upon it there.

douglasg14b commented 5 years ago

I'm still learning Vue, but coming from AngularJS the router there gave the ability to start fetching data when navigation started, and injecting it into the component when the data was available (and setting a prop to say when it was done). Which made for fairly elegant resource fetching during navigation, and easy state-management for loading indicators...etc. And the resource requirements could be defined on the routes themselves, instead of on the components.

Any way that the meat of that can be simply done with vue-router & vuex? Sans the injecting, just the async data retrieval then the ability to wait for it after render, as well as defining those requirements on the routes themselves vs individual components.

Ie:

Routing event     =>      Component Rendered
               v                        v
async beforeNavigation     await beforeNav promise  => View update

This also seems to touch on the dilemma of fetching data before or after navigation. Ideally data is retrieved during navigation. It starts before, and then is waited for after render, reducing perceived latency.

douglasg14b commented 5 years ago

Well, turns out I can do this fairly easy by taking advantage of the meta route object property!

In Route:

    {
      path: '/upload',
      name: 'Upload File',
      meta: {
        promises: null as Promise<[any]> | null
      },
      component: CreateFile,
      beforeEnter: async (to, from, next) => {
        // Run in parallel
        to.meta.promises = Promise.all([tagsStore.fetchEntries(), propertiesStore.fetchProperties()]);
        next();
      }

In Component

    private async created() {
       // check for null....etc
        await (this.$route.meta.promises as Promise<[any]>);
        this.initialized= true; // Stops loading overlay
    }

This can easily be improved upon and setup to rely on a configuration. Good stuff.

posva commented 3 years ago

Given the current state of this issue, there hasn't been any interest in implementing it in core but it seems some people like it while others don't. Therefore, it would be a nice addition as a plugin

If anybody still thinks this is worth including in core, open a discussion or an RFC in the vuejs/rfcs repository to continue the discussion!