Lets start with something simple; we will start by explaining the projects focus. We could go into reasons why one method of UI development is better than another, why we should use X over Y, but that serves no purpose; we will not do this here. This is the only statement here you will see on this matter, with all concentration and effort being used to deliver simple, clean standard web components for UI development. We will also touch on the flexibility of web components and where they can be used.
Custom web components are simply an extention of the web component mechanism in all browsers. Headings <h1>Large</h1> <h2>Smaller</h2>, Paragraphs <p>A paragraph</p> and Anchor Links <p>A paragraph</p> are all examples of HTML markup, think of a custom web component as being exactly the same; the only difference here is that we define the component and how it works.
The library (wrapper) is tiny, you also dont have to use templating, you can manipulate the DOM without lit-html, so it loads super quick, minimising FOUC. You can use them in static HTML served from S3 buckets, use them in other templating systems like Angular, React and VueJS; they can work in tandom with other engines or as the backbone of your application. We will concentrate on full application stack here, but there is nothing to stop you using smaller components in your current application stack.
We use them to create small or large insteractive HTML components. From a simple self validating, character counting input box, to full applications; custom web components can be used to create rich, interactive, UI experiences that leverage native browser technologies. Custom Web Component adds to the lifecycle hooks, adds to the helper functions and adds in databinding with the help of the very tiny lit-html templating engine.
So before we start, what are we going to do? We are going to create a basic application, and in doing so, create things we need to help us develop, publish and maintain our application. We need to make the basic structure, which can be in any way you wish. this way is simple and clean, adapt it to your on needs.
Whilst we do have a build process, when developing we run from root and develop in route. You can skip the build process if you do not want to support IE and are happy collating files for production (also removing babel/browserify stuff from package.json). You could also create a deploy script, just to copy files so you don't have to cherry pick NPM packages etc. For ease we combine the two, so we generate IE11 builds just incase, and copy files so when we deploy, we take the contents of a folder and push to production. We like to make things simple, building can be complex, modern browsers do not need builds anymore!
First of all we need to install Custom Web Components into your system or starter application. you can do this with NPM. You may also do this via downloading the project from GitHub, but this will mean havingto download all the dependencies manually too, so use NPM.
Command Line
npm install custom-web-component --save
This will install custom web component and all dependencies at correct versions. Should you need to install all of them manually, you can opt for this command...
Command Line
npm install custom-web-component \
@webcomponents/custom-elements @webcomponents/shadycss @webcomponents/webcomponentsjs \
es7-object-polyfill \
lit-html \
promise-polyfill \
reflect-constructor --save
Lets explain smoe of this. The web components stuff is polyfilling for the browser. Why polyfill? well this adds in missing standards the browsers are yet to adopt. We only polyfill and use those standards that are excepted and to be adopted; we just wan to use them now. As the browser upgrades they are dropped automatically by the browser. This approach means as browsers age, yur code improves due to native support. We then have some back filling of missing functionality, some written by us too, some just for IE11.
We also have development dependencies to add, these are to make development easier, to help build fall back bundles for old browsers and a way to run your code on a simple JS server; we will get to this in a moment. For now this will get us what we need. We will assume you have some level of familularity with NPM and JS/HTML at this point, if not we would suggest reading a bit on these topics.
The next thing we need to do is some housekeeping. We have older browsers to support, IE11 to be specific, pluss we wan to be able to develop locally meaning we need to serve files. Finally we will want a nice clean place to generate everything we need to deploy, so we have no headaches on what to deploy, what structure it should be in etc. We develop JS UI's mainly from a source folder called src. From here we build into a build folder called build. We dont want everything in there, so we create a build script to manage this.
We like to keep things as native as possible, meaning we write in ES6 JS, we write object orientated code and we create our build scripts with JS directly. Our aim here is to limit work, code, languages, preprocessors and time as much as is possible. We will be creating a build script direct in JS and using the NPM script management to run them using NPM commands.
We need to first adjust your package.js to add some development dependencies and also patch in some build scripts we will make next. open your package.js file and add the following:-
package.json
"devDependencies": {
"babel-core": "^6.26.3",
"babel-plugin-transform-custom-element-classes": "^0.1.0",
"babel-plugin-transform-es2015-classes": "^6.24.1",
"babel-preset-es2015": "^6.24.1",
"babel-preset-es2015-loose": "^7.0.0",
"babel-preset-es2016": "^6.24.1",
"babel-preset-latest": "^6.24.1",
"babelify": "^7.3.0",
"browserify": "^16.2.3",
"fs-extra": "^7.0.1",
"express": "^4.16.4",
"nodemon": "^1.18.10"
}
Once you have added these, we can install them by running the intal command on the CLI to update your node_mdoules...
Command Line
npm install
We need these to turn ES6 into old world JS for IE, we use babel to do this and package the bundle with browserify. Only IE needs this, all other browsers support ES6 mjs module loading directly, so no bundling for new browsers. Bundles are not good for metered internet connectins, they are not good for memory usage, use what you need! The rest is for the build/deploy script and express to serve so we can develop locally, running in a local server configured basic file delivery; finally we can use nodemon to auto build on change if we wish (optional).
package.json
"babel": {
"plugins": [
"transform-custom-element-classes",
"transform-es2015-classes"
],
"presets": [
"latest"
]
},
"browserify": {
"transform": [
[
"babelify",
{
"plugins": [
"transform-custom-element-classes",
"transform-es2015-classes"
],
"presets": [
"latest"
]
}
]
]
}
This lot is for babel/browserify; it will basically help babel decide how to transform files down to old JS and also how browserify should package the IE bundle. Soon enough, with the invention of EDGE chromium, all this will be a thing of the past, but for now, you need IE support, you need to bundle.
package.json
"scripts": {
"start": "node server.js",
"build": "node build.js",
"watch": "nodemon --ignore 'files to ignore go here' node build.js"
}
Finally we place some script hooks to do things when we type commands such as the following (note: npm run start can drop the run bit)
package.json
npm start
npm run build
npm run watch
The first will run the server script to start a local express server, watch the output in the CLI for the address to visit in your browser. Next we have build, to run the build script. Lastly we have an example of how you can watch the file system for changes and in this case, run build when you save file changes. Thats the NPM changes done, we now need to generate the server file, for the loca server and also the build script.
We are going to create a server file, this will help us serve the files from our project. Why do we need to do this? Well you could just hit the local files directly from the browser, but this can mess with relative paths, default landing files too. We wan to be able to hit the root URL and serve the index.html page without the need for /index.html. Also if we want to do hash based routing, common in JS UI land for single page apps, we don't wnat to hash the index.html address, we want to hash the root address.
So we are going to create a real simple express server setup to basically just serve files (and index.html if root is accessed). This is only for development use and should not be used for deploying code in production. Express is good for this too, but requires more work and thought for this.
// Config
var PORT = 8888;
var express = require('express');
var app = express();
// Start
app.use(express.static('./'));
app.listen(PORT);
console.log('Running on http://localhost:' + PORT);
Save this file in the application root folder, next to index.html, then you will be able to run this command through NPM to start the server from the CLI Once run, visit the address (with port number) to access the index.html file without the need to add /index.html.
Command Line
npm start
We are going to create a build file, this will help us create a clean build folder to deploy code from, as well as build the fallback bundles for IE11. We are going to create our build file in ES6 JS, we could use gulp, we could use grunt, but we go native as is inline with the project ethos. In here we will be doing many tasks, ensuring build folders clean, copying dependencies, building fallbacks and more. Once complete, we can simple copy the contents of the build folder to production in the manner we see fit, git hook, jenkins or upload direct.
var fs = require('fs');
var fsx = require('fs-extra');
var browserify = require("browserify");
var babelify = require("babelify");
/*************************************************/
/* Build into distributable, production versions */
/*************************************************/
// CUSTOM WEB COMPONENT -- BUILD //
console.log('---------------------------------------------------------');
console.log('CWC BUILD');
console.log('---------------------------------------------------------');
console.log('');
// find all deps we need to clean from package file
var package = JSON.parse(fs.readFileSync('package.json', 'utf8'));
var deps = Object.keys(package.dependencies);
// remove them all, and the index fall back for the component
Promise.resolve(console.log('Cleaned Build...'))
.then(() => fsx.remove('./build'))
.then(() => fsx.remove('./src/index.js'))
.then(() => console.log('Cleaned Build DONE'))
.catch((error) => console.log('Cleaned Build FAILED...', error))
// build src into distributable
.then(() => console.log('Create fallback bundle...'))
.then(() => new Promise((resolve, reject) => {
browserify({ debug: true })
.transform(babelify.configure({ extensions: [".mjs"] }))
.require('./src/index.mjs', { entry: true })
.bundle()
.on("error", (err) => reject("Browserify/Babelify compile error: " + err.message))
.pipe(fs.createWriteStream('./src/index.js'))
.on("finish", () => resolve());
}))
.then(() => console.log('Create fallback bundle DONE'))
.catch((error) => console.log('Create fallback bundle FAILED', error))
// copy over deps from package file
.then(() => console.log('Copy Dependencies...'))
.then(() => {
var copied = [];
for (let i = 0; i < deps.length; i++) copied.push(fsx.copy('./node_modules/' + deps[i], './build/node_modules/' + deps[i]));
// ensure deps come over if not listed in package file
copied.push(fsx.copy('./node_modules/@webcomponents/custom-elements', './build/node_modules/@webcomponents/custom-elements'));
copied.push(fsx.copy('./node_modules/@webcomponents/shadycss', './build/node_modules/@webcomponents/shadycss'));
copied.push(fsx.copy('./node_modules/@webcomponents/webcomponentsjs', './build/node_modules/@webcomponents/webcomponentsjs'));
copied.push(fsx.copy('./node_modules/es7-object-polyfill', './build/node_modules/es7-object-polyfill'));
copied.push(fsx.copy('./node_modules/lit-html', './build/node_modules/lit-html'));
copied.push(fsx.copy('./node_modules/promise-polyfill', './build/node_modules/promise-polyfill'));
copied.push(fsx.copy('./node_modules/reflect-constructor', './build/node_modules/reflect-constructor'));
return Promise.all(copied);
})
.then(() => console.log('Copy Dependencies DONE'))
.catch((error) => console.log('Copy Dependencies FAILED', error))
// copy assets
.then(() => console.log('Copy Assets...'))
.then(() => fsx.copy('./assets', './build/assets'))
.then(() => console.log('Copy Assets DONE'))
.catch((error) => console.log('Copy Assets FAILED', error))
// copy Source
.then(() => console.log('Copy Source...'))
.then(() => fsx.copy('./src', './build/src'))
.then(() => console.log('Copy Source DONE'))
.catch((error) => console.log('Copy Source FAILED', error))
// copy HTML files
.then(() => console.log('Copy HTML...'))
.then(() => fsx.copy('./index.html', './build/index.html'))
.then(() => console.log('Copy HTML DONE'))
.catch((error) => console.log('Copy HTML FAILED', error))
// finish
.then(() => {
console.log('');
console.log('---------------------------------------------------------');
console.log('COMPLETED - ' + new Date());
console.log('You may copy the contents of build to your server!');
console.log('---------------------------------------------------------');
console.log('');
});
Save this file in the application root folder, next to index.html, then you will be able to run this command through NPM to start the server from the CLI. NOTE: When running the server, we actually run from root, only use build when your ready to copy files to a server. Any other deps you add to your package.js file should be picked up automatically on build and added to your build folder!
Command Line
npm run build
We are going to create out first application now. By using a single entry application custom web component, we create a simple application we can add to a static file with ease. In time you will see you can build whole single page applications from a single HTML file and a bucket of web components.
We use mjs files, these are modular javascript files. They load differently. the only JS files we have are the build/server files and the bundle generated from the build script. The first file is the bootstrap file, we use this to load in all application logic, from there all other files are pulled in via imports in the JS files. We add the bootstrap file to the index.html page in a bit to load the bootstrap file and all other dependencies automatically via ES6 imports (thi sis why IE needs a bundle, no module import support) The HelloWorld folder houses the HelloWorld application which also happens to be our main application. The hello-world.js file is our application. All other components will be used form this file or the children of this file.
import '../node_modules/reflect-constructor/reflect-constructor.js'; // for IE11 support
import './HelloWorld/hello-world.mjs'; // your application entry point
// optional... set your service worker file!
// if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');
// optional... any runtime message to console
console.log('Powered by Custom Web Component');
This file loads an IE11 polyfill, then continues to pull in the application class. From here you can do other things too. Setup any service workers, configurations, polyfills and more to make the HTML work easier.
import { CustomHTMLElement, html } from "../node_modules/custom-web-component/index.js";
/**
* HelloWorld
* A sample Custom HTML Element, to be used in any system that is capable of outputting HTML
* Build on Web Standards and polyfilled for legacy browsers, using a simple clean lite HTML template rendering called lit-html
*/
class HelloWorld extends CustomHTMLElement {
/**
* @public constructor()
* Invoked when instantiation of class happens
* NOTE: Call super() first!
* NOTE: Declare local properties here... [this.__private, this._protected, this.public]
* NOTE: Declarations and kick starts only... no business logic here!
*/
constructor() {
super();
this.foo = 'FOO!!';
this.bar;
}
/**
* template()
* Return html TemplateResolver a list of observed properties, that will call propertyChanged() when mutated
* @return {TemplateResult} Returns a HTML TemplateResult to be used for the basis of the elements DOM structure
*/
static template() {
return html`
<style>
/* Style auto encapsulates in shadowDOM or shims for IE */
:host { display: block; }
div {
display: block;
padding: 20px;
color: #222;
background: #f5f2f0;
border: 1px solid #ccc;
border-radius: 3px;
}
button {
border: none;
background: #444;
color: white;
padding: 10px;
}
</style>
<div>
<p>
<slot name="main">Default text if no slot for main</slot>
<br />
<strong>FOO:</strong> ${this.foo}
<br />
<strong>BAR:</strong> ${this.bar}
<br />
<slot name="footer">Default text if no slot for footer</slot>
<button @click="${this._clicked.bind(this, 'something')}">Boo</button>
</p>
</div>
`;
}
/**
* @static @get observedProperties()
* Return a list of observed properties, that will call propertyChanged() when mutated
* @return {Array} List of properties that will promote the callback to be called on mutation
*/
static get observedProperties() { return ['foo', 'bar']; }
/**
* @public propertyChanged()
* Invoked when an observed instantiated property has changed
* @param {String} property The name of the property that changed
* @param {*} oldValue The old value before the change
* @param {*} newValue The new value after the change
*/
propertyChanged(property, oldValue, newValue) {
console.log(this.tagName, 'propertyChanged', property, oldValue, newValue);
this.updateTemplate();
}
/**
* @static @get observedAttributes()
* Return a list of observed attributes, that will call attributeChanged() when mutated
* @return {Array} List of attributes that will promote the callback to be called on mutation
*/
static get observedAttributes() { return ['bar']; }
/**
* @public attributeChanged()
* Invoked when an observed node attribute has changed
* @param {String} attribute The name of the attribute that changed
* @param {*} oldValue The old value before the change
* @param {*} newValue The new value after the change
*/
attributeChanged(attribute, oldValue, newValue) {
console.log(this.tagName, 'attributeChanged', attribute, oldValue, newValue);
if (attribute === 'bar') this.bar = newValue;
this.updateTemplate();
}
/**
* @public connected()
* Invoked when node is connected/added to the DOM
*/
connected() {
console.log('connected');
}
/**
* @public disconnected()
* Invoked when node is disconnected/removed from the DOM
*/
disconnected() {
console.log('disconnected');
}
/**
* @public update() [parent class]
* Update the view, pushing only changes for update in shadow DOM
*/
templateUpdated() {
console.log(this.shadowRoot, this.tagName, 'updated');
}
_clicked(text, ev) {
alert(text);
}
}
customElements.define('hello-world', HelloWorld);
This is your main application file. here you can load whole user interfaces, perform authentication to back ends, configure message systems and much more. Now all we need to do is add an index.html file, and add our new web component to it. We can use it many times too in the HTML file!
<!DOCTYPE html>
<html>
<head>
<title>Boom!</title>
<!-- Polyfill -->
<script src="/node_modules/promise-polyfill/dist/polyfill.min.js"></script>
<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
<!-- Bootstrap App -->
<script type="module" src="./index.mjs"></script>
<script nomodule src="./index.js"></script>
</head>
<body>
<h1>Example View</h1>
<p>Show a component in action below...</p>
<hello-world bar="bar!">
<p slot="main">Hello</p>
<p slot="footer">World</p>
</hello-world>
<p>We have pre built components, npm install razilocomponents (with an 's';)</p>
</body>
</html>
Thats it... We may now start your server, and see your web component in action. Once you are happy, run a build, copy the files form your build folder to your production environment.