Fundamentals of cross-platform development

When developing for different platforms, the most common problem we have to face is sharing the common parts of a component while providing different implementations for details that are platform-specific. We will now explore some of the principles and the patterns to use when facing this challenge.

Runtime code branching

The most simple and intuitive technique for providing different implementations based on the host platform is to dynamically branch our code. This requires that we have a mechanism to recognize at runtime the host platform and then switch dynamically the implementation with an if…else statement. Some generic approaches involve checking global variables that are available only on Node.js or only in the browser. For example, we can check the existence of the window global:

if(typeof window !== "undefined" && window.document) { 
  //client side code 
  console.log('Hey browser!'); 
} else { 
  //Node.js code 
  console.log('Hey Node.js!'); 
} 

Using a runtime branching approach for switching between Node.js and the browser is definitely the most intuitive and simple pattern we can use for the purpose; however, there are some inconveniences:

  • The code for both the platforms is included in the same module and therefore in the final bundle, increasing its size with unreachable code.
  • If used too extensively, it can considerably reduce the readability of the code, as business logic would be mixed with logic meant only to add cross-platform compatibility.
  • Using dynamic branching to load a different module depending on the platform will result in all the modules being added to the final bundle regardless of their target platform. For example, if we consider the next code fragment, both clientModule and serverModule will be included in a bundle generated with Webpack, unless we don't explicitly exclude one of them from the build:
       if(typeof window !== "undefined" && window.document) { 
         require('clientModule'); 
       } else { 
         require('serverModule'); 
       } 

This last inconvenience is due to the fact that bundlers have no sure way of knowing the value of a runtime variable at build time (unless the variable is a constant), so they include any module regardless of whether it's required from reachable or unreachable code.

A consequence of this last property is that modules required dynamically using variables are not included in the bundle. For example, from the following code, no module will be bundled:

moduleList.forEach(function(module) { 
  require(module); 
}); 

It's worth underlining that Webpack overcomes some of these limitations and, under certain specific circumstances, it is able to guess all the possible values for a dynamic requirement. For instance, if you have a snippet of code like the following:

function getController(controllerName) { 
  return require("./controller/" + controllerName); 
}

It will put all the modules available in the controller folder.

It's heavily recommended to have a look at the official documentation to understand all the supported cases.

Build-time code branching

In this section, we are going to see how to use Webpack to remove, at build time, all the parts of the code that we want only the server to use. This allows us to obtain lighter bundle files and to avoid accidentally exposing sensible code that should live only on the server.

Apart from loaders, Webpack also offers support for plugins, which allows us to extend our processing pipeline used to build the bundle file. To perform build-time code branching, we can use a pipeline of two built-in plugins called DefinePlugin and UglifyJsPlugin.

DefinePlugin can be used to replace specific code occurrences in our source files with custom code or variables. Instead, UglifyJsPlugin allows us to compress the resulting code and remove unreachable statements (dead code).

Let's see a practical example to better understand these concepts. Let's assume we have the following content in our main.js file:

if (typeof __BROWSER__ !== "undefined") { 
  console.log('Hey browser!'); 
} else { 
  console.log('Hey Node.js!'); 
} 

Then, we can define the following webpack.config.js file:

const path = require('path'); 
const webpack = require('webpack'); 
 
const definePlugin = new webpack.DefinePlugin({ 
  "__BROWSER__": "true" 
});
const uglifyJsPlugin = new webpack.optimize.UglifyJsPlugin({ 
  beautify: true, 
  dead_code: true 
}); 
 
module.exports = { 
  entry:  path.join(__dirname, "src", "main.js"), 
  output: { 
    path: path.join(__dirname, "dist"), 
    filename: "bundle.js" 
  }, 
  plugins: [definePlugin, uglifyJsPlugin] 
}; 

The important parts of the code here are the definition and configuration of the two plugins that we introduced.

The first plugin, DefinePlugin, allows us to replace specific parts of the source code with dynamic code or constant values. The way it is configured is a bit tricky but this example should help with understanding how it works. In this case, we are configuring the plugin to look for all the occurrences of __BROWSER__ in the code and to replace them with true. Every value in the configuration object (in our case, "true" as a string and not as a boolean) represents a piece of code that will be evaluated at build time and then used to replace the currently matched snippet of code. This allows us to put in the bundle external dynamic values containing, for instance, the content of an environment variable, the current timestamp, or the hash of the last git commit. After occurrence of __BROWSER__ is replaced, the first if statement will internally look like if (true !== "undefined"), but Webpack is smart enough to understand that this expression will always be evaluated as true, so it transforms the resulting code again to be if (true).

The second plugin (UglifyJsPlugin) is instead used to obfuscate and minify the JavaScript code of the bundle file using UglifyJs (https://github.com/mishoo/UglifyJS). With the dead_code option provided to the plugin, UglifyJs is able to remove all the dead code, so our currently processed code that will look like this:

if (true) { 
  console.log('Hey browser!'); 
} else { 
  console.log('Hey Node.js!'); 
} 

can be easily converted only in:

console.log('Hey browser!'); 

The beautify: true option is used to avoid removing all the indentation and whitespaces so that, if you are curious, you can go and read the resulting bundle file. When creating bundles for production, it is better to avoid specifying this option, which is by default false.

Tip

In the example code that you can download in addition to this book, you will find an extra example that will show you how to use the Webpack DefinePlugin to replace specific constants with dynamic variables such as the timestamp of bundle generation, the current user, and the current operative system.

Even if this technique is way better than runtime code branching because it produces much leaner bundle files, it can still make our source code cumbersome when abused. You don't want to have statements that branches your server code from your browser code all around your application, right?

Module swapping

Most of the time, we already know at build time what code has to be included in the client bundle and what shouldn't. This means that we can take this decision upfront and instruct the bundler to replace the implementation of a module at build time. This often results in a leaner bundle, as we are excluding unnecessary modules, and a more readable code because we don't have all the if…else statements required by runtime and build-time branching.

Let's find out how to adopt module swapping with Webpack with a very simple example.

We are going to build a module that exports a function called alert, which simply shows an alert message. We will have two different implementations, one for the server and one for the browser. Let's start with alertServer.js:

module.exports = console.log; 

Then, with the alertBrowser.js:

module.exports = alert; 

The code is super simple. As you can tell, we are just using the default functions console.log for the server and alert for the browser. They both accept a string as an argument, but the first prints the string in the console while the seconds displays it in a window.

Now let's write our generic main.js code, which by default, uses the module for the server:

const alert = require('./alertServer'); 
alert('Morning comes whether you set the alarm or not!'); 

There's nothing crazy here—we are just importing the alert module and using it. If we run:

node main.js

it will just print Morning comes whether you set the alarm or not! in the console.

Now comes the interesting part, let's see how our webpack.config.js should look to be able to swap the require of alertServer with alertBrowser when we want to create the bundle for the browser:

const path = require('path'); 
const webpack = require('webpack'); 
 
const moduleReplacementPlugin = 
  new webpack.NormalModuleReplacementPlugin(/alertServer.js$/,
    './alertBrowser.js'); 
 
module.exports = { 
  entry:  path.join(__dirname, "src", "main.js"), 
  output: { 
    path: path.join(__dirname, "dist"), 
    filename: "bundle.js" 
  }, 
  plugins: [moduleReplacementPlugin] 
}; 

We are using the NormalModuleReplacementPlugin, which accepts two arguments. The first argument is a regular expression and the second one is a string representing a path to a resource. At build time, if a resource matches the given regular expression, it is replaced with the one provided in the second argument.

In this example, we are providing a regular expression that matches our alertServer module and replaces it with alertBrowser.

Note

Notice that we used the const keyword in this example but for the sake of simplicity, we didn't add the configuration to transpile ES2015 functionalities to the equivalent ES5 code, so with the current configuration the resulting code might not work with old browsers.

Of course, we can use the same swapping technique also with external modules fetched from npm. Let's improve the previous example to see how to use one or more external modules together with module swapping.

Nobody wants to use the alert function today, and for good reason. This function in fact displays a very bad looking window, which blocks the browser until the user dismisses it. It would be much nicer to use a fancy toast popup to display our alert message. There are a number of libraries on npm that provide this toast functionality, and one of these is toastr (https://npmjs.com/package/toastr), which provides a very simple programmatic interface and an enjoyable look and feel.

toastr relies on jQuery, so the first thing that we need to do is to install both with:

npm install jQuery toastr

Now we can rewrite our alertBrowser module to use toastr instead of the native alert function:

const toastr = require('toastr'); 
module.exports = toastr.info; 

The toastr.info function accepts a string as an argument and it will take care to display the given message as a box in the top right corner of the browser window once invoked.

Our Webpack configuration file remains the same but, this time, Webpack will resolve the full dependency tree for the new version of the alertBrowser module, thus including jQuery and toastr in the resulting bundle file.

In addition, the server version of the module and the main.js file remained unchanged, and this proves how this solution makes our code much easier to maintain.

Note

To make this example work nicely in the browser, we should take care to add the toastr CSS file to our HTML file.

Thanks to Webpack and the module replacement plugin, we can easily deal with structural differences between platforms. We can focus on writing separate modules that are meant to provide platform-specific code and we can then swap Node.js—only modules with browser-specific ones in the final bundle.

Design patterns for cross-platform development

Now that we know how to switch between Node.js and browser code, the remaining pieces of the puzzle are how to integrate this within our design and how we can create our components in such a way that some of their parts are interchangeable. These challenges should not sound new to us at all; in fact, all throughout the book we have seen, analyzed, and used patterns to achieve this very purpose.

Let's revise some of them and describe how they apply to cross-platform development:

  • Strategy and Template: These two are probably the most useful patterns when sharing code with the browser. Their intent is, in fact, to define the common steps of an algorithm, allowing some of its parts to be replaced, which is exactly what we need! In cross-platform development, these patterns allow us to share the platform-agnostic part of our components, while allowing their platform-specific parts to be changed using a different strategy or template method (which can be changed using runtime or compile-time branching).
  • Adapter: This pattern is probably the most useful when we need to swap an entire component. In Chapter 6, Design Patterns, we have already seen an example of how an entire module, incompatible with the browser, can be replaced with an adapter built on top of a browser-compatible interface. Do you remember the LevelUP adapter for the fs interface?
  • Proxy: When code meant to run in the server runs in the browser, we often expect things that live on the server to be available in the browser as well. This is where the remote Proxy pattern comes into place. Imagine if we wanted to access the filesystem of the server from the browser: we could think of creating an fs object on the client that proxies every call to the fs module living on the server, using Ajax or Web Sockets as a way of exchanging commands and return values.
  • Observer: The Observer pattern provides a natural abstraction between the component that emits the event and those that receive it. In cross-platform development, this means that we can replace the emitter with its browser-specific implementation without affecting the listeners and vice versa.
  • DI and service locator: Both DI and service locator can be useful to replace the implementation of a module at the moment of its injection.

As we can see, the arsenal of patterns at our disposal is quite powerful, but the most powerful weapon is still the ability of the developer to choose the best approach and adapt it to the specific problem at hand. In the next section, we are going to put into action what we have learned, leveraging some of the concepts and patterns we have seen so far.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.191.74.66