UNPKG

16.6 kBJavaScriptView Raw
1/*
2 * components.js
3 *
4 * Components associate custom script functionality with a view. They can be
5 * distributed as standalone modules containing templates, scripts, and styles.
6 * They can also be used to modularize application functionality.
7 *
8 */
9
10var util = require('racer/lib/util');
11var derbyTemplates = require('derby-templates');
12var templates = derbyTemplates.templates;
13var expressions = derbyTemplates.expressions;
14var Controller = require('./Controller');
15var slice = [].slice;
16
17exports.Component = Component;
18exports.ComponentAttribute = ComponentAttribute;
19exports.ComponentAttributeBinding = ComponentAttributeBinding;
20exports.ComponentFactory = ComponentFactory;
21exports.SingletonComponentFactory = SingletonComponentFactory;
22exports.createFactory = createFactory;
23exports.extendComponent = extendComponent;
24
25function Component(parent, context, id, scope) {
26 this.parent = parent;
27 this.context = context;
28 this.id = id;
29 this._scope = scope;
30}
31
32util.mergeInto(Component.prototype, Controller.prototype);
33
34Component.prototype.destroy = function() {
35 this.emit('destroy');
36 this.model.removeContextListeners();
37 this.model.destroy();
38 delete this.page._components[this.id];
39 var components = this.page._eventModel.object.$components;
40 if (components) delete components.object[this.id];
41};
42
43// Apply calls to the passed in function with the component as the context.
44// Stop calling back once the component is destroyed, which avoids possible bugs
45// and memory leaks.
46Component.prototype.bind = function(callback) {
47 var component = this;
48 this.on('destroy', function() {
49 // Reduce potential for memory leaks by removing references to the component
50 // and the passed in callback, which could have closure references
51 component = null;
52 // Cease calling back after component is removed from the DOM
53 callback = null;
54 });
55 return function componentBindWrapper() {
56 if (!callback) return;
57 return callback.apply(component, arguments);
58 };
59};
60
61// When passing in a numeric delay, calls the function at most once per that
62// many milliseconds. Like Underscore, the function will be called on the
63// leading and the trailing edge of the delay as appropriate. Unlike Underscore,
64// calls are consistently called via setTimeout and are never synchronous. This
65// should be used for reducing the frequency of ongoing updates, such as scroll
66// events or other continuous streams of events.
67//
68// Additionally, implements an interface intended to be used with
69// window.requestAnimationFrame or process.nextTick. If one of these is passed,
70// it will be used to create a single async call following any number of
71// synchronous calls. This mode is typically used to coalesce many synchronous
72// events (such as multiple model events) into a single async event.
73//
74// Like component.bind(), will no longer call back once the component is
75// destroyed, which avoids possible bugs and memory leaks.
76Component.prototype.throttle = function(callback, delayArg) {
77 var component = this;
78 this.on('destroy', function() {
79 // Reduce potential for memory leaks by removing references to the component
80 // and the passed in callback, which could have closure references
81 component = null;
82 // Cease calling back after component is removed from the DOM
83 callback = null;
84 });
85
86 // throttle(callback)
87 // throttle(callback, 150)
88 if (delayArg == null || typeof delayArg === 'number') {
89 var delay = delayArg || 0;
90 var nextArgs;
91 var previous;
92 var boundCallback = function() {
93 var args = nextArgs;
94 nextArgs = null;
95 previous = +new Date();
96 if (callback && args) {
97 callback.apply(component, args);
98 }
99 };
100 return function componentThrottleWrapper() {
101 var queueCall = !nextArgs;
102 nextArgs = slice.call(arguments);
103 if (queueCall) {
104 var now = +new Date();
105 var remaining = Math.max(previous + delay - now, 0);
106 setTimeout(boundCallback, remaining);
107 }
108 };
109 }
110
111 // throttle(callback, window.requestAnimationFrame)
112 // throttle(callback, process.nextTick)
113 if (typeof delayArg === 'function') {
114 var nextArgs;
115 var boundCallback = function() {
116 var args = nextArgs;
117 nextArgs = null;
118 if (callback && args) {
119 callback.apply(component, args);
120 }
121 };
122 return function componentThrottleWrapper() {
123 var queueCall = !nextArgs;
124 nextArgs = slice.call(arguments);
125 if (queueCall) delayArg(boundCallback);
126 };
127 }
128
129 throw new Error('Second argument must be a delay function or number');
130};
131
132// Suppresses calls until the function is no longer called for that many
133// milliseconds. This should be used for delaying updates triggered by user
134// input, such as window resizing, or typing text that has a live preview or
135// client-side validation. This should not be used for inputs that trigger
136// server requests, such as search autocomplete; use debounceAsync for those
137// cases instead.
138//
139// Like component.bind(), will no longer call back once the component is
140// destroyed, which avoids possible bugs and memory leaks.
141Component.prototype.debounce = function(callback, delay) {
142 delay = delay || 0;
143 if (typeof delay !== 'number') {
144 throw new Error('Second argument must be a number');
145 }
146 var component = this;
147 this.on('destroy', function() {
148 // Reduce potential for memory leaks by removing references to the component
149 // and the passed in callback, which could have closure references
150 component = null;
151 // Cease calling back after component is removed from the DOM
152 callback = null;
153 });
154 var nextArgs;
155 var timeout;
156 var boundCallback = function() {
157 var args = nextArgs;
158 nextArgs = null;
159 timeout = null;
160 if (callback && args) {
161 callback.apply(component, args);
162 }
163 };
164 return function componentDebounceWrapper() {
165 nextArgs = slice.call(arguments);
166 if (timeout) clearTimeout(timeout);
167 timeout = setTimeout(boundCallback, delay);
168 };
169};
170
171// Forked from: https://github.com/juliangruber/async-debounce
172//
173// Like debounce(), suppresses calls until the function is no longer called for
174// that many milliseconds. In addition, suppresses calls while the callback
175// function is running. In other words, the callback will not be called again
176// until the supplied done() argument is called. When the debounced function is
177// called while the callback is running, the callback will be called again
178// immediately after done() is called. Thus, the callback will always receive
179// the last value passed to the debounced function.
180//
181// This avoids the potential for multiple callbacks to execute in parallel and
182// complete out of order. It also acts as an adaptive rate limiter. Use this
183// method to debounce any field that triggers an async call as the user types.
184//
185// Like component.bind(), will no longer call back once the component is
186// destroyed, which avoids possible bugs and memory leaks.
187Component.prototype.debounceAsync = function(callback, delay) {
188 var applyArguments = callback.length !== 1;
189 delay = delay || 0;
190 if (typeof delay !== 'number') {
191 throw new Error('Second argument must be a number');
192 }
193 var component = this;
194 this.on('destroy', function() {
195 // Reduce potential for memory leaks by removing references to the component
196 // and the passed in callback, which could have closure references
197 component = null;
198 // Cease calling back after component is removed from the DOM
199 callback = null;
200 });
201 var running = false;
202 var nextArgs;
203 var timeout;
204 function done() {
205 var args = nextArgs;
206 nextArgs = null;
207 timeout = null;
208 if (callback && args) {
209 running = true;
210 args.push(done);
211 callback.apply(component, args);
212 } else {
213 running = false;
214 }
215 }
216 return function componentDebounceAsyncWrapper() {
217 nextArgs = (applyArguments) ? slice.call(arguments) : [];
218 if (timeout) clearTimeout(timeout);
219 if (running) return;
220 timeout = setTimeout(done, delay);
221 };
222};
223
224Component.prototype.get = function(viewName, unescaped) {
225 var view = this.getView(viewName);
226 return view.get(this.context, unescaped);
227};
228
229Component.prototype.getFragment = function(viewName) {
230 var view = this.getView(viewName);
231 return view.getFragment(this.context);
232};
233
234Component.prototype.getView = function(viewName) {
235 var contextView = this.context.getView();
236 return (viewName) ?
237 this.app.views.find(viewName, contextView.namespace) : contextView;
238};
239
240Component.prototype.getAttribute = function(key) {
241 var attributeContext = this.context.forAttribute(key);
242 if (!attributeContext) return;
243 var value = attributeContext.attributes[key];
244 if (value instanceof expressions.Expression) {
245 value = value.get(attributeContext);
246 }
247 return expressions.renderValue(value, this.context);
248};
249
250Component.prototype.setAttribute = function(key, value) {
251 this.context.parent.attributes[key] = value;
252};
253
254Component.prototype.setNullAttribute = function(key, value) {
255 var attributes = this.context.parent.attributes;
256 if (attributes[key] == null) attributes[key] = value;
257};
258
259function ComponentAttribute(expression, model, key) {
260 this.expression = expression;
261 this.model = model;
262 this.key = key;
263}
264ComponentAttribute.prototype.update = function(context, binding) {
265 var value = this.expression.get(context);
266 binding.condition = value;
267 this.model.setDiff(this.key, value);
268};
269function ComponentAttributeBinding(expression, model, key, context) {
270 this.template = new ComponentAttribute(expression, model, key);
271 this.context = context;
272 this.condition = expression.get(context);
273}
274ComponentAttributeBinding.prototype = Object.create(templates.Binding.prototype);
275ComponentAttributeBinding.prototype.constructor = ComponentAttributeBinding;
276
277function setModelAttributes(context, model) {
278 var attributes = context.parent.attributes;
279 if (!attributes) return;
280 // Set attribute values on component model
281 for (var key in attributes) {
282 var value = attributes[key];
283 setModelAttribute(context, model, key, value);
284 }
285}
286
287function setModelAttribute(context, model, key, value) {
288 // If an attribute is an Expression, set its current value in the model
289 // and keep it up to date. When it is a resolvable path, use a Racer ref,
290 // which makes it a two-way binding. Otherwise, set to the current value
291 // and create a binding that will set the value in the model as the
292 // expression's dependencies change.
293 if (value instanceof expressions.Expression) {
294 var segments = value.pathSegments(context);
295 if (segments) {
296 model.root.ref(model._at + '.' + key, segments.join('.'), {updateIndices: true});
297 } else {
298 var binding = new ComponentAttributeBinding(value, model, key, context);
299 context.addBinding(binding);
300 model.set(key, binding.condition);
301 }
302 return;
303 }
304
305 // If an attribute is a Template, set a template object in the model.
306 // Eagerly rendering a template can cause excessive rendering when the
307 // developer wants to pass in a complex chunk of HTML, and if we were to
308 // set a string in the model that represents the template value, we'd lose
309 // the ability to use the value in the component's template, since HTML
310 // would be escaped and we'd lose the ability to create proper bindings.
311 //
312 // This may be of surprise to developers, since it may not be intuitive
313 // whether a passed in value will produce an expression or a template. To
314 // get the rendered value consistently, the component's getAttribute(key)
315 // method may be used to get the value that would be rendered.
316 if (value instanceof templates.Template) {
317 var template = new templates.ContextClosure(value, context);
318 model.set(key, template);
319 return;
320 }
321
322 // For all other value types, set the passed in value directly. Passed in
323 // values will only be set initially, so model paths should be used if
324 // bindings are desired.
325 model.set(key, value);
326}
327
328function createFactory(constructor) {
329 return (constructor.prototype.singleton) ?
330 new SingletonComponentFactory(constructor) :
331 new ComponentFactory(constructor);
332}
333
334function emitInitHooks(context, component) {
335 if (!context.initHooks) return;
336 // Run initHooks for `on` listeners immediately before init
337 for (var i = 0, len = context.initHooks.length; i < len; i++) {
338 context.initHooks[i].emit(context, component);
339 }
340}
341
342function ComponentFactory(constructor) {
343 this.constructor = constructor;
344}
345ComponentFactory.prototype.init = function(context) {
346 var component = new this.constructor();
347
348 var parent = context.controller;
349 var id = context.id();
350 var scope = ['$components', id];
351 var model = parent.model.root.eventContext(component);
352 model._at = scope.join('.');
353 model.set('id', id);
354 // Store a reference to the component's scope such that the expression
355 // getters are relative to the component
356 model.data = model.get();
357 parent.page._components[id] = component;
358
359 var componentContext = context.componentChild(component);
360 Controller.call(component, parent.app, parent.page, model);
361 Component.call(component, parent, componentContext, id, scope);
362 setModelAttributes(componentContext, model);
363
364 // Do the user-specific initialization. The component constructor should be
365 // an empty function and the actual initialization code should be done in the
366 // component's init method. This means that we don't have to rely on users
367 // properly calling the Component constructor method and avoids having to
368 // play nice with how CoffeeScript extends class constructors
369 emitInitHooks(context, component);
370 component.emit('init', component);
371 if (component.init) component.init(model);
372
373 return componentContext;
374};
375ComponentFactory.prototype.create = function(context) {
376 var component = context.controller;
377 component.emit('create', component);
378 // Call the component's create function after its view is rendered
379 if (component.create) {
380 component.create(component.model, component.dom);
381 }
382};
383
384function SingletonComponentFactory(constructor) {
385 this.constructor = constructor;
386 this.component = null;
387}
388SingletonComponentFactory.prototype.init = function(context) {
389 if (!this.component) this.component = new this.constructor();
390 return context.componentChild(this.component);
391};
392// Don't call the create method for singleton components
393SingletonComponentFactory.prototype.create = function() {};
394
395function isBasePrototype(object) {
396 return (object === Object.prototype) ||
397 (object === Function.prototype) ||
398 (object === null);
399}
400function getRootPrototype(object) {
401 while (true) {
402 var prototype = Object.getPrototypeOf(object);
403 if (isBasePrototype(prototype)) return object;
404 object = prototype;
405 }
406}
407var _extendComponent = (Object.setPrototypeOf && Object.getPrototypeOf) ?
408 // Modern version, which supports ES6 classes
409 function(constructor) {
410 // Find the end of the prototype chain
411 var rootPrototype = getRootPrototype(constructor.prototype);
412 // Establish inheritance with the pattern that Node's util.inherits() uses
413 // if Object.setPrototypeOf() is available (all modern browsers & IE11).
414 // This inhertance pattern is not equivalent to class extends, but it does
415 // work to make instances of the constructor inherit the desired prototype
416 // https://github.com/nodejs/node/issues/4179
417 Object.setPrototypeOf(rootPrototype, Component.prototype);
418 } :
419 // Fallback for older browsers
420 function(constructor) {
421 // In this version, we iterate over all of the properties on the
422 // constructor's prototype and merge them into a new prototype object.
423 // This flattens the prototype chain, meaning that instanceof will not
424 // work for classes from which the current component inherits
425 var prototype = constructor.prototype;
426 // Otherwise, modify constructor.prototype. This won't work with ES6
427 // classes, since their prototype property is non-writeable. However, it
428 // does work in older browsers that don't support Object.setPrototypeOf(),
429 // and those browsers don't support ES6 classes either
430 constructor.prototype = Object.create(Component.prototype);
431 constructor.prototype.constructor = constructor;
432 util.mergeInto(constructor.prototype, prototype);
433 };
434function extendComponent(constructor) {
435 // Don't do anything if the constructor already extends Component
436 if (constructor.prototype instanceof Component) return;
437 // Otherwise, append Component.prototype to constructor's prototype chain
438 _extendComponent(constructor);
439}