Vue 3 原生 ESM 开发实战:不用打包工具,从零搭建组件化应用

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' --- 相对路径引入,不经过 importmap
  • components: { 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 是一个干净利落的选择。

相关推荐
我的div丢了肿么办1 小时前
grid布局justify-items和align-items、justify-content和align-content
前端·css
用户298698530141 小时前
在 React 中使用 JavaScript 将 HTML 转换为图片
javascript·react.js·html
寻缘千鹤1 小时前
LogicFlow流程图 PNG 导出「线上按钮置灰、无反应」故障复盘
前端·javascript·vue.js
梨想橙汁1 小时前
Vite 优化、踩坑汇总 + Webpack 迁移 Vite 实战
前端·webpack·vite
10share1 小时前
给 JSX 组件也来一份 Vue 式 scoped:jsx-scoped 让样式隔离不再靠自觉
前端·vue.js
小聪7081 小时前
elpis-core 领域模型驱动设计解析
前端
北城笑笑1 小时前
Vue 104 ,AI + ECharts + Word:大模型数据可视化报告生成实战(前端导出图文并茂 Word 文档)
前端·vue.js·word·echarts
创新技术阁1 小时前
FastapiAdmin 实战:二次开发前的准备(环境配置与项目启动)
前端·后端·fastapi
李少兄1 小时前
JavaScript 运算符完全指南
java·开发语言·javascript