Last time you heard from me was all about the virtues of building cross-framework UIs with web components. But how exactly do you go about developing a web component?
There are a number of solutions for developing web components, you can go with a pure Javascript approach or use any number of web-component specific frameworks like Lit or Ionic. One convenient option that is fairly new to the scene is the ability to build Vue components as web components - a solution that is employed here at Passage.
Using a framework like Vue for web component development provides a number of potential benefits such as full Vue implementation of component state & lifecycle management, the ability to use a framework you may already be familiar with, or if the rest of your front-end is built in Vue the ability to reuse code across various product offerings. Here at Passage, Vue is used for developing all of the main front-end offerings and so building the Passage elements web components using Vue is a natural fit.
As of the release of Vue 3.2, building Vue components directly into web components is now supported through the use of the defineCustomElement() function. This function takes in a Vue component as an input and returns a native custom element constructor that can be registered with the browser (see below).
To get started you’ll define a standard Vue element in a single-file component with the special file ending .ce.vue which indicates to Vue transpilers that the component being defined is to be used as a custom element, for instance MyElement.ce.vue.
In this single file component you’ll define the root component that will be built into a custom element. This component supports the same Options API that you would use when building any non-web component Vue component.
MyElement.ce.vue:
Any props added that are to this top-level component will be created as properties on the web component that is built by Vue. Vue will also reflect any defined properties that can be two-way cast to a string as HTML attributes on the custom element. For instance, in the above code the defined custom element will support the stringProp and numericProp properties as properties on the custom element but will also allow the end user to assign their values through kebab-case HTML attributes: <my-custom-element string-prop="string value" numeric-prop="0" />. Values assigned through HTLM attributes will be automatically reflected to Vue props will the correct typing.
Once you have built your root element you can now use defineCustomElement() to build a custom element constructor that can be used with the standard web browser custom element registry. This is a simple two step process:
defineCustomElement returns a custom element constructor that is used with the customElement.define() which registers the web component containing all the embedded Vue functionality with the custom element tag of your choice (<my-custom-element/> in this example).
Using the custom element in a client application is now just a matter of importing the above code in Javascript context where you intend to use the element.
One of the key pieces of developing custom elements is the fully encapsulated styling within the shadowDOM. This allows styles defined for the custom element to not collide with styles defined by the client using the custom element. As a result, all styling used by the custom element needs to be embedded in <style> tags defined within the shadowDOM.
When building a Vue custom element this is handled by Vue compilation toolchains which inline styles defined in the component that is built into the custom element. This is one of the primary uses of the .ce.vue extension which indicates to the compilation tools that the component is intended for use as a custom element and as such the styling should be inlined into the appropriate <style> tags.
This only applies to the main component defined in the .ce.vue single file component so styling for sub-components need to be rolled up into the main component’s definition. For more complex components you won’t want to include all of that styling in one massive style section of the main component’s SFC.
At Passage, this problem is solved by managing styles for the custom elements in a series of modular SCSS files that are imported into a main SCSS file which is then assigned as the style option of the custom element. This allows separating out styles into individual modules while still being able to inline it all into a single <style> tag at the base of the custom element.
App.scss:
MyElement.ce.vue:
📢 Note: If you’re using TypeScript you’ll need to add a simple definition for .scss module types, declare module '*.scss', to your TypeScript typings file.
Generally custom elements are simple enough that component-level state management is sufficient, however there may be times when your custom element gets sufficiently complex that it is useful to introduce global state management to your component.
In a typical Vue (or React, Angular, etc.) application you would usually use an off-the shelf state management store solution like Vuex to handle this kind of problem. Tools like Vuex are built to be used with Vue plugins that provide functionality to your application on a global level. However, when building Vue components as custom elements this global context that Vue plugins utilize does not exist. Therefore things that act on a global app level, like Vuex, vue-router, or other plugins can’t be used in the context of a custom element.
So how could a global state be implemented? Well you can take advantage of Vue’s reactivity engine and composable architecture to design a composable that can act as global state.
store.ts:
This above example is a demonstration of how a composable could be used to implement some piece of global state. When the custom composable useStore() is imported, the store object itself is a const value that will be created in memory only the first time the composable is imported and future imports of useStore() will simply provide access to that piece of global memory.
The store object is created as a global Vue ref so any components that reference its value will be reactively updated when the value is mutated. It also takes advantages of the fact that it can be exported with readonly() so it cannot be directly mutated and any changes can be controlled through centralized mutator functions like updateStoreValue(). This in essence becomes a very lightweight, reactivity-backed, implementation of a global store that is Vue custom-element friendly.
As a final addition to the custom element you may want to define types for your custom element for any clients using TypeScript. Vue’s custom element support doesn’t provide much type information but fortunately manually shimming types for custom elements is not terribly difficult.
All custom elements inherit from the base HTMLElement type. Vue custom elements in particular also include properties from the VueElement type. From there its a matter of defining a new TypeScript interface that extends both of these types plus any additional props that your custom element supports. For instance the type definition for a custom element defined in MyElement.ce.vue would look like:
📢 Note: The custom props on the element could potentially be undefined if the end client doesn’t assign values to them through HTML attributes or directly to the properties.
Web components are an exciting new technology enabling a more open web and its great to see developer-friendly tools like Vue provide direct support for building web components. Hopefully with the extra context around these four points of building web components you’ll be able to use your Vue skills to build exciting new web components of your own.
For an example of what can be done with these design principles look no further than Passage’s own set of web components which handle a full biometric authentication workflow in a single web component.