Merge pull request #1746 from pygy/class-closure-compat

Class and closure components backwards compatibility fixes
This commit is contained in:
Pierre-Yves Gérardy 2017-03-27 22:51:34 +02:00 committed by GitHub
commit 0ce5dcac3b
5 changed files with 259 additions and 66 deletions

View file

@ -101,6 +101,107 @@ To learn more about lifecycle methods, [see the lifecycle methods page](lifecycl
---
### Alternate component syntaxes
#### ES6 classes
Components can also be written using ES6 class syntax:
```javascript
class ES6ClassComponent {
constructor(vnode) {
// vnode.state is undefined at this point
this.kind = "ES6 class"
}
view() {
return m("div", `Hello from an ${this.kind}`)
}
oncreate() {
console.log(`A ${this.kind} component was created`)
}
}
```
Component classes must define a `view()` method, detected via `.prototype.view`, to get the tree to render.
They can be consumed in the same way regular components can.
```javascript
// EXAMPLE: via m.render
m.render(document.body, m(ES6ClassComponent))
// EXAMPLE: via m.mount
m.mount(document.body, ES6ClassComponent)
// EXAMPLE: via m.route
m.route(document.body, "/", {
"/": ES6ClassComponent
})
// EXAMPLE: component composition
class AnotherES6ClassComponent {
view() {
return m("main", [
m(ES6ClassComponent)
])
}
}
```
#### Closure components
Functionally minded developers may prefer using the "closure component" syntax:
```javascript
function closureComponent(vnode) {
// vnode.state is undefined at this point
var kind = "closure component"
return {
view: function() {
return m("div", "Hello from a " + kind)
},
oncreate: function() {
console.log("We've created a " + kind)
}
}
}
```
The returned object must hold a `view` function, used to get the tree to render.
They can be consumed in the same way regular components can.
```javascript
// EXAMPLE: via m.render
m.render(document.body, m(closureComponent))
// EXAMPLE: via m.mount
m.mount(document.body, closuresComponent)
// EXAMPLE: via m.route
m.route(document.body, "/", {
"/": closureComponent
})
// EXAMPLE: component composition
function anotherClosureComponent() {
return {
view: function() {
return m("main", [
m(closureComponent)
])
}
}
}
```
#### Mixing component kinds
Components can be freely mixed. A Class component can have closure or POJO components as children, etc...
---
### State
Like all virtual DOM nodes, component vnodes can have state. Component state is useful for supporting object-oriented architectures, for encapsulation and for separation of concerns.
@ -109,7 +210,7 @@ The state of a component can be accessed three ways: as a blueprint at initializ
#### At initialization
The component object is the prototype of each component instance, so any property defined on the component object will be accessible as a property of `vnode.state`. This allows simple state initialization.
For POJO components, the component object is the prototype of each component instance, so any property defined on the component object will be accessible as a property of `vnode.state`. This allows simple state initialization.
In the example below, `data` is a property of the `ComponentWithInitialState` component's state object.
@ -127,6 +228,10 @@ m(ComponentWithInitialState)
// <div>Initial content</div>
```
For class components, the state is an instance of the class, set right after the constructor is called.
For closure components, the state is the object returned by the closure, set right after the closure returns. The state object is mostly redundant for closure components (since variables defined in the closure scope can be used instead).
#### Via vnode.state
State can also be accessed via the `vnode.state` property, which is available to all lifecycle methods as well as the `view` method of a component.
@ -171,44 +276,6 @@ Be aware that when using ES5 functions, the value of `this` in nested anonymous
---
### ES6 classes
Components can also be written using ES6 class syntax:
```javascript
class ES6ClassComponent {
view() {
return m("div", "Hello from an ES6 class")
}
}
```
They can be consumed in the same way regular components can.
```javascript
// EXAMPLE: via m.render
m.render(document.body, m(ES6ClassComponent))
// EXAMPLE: via m.mount
m.mount(document.body, ES6ClassComponent)
// EXAMPLE: via m.route
m.route(document.body, "/", {
"/": ES6ClassComponent
})
// EXAMPLE: component composition
class AnotherES6ClassComponent {
view() {
return m("main", [
m(ES6ClassComponent)
])
}
}
```
---
### Avoid anti-patterns
Although Mithril is flexible, some code patterns are discouraged:

View file

@ -73,9 +73,11 @@ Property | Type | Description
`text` | `(String|Number|Boolean)?` | This is used instead of `children` if a vnode contains a text node as its only child. This is done for performance reasons. Component vnodes never use the `text` property even if they have a text node as their only child.
`dom` | `Element?` | Points to the element that corresponds to the vnode. This property is `undefined` in the `oninit` lifecycle method. In fragments and trusted HTML vnodes, `dom` points to the first element in the range.
`domSize` | `Number?` | This is only set in fragment and trusted HTML vnodes, and it's `undefined` in all other vnode types. It defines the number of DOM elements that the vnode represents (starting from the element referenced by the `dom` property).
`state` | `Object`? | An object that is persisted between redraws. It is provided by the core engine when needed. In component vnodes, the `state` inherits prototypically from the component object/class.
`events` | `Object?` | An object that is persisted between redraws and that stores event handlers so that they can be removed using the DOM API. The `events` property is `undefined` if there are no event handlers defined. This property is only used internally by Mithril, do not use it.
`state` | `Object?` | An object that is persisted between redraws. It is provided by the core engine when needed. In POJO component vnodes, the `state` inherits prototypically from the component object/class. In class component vnodes it is an instance of the class. In closure components it is the object returned by the closure.
`_state` | `Object?` | For components, a reference to the original `vnode.state` object, used to lookup the `view` and hooks. This property is only used internally by Mithril, do not use or modify it.
`events` | `Object?` | An object that is persisted between redraws and that stores event handlers so that they can be removed using the DOM API. The `events` property is `undefined` if there are no event handlers defined. This property is only used internally by Mithril, do not use or modify it.
`instance` | `Object?` | For components, a storage location for the value returned by the `view`. This property is only used internally by Mithril, do not use or modify it.
`skip` | `Boolean` | This property is only used internally by Mithril when diffing keyed lists, do not use or modify it.
---

View file

@ -103,29 +103,28 @@ module.exports = function($window) {
}
function initComponent(vnode, hooks) {
var sentinel
if (typeof vnode.tag === "function") {
vnode.state = null
sentinel = vnode.tag
if (sentinel.$$reentrantLock$$ != null) return $emptyFragment
sentinel.$$reentrantLock$$ = true
vnode.state = (vnode.tag.prototype != null && typeof vnode.tag.prototype.view === "function") ? new vnode.tag(vnode) : vnode.tag(vnode)
} else {
// For object literals since `Vnode()` always sets the `state` field.
if (typeof vnode.tag.view === "function") {
vnode.state = Object.create(vnode.tag)
sentinel = vnode.state.view
if (sentinel.$$reentrantLock$$ != null) return $emptyFragment
sentinel.$$reentrantLock$$ = true
} else {
vnode.state = void 0
sentinel = vnode.tag
if (sentinel.$$reentrantLock$$ != null) return $emptyFragment
sentinel.$$reentrantLock$$ = true
vnode.state = (vnode.tag.prototype != null && typeof vnode.tag.prototype.view === "function") ? new vnode.tag(vnode) : vnode.tag(vnode)
}
vnode._state = vnode.state
if (vnode.attrs != null) initLifecycle(vnode.attrs, vnode, hooks)
initLifecycle(vnode.state, vnode, hooks)
vnode.instance = Vnode.normalize(vnode.state.view(vnode))
initLifecycle(vnode._state, vnode, hooks)
vnode.instance = Vnode.normalize(vnode._state.view.call(vnode.state, vnode))
if (vnode.instance === vnode) throw Error("A view cannot return the vnode it received as argument")
sentinel.$$reentrantLock$$ = null
}
function createComponent(parent, vnode, hooks, ns, nextSibling) {
initComponent(vnode, hooks)
if (vnode.instance != null) {
if (vnode.instance === vnode) throw Error("A view cannot return the vnode it received as arguments")
var element = createNode(parent, vnode.instance, hooks, ns, nextSibling)
vnode.dom = vnode.instance.dom
vnode.domSize = vnode.dom != null ? vnode.instance.domSize : 0
@ -235,6 +234,7 @@ module.exports = function($window) {
var oldTag = old.tag, tag = vnode.tag
if (oldTag === tag) {
vnode.state = old.state
vnode._state = old._state
vnode.events = old.events
if (!recycling && shouldNotUpdate(vnode, old)) return
if (typeof oldTag === "string") {
@ -317,9 +317,10 @@ module.exports = function($window) {
if (recycling) {
initComponent(vnode, hooks)
} else {
vnode.instance = Vnode.normalize(vnode.state.view(vnode))
vnode.instance = Vnode.normalize(vnode._state.view.call(vnode.state, vnode))
if (vnode.instance === vnode) throw Error("A view cannot return the vnode it received as argument")
if (vnode.attrs != null) updateLifecycle(vnode.attrs, vnode, hooks)
updateLifecycle(vnode.state, vnode, hooks)
updateLifecycle(vnode._state, vnode, hooks)
}
if (vnode.instance != null) {
if (old.instance == null) createNode(parent, vnode.instance, hooks, ns, nextSibling)
@ -405,15 +406,15 @@ module.exports = function($window) {
}
function removeNode(vnode, context) {
var expected = 1, called = 0
if (vnode.attrs && vnode.attrs.onbeforeremove) {
if (vnode.attrs && typeof vnode.attrs.onbeforeremove === "function") {
var result = vnode.attrs.onbeforeremove.call(vnode.state, vnode)
if (result != null && typeof result.then === "function") {
expected++
result.then(continuation, continuation)
}
}
if (typeof vnode.tag !== "string" && vnode.state.onbeforeremove) {
var result = vnode.state.onbeforeremove(vnode)
if (typeof vnode.tag !== "string" && typeof vnode._state.onbeforeremove === "function") {
var result = vnode._state.onbeforeremove.call(vnode.state, vnode)
if (result != null && typeof result.then === "function") {
expected++
result.then(continuation, continuation)
@ -445,8 +446,8 @@ module.exports = function($window) {
if (parent != null) parent.removeChild(node)
}
function onremove(vnode) {
if (vnode.attrs && vnode.attrs.onremove) vnode.attrs.onremove.call(vnode.state, vnode)
if (typeof vnode.tag !== "string" && vnode.state.onremove) vnode.state.onremove(vnode)
if (vnode.attrs && typeof vnode.attrs.onremove === "function") vnode.attrs.onremove.call(vnode.state, vnode)
if (typeof vnode.tag !== "string" && typeof vnode._state.onremove === "function") vnode._state.onremove.call(vnode.state, vnode)
if (vnode.instance != null) onremove(vnode.instance)
else {
var children = vnode.children
@ -585,7 +586,7 @@ module.exports = function($window) {
function shouldNotUpdate(vnode, old) {
var forceVnodeUpdate, forceComponentUpdate
if (vnode.attrs != null && typeof vnode.attrs.onbeforeupdate === "function") forceVnodeUpdate = vnode.attrs.onbeforeupdate.call(vnode.state, vnode, old)
if (typeof vnode.tag !== "string" && typeof vnode.state.onbeforeupdate === "function") forceComponentUpdate = vnode.state.onbeforeupdate(vnode, old)
if (typeof vnode.tag !== "string" && typeof vnode._state.onbeforeupdate === "function") forceComponentUpdate = vnode._state.onbeforeupdate.call(vnode.state, vnode, old)
if (!(forceVnodeUpdate === undefined && forceComponentUpdate === undefined) && !forceVnodeUpdate && !forceComponentUpdate) {
vnode.dom = old.dom
vnode.domSize = old.domSize

View file

@ -260,8 +260,9 @@ o.spec("component", function() {
o(root.childNodes.length).equals(0)
})
o("throws a custom error if it returns itself", function() {
o("throws a custom error if it returns itself when created", function() {
// A view that returns its vnode would otherwise trigger an infinite loop
var threw = false
var component = createComponent({
view: function(vnode) {
return vnode
@ -271,10 +272,41 @@ o.spec("component", function() {
render(root, [{tag: component}])
}
catch (e) {
threw = true
o(e instanceof Error).equals(true)
// Call stack exception is a RangeError
o(e instanceof RangeError).equals(false)
}
o(threw).equals(true)
})
o("throws a custom error if it returns itself when updated", function() {
// A view that returns its vnode would otherwise trigger an infinite loop
var threw = false
var init = true
var oninit = o.spy()
var component = createComponent({
oninit: oninit,
view: function(vnode) {
if (init) return init = false
else return vnode
}
})
render(root, [{tag: component}])
o(root.firstChild.nodeType).equals(3)
o(root.firstChild.nodeValue).equals("")
try {
render(root, [{tag: component}])
}
catch (e) {
threw = true
o(e instanceof Error).equals(true)
// Call stack exception is a RangeError
o(e instanceof RangeError).equals(false)
}
o(threw).equals(true)
o(oninit.callCount).equals(1)
})
o("can update when returning fragments", function() {
var component = createComponent({
@ -732,6 +764,97 @@ o.spec("component", function() {
o(attrs[hook].callCount).equals(methods[hook].callCount)(hook)
})
})
o("lifecycle timing megatest (for a single component with the state overwritten)", function() {
var methods = {
view: o.spy(function(vnode) {
o(vnode.state).equals(1)
return ""
})
}
var attrs = {}
var hooks = [
"oninit", "oncreate", "onbeforeupdate",
"onupdate", "onbeforeremove", "onremove"
]
hooks.forEach(function(hook) {
// the `attrs` hooks are called before the component ones
attrs[hook] = o.spy(function(vnode) {
o(vnode.state).equals(1)
o(attrs[hook].callCount).equals(methods[hook].callCount + 1)
})
methods[hook] = o.spy(function(vnode) {
o(vnode.state).equals(1)
o(attrs[hook].callCount).equals(methods[hook].callCount)
})
})
var attrsOninit = attrs.oninit
var methodsOninit = methods.oninit
attrs.oninit = o.spy(function(vnode){
vnode.state = 1
return attrsOninit.call(this, vnode)
})
methods.oninit = o.spy(function(vnode){
vnode.state = 1
return methodsOninit.call(this, vnode)
})
var component = createComponent(methods)
o(methods.view.callCount).equals(0)
o(methods.oninit.callCount).equals(0)
o(methods.oncreate.callCount).equals(0)
o(methods.onbeforeupdate.callCount).equals(0)
o(methods.onupdate.callCount).equals(0)
o(methods.onbeforeremove.callCount).equals(0)
o(methods.onremove.callCount).equals(0)
hooks.forEach(function(hook) {
o(attrs[hook].callCount).equals(methods[hook].callCount)(hook)
})
render(root, [{tag: component, attrs: attrs}])
o(methods.view.callCount).equals(1)
o(methods.oninit.callCount).equals(1)
o(methods.oncreate.callCount).equals(1)
o(methods.onbeforeupdate.callCount).equals(0)
o(methods.onupdate.callCount).equals(0)
o(methods.onbeforeremove.callCount).equals(0)
o(methods.onremove.callCount).equals(0)
hooks.forEach(function(hook) {
o(attrs[hook].callCount).equals(methods[hook].callCount)(hook)
})
render(root, [{tag: component, attrs: attrs}])
o(methods.view.callCount).equals(2)
o(methods.oninit.callCount).equals(1)
o(methods.oncreate.callCount).equals(1)
o(methods.onbeforeupdate.callCount).equals(1)
o(methods.onupdate.callCount).equals(1)
o(methods.onbeforeremove.callCount).equals(0)
o(methods.onremove.callCount).equals(0)
hooks.forEach(function(hook) {
o(attrs[hook].callCount).equals(methods[hook].callCount)(hook)
})
render(root, [])
o(methods.view.callCount).equals(2)
o(methods.oninit.callCount).equals(1)
o(methods.oncreate.callCount).equals(1)
o(methods.onbeforeupdate.callCount).equals(1)
o(methods.onupdate.callCount).equals(1)
o(methods.onbeforeremove.callCount).equals(1)
o(methods.onremove.callCount).equals(1)
hooks.forEach(function(hook) {
o(attrs[hook].callCount).equals(methods[hook].callCount)(hook)
})
})
o("hook state and arguments validation", function(){
var methods = {
view: o.spy(function(vnode) {
@ -862,7 +985,7 @@ o.spec("component", function() {
o("Constructible", function() {
var oninit = o.spy()
var component = o.spy(function(vnode){
o(vnode.state).equals(null)
o(vnode.state).equals(undefined)
o(oninit.callCount).equals(0)
})
var view = o.spy(function(){
@ -888,7 +1011,7 @@ o.spec("component", function() {
return ""
})
var component = o.spy(function(vnode) {
o(vnode.state).equals(null)
o(vnode.state).equals(undefined)
o(oninit.callCount).equals(0)
return state = {
view: view

View file

@ -1,7 +1,7 @@
"use strict"
function Vnode(tag, key, attrs, children, text, dom) {
return {tag: tag, key: key, attrs: attrs, children: children, text: text, dom: dom, domSize: undefined, state: undefined, events: undefined, instance: undefined, skip: false}
return {tag: tag, key: key, attrs: attrs, children: children, text: text, dom: dom, domSize: undefined, state: undefined, _state: undefined, events: undefined, instance: undefined, skip: false}
}
Vnode.normalize = function(node) {
if (Array.isArray(node)) return Vnode("[", undefined, undefined, Vnode.normalizeChildren(node), undefined, undefined)