JavaScript is dead — Build Web, iOS and Android apps with code sharing

Let's poke fun at JavaScript being dead when sharing code between Web, iOS and Android apps within an Nx workspace while achieving optimal user experience targets.

Nathan Walker
Posted on

Let's poke fun at JavaScript being dead when sharing code between Web, iOS and Android apps within an Nx workspace to achieve optimal user experience targets with the following goals:

  • A Web app with a 98+ PageSpeed score
  • An iOS and Android app with natural platform "looks and feels" representing the best of what each respective platform is capable of.

We aim to share code to make our development process consistent and efficient to build this Maxi Ferreira and Addy Osmani inspired example for their amazing "Death by JavaScript" series:

Source code: https://github.com/NathanWalker/nx-javascriptisdead

With multiple deployment targets, Nx is a first class workspace tool for our team to do this. Let's build!

Prepare our workspace

bash
npx create-nx-workspace@latest

We'll answer the prompts with the following:

bash
? Where would you like to create your workspace? › dead

? Which stack do you want to use?
None:  Configures a TypeScript/JavaScript project with minimal structure.

? Package-based monorepo, integrated monorepo, or standalone project?
Integrated Monorepo: Nx creates a monorepo that contains multiple projects.

? Enable distributed caching to make your CI faster
Yes I want faster builds

# After creation, we can navigate into our workspace:
cd dead

Setup desired tooling

We'll now prepare some helpful tooling to get up and running fast. We're going to use yarn for package management to help our workspace dependencies. We'll make sure we have yarn globally installed:

bash
npm install -g yarn

Init deadly JavaScript

We're going to be driving our workspace primarily with TypeScript so let's initialize it with:

bash
yarn nx g @nx/js:init

Create a Web app

Let's start by creating our web experience.

We'll setup an Analog powered web app! Let's install the tools:

bash
yarn add @analogjs/platform --dev

Now we can create the app:

bash
yarn nx g @analogjs/platform:app

? What name would you like to use for your AnalogJs app?
web-store

? Add TailwindCSS for styling? (Y/n)
true

? Add tRPC for typesafe client/server interaction? (y/N)
false

If we were setting up a backend we may opt to use tRPC but for this example we'll just setup a simple service to share.

We can now develop our web app with:

bash
yarn nx run web-store:serve

Install cross platform tools

We know we want some flexibility with cross platform deployment options so we'll arm ourselves with xplat:

bash
yarn add @nstudio/xplat --dev

Add an iOS and Android app with NativeScript

We'll go ahead and add a NativeScript app which can share all our code to achieve all those natural mobile platform "looks and feels" we're after here.

bash
yarn nx g @nstudio/xplat:app

? What name would you like for this app?
store

? What type of app would you like to create?
nativescript    [NativeScript app]

? In which directory should the app be generated? › apps

? Would you like to configure routing for this app? (Y/n) · true

? Which frontend framework should it use?
 angular        [Angular app]

? Use xplat supporting architecture? (Y/n) › true

We will opt to use "xplat supporting architecture" to share code quickly with several things already setup for us to save some time however it is purely optional.

Note

After installing tooling and generating apps, it's a good idea to run yarn clean to ensure your workspace setup is all complete with settled dependencies before developing further.

We can now develop our iOS and Android app using:

bash
yarn nx run nativescript-store:ios

yarn nx run nativescript-store:android

Generate shared code

We are ready to build with seamless code sharing fully at our disposal.

Create a shared service

Let's start by generating a service that will drive the entire store — it will provide our images to sell! Because this will be a cross platform service shareable across our Analog web app as well as our iOS and Android apps made possible by NativeScript we can use the following generator from the xplat tooling:

bash
yarn nx g @nstudio/angular:service dead

This will create an Angular service ready to be shared across our platform targets:

bash
CREATE libs/xplat/core/src/lib/services/dead.service.ts

This allows us to use it easily:

ts
import { DeadService } from '@dead/xplat/core'

The import path has meaning in that it tells us:

  • @dead: it's part of our workspace scope
  • xplat: it's part of cross platform code sharing
  • core: it's considered a core piece of functionality in our workspace architecture

It's implementation can be defined as follows:

ts
import { Injectable } from '@angular/core'
import { isNativeScript } from '@dead/xplat/utils'

@Injectable({
  providedIn: 'root',
})
export class DeadService {
  imageBaseUrl = `https://deathbyjavascript.com/img`

  get images() {
    return Array.from(
      { length: 225 },
      (x, i) => `${this.imageBaseUrl}/${i}.${isNativeScript() ? 'jpg' : 'webp'}`
    )
  }
}

This is a simple service that will return the full set of 225 "Death by JavaScript" images for view data binding usage.

We'll see a built-in xplat utility coming in handy allowing us to conditionally use .jpg images for optimized display on mobile platforms vs. .webp on the web.

Data bind our views

With a data service now ready for our usage, let's bind our views to use it.

Web data binding views

We can setup the predefined analog-welcome.component to use our service:

ts
import { Component, inject } from '@angular/core'
import { CommonModule, NgOptimizedImage } from '@angular/common'
import { DeadService } from '@dead/xplat/core'

@Component({
  selector: 'dead-store',
  standalone: true,
  imports: [CommonModule, NgOptimizedImage],
  host: {
    class: 'grid grid-cols-4 gap-2 p-4 bg-[#f7f5ee]',
  },
  template: `
    <div *ngFor="let image of deadService.images">
      <img
        [alt]="image"
        [ngSrc]="image"
        width="300"
        height="300"
        class="rounded-lg duration-300 ease-in-out hover:scale-95"
      />
    </div>
  `,
})
export class AnalogWelcomeComponent {
  deadService = inject(DeadService)
}

This takes advantage of NgOptimizedImage for image handling.

iOS and Android data binding views

To handle multi-column layout with our collection views on iOS and Android, we'll use @nativescript-community/ui-collectionview and for nice image handling, we'll use @triniwiz/nativescript-image-cache-it so we can prepare by installing the plugins at the root of our workspace:

bash
yarn add @nativescript-community/ui-collectionview @triniwiz/nativescript-image-cache-it -W

For NativeScript dependencies, we also want to ensure the respective iOS and Android platform modules are included so we'll ensure we adjust apps/nativescript-store/package.json as follows:

json
"dependencies": {
  "@nativescript-community/ui-collectionview": "*",
  "@triniwiz/nativescript-image-cache-it": "*"

This will allow yarn to share these dependencies with our entire workspace so we can use it throughout shared code (eg, in libs as well as apps). It also informs the NativeScript CLI to build the platform dependencies into the respective iOS and Android deployment targets.

Note

Using "*" within NativeScript package.json dependencies inside a workspace is a good strategy when using latest versions in the root package.json. When using specific versions (like when pinning or using prerelease next, alpha, beta, rc tagged versions) you'll want to match the version between root package and NativeScript package.json.

We can now setup the predefined home.component to use our service:

ts
import { Component, inject } from '@angular/core'
import { ObservableArray } from '@nativescript/core'
import { CollectionViewItemEventData } from '@nativescript-community/ui-collectionview'
import { DeadService } from '@dead/xplat/core'

@Component({
  template: `<ActionBar title="Nx + NativeScript with xplat" />
    <GridLayout class="pr-2">
      <CollectionView
        [items]="images"
        colWidth="50%"
        rowHeight="28%"
        scrollBarIndicatorVisible="false"
        (itemTap)="itemTap($event)"
        iosOverflowSafeArea="true"
      >
        <ng-template let-image="item">
          <GridLayout class="pl-2 pt-2">
            <ImageCacheIt
              [src]="image"
              transition="fade"
              stretch="aspectFill"
              class="h-full w-full rounded-lg"
            />
          </GridLayout>
        </ng-template>
      </CollectionView>
    </GridLayout> `,
})
export class HomeComponent {
  deadService = inject(DeadService)
  images = new ObservableArray(this.deadService.images)
}

We notably bind our CollectionView to an ObservableArray using our deadService.images. This optimizes the CollectionView with it's data source.

Generate a detail view

We want to allow the user to select an image to view it larger. We can use our workspace code generators to scaffold the setup quickly.

Web creation of a detail route

We can use the VS Code extension Nx Console to browse generators in our workspace. For the web route generation we can use Analog's generators:

bash
yarn nx g @analogjs/platform:page --pathname=detail --project=web-store

Nx ConsoleNx Analog create page

We can now wire up our page navigation to view our selected image by adding routerLink on the img:

html
<img
  [alt]="image"
  [ngSrc]="image"
  [routerLink]="['/detail', i]"
  width="300"
  height="300"
  class="rounded-lg duration-300 ease-in-out hover:scale-95"
/>

Our new DetailComponent can be represented as follows:

ts
@Component({
  selector: 'dead-detail',
  standalone: true,
  imports: [CommonModule, NgOptimizedImage],
  host: {
    class: 'bg-black',
  },
  template: `
    <img
      [alt]="image | async"
      [ngSrc]="image | async"
      class="h-full w-full bg-black object-contain"
      fill
      priority
    />
  `,
})
export class DetailComponent {
  deadService = inject(DeadService)
  image = inject(ActivatedRoute).params.pipe(
    take(1),
    map((p) => this.deadService.images[+p['id']])
  )
}

Once Angular 17 is released we can enable View Transitions API which will be great!

iOS and Android creation of a detail route

We can use the various code generators that come with xplat which contain a full set of NativeScript generators. Since we're using Angular, we can use @nstudio/angular to create various sections of code. To create a detail route we can use the feature generator:

bash
yarn nx g @nstudio/angular:feature detail --onlyProject=true --projects=nativescript-store --routing=true

Nx NativeScript create page

We can now wire up our page navigation to view our selected image.

In our HomeComponent we can navigate to this new detail route and we'll even enable some nice Shared Element Transitions:

ts
import { RouterExtensions } from '@nativescript/angular'
// ...

export class HomeComponent {
  deadService = inject(DeadService)
  router = inject(RouterExtensions)
  images = new ObservableArray(this.deadService.images)

  itemTap(args: CollectionViewItemEventData) {
    this.router.navigate(['/detail', args.index], {
      transition: SharedTransition.custom(new PageTransition(), {
        pageStart: {
          x: 0,
          y: 0,
        },
        pageEnd: {
          duration: isAndroid ? 300 : null,
        },
        pageReturn: {
          duration: isAndroid ? 150 : null,
          x: 0,
          y: 0,
        },
      }),
    })
  }
}

On iOS, we can use the nice default spring settings by leaving duration to null whereby on Android we will use linear animation which feels nice there. We also customize the starting and return points of the detail page to customize how it appears in the transition.

For the detail page we can setup a simple image viewer that allows back navigation:

ts
@Component({
  template: `<GridLayout class="bg-black">
    <ImageCacheIt
      [src]="image | async"
      [sharedTransitionTag]="image | async"
      transition="fade"
      stretch="aspectFit"
      class="h-full w-full rounded-lg"
    />
    <Button
      class="fa ml-2 mt-2 text-left align-top text-2xl text-white"
      (tap)="back()"
      >{{ 'fa-close' | fonticon }}</Button
    >
  </GridLayout>`,
})
export class DetailComponent {
  deadService = inject(DeadService)
  image = inject(ActivatedRoute).params.pipe(
    take(1),
    map((p) => this.deadService.images[+p['id']])
  )
  router = inject(RouterExtensions)
  page = inject(Page)

  constructor() {
    this.page.actionBarHidden = true
  }

  back() {
    this.router.back()
  }
}

We could spruce this page up by adding @triniwiz/nativescript-image-zoom as well to allow user zoom/pan on the image!

Improve consistency by sharing more code

Our detail routes have logic which can be reduced to a simple utility to share across Web, iOS and Android apps.

Code duplication

We can add a utility method to our xplat/core/../dead.service for that:

ts
export function fetchDetailRouteImage() {
  const deadService = inject(DeadService)
  return inject(ActivatedRoute).params.pipe(
    take(1),
    map((p) => deadService.images[+p['id']])
  )
}

Which really simplifies our Web, iOS and Android DetailComponent to just use it from a single shareable import: import { fetchDetailRouteImage } from '@dead/xplat/core':

Nx code sharing heaven

Code Sharing Heaven 🕊😌

Nx provides for an incredible way to work on projects with large teams or even individually. With xplat you can expand even more possibilities to realize the sky truly is the limit.

Source code example repo

Take things for a spin: https://github.com/NathanWalker/nx-javascriptisdead

Next Steps?

If you need help on your projects, we would be happy to help.


More from our Blog