Removing platform specific code with webpack in your NativeScript application
The NativeScript framework automatically includes source files that are suffixed with the given platform name. This way the clutter and size of your built application is decreased and you do not distribute dead platform specific code that isn’t needed. But often there are case where is it much more simple to write your platform specific code in a single file and just surround it with if
statements for the platform, rather then separating it into separate platform specific files. So what happens then? Let’s take an example – bellow you will see the natvigatingTo
event handler for a page which has some platform specific code:
import { EventData } from "data/observable";
import { isIOS, isAndroid } from "platform";
import * as frame from "ui/frame";
import { Page } from "ui/page";
import { HelloWorldModel } from "./main-view-model";
export function navigatingTo(args: EventData) {
let page = <Page>args.object;
if (isIOS) {
console.log("test");
frame.topmost().ios.controller.view.window.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(242 / 255, 242 / 255, 242 / 255, 1);
UITableViewCell.appearance().selectionStyle = UITableViewCellSelectionStyle.None;
UITableView.appearance().tableFooterView = UIView.alloc().initWithFrame(CGRectZero); // Hide empty cells
}
console.log(`We are running on ${isAndroid ? "ANDROID" : "IOS"}`);
page.bindingContext = new HelloWorldModel();
}
If we run that though webpack with uglify (and you should always do that for the version of an app that you publish to the stores) we will get this (note that I reformatted code with new lines for readability):
function i(e) {
var t = e.object;
o.isIOS && (
console.log("test"),
a.topmost().ios.controller.view.window.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(242 / 255, 242 / 255, 242 / 255, 1),
UITableViewCell.appearance().selectionStyle = 0,
UITableView.appearance().tableFooterView = UIView.alloc().initWithFrame(CGRectZero)
),
console.log("We are running on " + (o.isAndroid ? "ANDROID" : "IOS")),
t.bindingContext = new r.HelloWorldModel
}
As you see, although changed a bit, all the platform specific code is there. And no matter if you bundle for iOS or Android the code will be the same. Since you cannot change the platform during runtime some parts of that code are dead and will never execute. So how can we remove those? This can easily be achieved by helping webpack “understand” which parts of the code are dead so it can remove them when it optimizes the bundle.
First thing that we will do is instead of using the isAndroid
/ isIOS
flags from the platform
module, we will use flags that we will define in the global
object (you will see why later on). In the app’s entry point before starting it we will extend the global
object with the flags and their values:
import { isIOS, isAndroid } from "platform";
Object.assign(global, { isIOS, isAndroid });
Now in order to make TypeScript happy and so that we can directly use ‘global.isIOS’ and ‘global.isAndroid’ lets add an ‘app.d.ts’ in our app with the following content:
// Augment the NodeJS global type with our own extensions
declare namespace NodeJS {
interface Global {
// Add custom properties here.
isIOS: boolean;
isAndroid: boolean;
}
}
This extends the definition of the global
object that is provided with NativeScript’s core modules and we won’t have to cast global
to any
in order to make our code compile.
Now if we refactor our platform specific code to use the global
object instead, the code will look like this:
export function navigatingTo(args: EventData) {
let page = <Page>args.object;
if (global.isIOS) {
console.log("test");
frame.topmost().ios.controller.view.window.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(242 / 255, 242 / 255, 242 / 255, 1);
UITableViewCell.appearance().selectionStyle = UITableViewCellSelectionStyle.None;
UITableView.appearance().tableFooterView = UIView.alloc().initWithFrame(CGRectZero); // Hide empty cells
}
console.log(`We are running on ${global.isAndroid ? "ANDROID" : "IOS"}`);
page.bindingContext = new HelloWorldModel();
}
If we run webpack with uglify now we wont see much of a difference. This is because webpack still does not know which code is dead. So lets help him understand that by changing the webpack.config.js
and adding to the webpack.DefinePlugin
the same global.isIOS
and global.isAndroid
flags. This is possible because during bundle time webpack already knows for which platform it is creating the bundle:
// Define useful constants like TNS_WEBPACK
new webpack.DefinePlugin({
"global.TNS_WEBPACK": "true",
"global.isAndroid": `${platform === "android"}`,
"global.isIOS": `${platform === "ios"}`,
"process": undefined,
}),
When the bundle is created webpack will replace the occurrence of those two flags with the respective value as defined in the config file. By doing this it will render parts of the code dead and the UglifyJS plugin can safely remove them from the final bundle.
One last thing to do – since we will not actually need the flags in the global
object when the code is bundled, we can safely change the code in the app entry point:
if (!global.TNS_WEBPACK) {
Object.assign(global, { isIOS, isAndroid });
}
This will ensure that both bundled and non-bundled builds are working as expected and we will not be polluting the global
object unnecessary.
Now if we bundle and uglify the code, for iOS we will get (again code reformatted for readability):
function r(e) {
var t = e.object;
console.log("test"),
o.topmost().ios.controller.view.window.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(242 / 255, 242 / 255, 242 / 255, 1),
UITableViewCell.appearance().selectionStyle = 0,
UITableView.appearance().tableFooterView = UIView.alloc().initWithFrame(CGRectZero),
console.log("We are running on IOS"),
t.bindingContext = new a.HelloWorldModel
}
And for Android we will get:
function a(t) {
var e = t.object;
0;
console.log("We are running on ANDROID");
e.bindingContext = new o.HelloWorldModel
}
As you will notice the code for both platforms is greatly optimized now. First the conditional expression we had in the last console.log
is changed so there is no unnecessary comparison. Furthermore for Android all the iOS specific code has been removed and replaced with a dummy 0
.
Thanks for sharing this technique Peter!
Looks great, thanks for sharing! Can you show some data around what is the performance gain of doing this?
Performance gain greatly depends on how much such conditional statements you have. So it is specific to the code that the user has.
Yeah, I get this. Still, if you can share some data from one of your existing apps – that would be great. We would consider this to be the built-in behaviour if there is a significant improvement in the general case.
In the app for which this was implemented the
bundle.js
size went down from 446KB to 443KB for android and from 445KB to 443KB for ios.In a synthetic test running 1 million times the above code, changed to assign to an export variable instead of logging to the console, w/o optimization it takes an average of 952ms per run. With the optimization it takes average of 916ms, so around 4%)