Skip to content

Instantly share code, notes, and snippets.

@federicofazzeri
Last active October 6, 2017 16:25
Show Gist options
  • Save federicofazzeri/7fcd071ef8817b179e54b27a64b6aa6a to your computer and use it in GitHub Desktop.
Save federicofazzeri/7fcd071ef8817b179e54b27a64b6aa6a to your computer and use it in GitHub Desktop.
Component based SPA with dependency injection, redux state management, code splitting and tree shaking WITHOUT using a framework

Redux - component based - SPA with dependency injection WITHOUT using frameworks.

The app is built entirely with browsers native Web Components (Custom Elements, Shadow DOM, HTML Imports and HTML Template)

The app works in older browsers thank to the web component polyfill loader that dynamically loads the necessary polyfills using features detection.

webpack.config.js builds the app using a custom template (app.template.html) where the root component is set, the components templates are imported (components-templates.html) and the Polyfills detector is loaded.

index.js is the app entry script, it waits until the necessary polyfills are loaded then instantiates the dependencies and bootstrap the app.

style encapsulation, component tree creation and modern frameworks-like component code organization is made possible by the awesomeness of the native Web Components elements, that are already available in your browser!!

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Component based App without frameworks</title>
<script type="text/javascript" src="webcomponents-polyfills/webcomponents-loader.js"></script>
<link rel="import" href="components-templates.html">
<style>
body, html {
width: 100%;
}
body {
font-family: arial;
padding: 0;
margin: 0;
display: flex;
justify-content: center;
background: ghostwhite;
padding-top: 20px;
}
</style>
</head>
<body>
<my-todo-component></my-todo-component>
</body>
</html>
export default template =>
class extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open'});
this.shadowRoot.appendChild(document.importNode(template.content, true))
}
}
export default (dependencies) => {
return (...components) => {
components.forEach( componentInit => {
let component = componentInit(dependencies)
customElements.define(
component.customElement,
component.class
);
})
}
}
import {Observable} from "rxjs/Observable";
import "rxjs/add/observable/fromEvent";
import customElementMixin from './component-helpers';
export const myForm = ({getTemplate, store}) =>({
customElement: 'my-form-component',
class: class extends customElementMixin(getTemplate('my-form-template')) {
constructor() {
super();
Observable.fromEvent(this.shadowRoot.querySelector('button'), "click")
.subscribe( ev => store.dispatch({
type: 'ADDTODOITEM',
payload: this.shadowRoot.querySelector('input').value
})
)
}
}
})
export const myItems = ({getTemplate, store}) => ({
customElement: 'my-items-component',
class: class extends customElementMixin(getTemplate('my-items-template')) {
createListElement (content, item) {
let liFragment = document.importNode(content, true)
liFragment.querySelector('span').innerText = item.text;
if(item.done) {
liFragment.querySelector('span').classList.add('done')
}
Observable.fromEvent(liFragment.querySelector('button'), "click")
.subscribe( () => store.dispatch({
type: 'DELETETODOITEM',
payload: item.text
})
)
Observable.fromEvent(liFragment.querySelector('span'), "click")
.subscribe( () => store.dispatch({
type: 'TODOITEMDONE',
payload: item.text
})
)
return liFragment;
}
constructor() {
super();
const itemTemplate = getTemplate('my-item-template');
const ul = this.shadowRoot.querySelector('ul');
store.subscribe( () => {
ul.innerHTML = '';
let listFragment = document.createDocumentFragment();
store.getState()
.forEach( item => {
let liFragment = this.createListElement(itemTemplate.content, item)
listFragment.appendChild(liFragment)
} )
ul.appendChild(listFragment)
})
}
}
})
export const myTodo = ({getTemplate}) => ({
customElement: 'my-todo-component',
class: customElementMixin(getTemplate('my-todo-template'))
})
<template id="my-todo-template">
<style>
:host {
background: red;
display: block;
background: white;
padding: 30px;
}
</style>
<p>Todo app</p>
<my-form-component></my-form-component>
<my-items-component></my-items-component>
</template>
<template id="my-form-template">
<style>
input {
padding: 5px 3px;
}
button {
padding: 7px;
}
</style>
<input type="text" placeholder="Add a todo item" />
<button type="submit">add</button>
</template>
<template id="my-items-template">
<style>
ul {
border-top: 1px solid #CCC;
padding: 10px 0 0 0;
}
</style>
<ul></ul>
</template>
<template id="my-item-template">
<style>
li {
font-size: 0.8rem;
color: lightslategrey;
list-style: none;
padding: 10px 0;
display: flex;
justify-content: space-between;
align-items: center;
}
button {
height: 23px;
width: 23px;
border-radius: 50%;
border: none;
margin: 0;
padding: 0;
background: lightcoral;
color: white;
}
.done {
text-decoration: line-through;
}
</style>
<li>
<span></span>
<button>x</button>
</li>
</template>
import { myForm, myTodo, myItems } from './components-mixins'
import initComponentsDependencies from './components-init'
import storeInit from './store-init'
import initGetTemplate from './template-helpers'
window.addEventListener('WebComponentsReady', () => {
const dependencies = {
getTemplate: initGetTemplate(),
store: storeInit()
};
const defineComponents = initComponentsDependencies(dependencies);
defineComponents( myTodo, myForm, myItems )
})
{
"name": "NoFrameworksSPAApp",
"version": "1.0.0",
"scripts": {
"start": "webpack --watch"
},
"main": "index.js",
"license": "MIT",
"dependencies": {
"@webcomponents": "https://github.com/webcomponents/webcomponentsjs.git",
"browser-sync": "^2.18.13",
"browser-sync-webpack-plugin": "^1.2.0",
"clean-webpack-plugin": "^0.1.17",
"copy-webpack-plugin": "^4.1.0",
"express": "^4.16.1",
"html-webpack-plugin": "^2.30.1",
"redux": "^3.7.2",
"rxjs": "^5.4.3",
"webpack": "^3.6.0",
"webpack-dev-middleware": "^1.12.0",
"webpack-dev-server": "^2.9.1"
}
}
function todoListReducer(state = [], action) {
switch (action.type) {
case 'ADDTODOITEM':
return [...state, { text: action.payload, done: false }]
case 'DELETETODOITEM':
return state.filter(item => item.text !== action.payload)
case 'TODOITEMDONE':
return state
.map(item => {
if(item.text == action.payload) {
return Object.assign(item, { done: true });
}
return item;
})
default:
return state
}
}
export { todoListReducer };
import { createStore } from 'redux'
import { todoListReducer } from './reducers'
export default () => createStore(todoListReducer);
const getTemplatesFromImport = () => {
const link = document.querySelector('link[rel="import"]');
if( ! link ) throw `Import link tag not found`;
return Array.from( link.import.querySelectorAll('template') );
}
export default () => {
const templates = getTemplatesFromImport();
return templateId => {
const selectedTemplate = templates.filter( template => template.id === templateId )
if( ! selectedTemplate.length ) throw `Template ${templateId} not found`;
if( selectedTemplate.length > 1 ) throw `Found multiple templates with id: ${templateId}`;
return selectedTemplate.pop();
}
}
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');
var CopyWebpackPlugin = require('copy-webpack-plugin');
var BrowserSyncPlugin = require('browser-sync-webpack-plugin');
module.exports = {
entry: './index.js',
plugins: [
new CleanWebpackPlugin(['dist']),
new HtmlWebpackPlugin({
title: 'redux components',
template: './app-template.html'
}),
new CopyWebpackPlugin([
{ from: './components-templates.html', to: 'components-templates.html' },
{ from: 'node_modules/@webcomponents/webcomponents-loader.js', to: 'webcomponents-polyfills' },
{ from: 'node_modules/@webcomponents/webcomponents-hi.js', to: 'webcomponents-polyfills' },
{ from: 'node_modules/@webcomponents/webcomponents-hi-ce.js', to: 'webcomponents-polyfills' },
{ from: 'node_modules/@webcomponents/webcomponents-hi-sd-ce.js', to: 'webcomponents-polyfills' },
{ from: 'node_modules/@webcomponents/webcomponents-sd-ce.js', to: 'webcomponents-polyfills' },
{ from: 'node_modules/@webcomponents/webcomponents-lite.js', to: 'webcomponents-polyfills' }
]),
new BrowserSyncPlugin({
port: 2222,
server: { baseDir: ['dist'] }
})
],
devtool: 'inline-source-map',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
publicPath: '/'
}
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment