Do Vue.js render functions allow return of an array of VNodes? - vue.js

I am working on extending a Vue.js frontend application. I am currently inspecting a render function within a functional component. After looking over the docs, I had the current understanding that the render function within the functional component will return a single VNode created with CreateElement aka h.
My confusion came when I saw a VNode being returned as an element in an array. I could not find any reference to this syntax in the docs. Does anyone have any insight?
export default {
name: 'OfferModule',
functional: true,
props: {
data: Object,
placementInt: Number,
len: Number
},
render (h, ctx) {
let adunitTheme = []
const isDev = str => (process.env.dev ? str : '')
const i = parseInt(ctx.props.placementInt)
const isDevice = ctx.props.data.Component === 'Device'
const Component = isDevice ? Device : Adunit
/* device helper classes */
const adunitWrapper = ctx.props.data.Decorate?.CodeName === 'AdunitWrapper'
if (!isDevice /* is Adunit */) {
const offerTypeInt = ctx.props.data.OfferType
adunitTheme = [
'adunit-themes',
`adunit-themes--${type}`.toLowerCase(),
`adunit-themes--${theme}`.toLowerCase(),
`adunit-themes--${type}-${theme}`.toLowerCase(),
]
}
const renderOfferModuleWithoutDisplayAdContainersWithAboveTemplate =
ctx.props.data.Decorate?.Position === 'AboveAdunit' || false
const renderOfferModuleWithoutDisplayAdContainers =
ctx.props.data.Decorate?.RemoveAds /* for adunits */ ||
ctx.props.data.DeviceData?.RemoveAds /* for devices */ ||
false
const getStyle = (className) => {
try {
return ctx.parent.$style[className]
} catch (error) {
console.log('$test', 'invalid style not found on parent selector')
}
}
const PrimaryOfferModule = (aboveAdunitSlot = {}) =>
h(Component, {
props: {
data: ctx.props.data,
itemIndex: i,
adunitTheme: adunitTheme.join('.')
},
attrs: {
class: [
...adunitTheme,
getStyle('product')
]
.join(' ')
.trim()
},
scopedSlots: {
...aboveAdunitSlot
}
})
if (renderOfferModuleWithoutDisplayAdContainersWithAboveTemplate) {
return [
PrimaryOfferModule({
aboveAdunit (props) {
return h({
data () {
return ctx.props.data.Decorate
},
template: ctx.props.data.Decorate?.Template.replace(
'v-show="false"',
''
)
})
}
})
]
} else if (renderOfferModuleWithoutDisplayAdContainers) {
return [PrimaryOfferModule()]
} else {
const withAd = i > 0 && i % 1 === 0
const adWrap = (placement, position, className) => {
return h(
'div',
{
class: 'm4d-wrap-sticky'
},
[
h(Advertisement, {
props: {
placement,
position: String(position)
},
class: getStyle(className)
})
]
)
}
return [
withAd && adWrap('inline-sticky', i, 'inlineAd'),
h('div', {
class: 'm4d-wrap-sticky-adjacent'
}),
h(
'div',
{
attrs: {
id: `inline-device--${String(i)}`
},
class: 'inline-device'
},
isDev(`inline-device id#: inline-device--${String(i)}`)
),
withAd &&
i !== ctx.props.len - 1 &&
h(EcomAdvertisement, {
props: {
placement: 'inline-static',
position: String(i)
},
class: getStyle('inlineStaticAd')
}),
PrimaryOfferModule()
]
}
}
}

It turns out that returning an array of VNodes actually predates the scopedSlots update.
I couldn't find it documented anywhere in the docs either, but via this comment on a Vue GitHub issue by a member of the Vue.js core team (which predates the scopedSlots commit by ~1 year), render() can return an Array of VNodes, which Vue will take and render in order. However, this only works in one, singular case: functional components.
Trying to return an array of VNodes with greater than 1 element in a normal (non-functional, stateful) component results in an error:
Vue.config.productionTip = false;
Vue.config.devtools = false;
Vue.component('render-func-test', {
render(h, ctx) {
return [
h('h1', "I'm a heading"),
h('h2', "I'm a lesser heading"),
h('h3', "I'm an even lesser heading")
];
},
});
new Vue({
el: '#app',
});
<script src="https://unpkg.com/vue#2/dist/vue.js"></script>
<div id="app">
Test
<render-func-test></render-func-test>
</div>
[Vue warn]: Multiple root nodes returned from render function. Render function should return a single root node.
But doing this in a functional component, as your example does, works just fine:
Vue.config.productionTip = false;
Vue.config.devtools = false;
Vue.component('render-func-test', {
functional: true, // <--- This is the key
render(h, ctx) {
return [
h('h1', "I'm a heading"),
h('h2', "I'm a lesser heading"),
h('h3', "I'm an even lesser heading")
];
},
});
new Vue({
el: '#app',
});
<script src="https://unpkg.com/vue#2/dist/vue.js"></script>
<div id="app">
Test
<render-func-test></render-func-test>
</div>
If you're interested in the why, another member of the Vue core team explained this limitation further down in the thread.
It basically boils down to assumptions made by the Vue patching and diffing algorithm, with the main one being that "each child component is represented in its parent virtual DOM by a single VNode", which is untrue if multiple root nodes are allowed.
The increase in complexity to allow this would require large changes to that algorithm which is at the very core of Vue. This is a big deal, since this algorithm must not only be good at what it does, but also very, very performant.
Functional components don't need to conform to this restriction, because "they are not represented with a VNode in the parent, since they don't have an instance and don't manage their own virtual DOM"– they're stateless, which makes the restriction unnecessary.
It should be noted, however, that this is possible on non-functional components in Vue 3, as the algorithm in question was reworked to allow it.

It seems this was implemented in:
https://github.com/vuejs/vue/commit/c7c13c2a156269d29fd9c9f8f6a3e53a2f2cac3d
This was a result of an issue raised in 2018 (https://github.com/vuejs/vue/issues/8056) , because this.$scopedSlots.default() returned both a VNode or an array of VNodes depending on the content.
The main argument was that this is inconsistent with how regular slots behave in render functions, and means any render function component rendering scoped slots as children needs to type check the result of invoking the slot to decide if it needs to be wrapped in an array
So Evan comments on the issue thread here, explaining that this.$scopedSlots.default would always return Arrays beginning v2.6 to allow for consistency, but to avoid breaking changes for how $scopedSlots was being used, the update would also allow return of an Array of a single VNode from render functions as well.

Related

Why it is hard to use vue-i18n in vue data() (why it is not reactive)

I am using vue-i18n in a vue project. And I found it really confusing when using some data in vue data with i18n. Then if I change locale, that data is not reactive. I tried to return that data from another computed data but anyways it is not reactive because i18n is written in data. *My situation - * I want to show table with dropdown(list of columns with checkbox) above it. When user checks a column it will be showed in table if unchecks it won't. It is working fine until I change locale. After changing locale table columns is not translated but dropdown items is reactively translated and my code won't work anymore. Here is some code to explain better: In my myTable.vue component I use bootstrap-vue table -
template in myTable.vue
<vs-dropdown vs-custom-content vs-trigger-click>
<b-link href.prevent class="card-header-action btn-setting" style="font-size: 1.4em">
<i class="fa fa-th"></i>
</b-link>
<vs-dropdown-menu class="columns-dropdown">
<visible-columns :default-fields="columns" #result="columnListener"></visible-columns>
</vs-dropdown-menu>
</vs-dropdown>
<b-table class="generalTableClass table-responsive" :fields="computedFieldsForTable">custom content goes here</b-table>
script in myTable.vue
data(){
return {
fieldsForTable: [];
}
},
computed: {
computedFieldsForTable () {
return this.fieldsForTable;
},
columns() {
return [
{
key: 'id',
label: this.$t('id'),,
visible: true,
changeable: true
},
{
key: 'fullName',
label: this.$t('full-name'),,
visible: true,
changeable: true
},
{
key: 'email',
label: this.$t('email'),,
visible: true,
changeable: true
}
]
}
},
mounted () {
this.fieldsForTable = this.filterColumns(this.columns);
},
methods: {
filterColumns(columns = []) {
return columns.filter(column => {
if (column.visible) {
return column
}
})
},
columnListener ($event) {
this.fieldsForTable = this.filterColumns($event)
}
}
Can someone give me some advice for this situation ?
*EDIT AFTER SOME DEBUGGING: I think when filtering columns(in computed) and returning it for fieldsForTable inside filterColumns(columns) method, it actually returning array(of objects) with label='Label Name' not label=this.$t('labelName'). So after filtering the new array has nothing to do with vue-i18n. My last chance is reloading the page when locale changes.
Trying modify computedFieldsForTable as follows. You need to reference this.columns in computedFieldsForTable, so that Vue can detect the change of labels in this.columns.
computedFieldsForTable () {
return this.filterColumns(this.columns);
},
EDITED: put your this.columns in data. Then
columnListener ($event) {
this.columns = $event;
}
I hope i didn't misunderstand what you mean.
EDITED (again):
Maybe this is the last chance that I think it can work. Put columns in computed() still and remove computedFieldsForTable. Finally, just leave fieldsForTable and bind it on fields of <b-table>.
watch: {
columns(val) {
this.fieldsForTable = this.filterColumns(val)
}
},
method: {
columnListener ($event) {
this.fieldsForTable = this.filterColumns($event)
}
}
However, I think it is better and easier to reload page whenever local change. Especially when your columns have a more complex data structure.

Using $refs in a computed property

How do I access $refs inside computed? It's always undefined the first time the computed property is run.
Going to answer my own question here, I couldn't find a satisfactory answer anywhere else. Sometimes you just need access to a dom element to make some calculations. Hopefully this is helpful to others.
I had to trick Vue to update the computed property once the component was mounted.
Vue.component('my-component', {
data(){
return {
isMounted: false
}
},
computed:{
property(){
if(!this.isMounted)
return;
// this.$refs is available
}
},
mounted(){
this.isMounted = true;
}
})
I think it is important to quote the Vue js guide:
$refs are only populated after the component has been rendered, and they are not reactive. It is only meant as an escape hatch for direct child manipulation - you should avoid accessing $refs from within templates or computed properties.
It is therefore not something you're supposed to do, although you can always hack your way around it.
If you need the $refs after an v-if you could use the updated() hook.
<div v-if="myProp"></div>
updated() {
if (!this.myProp) return;
/// this.$refs is available
},
I just came with this same problem and realized that this is the type of situation that computed properties will not work.
According to the current documentation (https://v2.vuejs.org/v2/guide/computed.html):
"[...]Instead of a computed property, we can define the same function as a method. For the end result, the two approaches are indeed exactly the same. However, the difference is that computed properties are cached based on their reactive dependencies. A computed property will only re-evaluate when some of its reactive dependencies have changed"
So, what (probably) happen in these situations is that finishing the mounted lifecycle of the component and setting the refs doesn't count as a reactive change on the dependencies of the computed property.
For example, in my case I have a button that need to be disabled when there is no selected row in my ref table.
So, this code will not work:
<button :disabled="!anySelected">Test</button>
computed: {
anySelected () {
if (!this.$refs.table) return false
return this.$refs.table.selected.length > 0
}
}
What you can do is replace the computed property to a method, and that should work properly:
<button :disabled="!anySelected()">Test</button>
methods: {
anySelected () {
if (!this.$refs.table) return false
return this.$refs.table.selected.length > 0
}
}
For others users like me that need just pass some data to prop, I used data instead of computed
Vue.component('my-component', {
data(){
return {
myProp: null
}
},
mounted(){
this.myProp= 'hello'
//$refs is available
// this.myProp is reactive, bind will work to property
}
})
Use property binding if you want. :disabled prop is reactive in this case
<button :disabled="$refs.email ? $refs.email.$v.$invalid : true">Login</button>
But to check two fields i found no other way as dummy method:
<button :disabled="$refs.password ? checkIsValid($refs.email.$v.$invalid, $refs.password.$v.$invalid) : true">
{{data.submitButton.value}}
</button>
methods: {
checkIsValid(email, password) {
return email || password;
}
}
I was in a similar situation and I fixed it with:
data: () => {
return {
foo: null,
}, // data
And then you watch the variable:
watch: {
foo: function() {
if(this.$refs)
this.myVideo = this.$refs.webcam.$el;
return null;
},
} // watch
Notice the if that evaluates the existence of this.$refs and when it changes you get your data.
What I did is to store the references into a data property. Then, I populate this data attribute in mounted event.
data() {
return {
childComps: [] // reference to child comps
}
},
methods: {
// method to populate the data array
getChildComponent() {
var listComps = [];
if (this.$refs && this.$refs.childComps) {
this.$refs.childComps.forEach(comp => {
listComps.push(comp);
});
}
return this.childComps = listComps;
}
},
mounted() {
// Populates only when it is mounted
this.getChildComponent();
},
computed: {
propBasedOnComps() {
var total = 0;
// reference not to $refs but to data childComps array
this.childComps.forEach(comp => {
total += comp.compPropOrMethod;
});
return total;
}
}
Another approach is to avoid $refs completely and just subscribe to events from the child component.
It requires an explicit setter in the child component, but it is reactive and not dependent on mount timing.
Parent component:
<script>
{
data() {
return {
childFoo: null,
}
}
}
</script>
<template>
<div>
<Child #foo="childFoo = $event" />
<!-- reacts to the child foo property -->
{{ childFoo }}
</div>
</template>
Child component:
{
data() {
const data = {
foo: null,
}
this.$emit('foo', data)
return data
},
emits: ['foo'],
methods: {
setFoo(foo) {
this.foo = foo
this.$emit('foo', foo)
}
}
}
<!-- template that calls setFoo e.g. on click -->

vue.js components - pass default values from parent (across multiple instances)

I am new to Vue.js and am trying to create components that will simplify form creation, based on a library I have been using for a while now (PHP).
I have created a component that renders a label + textbox, styled via Bootstrap.
In order to avoid having to pass all the parameters every time, I want to be able to define defaults from within the parent, so that they will stay in effect until changed.
The component looks like this (MyTextBox.vue)
<template>
<div v-bind:class="myDivWidth">
<label v-bind:class="`control-label ${myLabelWidth}`">{{label}}</label>
<div v-bind:class="myControlWidth">
<input class="form-control col-md-12" v-bind:value="value">
</div>
</div>
</template>
<script>
export default {
data: function() {
return {
// trying to use this as 'class' variable but most likely wrong
myDefaultLabelWidth: 4
}
},
props: {
label: String,
labelWidth: String,
controlWidth: String,
divWidth: String,
value: {required: false},
defaultLabelWidth: {type: String}
},
computed: {
myLabelWidth: function () {
let lw;
//debugger;
do {
if (typeof this.defaultLabelWidth !== 'undefined') {
lw = this.defaultLabelWidth;
// ****** Note the call to the parent function
this.$parent.setDefault('defaultLabelWidth', lw);
break;
}
if (typeof this.labelWidth !== 'undefined') {
lw = this.labelWidth;
break;
}
if (typeof this.lw !== 'undefined') {
lw = this.lw;
break;
}
// ****** Note the call to the parent function
lw = this.$parent.getDefault('defaultLabelWidth');
} while (false);
return `col-md-${lw}`;
},
// snip....
}
}
</script>
and it is used like this (I am only showing attributes relating to label, for brevity)
(StoryEditor.vue)
<my-textbox label="LableText1" default-label-width=4></my-textbox>
<my-textbox label="LableText2"></my-textbox>
<my-textbox label="LableText3" label-width=5></my-textbox>
<my-textbox label="LableText4"></my-textbox>
<my-textbox label="LableText5" default-label-width=6></my-textbox>
<my-textbox label="LableText6"></my-textbox>
<my-textbox label="LableText7"></my-textbox>
What this is meant to do, is set the label with to 4, for the first 2 instances
then force a width of 5 for the next instance
then go back to 4
then set a new default of 6 for the remaining 3 components.
This is useful in cases where a lot of components (of the same type) are used, most of which are of the same width.
This mechanism will also used for all other applicable attributes.
Please note that what is important here is that the default is set in the parent and can change between instances of the component.
(I am aware that I can have a default value in the template itself but, as I understand it, that would apply to all instances of that component)
Any help would be greatly appreciated!
[Edit]
I have found one solution:
I added these methods to the parent (StoryEditor.vue).
They are called by the component code, shown above with '******' in the comments
<script>
export default {
created: function () {
// make sure the variable exists
if (typeof window.defaultOptions === 'undefined') {
window.defaultOptions = {
defaultLabelWidth: 3,
defaultControlWidth: 7
};
}
},
data() {
return {
story: {
}
}
},
methods: {
getDefaultOptions: () => {
console.log('getDefaultOptions', window.defaultOptions);
},
setDefaultOptions: (opts) => {
window.defaultOptions = opts;
},
getDefault: (option) => {
console.log(' getDefault', window.defaultOptions);
return window.defaultOptions[option];
},
setDefault: (option, v) => {
window.defaultOptions[option] = v;
console.log('setDefault', window.defaultOptions);
}
}
}
</script>
This uses this.$parent. to call methods in the parent.
The parent then uses a window variable to store/retrieve the relevant parameters.
A window variable is used because I want to have a single variable that will be used by all instances of the component.

VueJS + VeeValidator + Multiple fields

Versions:
VueJs: 2.2.2
Vee-Validate: 2.0.0-beta.25
Description:
I'm wondering if there's a way to have a unique validator for multiple fields?
Typically, an Address form with 1 input fot the street, 1 for the number and 1 for the city
I want to make a validation on the combination of all the elements.
I've read the documentation but I can't find an exemple that could help me for that.
You could apply a custom validator to a custom component that contained all the fields you want to validate together. For example, you could build a location component (using location instead of address because address is an HTML5 element, and you cannot name a Vue component the same as an existing HTML element).
Vue.component("location", {
props:["value"],
template: "#location-template",
data(){
return {
location: this.value
}
},
methods:{
update(){
this.$emit('input', Object.assign({}, this.location))
}
},
})
Then you can build a validator for that component.
const locationValidator = {
currentLocation: null,
getMessage(field, args) {
if (!this.currentLocation.street)
return "Location requires a street";
if (!this.currentLocation.street_number)
return "Location requires a street_number";
if (!this.currentLocation.city)
return "Location requires a city";
},
validate(location, args) {
this.currentLocation = location;
if (!location.street || !location.street_number || !location.city)
return false;
return true
}
};
Finally, you can pull that together in your Vue.
new Vue({
el:"#app",
data:{
loc: {}
},
created(){
this.$validator.extend("location", locationValidator)
}
})
And your Vue template
<span v-show="errors.has('location')" style="color:red">{{ errors.first('location') }}</span>
<location v-validate="'location'" v-model="loc" data-vv-name="location"></location>
Here is an example.

VueJS and lodash, filtered array displays empty unless main array is utilized in template

My mixin:
export default {
data() {
return {
charges: [],
catCharges: [],
offenses: ['Class I Offenses', 'Class II Offenses', 'Class III Offenses', 'Class IV Offense']
}
},
methods: {
getCharges() {
axios.get('admin/charges').then((response) => {
this.charges = response.data;
for(let offense = 1; offense <= this.offenses.length; offense++) {
this.catCharges[offense - 1] = this.chargesAtOffense(offense);
}
});
},
chargesAtOffense(offense) {
return _.filter(this.charges, { offense_level: offense });
}
},
created() {
this.getCharges();
}
};
Fetching data works, the array 'charges' gets populated with the following:
After populating the array, I start looping over the offenses array and filter all 'charges' from the main array into the 'catCharges' array, so all offenses are split into 4 separated arrays in that array.
Chrome's developer tools shows the array just fine and the charges are properly filtered.
This is my component:
<template>
<div>
<h1>Total charges: {{charges.length}}</h1>
<h1>Total offense categories: {{catCharges.length}}</h1>
<div v-for="(charges, offenseIdx) in catCharges">
{{charges}}
</div>
</div>
</template>
<script>
import chargesMixin from '../mixins/chargesMixin';
export default {
mixins: [chargesMixin],
data() {
return {
}
},
methods: {
},
computed: {
},
mounted() {
console.log('Disciplinary Segregation mounted.')
}
}
</script>
It uses the mixin provided above, and IT works and shows the catCharges array properly, HOWEVER when I remove the following line from the template:
<h1>Total charges: {{charges.length}}</h1>
The catCharges array is displayed as EMPTY, why do I need to use the charges array too along with the filtered array? This is driving me crazy.
I also tried the following method in the mixin which also causes the same issue:
chargesAtOffense(offense) {
var newCharges = [];
for(var i = 0; i < this.charges.length; i++) {
if(this.charges[i].offense_level != offense) continue;
const cloned = _.clone(this.charges[i]);
newCharges.push(cloned);
}
return newCharges;
}
I think your use case is linked to the reactivity system of VueJS.
https://v2.vuejs.org/v2/guide/reactivity.html
If you delete the line
<h1>Total charges: {{charges.length}}</h1>
you tell to VueJS to refresh your template only on catCharges get / set.
catCharges is an array, and so it's not as 'reactive' as a simple variable.
If you read precisely https://v2.vuejs.org/v2/guide/list.html#Caveats, prefer use a push on your catCharges to explain correctly to Vue that your array has changed.
I'll try this code :
getCharges() {
axios.get('admin/charges').then((response) => {
this.charges = response.data;
for(let offense = 1; offense <= this.offenses.length; offense++) {
this.catCharges.push(this.chargesAtOffense(offense));
}
});
},
Hope this will solve your problem.