├── .gitignore ├── .npmrc ├── .travis.yml ├── README.md ├── app.css ├── bare.html ├── deploy.json ├── favicon.ico ├── index.html ├── package.json └── src ├── app.js ├── hydration.js ├── load-app.js └── run-hydration-or-not.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .grunt/ 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=http://registry.npmjs.org/ 2 | save-exact=true 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | cache: 4 | directories: 5 | - node_modules 6 | notifications: 7 | email: false 8 | node_js: 9 | - '4' 10 | before_install: 11 | - npm i -g npm@^2.0.0 12 | before_script: 13 | - npm prune 14 | after_success: 15 | - npm run semantic-release 16 | branches: 17 | except: 18 | - "/^v\\d+\\.\\d+\\.\\d+$/" 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hydrate-vue-todo 2 | 3 | > Almost instant Vue.js app init from the HTML stored from previous run 4 | 5 | [![NPM][hydrate-vue-todo-icon] ][hydrate-vue-todo-url] 6 | 7 | [![Build status][hydrate-vue-todo-ci-image] ][hydrate-vue-todo-ci-url] 8 | [![semantic-release][semantic-image] ][semantic-url] 9 | 10 | Demo [glebbahmutov.com/hydrate-vue-todo/](https://glebbahmutov.com/hydrate-vue-todo/), 11 | [glebbahmutov.com/hydrate-vue-todo/bare.html](https://glebbahmutov.com/hydrate-vue-todo/bare.html) 12 | 13 | The demo shows an application loading, but while it is loading, a "fake" DOM snapshot 14 | is inserted. This fake snapshot is saved from the "app" element every time the Todo list 15 | has changes. If the 'vue.js' library is taking long time to load from a CDN, this 16 | "fake" DOM becomes much better than a blank page. Of course, the user cannot interact 17 | with the "fake" - thus I show a blue overlay to stop the user. 18 | 19 | ### Small print 20 | 21 | Author: Gleb Bahmutov © 2015 22 | 23 | * [@bahmutov](https://twitter.com/bahmutov) 24 | * [glebbahmutov.com](http://glebbahmutov.com) 25 | * [blog](http://glebbahmutov.com/blog/) 26 | 27 | License: MIT - do anything with the code, but don't blame me if it does not work. 28 | 29 | Spread the word: tweet, star on github, etc. 30 | 31 | Support: if you find any problems with this module, email / tweet / 32 | [open issue](https://github.com/bahmutov/hydrate-vue-todo/issues) on Github 33 | 34 | ## MIT License 35 | 36 | Copyright (c) 2015 Gleb Bahmutov 37 | 38 | Permission is hereby granted, free of charge, to any person 39 | obtaining a copy of this software and associated documentation 40 | files (the "Software"), to deal in the Software without 41 | restriction, including without limitation the rights to use, 42 | copy, modify, merge, publish, distribute, sublicense, and/or sell 43 | copies of the Software, and to permit persons to whom the 44 | Software is furnished to do so, subject to the following 45 | conditions: 46 | 47 | The above copyright notice and this permission notice shall be 48 | included in all copies or substantial portions of the Software. 49 | 50 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 51 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 52 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 53 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 54 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 55 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 56 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 57 | OTHER DEALINGS IN THE SOFTWARE. 58 | 59 | [hydrate-vue-todo-icon]: https://nodei.co/npm/hydrate-vue-todo.png?downloads=true 60 | [hydrate-vue-todo-url]: https://npmjs.org/package/hydrate-vue-todo 61 | [hydrate-vue-todo-ci-image]: https://travis-ci.org/bahmutov/hydrate-vue-todo.png?branch=master 62 | [hydrate-vue-todo-ci-url]: https://travis-ci.org/bahmutov/hydrate-vue-todo 63 | [semantic-image]: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg 64 | [semantic-url]: https://github.com/semantic-release/semantic-release 65 | -------------------------------------------------------------------------------- /app.css: -------------------------------------------------------------------------------- 1 | .hidden { 2 | visibility: hidden; 3 | display: none; 4 | } 5 | body { 6 | padding: 2em; 7 | font-family: Didot, 'Didot LT STD', 'Hoefler Text', 'Garamond', 'Times New Roman', serif; 8 | color: #333333; 9 | background-color: #fff; 10 | } 11 | a { 12 | text-decoration: none; 13 | font-family: monospace; 14 | font-size: large; 15 | } 16 | .controls { 17 | padding: 2px 10px; 18 | border: 1px solid #dddddd; 19 | border-radius: 5px; 20 | background-color: #fefefe; 21 | } 22 | -------------------------------------------------------------------------------- /bare.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hydrate Vue Todo 5 | 6 | 7 | 8 | 9 |

10 | Use hydration: 12 | Clear local storage 13 | 14 |

15 |

Vue app below (2 second delay)

16 | 26 | 27 | 32 | 33 |

The above Todo example is taken pretty much verbatim from 34 | vuejs.org/guide. 35 | See bahmutov/hydrate-vue-todo 36 | for more information. 37 |

38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /deploy.json: -------------------------------------------------------------------------------- 1 | { 2 | "gh-pages": { 3 | "options": { 4 | "base": "." 5 | }, 6 | "src": [ 7 | "index.html", 8 | "bare.html", 9 | "app.css", 10 | "favicon.ico", 11 | "src/*.js" 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bahmutov/hydrate-vue-todo/0a29c9a7e7d80e1c940cb39c0557f277ccf04628/favicon.ico -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hydrate Vue Todo 5 | 6 | 7 | 8 | 9 | 10 | 11 |

Vue app below

12 | 28 | 29 |

The above Todo example is taken pretty much verbatim from 30 | vuejs.org/guide. 31 |

32 | 33 |

What's the problem?

34 |

Modern Web application all suffer from a blank screen problem. 35 | The framework code needs to be loaded, then the application, and only then 36 | the DOM is updated with actual content.

37 |

To fully appreciate the experience, we simulate the network delays 38 | by delaying the vue.js and application loading by 2 seconds. 39 | Notice that during the loading delay the user sees a blank white page (bad). 40 |

41 | 42 |

What is hydration?

43 |

44 | Turn the "Hydration" by checking the box below and reload the page. 45 | You should see the items as you had them before almost immediately. 46 | This is "dry" HTML snapshot saved into localStorage and loaded by 47 | a tiny piece of code "hydration.js" as the page loads. 48 | The "dry" HTML will be shown while the Vue.js library and the app code is being loaded; 49 | the switch from "dry" to full app will happen automatically. 50 |

51 |

52 | Use hydration: 54 | Clear local storage 55 | 56 |

57 | 58 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hydrate-vue-todo", 3 | "description": "Almost instant Vue.js app init from the HTML stored from previous run", 4 | "main": "src/hydration.js", 5 | "version": "0.0.0-semantic-release", 6 | "scripts": { 7 | "test": "npm run lint", 8 | "lint": "standard --verbose --fix src/*.js", 9 | "commit": "commit-wizard", 10 | "size": "t=\"$(npm pack .)\"; wc -c \"${t}\"; tar tvf \"${t}\"; rm \"${t}\";", 11 | "semantic-release": "semantic-release pre && npm publish && semantic-release post", 12 | "deploy": "grunty grunt-gh-pages gh-pages deploy.json" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/bahmutov/hydrate-vue-todo.git" 17 | }, 18 | "keywords": [ 19 | "vue", 20 | "vue.js", 21 | "hydrate", 22 | "app", 23 | "application", 24 | "todo", 25 | "start", 26 | "performance", 27 | "demo" 28 | ], 29 | "author": "Gleb Bahmutov ", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/bahmutov/hydrate-vue-todo/issues" 33 | }, 34 | "files": [ 35 | "index.html", 36 | "favicon.ico", 37 | "src" 38 | ], 39 | "homepage": "https://github.com/bahmutov/hydrate-vue-todo#readme", 40 | "config": { 41 | "pre-git": { 42 | "commit-msg": [], 43 | "pre-commit": [ 44 | "npm run test" 45 | ], 46 | "pre-push": [ 47 | "npm run lint" 48 | ], 49 | "post-commit": [], 50 | "post-merge": [] 51 | } 52 | }, 53 | "devDependencies": { 54 | "grunt-gh-pages": "1.0.0", 55 | "grunty": "0.2.0", 56 | "pre-git": "3.1.1", 57 | "semantic-release": "^4.3.5", 58 | "standard": "8.4.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | /* global bottle, localStorage */ 2 | new Vue({ // eslint-disable-line 3 | el: '#app', 4 | data: { 5 | newTodo: '', 6 | todos: localStorage.getItem('todos') 7 | ? JSON.parse(localStorage.getItem('todos')) 8 | : [ 9 | { text: 'item 1' }, 10 | { text: 'item 2' }, 11 | { text: 'item 3' }, 12 | { text: 'item 4' }, 13 | { text: 'item 5' }, 14 | { text: 'item 6' }, 15 | { text: 'item 7' }, 16 | { text: 'item 8' }, 17 | { text: 'item 9' }, 18 | { text: 'item 10' } 19 | ] 20 | }, 21 | ready: function () { 22 | console.log('Todo app is ready') 23 | // replaces fake content with live 24 | bottle.drink() 25 | document.getElementById('app').classList.remove('hidden') 26 | 27 | // save the starting HTML 28 | bottle.refill() 29 | }, 30 | methods: { 31 | save: function save () { 32 | localStorage.setItem('todos', JSON.stringify(this.todos)) 33 | setTimeout(function () { 34 | // we should save the HTML after it has been rendered 35 | bottle.refill() 36 | }, 0) 37 | }, 38 | addTodo: function () { 39 | var text = this.newTodo.trim() 40 | if (text) { 41 | this.todos.unshift({ text: text }) 42 | this.newTodo = '' 43 | } 44 | this.save() 45 | }, 46 | removeTodo: function (index) { 47 | this.todos.splice(index, 1) 48 | this.save() 49 | }, 50 | addManyTodos: function () { 51 | var k 52 | for (k = 0; k < 100; k += 1) { 53 | this.todos.push({ text: 'do ' + k }) 54 | } 55 | this.save() 56 | }, 57 | removeTodos: function () { 58 | this.todos = [] 59 | this.save() 60 | } 61 | } 62 | }) 63 | -------------------------------------------------------------------------------- /src/hydration.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | function noop () {} 4 | 5 | function getBottle (selectId, verbose, verboseUi) { 6 | var log = verbose ? console.log.bind(console) : noop 7 | 8 | function formDrySelectorId (id) { 9 | return 'dry-' + id 10 | } 11 | 12 | var dryId = formDrySelectorId(selectId) 13 | 14 | // TODO factor out into separate module 15 | var display = (function initOverlay () { 16 | var overlay, message 17 | 18 | function createOverlay () { 19 | if (overlay) { 20 | return 21 | } 22 | 23 | overlay = document.createElement('div') 24 | var style = overlay.style 25 | style.width = '100%' 26 | style.height = '100%' 27 | style.opacity = 0.5 28 | style.position = 'fixed' 29 | style.left = 0 30 | style.top = 0 31 | style.backgroundColor = 'hsla(187, 100%, 42%, 0.12)' 32 | document.body.appendChild(overlay) 33 | } 34 | 35 | function createMessage (text) { 36 | var style 37 | if (!message) { 38 | message = document.createElement('h3') 39 | style = message.style 40 | style.color = '#333' 41 | style.position = 'fixed' 42 | style.bottom = '0em' 43 | style.right = '1em' 44 | style.backgroundColor = '#7FFFD4' 45 | style.borderRadius = '5px' 46 | style.borderWidth = '1px' 47 | style.borderColor = '#73E1BC' 48 | style.borderStyle = 'solid' 49 | style.padding = '1em 2em' 50 | document.body.appendChild(message) 51 | } 52 | message.textContent = text 53 | } 54 | 55 | function closeOverlay () { 56 | if (overlay) { 57 | document.body.removeChild(overlay) 58 | overlay = null 59 | } 60 | } 61 | 62 | function closeMessage () { 63 | if (message) { 64 | document.body.removeChild(message) 65 | message = null 66 | } 67 | } 68 | 69 | return { 70 | message: { 71 | show: function show (text) { 72 | createMessage(text) 73 | }, 74 | hide: function (timeoutMs) { 75 | if (timeoutMs) { 76 | setTimeout(closeMessage, timeoutMs) 77 | } else { 78 | closeMessage() 79 | } 80 | } 81 | }, 82 | overlay: { 83 | show: function show (text) { 84 | createOverlay() 85 | if (text) { 86 | createMessage(text) 87 | } 88 | }, 89 | hide: function hide (timeoutMs) { 90 | if (timeoutMs) { 91 | setTimeout(closeOverlay, timeoutMs) 92 | } else { 93 | closeOverlay() 94 | } 95 | } 96 | } 97 | } 98 | }()) 99 | 100 | /* global localStorage */ 101 | 102 | return { 103 | // clears any saved HTML 104 | recycle: function recycle () { 105 | localStorage.removeItem(selectId) 106 | log('removed HTML from localStorage') 107 | if (verboseUi) { 108 | display.message.show('Cleared storage') 109 | display.message.hide(1000) 110 | } 111 | }, 112 | // saves HTML snapshot for a given module 113 | refill: function refill () { 114 | var html = document.getElementById(selectId).outerHTML 115 | localStorage.setItem(selectId, html) 116 | log('poured', selectId, html) 117 | // if (verboseUi) { 118 | // display.message.show('Saved application UI') 119 | // display.message.hide(1000) 120 | // } 121 | }, 122 | // takes saved HTML snapshot and creates 123 | // a temporary static DOM, allowing real app 124 | // to load in hidden mode 125 | open: function open () { 126 | log('opening', selectId) 127 | if (verboseUi) { 128 | display.overlay.show('Web application is loading ...') 129 | } 130 | 131 | var html = localStorage.getItem(selectId) 132 | if (html) { 133 | html = html.replace('id="' + selectId + '"', 134 | 'id="' + dryId + '"') 135 | var el = document.getElementById(selectId) 136 | el.insertAdjacentHTML('beforebegin', html) 137 | el.style.visibility = 'hidden' 138 | el.style.display = 'none' 139 | } 140 | }, 141 | // when application is ready, replaces the static 142 | // DRY content with fully functioning application 143 | drink: function drink () { 144 | log('drinking', selectId) 145 | if (verboseUi) { 146 | display.message.show('Web application is running') 147 | display.overlay.hide() 148 | display.message.hide(1000) 149 | } 150 | 151 | var dryEl = document.getElementById(dryId) 152 | if (dryEl) { 153 | dryEl.parentNode.removeChild(dryEl) 154 | } 155 | var appEl = document.getElementById(selectId) 156 | appEl.style.visibility = '' 157 | appEl.style.display = 'initial' 158 | } 159 | } 160 | } 161 | 162 | !(function initBottle () { 163 | function findAttribute (attributes, name) { 164 | var found 165 | Array.prototype.some.call(attributes, function (attr) { 166 | if (attr.name === name) { 167 | found = attr 168 | return found 169 | } 170 | }) 171 | return found && found.value 172 | } 173 | 174 | var scripts = document.querySelectorAll('script') 175 | var lastScript = scripts[scripts.length - 1] 176 | 177 | var id = findAttribute(lastScript.attributes, 'id') || 'app' 178 | var verbose = findAttribute(lastScript.attributes, 'verbose') === 'true' 179 | var verboseUi = findAttribute(lastScript.attributes, 'verbose-ui') === 'true' 180 | var shouldHydrateName = findAttribute(lastScript.attributes, 'on') || 'hydrate' 181 | var shouldHydrate = window[shouldHydrateName] 182 | 183 | var bottle = getBottle(id, verbose, verboseUi) 184 | if (!shouldHydrate) { 185 | bottle.open = bottle.drink = noop 186 | } 187 | 188 | bottle.open() 189 | window.bottle = bottle 190 | }()) 191 | -------------------------------------------------------------------------------- /src/load-app.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // loads scripts in parallel but makes sure they 4 | // are executed in order 5 | // http://www.html5rocks.com/en/tutorials/speed/script-loading/ 6 | function loadScripts () { 7 | [ 8 | 'https://cdn.jsdelivr.net/vue/1.0.11/vue.js', 9 | 'src/app.js' 10 | ].forEach(function (src, index) { 11 | var script = document.createElement('script') 12 | script.src = src 13 | script.async = false 14 | document.head.appendChild(script) 15 | }) 16 | } 17 | 18 | setTimeout(loadScripts, 2000) 19 | -------------------------------------------------------------------------------- /src/run-hydration-or-not.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* global localStorage, bottle */ 4 | var savedValue = localStorage.getItem('hydrate') === 'true' 5 | var el = document.getElementById('hydrate') 6 | el.checked = savedValue 7 | window.hydrate = el.checked 8 | 9 | el.addEventListener('click', function () { 10 | console.log('should hydrate next time', el.checked) 11 | localStorage.setItem('hydrate', el.checked) 12 | if (el.checked) { 13 | bottle.refill() 14 | } else { 15 | bottle.recycle() 16 | } 17 | }) 18 | 19 | document.getElementById('clear').addEventListener('click', function () { 20 | console.log('clearing any HTML saved in the local storage') 21 | bottle.recycle() 22 | }) 23 | 24 | document.getElementById('reload').addEventListener('click', function () { 25 | window.location.reload() 26 | }) 27 | --------------------------------------------------------------------------------