├── .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 Clear
13 | Reload
14 |
15 | Vue app below (2 second delay)
16 |
17 | Enter todo:
19 |
20 |
21 |
22 | X
23 |
24 |
25 |
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 |
13 |
14 | Enter todo:
16 |
17 |
18 | Add many todos
19 | Remove all todos
20 |
21 |
22 |
23 |
24 | X
25 |
26 |
27 |
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 Clear
55 | Reload
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 |
--------------------------------------------------------------------------------