Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce plugin architecture. #124

Merged
merged 9 commits into from
Mar 4, 2017
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 37 additions & 37 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,39 +1,39 @@
{
"name": "hyperapp",
"description": "1kb JavaScript library for building frontend applications.",
"version": "0.6.0",
"main": "dist/hyperapp.js",
"jsnext:main": "src/index.js",
"module": "src/index.js",
"license": "MIT",
"repository": "hyperapp/hyperapp",
"files": [
"src",
"dist"
],
"author": "Jorge Bucaran",
"keywords": [
"hyperx",
"hyperscript",
"jsx",
"react",
"elm",
"virtual-dom"
],
"scripts": {
"test": "jest --coverage --no-cache",
"build": "rollup -cm -n hyperapp -f umd -i src/index.js -o dist/hyperapp.js",
"prepublish": "npm run build",
"release": "npm run build && npm test && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish"
},
"babel": {
"presets": "es2015"
},
"devDependencies": {
"babel-preset-es2015": "^6.22.0",
"hyperx": "^2.0.5",
"jest": "^18.1.0",
"rollup": "^0.41.4",
"rollup-plugin-uglify": "^1.0.1"
}
"name": "hyperapp",
"description": "1kb JavaScript library for building frontend applications.",
"version": "0.6.0",
"main": "dist/hyperapp.js",
"jsnext:main": "src/index.js",
"module": "src/index.js",
"license": "MIT",
"repository": "hyperapp/hyperapp",
"files": [
"src",
"dist"
],
"author": "Jorge Bucaran",
"keywords": [
"hyperx",
"hyperscript",
"jsx",
"react",
"elm",
"virtual-dom"
],
"scripts": {
"test": "jest --coverage --no-cache --verbose",
"build": "rollup -cm -n hyperapp -f umd -i src/index.js -o dist/hyperapp.js",
"prepublish": "npm run build",
"release": "npm run build && npm test && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish"
},
"babel": {
"presets": "es2015"
},
"devDependencies": {
"babel-preset-es2015": "^6.22.0",
"hyperx": "^2.0.5",
"jest": "^18.1.0",
"rollup": "^0.41.4",
"rollup-plugin-uglify": "^1.0.1"
}
}
150 changes: 83 additions & 67 deletions src/app.js
Original file line number Diff line number Diff line change
@@ -1,48 +1,47 @@
export default function (options) {
var model = options.model
var view = options.view
export default function (app) {
var view = app.view || function () {
return ""
}

var model
var actions = {}
var effects = options.effects || {}
var reducers = options.reducers || {}
var subs = options.subscriptions

var router = options.router

var reducers
var effects
var subscriptions = []
var hooks = []
var plugins = app.plugins
var node
var root
var batch = []

var hooks = merge({
onAction: Function.prototype,
onUpdate: Function.prototype,
onError: function (err) {
var onError = function (err) {
if (!hook("onError", function (fn) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very strange to me.. Consider moving the callback function before the if?

var onerror = function (fn) { fn(err) }
if (!hook("onError", onerror)) {
  throw err
}

and in anyway it looks strange, but that's better

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not going to work, because we pass onError to effects and subscriptions.

Copy link

@tunnckoCore tunnckoCore Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wat? no, no. I not meant that. It's no matter how the var is named. The point is to not be inside the if condition

var foobar = function (fn) { fn(err) }
if (!hook("onError", foobar)) {
  throw err
}

and that (as currently) are the same thing

if (!hook("onError", function (fn) { fn(err) })) { // collapsed in one line
  throw err
}

Copy link
Owner Author

@jorgebucaran jorgebucaran Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm missing something, but I still think this is not going to work when sharing onError with multiple plugins. This is a minor thing, so please send me a PR after we land this one 👍.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the new hook system this problem has gone away!

fn(err)
})) {
throw err
}
}, options.hooks)
}

initializeActions(actions, effects, false)
initializeActions(actions, reducers, true)
use(app)

domContentLoaded(function () {
root = options.root || document.body.appendChild(document.createElement("div"))
for (var key in plugins) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use for ... in ... for array iteration.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the other hand for loop is more verbose and we'd need to check if it's empty or not with .length. I'll experiment with for and see how that affects size/performance. 👍

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Size might be more, but for in is basically a hack when used on arrays.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just out of curiosity how come for ... in is being preferred over forEach or map

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lukejacksonn for in is used to loop through props of an object. This can be used to traverse an array too, but it's a "hack". The best practice to traverse arrays is using a regular for loop or forEach.

I've changed them all to use a regular for loop, so say bye to for in! :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉

use(plugins[key](app))
}

if (typeof view === "function") {
render(model, view)
}
init(actions, effects, false)
init(actions, reducers, true)

if (router) {
router(function (newView) {
render(model, view = newView ? newView : view, node)
}, options)
}
load(function () {
root = app.root || document.body.appendChild(document.createElement("div"))

for (var key in subs) {
subs[key](model, actions, hooks.onError)
render(model, view)

for (var key in subscriptions) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for ... in

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

subscriptions[key](model, actions, onError)
}
})

function initializeActions(container, group, shouldUpdate, lastName) {
function init(container, group, shouldRender, lastName) {
Object.keys(group).forEach(function (key) {
if (!container[key]) {
container[key] = {}
Expand All @@ -53,32 +52,72 @@ export default function (options) {

if (typeof action === "function") {
container[key] = function (data) {
hooks.onAction(name, data)
hook("onAction", function (fn) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you implement the data structure I suggested this would be

hooks.onAction.forEach(hook => hook(name, data))

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, this sounds good. Let me see how I can do that.

fn(name, data)
})

if (shouldRender) {
var oldModel = model
model = merge(model, action(model, data))

hook("onUpdate", function (fn) {
fn(oldModel, model, data)
})

render(model, view)

if (shouldUpdate) {
hooks.onUpdate(model, model = merge(model, action(model, data)), data)
render(model, view, node)
return actions
} else {
return action(model, actions, data, hooks.onError)
return action(model, actions, data, onError)
}
}
} else {
initializeActions(container[key], action, shouldUpdate, name)
init(container[key], action, shouldRender, name)
}
})
}

function domContentLoaded(initApp) {
function load(fn) {
if (document.readyState[0] !== "l") {
initApp()
fn()
} else {
document.addEventListener("DOMContentLoaded", initApp)
document.addEventListener("DOMContentLoaded", fn)
}
}

function render(model, view, lastNode) {
patch(root, node = view(model, actions), lastNode, 0)
function use(app) {
if (app.model) {
model = merge(model, app.model)
}

reducers = merge(reducers, app.reducers)
effects = merge(effects, app.effects)

if (app.subscriptions) {
subscriptions = subscriptions.concat(app.subscriptions)
}

if (app.hooks) {
hooks.push(app.hooks)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of pushing objects into an array I would iterate through the objects and construct this data-structure:

var hooks = {
	onRender: [f1, f2, f3],
	onError: [f4]
}

This has the advantage that access is fast and easy. Basically it should be handled the same as subscriptions.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dodekeract Can you provide some pseudocode?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

var hooks = {
	onRender: []
}
function addToHooks(hook, name) {
	hooks[name].push(hook)
}
function handlePluginHooks (hooks) {
	for (var name in hooks) {
		addToHooks(hooks[name], name)
	}
}

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems more verbose. Specially if more hooks are added in the future. I'll see if there are any performance gains! 👍

Copy link

@FlorianWendelborn FlorianWendelborn Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There will be performance gains. https://jsperf.com/for-in-or-for

1,400,835,640 vs 43,479 for me.

Copy link
Owner Author

@jorgebucaran jorgebucaran Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you talking about for-in used with arrays?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, yes. Hard to keep track of 10 conversations at the same time.

}
}

function hook(name, fn) {
for (var key in hooks) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use for in for array iteration. It's slow and potentially buggy.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

var hookfn = hooks[key][name]
if (hookfn) {
fn(hookfn)
}
}
return hookfn !== undefined
}

function render(model, view) {
hook("onRender", function (fn) {
view = fn(model, view)
})

patch(root, node, node = view(model, actions), 0)

for (var i = 0; i < batch.length; i++) {
batch[i]()
Expand Down Expand Up @@ -141,15 +180,7 @@ export default function (options) {
}

for (var i = 0; i < node.children.length; i++) {
var childNode = node.children[i]

if (
childNode !== undefined &&
typeof childNode !== "boolean" &&
childNode !== null
) {
element.appendChild(createElementFrom(childNode))
}
element.appendChild(createElementFrom(node.children[i]))
}
}

Expand Down Expand Up @@ -213,7 +244,7 @@ export default function (options) {
}
}

function patch(parent, node, oldNode, index) {
function patch(parent, oldNode, node, index) {
if (oldNode === undefined) {
parent.appendChild(createElementFrom(node))

Expand All @@ -227,20 +258,7 @@ export default function (options) {
}

} else if (shouldUpdate(node, oldNode)) {
var element = parent.childNodes[index]

if (typeof node === "boolean") {
parent.removeChild(element)

} else {
var newElement = createElementFrom(node)

if (element === undefined) {
parent.appendChild(newElement)
} else {
parent.replaceChild(newElement, element)
}
}
parent.replaceChild(createElementFrom(node), parent.childNodes[index])

} else if (node.tag) {
var element = parent.childNodes[index]
Expand All @@ -252,9 +270,7 @@ export default function (options) {
for (var i = 0; i < len || i < oldLen; i++) {
var child = node.children[i]

if (child !== null) {
patch(element, child, oldNode.children[i], i)
}
patch(element, oldNode.children[i], child, i)
}
}
}
Expand Down
90 changes: 56 additions & 34 deletions src/h.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,60 @@
export default function(tag, data) {
data = data || {}

var children = []
children.push.apply(children, arguments)
children.shift()
children.shift()
// var children = children.push.apply(children, arguments).splice(2)
var head = children[0]

children = Array.isArray(head) || head === undefined ? head : children

if (typeof tag === "function") {
return tag(data, children || [])
}

if (tag === "svg") {
svg(tag, data, children)
}

return {
tag: tag,
data: data,
children: [].concat.apply([], children)
}
var i, node, children, stack = []

export default function (tag, data) {
var canConcat, oldCanConcat

children = []
i = arguments.length

while (i-- > 2) {
stack.push(arguments[i])
}

while (stack.length) {
if (Array.isArray(node = stack.pop())) {
i = node.length

while (i--) {
stack.push(node[i])
}
} else if (node != null && node !== true && node !== false) {
if (typeof node === "number") {
node = node + ""
}

canConcat = typeof node === "string"

if (canConcat && oldCanConcat) {
children[children.length - 1] += node
} else {
children.push(node)
oldCanConcat = canConcat
}
}
}

if (typeof tag === "function") {
return tag(data, children)
}

if (tag === "svg") {
svg(tag, data, children)
}

return {
tag: tag,
data: data || {},
children: children
}
}

function svg(tag, data, children) {
data.ns = "http://www.w3.org/2000/svg"

for (var i = 0; i < children.length; i++) {
var node = children[i]

if (node.data) {
svg(node.tag, node.data, node.children)
}
}
data.ns = "http://www.w3.org/2000/svg"

for (var i = 0; i < children.length; i++) {
var node = children[i]
if (node.data) {
svg(node.tag, node.data, node.children)
}
}
}
4 changes: 2 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import h from "./h"
import app from "./app"
import router from "./router"
import Router from "./router"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this TitleCase? It's not a class.

Copy link
Owner Author

@jorgebucaran jorgebucaran Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not a class.

I don't care about that convention. I think it sucks to limit myself to a convention for a construct I rarely use in JavaScript anyway. I don't use Classes/Prototypes.

Router is more distinctive. It's not a component, like in React, but they also use Router, which is familiar.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jbucaran the thing is that it implies that router has to be used like new Router().

Copy link
Owner Author

@jorgebucaran jorgebucaran Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't use new or this; as an extension, neither does HyperApp. So, why should Router imply that?

Copy link

@FlorianWendelborn FlorianWendelborn Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because it's TitleCase. For someone familiar with javascript (and not that much with hyperapp's source-code) that usually means it has to be instantiated.

Copy link
Owner Author

@jorgebucaran jorgebucaran Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plugins written in title case look better, just like Components.

app({ 
    plugins: [
        Router, 
        Logger, 
        TimeDebugger, 
        FusionGenerator, 
        FluxCapacitor
    ]
})

Copy link
Contributor

@lukejacksonn lukejacksonn Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Router could be a plugin but that doesn't mean it has to be. If it is then it negates the R vs r issue. The awkward thing about the router is that it is not needed for all cases, but in reality it is needed for a lot of cases and if it is not needed (they only have one view/route) then hyperapp already has that covered with very little redundant bytes. Good frontend routers are hard to come by. I think this..

model.router = {
	matched: '/some/route/:id',
	url: '/some/route/34593458',
	params: {
		id: 34593458
	}
}

with an onrender onRender hook, feels like a very robust approach. I'd say for the sakes of argument.. abstract the router out, use it as an example of a really useful plugin (npm i hyperapp-router -S is a good practice for developers to get into anyway if we are encouraging a plugin ecosystem) and let people call it what they want.

Copy link
Owner Author

@jorgebucaran jorgebucaran Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @lukejacksonn. Notice the correct name is onRender. Hooks are application level events so we use camelCase. DOM events are lowercase so we use oncreate, onupdate, onremove for lifecycle methods. Just for the record and in case someone finds this in the future.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what it's worth, the convention in Hapi is to capitalize anything you 'require'. It makes it more clear which variables are from an external file, and I agree with @jbucaran that it looks nicer.

Copy link
Owner Author

@jorgebucaran jorgebucaran Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to know! Components are also capitalized in HyperApp, but they are not classes.


export { h, app, router }
export { h, app, Router }
Loading