项目:html5-dataviz | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
既有文档对核心模块的描述是"机制层面"的(它们做什么、怎么协作),本章下沉到逐段带码走读:选取三个最核心的文件——src/utils/sfcRuntime.ts(沙箱编译管线)、src/components/chart-examples/ChartExampleRenderer.vue(分派器与二分求值)、src/chart-examples/EChartDemo.vue(执行契约与 myChart 延迟 Proxy)——直接引用真实源码片段,逐段解读其行为与设计意图,每段之后附"这段代码教会我们什么"小结。走读取舍说明:限于篇幅与分工,每个文件只展开最有信息量的区段,未被引录的部分(样式注入、工具栏模板等)其机制已在《07-模块详解》给出;引录的代码保持仓库原貌(含注释),仅省略号标注截断。三份文件合计约 1250 行,是理解本项目"怎么把不可信字符串变成可信渲染"的完整教材。
文件位于 src/utils/sfcRuntime.ts,538 行。它要解决的问题一句话可以说清:JSON 数据里存着一段 SFC 源码字符串(componentCode),浏览器里没有 vite、没有文件系统、没有 import 解析器,怎样让这段字符串变成一个真正可挂载的 Vue 组件?管线的完整步骤是"降级 → 解析 → 三编译 → 依赖 → 重写 → 验证 → 执行 → 包装",下面按执行顺序走读。
管线有两道关卡,第一道在编译产物拼接完成之后、真正执行之前:
const RUNTIME_BODY_MAX_LENGTH = 500000
const DANGEROUS_RUNTIME_PATTERNS = [
/\beval\s*\(/i,
/\bnew\s+Function\s*\(/i,
/\bFunction\s*\(\s*['"`]/i,
/\bWebSocket\s*\(/i,
/\bdocument\.write\s*\(/i,
/\blocation\s*\.\s*(?:href|assign|replace)\s*=/i,
/\bwindow\s*\.\s*location\s*=/i,
/\btop\s*\.\s*(?:location|href)/i,
/\bparent\s*\.\s*(?:location|href)/i,
]
function validateRuntimeBody(
runtimeBody: string,
{ source = 'runtime', warnOnly = false }: { source?: string; warnOnly?: boolean } = {}
): boolean {
if (runtimeBody.length > RUNTIME_BODY_MAX_LENGTH) {
const message = 'SFC 编译产物过大,可能存在异常输入'
if (warnOnly) { logger.warn('sfcRuntime', `${source} ${message}`); return false }
throw new Error(message)
}
if (hasDangerousRuntimePatterns(runtimeBody)) {
const message = 'SFC 编译产物包含不安全的运行时调用'
if (warnOnly) { logger.warn('sfcRuntime', `${source} ${message}`); return false }
throw new Error(message)
}
return true
}三个细节值得驻足。其一,校验对象是 runtimeBody(编译产物整体)而非原始 SFC 源码——编译器自己生成的代码(如 new Function 的模板编译产物或 scopeId 注入)也在被校验范围内,这避免了"输入干净、产物带毒"的盲区;其二,warnOnly 参数让同一套规则可降级为告警(供非阻断场景复用),主链路走 throw、错误进渲染失败态;其三,50 万字符上限的定位不是防攻击而是防意外——注释原话"编译产物过大,可能存在异常输入",兜住的是"迁移脚本把整个数据集内联进 SFC"这类事故。
第二道关卡在真正执行前,对执行体做词法消毒:
function executeRuntimeBody(
runtimeBody: string,
deps: Record<string, unknown>,
sfcHolder: { __sfc__: Component | null }
) {
const sanitizedBody = runtimeBody
.replace(/\beval\b/g, '__unsafe_eval__')
.replace(/\barguments\b/g, '__unsafe_arguments__')
const undeclaredVars = detectUndeclaredAssignments(sanitizedBody)
const varDeclarations = undeclaredVars.map((v) => `var ${v};`).join('')
const fn = new Function(
'__deps__',
'__sfc_holder__',
'"use strict";\n' +
'var parent=undefined, top=undefined, opener=undefined;\n' +
'var __unsafe_eval__=undefined, __unsafe_arguments__=undefined;\n' +
'var ROOT_PATH="/echarts-data";\n' +
varDeclarations +
sanitizedBody
)
fn(deps, sfcHolder)
}这段代码的每一行注入都有明确语义:parent/top/opener 声明为 undefined,让"访问父框架"从真实 window 引用变成无害的 undefined;eval/arguments 的词替换配合体顶部声明,让这两个保留字的动态能力失效;ROOT_PATH="/echarts-data" 让官方脚本的相对取数语义在沙箱内成立——官方 demo 里 fetch(ROOT_PATH + '/data/...') 这种写法不需要任何修改就能工作。varDeclarations 是个容易被忽视的宽容设计:detectUndeclaredAssignments 检测体中是否有未声明就对 option 赋值的代码(官方脚本常直接 option = {...} 而不声明),检测到就预置一个 var option;——严格模式会把这类代码直接打死,宽容声明让它照常运行。
这段代码教会我们什么:沙箱的工程价值不在于"绝对防御"而在于"把已知危险变成已知无害"。九条正则可被混淆绕过(见《08-安全与权限设计》第 2.3 节的诚实定位),但它们拦截的官方示例历史残留(document.write、location.href)是真实发生过的;消毒注入的四组变量则示范了另一种思路——与其拦截访问,不如重定义访问到的值。安全护栏的规格应当与威胁模型匹配:这里的威胁模型是"迁移代码的意外残留",不是"蓄意攻击者"。
compileSfcToComponent 的主干按 parse → compileScript → compileTemplate → compileStyle 推进,每段的错误处理是同一个模式:
const { descriptor, errors } = parse(sfcSource, {
filename: `${demoId || 'App'}.vue`,
})
if (errors.length) {
throw new Error(`SFC 解析错误: ${formatCompilerErrors(errors)}`)
}
if (!descriptor.template || !descriptor.template.content.trim()) {
throw new Error('缺少 <template> 内容')
}
const scopeId = `sfc-${demoId || Date.now()}`
const hasScopedStyle = descriptor.styles.some((s) => s.scoped)scopeId 的构造值得注意:它以 demoId 为基础(编译缓存键同源),Date.now() 只是无 demoId 时的兜底——scopeId 同时决定 scoped style 的 data-v- 前缀与组件的 __scopeId,两处必须同源,否则样式与元素对不上。进入 script 编译段:
if (descriptor.script || descriptor.scriptSetup) {
try {
const scriptResult = compileScript(descriptor, { id: scopeId })
bindingMetadata = (scriptResult.bindings as Record<string, string>) || {}
scriptCode = rewriteDefault(scriptResult.content, '__sfc__')
} catch (err) {
throw new Error(`Script 编译错误: ${err instanceof Error ? err.message : String(err)}`, {
cause: err,
})
}
}两处关键动作:bindingMetadata 被留存并喂给后续的 compileTemplate(compilerOptions.bindingMetadata),这让模板编译器知道哪些绑定来自 setup 语法糖、可以生成更直接的访问代码而非昂贵的 ctx 查找;rewriteDefault 把 ESM 的 export default 改写为 const __sfc__ =——因为这段代码将在 new Function 体里以脚本(非模块)形式执行,而 __sfc__ 这个约定名是后续"把产物交回宿主"的握手通道。模板段有一处不改不行的改写:
templateCode = templateResult.code
.replace(/export\s+function\s+render\b/g, 'function __sfc_render__')
.replace(/function\s+render\b/g, 'function __sfc_render__')compileTemplate 默认产出名为 render 的函数,而脚本段编译产物中可能同样存在 render 标识符(比如用户代码定义了 render 方法)——两个 render 会互相遮蔽。改名为 __sfc_render__ 后,runtimeBody 末尾的 __sfc__.render = __sfc_render__ 把它显式挂回组件对象,命名冲突被命名约定化解。
这段代码教会我们什么:错误信息的分段前缀("SFC 解析错误 / Script 编译错误 / Template 编译错误 / Style 编译错误")不是装饰,而是排查接口——《12-FAQ与故障排查》的报错速查表直接按这些前缀分派排查动作。在一条多阶段管线里,"错误发生在哪一段"比"发生了什么错误"更能定位问题,每一阶段把自己的名字写进错误消息,等于给排查者递了一张地图。
编译产物仍含 import 语句,而 new Function 体里没有模块系统。sfcRuntime 的做法不是实现一个模块加载器,而是把"解析"委托给 src/utils/sfc-modules.ts 的 ensureModule,自己只做"收集与改写":
async function collectModuleDeps(
scriptCode: string,
templateCode: string,
componentPath?: string
): Promise<{ deps: Record<string, unknown>; uniqueSources: string[] }> {
const importSources: string[] = [
...extractImportSources(scriptCode),
...extractImportSources(templateCode),
...extractDynamicImportSources(scriptCode),
...extractDynamicImportSources(templateCode),
]
const uniqueSources = [...new Set(importSources)]
const deps: Record<string, unknown> = { vue }
await Promise.all(
uniqueSources.map(async (src) => {
if (src !== 'vue') {
deps[src] = await ensureModule(src, componentPath)
}
})
)
return { deps, uniqueSources }
}注意 vue 是无条件预置的(deps 的初始值),且被跳过重复解析——沙箱代码 import vue 的频率是 100%,让它走白名单纯属浪费。所有 import 源(含模板里可能出现的动态 import)被并行解析进一张 deps 表,这张表随后成为沙箱的"模块宇宙"。改写阶段把两种 import 形态分别处理:
const rewrittenScript = rewriteDynamicImports(rewriteScriptImports(scriptCode))
const rewrittenTemplate = rewriteDynamicImports(rewriteScriptImports(templateCode))静态 import 被 rewriteScriptImports 改写为对 __deps__["源"] 的解构(支持 namespace/default/命名导入三种形态,无法识别的形态降级为注释行——宁可少一个绑定也不让脚本体语法错误);动态 import 被改写为 __dynamicImport__("源") 调用,而这个闸门函数本身是 runtimeBody 的第一行:
`const __dynamicImport__ = (source) => {
if (!Object.prototype.hasOwnProperty.call(__deps__, source)) {
return Promise.reject(new Error(\`[sfcRuntime] 不允许的动态导入: \${source}\`))
}
const mod = __deps__[source]
if (mod && (typeof mod === 'object' || typeof mod === 'function')) {
return Promise.resolve(mod)
}
return Promise.resolve({ default: mod })
}`,Object.prototype.hasOwnProperty.call 而非 source in __deps__,防的是原型链污染路径(比如源名为 "toString" 时 in 会误放行)——单行代码里的防御意识。返回值的 { default: mod } 包装处理"模块只有命名导出、被 default import 消费"的宽容兼容。
这段代码教会我们什么:白名单机制最容易犯的错是"校验点和放行点分离"——校验在一处做、实际加载在另一处做,两处之间的缝隙就是逃逸口。这套实现把白名单的执行点收拢到"依赖收集阶段的结果"这一事实上:静态导入拿到的是 deps 表里的值,动态导入放行的条件是"在 deps 表里",两条路径共享同一张表,没有第二处需要单独维护的放行逻辑。设计权限系统(哪怕只是模块白名单这种"小权限")时,"单一执行点"应该成为下意识。
执行完成后,__sfc__ 通过 sfcHolder 交回宿主,但它还不是一个"正式组件"——缺少组件注册表、scopeId 与错误包装:
function buildSfcComponent(
sfc: Component,
demoId: string | undefined,
scopeId: string,
hasScopedStyle: boolean
): Component {
const sfcRecord = sfc as Record<string, unknown>
const origSetup =
typeof sfcRecord.setup === 'function'
? (sfcRecord.setup as (props: unknown, ctx: unknown) => unknown).bind(sfc)
: null
const origRender = /* 同款 bind */
const componentRegistry = getComponentRegistry()
return defineComponent({
...sfc,
name: (sfcRecord.name as string | undefined) ||
(sfcRecord.__name as string | undefined) || `DemoComponent_${demoId}`,
inheritAttrs: (sfcRecord.inheritAttrs as boolean | undefined) ?? false,
components: {
...(sfcRecord.components as Record<string, Component> | undefined),
...Object.fromEntries(componentRegistry),
},
...(hasScopedStyle ? { __scopeId: `data-v-${scopeId}` } : {}),
...(origSetup ? { setup(props, ctx) { return origSetup(props, ctx) } } : {}),
...(origRender
? {
render(_ctx, _cache, $props, $setup, $data, $options) {
try {
return origRender(_ctx, _cache, $props, $setup, $data, $options)
} catch (err) {
logger.error('sfcRuntime', `${demoId} render 执行失败:`, err instanceof Error ? err.message : String(err))
throw err
}
},
}
: {}),
})
}四个包装动作各有动机。合并组件注册表(componentRegistry)让沙箱模板可以不经 import 使用 v-chart 等全局组件——沙箱代码没有构建期的 import 解析,组件注册表是它接触宿主能力的唯一窗口;setup/render 先 bind 再包一层,保证函数内的 this 指向原始 sfc 对象且异常有日志出口(catch 后 rethrow 而非吞掉,让 onErrorCaptured 仍能接管);inheritAttrs: false 的默认值避免未经设计的 attrs 透传把布局属性洒到意外节点;文件顶部的 // @ts-nocheck 注释解释了为什么整个文件对类型系统关闭——它操作的是编译产物与 Vue 运行时内部参数,类型价值低且易被 Vue 内部签名破坏,诚实地放弃比虚假地声明更可维护。
这段代码教会我们什么:包装层的本质是"在不可控产物外面套一层可控边界"。注意它的克制——只加四个动作、每个动作可独立解释,没有试图"规范化"产物(比如不深度代理、不重写生命周期)。对动态生成的代码做包装时,最小干预原则能显著降低"包装层本身成为 bug 源"的概率;同时把 @ts-nocheck 的理由写在文件首行,让下一个维护者不必重新推导"这里为什么没有类型"。
文件位于 src/components/chart-examples/ChartExampleRenderer.vue,448 行,是 71 条兜底条目的唯一入口。走读三个区段:类型优先路由、echarts 的 new Function 求值、antv 的二分求值,以及贯穿其中的 renderSeq 代次模式。
const OPTION_KINDS = ['option', 'api-async', 'antv-config'] as const
const usesSfc = computed(() => {
const e = props.example
if (!e.componentCode) return false
if ((OPTION_KINDS as readonly string[]).includes(e.loaderKind) && e.optionCode) return false
return true
})这五行浓缩了一次真实事故。文件上方的注释给出背景:迁移遗留数据中部分条目同时携带 componentCode 与 optionCode(历史上后端把同一示例存成两种格式),早期版本按"字段优先"路由——有 componentCode 就进 SFC 编译链——结果 54 个本该走 option 求值的兜底条目全部被劫持进编译链,表现为"渲染空白 + 缺失 componentPath 徽章"。修复不是清理数据(数据面不动),而是把分派判据从"字段存在性"升级为"类型优先":loaderKind 明确是 option/api-async/antv-config 的条目,即使带了 componentCode 也走本职求值链路,SFC 编译只服务于显式 component 类型。
这段代码教会我们什么:当数据可能"不干净"时,判别字段的可信度高于内容字段的可信度。类型字段(loaderKind)是数据的自我声明,内容字段(componentCode)只是历史沉积——路由决策应该信声明、验内容,而不是反过来。另外注意这个修复选择了"渲染器容错"而非"数据清洗",这与《11-扩展开发指南》第 8 节"新增案例永远只动注册表"的纪律形成互补:渲染器可以容错历史,但容错逻辑必须像这五行一样有注释、有测试,否则容错会悄悄变成第二个数据格式。
这个 100 余行的函数是"官方脚本宿主"的完整实现,走读其中代次比对最密集的片段:
async function renderEchartsOption(gen: number) {
const container = chartContainerRef.value
if (!container) return
if (props.example.requiresGl) await import('echarts-gl')
if (gen !== renderSeq) return
const echarts = await import('echarts')
if (props.example.requiresMap) {
for (const [name, file] of [
['china', '/map-geojson/china.json'],
['world', '/map-geojson/world.json'],
] as const) {
try {
const geo = await fetchJson(file)
if (gen !== renderSeq) return
echarts.registerMap(name, geo as never)
} catch {
// 单张地图注册失败不阻断渲染:条目可能只用另一张或自备地理数据
}
}
}
const chart = echarts.init(container)
if (gen !== renderSeq) {
// 已被更新一轮渲染顶替:销毁本实例,避免同容器重复 init 与实例泄漏
chart.dispose()
return
}
chartInstance = chartgen 参数是调用方传入的代次快照(const gen = ++renderSeq),每个 await 之后的第一个语句都是比对——GL 预载后比对一次、每张地图 fetch 后比对一次、init 后比对一次。比对失败的行为分两类:init 之前过期,直接 return(尚无副作用);init 之后过期,先 chart.dispose() 再 return(已有实例副作用)。这就是《07-模块详解》第 7 节强调的"过期自弃 = 先清理自己已产生的副作用再 return"。地图循环里的静默 catch 是第三种取舍:单张地图失败不阻断——因为条目可能只用另一张图,错误要到真正被用到时才该出现。
继续到求值段,new Function 的九形参签名是这条链路的完整暴露面:
let option: unknown
if (props.example.optionCode) {
const fn = new Function(
'echarts',
'myChart',
'setOption',
'fetchJson',
'loadScript',
'registerMap',
'registerTransform',
'ROOT_PATH',
'console',
'"use strict";\n' +
'var option;\n' +
props.example.optionCode +
'\n;return (typeof option !== "undefined" && option !== null && typeof option.then !== "function") ? option : undefined;'
) as (...args: unknown[]) => unknown
option = await fn(
echarts,
chart,
(opt: unknown) => chart.setOption(opt as Record<string, unknown>),
fetchJson,
loadScript,
(name: string, geoJson: unknown) => echarts.registerMap(name, geoJson as never),
(t: unknown) => echarts.registerTransform(t as never),
'/echarts-data',
console
)
}形参清单本身就是设计文档:官方脚本只需要 echarts 命名空间、myChart 实例、以及取数/注册/扩展加载这几样宿主能力,fn 的第二实参把 chart 实例直接冒充为 myChart(脚本的 myChart.setOption(...) 因此原样可跑)。return 表达式的三重判断(非 undefined、非 null、非 Promise)实现了"脚本留下 option 就回填、脚本自行异步 setOption 就不管"的双模式兼容——返回 Promise 的脚本(自带 fetch().then() 链)被有意排除在回填之外,避免把未完成的 Promise 传给 setOption。
这段代码教会我们什么:其一,宿主函数的形参表是暴露面的机器可读枚举——想知道"沙箱代码能摸到什么",数一数 new Function 的形参名即可,这种自文档性值得在所有能力注入场景模仿;其二,代次比对必须落在"每个 await 之后"而不是"函数开头结尾各一次",因为竞态窗口正是 await 间隙,错一个续体就漏一个窗口;其三,myChart 用 chart 实例直接冒充,是"适配层不追求完备、只追求原样兼容"的示范——适配的目的是让旧代码跑起来,不是让旧代码更优雅。
async function renderAntvConfig(gen: number) {
const container = chartContainerRef.value
if (!container) return
const g2plot = await import('@antv/g2plot')
if (gen !== renderSeq) return
let payload: { plotClass?: string; options?: Record<string, unknown> } = {}
if (props.example.optionCode) {
// 官方 antv 脚本主流是函数体形态(`return { plotClass, options }`),按函数体执行;
// 少数条目是无 return 的裸对象字面量,按表达式求值。
// `return (code)` 的表达式包装对 return 语句必然语法错误,故必须二分。
const code = props.example.optionCode.trim()
const fn =
code.startsWith('return') || code.includes('\nreturn')
? new Function('console', '"use strict";\n' + code)
: new Function('"use strict"; return (' + code + ')')
payload = (fn as (...args: unknown[]) => undefined)?.(console) ?? {}
}
// 失败必须可见(统一错误面板),静默返回会让用户面对无解释的空白画布
if (!payload.plotClass) throw new Error('antv-config 条目缺少 plotClass 字段')
const PlotClass = (g2plot as Record<string, unknown>)[payload.plotClass] as
| (new (el: HTMLElement, opts: Record<string, unknown>) => { render(): void; destroy(): void })
| undefined
if (!PlotClass) throw new Error(`未知的 G2Plot 类型: ${payload.plotClass}`)
if (!payload.options) throw new Error(`antv-config 条目 ${payload.plotClass} 缺少 options 配置`)
antvPlotInstance = new PlotClass(container, payload.options)
;(antvPlotInstance as { render(): void }).render()
}二分求值的判据朴素到近乎简陋(startsWith('return') || includes('\nreturn')),但它精准覆盖了数据的两种真实形态:17 条兜底 antv 条目中主流是完整函数体(以 return 语句开头或含换行 return),少数是无 return 的裸对象。前者的包装是 new Function(body)(函数体执行),后者的包装是 new Function('return (' + code + ')')(表达式求值)——若对前者套表达式包装,return (return {...}) 是语法错误;若对后者套函数体包装,函数执行完没有返回值,payload 恒空。判定条件不求优雅只求与数据形态同构。随后的三连 throw 是"失败可见原则"的教科书实现:缺 plotClass、plotClass 不在 g2plot 命名空间、缺 options,每一种失败都翻译成一条能直接定位数据问题的错误消息,注释甚至写明了反面案例("静默返回会让用户面对无解释的空白画布")。
这段代码教会我们什么:当输入格式有两种历史形态时,与其强统一格式(改 17 条数据的成本与风险),不如写一个显式的二分判定——但判定条件要写在注释里,让"为什么不能只用一种包装"的教训不随人员流动丢失。反射取构造器(g2plot[payload.plotClass])之后的 if (!PlotClass) throw 同样关键:反射让"字符串到构造器"的失败从 TypeError 降格为可读的业务错误,这类"把运行时异常翻译成人话"的投资回报率极高。
文件位于 src/chart-examples/EChartDemo.vue,264 行。它是 294 个 echarts 本地 SFC 的统一容器,头部注释把契约稳定性说得毫不含糊:"option/onReady/setup/scripts 的语义被 294 个生成 SFC 依赖,只能收紧不能变更"。走读三个区段:有界等待、延迟代理、三段式执行。
/**
* 有界等待实例就绪:v-chart 挂载与 ECharts init 存在异步间隙,
* 单次 nextTick 探测曾是"图表实例未初始化"竞态的来源;改为轮询直至超时。
*/
async function waitForInstance(timeoutMs = 1500): Promise<VChartInstance['chart'] | null> {
const stepMs = 32
for (let waited = 0; waited < timeoutMs; waited += stepMs) {
await nextTick()
const instance = resolveInstance()
if (instance) return instance
await new Promise((resolve) => setTimeout(resolve, stepMs))
}
return resolveInstance()
}方法很短,注释里的教训很重。v-chart 是异步组件(defineAsyncComponent 包两级动态 import),它的挂载完成时刻与底层 ECharts init 完成时刻之间有一个不可精确预测的间隙;早期实现用单次 await nextTick() 后探测,nextTick 只保证 DOM 更新队列清空,不保证异步组件的内部 init 已完成——这就是注释里"图表实例未初始化"竞态的来源。修复是 32ms 步进的有界轮询:nextTick(让 Vue 有机会推进)→ 探测 → setTimeout 32ms(让宏任务队列有机会执行 echarts 的初始化链),循环上限 1500ms。超时后仍返回最后一次探测结果而非 null——把"再试一次"的权利留给调用方,让 run() 里的 if (!instance) throw 成为唯一判错点。1500ms 上限意味着极慢设备上仍可能失败,这是有意取舍:无限等待会让"永远出不来"的故障不可见,错误态的重试按钮才是兜底。
这段代码教会我们什么:对"别家异步组件的内部时序"做假设时,轮询 + 有界超时是最诚实的方案——它不假设就绪时机,只假设"最终会就绪或永远不就绪"。32ms 的选择也有讲究:约两帧,比人眼可感知的卡顿阈值低,且让典型场景(异步 chunk 已缓存)在一两个循环内通过,代价可忽略。
function buildMyChartProxy(): Record<string, unknown> {
return new Proxy(
{},
{
get(_t, prop) {
const instance = resolveInstance()
if (!instance) {
console.warn('[EChartDemo] myChart.' + String(prop) + ' 实例尚未就绪')
return () => undefined
}
if (prop === 'setOption') {
// 走 vue-echarts 实例、默认 merge,不强制 notMerge
return (opt: unknown, opts?: unknown) => {
const c = chartRef.value
if (c && typeof c.setOption === 'function') c.setOption(opt as never, opts as never)
}
}
if (prop === 'getOption') return () => instance.getOption()
if (prop === 'resize') return () => instance.resize()
if (prop === 'on') return (...a: unknown[]) => (instance.on as AnyFn)(...a)
if (prop === 'off') return (...a: unknown[]) => (instance.off as AnyFn)(...a)
if (prop === 'dispose') return () => instance.dispose()
const v = (instance as unknown as Record<string, unknown>)[prop as string]
return typeof v === 'function' ? (v as AnyFn).bind(instance) : v
},
}
)
}Proxy 的动机来自官方示例的书写习惯:脚本顶层立即 myChart.setOption(...)、myChart.on('click', ...),而契约要求示例作者无须关心实例何时就绪。get 拦截器按方法名分派:setOption 走 vue-echarts 组件方法(默认 merge 语义,注释特意说明"不强制 notMerge",与兜底链路 chart.setOption(option, true) 的 notMerge 形成对照——两条链路对"重复 setOption"的处理刻意不同);getOption/resize/on/off/dispose 五个高频 API 显式转发;其余属性兜底 bind 后转发,保证 myChart.convertToPixel 这类低频 API 也能用。未就绪分支返回 () => undefined 并 console.warn——调用不炸、行为可见,00 篇第 6 节甚至把"控制台不应出现 myChart.xxx 实例尚未就绪告警"列为健康启动信号,一条告警文案同时承担了降级、调试与巡检三重职责。
这段代码教会我们什么:Proxy 延迟代理是处理"对象存在时序差"的通用模式,但它的价值一半在转发、一半在告警——静默 no-op 会把时序 bug 变成"偶发空白"这类最难排查的幽灵,而 no-op + warn 把它变成可观测、可告警、可巡检的显式状态。另外注意分派顺序的语义:特判方法(setOption 走组件而非原生实例)在前、兜底转发在后,这种"少数精确、多数泛化"的分派结构在适配层反复出现。
async function run() {
// 重试场景:先清掉上一轮 setup 的定时器/事件,避免叠加
cleanupFn?.()
cleanupFn = null
loading.value = true
error.value = null
try {
const instance = await waitForInstance()
if (!instance) throw new Error('图表实例未初始化')
if (props.scripts?.length) {
const { loadScript } = await import('./loadScript')
await Promise.all(props.scripts.map((s) => loadScript(s)))
}
// 静态 option 模式:无钩子时直接 setOption
const staticOpt = normalizeOption()
if (staticOpt && !props.onReady && !props.setup) {
instance.setOption(staticOpt)
}
if (props.onReady) {
const result = await props.onReady(buildContext())
if (result) instance.setOption(result)
}
if (props.setup) {
cleanupFn = (await props.setup(buildContext())) ?? null
}
} catch (e) {
error.value = e instanceof Error ? e.message : '渲染失败'
emit('error', error.value)
} finally {
loading.value = false
}
}
onMounted(run)
onBeforeUnmount(() => {
cleanupFn?.()
cleanupFn = null
})执行顺序是刚性的五步:清上一轮 → 等实例 → 预载 scripts → 静态 option(仅当无钩子)→ onReady → setup。两个契约细节藏在条件表达式里。其一,静态 option 的生效条件是 !props.onReady && !props.setup——只要声明了任何钩子,option 的设置权就完全移交钩子,避免"静态 setOption 先执行、钩子里的 setOption 再覆盖"造成的双重渲染;其二,setup 的返回值被登记进 cleanupFn,该函数在两个时机被统一调用——重试(run 开头)与卸载(onBeforeUnmount)——示例作者返回一个清除定时器的函数,就自动获得了"重试不叠加、卸载不泄漏"的保证,无须自己在组件生命周期里操心。catch 分支把任何异常统一转为"渲染失败:{消息}"的错误态并 emit,finally 关 loading——loading/error 两个运行态是容器替 294 个 SFC 统一呈现的。
这段代码教会我们什么:契约设计的核心是"把易错的生命周期管理收归容器"。294 个示例如果各自管理 setInterval,会有 294 种清理遗漏;契约让作者只做一件事——返回清理函数——其余由容器统一调度,清理的所有权明确到"谁登记谁受益"。设计这类契约时的检验标准是:用一个最小示例(如 19 篇将展示的 line-simple)能否只写数据不写生命周期?能,契约就成立。
把三个文件放回一起,能提炼出三条超越本项目的方法论。第一,异步边界的防御是多点、成体系的:sfcRuntime 的运行时闸门、Renderer 的代次比对、EChartDemo 的有界轮询,分别守卫模块加载、渲染切换、实例就绪三类边界——单看每处都是小代码,合起来才是"快速切换不出事"的完整解释;学习它们的正确方式不是背代码,而是记住"每个 await 之后检查一次"这个纪律的普适性。第二,失败路径比成功路径更需要设计:三份文件里注释最密、用意最深的全是失败分支——危险模式拦截的错误消息、三连 throw 的人话翻译、waitForInstance 超时后的可重试错误态;成功路径通常一行 setOption 就完了,失败路径才决定一个系统"看起来可不可信"。第三,宽容与严格的边界感:宽容留给数据形态(二分求值、未声明变量预置声明、双格式容错路由),严格留给行为契约(危险模式零容忍、代次过期必自弃、错误必可见);把这两条线画反的系统——对数据形态严格(强求统一格式)、对行为宽容(静默降级)——维护体验会差一个数量级。本章三个文件可以作为这两条线的参照实现;它们各自的机制总览、交互关系与测试锁定见《07-模块详解》第 3~5 节与《09-测试与质量保障》第 5 节。