Portals
Breaking out of the component tree
with portal-vue
Thorsten Lünborg
github.com/linusborg // @linus_borg
whoami
- Vue core team member since ~06/2016
- "That guy on the forum" answering your questions
- Programming is not my job, but my passion
- Twitter: @linus_borg
- Github: @linusborg
What I'll be talking about
- What portal-vue does
- Why & where this it useful
- How I made it work
- Caveats and things that don't work (yet)
- What I plan to do next
So what's
portal-vue?
portal-vue is a Portal Component for Vue.js, for rendering DOM outside of a component, anywhere in the document.
<portal name="source" to="destination">
<p>Hi, I'm {{ name }}!</p>
</portal>
Simple Portal Example
Sending slot content from source to destination
<portal-target name="destination" />
<div class="v-portal"/>
<div class="v-portal-target">
<p>Hi, I'm Thorsten!</p>
</div>
renders
renders
<portal name="source" to="destination">
<template slot-scope="{ items }">
<li v-for="item in items">{{ item }}</li>
</template>
</portal>
...with scope!
Sending scoped slot content from source to destination
<!-- Imagine "items" to be an array of fruit names -->
<portal-target
tag="ul"
name="destination"
slot-props="items"
/>
<div class="v-portal"/>
<ul class="v-portal-target">
<li>Strawberry</li>
<li>Banana</li>
<li>Tomato</li>
</ul>
renders
renders
<portal name="sourceTwo" to="destination"
order="2"
>
<p>And I'm {{ someOtherName }}!</p>
</portal>
<portal name="sourceOne" to="destination"
order="1"
>
<p>Hi, I'm {{ name }}!</p>
</portal>
What's better than one portal?
Multiple portals!!
<portal-target
name="destination"
multiple
/>
<div class="v-portal"/>
<div class="v-portal-target">
<p>Hi, I'm Thorsten!</p>
<p>and I'm Donald!</p>
</div>
renders
renders
<portal
to="destination"
target-el="#outside-of-app"
>
<p>And I'm {{ someOtherName }}!</p>
</portal>
Render to DOM outside of #app
especially useful for non-SPA apps
<div id="#outside-of-app">
</div>
<div class="v-portal"/>
<div class="v-portal-target">
<p>Hi, I'm Thorsten!</p>
</div>
renders
renders
Will change in 2.0!!
<portal
name="source"
to="destination"
slim
:disabled="toggle"
>
<p>Hi, I'm {{ name }}!</p>
</portal>
...and more options
i.e. disabling a portal, rendering without a wrapper el ...
<portal-target name="destination" />
<!-- disabled === true -->
<p>Hi, I'm Thorsten</p>
<div class="v-portal-target" />
renders
renders
...and the best thing is, all of this works globally, between components, so you can render stuff
from anywhere to anywhere
So what can we do with it?
- easily move modals to the end of <body>
- render to a sidebar from a page component
- render e.g. a button into a toolbar
- ...and even render stuff to a target outside of your Vue App
How it works
The Big Picture
Portal 1
Portal 2
Portal 3
The Wormhole
Target 1
Target 2
Target 3
Content 1
Content 2
Content 3
for Target 2
for Target 1
import Wormhole from './wormhole'
export default {
created() {
wormhole.open(...)
},
updated() {
wormhole.open(...)
},
beforeDestroy() {
wormhole.close()
},
render(h) {
return h('div', {
class: 'v-portal'
})
}
}
1. The Portal
// What we send to the Wormhole
{
to: 'destination',
from: 'source',
// we send the virtualDOM nodes
// to the wormhole
passengers: this.$slots.default,
order: Infinity
}
- ...sends its slot content to the wormhole
- ...renders an empty root node
import Vue from 'vue'
export new Vue({
data: {
transports: {}
},
methods: {
open(content) { ...},
close(name) { ... },
// some helper functions
// and secondary features...
}
})
2. The Wormhole
- is just a Vue instance
- is a global Singleton
- saves the received slot content with from and to information
- removes content when a portal closes
-
orders contents of a target in multiple mode
import Wormhole from './wormhole'
export default {
props: ['name'],
data: {
transports: wormhole.transports
},
computed: {
ownTransports: {
return this.transports[this.name]
},
},
render(h) {
return h('div', {}, [
// here we return the vnodes from
// the portal
...this.ownTransports.passengers
])
}
}
3. The Target
- reads virtualDOM nodes that were sent to it from the wormhole
- renders these nodes with a wrapper element
Challenges along the way...
...well, it's a bit
more complicated
- scoped slots have to be turned into vnodes
- contents from multiple portals have to be ordered and merged
- transitions have to be applied
- default content has to be taken care of
- Workarounds for breaking parent-child cascade are necessary
// portal.js
export default {
abstract: true
}
Making $parent work
- It worked until I added scoped slots (why? dunno...)
- So I played around to find a workaround
- which was to make it "dynamically" abstract
// portal.js
export default {
abstract: false,
render(h) {
this.$options.abstract = true
return h('div', { class: 'v-portal' }, [
// if "disabled", render scopedSlot locally
])
},
updated() {
this.sendUpdate() // wormhole.open()
if ( this.$options.abstract ) {
this.$options.abstract = false
}
}
}
something similar was necessary for portal-target
Yikes!
Caveats
Things to be aware of
<template>
<portal>
<p slot-scope="{ name }">
{{ name }}
</p>
</portal>
</template>
<script>
export default {
provide: {
name: 'Tom'
}
}
</script>
1. Provide/Inject don't work
<template>
<portal-target
slot-props="{ name: name }"
/>
</template>
<script>
export default {
// will be `undefined`
inject: ['name']
}
</script>
<template>
<portal>
<p ref="portalP">
{{ name }}
</p>
</portal>
</template>
<script>
export default {
data: {
name: 'Tom'
}
},
mounted() {
this.$nextTick(() => {
// may be undefined, depending
// if the portal renders
// before the target, or asynchrnously
this.$refs.portalP
})
}
</script>
2. Refs work, but are tricky
<template>
<portal-target
slot-props="{ name: name }"
/>
</template>
<script>
export default {
// will be `undefined`
inject: ['name']
}
</script>
<template>
<portal>
<div>
<slot>
</div>
</portal>
</template>
<script>
export default {
name: 'reuseablePortal'
}
</script>
3. not transparent as root element
<resuable-portal class="for-the-div">
<p>some content</p>
</reusable-portal>
<!--
- this will not be applied to the <div>
- it will be applied to the <portal>
- because of the `abstract` hack (would work for
a truly abstract component like <transition>
- `class` can't be passsed on with $attrs
-->
The Future...
Refresh the project setup
- Porting to vue-cli 3
- Rewrite in Typescript
- Porting docs to vuepress
- Write e2e test with Cypress
Rewrite the targetEl feature
- It's nor working well during re-mounts
- replaces mounting target
- bloats code of portal.js
so?
<!-- PortalWithTarget.js -->
<template functional>
<TargetProvider target-el="targetEl">
<portal
slot-props="{ name }" :to="name" v-bind="$attrs">
<slot />
</portal>
</TargetRpovider>
</template>
<script>
export default {
name: 'PortalWithTarget',
props: ['targetEl']
}
</script>
Move logic into renderless component
Logic for mounting portal-target etc will be wrapped in internal TargetProvider component
...and features and bugfixes of course
- Fix 2-3 open issues
- Attempt to solve $parent problem
- register portals and target instances with the wormhole to prevent naming collisions
- take advantage of computed props in wormhole to provide reactive updating possibilities for other components
Questions?
Add the 100th star and win a beer!
Portals - Breaking out of the Component Tree with portal-vue
By Thorsten Lünborg
Portals - Breaking out of the Component Tree with portal-vue
as presented at Karlsruhe Vue.js User Group, 27.07.2018
- 1,579