jorgebucaran / hyperapp

1kB-ish JavaScript framework for building hypertext applications
MIT License
19.08k stars 780 forks source link

Server Rendering? #14

Closed FlorianWendelborn closed 7 years ago

FlorianWendelborn commented 7 years ago

The Problem

Server-side rendering mostly improves the following aspects of web-applications. They're arguably the most important ones:

  1. Time to load. The client can already start rendering without even loading the .js
  2. Search engine optimization. Most SEs (notable exception: Google) don't parse JavaScript-only websites or rank them down. Responding with a "real" HTML solves this perfectly.
  3. Especially reduces initial load times on mobile.
  4. Greatly improve the experience for people that disabled javascript. Especially given that hyperapp's router uses plain links that work on the server-side too.

Proposed Usage

  1. Create an app() that works on the client-side.
  2. Require said app() on the server-side
  3. Render it somehow (maybe with a hyperapp/server, so that the client-version isn't polluted )
  4. Get a string or buffer back
  5. Return said buffer via HTTP/etc.

So basically, the same as react. :smile:

Notable Challenges


Bonus feature?

Just thought about file-sizes. Wouldn't it be possible to somehow re-use the already rendered HTML version to make the javascript components even smaller? Maybe gzip already fixes that if you bundle the JS as well. Not sure.


Is this possible or planned? Would be awesome if this could completely replace React.

jorgebucaran commented 7 years ago

@dodekeract Planned, if not already possible!

But how would that look like? Can you describe a simple scenario or use case?

EDIT: Grammar.

tunnckoCore commented 7 years ago

This was my question previous evening (sun is rising up now here at Bulgaria) too.

SkaterDad commented 7 years ago

snabbdom/snabbdom-to-html would seem to be a good starting point for implementation. If you're open to it, I could try to hack something together.

It would also be a bonus if streaming SSR could be implemented, similar to what Vue 2.0 is doing. Their implementation is a bit complex due to their component design, but it could serve as inspiration. I guess it's possible that hyperapp is so lightweight that you won't benefit from streaming, though.

This is a really intriguing project! 95% of the reason I'm currently trying to implement my project in Vue was the promise of easy, high performance server rendering. But... all the extra baggage is making my eyes wander back to small, more functional approaches like hyperapp.

jorgebucaran commented 7 years ago

If you're open to it, I could try to hack something together.

Yes, absolutely!

FlorianWendelborn commented 7 years ago

I think the server-part doesn't have to be "small". It doesn't need to be transferred over the network so it's fine if it takes a bit more size.

tunnckoCore commented 7 years ago

I think the server-part doesn't have to be "small".

Yea. But not some biggy nasty shitty bloat :D

SkaterDad commented 7 years ago

@tunnckoCore No need to worry about size! Including some HTML tag lists and a few edge cases, the renderToString function is less than 100 lines of code so far. I don't expect it to grow too much larger, thanks to the simplicity of hyperapp's nodes.

@jbucaran Do you have a preferred testing library? I'll set up some unit tests of the string renderer once I'm ready.

jorgebucaran commented 7 years ago

@SkaterDad tape, if that's okay with you πŸ‘

Don't worry about the initial size, we'll tune things as we go.

tunnckoCore commented 7 years ago

Rollup also suggests options.globals so we can include the http://npm.im/global or http://npm.im/nodom package then just toString would work.

queckezz commented 7 years ago

I had half an hour of time so I'll started up a repository: https://github.com/queckezz/hyperapp-to-html. Appreciate taking a look at this so we can push this further. When done iterating we can possible include it via hyperapp/server, hyperapp/to-string or hyperapp/serialize @jbucaran?

Also, check out the tests

tunnckoCore commented 7 years ago

You may also look at https://github.com/tunnckoCore/mich-to-html :P It does not support void elements (self closing tags) currently, but i'll fix that. It is not a problem to be bigger than 500 bytes, but yea, i just wanted to try and did it to see if mich-h has some problems constructing the virtual dom.

@jbucaran that we could use if we change a bit the names of the virtual dom impl. Just tag -> tagName and data -> properties. And everything would just work.

jorgebucaran commented 7 years ago

@queckezz Your time management skills are incredible! :) Also, this looks pretty cool.

I don't have any data, but perhaps raw for loops would out perform their modern map/forEach brothers?

FlorianWendelborn commented 7 years ago

@jbucaran Would be really weird if they didn't or wouldn't at least be equally as fast.

jorgebucaran commented 7 years ago

@dodekeract Yeah, I just don't want to sound like I am a blind for-loop fanatic. Anyway, you'd think they'd perform better since you don't need to create a dummy function.

AutoSponge commented 7 years ago

There are (at least) 3 issues to solve here:

  1. implement the DOM api in the server (jsdom, undom, etc.), possibly an exercise for the dev.
  2. Create a string serialization of app to send to the client (easily solved by returning either app.root or the app's virtual tree which can be converted to HTML string (should be easy).
  3. Send the model/state so the client can "mount" the HTML (hard?)

I've worked on morphdom and they solved [#3] rather easily because (at first) there was no virtual dom, it's just diffing HTML. That may work for hyperapp but I think something will need to be done with root. If the server is holding a reference to a root property, it needs to be converted to a selector which will be replaced with the element reference when the app loads in the client.

So, I would suggest adding this feature earlier. The ability to make root a string. The string is used as a selector when app executes load. That should make it possible to completely serialize app/app state.

jorgebucaran commented 7 years ago

@dodekeract Can we start over again with this issue?

benjamminj commented 7 years ago

I'd love to be a part of this if I can be of any help.

Would a good place to restart be with a toString function that just spits out static html?

jorgebucaran commented 7 years ago

@benjaminj6 Thanks! Maybe you can start here: https://github.com/hyperapp/hyperapp/pull/28.

benjamminj commented 7 years ago

Perfect! Will see what I can do. πŸ‘ŒπŸ»

benjamminj commented 7 years ago

Hey all,

So we've been chatting in hyperapp/hyperapp-server regarding what form server rendering should take.

Right now the way things are evolving are towards having a render function built into hyperapp/hyperapp, which would be used like this:

import render from 'hyperapp/server'

Feel free to read the discussion there, but let's continue chatting about it here πŸŽ‰

jorgebucaran commented 7 years ago

I'd like to share @benjaminj6's summary of the different SSR approaches available, so I'll copy & paste his original comment here as we'll possibly throw away (or repurpose) hyperapp/hyperapp-serve.


Alright so I've been thinking a bit and here's my thoughts/a couple other ways people are doing it. * **[react-dom/server](https://facebook.github.io/react/docs/react-dom-server.html)** Offers two methods, `renderToString` and `renderToStaticMarkup`. Only difference between the two is primarily that the first includes all of the keys and internal tags that React uses while the second strips them away (I think in the case of hyperapp this would be something akin to leaving `key` attributes on elements) * **[preact-render-to-string](https://www.npmjs.com/package/preact-render-to-string)** This is a lot closer IMO to something that could work for hyperapp. Basically, it exports a `render` function that takes a vdom node as its only parameter. Actually, the API is essentially like interacting with our `toString` function 😎 * **[vue-server-renderer](https://ssr.vuejs.org/en/)** Basically you create a renderer object that contains two rendering methods, `renderToString` and `renderToStream`. As I've been thinking the best way forward to me might be a simple renderer, akin to what we've already got. We'd set it up so that you can basically call the function(s) used in `hyperapp/app.view`, except render them to a string. Since they're already just a function of `state` and `actions` it actually shouldn't take too much to render one of those views (or any component for that matter). – @benjaminj6
SkaterDad commented 7 years ago

My vote would be to build in streaming support, similar to Vue. It allows a much higher throughput on the server by breaking up the rendering into smaller async chunks. I haven't dug into how they implemented that, though.

A simple toString would be a great first step, though, and from there you can do perf benchmarking to see what could be improved, I suppose.

Another interesting strategy is what marko does. They write blog posts claiming their SSR performance is the fastest around.

https://hackernoon.com/server-side-rendering-shootout-with-marko-preact-rax-react-and-vue-25e1ae17800f

When compiled for the browser, rendering builds a virtual DOM tree that can be diffed directly with the browser’s DOM (powered by morphdom)β€” similar to the approaches in these other libraries. But when running on the server, Marko forgoes a virtual DOM completely and writes directly to a string buffer (which also happens to support streaming for progressive HTML rendering).

HTTP can only send strings/bytes, so it makes sense to start there and skip the overhead of building up a virtual DOM representation first. There’s a reason string based templating languages are so much faster than our modern frameworks using a virtual DOM implementation.

jorgebucaran commented 7 years ago

@SkaterDad @benjaminj6 Thanks! Before we implement anything though, can we have an example of how a typical SSR app would look like in hyperapp? πŸ€”

What do you think it would be a good SSR hello world / simple counter equivalent?

AutoSponge commented 7 years ago

@jbucaran The example should demonstrate the ability to SSR and remount the DOM. Something approaching real-world scenario, like TODO MVC, would be best IMO.

benjamminj commented 7 years ago

So a couple simple examples I can think of:

jorgebucaran commented 7 years ago

@benjaminj6 Can you provide a virtual example/code? Just how you think it should look like.

benjamminj commented 7 years ago

@jbucaran I'd be happy to πŸ˜€ I should have time in the next couple days to set up a couple glitch apps or write it out

jorgebucaran commented 7 years ago

@benjaminj6 That would be great, but let me clarify. By "virtual", I wanted to say dream/artificial code, so basically, a "non-working" ideal example that illustrates SSR with hyperapp. Does that make more sense? :)

benjamminj commented 7 years ago

Yeah that makes perfect sense. I'll whip some samples up over the next couple days πŸ‘ŒπŸ»

benjamminj commented 7 years ago

So here's my thoughts for a simple implementation of SSR. I'll try to work on something a little more involved in a bit, but I'd love to hear any thoughts.

Simple render function -- Counter app

// view.js
import { h } from "hyperapp"

export default (state, actions) => (
  <main>
    <h1>{state}</h1>
    <button onclick={actions.add}>+</button>
    <button onclick={actions.sub}>-</button>
  </main>
)

// actions.js
export const add = state => state + 1
export const sub = state => state - 1

// client.js
import { app } from "hyperapp"
import * as actions from "./actions"
import view from "./view"

app({
  state: 0,
  view,
  actions
})

// server.js
import express from "express"
import view from "./view"
import * as actions from "./actions"
import render from "hyperapp/server"

const app = express()

app.get('/', (req, res) => {
  const state = 0
  const vnode = view(state, actions)
  const html = render(vnode)

  res.send(html)
})

app.listen(3000) // Woot!

The important things to note with this:

  1. Only the initial view is rendered. In this case you would have to still send client.js to make the app interactive. I'm not sure whether vdom nodes will remount the html when the javascript executes client-side, but this case still has massive SEO benefits. If I'm reading the code correctly, they shouldn't remount the DOM unless it's actually different. We'd want to make sure, and I believe that would be related to the patch function in hyperapp/app.js. Any insight on this?
  2. If we make render take a vnode as it's input, the benefit is that we can render individual components too. So render(<MyComponent class="foo" />) could work.

Like I said, will try to brainstorm that more involved, real-world-ish scenario that @AutoSponge mentioned earlier but my initial hope is that this format for a render function would translate to that too πŸ˜„

SkaterDad commented 7 years ago

@benjaminj6 The approach you laid out is nice & clean, but I think we may need to have an instance of hyperapp loaded up on the server-side in order to execute the lifecycle hooks and plugins (mixins) that someone may be using. Probably not the full hyperapp, but something similar enough which invokes all the hooks then spits out HTML.

Here's a couple ideas with the router. 1st idea, you register it like normal, but it's a special version designed to handle Node.

//special app & router designed for backend use.
const {app, router} = require('hyperapp/server')

const view = require('./view.js')
const actions = require('./actions.js')

// don't include the state since that is specific to each request
// this could return a function which needs a state object, then executes lifecycle and returns html string
const serverApp = app({view, actions, plugins: [router]})

const express = require('express')
const server= express()

server.get('/*', (req, res) => {
  const state = {
    key: value,
    //  router state is initialized by lifecycle hooks like normal plugin, but takes req object instead of browser location
  }
  const html = serverApp(state)
  res.send(html)  // ideally streaming, but this is simpler to start
})

Alternatively, the server version of router is just the match function that turns a URL into the state object. This is probably not ideal for the router, though, since something could trigger a url redirect action during this process, but for simpler plugins it's an option.

const {app, router} = require('hyperapp/server')
const view = require('./view.js')
const actions = require('./actions.js')

// don't include the state since that is specific to each request
// this could return a function which needs a state object, then executes lifecycle and returns html string
const serverApp = app({view, actions})

const express = require('express')
const server= express()

server.get('/*', (req, res) => {
  const state = {
    key: value,
    router:  router(req.originalUrl)
  }
  const html = serverApp(state)
  res.send(html)  // ideally streaming, but this is simpler to start
})

And yes, I called them plugins, as a form of protest :stuck_out_tongue:

Thoughts?

FlorianWendelborn commented 7 years ago

I'm not even sure if we need a real router on the server-side. Only the "rendering" part of the router would be enough. The URL can be manipulated by changing the initial state.

andyrj commented 7 years ago

I haven't looked really close at app.js for ssr rendered dom re-hydration, but I would think we could make it a flag passed in the first render client side that simply informs the patch function that we aren't really patching, only attaching event handlers, but go ahead and build a vdom to diff against on next render. Now how that plays with mixins I really haven't the slightest...

You can use the full hyperapp serverside mixin's and all if you use jsdom/simple-dom, and provide mocks for the functions required by your mixins, for instance I had to mock addEventListener so that the Router would still work server side in jsdom.

nitely commented 7 years ago

Will render work for non-js servers? i.e: php-v8js, python-v8, therubyracer, etc. The only requirement would be that the render function runs synchronous code only (no async/callbacks) and no use of Node.js internals (this may be mitigated with webpack). React and react-router does support this.

andyrj commented 7 years ago

so I was messing around on my own fork to test hyperapp SSR with rehydration on first client render just to see what I could get going and I think I have something that mostly works and won't effect pure client side rendered apps...

https://github.com/andyrj/hyperapp/blob/rehydrate/src/app.js#L75

thoughts?

I wrote it with es6 just to test the idea since I had to link it up to my other build process with npm link...

I would obviously change to for loop instead for a PR, and I was really wondering what you all thought about how I didn't populate the attrs/props and just let the standard patch process take over to handle that...

Edit: changed link to point to line of the additional code

Edit: added below

The only thing I can think that might be wrong is since I don't pre-populate attrs/props some browser might take issue with the patch trying to add attrs/props that are already present on a node? If not then it seems to work just fine and short circuits on all subsequent renders so shouldn't cause any performance issues.

jorgebucaran commented 7 years ago

Closing in favor of #257.

jorgebucaran commented 7 years ago

@benjaminj6 In your example you are passing the actions to the view yourself view(state, actions). This doesn't work because HyperApp needs to wrap those first into the actual functions that know how to update the state and render the application.

benjamminj commented 7 years ago

Ahhh that does make sense.