vue-web-component
Generate custom elements from Vue component definitions.
npm install vue-web-component --save
What?
vue-web-component
generates Web Components (Custom Elements) that are
consumable in any sort of web application and can be manipulated by any view
library like Vue, React, Angular, and others. Custom Elements are user-defined
elements that the browser knows how to work with just like with builtin
elements.
Plus, it's just easy to write Web Components this way!
vue-web-component
takes the authoring of Custom Elements one step further by
letting you define a Vue component from which to render the content of your
Custom Element. Attribute changes on the generated custom element are
automatically propagated to the underlying Vue component instance, and the
elements are composable with ShadowDOM.
Behind the scenes, utilities offered by SkateJS are used in wiring up the functionality. SkateJS provides a set of powerfully simple mixins that make defining custom elements super simple and convenient, on top of the browser's native Web Components APIs.
Pros:
- Using Vue makes it easy to describe how the DOM content of your custom element will change depending on attribute.
- Morphing your DOM will be automatically fast thanks to Vue's dom diffing.
- Modifying properties (attributes) of your element will re-render your component DOM content automatically, it's nice!
- Vue single-file-component files are super nice to work with, and support a variety of languages like Pug, CoffeeScript, TypeScript, Sass, Less, Stylus, and more.
- For a wide variety of elements, using this approach makes it dirt simple to write custom elements.
- There's awesome tooling around Vue like loaders and syntax plugins for various editors, that make working with Vue magical.
- There's many ways to define a Vue component, all of which work with
vue-web-component
. For example, see 7 Ways to Define a Component Template in VueJS.
Cons:
- Not all custom elements need a view layer for describing how their inner DOM changes. It can be leaner to just define the static DOM yourself in this case yourself, and SkateJS is a great tool for that. Though, doing it with Vue will still be simple!
- When writing a Vue-based component, you have less control of the Web
Components APIs. Usually that's okay, but sometimes you may want more
control. Well, the good thing is the that the output from
VueWebComponent
is a SkateJS class with useful methods and properties that you can set and override. See below for more detail. - A component doesn't always need DOM content. It might be a pure-JavaScript
component. In that case using vanilla Web Components APIs can be preferable
rather than loading a
new Vue
, but it depends on your case.
vue-custom-element
already exists. Why another one?
But, vue-custom-element
didn't meet some requirements. I needed the following:
- Elements are instantiated only once
- Elements are nestable and composable
- Elements distribute as expected with ShadowDOM.
See here for details on why vue-custom-element
didn't meet these
requirements.
Requirements
- You'll need a Web Component polyfill to run this in older browsers that don't yet have Custom Elements and/or ShadowDOM.
- The source is not transpiled for older browsers so you may have to transpile this in your own build setup.
Basic Usage
You can import a Vue component then make a custom element out of it:
// define a custom element with the Vue componentcustomElements // now use it with regular DOM APIsdocumentbodyinnerHTML = ` `
You can also define the custom element inline, using an object literal of the same format as a Vue component:
// define a custom element using an object literalcustomElements // now use it with regular DOM APIsdocumentbodyinnerHTML = ` `
Usage inside other Vue components
Whether you create a Custom Element from a Vue component or not is your choice. You can use the Vue component inside another component without using it in the custom element form. The following example shows usage of both forms inside a Vue component, where the result is the same for all uses of the Foo component:
<template> <div class="app"> <!-- Foo as a Vue component, the value for `message` gets passed as an object literally --> <foo :message="{n: 999}"></foo> <!-- Foo as a custom element, the value for `message` gets deserialized first, then passed to the underlying Vue component --> <!-- The element will be passed the literal attribute value, the string '{"n": 111}', and then deserialize it. --> <foo-element message='{"n": 111}'></foo-element> <!-- Foo as a custom element, the value for `message` gets deserialized first, then passed to the underlying Vue component --> <!-- Vue will calculate the value of the attribute (it'll be the string `{"n": 111}`) then pass that string to the element which will deserialize it. --> <foo-element v-bind:message='`{"n": 111}`'></foo-element> </div></template> <script> import Foo from './Foo' import VueWebComponent from 'vue-web-component' // We'll use Foo as a custom element... customElements.define('foo-element', VueWebComponent(Foo)) export default { // ...and we'll use Foo as a Vue component directly too components: { Foo } }</script>
Scoped styles
Scoped styles (using <style scoped>
) should work as expected. If not, please
file a bug.
In fact, scoped styles work better with elements made with vue-web-components
because styles can not leak into deeper shadow roots!!! This is a win!!
Scoped styles with plain Vue components, on the other hand, will effect all
content of sub-components in the element where the style is
defined](https://github.com/vuejs/vue-loader/issues/957). This means that you
can use vue-web-component
to create style boundaries in your application when
otherwise you cannot do this with Vue alone.
vue-web-component
copies global styles generated by Vue and injects them into
the generated element's shadow root because global styles otherwise would not
have any effect on the content of the generated elements, due to the rules of
how ShadowDOM works.
Props from Attributes
Props that you define in your Vue component will be available as attributes on the generated custom element.
For example, in the Vue component:
<!-- FooBar.vue --><template> <span> {{ msg }} </span> </template><script> export default { props: { msg: Number }, }</script>
you can use the attributes to set values, as expected:
customElements documentbodyinnerHTML = ` ` document
In that example, because the type of msg
is Number
, the attribute value on
the element will be deserialized into a number with parseFloat
, thanks to some
convenient features of SkateJS. Keep in mind that if you do something like
msg="asdf35"
, then this will yield a NaN
just as expected from
parseFloat
.
Supported prop types are Number
, String
, Boolean
, Object
, Array
, and null
(null
is the default in Vue, which is basically like any
in Typescript,
which means the values can be anything).
TODO: Symbol
type. If you really need that, please open an issue or PR. Thanks!
caveats with prop from attributes
The following caveats apply only if you use the component as an element
generated with vue-web-component
. Otherwise, using it as a Vue component inside
another component does not have these caveats.
Custom constructor types are not supported. We don't have a way (yet) to deserialize a string into a specific constructor. See below, and PRs welcome!
At the moment, vue-web-component
supports only simple prop types, like the
ones that were just listed in the previous paragraph. If you use more complex
prop types, like custom validators, Function
, and OR types, for example
props: msg: String Number // String OR Number
that won't work well, and the prop will be treated like any
in TypeScript,
and what this means in practice is that the string value of the generated
element's msg
attribute will not be deserialized, and the string value will be
passed to the inner Vue component to do whatever it wants with the string
value.
To get the best use out of the runtime type system, see the following example to get an idea of what may and may not work well:
<!-- FooBar.vue --><template> ... </template><script> export default { props: { foo: Number, // works fine bar: String, // works fine baz: Object, // works fine lorem: Boolean, // works fine ipsum: null, // works fine, any type barbaz: Array, // works fine foobar: Function // won't work, usually you can emit events instead blah: { // works fine, just like others, its just using the object form type: Number, default: 5, }, boing: { // works fine type: Object, default: () => ({foo: 'bar'}), }, lala: { // may not work validator: function(value) { typeof value === 'string' // always true } } something: CustomConstructor, // won't work }, // or props: [ 'foo', 'bar', 'baz' ], // may not work well }</script>
It's key to note that element attributes are always strings. Therefore, string values get deserialized into their appropriate types. If a prop doesn't have a type then the attribute value will be left as-is: a string.
SkateJS (and Custom Elements by default) does not yet have a tool for passing non-string values to elements (like A-Frame does), at least for now.
With this in mind, here's how the the following would work based on the above props types:
<!-- Number: The string "5" will be converted into a Number then passed to the Vue component --> <!-- String: The string "5" will be passed as is --> <!-- Object: The string '{"n":123}' will be converted into an object with JSON.parse()and passed to the Vue component --> <!-- Boolean: Boolean attributes are not based on their value. Instead, if theattribute exists, the value passed to the Vue component is `true` regardlessof the attribute value. If the attribute doesn't exist, then `false` is passed. --><!-- In all of the following, Vue receives a value of `true`: --><!-- Only in the following does the Vue component receive `false` for lorem: --> <!-- null ("any"): When props is an array like `props:['foo','bar','baz']`, or thevalue is `null`, the attributes are treated the same as String, and their values arepassed as-is. The string "anything" is passed as-is: --> <!-- Array: The string '[1,2,3]' will be converted into an Array with JSON.parse()and passed to the Vue component --> <!-- Function: This doesn't work. There's (currently) no meaningful way to convert a string to a function.Usually this use case is for parents to pass functions to children, but this means a parent elementwould have to convert the function to a string, then it would need to be converted back to a function in the childwhich means all variable scope is lost, so this would be pointless for most cases. The type will be treated asString, and the attribute value will be passed as a string to the Vue component. --> <!-- Custom validator: Props with a custom validator will be treated as String and passed as-is to the Vue component.The validator function will therefore receive the string value. Then this is only good with string values! --> <!-- CustomConstructor: This currently will not work. PRs Welcome! The attribute value will be treated as Stringand be sent as-is to the Vue component. -->
Props from instance properties
Not only can you set attributes on the generated elements, but you can also set matching properties on the instances.
For example, given the following Vue component,
<!-- FooBar.vue --><template> <span> {{ msg }} </span> </template><script> export default { props: { msg: Number }, }</script>
you can set properties on the element instance directly, similarly to how we can do on Vue component instances:
customElements documentbodyinnerHTML = ` ` const el = documentelmsg = 271828
Using the property method, you can skip many (if not all) of the caveats mentioned in the previous section regarding props from attributes, because you can pass any value you want from JavaScript and it will go directly to the Vue component instance.
Nesting and composition
Elements generated with vue-web-component
from Vue components are fully
nestable and composable. For example, given that we've generated the elements
<foo-bar>
and <lorem-ipsum>
out of Vue components, we can compose them in
the DOM and they will appear that way in the DOM when you look at the inspector
(as expected, but this was not the case with vue-custom-element
):
documentbodyinnerHTML = ` `
Use with ShadowDOM
Elements generated by vue-web-component
have a ShadowDOM root by default. A
special element, <real-slot>
is registered for you by vue-web-component
that
you can use to define real ShadowDOM <slot>
elements. Using <slot>
instead of <real-slot>
won't work as you expect because those will be treated
as Vue's virtual slots when Vue renders it's template, which won't work with
actual ShadowDOM.
Given the following code,
<!-- FooBar.vue --><template> <div> <real-slot></real-slot> </div><template>
<!-- LoremIpsum.vue --><template> <p> <real-slot></real-slot> </p><template>
customElementscustomElements documentbodyinnerHTML = ` Hello `
then the DOM will contain the following (take a look in the element inspector):
<#shadow-root> ↳ // distributed node </#shadow-root> <#shadow-root> ↳ "Hello" // distributed node </#shadow-root> Hello
The "composed tree" (an internal tree based on the composition of shadow roots in the DOM that determines the final content that the browser will render) will look like this:
Hello
Note, the <real-slot>
elements remain as part of the composed tree, and they
behave like inline elements. Feel free to style them as needed.
In the near future (or perhaps now depending on when you read this), browsers
will have a display: contents
CSS property that will make an element render
only it's content and an element with display:contents
will render as if it
was not in the tree and only its children were in its place. The <real-slot>
elements are given this styling, and when this style is supported, the
"composed tree" will be more like this:
Hello
TODO
- Add API that allows Vue component authors to be able to more easily work around edge cases when they detect that their component is used as a custom element.