Working with Image Assets
Let's imagine you have something like this in a Vue component template:
<img src="../assets/image.png">
It works fine. You then try to change it to something dynamic:
<img :src="imgSrc">
imgSrc
is a string containing the path from the original code, yet it doesn't work. But, why? Surely they're equivalent?
Another way you might encounter the same problem is trying to pass the path to a component rather than an <img>
. For example:
<!-- Works -->
<img src="../assets/image.png">
<!-- Doesn't work -->
<my-image-component src="../assets/image.png" />
Assets and Build Tools
You'll encounter the same problem with both Vite and the old Vue CLI (webpack).
Both tools have a convention of putting assets such as images in the folder /src/assets
, but there's no special handling for that folder in those tools. Assets need to be imported into your JS/TS code, just like other dependencies. If they aren't imported then they'll be discarded as part of the tree-shaking process and won't be included in the final build output.
As part of the build process the images will be copied and renamed. So even though you have ../assets/image.png
in your code, that isn't where the image will be in a production build. The actual path will be something like /assets/image.12345678.png
, including a hash of the file contents for cache-busting purposes. Importing the image will yield the correct path to use at runtime.
So, for example:
<script setup>
// In production, imgSrc will either be a string path
// like '/assets/image.12345678.png', or a base64 encoded
// data URL if the image is small enough.
import imgSrc from '../assets/image.png'
</script>
<template>
<img :src="imgSrc" />
</template>
If you've configured a base
path (Vite) or a publicPath
(Vue CLI) then that will also be automatically added to the imported path string.
Why Does a Static src
Work?
So if images need to be imported, why does <img src="../assets/image.png">
work just fine?
That's a special case. The build tools look for <img src>
and import that path automatically. They do the same with several other native HTML elements too, and can be configured to work with custom components. The specifics depend on what tools you're using:
- Vite: @vitejs/plugin-vue
- Vue CLI / webpack: Vue Loader
But that can only handle static paths, hard-coded directly into the template.
Dynamic Paths
import
Using an import
statement doesn't allow for dynamic paths, but if you only have a small number of images then that's not necessarily a problem. The images can be statically imported individually, with other code to choose the appropriate image:
<script setup>
import img1 from '../assets/image1.png'
import img2 from '../assets/image2.png'
defineProps(['done'])
</script>
<template>
<img :src="done ? img1 : img2" />
</template>
import()
It is theoretically possible to use the import()
function to import an image, but that will wrap the value we need in a promise. It can be made to work, but it's fiddly:
<script setup>
import { ref } from 'vue'
const imgSrc = ref()
import('../assets/image.png').then(imageImports => {
imgSrc.value = imageImports.default
})
</script>
<template>
<img v-if="imgSrc" :src="imgSrc" />
</template>
The advantage of import()
is that the path can be dynamic, e.g. import(`../assets/${name}.png`)
. Thankfully there are other ways to achieve this that don't involve promises. With Vite we can use new URL()
or import.meta.glob()
, and with Vue CLI / webpack we can use require()
.
Whichever approach we use, it's important to understand roughly how they work. All of them rely on static analysis at build time. This means that the build tool is searching through your code for one of these constructs and then attempts to parse out the path. It isn't purely a runtime process. They can all handle dynamic paths to some extent, but you need to make it easy for them. If you write something like import(srcUrl)
then the build tool won't be able to figure out which files srcUrl
might match. Something like import(`../assets/${name}.png`)
, which includes some parts of the path statically, gives the tooling the hints it needs to import all .png
files in the ../assets
directory.
new URL()
If you're using Vite then images can be included dynamically using new URL()
:
<script setup>
import { computed } from 'vue'
const props = defineProps(['icon'])
const imgSrc = computed(() => {
return new URL(`../assets/${props.icon}.svg`, import.meta.url).href
})
</script>
<template>
<img :src="imgSrc" />
</template>
There are three important caveats when using this approach:
- You can't start the path with an alias, such as
@
. - Dynamic paths must use template literals (i.e. strings with backticks), passed inline as the first argument to
new URL()
. No other form of dynamic value is supported. - SSR is not supported.
See the Vite docs for more information.
import.meta.glob()
Vite supports importing many files at once using globs. This is asynchronous by default, but we can use the eager
option to make the path resolution synchronous:
<script setup>
const imgUrls = import.meta.glob('../assets/*.png', {
import: 'default',
eager: true
})
defineProps(['icon'])
</script>
<template>
<img :src="imgUrls[`../assets/${icon}.png`]">
</template>
import.meta.glob()
returns an object, with property keys being the source file names. Aliases are supported, but they will be expanded out to the equivalent relative path, beginning with ./
or ../
as appropriate.
Having the relative path as the property key isn't always convenient, but we can map it to something else:
<script setup>
// We take an object in this form:
// {
// "../assets/home.png": "/assets/home.12345678.png",
// "../assets/logout.png": "/assets/logout.12345678.png"
// }
// and map it to one with simpler property keys:
// {
// "home": "/assets/home.12345678.png",
// "logout": "/assets/logout.12345678.png"
// }
const simplifyKeys = (obj) => {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [
// Remove the directories and the extension
key.replace(/^.*\/|\.png$/g, ''),
value
])
)
}
const imgUrls = simplifyKeys(
import.meta.glob('../assets/*.png', {
import: 'default',
eager: true
})
)
defineProps(['icon'])
</script>
<template>
<img :src="imgUrls[icon]">
</template>
See the Vite docs for more information about glob imports.
If you're using Vite 2 then you'll need to use globEager
instead. See the Vite 2 docs for more information.
require()
If you're using a webpack-based build tool, such as Vue CLI, then you can use require()
to import the images. require()
behaves a bit like a synchronous version of import()
. e.g.:
const imgUrl = require('../assets/' + name + '.png')
// ... or ...
const imgUrl = require(`../assets/${name}.png`)
It can also be used directly inside a Vue template:
<template>
<img :src="require(`../assets/${name}.png`)" />
</template>
Much like with the other approaches described above, require()
needs a partially static path, so that webpack can figure out which files are possible matches. The following will not work:
<template>
<!-- This won't work, there must be some static path -->
<img :src="require(imgUrl)" />
</template>
See the webpack documentation for more information.
/public
Both Vite and Vue CLI have support for a /public
folder. This is a special folder for files that should always be included in the build, even though they are not imported anywhere. These files cannot have cache-busting hashes added, as their names need to be left unchanged so you can use them in your code.
Using /public
can be a good option if there are a lot of images that never change, making the hashing unnecessary. However, it does come with its own problems.
In the examples that follow, let's assume we've put our images in /public/images
.
/public
with Vite
If we're using Vite, the special handling for tags like <img src>
carries across to files in /public
. We can use static paths, we just need to start the path with a /
:
<!-- With Vite this will work fine -->
<img src="/images/image.png">
Note that we don't include /public
in the path.
The Vite plugin has special handling for files beginning with /
. It'll first check whether the file exists in the /public
folder, then fall back to an import if the file wasn't found. If you've configured a base
path, Vite will rewrite the attribute path accordingly.
If we're working with dynamic paths, or static paths on tags the plugin doesn't understand, then we have to apply the base
path ourselves.
For example, if we have base: '/my-app/'
in our Vite config, we can access that path using import.meta.env.BASE_URL
:
<script setup>
// This gives us access to the `base` config option
const base = import.meta.env.BASE_URL
defineProps(['name'])
</script>
<template>
<img :src="`${base}images/${name}.png`" />
</template>
You can read more about the /public
folder in the Vite docs.
/public
with Vue CLI
To use the /public
folder with Vue CLI, we need to apply the publicPath ourselves, even for static paths.
For example, the following code will only work if publicPath
is set to /
:
<img src="/images/image.png">
Vue CLI won't rewrite this path at all, it'll just be left as-is.
We can access the publicPath
using process.env.BASE_URL
:
<script setup>
// This gives us access to the `publicPath` config option
const base = process.env.BASE_URL
</script>
<template>
<img :src="`${base}images/image.png`" />
</template>
You can read more about the /public
folder in the Vue CLI docs.