├── .babelrc
├── .gitignore
├── README.md
├── lib
├── createRouteMatcher.js
├── index.js
├── ui.js
└── worker.js
├── package.json
└── src
├── createRouteMatcher.js
├── index.js
├── ui.js
└── worker.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015"]
3 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | npm-debug.log
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # featherweight
2 |
3 | **PLEASE NOTE:** This is all highly experimental, I've not built anything major with this and there are holes in functionality and unsolved problems related to events. Also, just because I'm open sourcing doesn't mean I'll maintain it for you or do anything else with it, ever. This is a pure experiment at this point. I'm simply sharing it in the spirit of open source in case anyone finds it interesting.
4 |
5 | [](http://unmaintained.tech/)
6 |
7 | **The basic idea of featherweight is that you as a developer only do this:**
8 |
9 | 1. Configure [redux](https://github.com/rackt/redux) however you want it (all your application state will live in Redux)
10 | 2. Write a single, main, pure, synchronous view function that takes the state from redux (including the current URL of your app) and returns a virtual dom to represent what it should look like at that exact moment.
11 |
12 | *That's it!*
13 |
14 | **Featherweight then manages everything else**. It's a simple abstraction to manage communication between the worker thread (where Redux and your reducers will run) and the main thread:**
15 |
16 | 1. 99% of your app code runs in a web worker
17 | 2. No fancy router, the url is just another piece of state
18 | 3. You can super easily pre-render as much HTML as possible before sending to the client either on the server or to static HTML files at build time.
19 | 4. JS "takes over" on the clientside as soon as it's loaded.
20 | 5. All code necessary to do the above should be < 10kb min + gzipped
21 |
22 |
23 | ## understanding the application pattern
24 |
25 | Featherweight lets you do all the heavy application work in a web worker. This means rendering new UI, virtual dom diffing, data fetching, etc. all happens in a Web Worker.
26 |
27 | **This leaves the main thread free to focus entirely on efficient DOM updates and listening for user interractions.**
28 |
29 | It consists of two primary components:
30 |
31 | 1. The worker code: `import { worker } from 'featherweight'`
32 | 2. The main thread (ui thread) code: `import { ui } from 'featherweight'`
33 |
34 |
35 | ### Setting up the main thread
36 |
37 | Featherweight's `ui` handles most of the main thread for you. The only custom code you need here, may be to import whatever styles or 3rd party scripts.
38 |
39 | Typically, it would just look like this:
40 |
41 | ```js
42 | // import the ui module
43 | import { ui } from 'featherweight'
44 |
45 | // import your worker code (with webpack-worker-loader this Just Works™)
46 | import WorkerThread from './worker.thread'
47 |
48 | // possibly import styles if you're using some sort of css/style loader with webpack
49 | import './styles/main.styl'
50 |
51 | ui({
52 | // pass it the worker thread
53 | worker: new WorkerThread(),
54 |
55 | // Pass in the root element where you
56 | // want the app to live. It's recommended
57 | // that this be something other than the
58 | //
in case other libraries or
59 | // browser plugins need to insert elements, etc,
60 | rootElement: document.body.firstChild
61 | })
62 | ```
63 |
64 | ### setting up the worker thread
65 |
66 | Here's where all the work happens, but again, most of your efforts will go into writing the Redux reducers and UI code.
67 |
68 | The boilerplate inside the worker is pretty straight forward:
69 |
70 | ```js
71 | // worker.thread.js
72 | import { worker } from 'featherweight'
73 | import appView from './views/app'
74 | import configureRedux from './configureRedux'
75 |
76 | worker({
77 | // pass it your redux main store
78 | // here we assume we're setting up
79 | // redux and it's reducers in another module
80 | // that exports a function we can use to
81 | // get the configured redux instance
82 | redux: configureRedux(),
83 |
84 | // This is the main application UI component that
85 | // you'll write. It should be a pure function returning
86 | // a new virtual DOM when passed the main application state
87 | // object we get from Redux.
88 | view: appView,
89 |
90 | // To keep things easy to trace we also want to
91 | // explicitily pass in the reference to the worker
92 | // context. This will always just be `self`.
93 | //
94 | // If you're unfamiliar with this concept, it's just
95 | // something that exists within all Web Workers.
96 | //
97 | // featherweight needs this in order to be able to listen for
98 | // and send DOM updates back to the ui thread.
99 | workerContext: self
100 | })
101 | ```
102 |
103 | ### Writing the UI components
104 |
105 | The assumption is that components don't have state. **All state lives in Redux**, including the current URL of the app.
106 |
107 | This means the entire UI needs to be a single pure function that takes the application state object and return a new virtual dom.
108 |
109 | It's signature is simple:
110 |
111 | ```js
112 | virtualDom = ui(state)
113 | ```
114 |
115 | Of course, you still have to be able to break your application into small, modular components and show different things based on different urls.
116 |
117 | To address this, here's a super simple example:
118 |
119 | ```
120 | import home from './home'
121 | import about from './about'
122 | import pageNotFound from './pageNotFound'
123 |
124 | export default (state) => {
125 | // we just grab the `.url` property of state
126 | const { url } = state
127 | let page
128 |
129 | // We then grab a `page` component
130 | // conditionally based on that url
131 | if (url === '/') {
132 | // here we can pass the state through
133 | // if we'd like
134 | page = home(state)
135 | } else if (url === '/about') {
136 | // this page is just text content
137 | // so passing in state isn't necessary
138 | page = about()
139 | }
140 |
141 | // we could also handle URLs our app isn't
142 | // aware of with a fallback page
143 | if (!page) {
144 | page = pageNotFound()
145 | }
146 |
147 | // here we simply return the JSX and include
148 | // the `{page}` content as part of our layout
149 | return (
150 | // note that `main` here is just an HTML5 element
151 | // nothing special
152 |
153 | Feather POC App
154 |
155 | home | about
156 |
157 | {page}
158 |
159 | )
160 | }
161 | ```
162 |
163 |
164 | ### handling state
165 |
166 | For cleanliness, I suggest setting up your Redux in a separate file.
167 |
168 | That file may look something like this:
169 |
170 | ```js
171 | import { createStore } from 'redux'
172 | import * as reducers from './reducers/index'
173 |
174 | export default () => {
175 | return createStore(reducers)
176 | }
177 |
178 | ```
179 |
180 | ### pre-rendering at build time or as part of a server response
181 |
182 | Because your app UI is a pure function, turning your view into HTML is quite simple.
183 |
184 | Simply call your apps main UI function passing in whatever state you'd like to render to generate virtual DOM.
185 |
186 | Then you can use the `vdom-to-html` module from npm to generate an HTML string.
187 |
188 |
189 | ```js
190 | import toHtml from 'vdom-to-html'
191 | import app from './views/app'
192 |
193 | // wherever you want to create the HTML string
194 | const renderedHtml = toHtml(app({url: '/about'}))
195 |
196 | ```
197 |
198 |
199 | ### featherweight patterns
200 |
201 | 1. Under no circumstances should your complete app weigh more than 60 kb min+gzip JS. Ideally, much less.
202 | 1. **every. single. piece. of. state. lives. in. redux**
203 | 2. Leave the main thread alone
204 | 3. Never touch the DOM directly
205 | 4. The UI thread does nothing other than apply DOM updates and post events back to the worker thread
206 | 5. Never use `this`
207 | 6. Never use `function` use `() => {}` for everything
208 | 7. The UI is a pure function
209 | 8. There are no stateful components
210 | 9. Pretty much everything is a pure function
211 | 10. Name all your modules with camelCase file names
212 | 11. Use [standard](https://github.com/feross/standard) for code style
213 | 12. The build step should turn your app into a set of static HTML, CSS, and JS files.
214 |
215 | ## install
216 |
217 | ```
218 | npm install featherweight
219 | ```
220 |
221 | ## credits
222 |
223 | If you like this follow [@HenrikJoreteg](http://twitter.com/henrikjoreteg) on twitter.
224 |
225 | ## license
226 |
227 | [MIT](http://mit.joreteg.com/)
228 |
229 |
--------------------------------------------------------------------------------
/lib/createRouteMatcher.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | Object.defineProperty(exports, "__esModule", {
4 | value: true
5 | });
6 | // regexes borrowed from backbone
7 | var optionalParam = /\((.*?)\)/g;
8 | var namedParam = /(\(\?)?:\w+/g;
9 | var escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g;
10 |
11 | // Parses a URL pattern such as `/users/:id`
12 | // and builds and returns a regex that can be used to
13 | // match said pattern. Credit for these
14 | // regexes belongs to Jeremy Ashkenas and the
15 | // other maintainers of Backbone.js
16 | //
17 | // It has been modified for extraction of
18 | // named paramaters from the URL
19 | var parsePattern = exports.parsePattern = function parsePattern(pattern) {
20 | var names = [];
21 | pattern = pattern.replace(escapeRegExp, '\\$&').replace(optionalParam, '(?:$1)?').replace(namedParam, function (match, optional) {
22 | names.push(match.slice(1));
23 | return optional ? match : '([^/?]+)';
24 | });
25 |
26 | return {
27 | regExp: new RegExp('^' + pattern + '(?:\\?([\\s\\S]*))?$'),
28 | namedParams: names
29 | };
30 | };
31 |
32 | // our main export, pure functions, FTW!
33 |
34 | exports.default = function (routes) {
35 | var keys = Object.keys(routes);
36 |
37 | // loop through each route we're
38 | // and build the shell of our
39 | // route cache.
40 | for (var item in routes) {
41 | routes[item] = {
42 | fn: routes[item]
43 | };
44 | }
45 |
46 | // main result is a function that can be called
47 | // with URL and current state
48 | return function (url, state) {
49 | var params = undefined;
50 | var route = undefined;
51 |
52 | // start looking for matches
53 | var matchFound = keys.some(function (key) {
54 | var parsed = undefined;
55 |
56 | // fetch the route pattern from the cache
57 | // there will always be one
58 | route = routes[key];
59 |
60 | // if the route doesn't already have
61 | // a regex we never generated one
62 | // so we do that here lazily.
63 | // Parse the pattern to generate the
64 | // regex once, and store the result
65 | // for next time.
66 | if (!route.regExp) {
67 | parsed = parsePattern(key);
68 | route.regExp = parsed.regExp;
69 | route.namedParams = parsed.namedParams;
70 | }
71 |
72 | // run our cached regex
73 | var result = route.regExp.exec(url);
74 |
75 | // if null there was no match
76 | // returning falsy here continues
77 | // the `Array.prototype.some` loop
78 | if (!result) {
79 | return;
80 | }
81 |
82 | // remove other cruft from result
83 | result = result.slice(1, -1);
84 |
85 | // reduce our match to an object of named paramaters
86 | // we've extracted from the url
87 | params = result.reduce(function (obj, val, index) {
88 | if (val) {
89 | obj[route.namedParams[index]] = val;
90 | }
91 | return obj;
92 | }, {});
93 |
94 | // stops the loop
95 | return true;
96 | });
97 |
98 | // no routes matched
99 | if (!matchFound) {
100 | return null;
101 | }
102 |
103 | // return the result of user's function
104 | // passing in state and parsed params from
105 | // url.
106 | return route.fn({ state: state, params: params });
107 | };
108 | };
--------------------------------------------------------------------------------
/lib/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | Object.defineProperty(exports, "__esModule", {
4 | value: true
5 | });
6 | exports.FEATHER_INIT = exports.worker = exports.ui = exports.createRouteMatcher = undefined;
7 |
8 | var _createRouteMatcher = require('./createRouteMatcher');
9 |
10 | var _createRouteMatcher2 = _interopRequireDefault(_createRouteMatcher);
11 |
12 | var _ui = require('./ui');
13 |
14 | var _ui2 = _interopRequireDefault(_ui);
15 |
16 | var _worker = require('./worker');
17 |
18 | var _worker2 = _interopRequireDefault(_worker);
19 |
20 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
21 |
22 | var FEATHER_INIT = '@@feather/INIT';
23 |
24 | exports.createRouteMatcher = _createRouteMatcher2.default;
25 | exports.ui = _ui2.default;
26 | exports.worker = _worker2.default;
27 | exports.FEATHER_INIT = FEATHER_INIT;
--------------------------------------------------------------------------------
/lib/ui.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | Object.defineProperty(exports, "__esModule", {
4 | value: true
5 | });
6 |
7 | var _vdomVirtualize = require('vdom-virtualize');
8 |
9 | var _vdomVirtualize2 = _interopRequireDefault(_vdomVirtualize);
10 |
11 | var _toJson = require('vdom-as-json/toJson');
12 |
13 | var _toJson2 = _interopRequireDefault(_toJson);
14 |
15 | var _patch = require('vdom-serialized-patch/patch');
16 |
17 | var _patch2 = _interopRequireDefault(_patch);
18 |
19 | var _localLinks = require('local-links');
20 |
21 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
22 |
23 | exports.default = function (_ref) {
24 | var worker = _ref.worker;
25 | var rootElement = _ref.rootElement;
26 |
27 | var firstChild = rootElement.firstChild;
28 |
29 | if (!firstChild) {
30 | firstChild = document.createElement('div');
31 | rootElement.appendChild(firstChild);
32 | }
33 | // any time we get a message from the worker
34 | // it will be a set of "patches" to apply to
35 | // the real DOM. We do this on a requestAnimationFrame
36 | // for minimal impact
37 | worker.onmessage = function (_ref2) {
38 | var data = _ref2.data;
39 | var url = data.url;
40 | var title = data.title;
41 | var patches = data.patches;
42 |
43 | window.requestAnimationFrame(function () {
44 | (0, _patch2.default)(rootElement.firstChild, patches);
45 | });
46 | // we only want to update the URL
47 | // if it's different than the current
48 | // URL. Otherwise we'd keep pushing
49 | // the same url to the history with
50 | // each render
51 | if (window.location.pathname !== url) {
52 | window.history.pushState(null, null, url);
53 | }
54 |
55 | // if page title
56 | if (document.title !== title) {
57 | document.title = title;
58 | }
59 | };
60 |
61 | // we start things off by sending a virtual DOM
62 | // representation of the *real* DOM along with
63 | // the current URL to our worker
64 | worker.postMessage({
65 | type: '@@feather/INIT',
66 | vdom: (0, _toJson2.default)((0, _vdomVirtualize2.default)(firstChild)),
67 | url: window.location.pathname
68 | });
69 |
70 | // if the user hits the back/forward buttons
71 | // pass the new url to the worker
72 | window.addEventListener('popstate', function () {
73 | worker.postMessage({
74 | type: 'SET_URL',
75 | url: window.location.pathname
76 | });
77 | });
78 |
79 | // listen for all clicks globally
80 | rootElement.addEventListener('click', function (event) {
81 | // handles internal navigation defined as
82 | // clicks on tags that have `href` that is
83 | // on the same origin.
84 | // https://www.npmjs.com/package/local-links
85 | var pathname = (0, _localLinks.getLocalPathname)(event);
86 | if (pathname) {
87 | // stop browser from following the link
88 | event.preventDefault();
89 | // instead, post the new URL to our worker
90 | // which will trigger compute a new vDom
91 | // based on that new URL state
92 | worker.postMessage({ type: 'SET_URL', url: pathname });
93 | return;
94 | }
95 |
96 | // this is for other "onClick" type events we want to
97 | // respond to. We check existance of an `data-click`
98 | // attribute and if it exists, post that back.
99 | var click = event.target['data-click'];
100 | if (click) {
101 | event.preventDefault();
102 | worker.postMessage(click);
103 | }
104 | });
105 |
106 | rootElement.addEventListener('submit', function (event) {
107 | var target = event.target;
108 |
109 | var formAction = target['data-action'];
110 | if (!formAction) {
111 | return;
112 | }
113 |
114 | event.preventDefault();
115 | var data = {};
116 | var l = target.length;
117 | for (var i = 0; i < l; i++) {
118 | var input = target[i];
119 | if (input.name) {
120 | data[input.name] = input.value;
121 | }
122 | }
123 | worker.postMessage({ type: formAction, data: data });
124 | });
125 | };
--------------------------------------------------------------------------------
/lib/worker.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | Object.defineProperty(exports, "__esModule", {
4 | value: true
5 | });
6 |
7 | var _diff = require('virtual-dom/diff');
8 |
9 | var _diff2 = _interopRequireDefault(_diff);
10 |
11 | var _serialize = require('vdom-serialized-patch/serialize');
12 |
13 | var _serialize2 = _interopRequireDefault(_serialize);
14 |
15 | var _fromJson = require('vdom-as-json/fromJson');
16 |
17 | var _fromJson2 = _interopRequireDefault(_fromJson);
18 |
19 | var _index = require('./index');
20 |
21 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
22 |
23 | exports.default = function (_ref) {
24 | var redux = _ref.redux;
25 | var view = _ref.view;
26 | var workerContext = _ref.workerContext;
27 | var _ref$debug = _ref.debug;
28 | var debug = _ref$debug === undefined ? false : _ref$debug;
29 |
30 | var currentVDom = undefined;
31 |
32 | var render = function render() {
33 | var state = redux.getState();
34 | var title = undefined;
35 | var newVDom = undefined;
36 |
37 | // our entire app in one line:
38 | var result = view(state);
39 |
40 | // allow main app to optionally return
41 | // {
42 | // vdom: {{ VIRTUAL DOM }},
43 | // title: {{ PAGE TITLE STRING }}
44 | // }
45 | if (result.hasOwnProperty('vdom')) {
46 | title = result.title;
47 | newVDom = result.vdom;
48 | } else {
49 | newVDom = result;
50 | }
51 |
52 | // do the diff
53 | var patches = (0, _diff2.default)(currentVDom, newVDom);
54 |
55 | // cache last vdom so we diff against
56 | // the new one the next time through
57 | currentVDom = newVDom;
58 |
59 | var message = {
60 | url: state.url,
61 | patches: (0, _serialize2.default)(patches)
62 | };
63 |
64 | if (title !== undefined) {
65 | message.title = title;
66 | }
67 |
68 | // send patches and current url back to the main thread
69 | workerContext.postMessage(message);
70 | };
71 |
72 | workerContext.onmessage = function (_ref2) {
73 | var data = _ref2.data;
74 |
75 | if (debug) console.log('action:', data);
76 | if (data.type === _index.FEATHER_INIT) {
77 | currentVDom = (0, _fromJson2.default)(data.vdom);
78 | }
79 |
80 | // let redux do its thing
81 | redux.dispatch(data);
82 | };
83 |
84 | redux.subscribe(render);
85 | };
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "featherweight",
3 | "description": "featherweight application pattern",
4 | "version": "3.0.0",
5 | "author": "Henrik Joreteg (https://joreteg.com)",
6 | "bugs": {
7 | "url": "https://github.com/HenrikJoreteg/featherweight/issues"
8 | },
9 | "devDependencies": {
10 | "babel": "^6.3.26",
11 | "babel-cli": "^6.3.17",
12 | "babel-core": "^6.3.26",
13 | "babel-preset-es2015": "^6.3.13",
14 | "node-babel": "^0.1.2",
15 | "nodemon": "^1.8.1",
16 | "npm-watch": "^0.1.1",
17 | "standard": "^5.4.1",
18 | "tape": "^4.3.0"
19 | },
20 | "files": [
21 | "src",
22 | "lib"
23 | ],
24 | "homepage": "https://github.com/HenrikJoreteg/featherweight#readme",
25 | "jsnext:main": "src/index.js",
26 | "keywords": [
27 | "application",
28 | "lightweight"
29 | ],
30 | "license": "MIT",
31 | "main": "lib/index.js",
32 | "repository": {
33 | "type": "git",
34 | "url": "git+https://github.com/HenrikJoreteg/featherweight.git"
35 | },
36 | "scripts": {
37 | "build": "babel src --out-dir lib",
38 | "preversion": "standard && npm run build",
39 | "lint": "standard",
40 | "watch": "npm-watch"
41 | },
42 | "watch": {
43 | "build": {
44 | "patterns": [
45 | "src"
46 | ]
47 | }
48 | },
49 | "dependencies": {
50 | "local-links": "^1.4.0",
51 | "vdom-serialized-patch": "^1.0.2",
52 | "vdom-virtualize": "^1.0.1",
53 | "virtual-dom": "^2.1.1"
54 | },
55 | "standard": {
56 | "ignore": "lib/*"
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/createRouteMatcher.js:
--------------------------------------------------------------------------------
1 | // regexes borrowed from backbone
2 | const optionalParam = /\((.*?)\)/g
3 | const namedParam = /(\(\?)?:\w+/g
4 | const escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g
5 |
6 | // Parses a URL pattern such as `/users/:id`
7 | // and builds and returns a regex that can be used to
8 | // match said pattern. Credit for these
9 | // regexes belongs to Jeremy Ashkenas and the
10 | // other maintainers of Backbone.js
11 | //
12 | // It has been modified for extraction of
13 | // named paramaters from the URL
14 | export const parsePattern = (pattern) => {
15 | const names = []
16 | pattern = pattern
17 | .replace(escapeRegExp, '\\$&')
18 | .replace(optionalParam, '(?:$1)?')
19 | .replace(namedParam, (match, optional) => {
20 | names.push(match.slice(1))
21 | return optional ? match : '([^/?]+)'
22 | })
23 |
24 | return {
25 | regExp: new RegExp('^' + pattern + '(?:\\?([\\s\\S]*))?$'),
26 | namedParams: names
27 | }
28 | }
29 |
30 | // our main export, pure functions, FTW!
31 | export default (routes) => {
32 | const keys = Object.keys(routes)
33 |
34 | // loop through each route we're
35 | // and build the shell of our
36 | // route cache.
37 | for (const item in routes) {
38 | routes[item] = {
39 | fn: routes[item]
40 | }
41 | }
42 |
43 | // main result is a function that can be called
44 | // with URL and current state
45 | return (url, state) => {
46 | let params
47 | let route
48 |
49 | // start looking for matches
50 | const matchFound = keys.some((key) => {
51 | let parsed
52 |
53 | // fetch the route pattern from the cache
54 | // there will always be one
55 | route = routes[key]
56 |
57 | // if the route doesn't already have
58 | // a regex we never generated one
59 | // so we do that here lazily.
60 | // Parse the pattern to generate the
61 | // regex once, and store the result
62 | // for next time.
63 | if (!route.regExp) {
64 | parsed = parsePattern(key)
65 | route.regExp = parsed.regExp
66 | route.namedParams = parsed.namedParams
67 | }
68 |
69 | // run our cached regex
70 | let result = route.regExp.exec(url)
71 |
72 | // if null there was no match
73 | // returning falsy here continues
74 | // the `Array.prototype.some` loop
75 | if (!result) {
76 | return
77 | }
78 |
79 | // remove other cruft from result
80 | result = result.slice(1, -1)
81 |
82 | // reduce our match to an object of named paramaters
83 | // we've extracted from the url
84 | params = result.reduce((obj, val, index) => {
85 | if (val) {
86 | obj[route.namedParams[index]] = val
87 | }
88 | return obj
89 | }, {})
90 |
91 | // stops the loop
92 | return true
93 | })
94 |
95 | // no routes matched
96 | if (!matchFound) {
97 | return null
98 | }
99 |
100 | // return the result of user's function
101 | // passing in state and parsed params from
102 | // url.
103 | return route.fn({state: state, params: params})
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import createRouteMatcher from './createRouteMatcher'
2 | import ui from './ui'
3 | import worker from './worker'
4 |
5 | const FEATHER_INIT = '@@feather/INIT'
6 |
7 | export {
8 | createRouteMatcher,
9 | ui,
10 | worker,
11 | FEATHER_INIT
12 | }
13 |
--------------------------------------------------------------------------------
/src/ui.js:
--------------------------------------------------------------------------------
1 | import virtualize from 'vdom-virtualize'
2 | import toJson from 'vdom-as-json/toJson'
3 | import applyPatch from 'vdom-serialized-patch/patch'
4 | import { getLocalPathname } from 'local-links'
5 |
6 | export default ({worker, rootElement}) => {
7 | let firstChild = rootElement.firstChild
8 |
9 | if (!firstChild) {
10 | firstChild = document.createElement('div')
11 | rootElement.appendChild(firstChild)
12 | }
13 | // any time we get a message from the worker
14 | // it will be a set of "patches" to apply to
15 | // the real DOM. We do this on a requestAnimationFrame
16 | // for minimal impact
17 | worker.onmessage = ({data}) => {
18 | const { url, title, patches } = data
19 | window.requestAnimationFrame(() => {
20 | applyPatch(rootElement.firstChild, patches)
21 | })
22 | // we only want to update the URL
23 | // if it's different than the current
24 | // URL. Otherwise we'd keep pushing
25 | // the same url to the history with
26 | // each render
27 | if (window.location.pathname !== url) {
28 | window.history.pushState(null, null, url)
29 | }
30 |
31 | // if page title
32 | if (document.title !== title) {
33 | document.title = title
34 | }
35 | }
36 |
37 | // we start things off by sending a virtual DOM
38 | // representation of the *real* DOM along with
39 | // the current URL to our worker
40 | worker.postMessage({
41 | type: '@@feather/INIT',
42 | vdom: toJson(virtualize(firstChild)),
43 | url: window.location.pathname
44 | })
45 |
46 | // if the user hits the back/forward buttons
47 | // pass the new url to the worker
48 | window.addEventListener('popstate', () => {
49 | worker.postMessage({
50 | type: 'SET_URL',
51 | url: window.location.pathname
52 | })
53 | })
54 |
55 | // listen for all clicks globally
56 | rootElement.addEventListener('click', (event) => {
57 | // handles internal navigation defined as
58 | // clicks on tags that have `href` that is
59 | // on the same origin.
60 | // https://www.npmjs.com/package/local-links
61 | const pathname = getLocalPathname(event)
62 | if (pathname) {
63 | // stop browser from following the link
64 | event.preventDefault()
65 | // instead, post the new URL to our worker
66 | // which will trigger compute a new vDom
67 | // based on that new URL state
68 | worker.postMessage({type: 'SET_URL', url: pathname})
69 | return
70 | }
71 |
72 | // this is for other "onClick" type events we want to
73 | // respond to. We check existance of an `data-click`
74 | // attribute and if it exists, post that back.
75 | const click = event.target['data-click']
76 | if (click) {
77 | event.preventDefault()
78 | worker.postMessage(click)
79 | }
80 | })
81 |
82 | rootElement.addEventListener('submit', (event) => {
83 | const { target } = event
84 | const formAction = target['data-action']
85 | if (!formAction) {
86 | return
87 | }
88 |
89 | event.preventDefault()
90 | const data = {}
91 | const l = target.length
92 | for (let i = 0; i < l; i++) {
93 | const input = target[i]
94 | if (input.name) {
95 | data[input.name] = input.value
96 | }
97 | }
98 | worker.postMessage({type: formAction, data})
99 | })
100 | }
101 |
--------------------------------------------------------------------------------
/src/worker.js:
--------------------------------------------------------------------------------
1 | import diff from 'virtual-dom/diff'
2 | import serializePatch from 'vdom-serialized-patch/serialize'
3 | import fromJson from 'vdom-as-json/fromJson'
4 | import { FEATHER_INIT } from './index'
5 |
6 | export default ({redux, view, workerContext, debug = false}) => {
7 | let currentVDom
8 |
9 | const render = () => {
10 | const state = redux.getState()
11 | let title
12 | let newVDom
13 |
14 | // our entire app in one line:
15 | const result = view(state)
16 |
17 | // allow main app to optionally return
18 | // {
19 | // vdom: {{ VIRTUAL DOM }},
20 | // title: {{ PAGE TITLE STRING }}
21 | // }
22 | if (result.hasOwnProperty('vdom')) {
23 | title = result.title
24 | newVDom = result.vdom
25 | } else {
26 | newVDom = result
27 | }
28 |
29 | // do the diff
30 | const patches = diff(currentVDom, newVDom)
31 |
32 | // cache last vdom so we diff against
33 | // the new one the next time through
34 | currentVDom = newVDom
35 |
36 | const message = {
37 | url: state.url,
38 | patches: serializePatch(patches)
39 | }
40 |
41 | if (title !== undefined) {
42 | message.title = title
43 | }
44 |
45 | // send patches and current url back to the main thread
46 | workerContext.postMessage(message)
47 | }
48 |
49 | workerContext.onmessage = ({data}) => {
50 | if (debug) console.log('action:', data)
51 | if (data.type === FEATHER_INIT) {
52 | currentVDom = fromJson(data.vdom)
53 | }
54 |
55 | // let redux do its thing
56 | redux.dispatch(data)
57 | }
58 |
59 | redux.subscribe(render)
60 | }
61 |
--------------------------------------------------------------------------------