diff --git a/.github/workflows/alpha.yml b/.github/workflows/alpha.yml index 4bc07b3e..d592da50 100644 --- a/.github/workflows/alpha.yml +++ b/.github/workflows/alpha.yml @@ -16,11 +16,11 @@ jobs: git config --global user.name "${GITHUB_ACTOR}" git config --global user.email "${GITHUB_ACTOR}@gmail.com" - name: pull-code - uses: actions/checkout@v2 + uses: actions/checkout@v7 - name: install-node - uses: actions/setup-node@v2 + uses: actions/setup-node@v7 with: - node-version: 16.x + node-version: 24.x cache: "npm" - name: install-dependencies run: | diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index b8bb147f..5443d011 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -16,11 +16,11 @@ jobs: git config --global user.name "${GITHUB_ACTOR}" git config --global user.email "${GITHUB_ACTOR}@gmail.com" - name: pull-code - uses: actions/checkout@v2 + uses: actions/checkout@v7 - name: install-node - uses: actions/setup-node@v2 + uses: actions/setup-node@v7 with: - node-version: 16.x + node-version: 24.x cache: "npm" - name: install-dependencies run: | @@ -39,7 +39,7 @@ jobs: npm run build - id: publish name: publish - uses: JS-DevTools/npm-publish@v1 + uses: JS-DevTools/npm-publish@v3 if: ${{ github.repository_owner == 'wszgrcy' }} with: token: ${{ secrets.NPM_PUBLISH_TOKEN }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 73f5dbb7..7889edae 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,11 +16,11 @@ jobs: git config --global user.name "${GITHUB_ACTOR}" git config --global user.email "${GITHUB_ACTOR}@gmail.com" - name: pull-code - uses: actions/checkout@v2 + uses: actions/checkout@v7 - name: install-node - uses: actions/setup-node@v2 + uses: actions/setup-node@v7 with: - node-version: 16.x + node-version: 24.x cache: "npm" - name: install-dependencies run: | diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index fb212763..98f400b0 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -12,11 +12,11 @@ jobs: steps: - name: pull-code - uses: actions/checkout@v2 + uses: actions/checkout@v7 - name: install-node - uses: actions/setup-node@v2 + uses: actions/setup-node@v7 with: - node-version: 16.x + node-version: 24.x cache: "npm" - name: install-dependencies run: | diff --git a/.gitignore b/.gitignore index 7f3dda10..b7e8f52d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,9 @@ script/startup-jasmine.js .temp-git .nyc_output docs -deploy/api-doc \ No newline at end of file +deploy/api-doc + +# 构建 / 探测产物(Vite 输出的小程序包,可随时重新生成) +__q +__w +__decl diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 00000000..b409c6c0 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,1308 @@ +# 本地开发 / 构建说明 + +环境:Node 18 ~ 24(已在 Node v24.21.0 + npm 11 验证通过),npm 作为包管理器。 + +## 一键流程 + +```bash +npm ci # 安装依赖 +npm run build # 构建 library + builder + karma(会自动补齐同步源码) +npm run test # 运行全部 jasmine 用例 +``` + +其它常用命令: + +```bash +npm run test:ci # build:library + library 用例 + 全量用例 +npm run coverage # nyc 统计,报告输出到 ./docs/coverage +npm run lint # eslint --max-warnings 0 +npm run sync # 手动从 angular/angular@17.3.1 同步源码(需要网络) +``` + +## 🔴 发布产物形态:包必须是 `type: commonjs` + +`src/library/package.json` 里显式写了 `"type": "commonjs"`,**不要删**。 + +ng-packagr 生成产物时是 `packageJson.type ??= 'module'`——你没写它就给你 `module`。 +而 `builder/**` 和 `karma/**` 是 `script/build.ts` 用 CommonJS 编出来的 +(`require` / `exports` + 无扩展名的相对 import)。一旦包顶层是 `type: module`, +Node 会把所有 `.js` 当 ESM,于是: + +``` +require('angular-miniprogram/karma/plugin') + → exports is not defined in ES module scope + → Cannot find module './main' // ESM 解析要求带扩展名 +``` + +库自己的产物是 `.mjs`(扩展名优先,永远是 ESM),所以顶层写 `commonjs` +**不影响 ESM 消费方**,只是让 `builder/` 和 `karma/` 的 CJS 能正常加载。 +线上 1.5.2 没有 `type` 字段(等价 commonjs),就是同一个道理。 + +发布前自检(`npm run build` 之后): + +```bash +cd dist && npm pack && cd /tmp && mkdir s && cd s && npm init -y +npm i <绝对路径>/dist/angular-miniprogram-1.5.2.tgz @angular-devkit/architect --legacy-peer-deps +node -e "console.log(Object.keys(require('angular-miniprogram/karma/plugin')))" +# 期望:[ 'framework:@angular-devkit/build-angular', 'launcher:miniprogram' ] +``` + +另:`src/builder/karma/plugin/tsconfig.json` 的 `outDir` 是 `dist/karma` 而不是 +`dist/karma/plugin`——因为 `index.ts` import 了 `../vite/karma-framework`, +TS 把 rootDir 推断到 `src/builder/karma`,outDir 多写一层会让产物变成 +`karma/plugin/plugin/index.js`,与 `exports["./karma/plugin"]` 对不上。 + +## 🔴 开工前先读这一条 + +**进 `setData` 的数据里绝不允许 `undefined`,无值一律用 `null`。** + +微信对 `setData` 里的 `undefined` 直接拒掉**整个调用**,界面从此冻结; +而且它**只在第二次及以后的 diff 更新才暴露**(首次走整体 setData 会被 +JSON 序列化丢掉),所以「首次渲染通过」的测试完全测不到。 + +完整规则、历史回归案例、检查清单见文末 +**《⚠️ 铁律:进 `setData` 的数据里绝不允许 `undefined`》**。 + +## 两个必须知道的坑(已在本仓库修好) + +### 1. `src/library/common` 与 `src/library/forms/src` 是生成代码 + +这两个目录(除少量手写的 value accessor)被 `.gitignore` 忽略,内容来自 +`npm run sync`(`@code-recycle/cli` + `script/package-sync.ts`,从 +`angular/angular` 的 `17.3.1` tag 拉取 `packages/common`、`packages/forms` +并把 `@angular/common` 改写成 `angular-miniprogram/common`)。 + +没同步过就构建会报: + +``` +TS6053: File '.../src/library/common/http/index.ts' not found. +``` + +现在 `build:library` 前会先执行 `tsx ./script/ensure-sync.ts`,检测到缺失文件时 +自动补跑一次 `npm run sync`,因此直接 `npm run build` 即可。 + +> 注意:`ng-packagr` 的 `deleteDestPath: true`,单独跑 `npm run build:library` +> 会清空 `dist`,其中包含 builder/karma 的产物。需要完整产物时请跑 `npm run build`。 + +### 2. Node 22+ 原生 TS 加载会绕过 ts-node + +Jasmine 5 默认用 `import()` 加载 spec 文件。Node 22/23/24 自带 `.ts` 类型剥离, +`import()` 会直接由 Node 处理,绕过 `ts-node` 的 CJS 钩子,导致: + +- 拿不到 `static-injector` 的 transformer(依赖注入装饰器不生效); +- 报 `ERR_UNSUPPORTED_DIR_IMPORT`(ESM 不支持目录导入)。 + +修复方式:`jasmine.json` 中加入 `"jsLoader": "require"`,让 Jasmine 走 +`require()`,由 ts-node 编译(等价于 Node 21 及以下的行为),因此不再需要 +`NODE_OPTIONS=--no-experimental-strip-types`。 + +## 关于 karma 用例(2 个 pending) + +`src/builder/karma/index.spec.ts` 里的 `karma 运行` / `karma watch` 用例是作者标记 +为 `xdescribe` / `xit` 的本地用例:它需要真实的小程序运行环境(微信开发者工具) +连上 karma server 才能跑完,CI/容器环境下无法执行,保持 pending 属正常状态。 + +`karma` builder 本身的编译链路(`npm run build:karma`、`dist/karma/client`、 +`dist/karma/plugin`)在 `npm run build` 中已验证可用。 + +## 验证结果(Node v24.21.0) + +| 命令 | 结果 | +| ------------------ | ----------------------------------------------- | +| `npm ci` | ✅ 1432 packages | +| `npm run build` | ✅ library + builder + karma 全部产出到 `dist/` | +| `npm run test` | ✅ 26 specs, 0 failures, 2 pending | +| `npm run test:ci` | ✅ | +| `npm run coverage` | ✅ | +| `npm run lint` | ✅ 0 error / 0 warning | + +## Angular 版本升级记录(17 → 18 → 19 → 20) + +> 每次升级的参考源码:`angular/angular` 与 `angular/angular-cli`, +> 先 `git checkout <对应 tag>` 再对照修改。同步源版本写在 +> `script/package-sync.ts` 的 `gitClone(..., '')` 里。 + +### 17 → 18 + +| 项目 | 变更 | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 版本 | `@angular/*` 18.2.x、devkit 18.2.x(architect `0.1802.x`)、`@angular/build` 18.2.x、ng-packagr 18.2.x、TS 5.5、webpack 5.94、zone.js 0.14 | +| `purgeStaleBuildCache` / `assertCompatibleAngularVersion` | 从 `@angular-devkit/build-angular/src/utils/*` 迁到 `@angular/build/private`;因 `moduleResolution: "node"` 解析不了 exports 子路径,在 `tsconfig.builder.json` / `tsconfig.spec.json` 用 `paths` 映射到 `@angular/build/src/private.d.ts` | +| browser schema 路径 | `src/browser/schema.json` → `src/builders/browser/schema.json`(`script/schema-merge.ts`) | +| 模板 AST | 新增 `@let` → `visitLetDeclaration` | +| 样式 | `lib` 需加 `dom.iterable`(`URLSearchParams` 迭代) | +| 类型 | `compiler.inputFileSystem` 可为 null | +| 依赖冲突 | `cyia-ngx-devkit` 钉死 devkit 17 → 用 `overrides` 强制升到目标版本;`rxjs` / `webpack` 必须与 devkit 的精确版本一致,否则出现双实例类型冲突 | + +### 18 → 19 + +| 项目 | 变更 | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 版本 | `@angular/*` 19.2.x、devkit 19.2.x、ng-packagr 19.2.x、TS 5.6、webpack 5.98、zone.js 0.15、tslib 2.8 | +| **standalone 默认值变为 `true`** | 所有被 NgModule `declarations` 的 `@Component`/`@Directive` 必须显式写 `standalone: false`(本仓库手写的 forms value accessor 共 9 个,以及 `test/hello-world-app` 与 builder fixture 的全部组件/指令) | +| 模板 AST | 新增 `visitTypeofExpression` / `visitTemplateLiteral` / `visitTemplateLiteralElement` | +| ng-packagr | `StylesheetProcessor` 构造函数在 `cacheDirectory` 前新增 `sass` 参数;Angular 诊断缓存改为 `entryPoint.cache.angularDiagnosticCache`(`get`/`update`);**不再把逐文件 ESM(`esm2022`)写盘**,只输出 `fesm2022`,`library.spec` 断言相应改为 fesm2022 | +| 同步脚本 | v19 的 `packages/common/http` 内部改用 `../../index` 相对引用,`package-sync.ts` 新增改写逻辑映射为 `angular-miniprogram/common[/http]`,否则 ng-packagr 报 `TS6059` rootDir 越界 | + +### 19 → 20 + +| 项目 | 变更 | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 版本 | `@angular/*` 20.3.x、devkit 20.3.x(architect `0.2003.x`)、ng-packagr 20.3.x、TS 5.8、webpack 5.101、rxjs 7.8.2 | +| **ng-packagr 目录结构** | `lib/**` 全部移动到 `src/lib/**`,所有 `ng-packagr/lib/...` 深引用改为 `ng-packagr/src/lib/...` | +| 表达式 AST | 移除 `KeyedWrite` / `PropertyWrite`(赋值改为带赋值运算符的 `Binary`);新增 `visitVoidExpression` / `visitTaggedTemplateLiteral` / `visitParenthesizedExpression` | +| 模板 AST | `Visitor` 新增 `visitComponent` / `visitDirective` | +| webpack | `splitChunks.cacheGroups.test` 参数类型收紧为 `Module`(需向下转型 `NormalModule`),返回值必须是 `boolean` | +| 测试顺序 | `test/hello-world-app/node_modules/test-library` 是上一次 library 构建的拷贝,跨大版本时必须先跑 `npm run test:jasmine library`(即 `npm run test:ci`)刷新,否则会残留旧版本指令(如 v19 的 `ɵɵhostProperty` 在 v20 已删除)导致 app 构建失败 | + +### 升级操作清单(可复用) + +```bash +# 1. 参考源码切 tag +git -C ../angular checkout +git -C ../angular-cli checkout + +# 2. 改 package.json / src/library/package.json 版本 + overrides +# (rxjs、webpack 必须与 @angular-devkit/build-angular 的精确依赖一致) +# 3. 改 script/package-sync.ts 的同步 tag +rm -rf node_modules package-lock.json && npm install # 直接 npm install 常因旧树报 ERESOLVE + +# 4. 重新同步生成源码并构建 +git clean -xdfq src/library/common src/library/forms +npm run sync && npm run build + +# 5. 先刷新 test-library 再跑全量 +npm run test:jasmine library && npm run test +npm run lint && npm run coverage +``` + +> 注意:本仓库历史代码不是按 prettier 3 默认配置格式化的(`trailingComma`), +> 因此**不要**对未修改的文件批量跑 `prettier --write`,会产生大量无关 diff。 + +## 去 zone.js 化(zoneless)与 signal input/output + +### 背景 + +Angular v20 已经可以完全不依赖 `zone.js`(`provideZonelessChangeDetection()`)。 +本仓库原先靠 `NgZone.run()` 从「小程序侧」触发变更检测,靠 +`runOutsideAngular()` 把 diff/setData 排除在 Angular 之外。现在改为: + +- 进入 Angular 的回调执行完后,显式 + `ChangeDetectionScheduler.notify(ɵNotificationSource.Listener)`; +- 需要「在 Angular 之外跑」的部分本来就不会触发 CD,直接调用即可。 + +### 关键改动 + +| 位置 | 变更 | +| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/library/platform/util/change-detection.ts` | 新增 `runInAngular()` / `scheduleChangeDetection()`,统一封装「执行回调 + 通知调度器」 | +| `src/library/platform/default/platform-core.ts` | `__ngZone` 换成 `__ngChangeDetectionScheduler`;事件回调改为 `try { handler() } finally { notify() }`;`runOutsideAngular` 包裹的 diff/setData 直接执行 | +| `src/library/platform/default/component-template-hook.factory.ts` | `propertyChange()` 不再取 `NgZone` | +| `src/library/platform/page.service.ts` | 页面注册改用 `runInAngular(injector, ...)` | +| `src/library/platform/http/backend.ts` | 注入 `ChangeDetectionScheduler`,所有 `Zone.current.run(...)` 改为 `this.runInAngular(...)` | +| `src/library/platform/type/type.ts` | `MiniProgramComponentVariable.__ngZone` → `__ngChangeDetectionScheduler` | +| `src/library/declaration/index.d.ts` | 删除 `declare const Zone: any` | +| `src/builder/application/webpack-configuration-change.service.ts` | DefinePlugin 不再映射全局 `Zone` | +| `src/builder/platform/template/app-template.js` | 平台模板不再导出 `Zone` | +| `script/package-sync.ts` | 同步 `@angular/common/http` 时剥掉 `fetch.ts` 里的 `import type {} from 'zone.js'` 与 `Zone.current`(zoneless 下 `reqZone` 恒为 `undefined`) | +| `test/hello-world-app/src/main.ts` / `test.ts` | 删除 `import 'zone.js'` | +| `test/hello-world-app/src/main.module.ts` / `main-test.module.ts` | `providers: [provideZonelessChangeDetection()]` | + +> 库本身不再强制 zoneless:由使用方在 root provider 里加 +> `provideZonelessChangeDetection()`。库只是不再产生任何 zone 依赖。 + +### signal input / output + +fixture 里所有 `@Input()` / `@Output()` 已改为 `input()` / `output()`。 +注意 **模板里读 signal 必须显式调用**(`{{ input1() }}`、`*ngFor="let x of list()"`), +Angular 的模板插值不会自动 unwrap signal。 + +新增覆盖: + +- `src/library/platform/util/change-detection.spec.ts`:5 个 Node 端单测, + 覆盖返回值、通知次数、抛错时仍然通知、自定义通知来源。 +- `src/builder/zoneless.spec.ts`:构建整个 fixture 后扫描产物, + 断言没有 `__zone_symbol__` / `zone.js/dist`,且包含 `ChangeDetectionSchedulerImpl`。 +- `test/hello-world-app/src/spec/signal-io-spec/`:小程序内 karma 用例, + 验证 signal input 渲染 + signal output 回传(需微信开发者工具,容器内跑不了)。 + +### 已知限制 + +`ChangeDetectionScheduler` 只在 `notify()` 之后调度一次 tick。若将来新增 +「从 `NgZone` 之外进入 Angular」的入口,必须显式调用 +`scheduleChangeDetection()`,否则视图不会刷新。 + +## 内建控制流(`@if` / `@for` / `@switch`) + +模板编译器(`src/builder/mini-program-compiler`)已支持 Angular 17+ 的内建控制流语法, +实现位置:`parse-node/template-definition.ts` 的 `visitIfBlock` / `visitForLoopBlock` / +`visitSwitchBlock`。 + +### 原理 + +内建控制流在 Angular 里最终也会编译成 embedded template(`ɵɵconditionalCreate` / +`ɵɵrepeaterCreate`),和 `` 是同一套锚点机制,所以可以直接复用 +`ParsedNgTemplate` 与 wxml 的 `` 渲染方式: +`nodeList[i]` 是该锚点下已创建视图的数组,条件为假时数组为空,天然实现显隐。 + +分支内容是独立的 embedded view,拥有自己的声明索引空间,因此子节点用新的 +`TemplateDefinition` 访问,不影响当前视图的 `declIndex`。 + +### 槽位(declIndex)分配规则 + +必须和 Angular 的 `slot_allocation` + `pipe_creation` 两个 phase 完全一致, +否则后面所有节点的 `nodeList[i]` 都会错位。规则(实测 20.3.x 产物得出): + +| 语法 | 槽位布局 | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `@if` / `@switch` | `i` = 第一个分支模板;`i+1 .. i+P` = **所有**分支条件表达式里的管道(统一插到第一个 create 之后);`i+P+1 ..` = 其余分支模板 | +| `@for` | `i` = `RepeaterMetadata`(不是 TNode,不可渲染但必须占位);`i+1` = 主模板;`i+2` = `@empty` 模板(若有);其后是被遍历表达式的管道槽位 | + +`track` 表达式 Angular 禁止使用管道,无需考虑。 + +### 模板名唯一性 + +wxml 的 `