component.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. import { walk, saferEval, saferEvalNoReturn, getXAttrs, debounce, convertClassStringToArray, TRANSITION_CANCELLED } from './utils'
  2. import { handleForDirective } from './directives/for'
  3. import { handleAttributeBindingDirective } from './directives/bind'
  4. import { handleTextDirective } from './directives/text'
  5. import { handleHtmlDirective } from './directives/html'
  6. import { handleShowDirective } from './directives/show'
  7. import { handleIfDirective } from './directives/if'
  8. import { registerModelListener } from './directives/model'
  9. import { registerListener } from './directives/on'
  10. import { unwrap, wrap } from './observable'
  11. import Alpine from './index'
  12. export default class Component {
  13. constructor(el, componentForClone = null) {
  14. this.$el = el
  15. const dataAttr = this.$el.getAttribute('x-data')
  16. const dataExpression = dataAttr === '' ? '{}' : dataAttr
  17. const initExpression = this.$el.getAttribute('x-init')
  18. let dataExtras = {
  19. $el: this.$el,
  20. }
  21. let canonicalComponentElementReference = componentForClone ? componentForClone.$el : this.$el
  22. Object.entries(Alpine.magicProperties).forEach(([name, callback]) => {
  23. Object.defineProperty(dataExtras, `$${name}`, { get: function () { return callback(canonicalComponentElementReference) } });
  24. })
  25. this.unobservedData = componentForClone ? componentForClone.getUnobservedData() : saferEval(dataExpression, dataExtras)
  26. /* IE11-ONLY:START */
  27. // For IE11, add our magic properties to the original data for access.
  28. // The Proxy polyfill does not allow properties to be added after creation.
  29. this.unobservedData.$el = null
  30. this.unobservedData.$refs = null
  31. this.unobservedData.$nextTick = null
  32. this.unobservedData.$watch = null
  33. // The IE build uses a proxy polyfill which doesn't allow properties
  34. // to be defined after the proxy object is created so,
  35. // for IE only, we need to define our helpers earlier.
  36. Object.entries(Alpine.magicProperties).forEach(([name, callback]) => {
  37. Object.defineProperty(this.unobservedData, `$${name}`, { get: function () { return callback(canonicalComponentElementReference, this.$el) } });
  38. })
  39. /* IE11-ONLY:END */
  40. // Construct a Proxy-based observable. This will be used to handle reactivity.
  41. let { membrane, data } = this.wrapDataInObservable(this.unobservedData)
  42. this.$data = data
  43. this.membrane = membrane
  44. // After making user-supplied data methods reactive, we can now add
  45. // our magic properties to the original data for access.
  46. this.unobservedData.$el = this.$el
  47. this.unobservedData.$refs = this.getRefsProxy()
  48. this.nextTickStack = []
  49. this.unobservedData.$nextTick = (callback) => {
  50. this.nextTickStack.push(callback)
  51. }
  52. this.watchers = {}
  53. this.unobservedData.$watch = (property, callback) => {
  54. if (! this.watchers[property]) this.watchers[property] = []
  55. this.watchers[property].push(callback)
  56. }
  57. /* MODERN-ONLY:START */
  58. // We remove this piece of code from the legacy build.
  59. // In IE11, we have already defined our helpers at this point.
  60. // Register custom magic properties.
  61. Object.entries(Alpine.magicProperties).forEach(([name, callback]) => {
  62. Object.defineProperty(this.unobservedData, `$${name}`, { get: function () { return callback(canonicalComponentElementReference, this.$el) } });
  63. })
  64. /* MODERN-ONLY:END */
  65. this.showDirectiveStack = []
  66. this.showDirectiveLastElement
  67. componentForClone || Alpine.onBeforeComponentInitializeds.forEach(callback => callback(this))
  68. var initReturnedCallback
  69. // If x-init is present AND we aren't cloning (skip x-init on clone)
  70. if (initExpression && ! componentForClone) {
  71. // We want to allow data manipulation, but not trigger DOM updates just yet.
  72. // We haven't even initialized the elements with their Alpine bindings. I mean c'mon.
  73. this.pauseReactivity = true
  74. initReturnedCallback = this.evaluateReturnExpression(this.$el, initExpression)
  75. this.pauseReactivity = false
  76. }
  77. // Register all our listeners and set all our attribute bindings.
  78. this.initializeElements(this.$el)
  79. // Use mutation observer to detect new elements being added within this component at run-time.
  80. // Alpine's just so darn flexible amirite?
  81. this.listenForNewElementsToInitialize()
  82. if (typeof initReturnedCallback === 'function') {
  83. // Run the callback returned from the "x-init" hook to allow the user to do stuff after
  84. // Alpine's got it's grubby little paws all over everything.
  85. initReturnedCallback.call(this.$data)
  86. }
  87. componentForClone || setTimeout(() => {
  88. Alpine.onComponentInitializeds.forEach(callback => callback(this))
  89. }, 0)
  90. }
  91. getUnobservedData() {
  92. return unwrap(this.membrane, this.$data)
  93. }
  94. wrapDataInObservable(data) {
  95. var self = this
  96. let updateDom = debounce(function () {
  97. self.updateElements(self.$el)
  98. }, 0)
  99. return wrap(data, (target, key) => {
  100. if (self.watchers[key]) {
  101. // If there's a watcher for this specific key, run it.
  102. self.watchers[key].forEach(callback => callback(target[key]))
  103. } else if (Array.isArray(target)) {
  104. // Arrays are special cases, if any of the items change, we consider the array as mutated.
  105. Object.keys(self.watchers)
  106. .forEach(fullDotNotationKey => {
  107. let dotNotationParts = fullDotNotationKey.split('.')
  108. // Ignore length mutations since they would result in duplicate calls.
  109. // For example, when calling push, we would get a mutation for the item's key
  110. // and a second mutation for the length property.
  111. if (key === 'length') return
  112. dotNotationParts.reduce((comparisonData, part) => {
  113. if (Object.is(target, comparisonData[part])) {
  114. self.watchers[fullDotNotationKey].forEach(callback => callback(target))
  115. }
  116. return comparisonData[part]
  117. }, self.unobservedData)
  118. })
  119. } else {
  120. // Let's walk through the watchers with "dot-notation" (foo.bar) and see
  121. // if this mutation fits any of them.
  122. Object.keys(self.watchers)
  123. .filter(i => i.includes('.'))
  124. .forEach(fullDotNotationKey => {
  125. let dotNotationParts = fullDotNotationKey.split('.')
  126. // If this dot-notation watcher's last "part" doesn't match the current
  127. // key, then skip it early for performance reasons.
  128. if (key !== dotNotationParts[dotNotationParts.length - 1]) return
  129. // Now, walk through the dot-notation "parts" recursively to find
  130. // a match, and call the watcher if one's found.
  131. dotNotationParts.reduce((comparisonData, part) => {
  132. if (Object.is(target, comparisonData)) {
  133. // Run the watchers.
  134. self.watchers[fullDotNotationKey].forEach(callback => callback(target[key]))
  135. }
  136. return comparisonData[part]
  137. }, self.unobservedData)
  138. })
  139. }
  140. // Don't react to data changes for cases like the `x-created` hook.
  141. if (self.pauseReactivity) return
  142. updateDom()
  143. })
  144. }
  145. walkAndSkipNestedComponents(el, callback, initializeComponentCallback = () => {}) {
  146. walk(el, el => {
  147. // We've hit a component.
  148. if (el.hasAttribute('x-data')) {
  149. // If it's not the current one.
  150. if (! el.isSameNode(this.$el)) {
  151. // Initialize it if it's not.
  152. if (! el.__x) initializeComponentCallback(el)
  153. // Now we'll let that sub-component deal with itself.
  154. return false
  155. }
  156. }
  157. return callback(el)
  158. })
  159. }
  160. initializeElements(rootEl, extraVars = () => {}) {
  161. this.walkAndSkipNestedComponents(rootEl, el => {
  162. // Don't touch spawns from for loop
  163. if (el.__x_for_key !== undefined) return false
  164. // Don't touch spawns from if directives
  165. if (el.__x_inserted_me !== undefined) return false
  166. this.initializeElement(el, extraVars)
  167. }, el => {
  168. el.__x = new Component(el)
  169. })
  170. this.executeAndClearRemainingShowDirectiveStack()
  171. this.executeAndClearNextTickStack(rootEl)
  172. }
  173. initializeElement(el, extraVars) {
  174. // To support class attribute merging, we have to know what the element's
  175. // original class attribute looked like for reference.
  176. if (el.hasAttribute('class') && getXAttrs(el, this).length > 0) {
  177. el.__x_original_classes = convertClassStringToArray(el.getAttribute('class'))
  178. }
  179. this.registerListeners(el, extraVars)
  180. this.resolveBoundAttributes(el, true, extraVars)
  181. }
  182. updateElements(rootEl, extraVars = () => {}) {
  183. this.walkAndSkipNestedComponents(rootEl, el => {
  184. // Don't touch spawns from for loop (and check if the root is actually a for loop in a parent, don't skip it.)
  185. if (el.__x_for_key !== undefined && ! el.isSameNode(this.$el)) return false
  186. this.updateElement(el, extraVars)
  187. }, el => {
  188. el.__x = new Component(el)
  189. })
  190. this.executeAndClearRemainingShowDirectiveStack()
  191. this.executeAndClearNextTickStack(rootEl)
  192. }
  193. executeAndClearNextTickStack(el) {
  194. // Skip spawns from alpine directives
  195. if (el === this.$el && this.nextTickStack.length > 0) {
  196. // We run the tick stack after the next frame to allow any
  197. // running transitions to pass the initial show stage.
  198. requestAnimationFrame(() => {
  199. while (this.nextTickStack.length > 0) {
  200. this.nextTickStack.shift()()
  201. }
  202. })
  203. }
  204. }
  205. executeAndClearRemainingShowDirectiveStack() {
  206. // The goal here is to start all the x-show transitions
  207. // and build a nested promise chain so that elements
  208. // only hide when the children are finished hiding.
  209. this.showDirectiveStack.reverse().map(handler => {
  210. return new Promise((resolve, reject) => {
  211. handler(resolve, reject)
  212. })
  213. }).reduce((promiseChain, promise) => {
  214. return promiseChain.then(() => {
  215. return promise.then(finishElement => {
  216. finishElement()
  217. })
  218. })
  219. }, Promise.resolve(() => {})).catch(e => {
  220. if (e !== TRANSITION_CANCELLED) throw e
  221. })
  222. // We've processed the handler stack. let's clear it.
  223. this.showDirectiveStack = []
  224. this.showDirectiveLastElement = undefined
  225. }
  226. updateElement(el, extraVars) {
  227. this.resolveBoundAttributes(el, false, extraVars)
  228. }
  229. registerListeners(el, extraVars) {
  230. getXAttrs(el, this).forEach(({ type, value, modifiers, expression }) => {
  231. switch (type) {
  232. case 'on':
  233. registerListener(this, el, value, modifiers, expression, extraVars)
  234. break;
  235. case 'model':
  236. registerModelListener(this, el, modifiers, expression, extraVars)
  237. break;
  238. default:
  239. break;
  240. }
  241. })
  242. }
  243. resolveBoundAttributes(el, initialUpdate = false, extraVars) {
  244. let attrs = getXAttrs(el, this)
  245. attrs.forEach(({ type, value, modifiers, expression }) => {
  246. switch (type) {
  247. case 'model':
  248. handleAttributeBindingDirective(this, el, 'value', expression, extraVars, type, modifiers)
  249. break;
  250. case 'bind':
  251. // The :key binding on an x-for is special, ignore it.
  252. if (el.tagName.toLowerCase() === 'template' && value === 'key') return
  253. handleAttributeBindingDirective(this, el, value, expression, extraVars, type, modifiers)
  254. break;
  255. case 'text':
  256. var output = this.evaluateReturnExpression(el, expression, extraVars);
  257. handleTextDirective(el, output, expression)
  258. break;
  259. case 'html':
  260. handleHtmlDirective(this, el, expression, extraVars)
  261. break;
  262. case 'show':
  263. var output = this.evaluateReturnExpression(el, expression, extraVars)
  264. handleShowDirective(this, el, output, modifiers, initialUpdate)
  265. break;
  266. case 'if':
  267. // If this element also has x-for on it, don't process x-if.
  268. // We will let the "x-for" directive handle the "if"ing.
  269. if (attrs.some(i => i.type === 'for')) return
  270. var output = this.evaluateReturnExpression(el, expression, extraVars)
  271. handleIfDirective(this, el, output, initialUpdate, extraVars)
  272. break;
  273. case 'for':
  274. handleForDirective(this, el, expression, initialUpdate, extraVars)
  275. break;
  276. case 'cloak':
  277. el.removeAttribute('x-cloak')
  278. break;
  279. default:
  280. break;
  281. }
  282. })
  283. }
  284. evaluateReturnExpression(el, expression, extraVars = () => {}) {
  285. return saferEval(expression, this.$data, {
  286. ...extraVars(),
  287. $dispatch: this.getDispatchFunction(el),
  288. })
  289. }
  290. evaluateCommandExpression(el, expression, extraVars = () => {}) {
  291. return saferEvalNoReturn(expression, this.$data, {
  292. ...extraVars(),
  293. $dispatch: this.getDispatchFunction(el),
  294. })
  295. }
  296. getDispatchFunction (el) {
  297. return (event, detail = {}) => {
  298. el.dispatchEvent(new CustomEvent(event, {
  299. detail,
  300. bubbles: true,
  301. }))
  302. }
  303. }
  304. listenForNewElementsToInitialize() {
  305. const targetNode = this.$el
  306. const observerOptions = {
  307. childList: true,
  308. attributes: true,
  309. subtree: true,
  310. }
  311. const observer = new MutationObserver((mutations) => {
  312. for (let i=0; i < mutations.length; i++) {
  313. // Filter out mutations triggered from child components.
  314. const closestParentComponent = mutations[i].target.closest('[x-data]')
  315. if (! (closestParentComponent && closestParentComponent.isSameNode(this.$el))) continue
  316. if (mutations[i].type === 'attributes' && mutations[i].attributeName === 'x-data') {
  317. const rawData = saferEval(mutations[i].target.getAttribute('x-data') || '{}', { $el: this.$el })
  318. Object.keys(rawData).forEach(key => {
  319. if (this.$data[key] !== rawData[key]) {
  320. this.$data[key] = rawData[key]
  321. }
  322. })
  323. }
  324. if (mutations[i].addedNodes.length > 0) {
  325. mutations[i].addedNodes.forEach(node => {
  326. if (node.nodeType !== 1 || node.__x_inserted_me) return
  327. if (node.matches('[x-data]') && ! node.__x) {
  328. node.__x = new Component(node)
  329. return
  330. }
  331. this.initializeElements(node)
  332. })
  333. }
  334. }
  335. })
  336. observer.observe(targetNode, observerOptions);
  337. }
  338. getRefsProxy() {
  339. var self = this
  340. var refObj = {}
  341. /* IE11-ONLY:START */
  342. // Add any properties up-front that might be necessary for the Proxy polyfill.
  343. refObj.$isRefsProxy = false;
  344. refObj.$isAlpineProxy = false;
  345. // If we are in IE, since the polyfill needs all properties to be defined before building the proxy,
  346. // we just loop on the element, look for any x-ref and create a tmp property on a fake object.
  347. this.walkAndSkipNestedComponents(self.$el, el => {
  348. if (el.hasAttribute('x-ref')) {
  349. refObj[el.getAttribute('x-ref')] = true
  350. }
  351. })
  352. /* IE11-ONLY:END */
  353. // One of the goals of this is to not hold elements in memory, but rather re-evaluate
  354. // the DOM when the system needs something from it. This way, the framework is flexible and
  355. // friendly to outside DOM changes from libraries like Vue/Livewire.
  356. // For this reason, I'm using an "on-demand" proxy to fake a "$refs" object.
  357. return new Proxy(refObj, {
  358. get(object, property) {
  359. if (property === '$isAlpineProxy') return true
  360. var ref
  361. // We can't just query the DOM because it's hard to filter out refs in
  362. // nested components.
  363. self.walkAndSkipNestedComponents(self.$el, el => {
  364. if (el.hasAttribute('x-ref') && el.getAttribute('x-ref') === property) {
  365. ref = el
  366. }
  367. })
  368. return ref
  369. }
  370. })
  371. }
  372. }