Browse Source

Merge pull request #74 from alpinejs/cp/refactor-out-selective-reactivity

Refactor out dependency tracking
Caleb Porzio 5 years ago
parent
commit
1047d919d9
6 changed files with 186 additions and 159 deletions
  1. 0 0
      dist/alpine.js
  2. 0 0
      dist/alpine.js.map
  3. 102 26
      index.html
  4. 41 117
      src/component.js
  5. 10 16
      src/utils.js
  6. 33 0
      test/data.spec.js

File diff suppressed because it is too large
+ 0 - 0
dist/alpine.js


File diff suppressed because it is too large
+ 0 - 0
dist/alpine.js.map


+ 102 - 26
index.html

@@ -4,16 +4,9 @@
             .hidden { display: none; }
             [x-cloak] { display: none; }
         </style>
-
         <script src="/dist/alpine.js" defer></script>
     </head>
     <body>
-        <div x-data="{ foo: 'bar' }">
-            <span :class="foo"></span>
-
-            <button @click="foo = 'baz'">hey</button>
-        </div>
-
         <table>
             <thead>
                 <tr>
@@ -26,13 +19,11 @@
                     <td>Dropdown</td>
                     <td>
                         <div x-data="{ open: false }">
-                            <button x-on:click="open = true">Open Dropdown</button>
-
+                            <button x-on:click="open= true">Open Dropdown</button>
                             <ul
                                 x-bind:class="{ 'hidden': ! open }"
-                                x-on:click.away="open = false"
-                                x-cloak
-                            >
+                                x-on:click.away="open= false"
+                                x-cloak>
                                 Dropdown Body
                             </ul>
                         </div>
@@ -43,11 +34,14 @@
                     <td>Tabs</td>
                     <td>
                         <div x-data="{ currentTab: 'tab1' }">
-                            <button x-bind:class="{ 'active': currentTab === 'tab1' }" x-on:click="currentTab = 'tab1'">Foo</button>
-                            <button x-bind:class="{ 'active': currentTab === 'tab2' }" x-on:click="currentTab = 'tab2'">Bar</button>
-
-                            <div x-bind:class="{ 'hidden': currentTab !== 'tab1' }">Tab Foo</div>
-                            <div class="hidden" x-bind:class="{ 'hidden': currentTab !== 'tab2' }">Tab Bar</div>
+                            <button x-bind:class="{ 'active': currentTab ===
+                                'tab1' }" x-on:click="currentTab= 'tab1'">Foo</button>
+                            <button x-bind:class="{ 'active': currentTab ===
+                                'tab2' }" x-on:click="currentTab= 'tab2'">Bar</button>
+                            <div x-bind:class="{ 'hidden': currentTab !== 'tab1'
+                                }">Tab Foo</div>
+                            <div class="hidden" x-bind:class="{ 'hidden':
+                                currentTab !== 'tab2' }">Tab Bar</div>
                         </div>
                     </td>
                 </tr>
@@ -55,22 +49,29 @@
                 <tr>
                     <td>Data Binding</td>
                     <td>
-                        <div x-data="{ text: 'foo', checkbox: ['foo'], radio: 'foo', select: 'foo', 'multiselect': ['foo'] }">
-                            <div x-text="JSON.stringify($data)"></div>
+                        <div x-data="{ text: 'foo', checkbox: ['foo'], radio:
+                            'foo', select: 'foo', 'multiselect': ['foo'] }">
                             Text:
+                            <span x-text="JSON.stringify(text)"></span>
                             <input type="text" x-model="text">
                             Checkbox:
-                            <input type="checkbox" x-model="checkbox" value="foo">
-                            <input type="checkbox" x-model="checkbox" value="bar">
+                            <span x-text="JSON.stringify(checkbox)"></span>
+                            <input type="checkbox" x-model="checkbox"
+                                value="foo">
+                            <input type="checkbox" x-model="checkbox"
+                                value="bar">
                             Radio:
+                            <span x-text="JSON.stringify(radio)"></span>
                             <input type="radio" x-model="radio" value="foo">
                             <input type="radio" x-model="radio" value="bar">
                             Select:
+                            <span x-text="JSON.stringify(select)"></span>
                             <select x-model="select">
                                 <option>foo</option>
                                 <option>bar</option>
                             </select>
                             Multiple Select:
+                            <span x-text="JSON.stringify(multiselect)"></span>
                             <select x-model="multiselect" multiple>
                                 <option>foo</option>
                                 <option>bar</option>
@@ -105,7 +106,7 @@
                         <div x-data="{ show: false }">
                             <div x-show="show">Hi There!</div>
 
-                            <button x-on:click="show = ! show">Show/Hide</button>
+                            <button x-on:click="show= ! show">Show/Hide</button>
                         </div>
                     </td>
                 </tr>
@@ -116,7 +117,8 @@
                         <div x-data="{ someText: 'bar' }">
                             <div x-ref="remove-target">Remove Me</div>
 
-                            <button x-on:click="$refs['remove-target'].remove()">Remove</button>
+                            <button
+                                x-on:click="$refs['remove-target'].remove()">Remove</button>
                         </div>
                     </td>
                 </tr>
@@ -127,7 +129,7 @@
                         <div x-data="{ someText: 'bar' }">
                             <span x-text="someText"></span>
 
-                            <div x-on:click="someText = 'baz'">
+                            <div x-on:click="someText= 'baz'">
                                 <button x-on:click.stop>Shouldn't change to baz</button>
                             </div>
 
@@ -139,7 +141,8 @@
                     <td>x-on:click.prevent</td>
                     <td>
                         <div x-data="{}">
-                            <a href="https://google.com" x-on:click.prevent>Shouldn't go to Google</a>
+                            <a href="https://google.com" x-on:click.prevent>Shouldn't
+                                go to Google</a>
                         </div>
                     </td>
                 </tr>
@@ -148,7 +151,8 @@
                     <td>x-on:click.once</td>
                     <td>
                         <div x-data="{ count: 0 }">
-                            <button x-on:click.once="count++">I've been clicked: <span x-text="count"></span></button>
+                            <button x-on:click.once="count++">I've been clicked:
+                                <span x-text="count"></span></button>
                         </div>
                     </td>
                 </tr>
@@ -173,6 +177,78 @@
                     </td>
                 </tr>
 
+                <tr>
+                    <td>Transitions</td>
+                    <td>
+                        <style>
+                            .opacity-0 { opacity: 0; }
+                                .opacity-100 { opacity: 1; }
+                                .transition-slow { transition-duration: 300ms; }
+                                .transition-medium { transition-duration: 200ms; }
+                                .transition-faster { transition-duration: 100ms; }
+                                .scale-90 { transform: scale(0.9); }
+                                .scale-100 { transform: scale(1); }
+                                .ease-in { transition-timing-function: cubic-bezier(0.4, 0, 1, 1); }
+                                .ease-out { transition-timing-function: cubic-bezier(0, 0, 0.2, 1); }
+                        </style>
+                        <div x-data="{ open: false }">
+                            <button x-on:click="open= ! open">
+                                Open Modal
+                            </button>
+
+                            <div x-show="open"
+                                x-transition:enter="transition-medium"
+                                x-transition:leave="transition-medium">
+                                <div x-show="open"
+                                    x-transition:enter="transition-medium"
+                                    x-transition:enter-start="opacity-0"
+                                    x-transition:leave-end="opacity-0"
+                                    x-transition:leave="transition-medium"></div>
+                                <div x-show="open"
+                                    x-transition:enter-start="opacity-0 scale-90"
+                                    x-transition:enter="ease-out transition-medium"
+                                    x-transition:enter-end="opacity-100 scale-100"
+                                    x-transition:leave-start="opacity-100 scale-100"
+                                    x-transition:leave="ease-in transition-faster"
+                                    x-transition:leave-end="opacity-0 scale-90">
+                                    <div>
+                                        hey
+                                    </div>
+                                    <div>
+                                        <button x-on:click="open= false" type="button">
+                                            Cancel
+                                        </button>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </td>
+                </tr>
+
+                <tr>
+                    <td>Init function callback access refs and mutate data</td>
+                    <td>
+                        <div x-data="initialData()" x-init="init()">
+                            <div :style="'width: '+width+'px; background: purple;'">hey</div>
+
+                            <button x-ref="inc">increase</button>
+                        </div>
+
+                        <script>
+                            function initialData() {
+                                return {
+                                    width: 20,
+                                    init() {
+                                        this.$refs.inc.addEventListener('click', () => {
+                                            this.width = this.width + 20
+                                        })
+                                    }
+                                }
+                            }
+                        </script>
+                    </td>
+                </tr>
+
                 <tr>
                     <td>Cloak</td>
                     <td>

+ 41 - 117
src/component.js

@@ -15,26 +15,16 @@ export default class Component {
         // Construct a Proxy-based observable. This will be used to handle reactivity.
         this.$data = this.wrapDataInObservable(unobservedData)
 
-        // Walk through the raw data and set the "this" context of any functions
-        // to the observable, so data manipulations are reactive.
-        Object.keys(unobservedData).forEach(key => {
-            if (typeof unobservedData[key] === 'function') {
-                unobservedData[key] = unobservedData[key].bind(this.$data)
-            }
-        })
-
         // After making user-supplied data methods reactive, we can now add
         // our magic properties to the original data for access.
         unobservedData.$el = this.$el
         unobservedData.$refs = this.getRefsProxy()
+
+        this.nextTickStack = []
         unobservedData.$nextTick = (callback) => {
-            this.delayRunByATick(callback)
+            this.nextTickStack.push(callback)
         }
 
-        // For $nextTick().
-        this.tickStack = []
-        this.collectingTickCallbacks = false
-
         var initReturnedCallback
         if (initExpression) {
             // We want to allow data manipulation, but not trigger DOM updates just yet.
@@ -73,70 +63,46 @@ export default class Component {
     }
 
     wrapDataInObservable(data) {
-        this.concernedData = []
-
         var self = this
 
-        const proxyHandler = keyPrefix => ({
+        const proxyHandler = {
             set(obj, property, value) {
-                const propertyName = keyPrefix ? `${keyPrefix}.${property}` : property
-
                 const setWasSuccessful = Reflect.set(obj, property, value)
 
                 // Don't react to data changes for cases like the `x-created` hook.
                 if (self.pauseReactivity) return
 
-                if (self.concernedData.indexOf(propertyName) === -1) {
-                    self.concernedData.push(propertyName)
-                }
+                debounce(() => {
+                    self.refresh()
 
-                self.refresh()
+                    // Walk through the $nextTick stack and clear it as we go.
+                    while (self.nextTickStack.length > 0) {
+                        self.nextTickStack.shift()()
+                    }
+                }, 0)()
 
                 return setWasSuccessful
             },
             get(target, key) {
-                // This is because there is no way to do something like `typeof foo === 'Proxy'`.
-                if (key === 'isProxy') return true
-
                 // If the property we are trying to get is a proxy, just return it.
                 // Like in the case of $refs
-                if (target[key] && target[key].isProxy) return target[key]
+                if (target[key] && target[key].isRefsProxy) return target[key]
 
                 // If property is a DOM node, just return it. (like in the case of this.$el)
                 if (target[key] && target[key] instanceof Node) return target[key]
 
                 // If accessing a nested property, retur this proxy recursively.
+                // This enables reactivity on setting nested data.
                 if (typeof target[key] === 'object' && target[key] !== null) {
-                    const propertyName = keyPrefix ? `${keyPrefix}.${key}` : key
-
-                    return new Proxy(target[key], proxyHandler(propertyName))
+                    return new Proxy(target[key], proxyHandler)
                 }
 
                 // If none of the above, just return the flippin' value. Gawsh.
                 return target[key]
             }
-        })
-
-        return new Proxy(data, proxyHandler())
-    }
-
-    delayRunByATick(callback) {
-        if (this.collectingTickCallbacks) {
-            this.tickStack.push(callback)
-        } else {
-            callback()
         }
-    }
 
-    startTick() {
-        this.collectingTickCallbacks = true
-    }
-
-    clearAndEndTick() {
-        this.tickStack.forEach(callable => callable())
-        this.tickStack = []
-
-        this.collectingTickCallbacks = false
+        return new Proxy(data, proxyHandler)
     }
 
     initializeElements() {
@@ -146,6 +112,11 @@ export default class Component {
     }
 
     initializeElement(el) {
+        this.registerListeners(el)
+        this.resolveBoundAttributes(el, true)
+    }
+
+    registerListeners(el) {
         getXAttrs(el).forEach(({ type, value, modifiers, expression }) => {
             switch (type) {
                 case 'on':
@@ -164,35 +135,45 @@ export default class Component {
                     const listenerExpression = this.generateExpressionForXModelListener(el, modifiers, expression)
 
                     this.registerListener(el, event, modifiers, listenerExpression)
+                    break;
+                default:
+                    break;
+            }
+        })
+    }
 
+    resolveBoundAttributes(el, initialUpdate = false) {
+        getXAttrs(el).forEach(({ type, value, modifiers, expression }) => {
+            switch (type) {
+                case 'model':
                     var attrName = 'value'
-                    var { output } = this.evaluateReturnExpression(expression)
+                    var output = this.evaluateReturnExpression(expression)
                     this.updateAttributeValue(el, attrName, output)
                     break;
 
                 case 'bind':
                     var attrName = value
-                    var { output } = this.evaluateReturnExpression(expression)
+                    var output = this.evaluateReturnExpression(expression)
                     this.updateAttributeValue(el, attrName, output)
                     break;
 
                 case 'text':
-                    var { output } = this.evaluateReturnExpression(expression)
+                    var output = this.evaluateReturnExpression(expression)
                     this.updateTextValue(el, output)
                     break;
 
                 case 'html':
-                    var { output } = this.evaluateReturnExpression(expression)
+                    var output = this.evaluateReturnExpression(expression)
                     this.updateHtmlValue(el, output)
                     break;
 
                 case 'show':
-                    var { output } = this.evaluateReturnExpression(expression)
-                    this.updateVisibility(el, output, true)
+                    var output = this.evaluateReturnExpression(expression)
+                    this.updateVisibility(el, output, initialUpdate)
                     break;
 
                 case 'if':
-                    var { output } = this.evaluateReturnExpression(expression)
+                    var output = this.evaluateReturnExpression(expression)
                     this.updatePresence(el, output)
                     break;
 
@@ -248,36 +229,8 @@ export default class Component {
     }
 
     refresh() {
-        var self = this
-
-        const actionByDirectiveType = {
-            'model': ({el, output}) => { self.updateAttributeValue(el, 'value', output) },
-            'bind': ({el, attrName, output}) => { self.updateAttributeValue(el, attrName, output) },
-            'text': ({el, output}) => { self.updateTextValue(el, output) },
-            'html': ({el, output}) => { self.updateHtmlValue(el, output) },
-            'show': ({el, output}) => { self.updateVisibility(el, output) },
-            'if': ({el, output}) => { self.updatePresence(el, output) },
-        }
-
-        const walkThenClearDependancyTracker = (rootEl, callback) => {
-            walkSkippingNestedComponents(rootEl, callback)
-
-            self.concernedData = []
-            self.clearAndEndTick()
-        }
-
-        this.startTick()
-
-        debounce(walkThenClearDependancyTracker, 5)(this.$el, function (el) {
-            getXAttrs(el).forEach(({ type, value, expression }) => {
-                if (! actionByDirectiveType[type]) return
-
-                var { output, deps } = self.evaluateReturnExpression(expression)
-
-                if (self.concernedData.filter(i => deps.includes(i)).length > 0) {
-                    (actionByDirectiveType[type])({ el, attrName: value, output })
-                }
-            })
+        walkSkippingNestedComponents(this.$el, el => {
+            this.resolveBoundAttributes(el)
         })
     }
 
@@ -361,36 +314,7 @@ export default class Component {
     }
 
     evaluateReturnExpression(expression) {
-        var affectedDataKeys = []
-
-        const proxyHandler = prefix => ({
-            get(object, prop) {
-                // Sometimes non-proxyable values are accessed. These are of type "symbol".
-                // We can ignore them.
-                if (typeof prop === 'symbol') return
-
-                const propertyName = prefix ? `${prefix}.${prop}` : prop
-
-                // If we are accessing an object prop, we'll make this proxy recursive to build
-                // a nested dependancy key.
-                if (typeof object[prop] === 'object' && object[prop] !== null && ! Array.isArray(object[prop])) {
-                    return new Proxy(object[prop], proxyHandler(propertyName))
-                }
-
-                affectedDataKeys.push(propertyName)
-
-                return object[prop]
-            }
-        })
-
-        const proxiedData = new Proxy(this.$data, proxyHandler())
-
-        const result = saferEval(expression, proxiedData)
-
-        return {
-            output: result,
-            deps: affectedDataKeys
-        }
+        return saferEval(expression, this.$data)
     }
 
     evaluateCommandExpression(expression, extraData) {
@@ -510,7 +434,7 @@ export default class Component {
         // For this reason, I'm using an "on-demand" proxy to fake a "$refs" object.
         return new Proxy({}, {
             get(object, property) {
-                if (property === 'isProxy') return true
+                if (property === 'isRefsProxy') return true
 
                 var ref
 

+ 10 - 16
src/utils.js

@@ -24,7 +24,7 @@ export function keyToModifier(key) {
     switch (key) {
         case ' ':
         case 'Spacebar':
-            return 'space'            
+            return 'space'
         default:
             return kebabCase(key)
     }
@@ -44,23 +44,17 @@ export function walkSkippingNestedComponents(el, callback, isRoot = true) {
     }
 }
 
-export function debounce(func, wait, immediate) {
-    var timeout;
+export function debounce(func, wait) {
+    var timeout
     return function () {
-        var context = this, args = arguments;
+        var context = this, args = arguments
         var later = function () {
-            timeout = null;
-            if (!immediate) func.apply(context, args);
-        };
-        var callNow = immediate && !timeout;
-        clearTimeout(timeout);
-        timeout = setTimeout(later, wait);
-        if (callNow) func.apply(context, args);
-    };
-};
-
-export function onlyUnique(value, index, self) {
-    return self.indexOf(value) === index;
+            timeout = null
+            func.apply(context, args)
+        }
+        clearTimeout(timeout)
+        timeout = setTimeout(later, wait)
+    }
 }
 
 export function saferEval(expression, dataContext, additionalHelperVariables = {}) {

+ 33 - 0
test/data.spec.js

@@ -30,3 +30,36 @@ test('x-data attribute value is optional', async () => {
 
     expect(document.querySelector('span').innerText).toEqual('foo')
 })
+
+test('x-data can use attributes from a reusable function', async () => {
+    document.body.innerHTML = `
+        <div x-data="test()">
+            <span x-text="foo"></span>
+        </div>
+    `
+        test = function() {
+            return {
+                foo: 'bar',
+            }
+        }
+
+    Alpine.start()
+
+    expect(document.querySelector('span').innerText).toEqual('bar')
+})
+
+test('functions in x-data are reactive', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: 'bar', getFoo() {return this.foo}}">
+            <span x-text="getFoo()"></span>
+            <button x-on:click="foo = 'baz'"></button>
+        </div>
+    `
+    Alpine.start()
+
+    expect(document.querySelector('span').innerText).toEqual('bar')
+
+    document.querySelector('button').click()
+
+    await wait(() => { expect(document.querySelector('span').innerText).toEqual('baz') })
+})

Some files were not shown because too many files changed in this diff