About ES Script

03 Aug, 2023

undefined

Photo credit @safarslife from unsplash.

Recently I have been working a lot on frontend performance tuning; and there are many blogs talking about how to do frontend optimization, where we can refer and use their tips.

The most common tip is try to minify JS bundles size; we should try best to decrease our JS bundles size in build time. There are many ways like code splitting, uglify and so on to reduce JS bundle size, but today I'd like to talk more deeply on the ES script.

Introduction


We often use Webpack and Babel together, Babel is integrated into the Webpack build pipeline using a Babel loader, which allows Webpack to leverage Babel's transpilation capabilities when bundling the code, to make the code is compatible with a wider range of browsers.

We know that modern browsers have started to support ES syntax, functionality natively; we can set the babel config to transpile to the newer one without transpiling to the older version of JavaScript. This saves a lot of syntax, so we are able to reduce the size of the JS bundles.

Back to the purpose of Babel, the core concern of building ES module is Browser Compatibility. Older browsers may not fully support it, many users may still be using older browsers.

module/nomodule


Actually modern browsers support module/nomodule pattern for declaratively loading modern VS legacy code, which provides browsers with both sources and lets them decide which to use:

<script type="module" src="/modern.js"></script>
<script nomodule src="/legacy.js"></script>

It seems when using this pattern in Edge and Safari caused double-fetching of scripts before. But as bugs were fixed, using the <script> tag with the module/nomodule attributes is considered a safe and widely used pattern.

Let's say we are just starting a new project and have not published yet, it is perfectly fine to use this pattern. But what if we want to improve the current service, is it safe to switch our build directly to this pattern?

My opinion


  1. We can have two builds of the client application, one for the older browsers, the other for the modern browsers.
  2. We need a sever to get the user agent of a user and then decide which build the server should send back to the user.

flow

If we want to do gradual rollout, I personally think it's a relatively safe way. And I can come up with several pros & cons:

Pros
Cons

Proof of Concept


Prerequisites: init the project, include client and server
$ mkdir server-consumer
$ cd server-consumer
$ vue create consumer # choose vue 2
$ nest new server
Client
1. Build the client code

The build result to be like this; the default build is for the older browser, the modern build is for the modern browser.

server-consumer
├── consumer/
│ ├── dist/
│ │ ├── default/
│ │ │ ├── index.html
│ │ ├── modern/
│ │ │ ├── index.html

We can still leverage the vue-cli-service build command since it helps do lots of thing, but needs to restructure the consumer a bit.

2. Create vue configs

Create configs folder and files under the consumer:

server-consumer
├── consumer/
│ ├── configs/
│ │ ├── vue.common.js # The common config used bothe in default & modern
│ │ ├── vue.default.js # For default build
│ │ ├── vue.modern.js. # For modern build

Won't go into too much detail on config settings here; the core concept is that we need to get vue.modern.js to override the babel loader setting. (Note: the setting is initially taken from babel.config.js):

const { defineConfig } = require('@vue/cli-service')
const commonConfig = require('./vue.common')

module.exports = defineConfig({
...commonConfig,
configureWebpack: {
target: ['web', 'es2022'],
},
chainWebpack: config => {
config.output.filename('js/[name].[hash:8].modern.js')
config.output.chunkFilename('js/[name].[chunkhash:8].chunk.modern.js')
const jsRule = config.module.rule('js')
jsRule
.use('babel-loader')
.loader(require.resolve('babel-loader'))
.tap((options) => {

return {
...options,
presets: [
[
'@vue/cli-plugin-babel/preset',
{
targets: {
"esmodules": true
},
}
]
]
};
});
}
})

References

3. Add the gulpfile.js and the build command
$ cd consumer
$ touch gulpfile.js
$ vim package.json # add "build:gulp": "gulp build" to scripts

Fortunately. this is an environment variable VUE_CLI_SERVICE_CONFIG_PATH that we can use, to make the build command can load the appropriate config:

const gulp = require('gulp');
const childProcess = require('child_process');
const path = require('path')

const { series, parallel } = gulp;
const { spawnSync } = childProcess;
const { join } = path;

async function buildDefault(cb) {
console.log("Create default build.")
await spawnSync(
'vue-cli-service',
[
'build',
'--dest',
'dist/default'
],
{
stdio: 'inherit',
env: {
...process.env,
VUE_CLI_SERVICE_CONFIG_PATH: join(__dirname, 'configs', 'vue.default.js'),
}
},
);
cb();

}

async function buildModern(cb) {
console.log("Create modern build.")
await spawnSync(
'vue-cli-service',
[
'build',
'--dest',
'dist/modern',
],
{
stdio: 'inherit',
env: {
...process.env,
VUE_CLI_SERVICE_CONFIG_PATH: join(__dirname, 'configs', 'vue.modern.js'),
}
},
);
cb();
}

exports.build = series(parallel(buildDefault, buildModern))
4. Try building
$ yarn build:gulp # Should be able to see results as follows.

build

Server - nest.js for demonstration

Using any framework or programming language to implement is fine. There are two main things to do with the server:

1. Create ServeStaticModule: ServeStaticLoader

The purpose of this loader is to:

import { Injectable } from '@nestjs/common';
import { loadPackage } from '@nestjs/common/utils/load-package.util';
import { join } from 'path';
import { AbstractHttpAdapter } from '@nestjs/core';
import { Request, Response } from 'express';
import { RegisterServeConfig } from './serve-static.type';

@Injectable()
export class ServeStaticLoader {
private getIndexFilePath(clientPath: string): string {
return join(clientPath, 'index.html');
}

public register(
httpAdapter: AbstractHttpAdapter,
options: RegisterServeConfig,

) {
const app = httpAdapter.getInstance();
const express = loadPackage('express', 'ServeStaticModule', () =>
require('express'),
);

const defaultClientPath = options.defaultBundleRootPath;
const modernClientPath = options.modernBundleRootPath;

const defaultIndexFilePath = this.getIndexFilePath(defaultClientPath);
const modernIndexFilePath = this.getIndexFilePath(modernClientPath);

const renderFn = (req: Request, res: Response) => {
if (req.modern) {
res.sendFile(modernIndexFilePath);
} else {
res.sendFile(defaultIndexFilePath);
}
};

app.use(express.static(defaultClientPath));
app.use(express.static(modernClientPath));
app.get(options.renderPath, renderFn);
}
}
2. ModernModule - ModernMiddleware

As we can see the renderFn in the first step. We have a middleware to append the modern value in the express request object. This is a simple implementation, we need to consider all the browser scenarios and complete testing.

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { UAParser } from 'ua-parser-js';

const modernBrowsersVersion = {
Chrome: 94,
Edge: 94,
Safari: 16.4,
Firefox: 93,
'Mobile Safari': 16.4,
'Mobile Chrome': 115,
};

@Injectable()
export class ModernMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const uaParser = new UAParser(req.headers['user-agent']);
console.log(uaParser.getResult());
const { name, version } = uaParser.getBrowser();
if (
modernBrowsersVersion[name] &&
parseFloat(version) >= modernBrowsersVersion[name]
) {
req.modern = true;
}
next();
}
}

es2022

import {
DynamicModule,
Inject,
Module,
MiddlewareConsumer,
} from '@nestjs/common';
import { ModernMiddleware } from './modern.middleware';

const ROUTE_CONFIG = 'ROUTES';

@Module({})
export class ModernModule {
constructor(@Inject(ROUTE_CONFIG) private readonly routes: any[]) {}

static forRoot(routes: any[]): DynamicModule {
return {
module: ModernModule,
providers: [
{
provide: ROUTE_CONFIG,
useValue: routes,
},
],
};
}

configure(consumer: MiddlewareConsumer) {
consumer.apply(ModernMiddleware).forRoutes(...this.routes);
}
}

References

3. ServeStaticModule

This module takes two parameters:

import { HttpAdapterHost } from '@nestjs/core';
import { Module, DynamicModule, OnModuleInit, Inject } from '@nestjs/common';
import { ModernModule } from '../modern/modern.module';
import { ServeStaticLoader } from './serve-static.loader';
import {
ServeStaticModuleOptions,
RegisterServeConfig,
} from './serve-static.type';

export const SERVE_STATIC_MODULE_OPTIONS = 'SERVE_STATIC_MODULE_OPTIONS';

@Module({
providers: [ServeStaticLoader],
})
export class ServeStaticModule implements OnModuleInit {
constructor(
@Inject(SERVE_STATIC_MODULE_OPTIONS)
private readonly serveOptions: RegisterServeConfig,
private readonly httpAdapterHost: HttpAdapterHost,
private readonly loader: ServeStaticLoader,

) {}

public static forRoot(options: ServeStaticModuleOptions): DynamicModule[] {
const { renderPath, rootPath } = options;

const defaultBundleRootPath = `${rootPath}/default`;
const modernBundleRootPath = `${rootPath}/modern`;

return [
ModernModule.forRoot([renderPath]),
{
module: ServeStaticModule,
providers: [
{
provide: SERVE_STATIC_MODULE_OPTIONS,
useValue: {
renderPath,
defaultBundleRootPath,
modernBundleRootPath,
},
},
],
},
];
}

public onModuleInit() {
const httpAdapter = this.httpAdapterHost.httpAdapter;
this.loader.register(httpAdapter, this.serveOptions);
}
}
4. Register the ServeStaticModule in the AppModule
import { Module } from '@nestjs/common';
import { join } from 'path';

import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ServeStaticModule } from './serve-static/serve-static.module';

@Module({
imports: [
...ServeStaticModule.forRoot({
rootPath: join(__dirname, '../../', 'consumer/dist'),
renderPath: '/portal/*',
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
5. Start the server
$ cd ./server
$ yarn start

Sum up


Either the module/nomodule pattern or my opinion provides an efficient user experience while maintaining browser compatibility. There's no definitive answer as to which is better; it depends on the use case and requirements. Personally, I think it's worth a try if it can reduce bundle size by 10% or more.





← Back home