Fix/node24 build and test - #55
Merged
Merged
Conversation
added 30 commits
September 22, 2026 01:40
- jasmine.json: 增加 "jsLoader": "require",避免 Node 22+ 原生 TS 类型剥离 绕过 ts-node 钩子导致的 static-injector transformer 丢失与 ERR_UNSUPPORTED_DIR_IMPORT,无需再手动设置 NODE_OPTIONS=--no-experimental-strip-types - package.json: build:library 前执行 tsx ./script/ensure-sync.ts - script/ensure-sync.ts: 检测被 gitignore 的 common/forms 生成源码, 缺失时自动补跑 npm run sync,避免 TS6053 index.ts not found - DEVELOPMENT.md: 记录本地构建/测试流程与已知问题(karma 用例需真机环境) 验证(Node v24.21.0): build/test/test:ci/coverage/lint 全部通过, 26 specs 0 failures 2 pending
依赖版本:
- @angular/{core,compiler,compiler-cli} 17.3.1 -> 18.2.14
- @angular-devkit/{architect,build-angular,core,schematics} -> 18.2.21 (architect 0.1802.21)
- 新增 @angular/build 18.2.21(v18 起 build-angular 内部工具迁移到该包)
- @ngtools/webpack 18.2.21, ng-packagr 18.2.1
- typescript 5.4.2 -> 5.5.4, zone.js 0.14.10, webpack 5.94.0, rxjs 7.8.1
- typedoc ^0.25 -> ^0.26.11(0.25 不支持 TS 5.5)
- overrides: 强制 cyia-ngx-devkit 使用 devkit 18;tapable 锁定 2.2.1
(tapable >= 2.2.2 调整了 SyncWaterfallHook 泛型顺序,与
webpack-bootstrap-assets-plugin 的 d.ts 不兼容)
代码适配:
- 新增 @let 支持:TemplateDefinition.visitLetDeclaration(v18 新增 AST 节点,
不产生渲染节点,占位空实现),ast.type 导出 LetDeclaration
- purgeStaleBuildCache / assertCompatibleAngularVersion 改从
`@angular/build/private` 引入;因 moduleResolution: node 无法解析 exports
子路径,在 tsconfig.builder/spec.json 用 paths 映射类型
- compiler.inputFileSystem 在 v18 类型中可为 null,改为可选链
- tsconfig.library.json: lib 增加 dom.iterable(HttpParams|URLSearchParams
迭代),typeRoots/types 增加 miniprogram-api-typings,避免跨 entry point
时 WechatMiniprogram 命名空间丢失
- script/schema-merge.ts: browser schema 路径改为 src/builders/browser/schema.json
- script/package-sync.ts: 同步源改为 angular@18.2.14
验证: build / test(26 specs 0 failures) / coverage / lint 全部通过
依赖版本:
- @angular/{core,compiler,compiler-cli} 18.2.14 -> 19.2.17
- @angular-devkit/* -> 19.2.19(architect 0.1902.19),@angular/build 19.2.19
- @ngtools/webpack 19.2.19, ng-packagr 19.2.2
- typescript 5.6.3, zone.js 0.15.1, webpack 5.98.0, tslib 2.8.1
- typedoc ^0.27.9
代码适配:
- v19 起 `standalone` 默认值改为 true:给本仓库手写的 forms value accessor
(default/slider/switch/picker/picker-view/checkbox/radio 共 9 个指令)
以及测试工程 test/hello-world-app、builder fixture 里的全部
@Component/@directive 显式补上 `standalone: false`
- v19 新增 AST 节点:CustomAstVisitor 实现
visitTypeofExpression / visitTemplateLiteral / visitTemplateLiteralElement
- ng-packagr 19:
- StylesheetProcessor 构造函数在 cacheDirectory 前新增 sass 参数
- Angular 诊断缓存改为 entryPoint.cache.angularDiagnosticCache(get/update)
- 不再把逐文件 ESM(esm2022) 落盘,只输出 fesm2022,
library.spec 断言随之调整
- script/package-sync.ts: v19 的 packages/common/http 内部改用 `../../index`
相对引用,新增改写逻辑,映射为 angular-miniprogram/common[/http] 入口,
避免 TS6059 rootDir 越界
验证: build / test(26 specs 0 failures) / coverage / lint 全部通过
依赖版本:
- @angular/{core,compiler,compiler-cli} 19.2.17 -> 20.3.15
- @angular-devkit/* -> 20.3.13(architect 0.2003.13),@angular/build 20.3.13
- @ngtools/webpack 20.3.13, ng-packagr 20.3.2
- typescript 5.8.3, webpack 5.101.2, rxjs 7.8.2
代码适配:
- ng-packagr 20 把 lib/** 移动到 src/lib/**:所有 `ng-packagr/lib/...`
深引用改为 `ng-packagr/src/lib/...`
- 表达式 AST:移除 KeyedWrite / PropertyWrite(赋值改为带赋值运算符的
Binary),新增 visitVoidExpression / visitTaggedTemplateLiteral /
visitParenthesizedExpression
- 模板 AST Visitor 新增 visitComponent / visitDirective
- webpack 5.101:splitChunks.cacheGroups.test 参数类型收紧为 Module,
需向下转型 NormalModule,且返回值必须为 boolean
- script/package-sync.ts 同步源改为 angular@20.3.15
- DEVELOPMENT.md 补充 17->18->19->20 的升级记录与可复用升级清单
验证: build / test:ci / test(26 specs 0 failures) / coverage / lint 全部通过
注意: 跨大版本需先跑 `npm run test:jasmine library` 刷新
test/hello-world-app/node_modules/test-library,否则会残留旧版本指令
去掉库对 zone.js 的依赖,改用 Angular v20 的 ChangeDetectionScheduler: - 新增 `platform/util/change-detection.ts`,封装 `runInAngular()` / `scheduleChangeDetection()`,回调执行完显式 notify 调度器 - `platform-core` 的 `__ngZone` 换成 `__ngChangeDetectionScheduler`, 事件回调改为 try/finally 保证通知;diff/setData 不再包 runOutsideAngular - `page.service` 注册页面、`http/backend` 的小程序回调统一走 runInAngular - 移除 `declare const Zone`、DefinePlugin 的 Zone 映射与平台模板的 Zone 导出 - `package-sync.ts` 同步 `@angular/common/http` 时剥掉 fetch.ts 的 zone.js 依赖 - fixture 应用删除 `import 'zone.js'`,改用 `provideZonelessChangeDetection()` fixture 中所有 `@Input()` / `@Output()` 改为 `input()` / `output()`, 模板读取 signal 需显式调用(Angular 插值不会自动 unwrap)。 测试: - `change-detection.spec.ts`:5 个 Node 端单测(含抛错仍通知) - `zoneless.spec.ts`:构建产物断言无 zone.js 特征、含 zoneless 调度器 - `spec/signal-io-spec`:小程序内验证 signal input 渲染与 output 回传 32 specs, 0 failures, 2 pending(karma 需微信开发者工具)
模板编译器原先只处理 `ng-template`(`*ngIf` / `*ngFor` 脱糖后的形态),
内建控制流的 AST 块是空实现,会静默渲染成空白。
内建控制流最终也编译成 embedded template(`ɵɵconditionalCreate` /
`ɵɵrepeaterCreate`),与 `ng-template` 共用同一套锚点机制,因此复用
`ParsedNgTemplate` 与 wxml 的 `<block wx:for="{{nodeList[i]}}">` 渲染:
条件为假时数组为空,天然实现显隐。
- `visitIfBlock` / `visitSwitchBlock`:第一个分支占 1 槽,其后是所有分支
条件表达式的管道槽位(Angular 的 pipe_creation 统一插在第一个 create 之后),
再后面是其余分支
- `visitForLoopBlock`:repeater 起始槽是 RepeaterMetadata(不可渲染但必须占位),
主模板在 index+1,`@empty` 在 index+2,其后是被遍历表达式的管道
- 分支内容是独立 embedded view,用新的 TemplateDefinition 访问,
不影响当前视图的 declIndex
- 顺带修掉模板名冲突:`TemplateDefinition` 增加 namePrefix,逐层追加 index,
解决嵌套匿名模板重名互相覆盖的问题(complex-structure 原本有 4 个 ngDefault_1)
- `@defer` / 未知块显式抛错,不再静默渲染空白
测试:
- `control-flow.spec.ts`:交叉校验 wxml 控制流锚点与编译产物 JS 的
conditionalCreate / repeaterCreate 索引一致,并断言模板名不重复
- `__pages/control-flow`:覆盖 @if/@else if/@else、@if(x; as y)、条件带管道、
@for+@empty+$index、@switch+@default、控制流嵌套
- `spec/control-flow-spec`:小程序内验证渲染与状态切换(需微信开发者工具)
36 specs, 0 failures, 2 pending
之前页面必须写成 `pageStartup(SomeModule, SomeComponent)`,即使组件本身 不需要 NgModule,也得先声明一个 NgModule 再加载。 - `bootstrapPage(StandaloneComponent)`:直接启动 standalone 组件, 内部走 `createComponent` + environmentInjector,不再创建 NgModuleRef - `pageStartup(module, component)` 标记 @deprecated,内部改为调用 `__ngStartPageWithModule`,行为不变 - `PageService` 拆出 `createPageInjector`,新增 standalone 启动分支 - `linkNgComponentWithPage` 的 `ngModuleRef` 改为可选,destroy 时用可选链 - `AppOptions.__ngStartPage` 改为 standalone 语义(component, instance) 36 specs, 0 failures, 2 pending
配合 `bootstrapPage`,把示例应用 16 个页面从 `standalone: false` + NgModule 迁移到 `standalone: true`,每页少一个 `.module.ts` 文件,入口从 `pageStartup(XModule, XComponent)` 变成 `bootstrapPage(XComponent)`。 原来模块 `imports` 里的内容(CommonModule / FormsModule / 其他组件模块) 直接搬到组件的 `imports`,模块上声明的兄弟指令改成 standalone 后一并引入, `schemas` 也搬到组件上。 编译器侧配套改动: - `resolveTemplateDeclarations`:standalone 组件的 `imports` 允许直接写 NgModule,此时 `meta.declarations` 会出现 `R3TemplateDependencyKind.NgModule` 的项,它只有指向模块标识符的 `type.node`,没有 `ref.node`,直接查元数据会炸。 改用 Angular 的 `TypeCheckScope` 拿扁平化后的作用域(模块展开成它导出的 指令与管道),非 standalone 组件走原路径不受影响。 - 展开出来的是 ngtsc 的 `DirectiveMeta`/`PipeMeta`,与下游读的 R3 形状不一致, 补齐 `importedFile`,并把 `ClassPropertyMapping` 形式的 inputs/outputs 转成 R3 的绑定名数组(取 `reverseMap` 的 key)。 - `getComponentPagePattern`:入口识别 `bootstrapPage(...)`,组件取 `arguments[0]`;`pageStartup(...)` 仍取 `arguments[1]`。 36 specs, 0 failures, 2 pending
v4 的 `Component.Options` 泛型从 5 个参数变成 6 个,新插入了必填的
`TBehavior extends BehaviorOption`(即 `BehaviorIdentifier[]`)作为第 4 位。
原来写在第 4 位的 `{}` 现在落到 TBehavior 上会报不满足约束,而 `TIsPage`
掉回默认 false 后 `methods` 里就没有 `onHide` / `onShow` / `onUnload` 了。
- `Options<{}, {}, {}, {}, true>` -> `Options<{}, {}, {}, [], {}, true>`
- `Options<{}, {}, {}>` -> `Options<{}, {}, {}, []>`
`Page.Options<TData, TCustom>` 没变,不用动。
36 specs, 0 failures, 2 pending
v5 没有触及本项目用到的类型,代码零改动。 36 specs, 0 failures, 2 pending
上游 cyia-ngx-devkit 停在 0.0.5 且不再更新,peerDependencies 钉死 `@angular-devkit/architect@0.1703.1` / `@angular-devkit/core@17.3.1`, 本项目已经是 20.3.13,之前只能靠 package.json 的 `overrides` 把它强行拉平。 版本对不上这件事以后每次升级都得再 override 一次,且装到的永远是按 17.x 写的产物。 - 新增 `test/cyia-ngx-devkit/`,按 20.x 的 architect 类型重写为 TS 源码, `test/plugin-describe-builder` 改成相对路径引用,不再走 npm 包解析 - 删掉 devDependencies 里的 `cyia-ngx-devkit` 和对应的 `overrides` 条目 (`tapable` 的 override 保留,那是另一个问题) - 去掉原实现里 `console.error` -> `process.exit(100)` 的全局钩子:任何一次 console.error 都会直接杀掉测试进程,日志都来不及看 36 specs, 0 failures, 2 pending
把这几轮改动里踩到的坑记进 DEVELOPMENT.md: - `bootstrapPage` 的写法与运行时改动(createComponent + EnvironmentInjector, ngModuleRef 改可选) - standalone 组件 imports 里带 NgModule 时 `declarations` 出现 kind=2 项、 没有 ref.node 的问题,以及用 TypeCheckScope 展开 + 补齐 importedFile / inputs / outputs 形状的做法 - miniprogram-api-typings v4 的 `Component.Options` 泛型从 5 个参数变 6 个, 插入必填 TBehavior,导致 TIsPage 掉回 false 而丢失页面生命周期类型 - cyia-ngx-devkit 内联的原因与结果
@angular/* 21.2.23 / @angular-devkit/* 21.2.24 / ng-packagr 21.2.7 /
TypeScript 5.9.3 / webpack 5.105.2。
踩到的坑:
- ng-packagr 21 删掉了 `src/lib/utils/load-esm`,改成直接 require。
`compile-source-files.ts` 里自己加一个 `ngCompilerCli()` 懒加载入口。
- Angular 21 默认开启 `typeCheckHostBindings`,会对 `@HostBinding` 做 DOM
schema 校验。本库的表单 accessor 绑的是小程序自定义元素
(checkbox / switch / radio / slider / picker / picker-view),
这些永远不在 Angular 的 DOM schema 里,全部报 NG8002。这些绑定最终由
本库的模板编译器翻成 wxml,不会真的 set DOM property,
所以库与 fixture 工程都关掉 `typeCheckHostBindings`。
- `@angular/core` 21 的 fesm 里有 `import '@angular/core/primitives/signals'`
这类 bare 子路径导入,fixture 的 `moduleResolution: "node"` 认不了 exports map。
改成 `"bundler"`(试过给 primitives 加 paths 指到 .d.ts,但那样 webpack 会把
.d.ts 当运行时模块加载,@ngtools 直接报 missing from compilation)。
- Angular 21 重构了 `@switch` 的 AST:children 不再挂在 `@case` 上,
而是把「共享同一份子节点的连续 case」合并成 `SwitchBlockCaseGroup`。
`visitSwitchBlock` 改为按 group 占位,`@case` 只提供判断表达式,
并补上 `visitSwitchBlockCaseGroup` / `visitSwitchExhaustiveCheck`。
- 表达式 AST 新增 `ArrowFunction` / `SpreadElement` / `RegularExpressionLiteral`
/ `Unary`,`CustomAstVisitor` 补齐对应访问方法。
- sync 脚本:Angular 21 起 git tag 改成带 `v` 前缀(20.x 及以前是裸版本号);
另外加 `ANGULAR_REPO` 环境变量支持指向本地 clone,离线可用。
顺带修了上一轮内联 devkit 时自己引入的 bug:`resolvePath` 用字符串拼接
`resolvePath('.')` 会留下尾部的 `/.`,导致 build-angular 用 startsWith 校验
资源路径是否在工作区内时全部失配,报 "The ./src asset path must be within
the workspace root"。改回 path.join。
typedoc 0.27 不支持 TS 5.9,升到 0.28.20。
36 specs, 0 failures, 2 pending
@angular/* 22.1.7 / @angular-devkit/* 22.1.8 / ng-packagr 22.1.1 / TypeScript 6.0.3 / webpack 5.109.2。 TypeScript 6.0 的破坏性变化(这次的大头): - strict 系列变成默认开启。空 tsconfig 也会开 noImplicitAny。 本仓库自己的 tsconfig.base.json 已显式写了 strict: false, 但 fixture 的 tsconfig.base.json 没写,直接继承了新默认值, 冒出一堆 TS7006 / TS7008 / TS2564。显式补 strict: false, 保留原来「只开 strictNullChecks」的严格度。 - baseUrl / moduleResolution=node10 / downlevelIteration / target=ES5 都变成硬错误,加 ignoreDeprecations: "6.0"。 - solution-style 的根 tsconfig.json 之前 compilerOptions 是空的, code-recycle 跑 sync 脚本时 ts-node 会拿它直接用,空配置让 TS 6 用默认 target=ES5 并因缺 rootDir 报 TS5107 / TS5011, 补上 target / module / rootDir。 - karma client 的 tsconfig 不再自动带上 node_modules/@types 全量, 显式声明 typeRoots 与 types(jasmine 命名空间、node 的 Console)。 Angular 22 的 API 移除: - createNgModuleRef 删除,改用 createNgModule(签名一致)。 - ComponentFactoryResolver 整个从公开 API 移除, NgModuleRef 上也没有 componentFactoryResolver 了。 废弃的 pageStartup(module, component) 路径改为用模块的 injector 当 environmentInjector 走 createComponent。 - 新增 @content 内容查询块,Visitor 接口加了 visitContentBlock。 它依赖 Angular 运行时的 content query 观察投影内容并重渲染, 小程序的 slot / self 模板是静态的,对不上,按 @defer 的先例显式抛错。 - 同步过来的 @angular/common 用到 Object.hasOwn(ES2022), 库的 lib 从 es2019 提到 es2022。 36 specs, 0 failures, 2 pending
把 20->21 与 21->22 两轮升级的坑记进 DEVELOPMENT.md,重点是 TypeScript 6.0 把 strict 变成默认开启、废弃项变硬错误、@types 不再 自动全量注入这几件事,以及 ComponentFactoryResolver 整体移除后 pageStartup 的走法。升级清单补上 ANGULAR_REPO 离线同步的用法。
之前 src/builder/component-template-inject/change-component.ts 里
`rf & 2` 那两条注入分支完全没有测试覆盖——全仓库没有任何 spec 断言过
rf & 1 / rf & 2 / propertyChange。
新增 change-component.spec.ts(单元):
- 非组件返回 undefined
- 两条 import 注入
- componentName 取 ɵcmp 的 type
- 分支 A(已有 rf & 2):propertyChange 落在更新块最后一条语句之后,
不会多补一个 rf & 2 块,也不污染 rf & 1
- 分支 B(无 rf & 2):在 init 块后补完整 if(rf & 2){...}
- 空更新块不崩,退化成补完整块
- 同文件多组件都被改到
新增 change-component.build.spec.ts(端到端):
用真实 Angular 编译产物断言,避免只靠手写 fixture 贴近 codegen。
校验两条分支都被真实命中(A=13 / B=10),且注入调用点总数等于两者之和、
没有一个落在 rf & 2 之外。
顺带修一个潜在崩溃:updateIfNode 存在但更新块是空的(statements 为 [])时,
statements[length - 1] 是 undefined,insertNode 里读 getStart 直接抛
"Cannot read properties of undefined (reading 'getStart')"。
改成按 statements.length 判断,空块退化成走补完整块的分支。
写断言时踩到的两个坑,都在注释里记了:
- webpack 在不同 chunk 里对同一份注入代码排版不同,entry 是 `}if(rf & 2){`,
vendor 会排成 `} if (rf & 2) {`、双分号变 `; ;`,匹配要放宽空白。
- 判断注入点是否在 rf & 2 内不能用固定长度前文窗口,长模板
(ngSwitchCase 一堆分支那种)离最近的 rf & 2 有 1600+ 字符会误判,
改成定位到包裹它的 template 函数开头再看区间。
47 specs, 0 failures, 2 pending
按 "别用字符串匹配,用 AST" 的思路重做,结果挖出一个真 bug。
## 挖出来的 bug
旧实现用 createCssSelectorForTs 定位元数据:
PropertyAccessExpression[name=ɵɵdefineComponent]~SyntaxList ObjectLiteralExpression
那个相邻兄弟(~)组合符在同文件多组件时兄弟关系会错位。实测对
test-library 的 OutsideTemplateComponent(类上带 `template` 字段 +
同文件还有别的组件):
OLDSEL cmps= 3
OLDSEL templateKind= StringLiteral initIf= NULL => injected= false
OLDSEL templateKind= StringLiteral initIf= NULL => injected= false
OLDSEL templateKind= FunctionExpression initIf= FOUND => injected= true
`PropertyAssignment[name=template]::initializer` 解析到了一个 StringLiteral,
initIfNode 拿不到就 `continue`,函数照常返回 { content, componentName },
调用方完全看不出异常——但一条 propertyChange 都没插进去。
线上表现是该组件的属性变更不会同步到小程序侧。
## 改法
改成纯 AST 定位:走 CallExpression 找 ɵɵdefineComponent(callee 用 includes,
webpack 会包成 (0, mod["ɵɵdefineComponent"])(...)),取第一个对象字面量参数,
template 属性必须是 FunctionExpression / ArrowFunction(字符串直接不认),
rf & 1 / rf & 2 只在模板函数体顶层找,不递归进嵌入式模板
(Foo_div_1_Template 那种),避免命中别人的 rf & 1。
顺带把 import 从循环里挪出来只加一份——原来同文件 N 个组件会 push N 份
`import * as amp ...`。
## 契约修正
中途把「没注入到任何东西」改成 return undefined,直接把 builder.spec 打挂了:
SetupComponentDataService 靠返回值是否为 undefined 决定要不要产出组件元数据,
空模板组件(TestLibraryComponent 的 template 是空的)会整个不产出,
dist 里少 test-library-component/test-library-component.js。
已恢复原契约:只有本文件根本没有组件才返回 undefined。
## 测试
新增 test/util/template-inject-ast.ts,AST 层面产出每个组件的
hasInitBlock / updateStatementCount / propertyChangeCount / isLastStatement /
branch(A|B|null) 与 strayCalls,单元和端到端共用。
端到端断言换成核心不变式:凡是有 rf & 1 的组件模板都必须被注入、
只注入一次、落在 rf & 2 最后一条语句、且没有 stray。
就是这条把 OutsideTemplateComponent 抓出来的。
单元补了 MULTI_WITH_TEMPLATE_FIELD 回归用例,直接复刻 OutsideTemplateComponent
的形状(类上有 template 字段 + 同文件多组件),旧实现在这个形状上
2/3 漏注入,新实现 2/2 命中。
51 specs, 0 failures, 2 pending
改造前实测症状:ResolvedDataGroup 的 outputContent / useComponentPath / style
三个 map 按源文件路径做 key,同文件多组件时后编译的覆盖先编译的。
两个组件共用源文件 + 两个独立 entry 的情况下,先编译那个组件的模板彻底丢失
(A 的 wxml 数 = 0),且两个 entry 都渲染成最后编译的那个组件;
元数据 componentName 取第一个组件、内容却是最后一个组件的,名字和内容对不上。
改法:
1. map key 改成 `源文件#组件类名`(makeComponentKey / splitComponentKey)。
# 在 POSIX / Windows 路径里都不会出现。
2. getComponentPagePattern 加 componentClassName 比对。原来只比对文件路径,
两个 entry 各自 import 同一文件的不同组件时分不出来。
3. changeComponent 返回 componentNames: string[],componentName 保留为
componentNames[0] 并标 deprecated。
4. SetupComponentDataService 按组件逐个产出元数据,缺模板内容的组件跳过,
不会拿别的组件的内容往上接。
踩到的坑:
- typeChecker.getSymbolAtLocation(importComponent) 拿到的声明是 ImportSpecifier
而不是类声明,ts.isClassDeclaration(node) 恒 false,一开始写的类名比对根本没
生效,两个组件仍然都解析到 entry-a。必须先用 getAliasedSymbol 沿 alias 链解到
原始 symbol 再取类名。import { X as Y } 以原始类名为准。
- npm run test:ci 不会用 tsconfig.builder.json(strict) 检查 src/builder,
builder 里的 strict 类型错误只有 npm run build 才暴露。
测试:
- multi-component-per-file.build.spec.ts 端到端断言两个组件各自产出独立模板、
内容不互串(改造前 A 直接是 0 个文件)。
- change-component.spec.ts 补 componentNames 断言。
- 53 specs, 0 failures, 2 pending
不修,但记下来防止以后撞上不知道怎么查。 症状:entry-a 出完整四件套,entry-b 只有 .js,没有 .wxml/.json/.wxss, 构建 success: true 且无 warning,微信里该组件直接跑不起来。 原因:outputContent 里 `源文件#组件名` 只有一条记录, getComponentPagePattern 走反向依赖链找到第一个匹配的 entry 就 break, 第二个 entry 什么都拿不到。 文档里附了排查用的 shell 片段(在 dist 里找有 js 但缺同名 wxml 的路径), 以及和「组件重名」的区分表——重名是安全的,已实测,别往那边查。 以后要修的话最小改法也记了:对同一 key 命中的 entry 去重,发现多个就构建期抛错。
先做可行性验证,结论是通的,之前判断的三个阻塞点都解掉了。
## 验证过程与结果
用真实 fixture(hello-world-app,25 个 entry)跑通了这条链路:
1. `@angular/build/private` 的 `createAngularCompilation(false, false, false)`
对 fixture 的 src/tsconfig.app.json 做 AOT:
AFFECTED=284,EMIT=57 个文件,产物里 ɵɵdefineComponent / rf & 1 / rf & 2
结构完整。
2. 在 vite 里挂 @analogjs/vite-plugin-angular
(fastCompile: false, experimental.useAngularCompilationAPI: true),
再在它**后面**挂一个探针插件,探针看到:
PROBE>>> len=1213 hasDefine=true hasRf1=true hasRf2=true
即完整的 AOT 产物。构建 247 个模块 1.32s(webpack 同一套要 5~6s)。
## 三个阻塞点为什么解了
- 「esbuild onLoad 抢占式,拦不到 AOT 产物」:Vite/Rollup 的 transform 是
链式的,每个插件的输出喂给下一个,排在 Angular 插件后面就能拿到编译结果。
- 「注入的 ESM import 在 bundle 里非法」:因为是在 per-module、bundle 之前的
transform 阶段注入,那里 import 语句合法,Rollup 打包时会正常解析。
所以不需要给 runtime 挂全局,库的运行时契约不用动。
- 「splitChunks 没有等价物」:Rollup 有 output.manualChunks(函数式),
比 esbuild 那个不透明的 splitting 接近 cacheGroups;模块替换用 resolve.alias。
## 依赖变更
- 新增 vite ^8.3.0、@analogjs/vite-plugin-angular ^2.7.2
(peer 支持 Angular 18~22 + vite 6~8)
- @types/node 20.11.30 -> ^22.19.0
vite 8 要求 ^20.19.0 || >=22.12.0,原来的 20.11.30 不满足,装不上
## 踩到的坑(记录)
- createAngularCompilation 的 hostOptions 必须给 processWebWorker,
否则 AotCompilation.initialize 里 hostOptions.processWebWorker.bind 直接抛
"Cannot read properties of undefined (reading 'bind')"。
- 探针一开始一直 emit 0 个文件,是因为 tsconfig 指到了 fixture 根目录那个
solution-style 配置(files/include 全空,只有 references),程序是空的。
必须指到 src/tsconfig.app.json。
仓库仍是绿的:53 specs, 0 failures, 2 pending。webpack 路径未动。
用 Vite + @analogjs/vite-plugin-angular 跑通了整个多入口构建,
实测 23 个入口 1.5s,对比 webpack 的 5.7s。
新增 src/builder/vite/:
- entry-patterns.ts:从 DynamicWatchEntryPlugin 里抽出入站计算
(resolveProjectRoots / generateModuleInfo),webpack 与 Vite 共用一份,
另加 toRollupInput 把 PagePattern 转成 Rollup 多入口
- plugins/component-transform.plugin.ts:在 transform 阶段注入 propertyChange,
对应 webpack 时代的 component-template.loader
- tsconfig-paths.ts:把 tsconfig 的 baseUrl + paths 翻成 Vite 的 resolve.alias
- index.ts:Vite 配置组装 + Architect builder 包装
三个必须自己补上的坑(webpack 侧由 @ngtools/webpack / webpack 本身兜掉的):
1. Vite / Rolldown 不读 tsconfig paths,`angular-miniprogram` 这类映射
解析不到,必须自己转成 resolve.alias。alias 按 key 长度倒序排,
否则 `angular-miniprogram` 会把 `angular-miniprogram/platform/wx` 抢走。
2. 平台包替换必须用 RegExp 带边界(new RegExp('^...$'))。Vite 的字符串
alias 走「精确 或 startsWith(find + '/')」,写 `.../wx$` 会被当字面量。
3. 组件注入插件要 enforce: 'post',保证排在 analog 的 Angular 插件之后,
这样拿到的才是 AOT 产物而不是原始 TS。
顺带:tsconfig.builder.json / tsconfig.spec.json 给 vite 加了显式类型入口
(vite 的 package.json 没有 main / types 字段,只靠 exports map,
moduleResolution: node10 认不出来)。
本阶段只产出 JS,wxml / json / wxss 的资产产出在下一步接入。
验证:npm run build ✅ npm run test:ci ✅ (54 specs, 0 failures, 2 pending)
npm run lint ✅
新增 src/builder/vite/plugins/mini-program-assets.plugin.ts, 对应 webpack 的 ExportMiniProgramAssetsPlugin,产出内容一致: 1. metaMap.outputContent -> wxml 2. metaMap.style -> wxss(样式源文件编译后按组件拼接) 3. metaMap.config -> json(合并目录里已存在的配置) 4. library 组件 config -> json 5. library 模板 -> literalResolve 转换后落盘 6. metaMap.selfTemplate -> self template 去 webpack 化的两个适配: - ts.System:webpack 侧用 @ngtools/webpack 的 createWebpackSystem(走 compiler.inputFileSystem),这里换成纯 node fs 实现。 - webpack.Compiler 桩:MiniProgramApplicationAnalysisService 实际只读 compiler.watchMode 和 compiler.inputFileSystem?.purge?.() 两处, 给个最小 stub 即可,不用真造 webpack。 WEBPACK_COMPILATION 在 Vite 链路下没有对应物,传 undefined (该 token 只被 webpack loader 侧的 symbol 挂载用到)。 样式编译复用已有的 CustomStyleSheetProcessor(ng-packagr 的 StylesheetProcessor 子类,library 链路早就在用),不用另起一套管线。 踩到的坑:Rollup / Rolldown 不接受绝对路径或以 / 开头的 fileName (webpack 会归一化)。metaMap 里有 /self-template/self.wxml 这种 带前导斜杠的 key,必须 strip 掉。注意 wxml 里的 `<import src="/self-template/self.wxml"/>` 引用要保留前导斜杠—— 小程序里那表示包根路径,是对的,只有落盘的 fileName 需要去斜杠。 验证:npm run build ✅ npm run test:ci ✅ (55 specs, 0 failures, 2 pending) npm run lint ✅
新增: - plugins/library-template.plugin.ts:把 webpack 的 library.loader + library-template.loader 合成一个 Vite transform。两个 loader 都是纯源码 分析(CSS selector 抓元数据变量)+ emitFile + 注册 scope,替换点只有 this.emitFile / this._compilation[Symbol] / this.resourcePath。 - copy-assets.ts:展开 builder 的 assets 配置。app.json 和 project.config.json 就是从这条路径进产物的(webpack 侧靠 copy-webpack-plugin)。 - parity.spec.ts:同一份 fixture 分别走 webpack 和 Vite,比对产物。 新增 parity 断言(5 条全绿): 1. webpack 构建成功 2. vite 构建成功 3. 两边产出文件清单完全一致 4. wxml 逐字节一致 5. json 语义一致(忽略 key 顺序) 修掉一个漏移植的步骤(关键): assets 插件里漏了 otherMetaCollectionGroup -> setScopeExtraUseComponents 这一步。少了它,scope 的 templateList 一直是空的, library-template/TestLibrary.wxml 会被 emit 成一个 0 字节文件—— 文件在、不报错,但内容是空的。这种静默空产物比直接崩难查得多, 是靠 parity 的文件内容比对(而不只是清单比对)抓出来的。 其他: - runViteBuilder 输出补上 baseOutputPath,和 webpack browser builder 的 输出契约对齐,spec 靠这个定位产物。 - Vite 的 emitFile 不接受以 / 开头的 fileName,devkit 的 join/normalize 出来的路径带前导斜杠,统一在 emit 前归一化。 验证:npm run build ✅ npm run test:ci ✅ (60 specs, 0 failures, 2 pending) npm run lint ✅
app.js(对应 webpack 的 BootstrapAssetsPlugin):
小程序没有模块系统,靠 app.js 里一串 require 把启动需要的 chunk 拉起来。
webpack 侧是 importTemplate + json.scripts.map(i => require('./' + i.src)),
这里按 chunk 的 imports 做拓扑排序后生成,保证依赖在前、入口在后
——拼接后都是全局作用域,顺序错了会拿到 undefined。
app.wxss:builder 配置里 styles 的编译产物,小程序的全局样式。
复用同一个 CustomStyleSheetProcessor。
parity 断言调整:
JS chunk 结构不纳入严格比对。webpack 有 runtime.js / vendor.js /
module-chunk.js 这套自己的拆包产物,Vite(rolldown) 的 hash 和拆包策略
本来就不同,逐文件比对没有意义。改成三条功能性断言:
- 小程序侧产物清单一致(wxml/json/wxss/app.js/app.wxss)
- 每个页面、组件入口 js 两边都存在且路径一致
- app.js 里 require 的文件都真实存在(防止 require 到空路径)
加上原有的 wxml 逐字节一致、json 语义一致,parity 共 8 条全绿。
验证:npm run build ✅ npm run test:ci ✅ (63 specs, 0 failures, 2 pending)
npm run lint ✅
watch 模式两个卡点没打通,改成显式抛错: 1. Vite/Rolldown 的 watch 不支持动态加 input,watch 期间新增入口文件 不会被拉进来(webpack 侧靠 DynamicWatchEntryPlugin 每轮改 config.entry) 2. devkit harness 的 watcherNotifier 走 webpack 的通知路径,Vite watcher 的重构建事件传不回测试里,拿不到第二轮结果 与其让不完整的 watch 静默产出错误产物,不如直接报错并说明用 webpack 开发。 顺带清掉 watch 分支留下的不可达代码。 DEVELOPMENT.md 补全 Vite 迁移现状: - 产物对等表格(8 条 parity 断言) - 补齐的产出清单(wxml/json/wxss/library 模板/assets/app.js/app.wxss) - 去 webpack 化的适配点 - 6 条 Vite 迁移专属踩坑记录 验证:npm run build ✅ npm run test:ci ✅ (63 specs, 0 failures, 2 pending) npm run lint ✅
不再抛错,改成「发现变动就重算入口 + 重跑一次 vite.build」。 为什么不用 Vite 原生 watch:Rolldown 的 watch 不支持动态加 input, watch 期间新增的入口文件拉不进来。webpack 侧是靠 DynamicWatchEntryPlugin 每轮重写 config.entry 解决的。重跑整轮构建顺带把这个问题一起解决, 冷构建才 1.5s,dev 体验完全可接受。 新增 watch-sources.ts: - watchSources():优先用传入的 watcher 工厂(测试里是 harness 的 WatcherNotifier),没有就退化成 fs.watch recursive - 带 debounce,一次保存不会触发多次重建 - collectWatchDirectories():sourceRoot + 各入口所在目录 builder 侧: - 每轮重新生成 config,入口 glob 重新展开 - running / queued 两个标志位,构建中又改了会排到下一轮而不是丢掉 - unsubscribe 时关 watcher 修掉三个真实 bug: 1. watcher 必须在推第一次成功输出**之前**注册好。 原来 emitSuccess() 之后才异步算入口、注册 watcher,消费方(含测试) 收到第一轮结果后立刻改文件,那个改动发生在注册之前,直接丢。 表现是 watch 永远等不到第二轮,600s 超时。 2. bootstrapPage(InlineComponent) 会崩。 getComponentPagePattern 里 `node.parent.parent.parent` 假定组件一定是 import 进来的;组件声明在 entry 文件自身时没有 ImportDeclaration, parent 链走到 undefined 直接 TypeError。 现在:组件声明文件就是 entry 本身 -> 直接命中; 解析不到 import -> 跳过这个候选而不是崩。 3. wxml 编译器会给每个节点注入 class / style 属性, `<view>text</view>` 编译后是 `<view class="..." style="...">text</view>`。 写断言时不能拿整段标签字符串去比,只能比标记文本本身。 顺带:给 vendored devkit 的 HarnessBuilderContext 加了 getWatcherFactory(), 真实 architect 不提供这个口子(webpack 自己管 watch),但我们的 Vite builder 需要自己实现 watch、测试里又必须走 harness 的通知路径。 验证:npm run build ✅ npm run test:ci ✅ (65 specs, 0 failures, 2 pending) npm run lint ✅
builders.json 新增 application-vite,schema 复用 application 的 (字段是超集,Vite builder 只读自己需要的那部分: tsConfig / outputPath / pages / components / platform / assets / styles / sourceMap / optimization / watch)。 copy:assets 已经把 src/builder/**/*.json 整体拷到 dist/builder, 所以 builders.json 和 vite/schema.json 会自动跟着走,不用改构建脚本。 现在同一个项目里可以并存: "builder": "angular-miniprogram:application" // webpack "builder": "angular-miniprogram:application-vite" // Vite 验证:npm run build ✅ npm run test:ci ✅ (65 specs, 0 failures, 2 pending) npm run lint ✅
实测 webpack=34 / vite=35 个 js chunk,基本持平, webpack 那套 moduleChunks / defaultVendors 在 Vite 下没有收益, 不移植。 改成一条 chunk 数量守卫断言:vite 的 js chunk 数不超过 webpack 的 1.2 倍。 小程序里每个 chunk 都是一次文件加载,数量爆炸会直接拖慢启动, 这条能挡住以后改配置改出一堆碎片 chunk。 验证:npm run test:ci ✅ (66 specs, 0 failures, 2 pending)
## 目标
新版 Angular 启动不再走 NgModule(浏览器侧是
`bootstrapApplication(App, appConfig)`)。参考 opentui-angular 的
`application.ts`:
ɵinternalCreateApplication({
rootComponent, appProviders, platformProviders, platformRef
})
## 小程序没有启动组件——但 Angular 支持不传
这是本题的关键。查 `@angular/core` 编译产物:
// rootComponent 是可选的
if (config.rootComponent !== undefined) {
appRef.bootstrap(config.rootComponent);
}
另外还有 `ɵENABLE_ROOT_COMPONENT_BOOTSTRAP` token 可整体关掉,
直接返回 ApplicationRef。
所以**只建 ApplicationRef,不 bootstrap 任何组件**。ApplicationRef
在这里的作用是「已挂载视图的容器 + 变更调度入口」,页面/组件由
小程序运行时逐个创建后经 `PageService.__ngStartPage` 的
`createComponent` + `attachView` 挂进来。
## 顺带查明:ApplicationModule 是空壳
class ApplicationModule {
static ɵinj = __defineInjector({}); // ← 一个 provider 都没有
}
原 `MiniProgramModule` 的 `imports: [ApplicationModule]` 是 no-op,
直接去掉。`ApplicationRef` 由 core 自己装配。
## 改动
**① 新增 `platform/application.ts`**
- `provideMiniProgramApp()` —— app 级 provider,逐项对应原
MiniProgramModule 的 providers;HttpClientModule 换成
官方 `provideHttpClient(withMiniProgramRequest(), withInterceptorsFromDi())`
- `provideMiniProgramStartup()` —— 启动钩子
- `bootstrapApplication(config?)` —— 无 root component
**② 为什么 register() 拆成单独的 startup**
原 `pageService.register()` 挂在 MiniProgramModule 构造函数上。改用
`provideAppInitializer` 后,测试环境(ts-node JIT,
emitDecoratorMetadata 关闭)会去实例化 PageService,触发 NG0202
—— 其构造依赖 Injector/EnvironmentInjector/ApplicationRef 的类型
JIT 拿不到。真实应用走 ng-packagr AOT,类型从 AST 解析,无此问题。
所以拆两个函数:
bootstrapApplication() → app + startup
initMiniProgramTestEnv() → 只要 app,不自动 register
**③ 删除 MiniProgramModule / MainModule / MainTestModule**
`MainModule` 本就是空壳(`ngDoBootstrap() {}`),存在的唯一理由是
让 MiniProgramModule 被实例化以跑 register()。现在不需要了。
**④ init-env.ts 不再手工镜像 provider 列表**
原来是「镜像 MiniProgramModule 的 provider 列表,但不带它的 ctor」,
改一处就要跟着改两边。现在直接用 `provideMiniProgramApp()`。
**⑤ 测试应用启动**
// 之前
platformMiniProgram().bootstrapModule(MainModule)
// 现在
bootstrapApplication().catch(err => console.error(err))
## 新增 application.spec.ts(6 个)
逐项钉住「原 NgModule 提供的东西一个没丢」:
- ɵINJECTOR_SCOPE === 'root'
- RendererFactory2 → MiniProgramRendererFactory
- ComponentFinderService / ErrorHandler 可解析
- HttpBackend → MiniprogramHttpBackend(原 HttpClientModule 等价物)
- ApplicationRef 可解析(证明不需要 ApplicationModule)
- PageService 已注册(断言不是 NG201「找不到 provider」;
JIT 下不可实例化是已知限制,已在注释说明)
验证:npm run build ✅ npm run lint ✅ npm run test:ci ✅ (146 specs, 0 failures)
## 报错
src/library/platform/default/lview-layout.spec.ts(35,29):
error TS2339: Property 'resolve' does not exist on type 'Require'.
## 根因:两个类型体系抢同一个全局名
`tsconfig.spec.json` 明明配了 `"types": ["jasmine", "node"]`,
`@types/node` 的 `NodeJS.Require` 也确实有 `resolve: RequireResolve`,
但全局 `require` 并不是解析到它。
`platform-core.ts` 带了一句
/// <reference types="miniprogram-api-typings"/>
而 `miniprogram-api-typings/types/wx/index.d.ts` 声明了**全局**:
interface Require {
(module, callback?, errorCallback?): any
async(module): Promise<any>
}
declare const require: Require
小程序的 `require` 只有 `()` 和 `.async()`,**没有 `resolve`**。
本 spec 与 `platform-core.ts` 同处一个编译单元,全局 `require`
就被小程序那一版接管了。
(已用最小复现确认:随便一个 spec 里写 `require.resolve('fs')`
同样报 TS2339。)
## 修法:不碰全局名,改用 createRequire
import { createRequire } from 'module';
const nodeRequire = createRequire(__filename);
const coreEntry = nodeRequire.resolve('@angular/core');
`createRequire` 由 `@types/node` 完整 typing,走的是模块导入而不是
全局标识符,所以跟 miniprogram-api-typings 的全局 `Require` 互不干扰。
比给全局类型打补丁(declare module / 交叉类型)干净:不动别人的
类型体系,也不依赖谁先被加载。
## 验证
npx tsc -p tsconfig.spec.json --noEmit → 完全无错误(之前有 TS2339)
全仓扫过,其余 spec 里的 `require(` 都是字符串字面量断言,
没有真实的 require.resolve 用法。
验证:npm run build ✅ npm run lint ✅ npm run test:ci ✅ (146 specs, 0 failures)
参照 `@angular/compiler` 的 `RecursiveVisitor`(render3/r3_ast.ts)与
`RecursiveAstVisitor`(expression_parser/ast.ts)逐方法比对,查出并修掉
8 处缺口。所有实测结论均由探针验证,非推断。
## 一、表达式侧(CustomAstVisitor)
### ① visitPipe 不访问 args —— 漏计嵌套管道【真 bug】
实测:`{{ a | date:(b | number) }}` 实际是 **2 个管道**
(`date` + 参数里的 `number`),旧实现只数到 1。
visitPipe(ast) { this.pipeCallback(); this.visitAll(ast.args); }
漏一个管道 = 后面所有节点声明槽整体错位一位,渲染错乱但不报错。
### ② visitSafePropertyRead 是空实现 —— 漏 receiver 子树【真 bug】
{{ (a | number)?.b }} → 1 个管道藏在 receiver 里,旧实现计 0
visitSafePropertyRead(ast) { this.visit(ast.receiver); }
### ③ visitThisReceiver 缺失
`ThisReceiver.visit()` 走的是 `visitor.visitThisReceiver?.(...)`
**可选调用** —— 方法不存在时返回 undefined 而不报错,
等于静默跳过整棵子树。补上。
### ④ visitEmptyExpr 缺失
`[title]=""` / `{{ }}` 是独立 AST 类型,缺方法会让遍历在此中断。补上。
### ⑤ visitTemplateLiteral / visitTaggedTemplateLiteral 不按声明序
改为照抄 Angular:elements 与 expressions 交替按声明序访问
(elements 比 expressions 多一个);tagged 版把整个 template
节点交回去,而不是只挑 expressions。
### ⑥ visit() 是空的,没有 dispatch
内部遍历统一改走 `this.visit(child)`,`visit()` 负责
`ast.visit(this)` 派发(对齐 `RecursiveAstVisitor.visit`)。
## 二、模板侧(TemplateDefinition)
### ⑦ visitIcu 空实现 —— 静默丢掉整段内容【真 bug】
实测 `{count, plural, ...}` 会真实产出 `Icu` 节点,而旧实现留空
→ 内容消失 + 后续槽位错位,且不报错。改为显式抛错
(小程序无 ICU 运行时能力),与 @defer / @content 同策略。
### ⑧ visitContent 不访问 children —— fallback 静默丢失
实测 `<ng-content>fallback</ng-content>` 的 children=1
(空标签与纯空白 Angular 归一成 `[]`)。小程序 slot 无 fallback 能力,
对非空 children 显式抛错。已确认仓库内无此用法,不打破现有代码。
### ⑨ visitComponent / visitDirective 空实现
实测 `parseTemplate` 路径**不产出**这两个节点
(普通标签与 selectorless 都只出 `Element`),它们来自旧的
`r3_template_transform` 与类型检查路径。
留空 = 「万一出现就静默丢」,改为抛错,让上游 AST 来源变化立刻暴露。
### ⑩ 删死代码
`init() {}` 无人调用;`visit?(node) {}` 声明了但本 fork 的 `visitAll`
不走它(Angular 的 `visitAll` 会用它拦截),留着语义反而不一致。
## 三、注释
**每个 visit 方法都补了「处理的是哪种写法的代码」的 demo**,
例如:
/**
* 管道 —— 本访问器唯一真正关心的节点。
*
* {{ title | uppercase }} → 1 个
* {{ a | date:(b | number) }} → 2 个(参数里还能再嵌)
*/
方法顺序刻意照抄 Angular,升级时可直接 diff 出「新增了哪种 AST 节点」。
## 四、查证清楚但**不需要改**的(避免误伤)
- `@if (x; as alias)` **不占槽**:`ingest.ts` 走
`cView.contextVariables.set(name, CTX_REF)`,是分支视图上下文变量
- `ng-template` 的 `let-` **不占槽**:同上走 `contextVariables`;
只有 `#ref` 占槽(`local_refs.ts`: `numSlotsUsed += localRefs.length`)
- `outputs` 无需访问:Angular 语法禁止事件表达式带管道
(`(click)="a|b"` → "Cannot have a pipe in an action expression")
- `attributes` 无需访问:`TextAttribute` 是纯字面量,无表达式 AST
- `@let` / `visitVariable` / `visitReference` 的空实现是**正确的**,
已在注释里写明「不占位」而非漏实现
## 五、测试(新增 43 条,template-definition.spec.ts)
**表达式侧(29 条)**:以 Angular 自己的 `RecursiveAstVisitor` 为
**权威基准**,28 种表达式逐条比对管道数,要求
`我们的计数 === Angular 的计数`。覆盖
管道/嵌套参数/安全读/this/二元/三元/数组/对象/下标/调用/
模板字符串/展开/一元/括号/typeof/void/非空断言等全部节点类型。
**反向对照**:复刻修复前的行为(visitPipe 不走 args +
safePropertyRead 空 + thisReceiver 空),断言其计数
**严格小于**权威值 —— 证明这套测试真能抓到漏计,不是恒等式。
**模板侧(7 条)**:ICU plural/select 抛错、ng-content fallback 抛错、
ng-content 空/纯空白正常通过(对照)、@defer/@content 仍抛错、
visitComponent/visitDirective 被调用即抛错。
**端到端(7 条)**:整段模板的 `declIndex` 与
「节点数 + 权威管道数」对齐,把表达式侧与模板侧串起来验证,
防止两侧各自正确、合起来错位。
验证:npm run build ✅ npm run lint ✅ npm run test:ci ✅ (189 specs, 0 failures)
## 1. git bisect 残留(已在上一轮 git bisect reset 清掉)
`.git/BISECT_*` 是定位 require.resolve 触发点那次二分留下的。
我的失误:收尾用了 `git bisect reset -q 2>/dev/null`,把输出全压掉,
**恰好把「没成功」这个最关键的信息藏了**。之后又在这个残留状态下
继续 checkout / 改码 / 提交,全靠每次都显式指定分支名才没出乱子。
教训:会改仓库状态的操作,收尾不该加 `-q` 和 `2>/dev/null`,
应该跑完再显式 `git status` 确认。
## 2. 临时产物
- `test/test-project-host-*` 累积 **47 个 / 61MB**(构建测试的临时
工程目录,虽已 gitignore 但一直堆积)→ 已清
- `/tmp` 下本次会话的实验脚本 → 已清
## 3. 陈旧注释
`src/builder/vite/index.ts` 的 `main` 字段注释还写着
`platformMiniProgram().bootstrapModule(MainModule)`,
而启动已 provider 化。改为现写法,并注明历史沿革。
## 4. DEVELOPMENT.md 缺两条「不支持」记录
`@defer` / `@content` 有记录,但这次新加的两条没有,补上:
- **ICU 消息** —— 实测节点会真实产出,`visitIcu` 曾是空实现
→ 内容静默消失 + 槽位错位。并注明这**不是「做不到」**:
本 fork 已有的 `__templateName`(`<template is="{{...}}">`)
恰好就是它需要的能力,只是未实现
- **`<ng-content>` fallback** —— 空/纯空白 Angular 归一成 `[]`,
只有写兜底才有子节点;小程序 slot 无此能力
## 核查结论(无需处理)
- `git stash` 空、工作区干净、HEAD 正常指向分支
- `src/` 下无探针/临时 spec 残留
- `MiniProgramModule` / `MainModule` 的引用**全在注释里**
(记录迁移沿革),无代码级残留引用
- Windows 路径**已有测试覆盖**:`asset-path.spec.ts` 用显式
`path.win32` 复现并验证,不依赖真机
验证:npm run build ✅ npm run lint ✅ npm run test:ci ✅ (189 specs, 0 failures)
## 现象
`*ngIf="flag; else tpl"` 这类结构,第一次点击正常,之后报
Setting data field "nodeList.11.0.__templateName" to undefined is invalid.
并**彻底不动了**。
## 根因:微信 setData 拒绝 undefined,且是整次调用失败
不是只丢那一个字段 —— 整个 `setData` 被拒,界面从此不再更新。
触发路径是 **diff 产出的路径式 key**:
{ "nodeList.11.0.__templateName": undefined }
为什么第一次没事:首次渲染走**整体** setData,对象里的 `undefined`
被 JSON 序列化直接丢掉,看不出问题。只有走 diff(路径式)才会撞上。
## 为什么这个模板会产出 undefined
`__templateName` 的推导是
`tView.declTNode.localNames[0]`(模板声明名):
| 分支 | 模板 | localNames[0] |
|---|---|---|
| `if` | `*ngIf` 脱糖出的 `<ng-template>`,**没有 `#ref`** | 无 → `undefined` |
| `else` | `<ng-template #ngIfElseTemplate>` | `'ngIfElseTemplate'` |
于是:
第一次点(if → else) diff 送出字符串 → 正常 ✅
第二次点(else → if) diff 送出 undefined → 整个 setData 被拒 ❌
「只能点击一次」完全对上。
## 修法(两层)
### ① 源头:兼底值 `undefined` → `null`
`component-template-hook.factory.ts`
__templateName: (context.__templateName) || declTNode.localNames[0] || null
`null` 是合法 setData 值,且在 wxml 里仍为 falsy,
`{{item.__templateName || 'xxxBlock_N'}}` 行为完全不变。
`MPView.__templateName` 类型同步改为 `string | null`。
### ② 防线:diff 出口统一净化
`diff-node-data.ts` 加 `sanitizeUndefined()`,在 `diffNodeData` 出口
把任意深度的 `undefined` 换成 `null`。
**两条返回路径都要处理** —— 只改逐字段那处不够:当所有 key 都变了会
走 `allChange` 分支直接返回整个 `to`,那里同样带着 `undefined`。
这一层是防住**整类**问题:`value` / `class` / `property.*` 任何一个
字段变成 `undefined`,都会引发同样的「整次 setData 被拒 → 冻结」。
## 测试(新增 6 条)
`diff-node-data.spec.ts`:
- 顶层字段变 undefined → 转 null
- 嵌套对象字段变 undefined → 转 null
- **复现真实场景**:`nodeList.N.0.__templateName` 有名 → 无名,
断言 diff 里无 undefined,且该路径被清成 `null`
- 数组元素变 undefined → 转 null
- **反向对照**:朴素实现(直接赋值)确实会漏出
`nodeList[0][0].__templateName`,而真实实现不会
- 值未变(都是 null)不产生 diff
## 顺带修正一条错误断言
`template-name-coverage.spec.ts` 原来写的是「值可为 `undefined`,
但字段必须存在」—— 这个断言本身就是错的,正是它让这个 bug 过了测试。
改为断言 `n === null || typeof n === 'string'`。
## 暴露的测试缺口
现有测试**全部只测首次渲染**,没有一个做
「改状态 → 再渲染 → diff → setData」。而本 bug 恰好只在第二次及以后的
diff 上出现。已用 diff 层的用例补上这个方向,但**页面级的
「切换两次」端到端用例仍缺**(TestBed 里 CD 不重跑,需另找路子)。
验证:npm run build ✅ npm run lint ✅ npm run test:ci ✅ (195 specs, 0 failures)
把这次 `__templateName` 回归的完整来龙去脉写进 DEVELOPMENT.md, 避免以后再踩。 ## 新增章节:⚠️ 铁律:进 setData 的数据里绝不允许 undefined 放在文末,并在**顶部加了「🔴 开工前先读这一条」指引**,保证能被发现。 内容: ### 三种「没有值」的区别(对照表) | 写法 | setData | wxml `{{x || '兜底'}}` | |---|---|---| | `undefined` | ❌ **整次调用失败** | — | | `null` | ✅ | 走兜底(falsy) | | 字段不存在 | ✅ | 走兜底,但会让 diff 误判 key 数量变化 → 退化全量 | **无值一律用 `null`。** ### 为什么「第一次正常,之后就坏」 - 首次渲染走**整体** setData,`undefined` 被 JSON 序列化丢掉 → 看不出问题 - 后续走 **diff**,产出**路径式 key** `{ "nodeList.11.0.__templateName": undefined }`, 不会被序列化丢掉 → 撞微信参数校验 → **整次 setData 被拒** → 冻结 **所以「首次渲染通过」的测试完全测不到这个坑。** ### 真实触发案例 `*ngIf="flag; else tpl"`:if 分支是脱糖模板**无 `#ref`** → 名字取不到; else 分支有 `#ngIfElseTemplate` → 有名。 第一次点(if → else) 送出字符串 → 正常 第二次点(else → if) 送出 undefined → 冻结 ### 历史回归对照 | 版本 | 无名模板的值 | 结果 | |---|---|---| | 旧(AST patch,改 Angular 源码) | `null` | ✅ | | `c290628`(改为 fork 自己推导) | `undefined` | ❌ 切换两次即坏 | | `492876b` | `null` | ✅ 恢复旧语义 | 并记下那个**把判断带偏的细节**:旧代码类型声明是 `string | undefined`,实际值一直是 `null` —— **类型与实际值本来就不一致**, 看类型会以为 `undefined` 是正常态。 **教训:做「等价替代」时兼底值也要逐一对齐;类型与实际值冲突时, 以实际值(跑一遍看产物)为准。** ### 现在的两道防线 1. 源头兼底 `null`(`component-template-hook.factory.ts`) 2. `diffNodeData` 出口 `sanitizeUndefined()` 净化任意深度 —— 特别注明**两条返回路径都要处理**,`allChange` 分支直接返回 整个 `to` 时也带着 `undefined`(第一次修就漏在这) ### 写代码检查清单(5 条) 1. 可能为空的字段兼底写 `null`,不写 `undefined` 2. 不要靠「省字段」表达无值(会让 diff 退化全量) 3. 类型声明要和实际值一致 4. **测试必须覆盖「第二次更新」**,至少断言 diff 产出里无任何 `undefined` 5. 新增 `MPView` / `MPElementData` / `MPTextData` 字段时回头再看这节 ### 相关测试索引 列出 `diff-node-data.spec.ts`(含反向对照)与 `template-name-coverage.spec.ts`,并注明后者原先「值可为 `undefined`」 的断言**本身就是错的**、正是它让 bug 过了测试。 DEVELOPMENT.md:1100 → 1275 行
## 现象
`base-forms` 页面里 `<input [(ngModel)]>` 和 `<checkbox-group>`
**点了完全没反应**。手动往 wxml 加 `bind:input` 就正常 —— 说明运行时
分发是好的,**缺的是生成阶段**。
## 根因链
指令的 host listener 要经过一条**跨构建阶段**的传递链:
```
库构建:AddDeclarationMetaDataService
→ 往中间 .d.ts 追加
declare const DefaultValueAccessor_Listeners:["bindinput","bindblur"]
↓
应用构建:getLibraryDirectiveMeta()
→ 从库 d.ts 查这个 declare const
↓
ComponentContext 用它【覆盖】host.listeners
↓
wxml 生成 bind:input="bindEvent"
```
**ng-packagr 22 的 d.ts 扁平化会把不在导出引用图里的
`declare const` tree-shake 掉** —— 标记丢失。
于是:
```
getLibraryDirectiveMeta() → listeners: []
→ ComponentContext 用 [] 覆盖掉 host.listeners
→ wxml 里一个事件绑定都没有
→ 表单输入/勾选/picker 全部不响应,且【没有任何报错】
```
实测证据(修复前):
```html
<input type="text" class="{{nodeList[0].class}}" style="{{nodeList[0].style}}" />
<checkbox-group class="{{nodeList[1].class}}" style="{{nodeList[1].style}}">
```
零事件绑定。
## 为什么以前是好的
ng-packagr 随 Angular 一起升到了 **22.1.1**(git 里 `17.3.0` 是 17 时代)。
新版扁平化(rolldown)对 d.ts 的 tree-shaking 更激进。
试过加 `export` —— **仍然被丢**,因为入口的 re-export 列表里没有它们,
不在引用图里。
## 修法:扁平化之后补写
标记必须在扁平化**之后**写回最终的 `dist/types/*.d.ts`。
### 新增 `src/builder/library/library-meta-marker.ts`
- `recordLibraryMetaMarker(entryPoint, text)` —— 库构建期间暂存
- `flushLibraryMetaMarkers(distRoot)` —— 扫 `package.json#typings`
定位最终 d.ts 并追加,**带哨兵实现幂等**(watch 模式反复调用安全)
**所有标记写进所有 entry 的 d.ts**,理由:
- 应用侧按「指令类所在的那个 d.ts」查标记,全集写入保证无论 TS 把指令
解析到哪个文件都能命中
- 扁平化后的 d.ts 是**模块**(含 `export`),`declare const` 是模块作用域,
跨文件同名不冲突
- 一个指令只属于一个 entry,不存在同名不同义
### `AddDeclarationMetaDataService.run()`
除原有追加外,同时 `recordLibraryMetaMarker()` 暂存一份。
### `ng-packagr-factory.ts`
包一层 `build()` / `watch()`,走完后 flush。
**放在工厂而不是 `builder.ts`** —— 因为 `npm run build:library` 直接调
`ngPackagrFactory`,**不经过 architect builder**;在工厂包一次两条路径都覆盖。
(第一版就是放错了地方,构建成功但标记数仍为 0,才发现这条路径。)
## 测试(新增 5 条)
`library-meta-marker.spec.ts`,基于**真实构建产物**:
1. 库 d.ts 里存在 `_Listeners` 标记(直接钉根因)
2. 标记内容含正确的微信事件名(`bindinput` / `bindchange`)
3. `base-forms` wxml 里 input 有 `bind:input` / `bind:blur`
4. `base-forms` wxml 里 checkbox-group 有 `bind:change`
5. **反向对照**:wxml 里出现的每个 `bind:<x>` 都必须能在库标记里找到来源
—— 若事件是别处硬编码的,这个对应关系就不成立
## 修复后的 wxml
```html
<input type="text" ... value="{{nodeList[0].property.value}}"
disabled="{{nodeList[0].property.disabled}}"
data-node-path="{{nodePath}}" data-node-index="{{0}}"
bind:input="bindEvent" bind:blur="bindEvent"/>
<checkbox-group ... data-node-index="{{1}}" bind:change="bindEvent">
<checkbox value="{{nodeList[2].property.value}}"
checked="{{nodeList[2].property.checked}}"></checkbox>
</checkbox-group>
```
## 遗留观察
`ComponentContext` 里 `libraryMeta.listeners` 是**无条件覆盖**
`Object.keys(host.listeners)` 的。库指令在应用构建时 `directiveMeta`
本来就是 `undefined`,所以标记是唯一来源 —— 这也意味着**标记一旦丢失
就静默变空**,没有任何告警。现在 flush 失败会 `console.warn`,
但「标记存在但内容不对」这种情况仍无校验。
验证:npm run build ✅ npm run lint ✅ npm run test:ci ✅ (200 specs, 0 failures)
- 新增 builder 选项 appJson:指向结构化 app 配置源文件,构建器编译生成 app.json - 编译期校验:pages 重复、分包 root 合法性、分包页面与主包冲突、 tabBar 必须在主包、preloadRule 页面/分包引用存在性 - 与 assets 静态 app.json 互斥,同时出现直接报错,杜绝「改了没效果」 - 不配置 appJson 时维持旧行为(逃生舱) - app-config 单测 16 项 + 构建集成测试 3 项,全量 219 specs 通过
- 代码级:注入 __MP_PLATFORM__ / __MP_WX__ / __MP_ZFB__ 等常量,
if(__MP_WX__){} 死分支由 bundler DCE 移除,零运行时开销
- 文件级:platformFileResolvePlugin 支持 foo.wx.ts 优先于 foo.ts
(等价 uni-app 的 index.mp-weixin.vue,走标准 resolve 钩子)
- 提供 platform-flags.d.ts 类型声明,copy:assets 一并产出
- vite 与 karma 两条链路都接入
- 单测 3 项 + 构建集成 2 项(wx 取变体+DCE / zfb 回落 base),全量 224 specs 通过
- app-config 新增 resolveSubPackages / findSubPackageByPath - subpackageChunkPlugin: * config() 改 chunkFileNames,分包专属 chunk 落进分包目录(主包瘦身) * 跨分包静态 import 检测报错(小程序禁止跨分包 require) * 独立分包不得依赖主包 chunk 的隔离校验 - 约定分包 root 同时是源码目录(src/<root>)与产物目录(<root>),对齐 uni-app - 从 appJson 解析分包配置,单数据源 - 分包解析单测 2 项 + 构建集成 2 项(产物归位 / 跨分包检测),全量 228 specs 通过 - 注:独立分包的完整 chunk 自带化(复制依赖进独立包)为后续项,当前为隔离校验
- 新增 builder 选项 nativeComponentsDir(如 src/wxcomponents)
- nativeComponentsPlugin:
* 整个原生组件目录原样拷进产物(基路径取目录名,落产物根)
* generateBundle(post) 扫已产出 wxml,命中原生标签往同级 json 注入
usingComponents 指向原生组件主文件(相对路径自动计算)
- 目录约定:<dir>/<name>/<name>.json 即认定为原生组件,标签名=目录名,可 tagMap 覆盖
- 模板里直接写 <van-button>(配 NO_ERRORS_SCHEMA),构建后自动接上
- 构建集成 1 项(拷贝 + usingComponents 注入),全量 229 specs 通过
- 运行时 lViewToWXView 的 __templateName 取值:context.__templateName || declTNode.localNames[0] || null,声明名自动兜底 - 新增 template-name-auto.spec 锁定:不传 __templateName 时名字来自 模板声明名(#autoTpl → autoTpl),context 为空;显式传则 context 优先(向后兼容) - 与已有 lview-to-node-list.spec(证明 lViewToWXView 读这些槽)串成 「声明名 → wxml template is」完整链路 - 文档更新:同组件内 __templateName 现为可选,仅覆盖默认名/跨组件传模板才需 - 全量 231 specs 通过
- AgentNode 增加 dirty 标记 + lastView 缓存: * toView() 未脏时返回同一缓存对象(引用相等) * 上层 diff 遇引用相同即跳过,只重序列化脏节点 - MiniProgramRenderer 所有变更方法(setAttribute/Class/Style/Property/Value)标脏 - 效果:单属性变更时,未变更节点不再重新序列化,diff 只报脏节点字段 - 新增 4 项缓存/脏标记单测(引用复用/标脏新对象/混合 diff 只报脏节点) - 全量 235 specs 通过(含运行时渲染链路,缓存未破坏既有行为)
该文件是 app-config.build.spec 早期用相对路径写文件时,落到进程 CWD (仓库根)留下的一次性残留,被 git add -A 误纳入。 当前测试已用 join(harness.host.root(), ...) 写临时目录,不再污染仓库。 框架本身不应包含用户工程才有的 app.config.json。
覆盖 app.json 编译生成 / 条件编译 / 分包 / 原生组件 / 模板名自动推导 / setData 优化,每项给出 angular.json 配置项、文件结构、可运行 demo。 配置项名称与平台值均从 schema/源码核对。
This reverts commit 5b2cdd7.
针对 diff 算法本身的三处硬伤优化(不改语义,逐条对齐旧行为):
1. 引用相等短路:fromItem===toItem 直接判定未变,跳过整棵子树递归
2. 单一累加器 + Object.assign:旧实现每层每个变更都
changeObject={...changeObject,...result.object},深层/多变更时
是 O(N^2) 对象拷贝;改为子累加器收集、父级 assign 一次,
拷贝量降到 O(变更数×深度)
3. 单趟净化:逐字段写时内联 undefined→null,只有「整体送出」的
子树才 sanitize,不再对最终结果整体再遍历
引入 SAME/PARTIAL/FULL 三态返回值,精确复现「每个子项都全变才折叠」
的 allChange 语义(部分变更不触发父级折叠)。
基准:N=2000 全变更 6ms(线性),N=5000 单点变更 0ms。
新增 4 项正确性+性能测试,全量 235 specs 通过。
在 spec 内嵌重写前的旧 diff 实现作对照,同输入跑: - 正确性平价:新旧输出逐字相等(确定性断言) - 性能对比:N=3000 部分变更负载(触发旧 O(N^2) spread) 旧=914ms 新=3ms 加速≈305x - 断言新算法不慢于旧算法(宽松,避免机器噪声误报) 连同原有 2 项绝对阈值基准(N=2000 全变更 6ms / N=5000 单点 0ms), 共 15 项 diff 测试,全量 236 specs 通过。
…r-miniprogram into fix/node24-build-and-test
叶子写入(class / style / property / value)不再触发整树序列化 + 深 diff, 而是直接产出路径式 setData key;结构性变更自动回退旧的全量管线。 实现: - lViewToWXView 序列化时顺手给每个 AgentNode 打 __pathPrefix / __mpRef。 该遍历本来就要经过每个 lView 槽位,所以打标签是零额外成本。 - renderer 的叶子写入经 emit() 产出 key,key 走 __keyCache 避免重复拼串; 拿不到前缀一律 markStructuralChange(),绝不猜路径。 - endRender() 三态分流: * 有结构性变更 / 开关关闭 → 走旧全量序列化 + diff(逐字等价改造前) * 纯叶子变更 → 直发路径式 key,跳过整树序列化与深 diff * 零写入 → 一次 setData 都不发(旧管线这里仍会整棵刷一遍) - 数组下标统一用方括号,与 diffNodeData 已跑通的 key 形式逐字一致, 不赌「点号下标」在微信上的兼容性。 - setPathDataEnabled(false) 可整体回退到改造前行为。 实测(新增 path bench): 500 节点单点变更:全量 140,365ns → 快路径 4,316ns,加速 32.5x 快路径成本与视图大小无关:100 节点 623ns vs 1000 节点 648ns 测试:新增 path-based-setdata.spec.ts 共 22 条,含「toView 调用次数 = 0」 的快路径硬断言,以及结构回退 / 未 stamp 强制回退 / 旧快照自愈合 覆盖。 全量 258 specs 0 failures。
分包 root 归属是 `startsWith` 前缀匹配,大小写敏感。Windows 下 sourceRoot 走 getSystemPath() 拿到的是大写盘符(C:\...),而 bundler 回传的 moduleIds 盘符大小写并不保证一致,一旦两侧不同就全线失配,且失配是静默的: - chunkFileNames 不再把分包 chunk 归进分包目录(分包代码全落主包,白拆) - 跨分包 / 独立分包校验全部漏报(该拦的拦不住) 新增 normalizeId():posix 分隔符 + 小写盘符,只用于比较。Windows 文件系统 本身大小写不敏感,统一小写不会引入误判;posix 下路径不带盘符,replace 是 no-op,不影响 Linux 的大小写敏感语义。 补 subpackage-chunk.spec.ts,直接喂 bundle 覆盖归一化分支:把 normalizeId 换回 toPosix 时 3 个 Windows 路径用例立刻转红,能钉住这个回归。
node-version 还钉在 16.x,但依赖早就跑不动了: @angular/compiler-cli 22 → ^22.22.3 || ^24.15.0 || >=26.0.0 ng-packagr 22 → ^22.22.3 || ^24.15.0 || >=26.0.0 vite 8 → ^20.19.0 || >=22.12.0 16.x 下 npm ci / npm run build 直接崩。本地是 24.21.0 跑通的,统一提到 24.x。 顺带: - checkout@v2 / setup-node@v2 → v7(v2 是 node16 runtime,GitHub 已弃用) - default.yml 的 npm-publish@v1 → v3,与 alpha.yml 对齐
两个都会让发布产物在消费方直接跑不起来,且本地测试完全测不到
(测试跑的是 TS 源码,不经过 dist 包)。
1) type: module 把 CJS 产物全打成 ESM
ng-packagr 生成 package.json 时是 `packageJson.type ??= 'module'`,
src/library/package.json 没写 type,于是 dist 顶层变成 type: module。
而 builder/** 和 karma/** 是 script/build.ts 用 CommonJS 编的
(require / exports + 无扩展名相对 import),Node 把 .js 全当 ESM:
require('angular-miniprogram/karma/plugin')
→ exports is not defined in ES module scope
→ Cannot find module './main' // ESM 解析要求带扩展名
实测(装进干净工程 require):改之前必炸,改之后拿到
[ 'framework:@angular-devkit/build-angular', 'launcher:miniprogram' ]。
库自己的产物是 .mjs,扩展名优先、永远是 ESM,所以顶层写 commonjs
不影响 ESM 消费方。线上 1.5.2 没有 type 字段(等价 commonjs),
这次是 ng-packagr 升级带出来的回归。
2) karma 插件 outDir 多套一层
index.ts import 了 ../vite/karma-framework,TS 把 rootDir 推到
src/builder/karma,outDir 再写 dist/karma/plugin 就变成
karma/plugin/plugin/index.js,而 exports["./karma/plugin"] 指的是
karma/plugin/index.js → 消费方 ENOENT。outDir 改成 dist/karma。
DEVELOPMENT.md 补了这两条不变量和发布前的 require 自检命令。
lint 步骤是发布流水线的门禁,8 条 warning 直接把 CI 卡在第二步。
- 7 条 import/order:eslint --fix 自动整理
- 1 条 max-lines-per-function:miniProgramAssetsPlugin 410 > 400,
把样式编译那段抽成模块级 createStyleProcessor() /
compileStyleSources()。processor 需要跳轮复用,仍由闭包持有,
插件里只留 4 行包装,行为不变。
|
太肝了 |
Owner
Author
我只是没有感情的pi agent调用工程师 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.