Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 22c1ae4

Browse files
committedFeb 1, 2018
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>

‎test/fixtures/lifecycle.html

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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></div>`,
6+
created () {
7+
console.log('created')
8+
},
9+
mounted () {
10+
console.log('mounted')
11+
},
12+
activated () {
13+
console.log('activated')
14+
},
15+
deactivated () {
16+
console.log('deactivated')
17+
}
18+
}))
19+
window.el = document.querySelector('my-element')
20+
console.log('ready')
21+
})
22+
</script>
23+
24+
<my-element></my-element>

‎test/fixtures/properties.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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 }} {{ someProp }}</div>`,
6+
props: {
7+
foo: {
8+
type: Number,
9+
default: 123
10+
},
11+
'some-prop': {
12+
type: String,
13+
default: 'bar'
14+
}
15+
}
16+
}))
17+
window.el = document.querySelector('my-element')
18+
console.log('ready')
19+
})
20+
</script>
21+
22+
<my-element></my-element>

‎test/fixtures/slots.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: `
6+
<div><slot/><slot name="foo"/></div>
7+
`,
8+
}))
9+
window.el = document.querySelector('my-element')
10+
console.log('ready')
11+
})
12+
</script>
13+
14+
<my-element>
15+
<div slot="foo">foo</div>
16+
<div>default</div>
17+
</my-element>

‎test/test.js

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
const puppeteer = require('puppeteer')
2+
const { createServer } = require('http-server')
3+
4+
const port = 3000
5+
const puppeteerOptions = process.env.CI
6+
? { args: ['--no-sandbox', '--disable-setuid-sandbox'] }
7+
: {}
8+
9+
let browser, server
10+
11+
async function launchPage (name) {
12+
const url = `http://localhost:${port}/test/fixtures/${name}.html`
13+
const page = await browser.newPage()
14+
const logs = []
15+
const ready = new Promise(resolve => {
16+
page.on('console', msg => {
17+
logs.push(msg.text())
18+
if (msg.text() === `ready`) resolve()
19+
})
20+
})
21+
await page.goto(url)
22+
await ready
23+
return { browser, page, logs }
24+
}
25+
26+
beforeAll(async () => {
27+
browser = await puppeteer.launch(puppeteerOptions)
28+
server = createServer({ root: process.cwd() })
29+
await new Promise((resolve, reject) => {
30+
server.listen(port, err => {
31+
if (err) return reject(err)
32+
resolve()
33+
})
34+
})
35+
})
36+
37+
afterAll(async () => {
38+
await browser.close()
39+
server.close()
40+
})
41+
42+
test('properties', async () => {
43+
const { page } = await launchPage(`properties`)
44+
45+
// props proxying: get
46+
const foo = await page.evaluate(() => el.foo)
47+
expect(foo).toBe(123)
48+
49+
// get camelCase
50+
const someProp = await page.evaluate(() => el.someProp)
51+
expect(someProp).toBe('bar')
52+
53+
// props proxying: set
54+
await page.evaluate(() => {
55+
el.foo = 234
56+
el.someProp = 'lol'
57+
})
58+
const newFoo = await page.evaluate(() => el.vueComponent.foo)
59+
expect(newFoo).toBe(234)
60+
const newBar = await page.evaluate(() => el.vueComponent.someProp)
61+
expect(newBar).toBe('lol')
62+
})
63+
64+
test('attributes', async () => {
65+
const { page } = await launchPage(`attributes`)
66+
67+
// boolean
68+
const foo = await page.evaluate(() => el.foo)
69+
expect(foo).toBe(true)
70+
71+
// boolean="true"
72+
const bar = await page.evaluate(() => el.bar)
73+
expect(bar).toBe(true)
74+
75+
// some-number="123"
76+
const someNumber = await page.evaluate(() => el.someNumber)
77+
expect(someNumber).toBe(123)
78+
79+
// set via attribute
80+
await page.evaluate(() => {
81+
el.setAttribute('foo', 'foo')
82+
el.setAttribute('bar', 'false')
83+
el.setAttribute('some-number', '234')
84+
})
85+
86+
// boolean="boolean"
87+
expect(await page.evaluate(() => el.foo)).toBe(true)
88+
expect(await page.evaluate(() => el.bar)).toBe(false)
89+
expect(await page.evaluate(() => el.someNumber)).toBe(234)
90+
})
91+
92+
test('events', async () => {
93+
const { page } = await launchPage(`events`)
94+
await page.evaluate(() => {
95+
el._shadowRoot.querySelector('button').click()
96+
})
97+
expect(await page.evaluate(() => window.emitted)).toBe(true)
98+
})
99+
100+
test('slots', async () => {
101+
const { page } = await launchPage(`slots`)
102+
103+
const content = await page.evaluate(() => {
104+
return el._shadowRoot.querySelector('div').innerHTML
105+
})
106+
expect(content).toMatch(`<div>default</div><div>foo</div>`)
107+
108+
// update slots
109+
await page.evaluate(() => {
110+
el.innerHTML = `<div>default2</div><div slot="foo">foo2</div>`
111+
})
112+
const newContent = await page.evaluate(() => {
113+
return el._shadowRoot.querySelector('div').innerHTML
114+
})
115+
expect(newContent).toMatch(`<div>default2</div><div>foo2</div>`)
116+
})
117+
118+
test('lifecycle', async () => {
119+
const { page, logs } = await launchPage(`lifecycle`)
120+
121+
expect(logs).toContain('created')
122+
expect(logs).toContain('mounted')
123+
124+
await page.evaluate(() => {
125+
el.parentNode.removeChild(el)
126+
})
127+
expect(logs).toContain('deactivated')
128+
129+
await page.evaluate(() => {
130+
document.body.appendChild(el)
131+
})
132+
expect(logs).toContain('activated')
133+
})

‎yarn.lock

Lines changed: 3621 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.