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

推荐订阅源

WordPress大学
WordPress大学
Security Latest
Security Latest
C
Cisco Blogs
P
Palo Alto Networks Blog
Know Your Adversary
Know Your Adversary
Project Zero
Project Zero
C
Cyber Attacks, Cyber Crime and Cyber Security
NISL@THU
NISL@THU
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
S
Secure Thoughts
P
Privacy International News Feed
V
Vulnerabilities – Threatpost
D
Docker
Google Online Security Blog
Google Online Security Blog
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
Recent Announcements
Recent Announcements
T
The Exploit Database - CXSecurity.com
G
Google Developers Blog
Schneier on Security
Schneier on Security
小众软件
小众软件
爱范儿
爱范儿
GbyAI
GbyAI
J
Java Code Geeks
T
Tailwind CSS Blog
Cisco Talos Blog
Cisco Talos Blog
The Hacker News
The Hacker News
D
DataBreaches.Net
Blog — PlanetScale
Blog — PlanetScale
TaoSecurity Blog
TaoSecurity Blog
MyScale Blog
MyScale Blog
B
Blog RSS Feed
Cyberwarzone
Cyberwarzone
有赞技术团队
有赞技术团队
Martin Fowler
Martin Fowler
C
CXSECURITY Database RSS Feed - CXSecurity.com
S
Securelist
L
Lohrmann on Cybersecurity
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Y
Y Combinator Blog
S
Schneier on Security
Latest news
Latest news
Apple Machine Learning Research
Apple Machine Learning Research
博客园 - 叶小钗
F
Fortinet All Blogs
M
MIT News - Artificial intelligence
PCI Perspectives
PCI Perspectives
V
V2EX
V2EX - 技术
V2EX - 技术
O
OpenAI News
W
WeLiveSecurity

Kent C. Dodds Blog

Implementing Hybrid Semantic + Lexical Search Simplifying Containers with Cloudflare Sandboxes Migrating to Workspaces and Nx Offloading FFmpeg with Cloudflare Building Semantic Search on my Content Helping YOU ask ME questions with AI How I used Cursor to Migrate Frameworks The Dow's Start on the Covenant Path 2025 in Review The next chapter: EpicAI.pro AI is taking your job How I increased my visibility Launching Epic Web 2023 in Review Stop Being a Junior RSC with Dan Abramov and Joe Savona Live Stream Fixing a Memory Leak in a Production Node.js App 2022 in Review My Car Accident I Migrated from a Postgres Cluster to Distributed SQLite with LiteFS I'm building EpicWeb.dev A review of my time at Remix Remix: The Yang to React's Yin How I help you build better websites Why I Love Remix The State Initializer Pattern How to React ⚛️ Get a catch block error message with TypeScript How Remix makes CSS clashes predictable Introducing the new kentcdodds.com How I built a modern website in 2021 How to use React Context effectively Static vs Unit vs Integration vs E2E Testing for Frontend Apps The Testing Trophy and Testing Classifications Array reduce vs chaining vs for loop Don't Solve Problems, Eliminate Them Super Simple Start to Remix Super Simple Start to ESModules in Node.js JavaScript Pass By Value Function Parameters How to write a Constrained Identity Function (CIF) in TypeScript How to optimize your context value How to write a React Component in TypeScript TypeScript Function Syntaxes Listify a JavaScript Array Build vs Buy: Component Libraries edition Using fetch with TypeScript Wrapping React.useState with TypeScript Define function overload types with TypeScript 2020 in Review Business and Engineering alignment Hi, thanks for reaching out to me 👋 useEffect vs useLayoutEffect Super simple start to Firebase functions Super simple start to Netlify functions Super Simple Start to css variables Favor Progress Over Pride in Open Source Testing Implementation Details How getting into Open Source has been awesome for me useState lazy initialization and function updates Use ternaries rather than && in JSX Application State Management with React Use react-error-boundary to handle errors in React JavaScript to Know for React How I structure Express apps What open source project should I contribute to? When I follow TDD AHA Programming 💡 How I Record Educational Videos Should I write a test or fix a bug? Stop mocking fetch Intentional Career Building Improve test error messages of your abstractions Tracing user interactions with React Eliminate an entire category of bugs with a few simple tools Common mistakes with React Testing Library Super Simple Start to React Stop using client-side route redirects The State Reducer Pattern with React Hooks Function forms Replace axios with a simple custom fetch wrapper How to test custom React hooks React Production Performance Monitoring Should I useState or useReducer? Stop using isLoading booleans Make Your Test Fail Make your own DevTools An Argument for Automation Fix the "not wrapped in act(...)" warning Super Simple Start to ESModules in the Browser Implementing a simple state machine library in JavaScript 2010s Decade in Review Why users care about how you write code Why I avoid nesting closures Don't call a React function component Why your team needs TestingJavaScript.com Inversion of Control Understanding React's key prop How to Enable React Concurrent Mode How to add testing to an existing project Profile a React App for Performance
Building an awesome image loading experience
Kent C. Dodds 🏹 @kentcdodds · 2021-10-19 · via Kent C. Dodds Blog

You may have noticed when opening various pages on my site (especially blog posts) that the images start as blurry and then the full image fades-in once it's loaded. Here's a video to demonstrate that experience:

I want to walk you through some of the things I had to do for this to work, and compare some of what I'm doing to what the ultimate image app (unsplash.com) does.

Layout shift

First of all, you'll notice that the image doesn't pop into place and cause a reflow/layout shift. In fact, I've got a 100/100 on the "Cumulative Layout Shift" web vitals score 😊. I do this by specifying the size of the area that holds the image via the aspect-ratio support in tailwind (my site uses tailwind btw 😅).

TL;DR:

<div class="aspect-[3/4] md:aspect-[3/2]">
	<img src="..." alt="..." class="..." />
</div>

That's all I need to make sure I don't get a bunch of layout shift while the image is loading (read more about this in Setting Height And Width On Images Is Important Again).

sizes, srcset, and Cloudinary

Another important aspect of making the image load fast is ensuring that you're only loading the size of image that you need. If you've got an image that's 3000x3000 and rendering that onto a retina screen in a 600x600 square, then you're serving 1800x1800 too many pixels! (retina means double-the pixels).

This is where the img tag's sizes and srcset attributes come to into play. The TL;DR of these attributes is that it allows you to tell the browser different versions of your image for different screen widths (srcset) and what size the image should be for a given set of media queries (sizes). Here's the example from MDN:

<img
	src="/files/16870/new-york-skyline-wide.jpg"
	srcset="
		/files/16870/new-york-skyline-wide.jpg 3724w,
		/files/16869/new-york-skyline-4by3.jpg 1961w,
		/files/16871/new-york-skyline-tall.jpg 1060w
	"
	sizes="((min-width: 50em) and (max-width: 60em)) 50em,
              ((min-width: 30em) and (max-width: 50em)) 30em,
              (max-width: 30em) 20em"
/>

What this says is that when the screen width is between 50em and 60em then the image will be 50em. So then the browser can determine the best image to load for that image size from the srcset you gave it. And look at that progressive enhancement! If the browser doesn't support these attributes it'll just use the src attribute like usual.

Unsplash uses this feature a great deal and so do I. But creating all those sizes of images would be an enormous pain, that's why I use cloudinary!

Here's what my img tag looks like for a blog post:

<img
	title="Photo by Kari Shea"
	class="z-10 rounded-lg object-cover object-center transition-opacity"
	alt="MacBook Pro on top of brown table"
	src="https://res.cloudinary.com/kentcdodds-com/image/upload/w_1517,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop"
	srcset="
		https://res.cloudinary.com/kentcdodds-com/image/upload/w_280,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop   280w,
		https://res.cloudinary.com/kentcdodds-com/image/upload/w_560,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop   560w,
		https://res.cloudinary.com/kentcdodds-com/image/upload/w_840,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop   840w,
		https://res.cloudinary.com/kentcdodds-com/image/upload/w_1100,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop 1100w,
		https://res.cloudinary.com/kentcdodds-com/image/upload/w_1650,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop 1650w,
		https://res.cloudinary.com/kentcdodds-com/image/upload/w_2500,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop 2500w,
		https://res.cloudinary.com/kentcdodds-com/image/upload/w_2100,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop 2100w,
		https://res.cloudinary.com/kentcdodds-com/image/upload/w_3100,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop 3100w
	"
	sizes="(max-width:1023px) 80vw, (min-width:1024px) and (max-width:1620px) 67vw, 1100px"
/>

And of course I don't write this out manually. I have a utility to generate these props for me:

function getImgProps(
	imageBuilder: ImageBuilder,
	{
		widths,
		sizes,
		transformations,
	}: {
		widths: Array<number>
		sizes: Array<string>
		transformations?: TransformerOption
	},
) {
	const averageSize = Math.ceil(widths.reduce((a, s) => a + s) / widths.length)

	return {
		alt: imageBuilder.alt,
		src: imageBuilder({
			quality: 'auto',
			format: 'auto',
			...transformations,
			resize: { width: averageSize, ...transformations?.resize },
		}),
		srcSet: widths
			.map((width) =>
				[
					imageBuilder({
						quality: 'auto',
						format: 'auto',
						...transformations,
						resize: { width, ...transformations?.resize },
					}),
					`${width}w`,
				].join(' '),
			)
			.join(', '),
		sizes: sizes.join(', '),
	}
}

Then I use it like so:

<img
	key={frontmatter.bannerCloudinaryId}
	title={frontmatter.bannerCredit}
	{...getImgProps(
		getImageBuilder(
			frontmatter.bannerCloudinaryId,
			getBannerAltProp(frontmatter),
		),
		{
			className: 'rounded-lg object-cover object-center',
			widths: [280, 560, 840, 1100, 1650, 2500, 2100, 3100],
			sizes: [
				'(max-width:1023px) 80vw',
				'(min-width:1024px) and (max-width:1620px) 67vw',
				'1100px',
			],
			transformations: {
				background: 'rgb:e6e9ee',
			},
		},
	)}
/>

We don't have too much time to get into the imageBuilder stuff. It's just a little abstraction I have on top of cloudinary-build-url for building cloudinary URLs in a typesafe way. My point is that Cloudinary makes it easy for me to serve you the right sized image for your device and screen size so it loads quickly and I save you data!

Unsplash's placeholder

If I stopped just at this point then users would just get a blank space before the image loads in. Much better to show some kind of placeholder. You've seen these around the web for sure. Medium was the first place I saw something like this. I used gatsby-plugin-sharp on the old version of this website which had support for an inline SVG that was a sort of tracing of the image (with mixed, but mostly positive results). And unsplash also has support for this. For this to work well, you need the placeholder to be smallish, server rendered, and inline. If you have to load your placeholder then you'll need a placeholder for your placeholder! As ridiculous as that sounds, this is actually what Unsplash does.

When you land on an unsplash image, there are three things that can happen in series depending on your network speed:

  1. The primary color of the image is displayed. This is server rendered.
  2. A blurred version of the image is displayed. I'm not sure whether they're using blurhash for this, but they're doing the exact same thing. It's a canvas drawing.
  3. The final image is loaded*

*There's a bit more to that step 3 which we'll talk about later.

These actually all happen, but they're layered with the image on top, then the blur canvas, then the div with a background color. That way unsplash shows the best thing it can as soon as possible. For this reason, it's quite possible you'll not see the blurred image on the initial page load, but if you navigate around after the page loads that's what you'll see for all other images and you won't see the primary color background anymore. This is because showing the blurred image canvas requires JavaScript to work.

So if the image loads before the JavaScript then the JavaScript won't have a chance to set up the canvas for the blurred image for you before you're looking at the actual image. And if the JavaScript is already loaded (like if you're doing client-side navigation) you won't see the background color and will only see the blurred image.

This is a nice setup and when I was working on my own image loading experience I looked at this for inspiration. The really cool thing about the blurhash / canvas approach is the size of the data required for the image is really small. Like, seriously, this is all you need to pass to the blurhash client library for a nice looking blur of a full image: LGFFaXYk^6#M@-5c,1J5@[or[Q6.:

blurhash website showing an image being
converted into a hash and the canvas representation of
that

Honestly, it's so cool. Magical 🧙

Ultimately the goal here is to minimize the amount of data needed to give the user a good experience while the full resolution image is loading. It's a balance of speed and a good user experience.

When I was reverse engineering unsplash's image loading techniques, I evaluated whether I should just adopt their approach or try something a little different. I really didn't like the fact that they have to render a div with a solid background color before they can render the blurred image. Why not just render the blurred image on the server via a base64 encoded data URL?

So I decided to try it out. First I needed to find a way to automatically generate the base64 data URL. For one thing, I know that the URL would be very large if I just tried with the regular size image (this would basically negate all the user experience gains by making my page load slowly).

So I needed to generate a base64 data URL for a downscaled version of the image. This is really easy since I'm using Cloudinary for all my images. Additionally, cloudinary has the ability to apply transforms like blur on the image. This means that I could easily reduce the amount of data to represent in my base64 string. So I generate a cloudinary URL like this:

https://res.cloudinary.com/kentcdodds-com/image/upload/w_100,q_auto,f_webp,e_blur:1000/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop

And when I fetch that image and base64 encode it, I end up with this:

data:image/webp;base64,UklGRhQBAABXRUJQVlA4IAgBAAAQDQCdASpkAEMAPrFGmko7qyWhsls9U3AWCWkGcA01nlwbK5buwWRoA3koD7+5vLBXAtOMrneG2GT90JyrLz+2XeotIAEq5PL4F0N1qTRIJ7LnMa5Zcre8UaDTMRtFt14eXNoGYkhNSt0REMN2PN4FwAD+7s4jHeyE9BXykzZMxIuwC4FSp408GYxRjoczsMvwZlqrnzr4cuA6X6MspvaoVHUro1XNU1SNxrLKLjhZrJ3GmlyoorlW1L532OP9tbhOeQgFiDwE81g+CH4d16xfOjEGrpus0wYxdunoI7Nokc5fnyoAw8pKJEq6cW3Yp4rqZw9fosV61qnAN+ViAH+WOzoqC6R90AA=

Now, that's quite a bit more than the 30 or so characters that represent the hash for blurhash, but remember that blurhash also requires a client library which is larger than this. But that's actually not the reason I didn't go with blurhash. Once you apply this to just a couple of images, blurhash more than pays for its own weight. So a site like unsplash definitely hits that point where it makes a lot of sense.

And blurhash would probably pay for itself on my site too. I have the recommendations at the bottom of every page which are all blur-loaded as well.

So why didn't I go with blurhash at this point? It's because I really didn't want to render the primary color. I just didn't think it looked good for my site. And this data URL wasn't so big that I thought it was worth server-rendering the solid block of color. It's a shame we can't server render a canvas. That'd be the best of both worlds. sigh

So I went with rendering the base64 behind the actual image. So while the image is loading I display a server-rendered blurred and upscaled version of the image.

Unfortunately, upscaling it like this made it look really pixelated.

Pixelated blog post placeholder

The platform to the rescue! I just slapped this in the DOM after my base64 image and we were in business:

<div class="backdrop-blur-xl"></div>

That ends up effectively applying this css:

backdrop-filter: blur(24px);

And we get the nice blur effect:

Blurred blog post placeholder

Sweet!

Fading in the image onload

I had a nice placeholder, but one thing that bothered me was when the image did load it would just appear in place of the placeholder, and I wanted it to feel like the placeholder kinda turned into the actual image. For this to work, I needed to write some JavaScript. I think it's about time I show you my BlurrableImage component... First, here's how I use it on my blog post page:

function BlogScreen() {
	// ...
	return (
		// ...
		<div className="col-span-full mt-10 lg:col-span-10 lg:col-start-2 lg:mt-16">
			{frontmatter.bannerCloudinaryId ? (
				<BlurrableImage
					key={frontmatter.bannerCloudinaryId}
					blurDataUrl={frontmatter.bannerBlurDataUrl}
					className="aspect-[3/4] md:aspect-[3/2]"
					img={
						<img
							key={frontmatter.bannerCloudinaryId}
							title={frontmatter.bannerCredit}
							{...getImgProps(
								getImageBuilder(
									frontmatter.bannerCloudinaryId,
									frontmatter.bannerAlt ??
										frontmatter.bannerCredit ??
										frontmatter.title ??
										'Post banner',
								),
								{
									className: 'rounded-lg object-cover object-center',
									widths: [280, 560, 840, 1100, 1650, 2500, 2100, 3100],
									sizes: [
										'(max-width:1023px) 80vw',
										'(min-width:1024px) and (max-width:1620px) 67vw',
										'1100px',
									],
									transformations: {
										background: 'rgb:e6e9ee',
									},
								},
							)}
						/>
					}
				/>
			) : null}
		</div>
		// ...
	)
	// ...
}

And here's the BlurrableImage component itself:

import * as React from 'react'
import { clsx } from 'clsx'
import { useSSRLayoutEffect } from '#app/utils/misc'

export function BlurrableImage({
	img,
	blurDataUrl,
	...rest
}: {
	img: React.ReactElement<React.ImgHTMLAttributes<HTMLImageElement>>
	blurDataUrl?: string
} & React.HTMLAttributes<HTMLDivElement>) {
	const [visible, setVisible] = React.useState(false)
	const jsImgElRef = React.useRef<HTMLImageElement>(null)

	React.useEffect(() => {
		if (!jsImgElRef.current) return
		if (jsImgElRef.current.complete) return

		let current = true
		jsImgElRef.current.addEventListener('load', () => {
			if (!jsImgElRef.current || !current) return
			setTimeout(() => {
				setVisible(true)
			}, 0)
		})

		return () => {
			current = false
		}
	}, [])

	const jsImgEl = React.cloneElement(img, {
		ref: jsImgElRef,
		className: clsx(img.props.className, 'transition-opacity', {
			'opacity-0': !visible,
		}),
	})

	return (
		<div {...rest}>
			{blurDataUrl ? (
				<>
					<img
						src={blurDataUrl}
						className={img.props.className}
						alt={img.props.alt}
					/>
					<div className={clsx(img.props.className, 'backdrop-blur-xl')} />
				</>
			) : null}
			{jsImgEl}
			<noscript>{img}</noscript>
		</div>
	)
}

Alright, that's a bit to take in... Let me walk you through it...

First, the props are pretty simple. We accept an img element which is the ultimate image we want to be loaded. We accept a blurDataUrl to render a blurred version of the image while we're waiting for the image to load. And then the rest of the props are just applied to the div that's the container for everything. I pretty much only use that for the className for the aspect ratio stuff.

Let's skip all the stuff in the middle and go down to what we're rendering:

We render a wrapper div to keep everything together (in particular for the aspect ratio stuff to work properly).

Then if there is a blurDataUrl, we render an img element with the blurDataUrl. We inherit the className to ensure we get stuff like the right border radius etc.

Then under that we render the backdrop to smooth out the blurriness of the data URL image since that's going to be scaled up as described earlier.

Then we render what I call a jsImgEl. This is a copy of the img. The jsImgEl is the primary image that will be displayed to the user when all is said and done. I make a copy of it so I can add some css for the fade-in action. More on this in a moment.

Finally, the <noscript>{img}</noscript> stuff is there for the handful of users who might disable JavaScript because otherwise they'll never get the image displayed (because displaying the image requires JavaScript). There probably aren't many (any) users like this, but it's just so easy so why not?

Alrighty, so to make the fade work, we need to have the jsImgEl start out as invisible. The browser will still load this for us though, and it fires events along the way, so we use useEffect to add an event handler to know when it's loaded and when it does finish loading we'll trigger an update to make the image fade in.

And really that's it.

... except now it's 2023 and I decided to improve one more thing...

Fading faster

One problem with our implementation is that the image won't fade in until after the JavaScript has been loaded. This is annoying because if the image is already in the browser cache, we'll still see the blurred image for a fraction of a second before the JS is loaded, parsed, and executed. I wanted it to be faster!

So I decided to add an onload prop 😱. Inline JavaScript in HTML is normally not something you want to do, but in this case it's perfect. So this is what I wanted to do:

// ...
const jsImgEl = React.cloneElement(img, {
	ref: jsImgElRef,
	onload: "this.classList.remove('opacity-0')",
	className: clsx(img.props.className, 'transition-opacity', {
		'opacity-0': !visible,
	}),
})
// ...

This works great because it means that the inline JS will run as soon as the image finishes loading (the JS doesn't need to "load" because it's inline!).

Unfortunately, doing that doesn't work because React throw a big stink about wanting you to use onLoad instead (with inline function handler instead of an inline string of JS).

So I had to go with a bit of a more complicated route to make this work.

This post is already quite long, so I'm not going to go into the details, but you can look at the commit which fixed all this. Here's the difference:

I unironically deployed an inline event handler in HTML in a production app. I used it to avoid an unnecessary blur. So you still get the nice blur loading experience when the image isn't downloaded yet, but if it's already in your browser cache, you don't get a flash of blur! twitter.com/kentcdodds/status/1630362305409216512

12 1,691

Nice win there!

Conclusion

So in review, to make an excellent image loading experience I'm doing a few things:

  1. Avoid layout shift by using Tailwind's aspect-ratio plugin
  2. Load the perfect sized image via the <img /> attributes sizes and srcset + cloudinary transforms.
  3. Generate the base64 encoding of a smaller blurred version of the image (shoutout to cloudinary). I do cache this to make it fast.
  4. Render the blurred image inline so it can be server rendered along with some JS to load the full image and display it when it's finished loading.

And that's what powers the awesome image loading experience on this site. It's important to remember that user experience is not all about performance. It's also about the experience. And I feel like the trade-offs I've made here are solid for the user experience I want to provide you as you navigate this site. I hope you love it :)

P.S. I'm eyeing unpic-img which promises to be a framework agnostic solution to this and includes nice placeholder support that has support for server rendering as well. May be something to look at!