Generate custom elements from Vue component definitions.
npm install vue-web-component --save
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.
vue-web-component
. For example, see 7 Ways to Define a Component Template
in
VueJS.VueWebComponent
is a SkateJS class with useful methods and properties that you can set and
override. See below for more detail.new Vue
, but it depends on your case.vue-custom-element
already exists. Why another one?vue-custom-element
didn't meet some requirements. I needed the following:
See here for details on why vue-custom-element
didn't meet these
requirements.
You can import a Vue component then make a custom element out of it:
import Foo from './Foo.vue'
import VueWebComponent from 'vue-web-component'
// define a custom element with the Vue component
customElements.define('foo-element', VueWebComponent( Foo ) )
// now use it with regular DOM APIs
document.body.innerHTML = `
<foo-element></foo-element>
`
You can also define the custom element inline, using an object literal of the same format as a Vue component:
import VueWebComponent from 'vue-web-component'
// define a custom element using an object literal
customElements.define('foo-element', VueWebComponent( {
props: {
// ...
},
data: () => ({
// ...
}),
methods: {
// ...
},
mounted() {
// ...
},
// etc
} ) )
// now use it with regular DOM APIs
document.body.innerHTML = `
<foo-element></foo-element>
`
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 (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 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:
import FooBar from './FooBar.vue'
import VueWebComponent from 'vue-web-component'
customElements.define( 'foo-bar', VueWebComponent( FooBar ) )
document.body.innerHTML = `
<foo-bar msg="42"></foo-bar>
`
document.querySelector('foo-bar').setAttribute('msg', 2.71828)
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!
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 -->
<foo-bar foo="5"></foo-bar>
<foo-bar blah="5"></foo-bar>
<!-- String: The string "5" will be passed as is -->
<foo-bar bar="5"></foo-bar>
<!-- Object: The string '{"n":123}' will be converted into an object with JSON.parse()
and passed to the Vue component -->
<foo-bar baz='{"n":123}'></foo-bar>
<foo-bar boing='{"n":123}'></foo-bar>
<!-- Boolean: Boolean attributes are not based on their value. Instead, if the
attribute exists, the value passed to the Vue component is `true` regardless
of the attribute value. If the attribute doesn't exist, then `false` is passed. -->
<!-- In all of the following, Vue receives a value of `true`: -->
<foo-bar lorem></foo-bar>
<foo-bar lorem="false"></foo-bar>
<foo-bar lorem="true"></foo-bar>
<foo-bar lorem="blah blah"></foo-bar>
<!-- Only in the following does the Vue component receive `false` for lorem: -->
<foo-bar></foo-bar>
<!-- null ("any"): When props is an array like `props:['foo','bar','baz']`, or the
value is `null`, the attributes are treated the same as String, and their values are
passed as-is. The string "anything" is passed as-is: -->
<foo-bar ipsum="anything"></foo-bar>
<!-- Array: The string '[1,2,3]' will be converted into an Array with JSON.parse()
and passed to the Vue component -->
<foo-bar barbaz='[1,2,3]'></foo-bar>
<!-- 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 element
would have to convert the function to a string, then it would need to be converted back to a function in the child
which means all variable scope is lost, so this would be pointless for most cases. The type will be treated as
String, and the attribute value will be passed as a string to the Vue component. -->
<foo-bar foobar="function() { this is basically pointless! (for now) }"></foo-bar>
<!-- 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! -->
<foo-bar lala="I am a string passed to the validator function"></foo-bar>
<!-- CustomConstructor: This currently will not work. PRs Welcome! The attribute value will be treated as String
and be sent as-is to the Vue component. -->
<foo-bar something="I am a string that gets passed on, no instance of CustomConstructor will be created"></foo-bar>
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:
import FooBar from './FooBar.vue'
import VueWebComponent from 'vue-web-component'
customElements.define( 'foo-bar', VueWebComponent( FooBar ) )
document.body.innerHTML = `
<foo-bar msg="42"></foo-bar>
`
const el = document.querySelector('foo-bar')
el.msg = 2.71828
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.
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
):
document.body.innerHTML = `
<foo-bar>
<lorem-ipsum>
<foo-bar></foo-bar>
</lorem-ipsum>
<foo-bar>
<lorem-ipsum></lorem-ipsum>
</foo-bar>
</foo-bar>
<lorem-ipsum></lorem-ipsum>
`
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>
import FooBar from './FooBar'
import LoremIpsum from './LoremIpsum'
import VueWebComponent from 'vue-web-component'
customElements.define('foo-bar', VueWebComponent(FooBar))
customElements.define('lorem-ipsum', VueWebComponent(LoremIpsum))
document.body.innerHTML = `
<foo-bar id="foo">
<lorem-ipsum id="lorem">Hello</lorem-ipsum>
</foo-bar>
`
then the DOM will contain the following (take a look in the element inspector):
<foo-bar id="foo">
<#shadow-root>
<div>
<real-slot>
<slot>
↳ <lorem-ipsum id="lorem"> // distributed node
</slot>
</real-slot>
</div>
</#shadow-root>
<lorem-ipsum id="lorem">
<#shadow-root>
<p>
<real-slot>
<slot>
↳ "Hello" // distributed node
</slot>
</real-slot>
</p>
</#shadow-root>
Hello
</lorem-ipsum>
</foo-bar>
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:
<foo-bar id="foo">
<div>
<real-slot>
<lorem-ipsum id="lorem">
<p>
<real-slot>
Hello
</real-slot>
</p>
</lorem-ipsum>
</real-slot>
</div>
</foo-bar>
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:
<foo-bar id="foo">
<div>
<lorem-ipsum id="lorem">
<p>
Hello
</p>
</lorem-ipsum>
</div>
</foo-bar>