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?

You can find portal-vue here:

 

https://github.com/LinusBorg/portal-vue

Add the 100th star and win a beer!

Made with Slides.com