Vue 3 原生 ESM 开发实战:不用打包工具,从零搭建组件化应用
前言
提到 Vue 开发,大多数人的第一反应是 npm create vite@latest。但你有没有想过------不用 Node、不用打包工具、不开终端,直接在浏览器里用 Vue 3 写组件化应用?
这篇文章记录了我用 Vue 3 原生 ESM 模式开发的完整过程:从一个报错开始,到 importmap 解析、组件拆分、CSS 样式隔离,最终实现一个可以在任何静态服务器上直接运行的 Vue 3 项目。
一、起手:一个经典报错
最初的尝试
html
<!-- index.html -->
<head>
<!-- 通过 CDN 全局加载 Vue -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module">
import { createApp } from 'vue' // ← 想用 ESM 导入
import App from './app.js'
createApp(App).mount('#app')
</script>
</body>
报错
text
Uncaught TypeError: Failed to resolve module specifier "vue".
Relative references must start with either "/", "./", or "../".
原因分析
这里同时做了两件冲突的事:
| 做法 | 效果 |
|---|---|
<script src="vue.global.js"> |
Vue 挂载到 window.Vue,是全局变量模式 |
import { createApp } from 'vue' |
浏览器尝试用 ESM 模块系统解析 'vue' |
浏览器的 ESM 系统只认识:
- 相对路径:
./、../、/ - 完整 URL:
https://...
而 'vue' 是一个裸模块标识符(bare specifier),浏览器根本不知道它是什么,所以报错。
二、解决方案:Import Maps
什么是 Import Map
<script type="importmap"> 是浏览器原生能力,作用是:
告诉浏览器:当遇到某个裸模块名时,应该去哪个 URL 加载它。
最终写法
html
<head>
<!-- 1. 删掉 vue.global.js -->
<!-- <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> -->
<!-- 2. 用 importmap 映射裸模块 -->
<script type="importmap">
{
"imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
}
}
</script>
</head>
<body>
<div id="app"></div>
<script type="module">
import { createApp } from 'vue' // ✅ 浏览器查 importmap,找到 CDN 地址
import App from './app.js' // ✅ 相对路径,原生支持
createApp(App).mount('#app')
</script>
</body>
关键点
| 注意 | 说明 |
|---|---|
用 vue.esm-browser.js |
这是 ES Module 版本,不是 vue.global.js |
删掉全局 <script> |
两套模式冲突,只保留 importmap 一条路径 |
映射放在 <script type="module"> 之前 |
浏览器需要先解析 importmap 再执行模块 |
加载流程
text
浏览器加载 index.html
│
├── 遇到 <script type="importmap">
│ └── 注册: "vue" → https://unpkg.com/vue@3/dist/vue.esm-browser.js
│
└── 执行 <script type="module">
├── import { createApp } from 'vue'
│ └── 查 importmap → 去 CDN 拉取 vue.esm-browser.js
│
└── import App from './app.js'
└── app.js 内部的 import { ref } from 'vue'
└── 同样命中 importmap → 复用已加载的 vue 模块
浏览器兼容性
| 浏览器 | 最低版本 |
|---|---|
| Chrome / Edge | 89+ |
| Safari | 16.4+ |
| Firefox | 108+ |
importmap 已经被所有现代浏览器支持,可以放心在生产项目中使用。
三、组件拆分:Card 组件
文件结构
text
project/
├── index.html ← 入口页面 + 全局样式
├── app.js ← 根组件
└── components/
├── Card.js ← Card 组件逻辑
└── Card.css ← Card 组件样式
设计思路
Card 是一个纯容器组件:
text
┌──────────────────────────┐
│ vcard-header │
│ ┌──────────────────────┐ │
│ │ title (prop) │ │
│ │ ────────────────── │ │
│ │ │ │
│ │ <slot> 自由内容 │ │
│ │ │ │
│ └──────────────────────┘ │
└──────────────────────────┘
- 标题 :通过
prop传入,不传则隐藏标题栏 - 内容 :通过默认插槽
<slot>插入,组件不关心具体内容 - 样式:组件自带,不依赖外部
components/Card.js
js
// 获取当前文件所在目录,拼出 CSS 的相对路径
const cssUrl = new URL('./Card.css', import.meta.url).href
let injected = false
export default {
name: 'VCard',
props: {
title: {
type: String,
default: ''
}
},
setup() {
if (!injected) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = cssUrl
document.head.appendChild(link)
injected = true
}
},
template: `
<div class="vcard">
<div class="vcard-header" v-if="title">
<span class="vcard-title">{{ title }}</span>
</div>
<div class="vcard-body">
<slot></slot>
</div>
</div>
`
}
components/Card.css
css
.vcard {
position: relative;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
min-width: 380px;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.03),
0 20px 60px rgba(0, 0, 0, 0.5);
opacity: 0;
animation: cardEnter 0.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.vcard-header {
padding: 20px 32px 0;
position: relative;
}
.vcard-header::after {
content: '';
display: block;
margin-top: 16px;
height: 1px;
background: linear-gradient(90deg, var(--accent), transparent 80%);
opacity: 0.25;
}
.vcard-title {
font-family: 'Instrument Serif', serif;
font-size: 14px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-muted);
}
.vcard-body {
padding: 36px 40px 40px;
text-align: center;
}
四、核心问题:浏览器里能 import './Card.css' 吗?
直接回答:不能
js
// ❌ 这样写会报错
import './Card.css'
浏览器会把 .css 文件当 JavaScript 解析,直接抛出语法错误。
对比各方案
| 方式 | 能否在浏览器原生使用 | 说明 |
|---|---|---|
import './Card.css' |
❌ | 需要 Vite / Webpack 等打包工具 |
动态插入 <style> 标签 |
✅ | 把 CSS 字符串内联写在 JS 里 |
动态插入 <link> 标签 |
✅ | 加载外部 CSS 文件 |
在 HTML 里手动写 <link> |
✅ | 最简单,但脱离组件自治 |
推荐方案:动态 <link> 注入
js
// Card.js 中的关键代码
const cssUrl = new URL('./Card.css', import.meta.url).href
let injected = false
setup() {
if (!injected) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = cssUrl
document.head.appendChild(link)
injected = true
}
}
原理拆解
text
import.meta.url
│
│ 指向当前 JS 文件的完整 URL
│ 例: http://localhost:3000/components/Card.js
│
├── new URL('./Card.css', import.meta.url)
│ │
│ └── 相对于 Card.js 自身定位 Card.css
│ 结果: http://localhost:3000/components/Card.css
│
└── 动态创建 <link> 标签注入 <head>
└── 浏览器发起请求加载 CSS 文件
| 机制 | 作用 |
|---|---|
import.meta.url |
获取当前模块的完整 URL |
new URL('./Card.css', ...) |
以当前模块为基准,解析相对路径 |
injected 守卫 |
防止多次实例化时重复注入 |
<link> 而非 <style> |
CSS 是独立文件,浏览器可缓存,开发时改动即时生效 |
这个模式和 Vite 中
import './Card.css'的体验几乎一致,只是加载机制从打包器换成了浏览器原生能力。
五、根组件:app.js
js
import { ref } from 'vue'
import VCard from './components/Card.js'
export default {
components: { VCard },
setup() {
const count = ref(0)
const bumped = ref(false)
function bump() {
bumped.value = true
setTimeout(() => (bumped.value = false), 200)
}
function increment() { count.value++; bump() }
function decrement() { count.value--; bump() }
function reset() { count.value = 0; bump() }
return { count, bumped, increment, decrement, reset }
},
template: `
<VCard title="Counter">
<div class="count-value" :class="{ bumped }">{{ count }}</div>
<div class="btn-row">
<button class="btn" @click="decrement">
<i class="fas fa-minus"></i>
</button>
<button class="btn btn--secondary" @click="reset">Reset</button>
<button class="btn" @click="increment">
<i class="fas fa-plus"></i>
</button>
</div>
<div class="footer-hint">
<i class="fas fa-circle"></i> Vue 3 Composition API
</div>
</VCard>
`
}
要点:
import VCard from './components/Card.js'--- 相对路径引入,不经过 importmapcomponents: { VCard }--- 注册后才能在 template 中使用<VCard />- 业务逻辑留在 app.js --- Card 只管容器,计数逻辑属于应用层
六、完整 index.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue 3 原生 ESM</title>
<!-- 字体 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif&family=DM+Mono:wght@300;400&display=swap"
rel="stylesheet">
<!-- 图标 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- Vue 3 importmap -->
<script type="importmap">
{
"imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
}
}
</script>
<style>
/* ══════════════════════════════
CSS 变量
══════════════════════════════ */
:root {
--bg: #0b0b0f;
--surface: #141419;
--surface-raised: #1c1c24;
--accent: #e8a83e;
--accent-glow: rgba(232, 168, 62, 0.15);
--text-primary: #f0ece4;
--text-muted: #6b6660;
--border: rgba(255, 255, 255, 0.06);
--radius: 16px;
}
/* ══════════════════════════════
全局基础样式
══════════════════════════════ */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'DM Mono', monospace;
background: var(--bg);
color: var(--text-primary);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
/* 背景光晕 */
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 600px 400px at 25% 30%, rgba(232, 168, 62, 0.06), transparent),
radial-gradient(ellipse 500px 500px at 75% 70%, rgba(90, 60, 200, 0.04), transparent);
pointer-events: none;
}
/* 噪点纹理 */
body::after {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E");
opacity: 0.03;
pointer-events: none;
}
/* ══════════════════════════════
按钮
══════════════════════════════ */
.btn-row {
display: flex;
gap: 12px;
justify-content: center;
}
.btn {
font-family: 'DM Mono', monospace;
font-size: 13px;
font-weight: 400;
letter-spacing: 0.06em;
padding: 12px 28px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-raised);
color: var(--text-muted);
cursor: pointer;
transition:
background 0.25s ease,
color 0.25s ease,
border-color 0.25s ease,
letter-spacing 0.3s ease,
box-shadow 0.3s ease;
user-select: none;
}
.btn:hover {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
letter-spacing: 0.12em;
box-shadow: 0 4px 24px var(--accent-glow);
}
.btn:active {
transform: scale(0.97);
}
.btn--secondary {
background: transparent;
border-color: var(--border);
}
.btn--secondary:hover {
background: transparent;
color: var(--accent);
border-color: var(--accent);
box-shadow: none;
}
/* ══════════════════════════════
计数器
══════════════════════════════ */
.count-value {
font-family: 'Instrument Serif', serif;
font-size: 96px;
font-weight: 400;
line-height: 1;
color: var(--text-primary);
margin-bottom: 36px;
transition: color 0.3s ease, transform 0.15s ease;
}
.count-value.bumped {
color: var(--accent);
transform: scale(1.06);
}
.footer-hint {
margin-top: 32px;
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.08em;
}
.footer-hint i {
color: var(--accent);
margin-right: 6px;
font-size: 10px;
}
/* ══════════════════════════════
动画
══════════════════════════════ */
@keyframes cardEnter {
from {
opacity: 0;
transform: translateY(30px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module">
import { createApp } from 'vue'
import App from './app.js'
createApp(App).mount('#app')
</script>
</body>
</html>
七、样式归属划分
text
┌─────────────────────────────────────────────────────┐
│ index.html │
│ │
│ CSS 变量 (:root) │
│ 全局基础 (body, 背景, 噪点) │
│ 业务样式 (.btn, .count-value, .footer-hint) │
│ 公共动画 (@keyframes) │
│ │
├─────────────────────────────────────────────────────┤
│ Card 组件 │
│ │
│ Card.css → .vcard / .vcard-header / .vcard-body │
│ (容器样式,与内容无关) │
│ │
└─────────────────────────────────────────────────────┘
| 归属 | 位置 | 包含 |
|---|---|---|
| 设计系统 | index.html |
CSS 变量、字体、颜色、圆角 |
| 全局基础 | index.html |
body、背景、噪点、动画关键帧 |
| 业务样式 | index.html |
按钮、数字、提示文字(属于 app 层) |
| 组件样式 | Card.css |
容器布局、头部、边框、阴影(属于 Card 层) |
八、完整模块关系图
text
index.html
│
├── importmap: "vue" → CDN vue.esm-browser.js
│
└── <script type="module">
│
├── import { createApp } from 'vue'
│ └── 浏览器查 importmap → CDN
│
└── import App from './app.js'
│
├── import { ref } from 'vue'
│ └── 浏览器查 importmap → CDN (复用缓存)
│
└── import VCard from './components/Card.js'
│
├── new URL('./Card.css', import.meta.url)
│ └── 动态注入 <link> → 加载 Card.css
│
└── 组件 template + setup
九、总结
| 要点 | 结论 |
|---|---|
| 裸模块导入 | 必须用 <script type="importmap"> 映射,否则浏览器报错 |
| Vue ESM 版本 | 用 vue.esm-browser.js,不是 vue.global.js |
| 子组件引入 | 走相对路径 ./components/Card.js,不经过 importmap |
| CSS 导入 | 浏览器原生不支持 import './Card.css',用 <link> 动态注入替代 |
| 样式归属 | 组件自带容器样式,业务样式留在页面层 |
| 运行方式 | 任何静态文件服务器都能跑,不需要 Node.js |
这种开发方式适合原型验证、教学演示、小工具页面。当你不需要构建流程、不想装 Node、只想写几个文件就跑起来的时候,原生 ESM + importmap 是一个干净利落的选择。