Skip to main content
Code Review

Return to Answer

typos
Source Link
Flambino
  • 33.3k
  • 2
  • 46
  • 90
function FSM(stateNames) {
 // keep variables local
 var states = {},
 currentState;
 
 stateNames = Array.prototype.slice.call(arguments);
 // check arguments
 if(!stateNames.length) {
 throw new Error("No states given");
 }
 // set initial state
 currentState = stateNames[0];
 
 // create objects to hold state properties and callbacks
 stateNames.forEach(function (name) {
 states[name] = {
 properties: {},
 callbacks: {
 enter: [],
 exit: []
 }
 };
 });
 // return an object with the public API. This let'slets the functions
 // access the "private" variables above with having to expose them
 // to the outisdeoutside world, and let'slets us call either "new FSM(...)" or
 // just "FSM(...)" and get something useful
 return {
 // get the current state name
 state: function () {
 return currentState;
 },
 
 // return (a copy of) the list of state names
 states: function () {
 return stateNames.slice(0);
 },
 
 // transition to another state
 transition: function (targetState) {
 if(!states[targetState]) {
 throw new Error("State '" + targetState + "' does not exist");
 }
 
 states[currentState].callbacks.exit.forEach(function (callback) {
 callback(currentState, targetState);
 });
 
 currentState = targetState;
 
 states[currentState].callbacks.exit.forEach(function (callback) {
 callback(currentState, targetState);
 });
 },
 
 // get state properties (optional stateName argument)
 properties: function (stateName) {
 stateName = stateName || currentState;
 return states[stateName].properties;
 },
 // add a transition listener
 on: function (stateName, event, callback) {
 // this'll automatically throw an exception if the state or event doesn't exist
 states[stateName][event].push(callback);
 },
 // remove a transition listener
 off: function (stateName, event, callback) {
 var callbacks = states[stateName][event];
 
 for (var i = callbacks.length - 1 ; i >= 0 ; i++) {
 if(callbacks[i] === callback) {
 callbacks.splice(i, 1);
 }
 }
 }
 };
}
function FSM(stateNames) {
 // keep variables local
 var states = {},
 currentState;
 
 stateNames = Array.prototype.slice.call(arguments);
 // check arguments
 if(!stateNames.length) {
 throw new Error("No states given");
 }
 // set initial state
 currentState = stateNames[0];
 
 // create objects to hold state properties and callbacks
 stateNames.forEach(function (name) {
 states[name] = {
 properties: {},
 callbacks: {
 enter: [],
 exit: []
 }
 };
 });
 // return an object with the public API. This let's the functions
 // access the "private" variables above with having to expose them
 // to the outisde world, and let's us call either "new FSM(...)" or
 // just "FSM(...)" and get something useful
 return {
 // get the current state name
 state: function () {
 return currentState;
 },
 
 // return (a copy of) the list of state names
 states: function () {
 return stateNames.slice(0);
 },
 
 // transition to another state
 transition: function (targetState) {
 if(!states[targetState]) {
 throw new Error("State '" + targetState + "' does not exist");
 }
 
 states[currentState].callbacks.exit.forEach(function (callback) {
 callback(currentState, targetState);
 });
 
 currentState = targetState;
 
 states[currentState].callbacks.exit.forEach(function (callback) {
 callback(currentState, targetState);
 });
 },
 
 // get state properties (optional stateName argument)
 properties: function (stateName) {
 stateName = stateName || currentState;
 return states[stateName].properties;
 },
 // add a transition listener
 on: function (stateName, event, callback) {
 // this'll automatically throw an exception if the state or event doesn't exist
 states[stateName][event].push(callback);
 },
 // remove a transition listener
 off: function (stateName, event, callback) {
 var callbacks = states[stateName][event];
 
 for (var i = callbacks.length - 1 ; i >= 0 ; i++) {
 if(callbacks[i] === callback) {
 callbacks.splice(i, 1);
 }
 }
 }
 };
}
function FSM(stateNames) {
 // keep variables local
 var states = {},
 currentState;
 
 stateNames = Array.prototype.slice.call(arguments);
 // check arguments
 if(!stateNames.length) {
 throw new Error("No states given");
 }
 // set initial state
 currentState = stateNames[0];
 
 // create objects to hold state properties and callbacks
 stateNames.forEach(function (name) {
 states[name] = {
 properties: {},
 callbacks: {
 enter: [],
 exit: []
 }
 };
 });
 // return an object with the public API. This lets the functions
 // access the "private" variables above with having to expose them
 // to the outside world, and lets us call either "new FSM(...)" or
 // just "FSM(...)" and get something useful
 return {
 // get the current state name
 state: function () {
 return currentState;
 },
 
 // return (a copy of) the list of state names
 states: function () {
 return stateNames.slice(0);
 },
 
 // transition to another state
 transition: function (targetState) {
 if(!states[targetState]) {
 throw new Error("State '" + targetState + "' does not exist");
 }
 
 states[currentState].callbacks.exit.forEach(function (callback) {
 callback(currentState, targetState);
 });
 
 currentState = targetState;
 
 states[currentState].callbacks.exit.forEach(function (callback) {
 callback(currentState, targetState);
 });
 },
 
 // get state properties (optional stateName argument)
 properties: function (stateName) {
 stateName = stateName || currentState;
 return states[stateName].properties;
 },
 // add a transition listener
 on: function (stateName, event, callback) {
 // this'll automatically throw an exception if the state or event doesn't exist
 states[stateName][event].push(callback);
 },
 // remove a transition listener
 off: function (stateName, event, callback) {
 var callbacks = states[stateName][event];
 
 for (var i = callbacks.length - 1 ; i >= 0 ; i++) {
 if(callbacks[i] === callback) {
 callbacks.splice(i, 1);
 }
 }
 }
 };
}
Source Link
Flambino
  • 33.3k
  • 2
  • 46
  • 90

I can't find a semicolon anywhere in your code. Sure, it'll work without them, but it's still a bad habit. Sooner or later, you'll write some lines of code that do require semicolons to parse correctly, or you'll feed the script to a minifier that just removes whitespace, and all of sudden things are broken. Basically, while JS does do automatic semicolon insertion, relying on it is a little iffy in my opinion. The language has semicolons for a reason.

Otherwise, the code looks well-formatted. Some names could be more descriptive, though. For instance prop in your for...in loops might more descriptively be named stateName or simply state. "Prop" isn't terribly descriptive (later you have single-letter variables which are even more opaque). You've also got some variables you're not using at all like ind and arr in your forEach callbacks. If you're not using them, leave them out (and if you need them, ind should just be i or index as those are the most conventional names for an "index" variable.)

Functionality-wise, this comment worries me a bit:

// Initialize to an arbitrary state, client can transition to 
// whatever their desired initial state is

This basically means that users must make two calls to fully set up the state machine. If they don't, the initial state will be arbitrary. And if they do, but they've already registered transition callbacks, those callbacks will fire even though you're just trying to set up the state machine). So you may get some unexpected, spurious state-changes, simply as a result of setting up the state machine.

Of course, as long as you use an object to hold the states, there's no guaranteed ordering, so there's no "first" state. I'd simply recommend not using an object, for this very reason.

Instead, you could do something like:

function FSM(stateNames /*, ... */) {
 this.states = Array.prototype.slice.call(arguments);
 if(!this.states.length) {
 throw new Error("No states given");
 }
 this.state = this.states[0]; // default to the first named state
 // ...
}
// ...
var stateMachine = FSM("idle", "working", "completed");

But continuing with your current code, you've also got this line:

// Set the _fsmName of each state to its name in the states object
// This makes it much easier to check if we're in a particular state

However, you don't need the name to check. Your inState function could simply be written as

FSM.prototype.inState = function(name) {
 return this.state === this.states[name];
}

and there'd be no need to change the object you've been passed. The caller might hold their own reference to that object, and won't expect it to change.

Now, the following is a bunch of suggestions for different approaches; sometimes quite different. I'm not saying this is the "one true way" to build things, it's just how I'd probably do it:

For handling associated properties, I'd propose something different: Simply return an object, and let the caller sort it out, instead of having getters and setters.

stateMachine.properties() // => an object containing the current state's properties

For here, the caller can read/write whatever they want in that object, with no need for getters and setters. The properties function could accept an optional argument in case you want properties for something other than the current state.

For handling transition callbacks, I'd storing them like so:

this.callbacks = {
 stateName: {
 enter: [callback, callback, ...],
 exit: [callback, callback, ...]
 }
};

Which would make it easier to call them, when needed

FSM.prototype.transition = function (targetState) {
 if(this.states.indexOf(targetState) === -1) {
 throw new Error("State '" + targetState + "' does not exist");
 }
 this.callbacks[this.state].exit.forEach(function (callback) { callback(); });
 this.state = targetState;
 this.callbacks[targetState].enter.forEach(function (callback) { callback(); });
}

Speaking of, it might be nice to pass the current and upcoming state names to the callbacks.

Lastly, it could be a good idea to skip the prototype functions in favor of dynamically adding functions in the constructor, or returning an object with the functions you want. For prototype functions to work, things like this.state have to be accessible, which means they're also accessible to the outside world. So I could just call stateMachine.state = 'foobar' and sidestep the transition function (and its checks and callbacks) entirely.

Below is an alternative implementation (again, this is just how I might do it - it's not to say that this is the only way):

function FSM(stateNames) {
 // keep variables local
 var states = {},
 currentState;
 
 stateNames = Array.prototype.slice.call(arguments);
 // check arguments
 if(!stateNames.length) {
 throw new Error("No states given");
 }
 // set initial state
 currentState = stateNames[0];
 
 // create objects to hold state properties and callbacks
 stateNames.forEach(function (name) {
 states[name] = {
 properties: {},
 callbacks: {
 enter: [],
 exit: []
 }
 };
 });
 // return an object with the public API. This let's the functions
 // access the "private" variables above with having to expose them
 // to the outisde world, and let's us call either "new FSM(...)" or
 // just "FSM(...)" and get something useful
 return {
 // get the current state name
 state: function () {
 return currentState;
 },
 
 // return (a copy of) the list of state names
 states: function () {
 return stateNames.slice(0);
 },
 
 // transition to another state
 transition: function (targetState) {
 if(!states[targetState]) {
 throw new Error("State '" + targetState + "' does not exist");
 }
 
 states[currentState].callbacks.exit.forEach(function (callback) {
 callback(currentState, targetState);
 });
 
 currentState = targetState;
 
 states[currentState].callbacks.exit.forEach(function (callback) {
 callback(currentState, targetState);
 });
 },
 
 // get state properties (optional stateName argument)
 properties: function (stateName) {
 stateName = stateName || currentState;
 return states[stateName].properties;
 },
 // add a transition listener
 on: function (stateName, event, callback) {
 // this'll automatically throw an exception if the state or event doesn't exist
 states[stateName][event].push(callback);
 },
 // remove a transition listener
 off: function (stateName, event, callback) {
 var callbacks = states[stateName][event];
 
 for (var i = callbacks.length - 1 ; i >= 0 ; i++) {
 if(callbacks[i] === callback) {
 callbacks.splice(i, 1);
 }
 }
 }
 };
}
default

AltStyle によって変換されたページ (->オリジナル) /