diff --git a/packages/mip/build/alias.js b/packages/mip/build/alias.js index 5dbbfd6a..980f6784 100644 --- a/packages/mip/build/alias.js +++ b/packages/mip/build/alias.js @@ -18,5 +18,6 @@ module.exports = { sfc: resolve('src/vue/sfc'), deps: resolve('deps'), 'script-loader!deps': resolve('deps'), - 'script-loader!document-register-element': resolve('node_modules/document-register-element') + 'script-loader!document-register-element': resolve('node_modules/document-register-element'), + 'mip-vue': resolve('src/vue-custom-element/index.js') } diff --git a/packages/mip/build/config.js b/packages/mip/build/config.js index 72cab579..5da20314 100644 --- a/packages/mip/build/config.js +++ b/packages/mip/build/config.js @@ -23,12 +23,18 @@ const resolve = p => { } const builds = { - 'mip-prod': { + 'mip': { entry: resolve('mip'), dest: resolve('dist/mip.js'), format: 'umd', env: 'production', banner: 'window._mipStartTiming=Date.now();' + }, + 'mip-vue': { + entry: resolve('mip-vue'), + dest: resolve('dist/mip-vue.js'), + format: 'iife', + env: 'production' } } diff --git a/packages/mip/build/webpack.config.dev.js b/packages/mip/build/webpack.config.dev.js index 5d9aa10b..75b14c58 100644 --- a/packages/mip/build/webpack.config.dev.js +++ b/packages/mip/build/webpack.config.dev.js @@ -13,7 +13,8 @@ module.exports = merge.smart(baseConfig, { mode: 'development', devtool: 'inline-source-map', entry: { - mip: resolve('src/index.js') + mip: resolve('src/index.js'), + 'mip-vue': resolve('src/vue-custom-element/index.js') }, resolve: { alias: { diff --git a/packages/mip/src/base-element.js b/packages/mip/src/base-element.js index 6f0e1391..c601efb4 100644 --- a/packages/mip/src/base-element.js +++ b/packages/mip/src/base-element.js @@ -9,7 +9,7 @@ import {hasOwn} from './util' import {customEmit} from './util/custom-event' import dom from './util/dom/dom' import css from './util/dom/css' -import {camelize} from './util/string' +import {camelize, capitalize} from './util/string' import {parseSizeList} from './size-list' /** @param {!Element} element */ @@ -104,7 +104,7 @@ class BaseElement extends HTMLElement { this.propTypes = this.vueCompat.getPropTypes(this.name, CustomElementImpl) - this.defaultValues = this.vueCompat.getDefaultValues(this.name, CustomElementImpl) + this.defaultProps = this.vueCompat.getDefaultProps(this.name, CustomElementImpl) this.customElement.props = {} @@ -131,7 +131,18 @@ class BaseElement extends HTMLElement { attributeChangedCallback (name, oldValue, newValue) { const propName = camelize(name) - this.customElement.props[propName] = this.vueCompat.parseAttribute(newValue, this.propTypes[propName]) + + if (this.isBuilt() && hasOwn(this.propTypes, propName) && oldValue !== newValue) { + const prevProps = this.customElement.props[propName] + const nextProps = this.vueCompat.parseAttribute(newValue, this.propTypes[propName]) + const handler = `handle${capitalize(propName)}Change` + + this.customElement.props[propName] = nextProps + if (typeof this.customElement[handler] === 'function' && + !(oldValue === null && nextProps === this.defaultProps[propName])) { + this.customElement[handler](prevProps, nextProps) + } + } this.customElement.attributeChangedCallback(name, oldValue, newValue) } @@ -318,7 +329,7 @@ class BaseElement extends HTMLElement { } catch (e) { this.error = e customEmit(this, 'build-error', e) - console.warn('build error:', e) + console.error(e) } } @@ -332,18 +343,18 @@ class BaseElement extends HTMLElement { */ getProps () { const propTypes = this.propTypes - const defaultValues = this.defaultValues + const defaultProps = this.defaultProps const props = this.vueCompat.getProps(this, propTypes) const names = Object.keys(propTypes) for (let i = 0; i < names.length; i++) { const name = names[i] - if (typeof props[name] !== 'undefined' || !hasOwn(defaultValues, name)) { + if (typeof props[name] !== 'undefined' || !hasOwn(defaultProps, name)) { continue } - const def = defaultValues[name] + const def = defaultProps[name] props[name] = typeof def === 'function' ? def() : def } diff --git a/packages/mip/src/components/mip-bind/watcher.js b/packages/mip/src/components/mip-bind/watcher.js index c214a0bd..2333c918 100644 --- a/packages/mip/src/components/mip-bind/watcher.js +++ b/packages/mip/src/components/mip-bind/watcher.js @@ -6,7 +6,7 @@ import Deps from './deps' import * as util from './util' -import {MAX_UPDATE_COUNT} from '../../vue/core/observer/scheduler' +const MAX_UPDATE_COUNT = 100 const queue = [] let has = {} diff --git a/packages/mip/src/custom-element.js b/packages/mip/src/custom-element.js index 5750f65c..2fc70eca 100644 --- a/packages/mip/src/custom-element.js +++ b/packages/mip/src/custom-element.js @@ -78,8 +78,17 @@ class CustomElement { /** * Called when the MIPElement is first inserted into the document. + * + * @deprecated + */ + build () { + this.buildCallback() + } + + /** + * Executes after the element attached to DOM. */ - build () {} + buildCallback () {} /** * Requests the element to unload any expensive resources when the element diff --git a/packages/mip/src/mip1-polyfill/element.js b/packages/mip/src/mip1-polyfill/element.js index 30de3e34..46b142f6 100644 --- a/packages/mip/src/mip1-polyfill/element.js +++ b/packages/mip/src/mip1-polyfill/element.js @@ -161,7 +161,7 @@ function createBaseElementProto () { customEmit(this, 'build') } catch (e) { customEmit(this, 'build-error', e) - console.warn('build error:', e) + console.error(e) } } diff --git a/packages/mip/src/services/extensions.js b/packages/mip/src/services/extensions.js index 96d5c871..afab0949 100644 --- a/packages/mip/src/services/extensions.js +++ b/packages/mip/src/services/extensions.js @@ -3,12 +3,31 @@ import {templates, Deferred, event} from '../util' import {whenDocumentInteractive} from '../util/dom/dom' import registerMip1Element from '../mip1-polyfill/element' import registerCustomElement from '../register-element' -import '../vue-custom-element' const {listen} = event const UNKNOWN_EXTENSION_ID = 'unknown' +// const LATEST_MIP_VERSION = '2' + +/** + * Inserts a script element in `` with specified url. Returns that script element. + * + * @param {string} url of script. + * @returns {!HTMLScriptElement} + * @private + */ +function insertScript (url) { + const script = document.createElement('script') + + script.async = true + script.src = url + + document.head.appendChild(script) + + return script +} + export class Extensions { constructor () { /** @@ -61,7 +80,8 @@ export class Extensions { resolve: null, reject: null, loaded: null, - error: null + error: null, + script: null } } @@ -78,6 +98,63 @@ export class Extensions { return this.getExtensionHolder(this.currentExtensionId || UNKNOWN_EXTENSION_ID) } + /** + * Returns the script url of extension. + * + * @param {string} extensionId of extension. + * @param {string=} version of extension. + * @returns {string} + * @private + */ + /* + getExtensionScriptUrl (extensionId, version = LATEST_MIP_VERSION) { + return `https://c.mipcdn.com/static/v${version}/${extensionId}/${extensionId}.js` + } + */ + + /** + * Returns the script element of extension or null. + * + * @param {string} extensionId of extension. + * @param {string=} version of extension. + * @returns {?HTMLScriptElement} + * @private + */ + /* + findExtensionScript (extensionId, version = LATEST_MIP_VERSION) { + const holder = this.getExtensionHolder(extensionId) + + if (holder.script) { + return holder.script + } + + const url = this.getExtensionScriptUrl(extensionId, version) + + holder.script = document.querySelector(`script[src="${url}"]`) + + return holder.script + } + */ + + /** + * Appends the extension script in `` if there's no existing script element of extension. + * + * @param {string} extensionId of extension. + * @param {string=} version of extension. + * @private + */ + /* + insertExtensionScriptIfNeeded (extensionId, version = LATEST_MIP_VERSION) { + const holder = this.getExtensionHolder(extensionId) + + if (holder.loaded || holder.error || this.findExtensionScript(extensionId, version)) { + return + } + + holder.script = insertScript(this.getExtensionScriptUrl(extensionId, version)) + } + */ + /** * Returns or creates a promise waiting for extension loaded. * @@ -124,6 +201,8 @@ export class Extensions { */ /* preloadExtension (extensionId) { + this.insertExtensionScriptIfNeeded(extensionId) + return this.waitForExtension(extensionId) } /* @@ -166,6 +245,32 @@ export class Extensions { this.currentExtensionId = extensionId factory(...args) + /** + * This extension needs `mip-vue` service. + */ + if ( + document.documentElement.hasAttribute('mip-vue') && + !Services.getServiceOrNull('mip-vue') + ) { + /** + * Inserts script of `mip-vue` service if needed. + */ + if (!document.querySelector('script[src*="mip-vue.js"]')) { + const baseUrl = document.querySelector('script[src*="mip.js"]').src.replace(/\/[^/]+$/, '') + + insertScript(`${baseUrl}/mip-vue.js`) + } + + /** + * Interrupts current registration. + * Reregisters this extension while `mip-vue` service is loaded. + */ + Services.getServicePromise('mip-vue') + .then(() => this.registerExtension(extensionId, factory, ...args)) + + return + } + /** * It still possible that all element instances in current extension call lifecycle `build` synchronously. * Executes callback in microtask to make sure all these elements are built. @@ -230,16 +335,20 @@ export class Extensions { * If `element.version === '1'`, then it will fallback to the registration of MIP1 elements. * * @param {!Object} element contains implementation, css and version. - * @returns {!function(string, !Function | !Object, string)} + * @returns {?function(string, !Function | !Object, string):?HTMLElement[]} * @private */ getElementRegistrator (element) { - if (element.version && element.version.split('.')[0] === '1') { - return registerMip1Element + if (typeof element.implementation === 'object') { + const vue = Services.getServiceOrNull('mip-vue') + + document.documentElement.setAttribute('mip-vue', '') + + return vue && vue.registerElement } - if (typeof element.implementation === 'object') { - return Services.getService('mip-vue').registerElement + if (element.version && element.version.split('.')[0] === '1') { + return registerMip1Element } return registerCustomElement @@ -262,30 +371,39 @@ export class Extensions { element.version = version } - holder.extension.elements[name] = element + if (!holder.extension.elements[name]) { + holder.extension.elements[name] = element + } - /** @type {HTMLElement[]} */ - let elementInstances = this.getElementRegistrator(element)(name, implementation, css) + const registrator = this.getElementRegistrator(element) + + if (!registrator) { + return + } + + /** @type {?HTMLElement[]} */ + let elementInstances = registrator(name, implementation, css) if (elementInstances && elementInstances.length) { holder.elementInstances = holder.elementInstances.concat(elementInstances) for (let i = 0, len = elementInstances.length; i < len; i++) { let el = elementInstances[i] - // Delay to last processing extension resolve. if (el.isBuilt()) { continue } - // It can't catch error of customElements.define with try/catch. - // @see https://github.com/w3c/webcomponents/issues/547 + /** + * Errors occurred in `customElements.define` cannot be caught. + * @see {@link https://github.com/w3c/webcomponents/issues/547} + */ if (el.error) { this.tryToRejectError(holder, el.error) break } /** - * Lifecycle `build` of element instances is probably delayed with `setTimeout`. + * Lifecycle `build` of element instances are probably delayed with `setTimeout`. * If they are not, these event listeners would not be registered before they emit events. */ let unlistenBuild = listen(el, 'build', () => { diff --git a/packages/mip/src/services/vue-compat.js b/packages/mip/src/services/vue-compat.js index 41388583..4a9250ea 100644 --- a/packages/mip/src/services/vue-compat.js +++ b/packages/mip/src/services/vue-compat.js @@ -56,7 +56,7 @@ export class VueCompat { /** * Returns metadata computed from a definition of custom element or Vue component, - * which contains `propTypes` and `defaultValues`. + * which contains `propTypes` and `defaultProps`. * * @param {string} name of custom element. * @param {?Object} definition of custom element or Vue component. @@ -66,7 +66,7 @@ export class VueCompat { getPropsMetadata (name, definition) { const metadata = { propTypes: {}, - defaultValues: {} + defaultProps: {} } if (!name || !definition) { @@ -100,7 +100,7 @@ export class VueCompat { metadata.propTypes[name] = this.getPropType(prop) if (prop && typeof prop === 'object' && hasOwn(prop, 'default')) { - metadata.defaultValues[name] = prop.default + metadata.defaultProps[name] = prop.default } } @@ -119,14 +119,14 @@ export class VueCompat { } /** - * Returns default values of custom element or Vue component. + * Returns default props of custom element or Vue component. * * @param {string} name of custom element. * @param {?Object} definition of custom element. * @returns {!Object} */ - getDefaultValues (name, definition) { - return this.getPropsMetadata(name, definition).defaultValues + getDefaultProps (name, definition) { + return this.getPropsMetadata(name, definition).defaultProps } /** diff --git a/packages/mip/src/util/index.js b/packages/mip/src/util/index.js index 7044a8fd..0b6af75f 100644 --- a/packages/mip/src/util/index.js +++ b/packages/mip/src/util/index.js @@ -17,6 +17,7 @@ import platform from './platform' import naboo from './naboo' import EventEmitter from './event-emitter' import Gesture from './gesture/index' +import {customEmit} from './custom-event' import customStorage from './custom-storage' import jsonParse from './json-parse' import templates from './templates' @@ -131,6 +132,7 @@ const hasOwnProperty = Object.prototype.hasOwnProperty export const hasOwn = (obj, key) => hasOwnProperty.call(obj, key) export { + customEmit, dom, event, fn, @@ -155,6 +157,7 @@ export default { isCacheUrl, EventEmitter, Gesture, + customEmit, customStorage, naboo, jsonParse, diff --git a/packages/mip/src/util/json-parse.js b/packages/mip/src/util/json-parse.js index 127e2dd7..28a7384f 100644 --- a/packages/mip/src/util/json-parse.js +++ b/packages/mip/src/util/json-parse.js @@ -21,17 +21,15 @@ export default function (jsonStr) { .replace(rxthree, item => (']' + (/:$/.test(item) ? ':' : ''))) .replace(rxfour, '') - if (!rxone.test(validate)) { - throw new Error(jsonStr + ' Content should be a valid JSON string!') + if (rxone.test(validate)) { + try { + /* eslint-disable */ + // @link https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/eval + // 等价于在全局作用域调用,不影响uglify压缩变量名 + let geval = eval + return geval('(' + jsonStr + ')') + /* eslint-enable */ + } catch (e) { throw e } } - - /** - * 等价于在全局作用域调用,不影响uglify压缩变量名 - * - * @see {@link https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/eval} - */ - /* eslint-disable-next-line no-eval */ - let geval = eval - - return geval('(' + jsonStr + ')') + throw new Error(jsonStr + ' Content should be a valid JSON string!') } diff --git a/packages/mip/src/util/string.js b/packages/mip/src/util/string.js index a05a7b13..e4548360 100644 --- a/packages/mip/src/util/string.js +++ b/packages/mip/src/util/string.js @@ -3,5 +3,8 @@ import {memoize} from './fn' /** @type {(name: string) => string} */ export const camelize = memoize(name => name.replace(/-[a-z]/g, s => s[1].toUpperCase())) +/** @type {(name: string) => string} */ +export const capitalize = memoize(name => name.replace(/^[a-z]/, s => s.toUpperCase())) + /** @type {(name: string) => string} */ export const hyphenate = memoize(name => name.replace(/\B[A-Z]/g, s => `-${s.toLowerCase()}`)) diff --git a/packages/mip/src/vue-custom-element/index.js b/packages/mip/src/vue-custom-element/index.js index 9b28fb28..10cf0e2b 100644 --- a/packages/mip/src/vue-custom-element/index.js +++ b/packages/mip/src/vue-custom-element/index.js @@ -4,12 +4,15 @@ */ import createVueInstance from './utils/create-vue-instance' -import {camelize, hyphenate} from '../util/string' -import CustomElement from '../custom-element' -import registerElement from '../register-element' -import Services from '../services/services' import Vue from 'vue' +const { + CustomElement, + Services, + registerElement, + util: {string: {camelize, hyphenate}} +} = window.MIP + class MIPVue { constructor () { this.vueCompat = Services.vueCompat() diff --git a/packages/mip/src/vue-custom-element/utils/create-vue-instance.js b/packages/mip/src/vue-custom-element/utils/create-vue-instance.js index 005dcada..59898d4d 100644 --- a/packages/mip/src/vue-custom-element/utils/create-vue-instance.js +++ b/packages/mip/src/vue-custom-element/utils/create-vue-instance.js @@ -4,8 +4,8 @@ */ import {reactiveProps} from './props' -import {customEmit} from '../../util/custom-event' -import viewer from '../../viewer' + +const {viewer, util: {customEmit}} = window.MIP /** * 获取 element 的 slot content,并将 slot content element 从父元素中移除 diff --git a/packages/mip/test/karma.base.conf.js b/packages/mip/test/karma.base.conf.js index 5a1d8841..5bfcb0d2 100644 --- a/packages/mip/test/karma.base.conf.js +++ b/packages/mip/test/karma.base.conf.js @@ -2,26 +2,14 @@ const webpack = require('webpack') const alias = require('../build/alias') const version = process.env.VERSION || require('../package.json').version -class WebpackRequirePlugin { +class AllowMutateEsmExportsPlugin { apply (compiler) { - compiler.hooks.compilation.tap('MainTemplate', (compilation) => { - compilation.mainTemplate.hooks.requireExtensions.tap('MainTemplate', () => - [ - '__webpack_require__.d = function (exported, name, get) {', - ' Reflect.defineProperty(exported, name, {', - ' configurable: true,', - ' enumerable: true,', - ' get', - ' })', - '}', - '__webpack_require__.n = function (exported) {', - ' return exported.a = exported', - '}', - '__webpack_require__.r = function () {}', - '__webpack_require__.o = function (object, property) {', - ' return Object.prototype.hasOwnProperty.call(object, property)', - '};' - ].join('\n') + compiler.hooks.compilation.tap('AllowMutateEsmExports', (compilation) => { + compilation.mainTemplate.hooks.requireExtensions.tap('AllowMutateEsmExports', source => + source.replace( + 'Object.defineProperty(exports, name, { enumerable: true, get: getter })', + 'Object.defineProperty(exports, name, { configurable: true, enumerable: true, get: getter })' + ) ) }) } @@ -74,7 +62,7 @@ const webpackConfig = { 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), '__VERSION__': JSON.stringify(version.toString()) }), - new WebpackRequirePlugin() + new AllowMutateEsmExportsPlugin() ], devtool: '#inline-source-map' } diff --git a/packages/mip/test/specs/base-element.spec.js b/packages/mip/test/specs/base-element.spec.js index effb655b..81ef5998 100644 --- a/packages/mip/test/specs/base-element.spec.js +++ b/packages/mip/test/specs/base-element.spec.js @@ -63,7 +63,7 @@ describe('base-element', () => { it('should warn if lifecycle build throws an error', function (done) { let name = prefix + '-build-error' - let warn = sinon.stub(console, 'warn') + let warn = sinon.stub(console, 'error') registerElement(name, class extends CustomElement { build () { @@ -349,19 +349,33 @@ describe('base-element', () => { }) }) - describe('props', () => { - const name = 'mip-responsive-example' + describe('props and handlers', () => { + const name = 'mip-reactive-example' - class MIPResponsiveExample extends CustomElement { + class MIPReactiveExample extends CustomElement { static get observedAttributes () { - return ['num', 'obj'] + return ['num', 'foo-items'] } + + constructor (element) { + super(element) + + this.handleNumChange = sinon.spy(this.handleNumChange) + this.handleObjChange = sinon.spy(this.handleObjChange) + this.handleFooItemsChange = sinon.spy(this.handleFooItemsChange) + } + + handleNumChange () {} + + handleObjChange () {} + + handleFooItemsChange () {} } const defaultObj = () => ({foo: 'bar'}) const defaultFooItems = () => ['foo', 'bar'] - MIPResponsiveExample.props = { + MIPReactiveExample.props = { num: { type: Number, default: 1024 @@ -377,7 +391,7 @@ describe('base-element', () => { } } - registerElement(name, MIPResponsiveExample) + registerElement(name, MIPReactiveExample) /** @type {import('src/base-element').default} */ let element @@ -405,7 +419,7 @@ describe('base-element', () => { obj: Object, fooItems: Array }) - expect(element.defaultValues).to.deep.equal({ + expect(element.defaultProps).to.deep.equal({ num: 1024, obj: defaultObj, fooItems: defaultFooItems @@ -438,17 +452,17 @@ describe('base-element', () => { document.body.removeChild(element) document.body.appendChild(element) expect(element.getProps).to.be.calledTwice - expect(element.customElement.attributeChangedCallback).to.not.be.called + expect(element.customElement.attributeChangedCallback).to.not.have.been.called }) it('should synchronize changed attributes to props', () => { - element.setAttribute('foo-items', '["foo", "baz"]') + element.setAttribute('obj', '{"foo": "baz"}') document.body.appendChild(element) - expect(element.customElement.props.fooItems).to.deep.equal(['foo', 'baz']) + expect(element.customElement.props.obj).to.deep.equal({foo: 'baz'}) element.setAttribute('num', '2048') expect(element.customElement.props.num).to.equal(2048) - element.setAttribute('obj', '{"foo": "baz"}') - expect(element.customElement.props.obj).to.deep.equal({foo: 'baz'}) + element.setAttribute('foo-items', '["foo", "baz"]') + expect(element.customElement.props.fooItems).to.deep.equal(['foo', 'baz']) expect(element.customElement.attributeChangedCallback).to.be.calledTwice }) @@ -458,7 +472,21 @@ describe('base-element', () => { expect(element.customElement.props.bool).to.be.true element.setAttribute('bool', 'false') expect(element.customElement.props.bool).to.be.true - expect(element.customElement.attributeChangedCallback).to.not.be.called + expect(element.customElement.attributeChangedCallback).to.not.have.been.called + }) + + it('should call the corresponding handler only when observed attribute changed', () => { + document.body.appendChild(element) + element.setAttribute('num', '1024') + expect(element.customElement.handleNumChange).to.not.have.been.called + element.setAttribute('num', '2048') + expect(element.customElement.handleNumChange).calledOnceWithExactly(1024, 2048) + element.setAttribute('foo-items', '["foo", "bar"]') + expect(element.customElement.handleFooItemsChange).calledWithExactly(['foo', 'bar'], ['foo', 'bar']) + element.setAttribute('foo-items', '["foo", "baz"]') + expect(element.customElement.handleFooItemsChange).calledWithExactly(['foo', 'bar'], ['foo', 'baz']) + element.setAttribute('obj', '{"foo": "baz"}') + expect(element.customElement.handleObjChange).to.not.have.been.called }) }) }) diff --git a/packages/mip/test/specs/services/extensions.spec.js b/packages/mip/test/specs/services/extensions.spec.js index 7a1b2920..d53cf9dc 100644 --- a/packages/mip/test/specs/services/extensions.spec.js +++ b/packages/mip/test/specs/services/extensions.spec.js @@ -7,50 +7,41 @@ import customElement from 'src/mip1-polyfill/customElement' import templates from 'src/util/templates' import resources from 'src/resources' -function mockAsyncBuildFactory (el) { - let add = resources.add - return { - stub () { - resources.add = targetEl => targetEl !== el && add.call(resources, targetEl) - }, - delayToRunBuild () { - setTimeout(() => { - el.build() - el.viewportCallback(true) - }) - }, - restore () { - resources.add = add - } - } -} - describe('extensions', () => { - /** - * @type {sinon.SinonSandbox} - */ + /** @type {sinon.SinonSandbox} */ let sandbox - /** - * @type {Extensions} - */ + /** @type {Extensions} */ let extensions - /** - * @type {import('src/services/timer').Timer} - */ + /** @type {import('src/services/timer').Timer} */ let timer + const add = resources.add + + const asyncBuild = (ele) => { + resources.add = target => target !== ele && add.call(resources, target) + timer.delay(() => { + ele.build() + ele.viewportCallback(true) + resources.add = add + }) + } + beforeEach(() => { sandbox = sinon.createSandbox() window.services.extensions = null installExtensionsService() extensions = Services.extensions() timer = Services.timer() + document.documentElement.removeAttribute('mip-vue') + window.services['mip-vue'] = null }) afterEach(() => { sandbox.restore() + document.querySelectorAll('body > *') + .forEach(element => element.tagName.startsWith('MIP') && document.body.removeChild(element)) }) it('should return extensions service', () => { @@ -134,20 +125,25 @@ describe('extensions', () => { it('should call extension factory synchronously', () => { let factoryExecuted = false + extensions.registerExtension('mip-ext', () => { expect(factoryExecuted).to.be.false factoryExecuted = true }) + expect(factoryExecuted).to.be.true }) it('should save current holder in registration', () => { let currentHolder + extensions.registerExtension('mip-ext', () => { expect(currentHolder).to.be.undefined currentHolder = extensions.getCurrentExtensionHolder() }) + const holder = extensions.extensions['mip-ext'] + expect(currentHolder).to.equal(holder) }) @@ -156,17 +152,19 @@ describe('extensions', () => { expect(args[0]).to.equal(MIP) expect(extensions.currentExtensionId).to.equal('mip-ext') }, MIP) + expect(extensions.currentExtensionId).to.be.null - await timer.then(() => { - const holder = extensions.extensions['mip-ext'] - expect(extensions.getExtensionHolder('mip-ext')).to.equal(holder) - expect(holder.promise).to.be.null - expect(holder.resolve).to.be.null - expect(holder.reject).to.be.null - expect(holder.loaded).to.be.true - expect(holder.error).to.be.null - }) + await timer.then() + + const holder = extensions.extensions['mip-ext'] + + expect(extensions.getExtensionHolder('mip-ext')).to.equal(holder) + expect(holder.promise).to.be.null + expect(holder.resolve).to.be.null + expect(holder.reject).to.be.null + expect(holder.loaded).to.be.true + expect(holder.error).to.be.null const extension = await extensions.waitForExtension('mip-ext') @@ -178,18 +176,20 @@ describe('extensions', () => { it('should register successfully with promise', async () => { const waiting = extensions.waitForExtension('mip-ext') const holder = extensions.getExtensionHolder('mip-ext') + holder.resolve = sinon.spy(holder.resolve) extensions.registerExtension('mip-ext', () => {}, MIP) + expect(extensions.currentExtensionId).to.be.null - await timer.then(() => { - expect(holder.promise).to.equal(waiting) - expect(holder.resolve).to.exist - expect(holder.reject).to.exist - expect(holder.loaded).to.be.true - expect(holder.error).to.be.null - expect(holder.resolve).to.be.calledWith(holder.extension) - }) + await timer.then() + + expect(holder.promise).to.equal(waiting) + expect(holder.resolve).to.exist + expect(holder.reject).to.exist + expect(holder.loaded).to.be.true + expect(holder.error).to.be.null + expect(holder.resolve).to.be.calledWith(holder.extension) expect(extensions.waitForExtension('mip-ext')).to.equal(waiting) @@ -207,52 +207,57 @@ describe('extensions', () => { expect(extensions.currentExtensionId).to.be.null const holder = extensions.extensions['mip-ext'] + expect(extensions.getExtensionHolder('mip-ext')).to.equal(holder) - await timer.then(() => { - expect(holder.promise).to.be.null - expect(holder.resolve).to.be.null - expect(holder.reject).to.be.null - expect(holder.loaded).to.be.null - expect(holder.error).to.exist - expect(holder.error.message).to.equal('intentional') - }) + await timer.then() + + expect(holder.promise).to.be.null + expect(holder.resolve).to.be.null + expect(holder.reject).to.be.null + expect(holder.loaded).to.be.null + expect(holder.error).to.exist + expect(holder.error.message).to.equal('intentional') - return extensions.waitForExtension('mip-ext').then(() => { + try { + await extensions.waitForExtension('mip-ext') throw new Error('It must have been rejected') - }).catch((err) => { + } catch (err) { expect(err.message).to.equal('intentional') - }) + } }) it('should fail registration with promise', async () => { const waiting = extensions.waitForExtension('mip-ext') + expect(() => extensions.registerExtension('mip-ext', () => { throw new Error('intentional') }, MIP)).to.throw(/intentional/) expect(extensions.currentExtensionId).to.be.null const holder = extensions.extensions['mip-ext'] + expect(extensions.getExtensionHolder('mip-ext')).to.equal(holder) - await timer.then(() => { - expect(holder.promise).to.equal(waiting) - expect(holder.resolve).to.exist - expect(holder.reject).to.exist - expect(holder.loaded).to.be.null - expect(holder.error).to.exist - expect(holder.error.message).to.equal('intentional') - }) + await timer.then() + + expect(holder.promise).to.equal(waiting) + expect(holder.resolve).to.exist + expect(holder.reject).to.exist + expect(holder.loaded).to.be.null + expect(holder.error).to.exist + expect(holder.error.message).to.equal('intentional') - return waiting.then(() => { + try { + await waiting throw new Error('It must have been rejected') - }).catch((err) => { + } catch (err) { expect(err.message).to.equal('intentional') - }) + } }) it('should register custom element in registration', async () => { - const buildCallback = sinon.spy() + const buildCallback = sandbox.spy() const implementation = class MIPCustom extends CustomElement { build () { buildCallback() @@ -267,11 +272,7 @@ describe('extensions', () => { extensions.registerElement('mip-custom', implementation, css) }, MIP) - // build 改成同步 - // await new Promise(resolve => ele.addEventListener('build', resolve)) - expect(buildCallback).to.be.calledOnce - document.body.removeChild(ele) const extension = await extensions.waitForExtension('mip-ext') @@ -283,152 +284,71 @@ describe('extensions', () => { expect(element.version).to.not.exist }) - it('should register multipe custom element in one extension', async () => { - let name = 'multi-custom-element' - const buildCallback1 = sinon.spy() - const implementation1 = class MIPCustom extends CustomElement { + it('should register multiple custom elements with asynchronous buildCallback', async () => { + const name = 'mip-sync-custom' + const buildCallback = sandbox.spy() + const implementation = class MIPSyncCustom extends CustomElement { build () { - buildCallback1() - } - } - - const buildCallback2 = sinon.spy() - const implementation2 = class MIPCustom extends CustomElement { - build () { - buildCallback2() + buildCallback() } } - const ele1 = document.createElement(name + '1') - const ele2 = document.createElement(name + '2') - - document.body.appendChild(ele1) - document.body.appendChild(ele2) - - extensions.registerExtension('mip-ext', () => { - extensions.registerElement(name + '1', implementation1) - extensions.registerElement(name + '2', implementation2) - }, MIP) - - expect(buildCallback1).to.be.calledOnce - expect(buildCallback2).to.be.calledOnce - document.body.removeChild(ele1) - document.body.removeChild(ele2) - - const extension = await extensions.waitForExtension('mip-ext') - - const element1 = extension.elements[name + '1'] - const element2 = extension.elements[name + '2'] - - expect(element1).to.exist - expect(element2).to.exist - expect(element1.implementation).to.equal(implementation1) - expect(element2.implementation).to.equal(implementation2) - expect(element1.version).to.not.exist - }) - - it('should register multipe asynchronous custom element in one extension', async () => { - let name = 'multi-custom-element-asynchronous' - const buildCallback1 = sinon.spy() - const implementation1 = class MIPCustom extends CustomElement { + const nameA = 'mip-async-custom-a' + const buildCallbackA = sandbox.spy() + const implementationA = class MIPAsyncCustomA extends CustomElement { build () { - buildCallback1() + buildCallbackA() } } - const buildCallback2 = sinon.spy() - const implementation2 = class MIPCustom extends CustomElement { + const nameB = 'mip-async-custom-b' + const buildCallbackB = sandbox.spy() + const implementationB = class MIPAsyncCustomB extends CustomElement { build () { - buildCallback2() + buildCallbackB() } } - const ele1 = document.createElement(name + '1') - const ele2 = document.createElement(name + '2') - - const mockAsyncBuild1 = mockAsyncBuildFactory(ele1) - const mockAsyncBuild2 = mockAsyncBuildFactory(ele1) - mockAsyncBuild1.stub() - mockAsyncBuild2.stub() - - document.body.appendChild(ele1) - document.body.appendChild(ele2) + const ele = document.createElement(name) + const eleA = document.createElement(nameA) + const eleB = document.createElement(nameB) - mockAsyncBuild1.delayToRunBuild() - mockAsyncBuild2.delayToRunBuild() + document.body.appendChild(ele) + document.body.appendChild(eleA) + document.body.appendChild(eleB) extensions.registerExtension('mip-ext', () => { - extensions.registerElement(name + '1', implementation1) - extensions.registerElement(name + '2', implementation2) + extensions.registerElement(name, implementation) + asyncBuild(eleA) + extensions.registerElement(nameA, implementationA) + asyncBuild(eleB) + extensions.registerElement(nameB, implementationB) }, MIP) - document.body.removeChild(ele1) - document.body.removeChild(ele2) + await Promise.all([ + new Promise(resolve => eleA.addEventListener('build', resolve)), + new Promise(resolve => eleB.addEventListener('build', resolve)) + ]) const extension = await extensions.waitForExtension('mip-ext') - expect(buildCallback1).to.be.calledOnce - expect(buildCallback2).to.be.calledOnce - - const element1 = extension.elements[name + '1'] - const element2 = extension.elements[name + '2'] - - expect(element1).to.exist - expect(element2).to.exist - expect(element1.implementation).to.equal(implementation1) - expect(element2.implementation).to.equal(implementation2) - expect(element1.version).to.not.exist - mockAsyncBuild1.restore() - mockAsyncBuild2.restore() - }) - - it('should register custom element with build asynchronous in registration', async () => { - const name = 'mip-ext-asynchronous-build' - const buildCallback = sinon.spy() - const implementation = class MIPCustom extends CustomElement { - build () { - buildCallback() - } - } - const css = name + '{display: block}' - const ele = document.createElement(name) - - const mockAsyncBuild = mockAsyncBuildFactory(ele) - mockAsyncBuild.stub() - - document.body.appendChild(ele) - - mockAsyncBuild.delayToRunBuild() - - extensions.registerExtension(name, () => { - extensions.registerElement(name, implementation, css) - }, MIP) - - // mock build asynchronous - setTimeout(() => { - ele.build() - ele.viewportCallback(true) - }, 100) - await new Promise(resolve => ele.addEventListener('build', resolve)) - expect(buildCallback).to.be.calledOnce - document.body.removeChild(ele) - - const extension = await extensions.waitForExtension(name) + expect(buildCallbackB).to.be.calledOnce const element = extension.elements[name] + const elementA = extension.elements[nameA] + const elementB = extension.elements[nameB] expect(element).to.exist + expect(elementA).to.exist + expect(elementB).to.exist expect(element.implementation).to.equal(implementation) - expect(element.css).to.equal(css) - expect(element.version).to.not.exist - - // restore add func - mockAsyncBuild.restore() + expect(elementA.implementation).to.equal(implementationA) + expect(elementB.implementation).to.equal(implementationB) }) - it('should fail registration in build asynchronous', async () => { - const name = 'mip-custom-error-asynchronous' + it('should fail registration in buildCallback', async () => { + const name = 'mip-custom-error' const implementation = class MIPCustomError extends CustomElement { build () { throw new Error('intentional') @@ -436,33 +356,23 @@ describe('extensions', () => { } const ele = document.createElement(name) - const mockAsyncBuild = mockAsyncBuildFactory(ele) - mockAsyncBuild.stub() - document.body.appendChild(ele) - mockAsyncBuild.delayToRunBuild() - extensions.registerExtension(name, () => { extensions.registerElement(name, implementation) }) - await new Promise(resolve => ele.addEventListener('build-error', resolve)) - - document.body.removeChild(ele) - - await extensions.waitForExtension(name).then(() => { + try { + await extensions.waitForExtension(name) throw new Error('It must have been rejected') - }).catch((err) => { + } catch (err) { expect(err.message).to.equal('intentional') - }) - - mockAsyncBuild.restore() + } }) - it('should fail registration in build', async () => { - let name = 'mip-ext-synchronous-error' - const implementation = class MIPCustomError extends CustomElement { + it('should fail registration in asynchronous buildCallback', async () => { + const name = 'mip-async-custom-error' + const implementation = class MIPAsyncCustomError extends CustomElement { build () { throw new Error('intentional') } @@ -472,22 +382,22 @@ describe('extensions', () => { document.body.appendChild(ele) extensions.registerExtension(name, () => { + asyncBuild(ele) extensions.registerElement(name, implementation) }) - // await new Promise(resolve => ele.addEventListener('build-error', resolve)) - - document.body.removeChild(ele) + await new Promise(resolve => ele.addEventListener('build-error', resolve)) - await extensions.waitForExtension(name).then(() => { + try { + await extensions.waitForExtension(name) throw new Error('It must have been rejected') - }).catch((err) => { + } catch (err) { expect(err.message).to.equal('intentional') - }) + } }) it('should register vue custom element in registration', async () => { - const mountedCallback = sinon.spy() + const mountedCallback = sandbox.spy() const implementation = { mounted () { mountedCallback() @@ -497,20 +407,46 @@ describe('extensions', () => { } } const css = 'mip-vue-custom{display:block}' + const baseUrl = 'https://c.mipcdn.com/static/v2' + + const script = document.createElement('script') + + script.src = `${baseUrl}/mip.js` + script.async = true + document.head.appendChild(script) extensions.registerExtension('mip-ext', () => { extensions.registerElement('mip-vue-custom', implementation, css) }, MIP) + extensions.registerExtension('mip-ext', () => {}, MIP) + + expect(document.documentElement.hasAttribute('mip-vue')).to.be.true + + /* + const scripts = document.head.querySelectorAll(`script[src="${baseUrl}/mip-vue.js"]`) + + expect(scripts.length).to.equal(1) + + const script = scripts[0] + + expect(script).to.be.not.null + expect(script.async).to.be.true + */ const ele = document.createElement('mip-vue-custom') document.body.appendChild(ele) + delete require.cache[require.resolve('src/vue-custom-element')] + require('src/vue-custom-element') + + await Services.getServicePromise('mip-vue') + ele.viewportCallback(true) - expect(mountedCallback).to.be.calledOnce + await timer.sleep() - document.body.removeChild(ele) + expect(mountedCallback).to.be.calledOnce const extension = await extensions.waitForExtension('mip-ext') @@ -523,7 +459,7 @@ describe('extensions', () => { }) it('should register mip1 custom element in registration', async () => { - const attachedCallback = sinon.spy() + const attachedCallback = sandbox.spy() const implementation = customElement.create() implementation.prototype.attachedCallback = attachedCallback const css = 'mip-legacy{display:block}' @@ -535,10 +471,7 @@ describe('extensions', () => { extensions.registerElement('mip-legacy', implementation, css, {version: '1'}) }) - // await new Promise(resolve => ele.addEventListener('build', resolve)) - expect(attachedCallback).to.be.calledOnce - document.body.removeChild(ele) const extension = await extensions.waitForExtension('mip-ext') const element = extension.elements['mip-legacy'] diff --git a/packages/mip/test/specs/services/vue-compat.spec.js b/packages/mip/test/specs/services/vue-compat.spec.js index 1d58d593..8ddc6773 100644 --- a/packages/mip/test/specs/services/vue-compat.spec.js +++ b/packages/mip/test/specs/services/vue-compat.spec.js @@ -149,13 +149,13 @@ describe('vue-compat', () => { describe('getDefaultValues', () => { it('should return an empty object if name or definition is not present', () => { - expect(vueCompat.getDefaultValues()).to.deep.equal({}) - expect(vueCompat.getDefaultValues('', MIPCustom)).to.deep.equal({}) - expect(vueCompat.getDefaultValues('mip-empty')).to.deep.equal({}) + expect(vueCompat.getDefaultProps()).to.deep.equal({}) + expect(vueCompat.getDefaultProps('', MIPCustom)).to.deep.equal({}) + expect(vueCompat.getDefaultProps('mip-empty')).to.deep.equal({}) }) it('should return custom element default values', () => { - expect(vueCompat.getDefaultValues('mip-custom', MIPCustom)).to.deep.equal({ + expect(vueCompat.getDefaultProps('mip-custom', MIPCustom)).to.deep.equal({ bool: true, obj: defaultObj, mixed: 0 @@ -163,8 +163,8 @@ describe('vue-compat', () => { }) it('should cache default values for the same elements', () => { - expect(vueCompat.getDefaultValues('mip-custom', MIPCustom)) - .to.equal(vueCompat.getDefaultValues('mip-custom', MIPCustom)) + expect(vueCompat.getDefaultProps('mip-custom', MIPCustom)) + .to.equal(vueCompat.getDefaultProps('mip-custom', MIPCustom)) }) }) diff --git a/packages/mip/test/specs/util/string.spec.js b/packages/mip/test/specs/util/string.spec.js index 7d4be1b8..f50de197 100644 --- a/packages/mip/test/specs/util/string.spec.js +++ b/packages/mip/test/specs/util/string.spec.js @@ -1,4 +1,4 @@ -import {camelize, hyphenate} from 'src/util/string' +import {camelize, capitalize, hyphenate} from 'src/util/string' describe('camelize', () => { it('should transform dash-case string to camelCase', () => { @@ -8,6 +8,14 @@ describe('camelize', () => { }) }) +describe('capitalize', () => { + it('should transform camelCase string to PascalCase', () => { + expect(capitalize('foo')).to.equal('Foo') + expect(capitalize('fooBar')).to.equal('FooBar') + expect(capitalize('fooBarBaz')).to.equal('FooBarBaz') + }) +}) + describe('hyphenate', () => { it('should transform camelCase string to dash-case', () => { expect(hyphenate('foo')).to.equal('foo') diff --git a/packages/mip/test/specs/vue-custom-element/index.spec.js b/packages/mip/test/specs/vue-custom-element/index.spec.js index 527cf0c6..af01bfbe 100644 --- a/packages/mip/test/specs/vue-custom-element/index.spec.js +++ b/packages/mip/test/specs/vue-custom-element/index.spec.js @@ -3,13 +3,33 @@ * @author huanghuiquan (huanghuiquan@baidu.com) */ +import Services from 'src/services' import Vue from 'vue' -import 'src/vue-custom-element' let prefix = 'vue-custom-element-index-' -describe('vue custom element', function () { - const vue = MIP.Services.getService('mip-vue') +describe('vue custom element', () => { + /** + * @type {sinon.SinonSandbox} + */ + let sandbox + + /** + * @type {import('src/vue-custom-element').MIPVue} + */ + let vue + + beforeEach(() => { + sandbox = sinon.createSandbox() + window.services['mip-vue'] = null + delete require.cache[require.resolve('src/vue-custom-element')] + require('src/vue-custom-element') + vue = Services.getService('mip-vue') + }) + + afterEach(() => { + sandbox.restore() + }) it('install customElment to Vue', function () { expect(typeof Vue.customElement).to.equal('function') @@ -21,7 +41,10 @@ describe('vue custom element', function () { let connectedCallback = sinon.spy() vue.registerElement(name, { created, - connectedCallback + connectedCallback, + render () { + return null + } }) let ele = document.createElement(name) @@ -176,6 +199,9 @@ describe('vue custom element', function () { let comp = { prerenderAllowed () { return true + }, + render () { + return null } } lifecycs.map(name => {