remix-run / remix

Build Better Websites. Create modern, resilient user experiences with web fundamentals.
https://remix.run
MIT License
29.99k stars 2.53k forks source link

Image Component #257

Closed sergiodxa closed 2 years ago

sergiodxa commented 3 years ago

Next has this actually useful Image component which wraps the img tag to optimize how you use it and enforce good practices like providing a width and height to avoid layout shifts.

Remix used to have this img: prefix on the imports to get an object instead of a string and that object contained data like the width, height, srcset, placeholder, etc. based on query params. This was removed on the migration to esbuild.

If it's hard to add that way to import adding the component could be a good fallback to have a way to optimize image load.

Component Interface

The component should be used as a drop-in replacement of the img tag, like Form with form.

// Switch from this
<img src={url} alt="something useful here" width={500} height={500} />
// To this
<Image src={url} alt="something useful here" width={500} height={500} />

And just with that it should be enough to start getting the benefit. Other options could be:

interface ImageProps extends HTMLImageAttributes { // it should still support normal img attributes
  src: string;
  width: number; // required
  height: number; // required
  alt: string; // should probably be required
  loader?(options: { src: string; width: number; quality: number }): string;
  quality?: string; // range from 0 to 100
  placeholder?: string; // if defined the string should be used as placeholder, if not it should be empty while the image loads
}

The loader should also be configured in the remix.config file so you don't need to add it individually. Remix could come with some built-in image loaders for services like Cloudinary, Cloudflare Image Resizing (when creating a new project with Cloudflare adapter this could come as the default) and maybe a custom one with some hidden endpoint created by Remix.

Special import

If adding back the support to img: imports, or using assert { type: "image" } in a future, is possible the object returned by the import could come with the same interface the Image component expects, that way you can use it like this:

import guitar from "img:~/guitar.jpg?quality=80&srcset=720,1080,2048&placeholder";
// with import assertion
import guitar2 from "~/guitar.jpg?quality=80&srcset=720,1080,2048&placeholder" assert { type: "image" };

export default function Route() {
  // super simple usage
  return (
    <>
      <Image {...guitar} />
      <Image {...guitar2} />
    </>
  );
}

In this case the usage will be simplified a lot, you import and pass the imported to the component and it should magically work.

tchak commented 3 years ago

I tried to use an image loader based on query params in imports with vite.js and it was a pain because this kind of imports is impossible to type in typescript. So not sure how I feel about them. But it is definitely a problem worth solving :+1:

ryanflorence commented 3 years ago

@sergiodxa yeah that was the plan when we had the image importer. Always thinking on the same wavelength!

We're not sure that the compiler is the right place to process something as expensive as images so we decided to remove it and put it on the backburner until v1 is humming along. We have some rough ideas about how to help out with images w/o making the compiler do it. I hope we can get to sooner than later!

sergiodxa commented 3 years ago

I'm not sure how Next.js did it but when you import an image file the object you import has a specific type

interface StaticImageData {
  src: string
  height: number
  width: number
  blurDataURL?: string
}

I saw they have this:

declare module '*.png' {
  const content: StaticImageData

  export default content
}

In a global.d.ts file, not sure if that's the only thing needed to correctly type the import of an image file

tchak commented 3 years ago

Yes declare module '*.png' is what does the trick. But if you start adding query params to the import path, it doesn’t work any more. You would have to explicitly type every possible query params combination which is obviously not possible.

merodiro commented 2 years ago

Next.js also has a way to handle dynamic images with some custom loader without affecting the build time the way they handle it in run time is they use /_next/image?url=original_url&w=750&q=75 to generate images only when needed

Someone created this gist for a possible way to build it. it's just a POC https://gist.github.com/olikami/236e3c57ca73d145984ec6c127416340

Or maybe as initial implementation you could provide an integration with popular services such as cloudinary or imgix

ixartz commented 2 years ago

Also extremely interested by this feature. Gatsby and Next JS provide out of the box.

Is it a way to do it in React without meta framework like Gatsby and Next JS?

kaushalyap commented 2 years ago

Really need image optimizations!

sergiodxa commented 2 years ago

Jacob from the team created a POC of the component using a resource route to do the optimization https://gist.github.com/jacob-ebey/3a37a86307de9ef22f47aae2e593b56f

Josh-McFarlin commented 2 years ago

@sergiodxa I just released the package remix-image which can automatically create responsive images using resource routes.

https://www.npmjs.com/package/remix-image https://github.com/Josh-McFarlin/remix-image#readme

Still in trial, and many more features need to be added to meet the functionality of next/image, so feel free to help with any contributions! Right now it only add srcSet and sizes to the img element, but I plan on expanding the library to include support for layouts soon.

ccssmnn commented 2 years ago

Here's my 50 cents. I optimize everything static with Squoosh tailored to the size I want to display it. My main issue was dealing with images that come from the CMS. Clients should not need to worry about performance.

I've taken @jacob-ebey 's example and stripped it down to only serve external images with a predefined width set. For caching, I rely on the CDN instead of caching in the file system. Gonna use this until remix lands its official solution.

// entry.server.tsx
import * as serverSharp from "sharp";

export const sharp = serverSharp.default;

// routes/images.tsx

import React, { ComponentPropsWithoutRef } from "react";
import { LoaderFunction } from "remix";
import { sharp } from "~/entry.server";

const BadImageResponse = () => {
  const buffer = Buffer.from(
    "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
    "base64"
  );
  return new Response(buffer, {
    status: 500,
    headers: {
      "Cache-Control": "max-age=0",
      "Content-Type": "image/gif;base64",
      "Content-Length": buffer.length.toFixed(0),
    },
  });
};

export const loader: LoaderFunction = async ({ request }) => {
  const url = new URL(request.url);
  const src = url.searchParams.get("src");
  const width = url.searchParams.get("w");
  const quality = url.searchParams.get("q");
  if (!src || !width || !quality) {
    return BadImageResponse();
  }
  try {
    const image = await fetch(src);
    if (!image.ok || !image.body) {
      throw new Error(
        `fetching image failed. src: ${src}, status: ${image.status}`
      );
    }
    const imageBuffer = Buffer.from(await image.arrayBuffer());
    let resizedImage = await sharp(imageBuffer)
      .resize(parseInt(width))
      .webp({
        quality: parseInt(quality),
      })
      .toBuffer();
    return new Response(resizedImage, {
      status: 200,
      headers: {
        "Cache-Control": `max-age=${60 * 60 * 24 * 365}, public`,
        "Content-Type": "image/webp",
        "Content-Length": resizedImage.length.toFixed(0),
      },
    });
  } catch (error) {
    console.error(error);
    return BadImageResponse();
  }
};

const widths = [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
const quality = 75;

export const Img: React.FC<ComponentPropsWithoutRef<"img">> = (props) => {
  if (!props.src) {
    throw new Error("no src provided to Img component");
  }
  if (props.srcSet) {
    console.warn("srcSet will be overwritten by srcSetGenerator");
  }
  const srcSetParts = widths.map(
    (width) => `/images?src=${props.src}&w=${width}&q=${quality} ${width}w`
  );
  return (
    <img {...props} srcSet={srcSetParts.join(", ")} src={srcSetParts[0]} />
  );
};

I think this is also a very basic & understandable approach, which others can use and extend to fit their needs, e.g. providing values for width, height and sizes via props. For me, this fire and forget img wrapper is perfect.

arekbartnik commented 2 years ago

@sergiodxa Next’s Image has also the priority prop. It adds link rel=preload to head to improve LCP.

sergiodxa commented 2 years ago

@sergiodxa Next’s Image has also the priority prop. It adds link rel=preload to head to improve LCP.

Because there’s no Head component in Remix that will not be possible I imagine, if you want to preload the image you can add it to the LinksFunction of the route.

zwily commented 2 years ago

Because there’s no Head component in Remix that will not be possible I imagine, if you want to preload the image you can add it to the LinksFunction of the route.

How does the <Link prefetch="render" /> stuff work then? Couldn't we do something similar?

arekbartnik commented 2 years ago

@sergiodxa Next’s Image has also the priority prop. It adds link rel=preload to head to improve LCP.

Because there’s no Head component in Remix that will not be possible I imagine, if you want to preload the image you can add it to the LinksFunction of the route.

I think it should be done at component level. Otherwise it will be too hard to implement it.

sergiodxa commented 2 years ago

Because there’s no Head component in Remix that will not be possible I imagine, if you want to preload the image you can add it to the LinksFunction of the route.

How does the <Link prefetch="render" /> stuff work then? Couldn't we do something similar?

It renders the <link rel="prefetch" /> tags below the <a> not in the <head> but the difference is that you are prefetching data and assets for the next page, doing this:

<link rel="preload" as="image" href="/something.png" />
<img src="/something.png" />

Will not give you a lot of benefits because it's going to trigger the preload just when it's about to find the img tag so don't optimize anything. The point of preload would be to put it in the <head> so before it starts to parse the body it can start loading the image immediately and when you do <Link prefetch="render" /> (or prefetch="intent") it will prefetch the image before the page navigate.

zwily commented 2 years ago

Will not give you a lot of benefits because it's going to trigger the preload just when it's about to find the img tag so don't optimize anything. The point of preload would be to put it in the <head> so before it starts to parse the body it can start loading the image immediately and when you do <Link prefetch="render" /> (or prefetch="intent") it will prefetch the image before the page navigate.

Makes sense. Surely we can figure out a way to allow components down tree to jam themselves up into <head> right? Requiring someone to specify the images to preload in links() seems kind of sad, especially since we want to put the generated imagesrcset in there, not just the image path.

Knaackee commented 2 years ago

@ryanflorence any news on this?

Thanks for your great work!