Evan You 12 роки тому
батько
коміт
9d0d2114f8

+ 24 - 0
examples/expression.html

@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <title></title>
+        <meta charset="utf-8">
+    </head>
+    <body>
+        <div id="demo">
+            <p sd-text="one + ' ' + two + '!'"></p>
+            <input sd-value="one"> <input sd-value="two">
+        </div>
+        <script src="../dist/seed.js"></script>
+        <script>
+            seed.config({ debug: true })
+            var demo = new seed.ViewModel({
+                el: '#demo',
+                data: {
+                    one: 'Hello',
+                    two: 'World'
+                }
+            })
+        </script>
+    </body>
+</html>

+ 7 - 7
examples/todomvc/index.html

@@ -19,7 +19,7 @@
                 >
             </header>
             <section id="main" sd-show="todos.length">
-                <input 
+                <input
                     id="toggle-all"
                     type="checkbox"
                     sd-checked="allDone"
@@ -28,7 +28,7 @@
                     <li
                         class="todo"
                         sd-each="todo:todos"
-                        sd-show="todoFiltered"
+                        sd-show="todoFilter(todo.completed)"
                         sd-class="completed:todo.completed, editing:todo.editing"
                     >
                         <div class="view">
@@ -56,12 +56,12 @@
                     <strong sd-text="remaining"></strong> {{remaining | pluralize item}} left
                 </span>
                 <ul id="filters">
-                    <li><a href="#/all" sd-class="selected:filterSelected">All</a></li>
-                    <li><a href="#/active" sd-class="selected:filterSelected">Active</a></li>
-                    <li><a href="#/completed" sd-class="selected:filterSelected">Completed</a></li>
+                    <li><a href="#/all" sd-class="selected:filter=='all'">All</a></li>
+                    <li><a href="#/active" sd-class="selected:filter=='active'">Active</a></li>
+                    <li><a href="#/completed" sd-class="selected:filter=='completed'">Completed</a></li>
                 </ul>
-                <button id="clear-completed" sd-on="click:removeCompleted" sd-show="completed">
-                    Remove Completed ({{completed}})
+                <button id="clear-completed" sd-on="click:removeCompleted" sd-show="todos.length > remaining">
+                    Remove Completed ({{todos.length - remaining}})
                 </button>
             </footer>
         </section>

+ 24 - 34
examples/todomvc/js/app.js

@@ -1,14 +1,18 @@
+seed.config({ debug: false })
+
 var filters = {
     all: function () { return true },
-    active: function (todo) { return !todo.completed },
-    completed: function (todo) { return todo.completed }
+    active: function (val) { return !val },
+    completed: function (val) { return val }
 }
 
 var Todos = seed.ViewModel.extend({
 
     init: function () {
         this.todos = todoStorage.fetch()
-        this.remaining = this.todos.filter(filters.active).length
+        this.remaining = this.todos.filter(function (todo) {
+            return filters.active(todo.completed)
+        }).length
         this.updateFilter()
     },
 
@@ -17,38 +21,9 @@ var Todos = seed.ViewModel.extend({
         updateFilter: function () {
             var filter = location.hash.slice(2)
             this.filter = (filter in filters) ? filter : 'all'
+            this.todoFilter = filters[this.filter]
         },
 
-        // computed properties ----------------------------------------------------
-        completed: {get: function () {
-            return this.todos.length - this.remaining
-        }},
-
-        // dynamic context computed property using info from target viewmodel
-        todoFiltered: {get: function (ctx) {
-            return filters[this.filter]({ completed: ctx.vm.todo.completed })
-        }},
-
-        // dynamic context computed property using info from target element
-        filterSelected: {get: function (ctx) {
-            return this.filter === ctx.el.textContent.toLowerCase()
-        }},
-
-        // two-way computed property with both getter and setter
-        allDone: {
-            get: function () {
-                return this.remaining === 0
-            },
-            set: function (value) {
-                this.todos.forEach(function (todo) {
-                    todo.completed = value
-                })
-                this.remaining = value ? 0 : this.todos.length
-                todoStorage.save()
-            }
-        },
-
-        // event handlers ---------------------------------------------------------
         addTodo: function () {
             var value = this.newTodo && this.newTodo.trim()
             if (value) {
@@ -89,8 +64,23 @@ var Todos = seed.ViewModel.extend({
         },
 
         removeCompleted: function () {
-            this.todos = this.todos.filter(filters.active)
+            this.todos.mutateFilter(function (todo) {
+                return filters.active(todo.completed)
+            })
             todoStorage.save()
+        },
+
+        allDone: {
+            get: function () {
+                return this.remaining === 0
+            },
+            set: function (value) {
+                this.todos.forEach(function (todo) {
+                    todo.completed = value
+                })
+                this.remaining = value ? 0 : this.todos.length
+                todoStorage.save()
+            }
         }
     }
 })

+ 3 - 2
src/binding.js

@@ -5,9 +5,10 @@
  *  which has multiple directive instances on the DOM
  *  and multiple computed property dependents
  */
-function Binding (compiler, key) {
+function Binding (compiler, key, isExp) {
     this.value = undefined
-    this.root = key.indexOf('.') === -1
+    this.isExp = !!isExp
+    this.root = !this.isExp && key.indexOf('.') === -1
     this.compiler = compiler
     this.key = key
     this.instances = []

+ 77 - 49
src/compiler.js

@@ -6,6 +6,7 @@ var Emitter         = require('emitter'),
     DirectiveParser = require('./directive-parser'),
     TextParser      = require('./text-parser'),
     DepsParser      = require('./deps-parser'),
+    ExpParser       = require('./exp-parser'),
     slice           = Array.prototype.slice,
     vmAttr,
     eachAttr
@@ -60,6 +61,8 @@ function Compiler (vm, options) {
     this.vm         = vm
     this.el         = el
     this.directives = []
+    // anonymous expression bindings that needs to be unbound during destroy()
+    this.expressions = []
 
     // Store things during parsing to be processed afterwards,
     // because we want to have created all bindings before
@@ -85,20 +88,17 @@ function Compiler (vm, options) {
         options.init.apply(vm, options.args || [])
     }
 
-    // now parse the DOM, during which we will create necessary bindings
-    // and bind the parsed directives
-    this.compileNode(this.el, true)
-
-    // for anything in viewmodel but not binded in DOM, also create bindings for them
+    // create bindings for keys set on the vm by the user
     for (var key in vm) {
-        if (vm.hasOwnProperty(key) &&
-            key.charAt(0) !== '$' &&
-            !this.bindings.hasOwnProperty(key))
-        {
+        if (key.charAt(0) !== '$') {
             this.createBinding(key)
         }
     }
 
+    // now parse the DOM, during which we will create necessary bindings
+    // and bind the parsed directives
+    this.compileNode(this.el, true)
+
     // observe root values so that they emit events when
     // their nested values change (for an Object)
     // or when they mutate (for an Array)
@@ -261,7 +261,9 @@ CompilerProto.bindDirective = function (directive) {
         compiler = traceOwnerCompiler(directive, this)
 
     var binding
-    if (compiler.vm.hasOwnProperty(baseKey)) {
+    if (directive.isExp) {
+        binding = this.createBinding(key, true)
+    } else if (compiler.vm.hasOwnProperty(baseKey)) {
         // if the value is present in the target VM, we create the binding on its compiler
         binding = compiler.bindings.hasOwnProperty(key)
             ? compiler.bindings[key]
@@ -305,27 +307,39 @@ CompilerProto.bindDirective = function (directive) {
 /*
  *  Create binding and attach getter/setter for a key to the viewmodel object
  */
-CompilerProto.createBinding = function (key) {
-    
-    utils.log('  created binding: ' + key)
-
-    // make sure the key exists in the object so it can be observed
-    // by the Observer!
-    this.ensurePath(key)
+CompilerProto.createBinding = function (key, isExp) {
 
     var bindings = this.bindings,
-        binding = new Binding(this, key)
-    bindings[key] = binding
-
-    if (binding.root) {
-        // this is a root level binding. we need to define getter/setters for it.
-        this.define(key, binding)
+        binding  = new Binding(this, key, isExp)
+
+    if (binding.isExp) {
+        // a complex expression binding
+        // we need to generate an anonymous computed property for it
+        var getter = ExpParser.parseGetter(key, this)
+        if (getter) {
+            utils.log('  created anonymous binding: ' + key)
+            binding.value = { get: getter }
+            this.markComputed(binding)
+            this.expressions.push(binding)
+        } else {
+            utils.warn('  invalid expression: ' + key)
+        }
     } else {
-        var parentKey = key.slice(0, key.lastIndexOf('.'))
-        if (!bindings.hasOwnProperty(parentKey)) {
-            // this is a nested value binding, but the binding for its parent
-            // has not been created yet. We better create that one too.
-            this.createBinding(parentKey)
+        utils.log('  created binding: ' + key)
+        bindings[key] = binding
+        // make sure the key exists in the object so it can be observed
+        // by the Observer!
+        this.ensurePath(key)
+        if (binding.root) {
+            // this is a root level binding. we need to define getter/setters for it.
+            this.define(key, binding)
+        } else {
+            var parentKey = key.slice(0, key.lastIndexOf('.'))
+            if (!bindings.hasOwnProperty(parentKey)) {
+                // this is a nested value binding, but the binding for its parent
+                // has not been created yet. We better create that one too.
+                this.createBinding(parentKey)
+            }
         }
     }
     return binding
@@ -338,17 +352,15 @@ CompilerProto.createBinding = function (key) {
  *  any given path.
  */
 CompilerProto.ensurePath = function (key) {
-    var path = key.split('.'), sec,
-        i = 0, depth = path.length - 1,
-        obj = this.vm
-    while (i < depth) {
+    var path = key.split('.'), sec, obj = this.vm
+    for (var i = 0, d = path.length - 1; i < d; i++) {
         sec = path[i]
         if (!obj[sec]) obj[sec] = {}
         obj = obj[sec]
-        i++
     }
     if (utils.typeOf(obj) === 'Object') {
-        obj[path[i]] = obj[path[i]] || undefined
+        sec = path[i]
+        if (!(sec in obj)) obj[sec] = undefined
     }
 }
 
@@ -367,11 +379,7 @@ CompilerProto.define = function (key, binding) {
 
     if (type === 'Object' && value.get) {
         // computed property
-        binding.isComputed = true
-        binding.rawGet = value.get
-        value.get = value.get.bind(vm)
-        if (value.set) value.set = value.set.bind(vm)
-        this.computed.push(binding)
+        this.markComputed(binding)
     } else if (type === 'Object' || type === 'Array') {
         // observe objects later, becase there might be more keys
         // to be added to it. we also want to emit all the set events
@@ -389,34 +397,48 @@ CompilerProto.define = function (key, binding) {
                 compiler.observer.emit('get', key)
             }
             return binding.isComputed
-                ? binding.value.get({
+                ? value.get({
                     el: compiler.el,
                     vm: compiler.vm,
                     item: compiler.each
                         ? compiler.vm[compiler.eachPrefix]
                         : null
                 })
-                : binding.value
+                : value
         },
-        set: function (value) {
+        set: function (newVal) {
+            var value = binding.value
             if (binding.isComputed) {
-                if (binding.value.set) {
-                    binding.value.set(value)
+                if (value.set) {
+                    value.set(newVal)
                 }
-            } else if (value !== binding.value) {
+            } else if (newVal !== value) {
                 // unwatch the old value
-                Observer.unobserve(binding.value, key, compiler.observer)
+                Observer.unobserve(value, key, compiler.observer)
                 // set new value
-                binding.value = value
-                compiler.observer.emit('set', key, value)
+                binding.value = newVal
+                compiler.observer.emit('set', key, newVal)
                 // now watch the new value, which in turn emits 'set'
                 // for all its nested values
-                Observer.observe(value, key, compiler.observer)
+                Observer.observe(newVal, key, compiler.observer)
             }
         }
     })
 }
 
+/*
+ *  Process a computed property binding
+ */
+CompilerProto.markComputed = function (binding) {
+    var value = binding.value,
+        vm    = this.vm
+    binding.isComputed = true
+    binding.rawGet = value.get
+    value.get = value.get.bind(vm)
+    if (value.set) value.set = value.set.bind(vm)
+    this.computed.push(binding)
+}
+
 /*
  *  Process subscriptions for computed properties that has
  *  dynamic context dependencies
@@ -445,6 +467,7 @@ CompilerProto.destroy = function () {
     utils.log('compiler destroyed: ', this.vm.$el)
     var i, key, dir, inss, binding,
         directives = this.directives,
+        exps = this.expressions,
         bindings = this.bindings,
         el = this.el
     // remove all directives that are instances of external bindings
@@ -457,6 +480,11 @@ CompilerProto.destroy = function () {
         }
         dir.unbind()
     }
+    // unbind all expressions (anonymous bindings)
+    i = exps.length
+    while (i--) {
+        exps[i].unbind()
+    }
     // unbind/unobserve all own bindings
     for (key in bindings) {
         if (bindings.hasOwnProperty(key)) {

+ 3 - 29
src/deps-parser.js

@@ -18,8 +18,10 @@ var dummyEl = document.createElement('div'),
  */
 function catchDeps (binding) {
     utils.log('\n─ ' + binding.key)
+    var depsHash = {}
     observer.on('get', function (dep) {
-        if (binding.deps.indexOf(dep) !== -1) return
+        if (depsHash[dep.key]) return
+        depsHash[dep.key] = 1
         utils.log('  └─ ' + dep.key)
         binding.deps.push(dep)
         dep.subs.push(binding)
@@ -32,33 +34,6 @@ function catchDeps (binding) {
     observer.off('get')
 }
 
-// Second pass seems no longer necessary because now we have control
-// over what values to emit (only non-computed values)
-
-/*
- *  The second pass of dependency extraction.
- *  Only include dependencies that don't have dependencies themselves.
- */
-// function filterDeps (binding) {
-//     var i = binding.deps.length, dep
-//     utils.log('\n─ ' + binding.key)
-//     while (i--) {
-//         dep = binding.deps[i]
-//         if (!dep.deps.length) {
-//             utils.log('  └─ ' + dep.key)
-//             dep.subs.push(binding)
-//         } else {
-//             binding.deps.splice(i, 1)
-//         }
-//     }
-//     var ctxDeps = binding.contextDeps
-//     if (!ctxDeps || !config.debug) return
-//     i = ctxDeps.length
-//     while (i--) {
-//         utils.log('  └─ ctx:' + ctxDeps[i])
-//     }
-// }
-
 /*
  *  We need to invoke each binding's getter for dependency parsing,
  *  but we don't know what sub-viewmodel properties the user might try
@@ -126,7 +101,6 @@ module.exports = {
         utils.log('\nparsing dependencies...')
         observer.isObserving = true
         bindings.forEach(catchDeps)
-        //bindings.forEach(filterDeps)
         observer.isObserving = false
         utils.log('\ndone.')
     }

+ 57 - 61
src/directive-parser.js

@@ -5,10 +5,10 @@ var config     = require('./config'),
 
 var KEY_RE          = /^[^\|<]+/,
     ARG_RE          = /([^:]+):(.+)$/,
-    FILTERS_RE      = /\|[^\|<]+/g,
+    FILTERS_RE      = /[^\|]\|[^\|<]+/g,
     FILTER_TOKEN_RE = /[^\s']+|'[^']+'/g,
-    INVERSE_RE      = /^!/,
-    NESTING_RE      = /^\^+/
+    NESTING_RE      = /^\^+/,
+    SINGLE_VAR_RE   = /^[\w\.]+$/
 
 /*
  *  Directive class
@@ -36,6 +36,7 @@ function Directive (directiveName, expression) {
     this.rawKey        = expression.match(KEY_RE)[0].trim()
     
     this.parseKey(this.rawKey)
+    this.isExp = !SINGLE_VAR_RE.test(this.key)
     
     var filterExps = expression.match(FILTERS_RE)
     this.filters = filterExps
@@ -45,6 +46,58 @@ function Directive (directiveName, expression) {
 
 var DirProto = Directive.prototype
 
+/*
+ *  parse a key, extract argument and nesting/root info
+ */
+DirProto.parseKey = function (rawKey) {
+
+    var argMatch = rawKey.match(ARG_RE)
+
+    var key = argMatch
+        ? argMatch[2].trim()
+        : rawKey.trim()
+
+    this.arg = argMatch
+        ? argMatch[1].trim()
+        : null
+
+    var nesting = key.match(NESTING_RE)
+    this.nesting = nesting
+        ? nesting[0].length
+        : false
+
+    this.root = key.charAt(0) === '$'
+
+    if (this.nesting) {
+        key = key.replace(NESTING_RE, '')
+    } else if (this.root) {
+        key = key.slice(1)
+    }
+
+    this.key = key
+}
+
+
+/*
+ *  parse a filter expression
+ */
+function parseFilter (filter) {
+
+    var tokens = filter.slice(2)
+        .match(FILTER_TOKEN_RE)
+        .map(function (token) {
+            return token.replace(/'/g, '').trim()
+        })
+
+    return {
+        name  : tokens[0],
+        apply : filters[tokens[0]],
+        args  : tokens.length > 1
+                ? tokens.slice(1)
+                : null
+    }
+}
+
 /*
  *  called when a new value is set 
  *  for computed properties, this will only be called once
@@ -78,7 +131,6 @@ DirProto.refresh = function (value) {
  *  Actually invoking the _update from the directive's definition
  */
 DirProto.apply = function (value) {
-    if (this.inverse) value = !value
     this._update(
         this.filters
         ? this.applyFilters(value)
@@ -93,48 +145,12 @@ DirProto.applyFilters = function (value) {
     var filtered = value, filter
     for (var i = 0, l = this.filters.length; i < l; i++) {
         filter = this.filters[i]
-        if (!filter.apply) throw new Error('Unknown filter: ' + filter.name)
+        if (!filter.apply) utils.warn('Unknown filter: ' + filter.name)
         filtered = filter.apply(filtered, filter.args)
     }
     return filtered
 }
 
-/*
- *  parse a key, extract argument and nesting/root info
- */
-DirProto.parseKey = function (rawKey) {
-
-    var argMatch = rawKey.match(ARG_RE)
-
-    var key = argMatch
-        ? argMatch[2].trim()
-        : rawKey.trim()
-
-    this.arg = argMatch
-        ? argMatch[1].trim()
-        : null
-
-    this.inverse = INVERSE_RE.test(key)
-    if (this.inverse) {
-        key = key.slice(1)
-    }
-
-    var nesting = key.match(NESTING_RE)
-    this.nesting = nesting
-        ? nesting[0].length
-        : false
-
-    this.root = key.charAt(0) === '$'
-
-    if (this.nesting) {
-        key = key.replace(NESTING_RE, '')
-    } else if (this.root) {
-        key = key.slice(1)
-    }
-
-    this.key = key
-}
-
 /*
  *  unbind noop, to be overwritten by definitions
  */
@@ -144,26 +160,6 @@ DirProto.unbind = function (update) {
     if (!update) this.vm = this.el = this.binding = this.compiler = null
 }
 
-/*
- *  parse a filter expression
- */
-function parseFilter (filter) {
-
-    var tokens = filter.slice(1)
-        .match(FILTER_TOKEN_RE)
-        .map(function (token) {
-            return token.replace(/'/g, '').trim()
-        })
-
-    return {
-        name  : tokens[0],
-        apply : filters[tokens[0]],
-        args  : tokens.length > 1
-                ? tokens.slice(1)
-                : null
-    }
-}
-
 module.exports = {
 
     /*

+ 1 - 1
src/directives/each.js

@@ -129,7 +129,7 @@ module.exports = {
             vmID = node.getAttribute(config.prefix + '-viewmodel'),
             ChildVM = utils.getVM(vmID) || ViewModel,
             wrappedData = {}
-        wrappedData[this.arg] = data
+        wrappedData[this.arg] = data || {}
         var item = new ChildVM({
             el: node,
             each: true,

+ 51 - 0
src/exp-parser.js

@@ -0,0 +1,51 @@
+/*
+ *  Variable extraction scooped from https://github.com/RubyLouvre/avalon 
+ */
+var KEYWORDS =
+        // keywords
+        'break,case,catch,continue,debugger,default,delete,do,else,false'
+        + ',finally,for,function,if,in,instanceof,new,null,return,switch,this'
+        + ',throw,true,try,typeof,var,void,while,with'
+        // reserved
+        + ',abstract,boolean,byte,char,class,const,double,enum,export,extends'
+        + ',final,float,goto,implements,import,int,interface,long,native'
+        + ',package,private,protected,public,short,static,super,synchronized'
+        + ',throws,transient,volatile'
+        // ECMA 5 - use strict
+        + ',arguments,let,yield'
+        + ',undefined',
+    KEYWORDS_RE = new RegExp(["\\b" + KEYWORDS.replace(/,/g, '\\b|\\b') + "\\b"].join('|'), 'g'),
+    REMOVE_RE   = /\/\*(?:.|\n)*?\*\/|\/\/[^\n]*\n|\/\/[^\n]*$|'[^']*'|"[^"]*"|[\s\t\n]*\.[\s\t\n]*[$\w\.]+/g,
+    SPLIT_RE    = /[^\w$]+/g,
+    NUMBER_RE   = /\b\d[^,]*/g,
+    BOUNDARY_RE = /^,+|,+$/g
+
+function getVariables (code) {
+    code = code
+        .replace(REMOVE_RE, '')
+        .replace(SPLIT_RE, ',')
+        .replace(KEYWORDS_RE, '')
+        .replace(NUMBER_RE, '')
+        .replace(BOUNDARY_RE, '')
+    code = code ? code.split(/,+/) : []
+    return code
+}
+
+module.exports = {
+    parseGetter: function (exp) {
+        var vars = getVariables(exp)
+        if (!vars.length) return null
+        var args = [],
+            v, i = vars.length,
+            hash = {}
+        while (i--) {
+            v = vars[i]
+            if (hash[v]) continue
+            hash[v] = 1
+            args.push(v + '=this.$get("' + v + '")')
+        }
+        args = 'var ' + args.join(',') + ';return ' + exp
+        /* jshint evil: true */
+        return new Function(args)
+    }
+}

+ 6 - 0
src/observer.js

@@ -13,6 +13,12 @@ var arrayMutators = {
     replace: function (index, data) {
         if (typeof index !== 'number') index = this.indexOf(index)
         this.splice(index, 1, data)
+    },
+    mutateFilter: function (fn) {
+        var i = this.length
+        while (i--) {
+            if (!fn(this[i])) this.splice(i, 1)
+        }
     }
 }
 

+ 31 - 1
src/viewmodel.js

@@ -18,13 +18,31 @@ var VMProto = ViewModel.prototype
  */
 VMProto.$set = function (key, value) {
     var path = key.split('.'),
-        obj = this
+        obj = getTargetVM(this, path)
+    if (!obj) return
     for (var d = 0, l = path.length - 1; d < l; d++) {
         obj = obj[path[d]]
     }
     obj[path[d]] = value
 }
 
+/*
+ *  The function for getting a key
+ *  which will go up along the prototype chain of the bindings
+ *  Used in exp-parser.
+ */
+VMProto.$get = function (key) {
+    var path = key.split('.'),
+        obj = getTargetVM(this, path),
+        vm = obj
+    if (!obj) return
+    for (var d = 0, l = path.length; d < l; d++) {
+        obj = obj[path[d]]
+    }
+    if (typeof obj === 'function') obj = obj.bind(vm)
+    return obj
+}
+
 /*
  *  watch a key on the viewmodel for changes
  *  fire callback with new value
@@ -48,4 +66,16 @@ VMProto.$destroy = function () {
     this.$compiler = null
 }
 
+/*
+ *  If a VM doesn't contain a path, go up the prototype chain
+ *  to locate the ancestor that has it.
+ */
+function getTargetVM (vm, path) {
+    var baseKey = path[0],
+        binding = vm.$compiler.bindings[baseKey]
+    return binding
+        ? binding.compiler.vm
+        : null
+}
+
 module.exports = ViewModel