刚入坑 Vue 的前端同事经常问我同一个问题:“项目里的文件模板到底该放哪?”起初我以为他问的是.vue文件里的<template>标签怎么写,后来发现他翻遍了整个项目目录,是想找一个叫template的文件夹。这种困惑其实非常典型——在 Vue 的世界里,“文件模板”是个多义词:组件模板、入口 HTML 模板、脚手架预设模板、编辑器里的代码片段模板,四者的存放位置和命名规则完全不同。
如果你也正在纠结“模板放哪”这件事,先把这四个概念分开理解,再对应到你的实际场景,就基本不会迷路了。这篇文章我会从项目目录、构建机制、脚手架预设、VS Code 片段、动态渲染这几个层面逐一拆解,既讲清“放哪里”,也讲清“为什么必须这样放”,最后附带一些我在真实项目里踩过的坑和处理经验。
1. 先拆词:Vue语境里的“文件模板”至少有四副面孔
同一个“模板”说法,放在不同的上下文里含义天差地别。我先把最常见的四类场景列出来,后文再逐个展开。
| 模板类型 | 典型位置 | 由谁使用 | 核心作用 |
|---|---|---|---|
| 单文件组件模板 | src/components/、src/views/下的.vue文件 | Vue 编译器(vue-loader / Vite 插件) | 描述组件 UI 结构 |
| 入口 HTML 模板 | public/index.html(Vue CLI)、项目根目录index.html(Vite) | html-webpack-plugin / Vite 构建 | 提供页面外壳、挂载点、meta 信息 |
| 脚手架项目模板 | 本机~/.vuerc、团队 Git 模板仓库 | vue-cli / create-vue | 初始化新项目的目录与依赖 |
| 编辑器代码片段 | VS Code 的 snippets 配置目录 | 编辑器本身 | 提升编码效率,快速生成代码结构 |
一张表看完,你应该发现了:它们之间几乎没有交集。很多人问“模板放哪里”,其实是在问第一类,也就是.vue文件里的<template>区块放在哪一层目录、怎么组织组件文件。少数人在问第二类,即index.html应该改哪里。问第三类和第四类的人往往是看了某个教程里提到的“项目模板”或“代码模板”才产生的疑问。
1.1 先分清概念,再动手找路径
我见过一个刚转前端的小伙,把一段后台返回的 HTML 字符串拆到了src/templates/目录下,想用v-html渲染出来,结果整个项目结构变得不伦不类。问题不在技术,而在对“模板”的理解发生了偏移。
Vue 的核心主张是组件化,一个.vue文件就是一个自包含的单元,它内部自带<template>、<script>、<style>。模板不是独立存在的文件,而是组件的一个属性区域。你非要把它从组件里抽出去单独维护,等于亲手拆散组件的内聚性。后续维护时,你改模板要跳文件,改逻辑跳文件,改样式还要再跳一次,想定位一个小改动要来回切三个地方,效率直线下降。
所以理解完概念之后,结论其实很朴素:99% 的场景下,组件模板就放在组件文件里,跟 script 和 style 在一起。后面几个问题都是围绕“组件文件放哪”和“例外情况放哪”展开的。
1.2 不是所有“模板文件”都需要你手动建
还有一层需要澄清:Vue 项目运行时根本不需要你把模板文件“放”在任何地方让浏览器直接读取。构建工具会在编译阶段把.vue文件里的<template>编译成render函数,最终产物是 JS 而不是 HTML 模板文件。所以你不需要建立一个templates静态目录,更不需要在public里放.vue模板——那不会被正确解析。
理解了这一点,后面所有目录规划才有意义。
2. 组件模板的停放准则:views、components与就近原则
框架本身不强制规定组件模板必须放哪个目录,但社区经过多年实践,沉淀出了一套被广泛接受的默认工程结构。遵守它,团队协作时会省很多沟通成本。
2.1 标准目录结构与“模板跟着组件走”
一个典型的 Vue 项目(基于 Vue CLI 或 Vite 创建)核心结构如下:
my-project/ ├── public/ │ ├── index.html │ └── favicon.ico ├── src/ │ ├── assets/ │ ├── components/ │ ├── router/ │ ├── stores/ │ ├── views/ │ ├── App.vue │ └── main.js ├── .gitignore ├── package.json └── vite.config.js模板存放在哪,取决于你写的组件是什么角色:
src/views/(或src/pages/):放页面级组件,也就是路由直接渲染的顶层组件。每个文件对应一个页面,文件名通常与路由名称保持一致。一个HomeView.vue负责一个路由入口的完整页面骨架,它的<template>描述整页布局。src/components/:放可复用组件,比如按钮、弹窗、表格、卡片等。这类组件文件同样自带模板。如果组件内部又拆了子组件,可以就近建一个子目录,子组件的模板跟着父组件文件走,形成独立小单元。src/App.vue:根组件,是所有组件的入口壳子,它的模板一般只保留全局性的布局层内容,例如<router-view>挂载点、公共 header/footer。
这个结构背后是一个经典原则:就近原则。组件模板应该离逻辑最近,放在组件文件内部;组件文件放在语义清晰的目录里。不要为了“找模板”而把模板单独抽到一个叫template的目录——除非你在做编辑器拖拽生成代码之类的高级二次开发,那不在常规工程讨论范围内。
2.2 容器组件与展示组件的拆分,决定了模板的归属
很多人问“模板放哪”时,其实真正纠结的是:一个表单页面,到底把整块表单写成一个大组件,还是拆成多个小组件?模板要怎么跟着拆?
我一般建议按“容器组件 + 展示组件”的思路拆。容器组件负责数据请求和业务状态管理,模板里渲染的是逻辑骨架;展示组件负责 UI 呈现,模板里写死结构,通过 props 接收数据、通过 emit 抛事件。
举例:
src/views/order/ ├── OrderList.vue // 容器组件:路由入口,拉数据、处理翻页 ├── OrderListItem.vue // 展示组件:单行订单渲染 ├── OrderFilterBar.vue // 展示组件:筛选条件栏OrderList.vue的模板里,写的是<OrderFilterBar :filters="filters" @change="onFilterChange" />这类组合关系;OrderFilterBar.vue的模板里,才是具体的<input>、<select>结构。每个组件的模板都紧跟自身文件,互不越界。
判断准则:如果你发现一个组件文件里的 template 超过 300 行,大概率它承担了太多职责,需要拆。拆出来的每个模板,都放到对应角色的组件文件里,而不是丢进一个公共的templates目录。
2.3 明确划分作用域:组件模板不受全局样式“连坐”
组件模板放在.vue文件内部还有一个隐藏福利:可以用<style scoped>把样式作用域限制在当前模板内。构建工具会给模板元素加上带有 hash 的 data 属性,实现样式隔离。
如果你把模板 HTML 抽出去写成公共片段,再通过某种方式拼进组件,你会瞬间失去:
- scoped 样式的自动属性匹配;
- 构建时的模板编译检查(比如标签未闭合、表达式写错);
- 组件内模板 ref、指令绑定的类型推导。
这些都是在真实项目中反复出现的返工原因。所以我的态度一直很明确:常规业务页面里,模板不存在“放哪”的争议,它就该老老实实待在组件文件里。
3. 入口HTML模板:public/index.html为什么是特例
如果说组件模板是 Vue 领域的事,那入口 HTML 模板就是构建工具的事。它的位置在不同脚手架下有明显差异,这也是最容易让人困惑的点。
3.1 Vue CLI 与 Vite 对 index.html 的处理不一样
Vue CLI 生成的项目里,public/index.html是以模板身份存在的,html-webpack-plugin会读取它,往里注入编译后的 JS/CSS 标签,再输出到dist目录。此时public/下的其他静态资源会被原样拷贝,不出现在构建产物中。
Vite 的默认结构与 Vue CLI 不同:index.html直接放在项目根目录,它是 Vite 的入口文件。项目启动或构建时,Vite 解析这个 HTML,找到<script src="/src/main.js">作为应用入口,再编译后续的.vue文件。Vite 项目也有public/目录,但它只存放静态资源,不承担 HTML 模板角色。
| 脚手架 | 入口 HTML 模板位置 | 底层机制 |
|---|---|---|
| Vue CLI(webpack) | public/index.html | html-webpack-plugin 注入打包资源 |
| Vite | 根目录index.html | Vite 将 HTML 作为模块图起点 |
如果你在 Vite 项目里把index.html塞进public,启动后大概率出现白屏,因为 Vite 根本找不到入口文件。这个坑我见过不止一个人踩进去。
3.2 修改 title、meta 的正确姿势
确定了入口模板位置后,“怎么改页面标题”就成了下一个高频问题。
最直接的方式是改index.html里的<title>标签。但 SPA 项目往往希望不同路由显示不同标题,这时应该用vue-router的路由 meta 配合afterEach钩子动态设置:
router.afterEach((to) => { document.title = to.meta?.title ? `${to.meta.title} - 管理系统` : '默认标题' })不要为了多个标题去复制多份 HTML 模板,那是多页应用才需要考虑的事。SPA 的本质是单页外壳,页面内容交给 Vue 组件模板去渲染,HTML 模板只提供最外层骨架。
3.3 多页面应用与多个 HTML 模板的摆放
如果确实要做多页应用,比如后台管理系统要同时输出管理端和用户端两个入口,Vue CLI 可以在vue.config.js中通过pages字段配置多模板:
module.exports = { pages: { admin: { entry: 'src/admin/main.js', template: 'public/admin.html', filename: 'admin.html' }, client: { entry: 'src/client/main.js', template: 'public/client.html', filename: 'client.html' } } }此时每个入口对应一个独立的 HTML 模板文件,都放在public/下,通过构建配置关联。Vite 侧则通过在根目录放多个 HTML 文件,并在入口脚本里用<script>标签关联实现。
这类场景比较少见,但如果遇到了,记住一条:HTML 入口模板和业务组件模板是两条线,前者放在公共外壳层,后者放在组件模块内部。
4. 脚手架模板与团队preset:新项目的初始文件从哪来
“文件模板”还有一层意思,是指创建新项目时用到的那套初始文件模板。很多团队每次建项目都重复配置 ESLint、Prettier、路由、状态管理,其实这些都可以固化成模板。
4.1 vue create 的 preset 机制
用 Vue CLI 创建项目时,如果你手动选过一遍特性,CLI 会提示是否保存为预设。保存后,配置会写入用户目录下的~/.vuerc文件:
{ "useConfigFiles": true, "plugins": { "@vue/cli-plugin-babel": {}, "@vue/cli-plugin-router": { "historyMode": true }, "@vue/cli-plugin-vuex": {} }, "vueVersion": "3" }以后执行vue create my-project,选择对应预设,就会按照这套配置生成项目模板。这个文件的位置是:Windows 在C:\Users\<用户名>\.vuerc,macOS/Linux 在~/.vuerc。
如果你用的是新版官方脚手架create-vue,交互式生成的是 Vite + Vue 3 模板,它本身没有 preset 文件,但支持通过命令行参数直接指定特性组合,比如:
npm create vue@latest my-project -- --ts --router --pinia这类模板化的思路是:把“初始化配置”从人的记忆里挪到配置文件里。新建项目时,直接复用脚本,比每次手动点十几次选项要稳定得多。
4.2 团队模板仓库与初始化脚本
preset解决的只是 CLI 层面的选项预设,但很多团队还想统一目录结构、工具链版本、基础组件库封装。更实际的做法是维护一个模板 Git 仓库。
我的习惯是:
- 在 Git 平台建一个
vue-project-template仓库,存放基础项目代码; - 开发新项目时,克隆模板仓库,删除
.git目录,重新初始化; - 用一个初始化脚本统一处理:克隆、安装依赖、修改项目名、替换
README、提交首次 commit。
脚本核心逻辑大致是:
git clone git@github.com:your-team/vue-project-template.git new-project cd new-project rm -rf .git npm install node scripts/rename.js --name new-project git init && git add . && git commit -m "init project"这样团队每个项目的“模板”都是同源的,模板仓库放在哪、由谁维护也一目了然。比散落在个人电脑里的~/.vuerc更适合团队协作。
4.3 模板初始化时的两个高频坑
第一,模板仓库里的依赖版本会快速过期。我建议模板仓库使用模糊版本号,并在模板中配置统一的依赖升级脚本(比如ncu),而不是锁死 patch 版本。不然三个月后新建的项目,从第一天起就带着一堆安全漏洞。
第二,模板仓库里的.gitignore容易被忽略。node_modules、dist、.env.local这些必须提前写好,否则第一次提交就会把构建产物和依赖目录推上远程仓库,后续想清理会很头疼。
5. 编辑器片段模板:VS Code里的Vue速写存放在哪
“模板文件”的第四副面孔,是编辑器里的代码片段(snippet)。这玩意儿和项目无关,但能极大提升你写 Vue 组件时的效率,特别是在重度重复的初始化结构上。
5.1 用户代码片段的存放路径
VS Code 的用户代码片段有两个级别:
- 全局用户片段:对所有项目生效,配置文件存放在用户设置的
snippets目录里。Windows 路径是%APPDATA%\Code\User\snippets\,macOS 是~/Library/Application Support/Code/User/snippets/,Linux 是~/.config/Code/User/snippets/。 - 项目级片段:只在当前项目生效,放在项目根目录的
.vscode/snippets/下。
打开方式很简单:命令面板搜索“配置用户代码片段”,选择vue或vue-html,就会打开对应的 JSON 文件。如果你希望团队共享同一套 Vue 片段,就放在项目的.vscode/snippets/目录里一起提交到 Git 仓库。
5.2 一份可以直接抄的Vue文件片段配置
这里给出一份我实际在用的 Vue 3 单文件模板片段:
{ "Vue3 SFC Base": { "scope": "vue", "prefix": "vue3sfc", "body": [ "<template>", " <div class=\"$1\">$0</div>", "</template>", "", "<script setup>", "import { ref, computed } from 'vue'", "", "const $1 = ref('')", "</script>", "", "<style scoped>", "</style>" ], "description": "Vue3 组合式单文件组件基础模板" } }在.vue文件里输入vue3sfc,按 Tab 就会展开。$1是第一个光标位置,$0是最后停靠位置。把类名和状态名绑定为同一个占位符,可以减少重复输入。
类似的片段还可以配置:vif展开v-if、vfor展开v-for、importref展开import { ref } from 'vue'。这些片段文件存放的位置就是上面说的两个目录,属于纯编辑器行为,不进项目构建链路。
5.3 Volar、Vetur 与自动生成模板
Vue 官方推荐的 Volar 插件(即 Vue Language Features)内置了一些代码能力,但它不负责生成整套 SFC 骨架。如果你装了Vue Volar extension pack,新建.vue文件后它可能自动给出基础结构,这属于扩展自带的 file template 机制,位置在扩展安装目录里,一般不需要手动维护。
有一点需要提醒:如果你的团队混用了 Vetur 和 Volar,Vue 2 时代的 Vetur 在 Vue 3 项目里可能会提示一堆格式问题。模板相关能力建议统一走 Volar,别在同一个工作区里同时启用两者,不然代码片段、语法高亮、跳转都会互相干扰。
6. 越界场景:动态模板、render函数与运行时编译
有些需求会让你产生“把模板动态拼出来”的冲动——比如后台返回一段 HTML 片段,比如根据 JSON 配置动态渲染表单,比如做一个低代码页面。这时“模板放哪”会变成一个真正有深度的工程问题。
6.1 字符串模板何时才会浮出水面
Vue 的 SFC 模板是在构建期被编译成 render 函数的。也就是说,运行时你手里拿到的组件里,根本不存在待编译的模板字符串。如果你在业务代码里写:
const template = '<div>{{ msg }}</div>'然后把这段字符串丢给某个组件去渲染,默认情况下(运行时构建 + 未引入编译器)是跑不起来的,控制台会报“You are using the runtime-only build of Vue where the template compiler is not available”。
此时你只有三条路:
- 改用带编译器的构建版 Vue,把
vue别名指到vue/dist/vue.esm-bundler.js,性能差、包体大,一般只在特殊调试场景用; - 用
render函数或 JSX 直接描述动态结构,不依赖模板编译; - 使用
defineComponent配合() => import()异步加载真正的.vue文件,让模板一直停留在 SFC 文件内。
我的建议是优先走 2 和 3。低代码类需求可以用动态组件<component :is="...">,配合映射表去加载不同组件文件,模板仍然放在各自的.vue文件里,只是由运行时去做切换。
6.2 render函数与JSX的取舍
当你要做高度动态的渲染,比如根据配置生成表单字段列表,模板会变得极其臃肿。此时把“模板”换写成 render 函数反而更清晰:
import { h } from 'vue' const FieldRenderer = { props: { field: { type: Object, required: true } }, setup(props) { return () => { switch (props.field.type) { case 'input': return h('input', { value: props.field.value }) case 'select': return h('select', props.field.options.map(opt => h('option', opt.label))) default: return h('span', props.field.value) } } } }这样写,模板不是消失了,而是变成了 h 函数组成的结构树。它的“存放位置”就在组件内部,跟 SFC 模板属于同一个地方——它仍是组件描述的一部分。团队里如果已经普及 JSX,把FieldRenderer写成.tsx文件放到src/components/下也行,只是要注意统一转译配置。
6.3 安全边界:模板不是拼接HTML
最后必须提醒一句:不要把“模板”和“HTML 字符串拼接”混为一谈。有人看到需求是动态渲染富文本,就顺手把后台返回的 HTML 字符串用v-html直接塞进模板:
<div v-html="contentFromServer"></div>这是极其危险的。v-html是明文插入 HTML,不是 Vue 模板编译。外部输入的 HTML 一旦包含恶意脚本,XSS 风险直接拉满。如果你的业务必须渲染富文本,可以使用经过消毒的富文本渲染方案,比如先通过 DOMPurify 清洗,再交给内容区组件渲染;不要把后端返回的原始 HTML 当成“模板”直接用。
动态渲染表单也好、渲染后端配置也好,核心边界都一致:结构性内容走组件化模板,可信静态 HTML 才考虑直接注入,不可信内容一律先清洗再处理。
我个人在实际项目里维护过一套基于配置的表单引擎,最初也试图把“模板”抽到一个公共目录,做了两个迭代后发现,越抽越难维护。后来彻底转向动态组件 + render 函数,模板还是老老实实待在各自组件里,只是由配置去驱动发起哪个组件、传什么参数。项目结构清爽了很多,新人上手也不需要再理解“模板层”这种自定义概念。
如果你现在也在规划 Vue 项目的目录,先问自己一句:你所说的“文件模板”是组件模板、入口 HTML、脚手架模板还是编辑器片段?确认好这个,再去对照目录结构,几乎不会走弯路。项目里真的没有一个叫template的文件夹等着你创建——好的模板,都活在它们该在的组件里。