惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Tailwind CSS Blog
The GitHub Blog
The GitHub Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog
Microsoft Security Blog
Microsoft Security Blog
Stack Overflow Blog
Stack Overflow Blog
量子位
Martin Fowler
Martin Fowler
月光博客
月光博客
P
Proofpoint News Feed
博客园_首页
Y
Y Combinator Blog
I
InfoQ
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
V
Visual Studio Blog
H
Help Net Security
U
Unit 42
GbyAI
GbyAI
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 司徒正美
MongoDB | Blog
MongoDB | Blog
F
Fortinet All Blogs
罗磊的独立博客
酷 壳 – CoolShell
酷 壳 – CoolShell

Deno

Deno 2.8 | Deno Claw Patrol: an open-source security firewall for agents | Deno Fresh 2.3: Zero JS by default, View Transitions, and Temporal support | Deno Deno 2.7: Temporal API, Windows ARM, and npm overrides | Deno Build a dinosaur runner game with Deno, pt. 6 | Deno Build a dinosaur runner game with Deno, pt. 5 | Deno Deno Deploy is Generally Available | Deno Introducing Deno Sandbox | Deno Build a dinosaur runner game with Deno, pt. 4 | Deno Build a dinosaur runner game with Deno, pt. 3 | Deno Build a dinosaur runner game with Deno, pt. 2 | Deno React / Next.js Denial-of-Service Vulnerability: Deno Deploy users protected | Deno Deno 2.6: dx is the new npx | Deno Build a dinosaur runner game with Deno, pt. 1 | Deno React Server Functions / Next.js Vulnerability: Deno Deploy users protected | Deno My highlights from the new Deno Deploy | Deno Deno's Other Open Source Projects | Deno How Deno protects against npm exploits | Deno Help Us Raise $200k to Free JavaScript from Oracle | Deno Deno 2.5: Permissions in the config file | Deno Fresh 2.0 Graduates to Beta, Adds Vite Support | Deno Deno 2.4: deno bundle is back | Deno JavaScript™ Trademark Update | Deno What's coming to JavaScript | Deno A brief history of JavaScript | Deno Reports of Deno's Demise Have Been Greatly Exaggerated | Deno An Update on Fresh | Deno How Plaid migrated 100 services to a new database platform 5x faster with Deno | Deno Deno 2.3: Improved deno compile, local npm packages, and more | Deno Add JSR packages with pnpm and Yarn | Deno
A Gentle Introduction to Islands | Deno
Andy Jiang · 2023-04-26 · via Deno

Modern JavaScript frameworks include a lot of JavaScript. That’s kinda obvious.

But most web sites don’t contain a lot of JavaScript.

Some do. If you’re building a dynamic interactive dashboard, JS your heart out. On the other end, documentation pages, blogs, static content sites, etc. require 0 JavaScript. This blog, for example, has no JS.

But then there’s this whole horde of websites in the middle that need some interactivity, but not a lot:

the goldilocks of javascript

These goldilocks “just enough” sites are a problem for frameworks: you can’t statically generate these pages, but bundling an entire framework and sending it over a network for one image carousel button seems overkill. What can we do for these sites?

Give them islands.

What are Islands?

Here’s how this looks on our merch site which is built with Fresh, our Deno-based web framework that uses islands:

islands of interactivity on our merch site

The main part of the page is static HTML: the header and footer, the headings, links, and text. None of these require interactivity so none use JavaScript. But three elements on this page do need to be interactive:

  • The ‘Add to Cart’ button
  • The image carousel
  • The cart button

These are the islands. Islands are isolated Preact components that are then hydrated on the client within a statically-rendered HTML page.

  • Isolated: these components are written and shipped independently of the rest of the page.
  • Preact: a tiny 3kb alternative to React, so even when Fresh is shipping islands it is still using the minimal amount of JS.
  • Hydration: how JavaScript gets added to a client-side page from a server-rendering.
  • Statically-rendered HTML page: The basic HTML with no JavaScript that is sent from the server to the client. If no islands were used on this page, only HTML would be sent.

The key part of that is the hydration. This is where JavaScript frameworks are struggling because it’s fundamental to how they work, but at the same time hydration is pure overhead.

JS frameworks are hydrating a page. Island frameworks are hydrating components.

The problem with hydration - “Hydrate level 4, please”

Why does so much JavaScript get sent without islands? It’s a function of the way modern ‘meta’ JavaScript frameworks work. You use the frameworks to both create your content and add interactivity to your pages, send them separately, then combine them in a technique called ‘Hydration’ in the browser.

In the beginning, these were separated. You had a server-side language producing the HTML (PHP, Django, then NodeJS) and client-side plugins providing interactivity (jQuery being the most prevalent). Then we got to the React SPA era and everything was client side. You shipped a bare bones HTML skeleton and the whole site, with content, data, and interactivity was generated on the client.

Then pages got big and SPAs got slow. SSR came back, but with the interactivity added without the plugins through the same codebase. You create your entire app in JS, then during the build step the interactivity and initial state of the app (the state of your components along with any data pulled server-side from APIs) are serialized and bundled into JS and JSON.

Whenever a page is requested, the HTML is sent along with the bundle of JS needed for interactivity and state. The client then ‘hydrates’ the JS, which means:

  • Traverse the entire DOM from a root node
  • For every node, attach an event listener if the element is interactive, add the initial state, and re-render. If the node isn’t supposed to be interactive (e.g. an h1), reuse the node from the original DOM and reconcile.

This way, the HTML is shown quickly so the user isn’t staring at a blank page and then the page becomes interactive once the JS has loaded

You can think about hydration like this:

Hydrate level four please! Back to the Future, Part 2

The build step takes all the juicy parts out of your application, leaving you with a dry husk. You can then ship that dry husk along with a separate gallon of water to be combined by your client’s Black & Decker hydrator browser. This gets you an edible pizza/usable site back (h/t to this SO answer for that analogy).

What’s the problem with this? Hydration treats the page as one single component. Hydration takes place top-down and traverses through the entire DOM to find the nodes it needs to hydrate. Even though you’re decomposing your app into components in development, that is thrown away and everything is bundled together and shipped.

These frameworks also ship framework-specific JavaScript. If we create a new next app and remove everything but an h1 on the index page, we still see JS being sent to the client, including a JS version of the h1, even when the build process says this page is statically generated:

Hello from Next

Code-splitting and progressive hydration are workarounds for this fundamental problem. They break up the initial bundle and the hydration into separate chunks and steps. This should make the page interactive quicker, as you can start hydrating from the first chunk before the rest is downloaded.

But you are still ultimately sending all this JavaScript to a client that may not be using it, and has to process it to find out if it’s going to use it.

How Islands work in Fresh

If we do something similar with Fresh, our Deno-based web framework we see zero JavaScript:

Hello from Fresh without JavaScript

Nothing on the page requires JS, so no JS was sent.

Now let’s add in some JavaScript in the shape of an island:

Hello from Fresh with islands

So we have three JavaScript files:

  • chunk-A2AFYW5X.js
  • island-counter.js
  • main.js

To show how those JS files appeared, here’s a timeline of what happens when a request is received:

Timeline of events from request

Note that this timeline is for the first request to a Fresh app. After the assets are cached, subsequent requests simply retrieve necessary scripts from the cache.

Let’s dig into the key steps that make islands work.

Check manifest from fresh.gen.ts for islands

The first step to locating any islands is to check the manifest from fresh.gen.ts. This is an auto-generated doc in your own app that lists the pages and islands in the app:



import config from "./deno.json" with { type: "json" };
import * as $0 from "./routes/index.tsx";
import * as $$0 from "./islands/Counter.tsx";

const manifest = {
  routes: {
    "./routes/index.tsx": $0,
  },
  islands: {
    "./islands/Counter.tsx": $$0,
  },
  baseUrl: import.meta.url,
  config,
};

export default manifest;

The Fresh framework processes the manifest into individual pages (not shown here) and components. Any islands are pushed into an islands array:




for (const [self, module] of Object.entries(manifest.islands)) {
  const url = new URL(self, baseUrl).href;
  if (typeof module.default !== "function") {
    throw new TypeError(
      `Islands must default export a component ('${self}').`,
    );
  }
  islands.push({ url, component: module.default });
}

Replace each island with a unique HTML comment during server-side rendering

During server rendering with render.ts, Preact creates a virtual DOM. As each virtual node is created, the options.vnode hook is called in Preact:



options.vnode = (vnode) => {
  assetHashingHook(vnode);
  const originalType = vnode.type as ComponentType<unknown>;
  if (typeof vnode.type === "function") {
    const island = ISLANDS.find((island) => island.component === originalType);
    if (island) {
      if (ignoreNext) {
        ignoreNext = false;
        return;
      }
      ENCOUNTERED_ISLANDS.add(island);
      vnode.type = (props) => {
        ignoreNext = true;
        const child = h(originalType, props);
        ISLAND_PROPS.push(props);
        return h(
          `!--frsh-${island.id}:${ISLAND_PROPS.length - 1}--`,
          null,
          child,
        );
      };
    }
  }
  if (originalHook) originalHook(vnode);
};

The function options.vnode can mutate the vnode before it’s rendered. Most vnodes (e.g., <div>) are rendered as expected. But if the vnode is both a function and has the same type of function as an element in the islands array (therefore, is the original node for the island), the vnode is wrapped in two HTML comments:



// the island vnode

</!--frsh-${island.id}:${ISLAND_PROPS.length - 1}-->

For the nerds out there, despite the fact that the closing comment, </!-- xxx --> is not valid HTML, the browser still parses and renders this correctly.

This info is also added to an ENCOUNTERED_ISLANDS set.

In our case, the title and the lemon image will render as expected, but once the vnode for Counter is created, the HTML comment !--frsh-counter:0-- is inserted.

(The reason why Fresh uses a comment (vs. a <div> or a custom element) is because introducing a new element can sometimes disturb the styling and layout on the page, leading to CLS issues.)

Dynamically generate hydration scripts

The next step is to generate hydration scripts based on the islands detected, based on all of the islands added to the set ENCOUNTERED_ISLANDS.

In render.ts, if ENCOUNTERED_ISLANDS is greater than 0, then we’ll add an import for the revive function from main.js to the hydration script that’ll be sent to the client:



if (ENCOUNTERED_ISLANDS.size > 0) {

  script += `import { revive } from "${bundleAssetUrl("/main.js")}";`;

Note that if ENCOUNTERED_ISLANDS is 0, the entire islands part is skipped and zero JavaScript is shipped to the client-side.

Then, the render function adds each island’s JavaScript (/island-${island.id}.js) to an array and its import line to script:



  let islandRegistry = "";
  for (const island of ENCOUNTERED_ISLANDS) {
    const url = bundleAssetUrl(`/island-${island.id}.js`);
    script += `import ${island.name} from "${url}";`;
    islandRegistry += `${island.id}:${island.name},`;
  }
  script += `revive({${islandRegistry}}, STATE[0]);`;
}

By the end of the render function, script, which is a string of import statements followed by the revive() function, is added to the body of the HTML. On top of that, the imports array with the URL path of each island’s JavaScript is rendered into an HTML string.

Here’s what the script string looks like when it’s loaded into the browser:

<script type="module">
  const STATE_COMPONENT = document.getElementById("__FRSH_STATE");
  const STATE = JSON.parse(STATE_COMPONENT?.textContent ?? "[[],[]]");
  import { revive } from "/_frsh/js/1fx0e17w05dg/main.js";
  import Counter from "/_frsh/js/1fx0e17w05dg/island-counter.js";
  revive({ counter: Counter }, STATE[0]);
</script>

When this script is loaded into the browser, it’ll execute the revive function from main.js to hydrate the Counter island.

The browser runs revive

The revive function is defined in main.js (which is the minified version of main.ts). It traverses a virtual dom searching for a regex match to identify any of the HTML comments Fresh inserted in the earlier step.



function revive(islands, props) {
  function walk(node) {
    let tag = node.nodeType === 8 &&
        (node.data.match(/^\s*frsh-(.*)\s*$/) || [])[1],
      endNode = null;
    if (tag) {
      let startNode = node,
        children = [],
        parent = node.parentNode;
      for (; (node = node.nextSibling) && node.nodeType !== 8;) {
        children.push(node);
      }
      startNode.parentNode.removeChild(startNode);
      let [id, n] = tag.split(":");
      re(
        ee(islands[id], props[Number(n)]),
        createRootFragment(parent, children),
      ), endNode = node;
    }
    let sib = node.nextSibling,
      fc = node.firstChild;
    endNode && endNode.parentNode?.removeChild(endNode),
      sib && walk(sib),
      fc && walk(fc);
  }
  walk(document.body);
}
var originalHook = d.vnode;
d.vnode = (vnode) => {
  assetHashingHook(vnode), originalHook && originalHook(vnode);
};
export { revive };

If we look in our index.html we’ll see we have that comment that would match that regex:

When revive finds a comment, it calls the createRootFragment with Preact’s render / h to render the component.

And now you have an island of interactivity ready to go on the client-side!

Islands in other frameworks

Fresh isn’t the only framework that uses islands. Astro also uses islands, though in a different configuration where you specify how you want each component to load its JavaScript. For instance, this component would load 0 JS:

But add a client directive and it’ll now load with JS:

<MyReactComponent client:load />

Other frameworks such as Marko use partial hydration. The difference between islands and partial hydration is subtle.

In islands, it’s explicit to the developer and framework which component will be hydrated and which won’t. In Fresh, for instance, the only components that ship JavaScript are the ones in the islands directory with CamelCase or kebab-case naming.

With partial hydration, components are written normally and the framework, during the build process, determines which JS should ship during the build process.

Other answers to this problem are React Server Components, which underpin NextJS’ new /app directory structure. This helps more clearly define work done on the server and work done on the client, though whether it reduces the amount of JS in the shipped bundle is still up for debate.

The most exciting development apart from islands is Quik’s resumability. They remove hydration completely and instead serialize the JavaScript within the HTML bundle. Once the HTML gets to the client the entire app is good to go, interactivity and all.

Islands in a stream

It might be possible to combine islands and resumability to ship little JS and remove hydration.

But there is more to islands than just smaller bundles. A huge benefit of the islands architecture is the mental model it makes you develop. You have to opt-in to JavaScript with islands. You can never ship JavaScript to the client by mistake. As a developer builds an app, every inclusion of interactivity and JavaScript is an explicit choice by the developer.

That way, it’s not the architecture and the framework that is shipping less JavaScript–it’s actually you, the developer.

Don’t miss any updates — follow us on Twitter.