I'm trying to use a shared vue.js state in a web extension.
The state is stored in the background script's DOM and rendered in a popup page.
First attempt
My first attempt was to use a simple store without vuex:
background.js
var store = {
count: 0
};
popup.js
browser.runtime.getBackgroundPage().then(bg => {
var store = bg.store;
var vue = new Vue({
el: '#app',
data: {
state: store
},
})
})
popup.html
<div id="app">
<p>{{ state.count }}</p>
<p>
<button #click="state.count++">+</button>
</p>
</div>
<script src="vue.js"></script>
<script src="popup.js"></script>
This works the first time the popup is opened (you can increment the counter and the value is updated) but when the popup is opened a second time, rendering fails with [Vue warn]: Error in render: "TypeError: can't access dead object". This seems to be caused by the fact that vue.js instance from the first popup modified the store by setting its own getter/setters, which have now been deleted since the first popup was closed, making the shared state unusable. This seems to be unavoidable, so I decided I'd give vuex a try.
Second attempt
background.js
var store = new Vuex.Store({
state: {
count: 0
},
mutations: {
increment: state => state.count++,
}
})
popup.js
browser.runtime.getBackgroundPage().then(bg => {
var store = bg.store;
var vue = new Vue({
el: '#app',
computed: {
count () {
return store.state.count
}
},
methods: {
increment () {
store.commit('increment')
},
}
});
})
popup.html
<div id="app">
<p>{{ count }}</p>
<p>
<button #click="increment">+</button>
</p>
</div>
<script src="vue.js"></script>
<script src="popup.js"></script>
Unfortunately, this doesn't work either. You can open the popup and see the current counter value, but incrementing it doesn't update the view (you need to reopen the popup to see the new value).
When taking the same code but with the store declared in popup.js, the code works as intended so this should work, but for some reason it doesn't
My questions:
Is vue.js unable to handle this use case at all?
If so, would other frameworks (angular, react, ...) work here?
This does not work because your vue instance is not the same in background and popup. So, when you get the state from background, the watchers on the state makes react the Vue inside the background page, not the view in popup.
You can achieve that by using the same store in background and popup and synchronizing the state between both. To synchronize the state, you can use the excellent plugin vuex-shared-mutations which uses localStorage to propagate mutations through different instances of a store. In your store, add
import createMutationsSharer from 'vuex-shared-mutations'
//...
export default new Vuex.Store({
//...
plugins: [createMutationsSharer({ predicate: ['increment'] })],
});
Now your popup is reacting to the button and your background is incremented. If you reopen the popup, count is 0 because you created a new store. You now need to load the initial state when the popup is initialized :
store.js :
export default new Vuex.Store({
state: {
count: 0
},
mutations: {
increment: state => state.count++,
setCount (state, count) {
state.count = count
},
},
plugins: [createMutationsSharer({ predicate: ['increment'] })],
actions: {
getCount ({ commit }) {
browser.runtime.sendMessage({type: "storeinit", key: "count"}).then(count => {
commit('setCount', count)
})
}
}
});
background.js
import store from './store';
browser.runtime.onMessage.addListener((message, sender) => {
if (message.type === 'storeinit') {
return Promise.resolve(store.state[message.key]);
}
});
popup.js
import store from '../store';
var vue = new Vue({
//...
created () {
this.$store.dispatch('getCount')
}
});
These difficulties are not vue related, react users have use a proxy to propagate state in browser extension : react-chrome-redux
Sorry for the delay, I create a node module for it:
https://github.com/MitsuhaKitsune/vuex-webextensions
The module use the webextensions messaging API for sync all store instances on the webextension.
The install are like another vuex plugins, you can check it on the Readme.
If you have any question or feedback just tell me here or on github issues.
Related
Lets say I built an app, that fetches data through axios.get from a database every time it loads or is refreshed. This makes the app slow, so I want to only get the data in the initial load of the app and put it into sessionStorage.
Problem is that sessionStorage is not reactive. When I do this:
const state = reactive({
image: sessionStorage.image
})
and want to render:
<div class="home-image" :style="{'background-image': 'url(' + state.image + ')'}"></div>
it only works, if the sessionStorage has been updated in the previous page load. For the initial page load state.image throws a 404 not found.
Is there any way to make sessionStorage reactive? I've read about watchers, set and get but can't quite figure it out.
Solution (new):
Aaaaactually, I just found a way to make sessionStorage behave as if it was reactive during initial load and I don't even need vuex for it:
<script setup>
import {reactive} from 'vue';
const state = reactive({
image: sessionStorage.image || ""
})
axios.get('/home')
.then(res => {
const data = res.data[0]
state.image = sessionStorage.image = 'storage/' + data['image']
})
</script>
This way the reactive function chooses an empty string, if sessionStorage is undefined during the initial load and assigns value from axios.get to sessionStorage. On all consecutive page loads, the reactive function uses sessionStorage, which now has a value assigned to it.
Coding is fun. Sometimes you learn how to replace 100 lines of code with 1 LOC.
Solution (old):
Ok, now my storage works how I want it to. It is global, reactive, persistent and easy to use. I will accept #Elsa's answer because she helped me look into vuex.
I created a store in a seperate store.js file:
import {createStore} from "vuex";
const store = createStore({
state() {
return {
image: sessionStorage.image || ""
}
}
})
export default store
then register it in app.js:
require('./bootstrap');
import {createApp} from 'vue';
import app from "../vue/app";
import store from "./store";
createApp(app)
.use(store)
.mount("#app");
and now I can use store in my components like so:
<template>
<section id="home" class="home">
<div class="image-container">
<div class="home-image" :style="{'background-image': 'url(' + store.state.image + ')'}"></div>
</div>
</section>
</template>
<script setup>
import {useStore} from "vuex";
const store = useStore()
if (!sessionStorage.image) {
axios.get('/home')
.then(res => {
const data = res.data[0]
store.state.image = sessionStorage.image = 'storage/' + data['image']
})
}
</script>
The axios request runs only if sessionStorage.image is undefined, otherwise its value is rendered directly. If the link exists, the image does not get newly loaded in the template first, but rendered instantly.
I might omit state = reactive completely now, since I can use store.state instead globally and even link it to ss/ls. The only thing I have to maintain is the fields inside:
const store = createStore({
state() {
return {
image: sessionStorage.image || ""
}
}
})
because vue throws 404 errors if I don't (but renders the elements anyway because of reactivity).
I had this problem a week ago and I try 3 ways to make a sessionStorage reactive in vue3.
1.vuex
2.create event listener
3.setInterval
I found a temporary solution for my problem with setInterval.
1.vuex
const state = () => {
return {
latInfo: sessionStorage.getItem('lat') || 0
}
}
const getters = {
latInfo: state => state.latInfo
}
3.setInterval
setup() {
setInterval(() => {
if (infoData) {
infoData.lat = sessionStorage.getItem('lat')
}
}, 1000)
Firstly, sessionStorage cannot be reactive.
If my understanding correct, you just want a global variable to be reactive. You can simply achieve it via provide and inject:
const yourGlobalVar = sessionStorage.getItem('image');
app.provide('yourImage', ref(yourGlobalVar))
Where used it:
setup(){
...
const image = inject('yourImage');
...
return {image}
}
(I spied some similar questions, but none seemed to address my problem, however please refer if I missed something.)
I'm using Vue, Vuex, and Vuetify. Working from the "Google Keep" example layout, I factored out the NavDrawer and AppBar components. I'm having some trouble getting the NavDrawer toggle to work, however.
Before implementing Vuex, I used props and events, going through the parent component. With Vuex, my code is as follows:
main.js:
const store = new Vuex.Store({
state: {
drawerState: null
},
mutations: {
toggleDrawerState(state) {
state.drawerState = !state.drawerState
}
}
})
AppBar.vue:
<template>
<v-app-bar app clipped-left class="primary">
<v-app-bar-nav-icon #click="toggleDrawer()"/>
</v-app-bar>
</template>
<script>
export default {
name: 'AppBar',
methods: {
toggleDrawer: function() {
this.$store.commit('toggleDrawerState')
}
}
}
</script>
NavigationDrawer.vue:
<template>
<v-navigation-drawer v-model="drawerState" app clipped color="grey lighten-4">
<!-- content -->
</v-navigation-drawer>
</template>
<script>
export default {
name: 'NavigationDrawer',
computed: {
drawerState: {
get: function() { return this.$store.state.drawerState },
set: () => null
}
}
}
</script>
The set: () => null in the NavDrawer's computed properties is because I at first set it to call the mutation, which resulted in a feedback loop of toggling.
Now, my problem is that, given an initial v-model value of null, Vuetify has the Drawer open on desktop and closed on mobile. And when the drawerState = !drawerState is called, the null is made true, but that just keeps the drawer open on desktop, meaning that the button has to be clicked again to close the drawer. After that initial double-trigger problem, it works fine on desktop. On mobile, however, it always needs two triggers. (I say mobile, but really I just resized my browser window down.) I assume this is because when resizing (or on load), the drawer automatically hides, but has no way to update the Vuex store boolean, meaning that a double trigger is necessary.
My question is thus: what is the standard or best-practice way to implement Vuetify's navdrawer, so as to toggle it from another component? I thought that the state (whether open or closed) might be directly stored, but there are no "opened" or "closed" events to access it by. (E.g. this question has no answers.) It works fine on the example page, but how can that be adapted to work as child components?
Thanks!
It's a good option to use Vuex getters and refer to them in your computed getter, as well as retrieving the new value in the computed setter and using that to commit the mutation in the store. So your store would become:
const store = new Vuex.Store({
state: {
drawerState: false
},
mutations: {
toggleDrawerState (state, data) {
state.drawerState = data
}
},
getters : {
drawerState: (state) => state.drawerState
}
})
And your navigation drawer component would become:
<template>
<v-navigation-drawer v-model="drawerState" app clipped color="grey lighten-4">
<!-- content -->
</v-navigation-drawer>
</template>
<script>
export default {
name: 'NavigationDrawer',
computed: {
drawerState: {
get () { return this.$store.getters.drawerState },
set (v) { return this.$store.commit('toggleDrawerState', v) }
}
}
}
</script>
Here is a codepen to show a full example:
https://codepen.io/JamieCurnow/pen/wvaZeRe
In the component of Vue.js application I take information from the Vuex storage. Inside that component, I want to show v-overlay (preloader) until the data from storage will not be available. How correctly to make it?
<template>
<v-navigation-drawer
v-model="open"
absolute
right>
<v-overlay
:absolute="absolute"
:opacity="opacity"
:value="overlay">
<v-progress-circular
indeterminate
size="64">
</v-progress-circular>
</v-overlay>
<v-checkbox
v-if="!overlay"
hide-details
v-model="selectedGenders"
v-for="gender in genders"
:label="gender"
:value="gender"
:key="gender">
</v-checkbox>
<v-checkbox
v-if="!overlay"
hide-details
v-model="selectedIncomeRanges"
v-for="incomeRange in incomeRanges"
:label="incomeRange"
:value="incomeRange"
:key="incomeRange">
</v-checkbox>
</v-navigation-drawer>
</template>
<script>
import {
mapGetters,
mapActions
} from 'vuex'
export default {
name: 'RightNavigationDrawer',
props: {
open: {
type: Boolean,
default: false
}
},
data () {
return {
absolute: true,
opacity: 0.8,
overlay: true,
selectedGenders: [],
selectedIncomeRanges: []
}
},
mounted () {
this.getGenders()
this.getIncomeRanges()
},
computed: mapGetters('customStore', [
'genders',
'incomeRanges'
]),
methods: mapActions('customStore', [
'getGenders',
'getIncomeRanges'
])
}
</script>
Ideally, you would track the loading status in vuex so that it's available in all of your components, like your other vuex state. In your store, create a loading boolean state. Next, create a loading action that will be called in the component like:
loadData({ commit, dispatch }) {
commit('SET_LOADING', true);
const loader1 = dispatch('getGenders')
const loader2 = dispatch('getIncomeRanges')
Promise.all([loader1, loader2]).then(() => {
commit('SET_LOADING', false);
})
}
Promise.all takes an array of promises from your loading actions and will not resolve until all of those promises have resolved. Just make sure that your getGenders and getIncomeRanges actions return promises as well. Now, in your component, map only loading and loadData:
...mapState('customStore', ['loading']),
...mapActions('customStore', ['loadData'])
Change mounted to call this action:
mounted() {
this.loadData()
}
Now you can check loading anywhere instead of overlay in all of your components. This is a superior pattern because now loading is stored only once in vuex with your other state, and is available in all of your components, rather than being managed and passed locally.
Here is a demo where I'm simulating AJAX calls with a timeout. (The example uses a single file to manage vuex + vue so it will look slightly different, but shouldn't be too hard to follow.)
Put a watch on overlay (getter from Vuex storage).Let say the value is this.overlay = true
watch: {
overlay (val) {
val && setTimeout(() => {
this.overlay = false
}, 3000)
},
},
Follow code pen
Listen for custom events for the bus in component b. However, after dispatching events in component a, it accesses component b. the listening function of component b is executed, but msg of data function is not updated
Please don't say Vuex.
The relevant code is based on Vue CLi3
Here code:
Component A:
<template>
<div>
Component A
<button #click="sendMsg">pushB</button>
</div>
</template>
<script>
import bus from './bus'
export default {
methods: {
sendMsg() {
bus.$emit('send', 'hello Component B')
this.$router.push('/bbb')
}
}
}
</script>
component B:
<template>
<div>
<p>component B:{{ msg }}</p>
</div>
</template>
<script type="text/javascript">
import bus from './bus'
export default {
data () {
return {
msg: 'bbb'
}
},
mounted () {
bus.$on('send', data => {
console.log(data)
console.log(this)
this.msg = data
})
}
}
</script>
bus.js
import Vue from 'vue';
export default new Vue()
router:
const aaa = () => import('#/components/demo/bus/a')
const bbb = () => import('#/components/demo/bus/b')
export default new Router({
routes: [{
path: '/aaa',
component: aaa
},
{
path: '/bbb',
component: bbb
}]
})
I tried using 'watch' to observe 'msg', but it didn't work.
Can you help me?
If possible, I would like to deeply understand 'bus'
This will work only if both component A and component B are present in the page at the time you are emitting. From the code it seems that you are emitting the value from component A and then navigating to component B and expecting the value there.
What you are doing is something like kicking a ball and then running after it and then picking it only to find that the ball has disappeared. What you need is another person already present at that location who picks up the ball.
A solution in this case can be to set the value in localstorage, navigate to the other route and then read the value from localstorage.
If the value you need to pass is a simple value, you can just pass it in query string and then read from $router params in component B.
Your code will not work as expected as your are changing route after emitting event from Component A. So it can't be catch by Component B.
You can save the changed value in mixing look here for mixins or use localstorage. And you can also use query string as stated in previous answer
When a user tries to directly navigate load a component url, an http call is made in my vuex actions, which will define a value in my state once it resolves.
I don't want to load my component until the http call is resolved, and the state value is defined.
For Example, in my component
export default {
computed: {
...mapState({
// ** this value needs to load before component mounted() runs **
asyncListValues: state => state.asyncListValues
})
},
mounted () {
// ** I need asyncListValues to be defined before this runs **
this.asyncListValues.forEach((val) => {
// do stuff
});
}
}
How can I make my component wait for asyncListValues to load, before loading my component?
One way to do it is to store state values.
For example, if your store relies on single API, you would do something like this. However, for multiple APIs, it's a good idea to store each api load state individually, or using a dedicated object for each API.
There are usualy 4 states that you can have, which I prefer to have in a globally accessible module:
// enums.js
export default {
INIT: 0,
LOADING: 1,
ERROR: 2,
LOADED: 3
};
Then, you can have the variable stored in the vuex state, where the apiState is initialized with INIT. you can also initialize the array with [], but that shouldn't be necessary.
import ENUM from "#/enums";
// store.js
export default new Vuex.Store({
state: {
apiState: ENUM.INIT,
accounts: [],
// ...other state
},
mutations: {
updateAccounts (state, accounts) {
state.accounts = accounts;
state.apiState = ENUM.LOADED;
},
setApiState (state, apiState) {
state.apiState = apiState;
},
},
actions: {
loadAccounts ({commit) {
commit('setApiState', ENUM.LOADING);
someFetchInterface()
.then(data=>commit('updateAccounts', data))
.catch(err=>commit('setApiState', ENUM.ERROR))
}
}
});
Then, by adding some computed variables, you can toggle which component is shown. The benefit of using state is that you can easily identify the Error state, and show a loading animation when state is not ready.
<template>
<ChildComponent v-if="apiStateLoaded"/>
<Loader v-if="apiStateLoading"/>
<Error v-if="apiStateError"/>
</template>
<script>
import ENUM from "#/enums";
export default {
computed: {
...mapState({
apiState: state=> state.apiState
}),
apiStateLoaded() {
return this.apiState === ENUM.LOADED;
},
apiStateLoading() {
return this.apiState === ENUM.LOADING || this.apiState === ENUM.INIT;
},
apiStateError() {
return this.apiState === ENUM.ERROR;
},
})
}
</script>
aside... I use this pattern to manage my applications as a state machine. While this example utilizes vuex, it can be adapted to use in a component, using Vue.observable (vue2.6+) or ref (vue3).
Alternatively, if you just initialize your asyncListValues in the store with an empty array [], you can avoid errors that expect an array.
Since you mentioned vue-router in your question, you can use beforeRouteEnter which is made to defer the rendering of a component.
For example, if you have a route called "photo":
import Photo from "../page/Photo.vue";
new VueRouter({
mode: "history",
routes: [
{ name: "home", path: "/", component: Home },
{ name: "photo", path: "/photo", component: Photo }
]
});
You can use the beforeRouteEnter like this:
<template>
<div>
Photo rendered here
</div>
</template>
<script>
export default {
beforeRouteEnter: async function(to, from, next) {
try {
await this.$store.dispatch("longRuningHttpCall");
next();
} catch(exception) {
next(exception);
}
}
}
</script>
What it does is, waiting for the action to finish, updating your state like you want, and then the call to next() will tell the router to continue the process (rendering the component inside the <router-view></router-view>).
Tell me if you need an ES6-less example (if you do not use this syntax for example).
You can check the official documentation of beforeRouteEnter on this page, you will also discover you can also put it at the route level using beforeEnter.
One approach would be to split your component into two different components. Your new parent component could handle fetching the data and rendering the child component once the data is ready.
ParentComponent.vue
<template>
<child-component v-if="asyncListValues && asyncListValues.length" :asyncListValues="asyncListValues"/>
<div v-else>Placeholder</div>
</template>
<script>
export default {
computed: {
...mapState({
asyncListValues: state => state.asyncListValues
})
}
}
</script>
ChildComponent.vue
export default {
props: ["asyncListValues"],
mounted () {
this.asyncListValues.forEach((val) => {
// do stuff
});
}
}
Simple way for me:
...
watch: {
vuexvalue(newVal) {
if (newVal == 'XXXX')
this.loadData()
}
}
},
computed: {
...mapGetters(['vuexvalue'])
}
Building on some of the other answers, if you're using Router, you can solve the problem by only calling RouterView when the state has been loaded.
Start with #daniel's approach of setting a stateLoaded flag when the state has been loaded. I'll just keep it simple here with a two-state flag, but you can elaborate as you like:
const store = createStore({
state () {
return {
mysettings: {}, // whatever state you need
stateLoaded: false,
}
},
mutations: {
set_state (state, new_settings) {
state.settings = new_settings;
state.stateLoaded = true;
},
}
}
Then, in app.vue you'll have something like this:
<div class="content">
<RouterView/>
</div>
Change this to:
<div class="content">
<RouterView v-if="this.$store.state.stateLoaded"/>
</div>
The v-if won't even attempt to do anything with RouterView until the (reactive) stateLoaded flag goes true. Therefore, anything you're rendering with the Router won't get called, and so there won't be any undefined state variables in it when it does get loaded.
You can of course build on this with a v-else to perhaps show a "Loading..." screen or something, just in case the state loading takes longer than expected. Using #daniel's multi-state flag, you could even report if there was a problem loading the state, and offer a Retry button or something.