123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461 |
- import { walk, saferEval, saferEvalNoReturn, getXAttrs, debounce, convertClassStringToArray, TRANSITION_CANCELLED } from './utils'
- import { handleForDirective } from './directives/for'
- import { handleAttributeBindingDirective } from './directives/bind'
- import { handleTextDirective } from './directives/text'
- import { handleHtmlDirective } from './directives/html'
- import { handleShowDirective } from './directives/show'
- import { handleIfDirective } from './directives/if'
- import { registerModelListener } from './directives/model'
- import { registerListener } from './directives/on'
- import { unwrap, wrap } from './observable'
- import Alpine from './index'
- export default class Component {
- constructor(el, componentForClone = null) {
- this.$el = el
- const dataAttr = this.$el.getAttribute('x-data')
- const dataExpression = dataAttr === '' ? '{}' : dataAttr
- const initExpression = this.$el.getAttribute('x-init')
- let dataExtras = {
- $el: this.$el,
- }
- let canonicalComponentElementReference = componentForClone ? componentForClone.$el : this.$el
- Object.entries(Alpine.magicProperties).forEach(([name, callback]) => {
- Object.defineProperty(dataExtras, `$${name}`, { get: function () { return callback(canonicalComponentElementReference) } });
- })
- this.unobservedData = componentForClone ? componentForClone.getUnobservedData() : saferEval(dataExpression, dataExtras)
- /* IE11-ONLY:START */
- // For IE11, add our magic properties to the original data for access.
- // The Proxy polyfill does not allow properties to be added after creation.
- this.unobservedData.$el = null
- this.unobservedData.$refs = null
- this.unobservedData.$nextTick = null
- this.unobservedData.$watch = null
- // The IE build uses a proxy polyfill which doesn't allow properties
- // to be defined after the proxy object is created so,
- // for IE only, we need to define our helpers earlier.
- Object.entries(Alpine.magicProperties).forEach(([name, callback]) => {
- Object.defineProperty(this.unobservedData, `$${name}`, { get: function () { return callback(canonicalComponentElementReference, this.$el) } });
- })
- /* IE11-ONLY:END */
- // Construct a Proxy-based observable. This will be used to handle reactivity.
- let { membrane, data } = this.wrapDataInObservable(this.unobservedData)
- this.$data = data
- this.membrane = membrane
- // After making user-supplied data methods reactive, we can now add
- // our magic properties to the original data for access.
- this.unobservedData.$el = this.$el
- this.unobservedData.$refs = this.getRefsProxy()
- this.nextTickStack = []
- this.unobservedData.$nextTick = (callback) => {
- this.nextTickStack.push(callback)
- }
- this.watchers = {}
- this.unobservedData.$watch = (property, callback) => {
- if (! this.watchers[property]) this.watchers[property] = []
- this.watchers[property].push(callback)
- }
- /* MODERN-ONLY:START */
- // We remove this piece of code from the legacy build.
- // In IE11, we have already defined our helpers at this point.
- // Register custom magic properties.
- Object.entries(Alpine.magicProperties).forEach(([name, callback]) => {
- Object.defineProperty(this.unobservedData, `$${name}`, { get: function () { return callback(canonicalComponentElementReference, this.$el) } });
- })
- /* MODERN-ONLY:END */
- this.showDirectiveStack = []
- this.showDirectiveLastElement
- componentForClone || Alpine.onBeforeComponentInitializeds.forEach(callback => callback(this))
- var initReturnedCallback
- // If x-init is present AND we aren't cloning (skip x-init on clone)
- if (initExpression && ! componentForClone) {
- // We want to allow data manipulation, but not trigger DOM updates just yet.
- // We haven't even initialized the elements with their Alpine bindings. I mean c'mon.
- this.pauseReactivity = true
- initReturnedCallback = this.evaluateReturnExpression(this.$el, initExpression)
- this.pauseReactivity = false
- }
- // Register all our listeners and set all our attribute bindings.
- this.initializeElements(this.$el)
- // Use mutation observer to detect new elements being added within this component at run-time.
- // Alpine's just so darn flexible amirite?
- this.listenForNewElementsToInitialize()
- if (typeof initReturnedCallback === 'function') {
- // Run the callback returned from the "x-init" hook to allow the user to do stuff after
- // Alpine's got it's grubby little paws all over everything.
- initReturnedCallback.call(this.$data)
- }
- componentForClone || setTimeout(() => {
- Alpine.onComponentInitializeds.forEach(callback => callback(this))
- }, 0)
- }
- getUnobservedData() {
- return unwrap(this.membrane, this.$data)
- }
- wrapDataInObservable(data) {
- var self = this
- let updateDom = debounce(function () {
- self.updateElements(self.$el)
- }, 0)
- return wrap(data, (target, key) => {
- if (self.watchers[key]) {
- // If there's a watcher for this specific key, run it.
- self.watchers[key].forEach(callback => callback(target[key]))
- } else if (Array.isArray(target)) {
- // Arrays are special cases, if any of the items change, we consider the array as mutated.
- Object.keys(self.watchers)
- .forEach(fullDotNotationKey => {
- let dotNotationParts = fullDotNotationKey.split('.')
- // Ignore length mutations since they would result in duplicate calls.
- // For example, when calling push, we would get a mutation for the item's key
- // and a second mutation for the length property.
- if (key === 'length') return
- dotNotationParts.reduce((comparisonData, part) => {
- if (Object.is(target, comparisonData[part])) {
- self.watchers[fullDotNotationKey].forEach(callback => callback(target))
- }
- return comparisonData[part]
- }, self.unobservedData)
- })
- } else {
- // Let's walk through the watchers with "dot-notation" (foo.bar) and see
- // if this mutation fits any of them.
- Object.keys(self.watchers)
- .filter(i => i.includes('.'))
- .forEach(fullDotNotationKey => {
- let dotNotationParts = fullDotNotationKey.split('.')
- // If this dot-notation watcher's last "part" doesn't match the current
- // key, then skip it early for performance reasons.
- if (key !== dotNotationParts[dotNotationParts.length - 1]) return
- // Now, walk through the dot-notation "parts" recursively to find
- // a match, and call the watcher if one's found.
- dotNotationParts.reduce((comparisonData, part) => {
- if (Object.is(target, comparisonData)) {
- // Run the watchers.
- self.watchers[fullDotNotationKey].forEach(callback => callback(target[key]))
- }
- return comparisonData[part]
- }, self.unobservedData)
- })
- }
- // Don't react to data changes for cases like the `x-created` hook.
- if (self.pauseReactivity) return
- updateDom()
- })
- }
- walkAndSkipNestedComponents(el, callback, initializeComponentCallback = () => {}) {
- walk(el, el => {
- // We've hit a component.
- if (el.hasAttribute('x-data')) {
- // If it's not the current one.
- if (! el.isSameNode(this.$el)) {
- // Initialize it if it's not.
- if (! el.__x) initializeComponentCallback(el)
- // Now we'll let that sub-component deal with itself.
- return false
- }
- }
- return callback(el)
- })
- }
- initializeElements(rootEl, extraVars = () => {}) {
- this.walkAndSkipNestedComponents(rootEl, el => {
- // Don't touch spawns from for loop
- if (el.__x_for_key !== undefined) return false
- // Don't touch spawns from if directives
- if (el.__x_inserted_me !== undefined) return false
- this.initializeElement(el, extraVars)
- }, el => {
- el.__x = new Component(el)
- })
- this.executeAndClearRemainingShowDirectiveStack()
- this.executeAndClearNextTickStack(rootEl)
- }
- initializeElement(el, extraVars) {
- // To support class attribute merging, we have to know what the element's
- // original class attribute looked like for reference.
- if (el.hasAttribute('class') && getXAttrs(el, this).length > 0) {
- el.__x_original_classes = convertClassStringToArray(el.getAttribute('class'))
- }
- this.registerListeners(el, extraVars)
- this.resolveBoundAttributes(el, true, extraVars)
- }
- updateElements(rootEl, extraVars = () => {}) {
- this.walkAndSkipNestedComponents(rootEl, el => {
- // Don't touch spawns from for loop (and check if the root is actually a for loop in a parent, don't skip it.)
- if (el.__x_for_key !== undefined && ! el.isSameNode(this.$el)) return false
- this.updateElement(el, extraVars)
- }, el => {
- el.__x = new Component(el)
- })
- this.executeAndClearRemainingShowDirectiveStack()
- this.executeAndClearNextTickStack(rootEl)
- }
- executeAndClearNextTickStack(el) {
- // Skip spawns from alpine directives
- if (el === this.$el && this.nextTickStack.length > 0) {
- // We run the tick stack after the next frame to allow any
- // running transitions to pass the initial show stage.
- requestAnimationFrame(() => {
- while (this.nextTickStack.length > 0) {
- this.nextTickStack.shift()()
- }
- })
- }
- }
- executeAndClearRemainingShowDirectiveStack() {
- // The goal here is to start all the x-show transitions
- // and build a nested promise chain so that elements
- // only hide when the children are finished hiding.
- this.showDirectiveStack.reverse().map(handler => {
- return new Promise((resolve, reject) => {
- handler(resolve, reject)
- })
- }).reduce((promiseChain, promise) => {
- return promiseChain.then(() => {
- return promise.then(finishElement => {
- finishElement()
- })
- })
- }, Promise.resolve(() => {})).catch(e => {
- if (e !== TRANSITION_CANCELLED) throw e
- })
- // We've processed the handler stack. let's clear it.
- this.showDirectiveStack = []
- this.showDirectiveLastElement = undefined
- }
- updateElement(el, extraVars) {
- this.resolveBoundAttributes(el, false, extraVars)
- }
- registerListeners(el, extraVars) {
- getXAttrs(el, this).forEach(({ type, value, modifiers, expression }) => {
- switch (type) {
- case 'on':
- registerListener(this, el, value, modifiers, expression, extraVars)
- break;
- case 'model':
- registerModelListener(this, el, modifiers, expression, extraVars)
- break;
- default:
- break;
- }
- })
- }
- resolveBoundAttributes(el, initialUpdate = false, extraVars) {
- let attrs = getXAttrs(el, this)
- attrs.forEach(({ type, value, modifiers, expression }) => {
- switch (type) {
- case 'model':
- handleAttributeBindingDirective(this, el, 'value', expression, extraVars, type, modifiers)
- break;
- case 'bind':
- // The :key binding on an x-for is special, ignore it.
- if (el.tagName.toLowerCase() === 'template' && value === 'key') return
- handleAttributeBindingDirective(this, el, value, expression, extraVars, type, modifiers)
- break;
- case 'text':
- var output = this.evaluateReturnExpression(el, expression, extraVars);
- handleTextDirective(el, output, expression)
- break;
- case 'html':
- handleHtmlDirective(this, el, expression, extraVars)
- break;
- case 'show':
- var output = this.evaluateReturnExpression(el, expression, extraVars)
- handleShowDirective(this, el, output, modifiers, initialUpdate)
- break;
- case 'if':
- // If this element also has x-for on it, don't process x-if.
- // We will let the "x-for" directive handle the "if"ing.
- if (attrs.some(i => i.type === 'for')) return
- var output = this.evaluateReturnExpression(el, expression, extraVars)
- handleIfDirective(this, el, output, initialUpdate, extraVars)
- break;
- case 'for':
- handleForDirective(this, el, expression, initialUpdate, extraVars)
- break;
- case 'cloak':
- el.removeAttribute('x-cloak')
- break;
- default:
- break;
- }
- })
- }
- evaluateReturnExpression(el, expression, extraVars = () => {}) {
- return saferEval(expression, this.$data, {
- ...extraVars(),
- $dispatch: this.getDispatchFunction(el),
- })
- }
- evaluateCommandExpression(el, expression, extraVars = () => {}) {
- return saferEvalNoReturn(expression, this.$data, {
- ...extraVars(),
- $dispatch: this.getDispatchFunction(el),
- })
- }
- getDispatchFunction (el) {
- return (event, detail = {}) => {
- el.dispatchEvent(new CustomEvent(event, {
- detail,
- bubbles: true,
- }))
- }
- }
- listenForNewElementsToInitialize() {
- const targetNode = this.$el
- const observerOptions = {
- childList: true,
- attributes: true,
- subtree: true,
- }
- const observer = new MutationObserver((mutations) => {
- for (let i=0; i < mutations.length; i++) {
- // Filter out mutations triggered from child components.
- const closestParentComponent = mutations[i].target.closest('[x-data]')
- if (! (closestParentComponent && closestParentComponent.isSameNode(this.$el))) continue
- if (mutations[i].type === 'attributes' && mutations[i].attributeName === 'x-data') {
- const rawData = saferEval(mutations[i].target.getAttribute('x-data') || '{}', { $el: this.$el })
- Object.keys(rawData).forEach(key => {
- if (this.$data[key] !== rawData[key]) {
- this.$data[key] = rawData[key]
- }
- })
- }
- if (mutations[i].addedNodes.length > 0) {
- mutations[i].addedNodes.forEach(node => {
- if (node.nodeType !== 1 || node.__x_inserted_me) return
- if (node.matches('[x-data]') && ! node.__x) {
- node.__x = new Component(node)
- return
- }
- this.initializeElements(node)
- })
- }
- }
- })
- observer.observe(targetNode, observerOptions);
- }
- getRefsProxy() {
- var self = this
- var refObj = {}
- /* IE11-ONLY:START */
- // Add any properties up-front that might be necessary for the Proxy polyfill.
- refObj.$isRefsProxy = false;
- refObj.$isAlpineProxy = false;
- // If we are in IE, since the polyfill needs all properties to be defined before building the proxy,
- // we just loop on the element, look for any x-ref and create a tmp property on a fake object.
- this.walkAndSkipNestedComponents(self.$el, el => {
- if (el.hasAttribute('x-ref')) {
- refObj[el.getAttribute('x-ref')] = true
- }
- })
- /* IE11-ONLY:END */
- // One of the goals of this is to not hold elements in memory, but rather re-evaluate
- // the DOM when the system needs something from it. This way, the framework is flexible and
- // friendly to outside DOM changes from libraries like Vue/Livewire.
- // For this reason, I'm using an "on-demand" proxy to fake a "$refs" object.
- return new Proxy(refObj, {
- get(object, property) {
- if (property === '$isAlpineProxy') return true
- var ref
- // We can't just query the DOM because it's hard to filter out refs in
- // nested components.
- self.walkAndSkipNestedComponents(self.$el, el => {
- if (el.hasAttribute('x-ref') && el.getAttribute('x-ref') === property) {
- ref = el
- }
- })
- return ref
- }
- })
- }
- }
|