skip to content

A server-rendered legacy page needs two independent Vue 3 widgets; how would you mount them, and what is isolated versus shared between the two apps?

level: seniorimportance: should knowfreq 32%

answer

  1. one createApp per widget
  2. small apps, not the whole page
  3. config and registrations per app
  4. modules are shared
  5. loop over matching elements

basics

~20 s

Create one app per widget with createApp and mount each on its own element, not one app over the whole page. Configuration, registrations, plugins and provides are per app; imported modules, including any module-level state, are shared.

solid answer

~40 s

Vue's guidance for enhancing server-rendered HTML is to mount several small apps on the elements they own rather than one app over the entire page. So each widget gets its own `createApp(Widget, rootProps)`, with root props read from its element's data attributes, configured and then mounted on that element; repeated widgets mean a loop over `querySelectorAll`, one app per element. Each app has its own `config`, registered components and directives, installed plugins and `provide` values, so one widget cannot inject what the other provides. What is shared is everything at module level: the Vue runtime and any module the two apps import. A module-level reactive store is therefore a deliberate channel between widgets, or an accidental leak.

code

ts · 23 lines
ts
import { createApp, type Component } from 'vue'
import RatingWidget from './RatingWidget.vue'
import MiniCart from './MiniCart.vue'
import { i18n } from './plugins/i18n'

function createWidget(component: Component, props: Record<string, unknown>) {
  return createApp(component, props).use(i18n) // same config for every app
}

const apps = []

for (const el of document.querySelectorAll<HTMLElement>('.rating')) {
  const app = createWidget(RatingWidget, { productId: Number(el.dataset.productId) })
  app.mount(el) // one app per element
  apps.push(app)
}

const cartEl = document.querySelector<HTMLElement>('#mini-cart')
if (cartEl) {
  const cart = createWidget(MiniCart, {})
  cart.mount(cartEl)
  apps.push(cart)
}

go deeper

for a junior

Recall that several apps can share one page, each created with createApp and mounted on its own element.

for a middle

Explain what each app owns versus what modules share, and why a selector mounts only the first match.

for a senior

Design the widget bootstrap: a shared factory, root props from data attributes, cross-widget communication and teardown when markup is replaced.

for a principal

Plan the migration path from embedded widgets to a fuller app, deciding what shared state and configuration should look like at each stage.

## The situation A legacy page is rendered by a server-side framework. Two pieces need interactivity: say, a product rating widget that appears several times and a mini-cart in the header. The rest of the page is static HTML, possibly with its own scripts. Rewriting the page as a single-page app is out of scope. ## Why not one app over the whole page Mounting one app on `body` or on a page-wide wrapper would make Vue own all of that HTML: it would clear the container's content and render it from a template, and either every static fragment becomes part of Vue's template, which then needs the runtime compiler, or it is lost. It would also interfere with the page's other scripts that expect the DOM they manipulate to stay put. The Vue guide explicitly recommends creating multiple small application instances mounted on the elements they are responsible for. ## Mounting the widgets 1. Load the widget bundle after the elements exist, for example with a deferred script. 2. For a single widget, create an app and mount it on its element. 3. For a repeated widget, loop: `mount('.rating')` would mount only the **first** match, because a selector is resolved with `querySelector`, and each app can be mounted once. 4. Read per-instance data from the element's `data-*` attributes and pass it as root props, converting strings to numbers or booleans. 5. Keep a reference to each app if the page may later replace those elements, so you can call `unmount()` first. ## What each app owns, and what they share | Aspect | Per app | Shared across apps | |---|---|---| | `app.config`, error handler | yes | | | globally registered components and directives | yes | | | installed plugins, `app.provide` values | yes | | | component instances and their reactive state | yes | | | the Vue runtime and scheduler | | yes | | module-level variables in imported files | | yes | | the DOM, `window`, global listeners | | yes | This assumes both widgets come from one bundle. Two separately built bundles each carry their own copy of Vue and of every module, and then share nothing but the DOM. Two consequences follow. First, anything app-wide must be configured on **each** app: a shared bootstrap function that applies the same plugins and config keeps them consistent. Second, `inject()` cannot cross apps: the mini-cart cannot inject a service the rating app provided. ## Communicating between widgets When the widgets must talk, for example when a rating action should refresh the cart: - **A shared module**: both apps import a module that exports a `reactive()` object or a small store. Because modules are evaluated once, both apps read and write the same state, and Vue's reactivity updates components in both apps. - **DOM events**: one widget dispatches a custom event on `window` or a shared element, and the other listens. Looser coupling, and it also works with non-Vue code on the page. The shared-module trick is the same mechanism that causes leaks when it is unintended, so make shared state explicit and name it as such. ## When the host page replaces markup Legacy pages often refresh fragments with their own scripts, for example replacing a product list after filtering. If a widget lives inside that fragment, its element is detached while its app is still mounted. To stay leak-free: 1. keep a map from host element to app when mounting; 2. before the page replaces the fragment, call `unmount()` on every app whose element is inside it; 3. after the new markup is inserted, run the same mounting loop on the new fragment only. If the page's scripts cannot call such a hook, a `MutationObserver` watching for removed widget elements can trigger the unmount instead. Either way, mounting and unmounting become symmetric operations owned by the widget bootstrap code. ## Pitfalls specific to multiple apps - **Duplicate ids** when both apps generate ids, which a per-app id prefix solves. - **Plugins with hidden module state** that assume a single app. - **Legacy scripts replacing markup** that hosts an app: unmount before the element is removed. - **Style leakage** between widgets and the page, since apps share one document.

  • Why can't the mini-cart app inject a service that the rating app provided?
    `app.provide` writes into one app's context, and every component's injection lookup ends at its own app's context. The two apps have separate contexts, so the lookup never crosses between them. Share such a service through an imported module or provide it on both apps.
  • How would you keep plugin and config setup identical across all the widget apps?
    Wrap `createApp` in one bootstrap function that installs the same plugins, sets the same `app.config` fields and returns the app, and create every widget through it. Configuration then changes in one place instead of drifting between widgets.

Each app is a separate shop in the same mall: its own stock, staff rules and signage, and one shop cannot sell from the other's shelves. The building's utilities, like imported modules, are shared by all shops, which is useful for a common intercom and a problem if one shop floods the pipes.

saying these in an interview costs you the question

  • One app mounted on body is the recommended way to enhance a legacy page
  • mount('.rating') mounts a widget on every matching element
  • A provide in one app is injectable from another app on the page
  • Each app gets its own copy of every imported module
  • Plugins installed on the first app apply to the second automatically