Skip to content

Commit 22c1ae4

Browse files
committed
init
0 parents  commit 22c1ae4

13 files changed

+4194
-0
lines changed

.circleci/config.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
version: 2
2+
jobs:
3+
build:
4+
docker:
5+
# specify the version you desire here
6+
- image: vuejs/ci
7+
8+
working_directory: ~/repo
9+
10+
steps:
11+
- checkout
12+
13+
# Download and cache dependencies
14+
- restore_cache:
15+
keys:
16+
- v1-dependencies-{{ checksum "yarn.lock" }}
17+
# fallback to using the latest cache if no exact match is found
18+
- v1-dependencies-
19+
20+
- run: yarn install
21+
22+
- save_cache:
23+
paths:
24+
- node_modules
25+
- ~/.cache/yarn
26+
key: v1-dependencies-{{ checksum "yarn.lock" }}
27+
28+
# run tests!
29+
- run: yarn test

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules
2+
DS_Store

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# @vue/web-component-wrapper
2+
3+
> Wrap and register a Vue component as a custom element.
4+
5+
## Compatibility
6+
7+
Requires [native ES2015 class support](https://caniuse.com/#feat=es6-class). IE11 and below are not supported.
8+
9+
## Usage
10+
11+
``` js
12+
import Vue from 'vue'
13+
import wrap from '@vue/web-component-wrapper'
14+
15+
const Component = {
16+
// any component options
17+
}
18+
19+
const CustomElement = wrap(Vue, Component)
20+
21+
window.customElements.define('my-element', CustomElement)
22+
```
23+
24+
## Interface Proxying Details
25+
26+
### Props
27+
28+
### Events
29+
30+
### Slots
31+
32+
### Lifecycle
33+
34+
## Acknowledgments
35+
36+
Special thanks to the prior work by @karol-f in [vue-custom-element](https://github.com/karol-f/vue-custom-element).
37+
38+
## License
39+
40+
MIT

package.json

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"name": "@vue/web-component-wrapper",
3+
"version": "1.0.0",
4+
"description": "wrap a vue component as a web component.",
5+
"main": "src/index.js",
6+
"scripts": {
7+
"test": "jest",
8+
"lint": "eslint src"
9+
},
10+
"repository": {
11+
"type": "git",
12+
"url": "git+https://github.com/vuejs/web-component-wrapper.git"
13+
},
14+
"keywords": [
15+
"vue",
16+
"web-component"
17+
],
18+
"author": "Evan You",
19+
"license": "MIT",
20+
"bugs": {
21+
"url": "https://github.com/vuejs/web-component-wrapper/issues"
22+
},
23+
"homepage": "https://github.com/vuejs/web-component-wrapper#readme",
24+
"devDependencies": {
25+
"eslint": "^4.16.0",
26+
"eslint-plugin-vue-libs": "^2.1.0",
27+
"http-server": "^0.11.1",
28+
"jest": "^22.1.4",
29+
"lint-staged": "^6.1.0",
30+
"puppeteer": "^1.0.0",
31+
"vue": "^2.5.13",
32+
"yorkie": "^1.0.3"
33+
},
34+
"eslintConfig": {
35+
"env": {
36+
"browser": true
37+
},
38+
"extends": "plugin:vue-libs/recommended"
39+
},
40+
"gitHooks": {
41+
"pre-commit": "lint-staged"
42+
},
43+
"lint-staged": {
44+
"*.js": [
45+
"eslint --fix",
46+
"git add"
47+
]
48+
}
49+
}

src/index.js

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {
2+
toVNodes,
3+
camelize,
4+
hyphenate,
5+
callHooks,
6+
getInitialProps,
7+
createCustomEvent,
8+
convertAttributeValue
9+
} from './utils.js'
10+
11+
export default function wrap (Vue, Component) {
12+
const options = typeof Component === 'function'
13+
? Component.options
14+
: Component
15+
16+
// inject hook to proxy $emit to native DOM events
17+
options.beforeCreate = [].concat(options.beforeCreate || [])
18+
options.beforeCreate.unshift(function () {
19+
const emit = this.$emit
20+
this.$emit = (name, ...args) => {
21+
this.$root.$options.customElement.dispatchEvent(createCustomEvent(name, args))
22+
return emit.call(this, name, ...args)
23+
}
24+
})
25+
26+
// extract props info
27+
const propsList = Array.isArray(options.props)
28+
? options.props
29+
: Object.keys(options.props || {})
30+
const hyphenatedPropsList = propsList.map(hyphenate)
31+
const camelizedPropsList = propsList.map(camelize)
32+
const originalPropsAsObject = Array.isArray(options.props) ? {} : options.props || {}
33+
const camelizedPropsMap = camelizedPropsList.reduce((map, key, i) => {
34+
map[key] = originalPropsAsObject[propsList[i]]
35+
return map
36+
}, {})
37+
38+
class CustomElement extends HTMLElement {
39+
static get observedAttributes () {
40+
return hyphenatedPropsList
41+
}
42+
43+
constructor () {
44+
super()
45+
const el = this
46+
this._wrapper = new Vue({
47+
name: 'shadow-root',
48+
customElement: this,
49+
data () {
50+
return {
51+
props: getInitialProps(camelizedPropsList),
52+
slotChildren: Object.freeze(toVNodes(
53+
this.$createElement,
54+
el.childNodes
55+
))
56+
}
57+
},
58+
render (h) {
59+
return h(Component, {
60+
ref: 'inner',
61+
props: this.props
62+
}, this.slotChildren)
63+
}
64+
})
65+
66+
// in Chrome, this.childNodes will be empty when connectedCallback
67+
// is fired, so it's necessary to use a mutationObserver
68+
const observer = new MutationObserver(() => {
69+
this._wrapper.slotChildren = Object.freeze(toVNodes(
70+
this._wrapper.$createElement,
71+
this.childNodes
72+
))
73+
})
74+
observer.observe(this, {
75+
childList: true,
76+
subtree: true,
77+
characterData: true,
78+
attributes: true
79+
})
80+
}
81+
82+
get vueComponent () {
83+
return this._wrapper.$refs.inner
84+
}
85+
86+
connectedCallback () {
87+
if (!this._wrapper._isMounted) {
88+
this._shadowRoot = this.attachShadow({ mode: 'open' })
89+
this._wrapper.$options.shadowRoot = this._shadowRoot
90+
this._wrapper.$mount()
91+
// sync default props values to wrapper
92+
for (const key of camelizedPropsList) {
93+
this._wrapper.props[key] = this.vueComponent[key]
94+
}
95+
this._shadowRoot.appendChild(this._wrapper.$el)
96+
} else {
97+
callHooks(this.vueComponent, 'activated')
98+
}
99+
}
100+
101+
disconnectedCallback () {
102+
callHooks(this.vueComponent, 'deactivated')
103+
}
104+
105+
// watch attribute change and sync
106+
attributeChangedCallback (attrName, oldVal, newVal) {
107+
const camelized = camelize(attrName)
108+
this._wrapper.props[camelized] = convertAttributeValue(
109+
newVal,
110+
attrName,
111+
camelizedPropsMap[camelized]
112+
)
113+
}
114+
}
115+
116+
// proxy props as Element properties
117+
camelizedPropsList.forEach(key => {
118+
Object.defineProperty(CustomElement.prototype, key, {
119+
get () {
120+
return this._wrapper.props[key]
121+
},
122+
set (newVal) {
123+
this._wrapper.props[key] = newVal
124+
},
125+
enumerable: false,
126+
configurable: true
127+
})
128+
})
129+
130+
return CustomElement
131+
}

src/utils.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
const camelizeRE = /-(\w)/g
2+
export const camelize = str => {
3+
return str.replace(camelizeRE, (_, c) => c ? c.toUpperCase() : '')
4+
}
5+
6+
const hyphenateRE = /\B([A-Z])/g
7+
export const hyphenate = str => {
8+
return str.replace(hyphenateRE, '-$1').toLowerCase()
9+
}
10+
11+
export function getInitialProps (propsList) {
12+
const res = {}
13+
for (const key of propsList) {
14+
res[key] = undefined
15+
}
16+
return res
17+
}
18+
19+
export function callHooks (vm, hook) {
20+
if (vm) {
21+
const hooks = vm.$options[hook] || []
22+
hooks.forEach(hook => {
23+
hook.call(vm)
24+
})
25+
}
26+
}
27+
28+
export function createCustomEvent (name, args) {
29+
return new CustomEvent(name, {
30+
bubbles: false,
31+
cancelable: false,
32+
detail: args
33+
})
34+
}
35+
36+
const isBoolean = val => /function Boolean/.test(String(val))
37+
const isNumber = val => /function Number/.test(String(val))
38+
39+
export function convertAttributeValue (value, name, options) {
40+
if (isBoolean(options.type)) {
41+
if (value === 'true' || value === 'false') {
42+
return value === 'true'
43+
}
44+
if (value === '' || value === name) {
45+
return true
46+
}
47+
return value != null
48+
} else if (isNumber(options.type)) {
49+
const parsed = parseFloat(value, 10)
50+
return isNaN(parsed) ? value : parsed
51+
} else {
52+
return value
53+
}
54+
}
55+
56+
export function toVNodes (h, children) {
57+
return [].map.call(children, node => toVNode(h, node))
58+
}
59+
60+
function toVNode (h, node) {
61+
if (node.nodeType === 3) {
62+
return node.data.trim() ? node.data : null
63+
} else if (node.nodeType === 1) {
64+
const data = {
65+
attrs: getAttributes(node),
66+
domProps: {
67+
innerHTML: node.innerHTML
68+
}
69+
}
70+
if (data.attrs.slot) {
71+
data.slot = data.attrs.slot
72+
delete data.attrs.slot
73+
}
74+
return h(node.tagName, data)
75+
} else {
76+
return null
77+
}
78+
}
79+
80+
function getAttributes (node) {
81+
const res = {}
82+
for (const attr of node.attributes) {
83+
res[attr.nodeName] = attr.nodeValue
84+
}
85+
return res
86+
}

test/fixtures/attributes.html

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<script src="../../node_modules/vue/dist/vue.js"></script>
2+
<script>
3+
import('../../src/index.js').then(module => {
4+
window.customElements.define('my-element', module.default(Vue, {
5+
template: `<div>{{ foo }} {{ bar }} {{ someNumber }}</div>`,
6+
props: {
7+
foo: {
8+
type: Boolean
9+
},
10+
bar: {
11+
type: Boolean
12+
},
13+
someNumber: {
14+
type: Number
15+
}
16+
}
17+
}))
18+
window.el = document.querySelector('my-element')
19+
console.log('ready')
20+
})
21+
</script>
22+
23+
<my-element foo="foo" bar="true" some-number="123"></my-element>

test/fixtures/events.html

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<script src="../../node_modules/vue/dist/vue.js"></script>
2+
<script>
3+
import('../../src/index.js').then(module => {
4+
window.customElements.define('my-element', module.default(Vue, {
5+
template: `<div>
6+
<button @click="$emit('foo', 123)">Emit</button>
7+
</div>`
8+
}))
9+
window.el = document.querySelector('my-element')
10+
el.addEventListener('foo', () => {
11+
window.emitted = true
12+
})
13+
console.log('ready')
14+
})
15+
</script>
16+
17+
<my-element></my-element>

0 commit comments

Comments
 (0)