Technology Overview
Learn the basics of CanJS’s technology.
Overview
CanJS, at its most simplified, consists of key-value observables connected to web browser APIs using various libraries.
The general idea is that you create observable objects that encapsulate the logic and state of your application and connect those observable objects to:
- The Document Object Model (DOM) to update your HTML automatically.
- The browser URL to support the forward and back buttons through Routing.
- Your service layer to make receiving, creating, updating, and deleting service data easier.
Instead of worrying about calling the various browser APIs, CanJS abstracts this away, so you can focus on the logic of your application. The logic of your application is contained within observables.
The rest of this page walks through the basics of observables and brief examples of connecting observables to browser APIs. For a deeper dive, please read through the HTML, Routing and Service Layer guides.
Key-Value Observables
The DefineMap and DefineList observables define the logic and state in your application. For example, the following uses DefineMap to:
- Model a simple
Counter
type. - Create instances of
Counter
, call its methods, and inspect its state.
import {DefineMap} from "can";
// Extend DefineMap to create a custom observable type.
const Counter = DefineMap.extend({
// Defines a `count` property that defaults to 0.
count: {default: 0},
// Defines an `increment` method that increments
// the `count` property.
increment() {
this.count++;
}
});
// Create an instance of the Counter observable type.
const myCounter = new Counter();
// Read the `count` property.
console.log( myCounter.count ) //-> 0
// Calls the `increment` method.
myCounter.increment()
// Read the `count` property again.
console.log( myCounter.count ) //-> 1
myCounter
is an instance of Counter
. myCounter.count
is part of the state of the myCounter
instance. myCounter.increment
is part of the logic that controls the
state of myCounter
.
myCounter
is an observable because you can listen to when
its state changes. The following uses listenTo
to log each time the count changes:
import {DefineMap} from "can";
const Counter = DefineMap.extend({
count: {default: 0},
increment() {
this.count++;
}
});
const myCounter = new Counter();
myCounter.listenTo("count", function(event, newCount){
console.log(newCount); // logs 1, 10, then 11
});
myCounter.increment();
myCounter.count = 10;
myCounter.increment();
DefineList creates observable lists. Observable lists are most commonly
used with the service layer. The following
defines a Counters
list type. Instances of Counters
will have a sum
property that returns the sum of
each Counter
within the list:
import {DefineMap, DefineList} from "can";
const Counter = DefineMap.extend({
count: {default: 0},
increment() {
this.count++;
}
});
// Extend DefineList to create a custom observable list type.
const Counters = DefineList.extend({
// "#" specifies the type of items in the list.
// Plain objects will be converted to Counter instances.
"#": Counter,
// Defines a getter for sum
get sum(){
// Loop through each counter and sum its count;
let sum = 0;
this.forEach( (counter) => sum += counter.count );
return sum;
}
});
// Create an instance of Counters
let myCounters = new Counters([
new Counter(),
// Initializes count with value 3
new Counter({count: 3}),
// Plain objects will be converted to Counter instances.
{count: 4}
]);
console.log( myCounters[0].count ) //-> 0
console.log( myCounters.sum ) //-> 7
myCounters[0].increment();
console.log( myCounters.sum ) //-> 8
NOTE: CanJS application logic is coded within instances of DefineMap and DefineList. You often don’t need the DOM for unit testing!
DefineMap and DefineList have a wide variety of features and shorthands for defining property behavior. For more information about how to write logic within CanJS’s observables read the DefineMap Use section and the PropDefinition documentation.
Observables and HTML elements
CanJS applications use Components to connect observables
to a page's HTML elements. We can use Component to create a counting widget
for the Counter
observables we just created.
The following widget counts the number of times the button is clicked:
In CanJS, widgets are encapsulated with custom elements. Custom elements allow us to put an
element in our HTML like <my-counter></my-counter>
, and the widget will spring to life.
The previous demo defines its view
and ViewModel
as separate entities. However,
a Component's ViewModel and view are typically defined inline as follows:
<my-counter></my-counter>
<script type="module">
import { Component } from "can";
Component.extend({
tag: "my-counter",
view: `
Count: <span>{{count}}</span>
<button on:click='increment()'>+1</button>
`,
// If `ViewModel` is set to an Object,
// that Object is used to extend DefineMap.
ViewModel: {
count: {default: 0},
increment() {
this.count++;
}
}
});
</script>
You might have noticed that Components are mostly 2 parts:
- A view that specifies the HTML content within the custom element. In this case, we’re adding a
<span>
and a<button>
within the<my-counter>
element. Magic tags like{{count}}
are provided by stache and bindings likeon:click
are provided by stacheBindings. - An observable ViewModel that manages the logic and state of the application.
These work together to receive input from the user, update the state of the application, and then update the HTML the user sees accordingly. See how in this 2 minute video:
For more information on how to connect observables to the DOM, read the HTML guide.
Observables and the browser's location
CanJS applications use route (or mixin RoutePushstate) to connect an observable to the browser's URL. The observable acts like a key-value store of the data in the URL.
The following example shows that:
- When the observable state updates, the URL changes.
- When the URL changes, the observable state updates.
import {route, DefineMap} from "//unpkg.com/can@5/core.mjs";
location.hash = "#!&page=todos&id=1";
route.data = new DefineMap({});
route.start();
// The route data matches what's in the hash.
console.log(route.data.serialize())
//-> {id: "1", page: "todos" }
// If you change the route data ...
route.data.id = 2;
setTimeout(() => {
// ... the hash is updated!
console.log(location.hash)
//-> "#!&page=todos&id=2"
// If you change the hash ...
location.hash = "#!&page=login"
},20);
setTimeout(()=>{
// ... the route data is updated!
console.log(route.data.serialize())
//-> { page: "login" }
},40);
The following shows the browser's forward and back buttons connected to the <my-counter>
Component's observable state. Click +1
a few times, then click
the forward (⇦
) and back (⇨
) buttons to see the count change:
Notice how the URL changes when you click the +1
button AND the Count changes when the
forward and back button are clicked.
The following connects the <my-counter>
’s observable ViewModel
to the browser's URL:
<mock-url></mock-url>
<my-counter></my-counter>
<script type='module'>
// Imports the <mock-url> element that provides
// a fake back, forward, and URL controls.
import "//unpkg.com/mock-url@5";
import {route, Component} from "can";
Component.extend({
tag: "my-counter",
view: `
Count: <span>{{count}}</span>
<button on:click='increment()'>+1</button>
`,
ViewModel: {
count: {default: 0},
increment() {
this.count++;
}
}
});
// The `.data` property specifies the observable to cross
// bind the URL to.
route.data = document.querySelector("my-counter").viewModel;
route.start();
</script>
Use route.register to create routing rules.
Instead of URLs like #!&count=1
, #!&count=2
, #!&count=3
;
the following changes the URLs to look like #!1
, #!2
, #!3
:
<mock-url></mock-url>
<my-counter></my-counter>
<script type='module'>
// Imports the <mock-url> element that provides
// a fake back, forward, and URL controls.
import "//unpkg.com/mock-url@^5.0.0";
import {route, Component} from "can";
Component.extend({
tag: "my-counter",
view: `
Count: <span>{{count}}</span>
<button on:click='increment()'>+1</button>
`,
ViewModel: {
count: {default: 0},
increment() {
this.count++;
}
}
});
// Register rules that translate from the URL to
// setting properties on the cross-bound observable.
route.register("{count}");
route.data = document.querySelector("my-counter");
route.start();
</script>
For more information on how to connect observables to the browser's URL, read the Routing guide.
Observables and the service layer
CanJS applications use models to connect
observables to backend services. For example,
lets say the service layer is providing a JSON list of todo data at /api/todos
. The
response looks like:
{
data: [
{ "id": 1, "name": "cook food",
"complete": false, "dueDate": "Wed Jul 11 2018 13:42:31 GMT-0500" },
{ "id": 2, "name": "do taxes",
"complete": true, "dueDate": "Sun Jul 29 2018 20:58:25 GMT-0500" },
...
]
}
The following loads this list of data and logs it to the console by:
- Defining an observable data type to represent individual todos (
Todo
). - Defining an observable data type to represent a list of todos (
Todo.List
). - Connecting those observable data types to the RESTFUL service layer with restModel.
- Using the getList method mixed-into the
Todo
type to get the todos from the server and log them to the console.
import {restModel, DefineMap, DefineList } from "can";
import mockTodosService from "//unpkg.com/todos-fixture@1";
// Defines the observable Todo type and its properties
const Todo = DefineMap.extend("Todo",{
// `identity: true` specifies that `id` values must be unique.
id: { type: "number", identity: true },
complete: { type: "boolean", default: false },
dueDate: "date",
name: "string"
});
// Defines an observable list of Todo instances and its methods
Todo.List = DefineList.extend("TodoList",{
"#": Todo,
// A helper method to complete every todo in the list.
completeAll(){
return this.forEach((todo) => { todo.complete = true; });
}
});
// Mixes in methods on `Todo` useful for
// creating, retrieving, updating and deleting
// data at the URL provided.
restModel({
Map: Todo,
url: "/api/todos/{id}"
});
// Call to setup the mock server before we make a request.
mockTodosService(20);
// Gets a Promise that resolves to a `Todo.List` of `Todo` instances.
let todosPromise = Todo.getList();
todosPromise.then(function(todos){
// .get() converts the Todo instances back to plain JS objects
// for easier to read logging.
console.log(todos.get())
});
The following lists the todos in the page by defining a <todo-list>
component
that:
- Gets a promise that resolves to a list of all todos with
Todo.getList({})
. - Loops through the list of todos and creates an
<li>
for each one with{{# for(todo of todosPromise.value) }}
.
<todo-list></todo-list>
<script type='module'>
import {restModel, DefineMap, DefineList } from "can";
import mockTodosService from "//unpkg.com/todos-fixture@1";
const Todo = DefineMap.extend("Todo",{
id: { type: "number", identity: true },
complete: { type: "boolean", default: false },
dueDate: "date",
name: "string"
});
Todo.List = DefineList.extend("TodoList",{
"#": Todo,
completeAll(){
return this.forEach((todo) => { todo.complete = true; });
}
});
restModel({
Map: Todo,
url: "/api/todos/{id}"
})
mockTodosService(20);
import { Component } from "can";
Component.extend({
tag: "todo-list",
view: `
<ul>
{{# for(todo of todosPromise.value) }}
<li>
<input type='checkbox' checked:from='todo.complete' disabled/>
<label>{{todo.name}}</label>
<input type='date' valueAsDate:from='todo.dueDate' disabled/>
</li>
{{/ for }}
</ul>
`,
ViewModel: {
todosPromise: {
default(){
return Todo.getList({})
}
}
}
});
</script>
You can do a lot more with CanJS’s data layer besides showing a list of data. Read the Service Layer guide for more information on how to:
- Create, update and delete data.
- Automatically insert or remove items from lists when data is created, updated or deleted (automatic list management).
Next Steps
Now that you've got a rough idea idea on the major pieces of CanJS, we suggest: