About ES Script
03 Aug, 2023
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
- We can have two builds of the client application, one for the older browsers, the other for the modern browsers.
- 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.
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
- We may have to have two build configs, but it provides more flexibility to customize each config, rather than sharing the same config.
- Pattern module/nomodule lets browsers decide which to use, we can take back the decisions right from the browsers.
Cons
- Require a server.
- UA detection might be difficult and can be prone to false classification.
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.
- Use gulp to build default and modern in parallel.
- Have different vue.config for each build.
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.
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:
- Serving the
default
andmodern
builds. - A middleware to parse the user agent.
1. Create ServeStaticModule: ServeStaticLoader
The purpose of this loader is to:
- Serve two builds by using
express.static
:- defaultBundleRootPath
- modernBundleRootPath
- Register the route
renderPath
by usingexpress.get
.
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();
}
}
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:
- rootPath: Static files root directory, should include
default
&modern
build folders. - renderPath: Path to render static application
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