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

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直引 Vue和webpack vue-loader 打包两套完全不同的玩法。本文从头讲透。零、先记住这张图你到底活在哪个世界┌──────────────────────── 世界 Ascript srcvue.js 直引 ────────────────────────┐ │ script srchttps://cdn/vue.js/script ← 必须是「完整版」(含编译器) │ │ script │ │ new Vue({ │ │ el: #app, │ │ template: div{{ msg }}/div ← 模板是「运行时才存在的字符串」 │ │ }) │ │ /script │ │ 浏览器里Vue 内置编译器把字符串 → render 函数 → 再渲染。【编译器必须在场】。 │ │ 刚需的是「编译器」template 只是这个世界最自然的写法你也可手写 renderruntime│ └────────────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────── 世界 Bwebpack 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 // 同上世界 ACDN / unpkg / jsdelivr默认拿到dist/vue.js完整版含编译器世界 Bwebpackrequire(vue)默认拿到vue.runtime.common.js仅运行时无编译器。实锤②直接数两个产物里「编译器」的有无用compileToFunctionsVue 的模板编译入口函数当探针文件用途compileToFunctions命中含编译器dist/vue.jsCDNscript srcunpkg/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 这个东西根本不存在了」很多人以为template和render只是「两种等价写法、看个人喜好」。错。在 webpack项目里template标签在打包那一刻就被 vue-loader 物理删除了产物里只剩render 函数。下面一步步看给你。① 你写的App.vue!-- App.vue -- template div classapp{{ msg }}/div /template script export default { data: () ({ msg: hi }) } /script② vue-loader 在【打包时】把它翻译成什么template块被编译成一个render函数挂到组件 options 上。打包产物里再也找不到div classapp这个字符串模板取而代之的是等价的 render 代码示意// App.vue 打包后简化示意—— 注意没有 template 字段了exportdefault{data:()({msg:hi}),render(h){returnh(div,{class:app},[this._v(this._s(this.msg))])},}关键编译这一步发生在「打包时」干活的是 vue-loader一个构建工具不是 Vue 运行时。所以运行时拿到的 App 已经是「带 render、无 template」的成品不需要 Vue 里那个编译器再插手。这正是世界 B 的精髓。③ 两种入口写法命运截然不同// ❌ 世界 A 的写法混进了世界 B留下一段「运行时字符串模板」newVue({components:{App},template:App/,// ← 这串字符串 vue-loader【看不见】(它只编 .vue 里的 template)}).$mount(#app)// → 原样打进 bundle → 运行时才需编译 → 必须带编译器// ✅ 纯正世界 B直接给 render运行时无任何模板可编newVue({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 即可适合的世界世界 Ascript src直引、手写组件世界 Bwebpack vue-loader④ 为什么世界 A 离不开template因为在script srcvue.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.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-loaderrule。所以哪怕用 runtime-only 版本SFC 也能正常跑。2. 运行时模板 ——需要运行时编译器典型是入口main.js里这种写法// 用 template 字符串newVue({template:App/,components:{App},}).$mount(#app)// 或者 el 挂载到一段已有 DOM把 DOM 当模板newVue({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.jsruntime-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 个用了运行时字符串模板入口原写法是否触发编译器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.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-onlyhttps://v2.vuejs.org/v2/guide/installation.html#Runtime-Compiler-vs-Runtime-only「世界 A / 世界 B」与「打包后不需要编译器」的原始出处Vue 2 官方文档 · 不同构建版本说明Explanation of Different Buildshttps://v2.vuejs.org/v2/guide/installation.html#Explanation-of-Different-Buildsvue.js完整版 vsvue.runtime.*仅运行时版的官方对照表