99% 的 Vue 开发者都搞错了:template:‘<App/>‘ 和 render:h=>h(App) 根本不是「两种写法」,而是「两个世界」

一行 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> 直引 Vuewebpack + 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-loadervueify 时,*.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.jsCDN <script src>(unpkg/jsdelivr 指向)5
dist/vue.runtime.common.dev.jsrequire('vue') 默认(webpack 用)0

而那句招牌报错 You are using the runtime-only build... 的字符串,正是从
vue.runtime.common.dev.js 里搜出来的
—— 它本就是「无编译器版」用来提醒你的。

结论:「完整版含编译器、仅运行时不含」「CDN 默认完整版、打包默认仅运行时」
这些都不是经验之谈,而是官方文档明文 + 本机产物可复现的事实。


零之二、用真实代码看清:打包后「template 这个东西根本不存在了」

很多人以为 templaterender 只是「两种等价写法、看个人喜好」。错。在 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.jsondependencies 里,凡是不在 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.jsCommonJS✅ 完整版require('vue/dist/vue.common.js')
vue.esm.jsESM✅ 完整版当前 alias 指向它
vue.runtime.common.jsCommonJS❌ 仅运行时require('vue') 默认命中这个
vue.runtime.esm.jsESM❌ 仅运行时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-only main,直接指向带编译器的完整版。
    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"   // ← 已经是预编译好的产物
}

差别就在这里:

  1. element-ui 没有「完整版 / runtime-only」之分。
    它本身是一个组件库,发布前所有 .vue 组件早已被编译成 render 函数
    lib/element-ui.common.js 就是编译后的成品。它运行时根本不需要模板编译器
    自然不存在「命中 runtime-only 入口」这种问题。

  2. 它内部用到的 vue 也是运行时 API。
    element-ui 内部 require('vue') 拿到的即便是 runtime-only 版本也没关系 ——
    因为它用的全是 Vue.extendVue.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 警告时)

  1. 看报错里的 (found in <XXX>),定位是哪个实例/组件触发了运行时编译。
  2. 确认该模块是「被打包」还是「external」:
    • 被打包 → 检查 resolve.alias 是否指向了完整版。
    • external → 检查 external 是否重定向到完整版(alias 此时无效)。
  3. node_modules/对应包/package.jsonmain,确认默认入口是不是 runtime-only。
  4. 如果是自己项目的根实例报错,考虑把入口改成 render: h => h(App)
    从根上消除对运行时编译器的依赖。

九、彻底理清:根本不存在「一个」模板编译,而是两个

讨论里最容易把人绕晕的,是把两种完全不同的「模板编译」当成了一回事。务必分开:

谁来做何时做处理对象
A. SFC 编译vue-loader(构建工具)打包时你项目里的 .vue 文件
B. 运行时模板编译vue 库内置的编译器运行时字符串模板 / DOM 模板

报错里缺的是 B.vue 文件(A)毫无关系。常见的错误心智模型是:

❌「未打包时,引入 .vue 文件需要先把它编译成 js 函数体再运行,所以要编译器。」

这是错的。纠正:

  1. .vue 文件永远在打包时被 vue-loader 编译,跟 vue 是不是 external、是不是 runtime-only
    完全无关whiteListedModules 管的是「vue 这个库本体打不打包」,
    根本不碰你的 .vue 文件。这是两件独立的事。
  2. vue-loader 只认 .vue 文件里的 <template> 块。 main.js 里写的
    template: '<App/>' 只是一个普通 JS 字符串字面量,webpack「看不见」它、
    没有任何 loader 会去解析它,于是原样打包进 bundle,一路裸奔到运行时才被发现要编译。

所以「需要编译器」这件事,只可能由「运行时还以字符串/DOM 形式存在的模板」触发
而这种模板只会出现在你自己还没被预编译的入口代码里(就是 template: '<App/>')。


十、为什么「直接从 node_modules 引入」永远不会触发编译器问题?

因为凡是发布到 npm 的包,都已经过了它自己的一次构建。node_modules 里全是「成品」:

  1. 没有 .vue 文件 —— 作者发布前已用自己的 vue-loader 把 .vue 编译成 render 函数。
  2. 没有运行时字符串模板 —— 组件库内部一律用 render 函数 / 预编译产物,
    从不写 new Vue({ template: '...' })

所以 require 一个 node_modules 包,本质是调用一堆已编译好的 render 函数
运行时没有任何模板需要编译,编译器在不在都无所谓。一张表收束:

来源里面有 .vue有运行时字符串模板?会触发编译器?
node_modules 成品包(element-ui 等)❌ 发布前已编译❌ 内部用 render永不
你项目的 .vue 文件✅ 但被 vue-loader 打包时吃掉不会
你入口 main.jstemplate:'<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 个用了运行时字符串模板:

入口原写法是否触发编译器
mainrender: (h) => h(App)❌ 不触发(一直安全)
copilotrender: (h) => h(App)❌ 不触发
login-registertemplate: '<App/>'元凶
pre-loadertemplate: '<App/>'元凶
remindtemplate: '<App/>'元凶
pettemplate: '<App/>'元凶
pet-notificationtemplate: '<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.jswhiteListedModules / externals / resolve.alias 三处联动
  • node_modules/vue/package.jsonmain 指向 runtime-only 是一切的起点
  • node_modules/element-ui/package.jsonmain 是预编译成品,所以无需处理
  • 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 完整版 vs vue.runtime.* 仅运行时版的官方对照表)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

森叶

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值