|
| 1 | +- Start Date: 2020-01-20 |
| 2 | +- Target Major Version: (3.x) |
| 3 | +- Reference Issues: (fill in existing related issues, if any) |
| 4 | +- Implementation PR: |
| 5 | + |
| 6 | +# Summary |
| 7 | + |
| 8 | +- Adds a `<portal>` component to Vue core |
| 9 | +- the component accepts a DOM selector via a prop |
| 10 | +- the component moves its children to the element identified by the DOM selector |
| 11 | +- At the virtual DOM level, the children stay descendants of the `<portal>` though, so they i.e. have access to injections from its ancestors |
| 12 | + |
| 13 | +# Basic example |
| 14 | + |
| 15 | +```html |
| 16 | +<body> |
| 17 | + <div id="app"> |
| 18 | + <h1>Move the #content with the portal component</h1> |
| 19 | + <portal target="#endofbody"> |
| 20 | + <div id="content"> |
| 21 | + <p> |
| 22 | + this will be moved to #endofbody.<br /> |
| 23 | + Pretend that it's a modal |
| 24 | + </p> |
| 25 | + <Child /> |
| 26 | + </div> |
| 27 | + </portal> |
| 28 | + </div> |
| 29 | + <div id="endofbody"></div> |
| 30 | + <script> |
| 31 | + const { Portal } = window.Vue; |
| 32 | + new Vue({ |
| 33 | + el: "#app", |
| 34 | + components: { |
| 35 | + Portal, |
| 36 | + Child: { template: "<div>Placeholder</div>" } |
| 37 | + } |
| 38 | + }); |
| 39 | + </script> |
| 40 | + <body></body> |
| 41 | +</body> |
| 42 | +``` |
| 43 | + |
| 44 | +This will result in the following behaviour: |
| 45 | + |
| 46 | +1. All of the children of `<portal>` - in this example: `<div id="content">` - will be appended to `<div id="endofbody">` |
| 47 | +2. the `<Child>` component as one of these children will remain a child component of the `<Portal>`'s parent (the Portal is transparent). |
| 48 | + |
| 49 | +```html |
| 50 | +<div id="app"> |
| 51 | + <!-- --> |
| 52 | +</div> |
| 53 | +<div id="endofbody"> |
| 54 | + <div id="content"> |
| 55 | + <p> |
| 56 | + this will be moved to #endofbody.<br /> |
| 57 | + Pretend that it's a modal |
| 58 | + </p> |
| 59 | + <div>Placeholder</div> |
| 60 | + </div> |
| 61 | +</div> |
| 62 | +``` |
| 63 | + |
| 64 | +# Motivation |
| 65 | + |
| 66 | +Vue encourages us to build our UIs by encapsulating UI and related behaviour into components, which we can nest inside one another to build a tree of components that make up your application UI. That model has proven itself in Vue and other frameworks in many ways, but there's one weakness this RFC seeks to address: |
| 67 | + |
| 68 | +Sometimes, a part of a component's template belongs into this component _logically_, while from a technical point of view (i.e.: styling requirements), it would be preferable to move this part of the template somewhere else in the DOM, breaking it out of it's deeply nested position without our DOM tree. |
| 69 | + |
| 70 | +## Use cases |
| 71 | + |
| 72 | +### z-Index |
| 73 | + |
| 74 | +The main use cases for such a behaviour are usually styling-related. Various common UI patterns such as modals, dialogs, dropdown menus, notifications etc. require fixed or absolute positioning and management of their z-index. |
| 75 | + |
| 76 | +In order to work around issues with [z-index Stacking Context](https://philipwalton.com/articles/what-no-one-told-you-about-z-index/) behaviour, it's a common pattern to put the DOM elements of those components right before the `</body>` tag in order to move them out of any parent element's z-index stacking context. |
| 77 | + |
| 78 | +### Widgets |
| 79 | + |
| 80 | +Many apps have the concept of widgets, where their UI has an outlet (i.e. in a sidebar or dashboard) where other parts of the application, i.e. plugins, can inject small pieces of UI. |
| 81 | + |
| 82 | +In Single Page Applications, where our Javascript controls essentially he whole page, this is generally not a challenge. But in situations where our Vue app only controls a part of the page, it currently proves to be challenging (but impossible) to mount individual elements and components in other parts of the page. |
| 83 | + |
| 84 | +With Portals, we have a straightforward way to mount child components to other locations in the DOM declaratively. |
| 85 | + |
| 86 | +# Detailed design |
| 87 | + |
| 88 | +## Globally imported `Portal` component |
| 89 | + |
| 90 | +The `'vue'` package has a named export for a `<Portal>` "component". |
| 91 | + |
| 92 | +Since this component doesn't have any component logic of its own (Portal functionality would be implemented at the virtual DOM level), so this could be a `Symbol` instead of a full component. |
| 93 | + |
| 94 | +```js |
| 95 | +import { Portal } from "vue"; |
| 96 | +export default { |
| 97 | + template: `<div> |
| 98 | + <portal target="#endofbody"> |
| 99 | + Some content. |
| 100 | + </portal> |
| 101 | + <div>`, |
| 102 | + components: { |
| 103 | + Portal |
| 104 | + } |
| 105 | +}; |
| 106 | +``` |
| 107 | + |
| 108 | +When using a render function, the component can be used directly without first registering it, like any other component: |
| 109 | + |
| 110 | +```js |
| 111 | +import { Portal, h } from "vue"; |
| 112 | +export default { |
| 113 | + render() { |
| 114 | + return h("div", [h(Portal, { target: "#endofbody" }, ["Some content"])]); |
| 115 | + }, |
| 116 | + // or with JSX: |
| 117 | + render() { |
| 118 | + <div> |
| 119 | + <Portal target="#endofbody">Some content</portal> |
| 120 | + </div>; |
| 121 | + } |
| 122 | +}; |
| 123 | +``` |
| 124 | + |
| 125 | +If used directly in the browser from a CDN, we can access the Portal as a property on the Vue constructor: |
| 126 | + |
| 127 | +```js |
| 128 | +const App = { |
| 129 | + template: `<div> |
| 130 | + <portal target="#endofbody"> |
| 131 | + Some content. |
| 132 | + </portal> |
| 133 | + <div>`, |
| 134 | + components: { |
| 135 | + Portal: Vue.Portal |
| 136 | + } |
| 137 | +}; |
| 138 | +``` |
| 139 | + |
| 140 | +## The `target` prop |
| 141 | + |
| 142 | +The component has only one _required_ prop, named `target`. It accepts a string wich has to be a valid query selector. |
| 143 | + |
| 144 | +```html |
| 145 | +<!-- ok --> |
| 146 | +<Portal target="#some-id"> |
| 147 | + <Portal target=".some-class"> |
| 148 | + <Portal target="[data-portal]"> |
| 149 | + <!-- |
| 150 | + probably too unspecific, but technically valid |
| 151 | + should we allow this or block it? |
| 152 | +--> |
| 153 | + <Portal target="h1"> |
| 154 | + <!-- Wrong --> |
| 155 | + <Portal target="some-string"></Portal></Portal></Portal></Portal |
| 156 | +></Portal> |
| 157 | +``` |
| 158 | + |
| 159 | +## Lifecycle |
| 160 | + |
| 161 | +### Mounting |
| 162 | + |
| 163 | +When the Portal component is mounted by its parent, it will use the `target` prop's value as a selector. |
| 164 | + |
| 165 | +- If the query returns an element, the slot children of the `Portal` will be mounted as child nodes of that element in the DOM |
| 166 | +- If this element doesn't exist in the DOM at the moment that this `<Portal>` is mounted, a warning will be logged during development (nothing would happen in production): |
| 167 | + |
| 168 | +```js |
| 169 | +`Portal could not be mounted to element with selector '${props.target}': element not found in DOM. |
| 170 | +
|
| 171 | +// following would be a display where in the component tree this happened etc. |
| 172 | +``` |
| 173 | + |
| 174 | +#### \$parent |
| 175 | + |
| 176 | +If the children of `Portal` contain any components, their `this.$parent` property should reference the `Portal`'s parent component. In other words, these components stay in their original spot in the _component tree_, even though they ended up mounted somewhere else in the _DOM tree_. |
| 177 | +
|
| 178 | +`Portal`, not being a real component at all, is transparent and will not appear as an ancestor in the `$parent`chain. |
| 179 | +
|
| 180 | +```html |
| 181 | +<template> |
| 182 | + <Portal v-bind:target="targetName"> |
| 183 | + <Child /> |
| 184 | + </Portal> |
| 185 | + <template> |
| 186 | + <script> |
| 187 | + import { Portal } from 'vue' |
| 188 | + export default { |
| 189 | + name: 'Parent' |
| 190 | + components: { |
| 191 | + Portal, |
| 192 | + Child: { |
| 193 | + template: '<div/>', |
| 194 | + mounted() { |
| 195 | + console.log(this.$parent.$options.name ) |
| 196 | + // => 'Parent' |
| 197 | + } |
| 198 | + } |
| 199 | + }, |
| 200 | + } |
| 201 | + </script></template |
| 202 | + ></template |
| 203 | +> |
| 204 | +``` |
| 205 | +
|
| 206 | +Similarly, using `inject` in `Child` should be able to inject any provided content from `Paren` or one of its ancestors. |
| 207 | +
|
| 208 | +### Updating |
| 209 | +
|
| 210 | +The `target` prop can be changed dynamically with `v-bind`. When the value changes, `Portal` will remove the children from the previous target and move them to the new one. |
| 211 | +
|
| 212 | +If the children contain any component instances, these will not be influenced by this. The instances will be kept alive, keep their state etc. |
| 213 | +
|
| 214 | +```html |
| 215 | +<template> |
| 216 | + <Portal v-bind:target="targetName"> |
| 217 | + <p>This can be moved around with the button below</p> |
| 218 | + </Portal> |
| 219 | + <button v-on:click="toggleTarget">Toggle</button> |
| 220 | + <hr /> |
| 221 | + <div id="A"></div> |
| 222 | + <div id="B"></div> |
| 223 | + <template> |
| 224 | + <script> |
| 225 | + import { Portal } from "vue"; |
| 226 | + export default { |
| 227 | + components: { Portal }, |
| 228 | + data: () => ({ |
| 229 | + targetName: "A" |
| 230 | + }), |
| 231 | + methods: { |
| 232 | + toggleTarget() { |
| 233 | + this.targetName = this.targetName == "A" ? "B" : "A"; |
| 234 | + } |
| 235 | + } |
| 236 | + }; |
| 237 | + </script></template |
| 238 | + ></template |
| 239 | +> |
| 240 | +``` |
| 241 | +
|
| 242 | +If the new target selector doesn't match any elements, a warning should be logged during development. |
| 243 | + |
| 244 | +> **Question:** Should the old content still be removed from the old target in that situation or should everything stay the way it is? |
| 245 | + |
| 246 | +> **Question** What should happen when the parent component re-renders? should the query Selector be run again? Seems like it's unnecessary in most situations, but if we don't, then having a selector that doesn't match anything initially will result in a stale component even if that element pops up later, wouldn't it? |
| 247 | + |
| 248 | +### Destruction |
| 249 | + |
| 250 | +When a `Portal` is being destroyed (e.g. because its parent component is being destroyed or because of a `v-if`), its children are removed from the DOM and any component instances destroyed just like they were still children iof the parent. |
| 251 | + |
| 252 | +### dev-tools |
| 253 | + |
| 254 | +The Portal should not appear in the chain of parent components (`this.$parent`), but it should be identifiable within the virtual DOM so that Vue's dee-tools can show them in their visualisation of the component tree. |
| 255 | +
|
| 256 | +### Using a Portal on an element within a Vue app |
| 257 | +
|
| 258 | +Technically, this proposal allows to select _any_ element in the DOM , including elements that are rendered by our Vue app in some other part of the component tree. |
| 259 | +
|
| 260 | +But that puts the portal'd slot content under the control of that other component's lifecycle, which means the content can possibly be removed from the DOM if that component gets destroyed. |
| 261 | +
|
| 262 | +Any component that came through a `Portal` would effectively have its DOM removed by still be in the original virtual DOM tree, which would lead to patch errors when these components tried to update. |
| 263 | +
|
| 264 | +# Drawbacks |
| 265 | +
|
| 266 | +The only notable drawback that we see is the additional code required to implement this. But judging from experiments in the prototype, that code will be very light, as it's just a slightly different way to mount elements at the virtualDOM level. |
| 267 | + |
| 268 | +As it's an additive feature and the functionality is pretty straightforward (one prop defining as target selector), this should also not add much complexity to Vue in terms of documentation or teaching. |
| 269 | +
|
| 270 | +When considering how popular current userland solutions are even with their caveats and limitations, the cost/benefit ratio seems clear. |
| 271 | +
|
| 272 | +# Alternatives |
| 273 | +
|
| 274 | +No other designs were considered so far. |
| 275 | +
|
| 276 | +## What happens if we don't do this |
| 277 | + |
| 278 | +There are currently several userland implementations of this feature available, which usually suffer some caveats and drawbacks that stem of the fact that portals are not supported at the virtual DOM level in Vue 2. |
| 279 | + |
| 280 | +People could continue to use these with their existing limitations and drawbacks. |
| 281 | + |
| 282 | +# Adoption strategy |
| 283 | + |
| 284 | +Portals is a new feature and as such purely additive in nature. |
| 285 | + |
| 286 | +Since the component is not globally registered (unlike e.g. `<transition>` was in Vue 2.0), there are also not risks if naming conflicts for applications that already use the name `<portal>` in some other context: |
| 287 | + |
| 288 | +```html |
| 289 | +<template> |
| 290 | + <portal> <!-- custom component --></portal> |
| 291 | + <VuePortal><!-- portal from this RFC --></VuePortal> |
| 292 | + <template> |
| 293 | + <script> |
| 294 | + import { Portal } from "vue"; |
| 295 | + export default { |
| 296 | + components: { |
| 297 | + VuePortal: Portal |
| 298 | + } |
| 299 | + }; |
| 300 | + </script></template |
| 301 | + ></template |
| 302 | +> |
| 303 | +``` |
| 304 | + |
| 305 | +As such, this feature does not have any impact on the migration of Vue 2.0 applications to Vue 3.0. |
| 306 | + |
| 307 | +Users new to Vue 3.0 or Vue in general will be able to learn about this feature from the docs in the usual way and gradually introduce it into their projects where it makes sense. |
| 308 | + |
| 309 | +## Existing 3rd party solutions |
| 310 | + |
| 311 | +As mentioned, several 3rd party plugins/libs implement similar functionality right now. |
| 312 | + |
| 313 | +Some of them may become irrelevant though this RFC, while others, offering functionality that exceeds what this proposal describes, would could adapt their implementation to make use of this proposal's "native" `<portal>` component internally. |
| 314 | +
|
| 315 | +If RFC vuejs/vue-next#28 (Render function change) is adopted, these libraries will have to be reworked either way, at which point they can adopt this new feature. |
| 316 | +
|
| 317 | +# Unresolved questions |
| 318 | +
|
| 319 | +### Mounting to elements controlled by Vue |
| 320 | +
|
| 321 | +When portal-ing content to a target element that is controlled by Vue, the portal's content might be removed from the DOM by the component that controls that target element. |
| 322 | + |
| 323 | +- Can we handle this gracefully? |
| 324 | +- If not: Are userland solutions on top of this basic implementations able to work around this (in my opinion: yes) |
| 325 | +- Or is this feature strictly limited to mounting to elements _outside_ of the part of the DOM controlled by Vue? |
| 326 | + |
| 327 | +### Missing Targets |
| 328 | + |
| 329 | +- What do we do if the new target doesn't exist? unmount the old one anyway? |
| 330 | +- Should target selectors re-run on every re-render of the `Portal`'s parent so that initially missing targets can be mounted to once they exist? |
| 331 | + |
| 332 | +### using multiple portals on the same target |
| 333 | + |
| 334 | +portal-vue supports sending content from multiple `<portals>` to the same target. it does so by using a `<portal-target>` component that manages this. |
| 335 | + |
| 336 | +The portal functionality proposed in this RFC requires that each portal has its own target element to mount to. |
| 337 | + |
| 338 | +Should we and if so how can we make n:1 work? |
| 339 | + |
| 340 | +### Naming conflict with native portals |
| 341 | + |
| 342 | +There's proposal for native portals: |
| 343 | +
|
| 344 | +- Spec: https://wicg.github.io/portals/ |
| 345 | +- Introduction: https://web.dev/hands-on-portals/ |
| 346 | +
|
| 347 | +We probably don't want to have a naming conflict with a future HTML element that may be called `<portal>`, especially since it's functionality is about something completely different form what portals in libs like Vue or React mean right now. |
| 348 | +
|
| 349 | +So should we give the concept of this RFC (and by extension, the component it introduces) another name to prepare for native `<portal>` elements becoming a standard? If so, what would we call it instead? |
| 350 | +
|
| 351 | +- `<Teleport>` |
| 352 | +- `<Wormhole>` |
| 353 | +- `<...?>` |
| 354 | +
|
| 355 | +Or should we keep it as the concept of what a portal is in Vue, React e.t al. is already "common knowledge" and a new term might confuse people more than it would help? |
0 commit comments