saintcrawler saintcrawler - 4 months ago 9
Javascript Question

Strange `bind` behavior

I'm trying to make a function which is similar to React's

createClass
function. It should take a POJO and turn it into constructor function which can be invoked with additional arguments.

Here's the code:

function createClass(obj) {
return function(args = {}) {
Object.keys(obj).forEach(k => this[k] = obj[k]);
Object.keys(args).forEach(k => this[k] = args[k]);
Object.keys(this.actions).forEach(k => this.actions[k] = this.actions[k].bind(this));
console.log('binded to', this);
}
}

const MyClass = createClass({
actions: {
foo: function() {
console.log('actions.foo');
this.request();
}
},
foo: function() {
console.log('foo');
this.request();
}
});

const req1 = function() {
console.log('req1 called')
}

const req2 = function() {
console.log('req2 called')
}

const c1 = new MyClass({request: req1});
const c2 = new MyClass({request: req2});

// As expected
c1.request();
c2.request();
console.log('---')

// As expected
c1.foo();
c2.foo();
console.log('---')

// Error: both call req1
c1.actions.foo();
c2.actions.foo();
console.log('---')


I don get why calling
c2.foo()
works as expected, but calling
c2.actions.foo()
instead calls method from another instance. How can it be possible?

Also, there is jsbin example

Answer

Because on this line:

Object.keys(obj).forEach(k => this[k] = obj[k]);

...you're copying actions (the reference, not the array) to the new object. Later you update the contents of that array, but they're all sharing the same one. Since you're calling bind on the functions in action, the first time they'll get bound to that first instance; subsequently, they won't, because the whole point of bind is that it ignores the this you call it with, so calling bind on a bound function doesn't do anything (with regard to this). E.g.:

var f = function() { console.log(this.name); };
var f1 = f.bind({name: "first"});
f1();                                                         // "first"
var f2 = f1.bind({name: "second"});
//       ^^------ Note we're using an already-bound function
f2();                                                         // Also "first"

Consequently, any call to functions via actions will always call those functions with this referring to the first instance you created.

Solution: Copy the array contents (at least a shallow copy), for instance with Object.assign.

Here's that code with some tests removed but checking c1.actions === c2.actions, showing that it's true (they're using the same array):

function createClass(obj) {
  return function(args = {}) {
    Object.keys(obj).forEach(k => this[k] = obj[k]);
    Object.keys(args).forEach(k => this[k] = args[k]);
    Object.keys(this.actions).forEach(k => this.actions[k] = this.actions[k].bind(this));
    //console.log('binded to', this);
  }
}

const MyClass = createClass({
  actions: {
    foo: function() {
      //console.log('actions.foo');
      this.request();
    }
  },
  foo: function() {
    //console.log('foo');
    this.request();
  }
});

const req1 = function() {
  console.log('req1 called')
}

const req2 = function() {
  console.log('req2 called')
}

const c1 = new MyClass({request: req1});
const c2 = new MyClass({request: req2});

console.log(c1.actions === c2.actions); // true

Here's the code making a copy of actions first:

function createClass(obj) {
  return function(args = {}) {
    Object.keys(obj).forEach(k => {
      if (k === "actions") {
        this[k] = Object.assign({}, obj[k]);
      } else {
        this[k] = obj[k];
      }
    });
    Object.keys(args).forEach(k => this[k] = args[k]);
    Object.keys(this.actions).forEach(k => this.actions[k] = this.actions[k].bind(this));
    //console.log('binded to', this);
  }
}

Example:

function createClass(obj) {
  return function(args = {}) {
    Object.keys(obj).forEach(k => {
      if (k === "actions") {
        this[k] = Object.assign({}, obj[k]);
      } else {
        this[k] = obj[k];
      }
    });
    Object.keys(args).forEach(k => this[k] = args[k]);
    Object.keys(this.actions).forEach(k => this.actions[k] = this.actions[k].bind(this));
    //console.log('binded to', this);
  }
}

const MyClass = createClass({
  actions: {
    foo: function() {
      console.log('actions.foo');
      this.request();
    }
  },
  foo: function() {
    console.log('foo');
    this.request();
  }
});

const req1 = function() {
  console.log('req1 called')
}

const req2 = function() {
  console.log('req2 called')
}

const c1 = new MyClass({request: req1});
const c2 = new MyClass({request: req2});

// As expected
c1.request();
c2.request();
console.log('---');

// As expected
c1.foo();
c2.foo();
console.log('---');

// Error: both call req1
c1.actions.foo();
c2.actions.foo();
console.log('---');

Or you could generalize and always copy non-function objects:

function createClass(obj) {
  return function(args = {}) {
    Object.keys(obj).forEach(k => {
      const src = obj[k];
      if (Array.isArray(src)) {
        this[k] = src.slice();
      } else if (typeof src === "object") {
        this[k] = Object.assign({}, src);
      } else {
        this[k] = src;
      }
    });
    Object.keys(args).forEach(k => this[k] = args[k]);
    Object.keys(this.actions).forEach(k => this.actions[k] = this.actions[k].bind(this));
    //console.log('binded to', this);
  }
}

Example:

function createClass(obj) {
  return function(args = {}) {
    Object.keys(obj).forEach(k => {
      const src = obj[k];
      if (Array.isArray(src)) {
        this[k] = src.slice();
      } else if (typeof src === "object") {
        this[k] = Object.assign({}, src);
      } else {
        this[k] = src;
      }
    });
    Object.keys(args).forEach(k => this[k] = args[k]);
    Object.keys(this.actions).forEach(k => this.actions[k] = this.actions[k].bind(this));
    //console.log('binded to', this);
  }
}

const MyClass = createClass({
  actions: {
    foo: function() {
      console.log('actions.foo');
      this.request();
    }
  },
  foo: function() {
    console.log('foo');
    this.request();
  }
});

const req1 = function() {
  console.log('req1 called')
}

const req2 = function() {
  console.log('req2 called')
}

const c1 = new MyClass({request: req1});
const c2 = new MyClass({request: req2});

// As expected
c1.request();
c2.request();
console.log('---');

// As expected
c1.foo();
c2.foo();
console.log('---');

// Error: both call req1
c1.actions.foo();
c2.actions.foo();
console.log('---');

I may not have covered all edge cases there, and you'll have to decide whether to cater for (say) RegExp or Date objects, etc.

Comments