一行
template: '<App/>',让整个打包产物白白塞进一个模板编译器——而你可能从头到尾都不知道它在那。背景:把
webpack.renderer.common.js里的whiteListedModules置空后,打包出来的
渲染层突然报错[Vue warn]: You are using the runtime-only build of Vue where the template compiler is not available。同样被改成 external 的element-ui却安然无恙。
顺着这条线挖下去,会挖出一个绝大多数人都忽略的真相:template选项和render
函数,背后对应的是<script src>直引 Vue 和 webpack + vue-loader 打包
两套完全不同的玩法。本文从头讲透。
零、先记住这张图:你到底活在哪个世界?
┌──────────────────────── 世界 A:<script src="vue.js"> 直引 ────────────────────────┐
│ <script src="https://cdn/vue.js"></script> ← 必须是「完整版」(含编译器) │
│ <script> │
│ new Vue({ │
│ el: '#app', │
│ template: '<div>{{ msg }}</div>' ← 模板是「运行时才存在的字符串」 │
│ }) │
│ </script> │
│ 浏览器里:Vue 内置编译器把字符串 → render 函数 → 再渲染。【编译器必须在场】。 │
│ (刚需的是「编译器」;template 只是这个世界最自然的写法,你也可手写 render+runtime)│
└────────────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────── 世界 B:webpack + vue-loader 打包 ──────────────────────────┐
│ App.vue 里写 <template>...</template> │
│ │ vue-loader 在【打包时】就把 <template> 翻译成 render 函数 │
│ ▼ │
│ 打包产物里【根本没有 template 这个东西】,只剩 render 函数 │
│ │ │
│ ▼ │
│ 运行时直接执行 render,【完全不需要编译器】→ 用 runtime-only 版 Vue 即可、体积更小 │
│ (前提:全程不再混入运行时字符串/DOM 模板,即不写 template:/el:) │
└────────────────────────────────────────────────────────────────────────────────────┘
template 是世界 A 的母语,render 是世界 B 的母语。 项目用 webpack 打包,
本该全程待在世界 B,却在入口 main.js 里写了一句世界 A 的 template: '<App/>',
等于把一只脚跨回了世界 A —— 于是被迫拖着世界 A 才需要的编译器。这就是一切的根源。
零之一、这不是我的臆想:官方依据 + 产物实锤
「两个世界」的说法直接来自 Vue 官方文档的「运行时 + 编译器 vs. 仅运行时」
(Runtime + Compiler vs. Runtime-only)一节,原文(意译):
- 「如果你需要在客户端编译模板(比如给
template选项传字符串,
或挂载到一个元素并以它的 in-DOM HTML 作为模板),就需要编译器,也就是完整版。」- 「当使用
vue-loader或vueify时,*.vue文件里的模板会在构建时
预编译成 JavaScript。最终 bundle 里并不需要编译器,因此可以用仅运行时构建。」—— Vue 2 官方文档 “Installation › Runtime + Compiler vs. Runtime-only”
(https://v2.vuejs.org/v2/guide/installation.html#Runtime-Compiler-vs-Runtime-only)
这两段就是本文「世界 A / 世界 B」的官方出处。下面再用本机产物验证它不是空话。
实锤①:vue 自己的 package.json 把分界写死了
// node_modules/vue/package.json (v2.7.16)
"main": "dist/vue.runtime.common.js", // 打包器 require('vue') 默认走这 → 仅运行时
"module": "dist/vue.runtime.esm.js", // ESM 入口同样是仅运行时
"unpkg": "dist/vue.js", // CDN <script src> 走这 → 完整版
"jsdelivr": "dist/vue.js" // 同上
- 世界 A(CDN / unpkg / jsdelivr)默认拿到
dist/vue.js= 完整版(含编译器); - 世界 B(webpack
require('vue'))默认拿到vue.runtime.common.js= 仅运行时(无编译器)。
实锤②:直接数两个产物里「编译器」的有无
用 compileToFunctions(Vue 的模板编译入口函数)当探针:
| 文件 | 用途 | compileToFunctions 命中 | 含编译器? |
|---|---|---|---|
dist/vue.js | CDN <script src>(unpkg/jsdelivr 指向) | 5 | ✅ |
dist/vue.runtime.common.dev.js | require('vue') 默认(webpack 用) | 0 | ❌ |
而那句招牌报错 You are using the runtime-only build... 的字符串,正是从
vue.runtime.common.dev.js 里搜出来的 —— 它本就是「无编译器版」用来提醒你的。
结论:「完整版含编译器、仅运行时不含」「CDN 默认完整版、打包默认仅运行时」
这些都不是经验之谈,而是官方文档明文 + 本机产物可复现的事实。
零之二、用真实代码看清:打包后「template 这个东西根本不存在了」
很多人以为 template 和 render 只是「两种等价写法、看个人喜好」。错。在 webpack
项目里,<template> 标签在打包那一刻就被 vue-loader 物理删除了,产物里只剩
render 函数。下面一步步看给你。
① 你写的 App.vue
<!-- App.vue -->
<template>
<div class="app">{{ msg }}</div>
</template>
<script>
export default { data: () => ({ msg: 'hi' }) }
</script>
② vue-loader 在【打包时】把它翻译成什么
<template> 块被编译成一个 render 函数,挂到组件 options 上。打包产物里再也找不到
<div class="app"> 这个字符串模板,取而代之的是等价的 render 代码(示意):
// App.vue 打包后(简化示意)—— 注意:没有 template 字段了!
export default {
data: () => ({ msg: 'hi' }),
render(h) {
return h('div', { class: 'app' }, [this._v(this._s(this.msg))])
},
}
关键:编译这一步发生在「打包时」,干活的是 vue-loader(一个构建工具),
不是 Vue 运行时。 所以运行时拿到的 App 已经是「带 render、无 template」的成品,
不需要 Vue 里那个编译器再插手。这正是世界 B 的精髓。
③ 两种入口写法,命运截然不同
// ❌ 世界 A 的写法混进了世界 B:留下一段「运行时字符串模板」
new Vue({
components: { App },
template: '<App/>', // ← 这串字符串 vue-loader【看不见】(它只编 .vue 里的 <template>)
}).$mount('#app') // → 原样打进 bundle → 运行时才需编译 → 必须带编译器
// ✅ 纯正世界 B:直接给 render,运行时无任何模板可编
new Vue({
render: (h) => h(App), // ← 你手写的 render,等价于「把 <App/> 提前编译好」
}).$mount('#app') // → 产物里没有任何 template 字符串 → 不需要编译器
两者的差别不是风格,是产物里到底还有没有「待编译的模板」:
template: '<App/>' | render: (h) => h(App) | |
|---|---|---|
| 这段「模板」谁来编译? | Vue 运行时编译器(打包后才编) | 你自己(写代码时就编好了) |
| vue-loader 能处理它吗? | ❌ 它是 js 字符串,不是 .vue 的 <template> | —— 无需处理 |
| 打包产物里还有模板吗? | ✅ 有,裸奔到运行时 | ❌ 完全没有 |
| 需要 Vue 带编译器吗? | ✅ 必须(runtime-only 就报错) | ❌ 不需要,runtime-only 即可 |
| 适合的世界 | 世界 A:<script src> 直引、手写组件 | 世界 B:webpack + vue-loader |
④ 为什么世界 A 离不开 template?
因为在 <script src="vue.js"> 直引场景下,没有 vue-loader、没有打包步骤,
你写的组件模板只能以字符串 / 页面里的 DOM形式存在,只能等浏览器里运行时再编译。
这时 template 选项是刚需,引入的 Vue 也必须是含编译器的完整版。Vue 把完整版做成
默认 CDN 产物,就是为了照顾这种「打开即用、不打包」的人群。
一句话定性:
template选项是为「不打包」的世界 A 准备的;一旦你用 webpack 打包
(世界 B),它就是个该被render取代的累赘——还会顺手把整个编译器拖下水。
一、先理解 externals 到底做了什么
渲染层 webpack 配置(.electron-vue/configs/webpack.renderer.common.js)里有这么一段:
externals: [
...Object.keys(dependencies || {}).filter(
(d) => !whiteListedModules.includes(d)
),
],
含义是:package.json 的 dependencies 里,凡是不在 whiteListedModules 白名单里的,
统统当成 external。
什么叫 external?就是 webpack 不把这个模块打进 bundle,而是在产物里原样保留
require('xxx') 这行语句,等运行时再从 node_modules 里加载。
这是 electron-vue 模板的惯用手法:Electron 渲染进程本身能访问 node_modules,
所以没必要把一堆第三方库塞进 bundle,留 external 可以减小体积、加快构建。
所以两个状态要分清:
| 状态 | webpack 行为 | 运行时行为 |
|---|---|---|
| 在白名单里(被打包) | 把模块源码打进 bundle,期间会应用 resolve.alias 等规则 | bundle 自带,不再 require |
| 不在白名单(external) | 产物里保留 require('xxx') | 运行时从 node_modules/xxx 加载 |
二、一个模块被 require 时,到底加载哪个文件?
require('vue') 并不是加载某个固定文件,而是去读 node_modules/vue/package.json,
按入口字段决定加载哪个构建产物。看 vue 2.7 的 package.json:
// node_modules/vue/package.json
{
"version": "2.7.16",
"main": "dist/vue.runtime.common.js", // ← CommonJS require 走这里
"module": "dist/vue.runtime.esm.js" // ← ESM import 走这里(打包器优先)
}
注意 main 指向的是 vue.runtime.common.js —— 名字里的 runtime 是关键。
Vue 2 发了好几个构建版本,核心差别是带不带「模板编译器」:
| 文件 | 模块格式 | 带模板编译器? | 谁会用到 |
|---|---|---|---|
vue.common.js | CommonJS | ✅ 完整版 | require('vue/dist/vue.common.js') |
vue.esm.js | ESM | ✅ 完整版 | 当前 alias 指向它 |
vue.runtime.common.js | CommonJS | ❌ 仅运行时 | require('vue') 默认命中这个 |
vue.runtime.esm.js | ESM | ❌ 仅运行时 | import 默认命中这个 |
「模板编译器」负责把字符串模板 / DOM 模板编译成 render 函数。没有它,
遇到需要运行时编译的模板就会抛出本文开头那条警告。
三、那「需要运行时编译的模板」到底指什么?
这里最容易搞混。分两种情况:
1. .vue 单文件组件 —— 不需要运行时编译器
.vue 文件在打包阶段就被 vue-loader 编译成 render 函数了(见配置里的
vue-loader rule)。所以哪怕用 runtime-only 版本,SFC 也能正常跑。
2. 运行时模板 —— 需要运行时编译器
典型是入口 main.js 里这种写法:
// 用 template 字符串
new Vue({
template: '<App/>',
components: { App },
}).$mount('#app')
// 或者 el 挂载到一段已有 DOM,把 DOM 当模板
new Vue({
el: '#app', // #app 里的 HTML 会被当成模板去编译
// 没有 render 函数
})
这两种都要在运行时把模板编译成 render 函数,于是必须用带编译器的完整版。
报错信息里的 (found in <Root>) 就是指根实例 —— 说明项目的入口正是这种写法。
对照:如果入口写成
new Vue({ render: h => h(App) }).$mount('#app'),
就完全不需要运行时编译器,runtime-only 版也能跑。
四、把上面三点拼起来 —— bug 的完整因果链
原来白名单是 ['vue', 'element-ui'],vue 被打包,打包时 resolve.alias 生效:
resolve: {
alias: {
vue$: 'vue/dist/vue.esm.js', // ← 强制 vue 解析到「完整版」
},
}
所以打包进 bundle 的是带编译器的 vue.esm.js,运行时模板能正常编译。
置空白名单后,vue 变成 external,链条全断了:
whiteListedModules = []
│
▼
vue 不在白名单 → 被划为 external
│
▼
产物里保留 require('vue') ← alias 对 external 完全不生效!
│
▼
运行时按 package.json 的 main 加载
│
▼
命中 dist/vue.runtime.common.js(runtime-only,无编译器)
│
▼
根实例用了运行时模板 → 找不到编译器 → [Vue warn] 报错
关键坑点:resolve.alias 只对「被 webpack 打包」的模块生效,对 external 一点用都没有。
external 在产物里就是一句裸的 require('vue'),alias 根本没机会介入。
五、修复:让 vue 不打包,又能拿到完整版
既然 alias 管不到 external,那就直接在 external 这一层做重定向:
externals: [
// vue 不打包,但把 external 指向含模板编译器的完整 CommonJS 构建
{vue: 'commonjs vue/dist/vue.common.js'},
...Object.keys(dependencies || {}).filter(
(d) => d !== 'vue' && !whiteListedModules.includes(d)
),
],
{vue: 'commonjs vue/dist/vue.common.js'}
让产物里的require('vue')变成require('vue/dist/vue.common.js')
—— 绕开默认的 runtime-onlymain,直接指向带编译器的完整版。
(vue.common.js内部会按process.env.NODE_ENV自动切 dev/prod,无需额外处理。)- 过滤里加
d !== 'vue',避免 vue 又被下面的...Object.keys当成普通字符串
external 加一遍,造成重定向被覆盖。
这样 vue 既不进 bundle(体积小),运行时又自带编译器(不报错)。
六、回到正题:为什么 element-ui 不用做任何处理?
同样看它的入口字段:
// node_modules/element-ui/package.json
{
"version": "2.15.14",
"main": "lib/element-ui.common.js" // ← 已经是预编译好的产物
}
差别就在这里:
-
element-ui 没有「完整版 / runtime-only」之分。
它本身是一个组件库,发布前所有.vue组件早已被编译成 render 函数,
lib/element-ui.common.js就是编译后的成品。它运行时根本不需要模板编译器,
自然不存在「命中 runtime-only 入口」这种问题。 -
它内部用到的 vue 也是运行时 API。
element-ui 内部require('vue')拿到的即便是 runtime-only 版本也没关系 ——
因为它用的全是Vue.extend、Vue.component等运行时 API,
以及自己预编译好的 render 函数,从不在运行时编译模板。
一句话总结:
vue 的默认入口是「缺了编译器的半成品」,而 element-ui 的默认入口是
「编译完的成品」。 只有「半成品 + 运行时还要编译模板」这个组合才会出事,
所以唯独 vue 需要重定向到完整版。
七、三种方案对比(按需选择)
| 方案 | 渲染层 bundle 体积 | 运行时开销 | 何时选 |
|---|---|---|---|
| vue 进白名单打包(原 HEAD 写法) | 大(vue + 编译器都进包) | 无额外 require | 想省事、不在意体积 |
external + 重定向到 vue.common.js(当前写法) | 小 | 启动多一次 require + 运行时编译 | 想减小 bundle / 加快构建 |
| 入口改用 render 函数 + external runtime-only | 最小(连编译器都不带) | 最低 | 追求极致体积,愿意改 main.js 入口 |
八、排查清单(下次再遇到 runtime-only 警告时)
- 看报错里的
(found in <XXX>),定位是哪个实例/组件触发了运行时编译。 - 确认该模块是「被打包」还是「external」:
- 被打包 → 检查
resolve.alias是否指向了完整版。 - external → 检查 external 是否重定向到完整版(alias 此时无效)。
- 被打包 → 检查
- 看
node_modules/对应包/package.json的main,确认默认入口是不是 runtime-only。 - 如果是自己项目的根实例报错,考虑把入口改成
render: h => h(App),
从根上消除对运行时编译器的依赖。
九、彻底理清:根本不存在「一个」模板编译,而是两个
讨论里最容易把人绕晕的,是把两种完全不同的「模板编译」当成了一回事。务必分开:
| 谁来做 | 何时做 | 处理对象 | |
|---|---|---|---|
| A. SFC 编译 | vue-loader(构建工具) | 打包时 | 你项目里的 .vue 文件 |
| B. 运行时模板编译 | vue 库内置的编译器 | 运行时 | 字符串模板 / DOM 模板 |
报错里缺的是 B,跟 .vue 文件(A)毫无关系。常见的错误心智模型是:
❌「未打包时,引入
.vue文件需要先把它编译成 js 函数体再运行,所以要编译器。」
这是错的。纠正:
.vue文件永远在打包时被vue-loader编译,跟 vue 是不是 external、是不是 runtime-only
完全无关。whiteListedModules管的是「vue 这个库本体打不打包」,
它根本不碰你的.vue文件。这是两件独立的事。vue-loader只认.vue文件里的<template>块。main.js里写的
template: '<App/>'只是一个普通 JS 字符串字面量,webpack「看不见」它、
没有任何 loader 会去解析它,于是原样打包进 bundle,一路裸奔到运行时才被发现要编译。
所以「需要编译器」这件事,只可能由「运行时还以字符串/DOM 形式存在的模板」触发,
而这种模板只会出现在你自己还没被预编译的入口代码里(就是 template: '<App/>')。
十、为什么「直接从 node_modules 引入」永远不会触发编译器问题?
因为凡是发布到 npm 的包,都已经过了它自己的一次构建。node_modules 里全是「成品」:
- 没有
.vue文件 —— 作者发布前已用自己的 vue-loader 把.vue编译成 render 函数。 - 没有运行时字符串模板 —— 组件库内部一律用 render 函数 / 预编译产物,
从不写new Vue({ template: '...' })。
所以 require 一个 node_modules 包,本质是调用一堆已编译好的 render 函数,
运行时没有任何模板需要编译,编译器在不在都无所谓。一张表收束:
| 来源 | 里面有 .vue? | 有运行时字符串模板? | 会触发编译器? |
|---|---|---|---|
| node_modules 成品包(element-ui 等) | ❌ 发布前已编译 | ❌ 内部用 render | 永不 |
你项目的 .vue 文件 | ✅ 但被 vue-loader 打包时吃掉 | ❌ | 不会 |
你入口 main.js 写 template:'<App/>' | —— | ✅ 字符串原样留到运行时 | 会 ← 唯一元凶 |
报错的真正含义不是「引入 vue 出错」,而是「你的代码调用了
new Vue({template}),
向 Vue 要编译能力,但这个 vue 版本(runtime-only)没带」。
十一、template:'<App/>' 这一行的真实代价:是「常驻编译器」,不是「编译慢」
把 template 想成性能负担是误解。它的代价主要是一个,且重在体积:
- ① 真正的代价 —— 逼整个 app 必须携带编译器(体积)。
为了能在运行时编译那段字符串,Vue 必须用「完整版」。完整版比 runtime-only 大不少,
编译器约占 Vue 体积的三分之一。就为了<App/>这一个标签,得把整套编译器带进去。 - ② 几乎可忽略的代价 —— 启动时编译一次。
每个窗口启动把'<App/>'编译成 render 函数跑一次,但只有一个标签,耗时微乎其微。
不是「多跑一次编译很慢」,而是「为了这一行,被迫常驻一整个编译器」。
一个本可零成本写出的render: h => h(App),换来了「必须携带编译器」的固定开销。
这正是 Vue 官方默认推荐 runtime-only 的原因:模板编译应在构建期一次性做完
(vue-loader 干掉 .vue),不该把编译器拖到运行时。
十二、本项目的实际元凶与已落地修复
排查 src/renderer/pages/*/main.js 七个入口,发现只有 5 个用了运行时字符串模板:
| 入口 | 原写法 | 是否触发编译器 |
|---|---|---|
main | render: (h) => h(App) | ❌ 不触发(一直安全) |
copilot | render: (h) => h(App) | ❌ 不触发 |
login-register | template: '<App/>' | ✅ 元凶 |
pre-loader | template: '<App/>' | ✅ 元凶 |
remind | template: '<App/>' | ✅ 元凶 |
pet | template: '<App/>' | ✅ 元凶 |
pet-notification | template: '<App/>' | ✅ 元凶 |
修复(已落地):把这 5 个入口的 components: { App } + template: '<App/>'
统一改成 render: (h) => h(App)。改完后渲染层全局零运行时模板,
从根上不再依赖运行时编译器——无论 vue 走打包还是 external runtime-only 都不会再报错。
这是比「external 重定向到
vue.common.js」更本质的修法:前者是绕过症状(仍带编译器),
后者是消除病因(连编译器都不需要)。注:本项目最终把
whiteListedModules保留为['vue', 'element-ui'](vue 仍打包、
走 alias 完整版),属最省事的稳妥态;但因 5 个入口已改 render,即便日后把 vue 改成
external runtime-only 也不会再触发本问题。这条「入口只用 render 函数」的约定才是真正的护栏。
相关文件
.electron-vue/configs/webpack.renderer.common.js—whiteListedModules/externals/resolve.alias三处联动node_modules/vue/package.json—main指向 runtime-only 是一切的起点node_modules/element-ui/package.json—main是预编译成品,所以无需处理src/renderer/pages/*/main.js— 渲染入口,一律render: (h) => h(App),禁用template: '<App/>'
参考资料(官方依据)
- Vue 2 官方文档 · Runtime + Compiler vs. Runtime-only:
https://v2.vuejs.org/v2/guide/installation.html#Runtime-Compiler-vs-Runtime-only
(「世界 A / 世界 B」与「打包后不需要编译器」的原始出处) - Vue 2 官方文档 · 不同构建版本说明(Explanation of Different Builds):
https://v2.vuejs.org/v2/guide/installation.html#Explanation-of-Different-Builds
(vue.js完整版 vsvue.runtime.*仅运行时版的官方对照表)
615

被折叠的 条评论
为什么被折叠?



