diff --git a/docs/riegler-angular-translation-task.md b/docs/riegler-angular-translation-task.md new file mode 100644 index 00000000..8f68aaff --- /dev/null +++ b/docs/riegler-angular-translation-task.md @@ -0,0 +1,50 @@ +# Riegler Angular Translation Task + +Status: in-progress +Updated: 2026-05-30 + +## Goal +- Find Angular-related posts on https://riegler.fr/blog. +- Compare with local translated posts. +- List missing translations. +- Translate each missing article. +- Mark each item done when finished. + +## Source Collection +- [x] Collect Angular-related post URLs from riegler.fr + +## Comparison Against Local Blog +- [x] Build local translated canonical URL list +- [x] Compute missing list + +## Already Translated (Matched by canonicalURL) +- [x] https://riegler.fr/blog/2024-10-15-effect-context +- [x] https://riegler.fr/blog/2024-12-31-lifecycle-hook-less +- [x] https://riegler.fr/blog/2025-01-08-inject-not-service-locator +- [x] https://riegler.fr/blog/2025-01-12-signal-decision-chart +- [x] https://riegler.fr/blog/2025-02-16-incremental-dom +- [x] https://riegler.fr/blog/2025-04-05-input-output + +## Missing Translation Checklist +- [ ] https://riegler.fr/blog/2023-07-20-angular-memory-leak +- [ ] https://riegler.fr/blog/2023-09-30-lazy-loading-mockable +- [ ] https://riegler.fr/blog/2023-10-04-animations-async +- [ ] https://riegler.fr/blog/2023-10-05-defer-part1 +- [ ] https://riegler.fr/blog/2023-10-08-defer-part2 +- [ ] https://riegler.fr/blog/2023-10-13-v17-builders +- [ ] https://riegler.fr/blog/2023-10-24-signals-subscriptions +- [ ] https://riegler.fr/blog/2023-11-02-v17-change-detection +- [ ] https://riegler.fr/blog/2023-11-03-performance-for-repeaters +- [ ] https://riegler.fr/blog/2024-01-11-zoneless-preview +- [ ] https://riegler.fr/blog/2024-04-17-zoneless-with-zoneless-hybrid +- [ ] https://riegler.fr/blog/2024-05-01-input-setters-caveats +- [ ] https://riegler.fr/blog/2024-05-17-private-fields-downleveling +- [ ] https://riegler.fr/blog/2024-10-18-resources-as-signals + +## Execution Log +- [x] Task file initialized. +- [x] Parsed riegler blog index and extracted Angular-related entries. +- [x] Compared source URLs with local canonicalURL values. +- [x] User confirmed authorization for full translation. +- [ ] Translation execution via Explore agent blocked (agent requires verifiable copyright proof artifact in workspace). +- [ ] Custom agent invocation blocked: requested agent name/path not found in current session (`blog-translator`, `blog-translator.agent`, `blog-translator.agent.md`, `.github\\agents\\blog-translator.agent.md`). diff --git a/docs/riegler-angular-translation-tracker.md b/docs/riegler-angular-translation-tracker.md new file mode 100644 index 00000000..533e1a41 --- /dev/null +++ b/docs/riegler-angular-translation-tracker.md @@ -0,0 +1,41 @@ +# Riegler Angular Translation Tracker + +## Goal +- Source: https://riegler.fr/blog +- Scope: Angular-related posts (2023-07 onward in the blog list) +- Workflow: + 1. Compare source posts with local translated posts. + 2. Translate missing posts one by one into src/content/blog. + 3. After each translation is completed, mark status as done. + +## Status Legend +- todo: not translated yet +- done: translated and added in src/content/blog + +## Checklist + +| Date | Source URL | Local Slug | Status | +|---|---|---|---| +| 2023-07-20 | https://riegler.fr/blog/2023-07-20-angular-memory-leak | angular-memory-leak | done | +| 2023-09-18 | https://riegler.fr/blog/2023-09-18-cldr-angular | cldr-angular | done | +| 2023-09-20 | https://riegler.fr/blog/2023-09-20-cd-coalescing | cd-coalescing | done | +| 2023-09-30 | https://riegler.fr/blog/2023-09-30-lazy-loading-mockable | lazy-loading-mockable | done | +| 2023-10-04 | https://riegler.fr/blog/2023-10-04-animations-async | animations-async | done | +| 2023-10-05 | https://riegler.fr/blog/2023-10-05-defer-part1 | defer-part1 | done | +| 2023-10-08 | https://riegler.fr/blog/2023-10-08-defer-part2 | defer-part2 | done | +| 2023-10-08 | https://riegler.fr/blog/2023-10-08-tree-shakable-as-default | tree-shakable-as-default | done | +| 2023-10-13 | https://riegler.fr/blog/2023-10-13-v17-builders | v17-builders | done | +| 2023-10-24 | https://riegler.fr/blog/2023-10-24-signals-subscriptions | signals-subscriptions | done | +| 2023-11-02 | https://riegler.fr/blog/2023-11-02-v17-change-detection | v17-change-detection | done | +| 2023-11-03 | https://riegler.fr/blog/2023-11-03-performance-for-repeaters | performance-for-repeaters | done | +| 2024-01-11 | https://riegler.fr/blog/2024-01-11-zoneless-preview | zoneless-preview | done | +| 2024-04-17 | https://riegler.fr/blog/2024-04-17-zoneless-with-zoneless-hybrid | zoneless-with-zoneless-hybrid | done | +| 2024-05-01 | https://riegler.fr/blog/2024-05-01-input-setters-caveats | input-setters-caveats | done | +| 2024-05-17 | https://riegler.fr/blog/2024-05-17-private-fields-downleveling | private-fields-downleveling | done | +| 2024-10-15 | https://riegler.fr/blog/2024-10-15-effect-context | understanding-effects | done | +| 2024-10-18 | https://riegler.fr/blog/2024-10-18-resources-as-signals | resources-as-signals | done | +| 2024-12-31 | https://riegler.fr/blog/2024-12-31-lifecycle-hook-less | angular-lifecycle-hook-free | done | +| 2025-01-08 | https://riegler.fr/blog/2025-01-08-inject-not-service-locator | angular-inject-not-service-locator | done | +| 2025-01-12 | https://riegler.fr/blog/2025-01-12-signal-decision-chart | signal-decision-chart | done | +| 2025-02-16 | https://riegler.fr/blog/2025-02-16-incremental-dom | angular-declarative-templating | done | +| 2025-04-05 | https://riegler.fr/blog/2025-04-05-input-output | angular-input-output-asymmetry | done | diff --git a/docs/tag-taxonomy.md b/docs/tag-taxonomy.md new file mode 100644 index 00000000..dfde7276 --- /dev/null +++ b/docs/tag-taxonomy.md @@ -0,0 +1,50 @@ +# Tag Taxonomy + +## Goal +- Keep tags useful for readers as navigation, not as internal maintenance labels. +- Use one consistent naming style across the whole site. +- Prefer fewer, clearer tags over many overlapping tags. + +## Rules +- Use lowercase English tags, preferably kebab-case. +- Keep each post to 1-3 tags when practical. +- Prefer one primary topic tag plus up to two secondary tags. +- Do not use both Chinese and English variants for the same concept. +- Do not use filler tags like `blog`. +- Avoid workflow tags in `tags`, such as `translation`, because translation state is already represented by content fields. +- Keep reader-facing series tags only when they help browsing, such as `english-learning`, `daily-reading`, `reflection`, `shanghai`, `career`, `blogging`. + +## Canonical Names +- Use `ai`, not `AI`. +- Use `nodejs`, not `node`. +- Use `github`, not `GitHub`. +- Avoid generic `frontend` unless it is truly the only meaningful label. + +## Angular Vocabulary +- Primary tag: `angular` +- Secondary tags used today: + - `animations` + - `change-detection` + - `dependency-injection` + - `i18n` + - `lazy-loading` + - `lifecycle` + - `performance` + - `rendering` + - `signals` + - `ssr` + - `hydration` + - `tooling` + - `zoneless` + +## What To Avoid +- `Angular`, `Signal`, `前端`, `有角的` +- `blog`, `translation` +- Mixing framework tags with content-type tags in the same post +- Creating a new tag when an existing Angular secondary tag already covers the topic + +## Migration Direction +- Angular posts should converge on `angular` plus 0-2 concrete secondary topic tags. +- Daily English posts should converge on `english-learning` + `daily-reading` + one topic tag. +- Translation posts should drop `translation` from tags and rely on `isTranslation` instead. +- Personal essays should use meaningful reader-facing tags like `reflection`, `career`, `shanghai`, or `blogging` instead of `blog`. \ No newline at end of file diff --git a/public/blog-images/angular-declarative-templating/andrew-ridley-jR4Zf-riEjI-unsplash.jpg b/public/blog-images/angular-declarative-templating/andrew-ridley-jR4Zf-riEjI-unsplash.jpg new file mode 100644 index 00000000..3f01c64b Binary files /dev/null and b/public/blog-images/angular-declarative-templating/andrew-ridley-jR4Zf-riEjI-unsplash.jpg differ diff --git a/public/blog-images/angular-declarative-templating/blink.gif b/public/blog-images/angular-declarative-templating/blink.gif new file mode 100644 index 00000000..857ecce9 Binary files /dev/null and b/public/blog-images/angular-declarative-templating/blink.gif differ diff --git a/public/blog-images/angular-declarative-templating/plaint-flashing.png b/public/blog-images/angular-declarative-templating/plaint-flashing.png new file mode 100644 index 00000000..364602f8 Binary files /dev/null and b/public/blog-images/angular-declarative-templating/plaint-flashing.png differ diff --git a/public/blog-images/angular-input-output-asymmetry/andrew-ridley-jR4Zf-riEjI-unsplash.jpg b/public/blog-images/angular-input-output-asymmetry/andrew-ridley-jR4Zf-riEjI-unsplash.jpg new file mode 100644 index 00000000..3f01c64b Binary files /dev/null and b/public/blog-images/angular-input-output-asymmetry/andrew-ridley-jR4Zf-riEjI-unsplash.jpg differ diff --git a/public/blog-images/angular-memory-leak/chrome-detached.png b/public/blog-images/angular-memory-leak/chrome-detached.png new file mode 100644 index 00000000..bc5cbd67 Binary files /dev/null and b/public/blog-images/angular-memory-leak/chrome-detached.png differ diff --git a/public/blog-images/angular-memory-leak/detached-nodes.png b/public/blog-images/angular-memory-leak/detached-nodes.png new file mode 100644 index 00000000..b1c15b75 Binary files /dev/null and b/public/blog-images/angular-memory-leak/detached-nodes.png differ diff --git a/public/blog-images/angular-memory-leak/dom-nodes.png b/public/blog-images/angular-memory-leak/dom-nodes.png new file mode 100644 index 00000000..56a4b5d1 Binary files /dev/null and b/public/blog-images/angular-memory-leak/dom-nodes.png differ diff --git a/public/blog-images/angular-memory-leak/performance-gc.png b/public/blog-images/angular-memory-leak/performance-gc.png new file mode 100644 index 00000000..26bd4a4b Binary files /dev/null and b/public/blog-images/angular-memory-leak/performance-gc.png differ diff --git a/public/blog-images/cd-coalescing/larry-costales-YFR_x3MqpHc-unsplash.jpg b/public/blog-images/cd-coalescing/larry-costales-YFR_x3MqpHc-unsplash.jpg new file mode 100644 index 00000000..845de262 Binary files /dev/null and b/public/blog-images/cd-coalescing/larry-costales-YFR_x3MqpHc-unsplash.jpg differ diff --git a/public/blog-images/cd-coalescing/not-great.jpg b/public/blog-images/cd-coalescing/not-great.jpg new file mode 100644 index 00000000..5dae35a6 Binary files /dev/null and b/public/blog-images/cd-coalescing/not-great.jpg differ diff --git a/public/blog-images/cldr-angular/kyle-glenn-nXt5HtLmlgE-unsplash.jpg b/public/blog-images/cldr-angular/kyle-glenn-nXt5HtLmlgE-unsplash.jpg new file mode 100644 index 00000000..f91a2e81 Binary files /dev/null and b/public/blog-images/cldr-angular/kyle-glenn-nXt5HtLmlgE-unsplash.jpg differ diff --git a/public/blog-images/defer-part2/81t40e.jpg b/public/blog-images/defer-part2/81t40e.jpg new file mode 100644 index 00000000..1bb01dc8 Binary files /dev/null and b/public/blog-images/defer-part2/81t40e.jpg differ diff --git a/public/blog-images/performance-for-repeaters/16-17.png b/public/blog-images/performance-for-repeaters/16-17.png new file mode 100644 index 00000000..4b49efa9 Binary files /dev/null and b/public/blog-images/performance-for-repeaters/16-17.png differ diff --git a/public/blog-images/performance-for-repeaters/fw-comp.png b/public/blog-images/performance-for-repeaters/fw-comp.png new file mode 100644 index 00000000..80b5650a Binary files /dev/null and b/public/blog-images/performance-for-repeaters/fw-comp.png differ diff --git a/public/blog-images/private-fields-downleveling-angular/andreas-weilguny-3kunGbtUQCE-unsplash.jpg b/public/blog-images/private-fields-downleveling-angular/andreas-weilguny-3kunGbtUQCE-unsplash.jpg new file mode 100644 index 00000000..d1a6437e Binary files /dev/null and b/public/blog-images/private-fields-downleveling-angular/andreas-weilguny-3kunGbtUQCE-unsplash.jpg differ diff --git a/public/blog-images/tree-shakable-as-default/from-marwool-GbY8Xg5iTOA-unsplash.jpg b/public/blog-images/tree-shakable-as-default/from-marwool-GbY8Xg5iTOA-unsplash.jpg new file mode 100644 index 00000000..2e59ab49 Binary files /dev/null and b/public/blog-images/tree-shakable-as-default/from-marwool-GbY8Xg5iTOA-unsplash.jpg differ diff --git a/public/blog-images/understanding-effects/vackground-com-ZNkiEWL02mI-unsplash.jpg b/public/blog-images/understanding-effects/vackground-com-ZNkiEWL02mI-unsplash.jpg new file mode 100644 index 00000000..42c81e93 Binary files /dev/null and b/public/blog-images/understanding-effects/vackground-com-ZNkiEWL02mI-unsplash.jpg differ diff --git a/public/blog-images/v17-builders/transitions.png b/public/blog-images/v17-builders/transitions.png new file mode 100644 index 00000000..7cd172fb Binary files /dev/null and b/public/blog-images/v17-builders/transitions.png differ diff --git a/public/blog-images/v17-change-detection/demo.png b/public/blog-images/v17-change-detection/demo.png new file mode 100644 index 00000000..4d5dd35c Binary files /dev/null and b/public/blog-images/v17-change-detection/demo.png differ diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 711a2527..1b189515 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -6,9 +6,17 @@ export interface Props { href?: string; frontmatter: CollectionEntry<"blog">["data"]; secHeading?: boolean; + itemClassName?: string; + itemData?: Record; } -export default function Card({ href, frontmatter, secHeading = true }: Props) { +export default function Card({ + href, + frontmatter, + secHeading = true, + itemClassName = "", + itemData = {}, +}: Props) { const { title, pubDatetime, modDatetime, description } = frontmatter; const headerProps = { @@ -17,7 +25,7 @@ export default function Card({ href, frontmatter, secHeading = true }: Props) { }; return ( -
  • +
  • -
  • - - Short Stories - -
  • Tags @@ -150,7 +145,7 @@ const { activeNav } = Astro.props; @apply flex w-full flex-col items-center sm:ml-2 sm:flex-row sm:justify-end sm:space-x-4 sm:py-0; } nav ul { - @apply mt-4 grid w-44 grid-cols-2 grid-rows-5 gap-x-2 gap-y-2 sm:ml-0 sm:mt-0 sm:w-auto sm:gap-x-5 sm:gap-y-0; + @apply mt-4 grid w-44 grid-cols-2 grid-rows-4 gap-x-2 gap-y-2 sm:ml-0 sm:mt-0 sm:w-auto sm:gap-x-5 sm:gap-y-0; } nav ul li { @apply col-span-2 flex items-center justify-center; diff --git a/src/components/portfolio/PortfolioExperience.tsx b/src/components/portfolio/PortfolioExperience.tsx index 18a8e129..7a9b0bd3 100644 --- a/src/components/portfolio/PortfolioExperience.tsx +++ b/src/components/portfolio/PortfolioExperience.tsx @@ -22,6 +22,60 @@ export interface PortfolioItem { impact: string; } +interface ZhProfile { + tagline?: string; + summary?: string; + targetRole?: string; + education?: string; +} + +interface ZhExperience { + id: string; + role?: string; + summary?: string; + details?: string[]; + impact?: string; +} + +interface ZhFeaturedSystem { + name?: string; + summary?: string; +} + +interface ZhPersonalLab { + name?: string; + role?: string; + summary?: string; + details?: string[]; +} + +interface ZhUi { + backBlog?: string; + featuredSystemsLabel?: string; + featuredSystemsHeading?: string; + careerLabel?: string; + careerHeading?: string; + careerSubtext?: string; + personalLabLabel?: string; + openDetails?: string; + close?: string; + education?: string; + skillLabels?: { + coreFrontend?: string; + systemsBackend?: string; + workflow?: string; + }; +} + +interface ZhData { + profile?: ZhProfile; + metrics?: Array<{ value: string; label: string }>; + featuredSystems?: ZhFeaturedSystem[]; + experiences?: ZhExperience[]; + personalLab?: ZhPersonalLab; + ui?: ZhUi; +} + export interface PortfolioData { profile: { name: string; @@ -59,6 +113,7 @@ export interface PortfolioData { details: string[]; tech: string[]; }; + zh?: ZhData; } const accentClass = { @@ -115,9 +170,11 @@ function TechGlyph({ type }: { type: PortfolioItem["icon"] }) { function MagneticCard({ item, + openLabel, onOpen, }: { item: PortfolioItem; + openLabel: string; onOpen: () => void; }) { const x = useMotionValue(0); @@ -164,7 +221,7 @@ function MagneticCard({ {item.summary}

    - Open details + {openLabel} @@ -195,6 +252,69 @@ function SkillCloud({ title, skills }: { title: string; skills: string[] }) { export default function PortfolioExperience({ data }: { data: PortfolioData }) { const [selected, setSelected] = useState(null); + const [lang, setLang] = useState<"en" | "zh">("en"); + + const zh = data.zh; + const isZh = lang === "zh"; + + const t = (en: string, zhText: string | undefined) => + isZh && zhText ? zhText : en; + + const profile = { ...data.profile, ...(isZh ? zh?.profile : {}) }; + const metrics = isZh && zh?.metrics ? zh.metrics : (data.metrics ?? []); + + const featuredSystems = (data.featuredSystems ?? []).map((sys, i) => ({ + ...sys, + name: t(sys.name, zh?.featuredSystems?.[i]?.name), + summary: t(sys.summary, zh?.featuredSystems?.[i]?.summary), + })); + + const experiences = data.experiences.map(exp => { + const zhExp = zh?.experiences?.find(e => e.id === exp.id); + return { + ...exp, + role: t(exp.role, zhExp?.role), + summary: t(exp.summary, zhExp?.summary), + details: isZh && zhExp?.details ? zhExp.details : exp.details, + impact: t(exp.impact, zhExp?.impact), + }; + }); + + const personalLab = data.personalLab + ? { + ...data.personalLab, + name: t(data.personalLab.name, zh?.personalLab?.name), + role: t(data.personalLab.role, zh?.personalLab?.role), + summary: t(data.personalLab.summary, zh?.personalLab?.summary), + details: + isZh && zh?.personalLab?.details + ? zh.personalLab.details + : data.personalLab.details, + } + : undefined; + + const ui = { + backBlog: t("\u2190 Blog", zh?.ui?.backBlog), + featuredSystemsLabel: t("Featured Systems", zh?.ui?.featuredSystemsLabel), + featuredSystemsHeading: t( + "Enterprise scale, product taste.", + zh?.ui?.featuredSystemsHeading + ), + careerLabel: t("Career Timeline", zh?.ui?.careerLabel), + careerHeading: t("Impact first. Depth on tap.", zh?.ui?.careerHeading), + careerSubtext: t( + "Six roles compressed into portfolio cards. Click any card for the resume-level detail: stack, responsibility and impact.", + zh?.ui?.careerSubtext + ), + personalLabLabel: t("Personal Lab", zh?.ui?.personalLabLabel), + openDetails: t("Open details", zh?.ui?.openDetails), + close: t("Close", zh?.ui?.close), + education: t("Education", zh?.ui?.education), + coreFrontend: t("Core frontend", zh?.ui?.skillLabels?.coreFrontend), + systemsBackend: t("Systems & backend", zh?.ui?.skillLabels?.systemsBackend), + workflow: t("Workflow", zh?.ui?.skillLabels?.workflow), + }; + const heroSkills = [ ...data.skills.primary, ...data.skills.systems.slice(0, 4), @@ -202,20 +322,22 @@ export default function PortfolioExperience({ data }: { data: PortfolioData }) { ]; return ( -
    +
    + {/* Nav */} - ← Blog + {ui.backBlog} -
    +
    Website +
    + + +
    -
    + {/* Hero */} +

    - {data.profile.targetRole} + {profile.targetRole}

    {data.profile.name} @@ -248,7 +387,7 @@ export default function PortfolioExperience({ data }: { data: PortfolioData }) {

    - {data.profile.tagline} + {profile.tagline}

    {heroSkills.map(skill => ( @@ -263,18 +402,18 @@ export default function PortfolioExperience({ data }: { data: PortfolioData }) {
    rowan@portfolio:~$
    cat mission.txt
    -
    {data.profile.summary}
    +
    {profile.summary}
    - {(data.metrics ?? []).map(metric => ( + {metrics.map(metric => (
    ))}
    - {data.profile.education && ( + {profile.education && (
    - Education + {ui.education}
    - {data.profile.education} + {profile.education}
    )} -
    +
    -
    + {/* Featured Systems */} +

    - Featured Systems + {ui.featuredSystemsLabel}

    - Enterprise scale, product taste. + {ui.featuredSystemsHeading}

    - {(data.featuredSystems ?? []).map(system => ( + {featuredSystems.map((system, i) => ( @@ -322,7 +462,7 @@ export default function PortfolioExperience({ data }: { data: PortfolioData }) { {system.summary}

    - {system.tags.map(tag => ( + {data.featuredSystems?.[i]?.tags.map(tag => ( ))}
    -
    +
    -
    + {/* Career Timeline */} +

    - Career Timeline + {ui.careerLabel}

    - Impact first. Depth on tap. + {ui.careerHeading}

    -

    - Six roles compressed into portfolio cards. Click any card for the - resume-level detail: stack, responsibility and impact. -

    +

    {ui.careerSubtext}

    -
    - {data.experiences.map(item => ( + {experiences.map(item => ( setSelected(item)} /> ))}
    -
    +
    -
    - + {/* Skills */} +
    + -
    +
    - {data.personalLab && ( -
    + {/* Personal Lab */} + {personalLab && ( +

    - Personal Lab + {ui.personalLabLabel}

    - {data.personalLab.name} + {personalLab.name}

    - {data.personalLab.role} + {personalLab.role}

    - {data.personalLab.summary} + {personalLab.summary}

      - {data.personalLab.details.map(detail => ( + {personalLab.details.map((detail, i) => (
    • {detail} @@ -408,7 +548,7 @@ export default function PortfolioExperience({ data }: { data: PortfolioData }) { ))}
    - {data.personalLab.tech.map(tech => ( + {data.personalLab?.tech.map(tech => (
    -
    +
    )} + {/* Detail modal */} {selected && ( setSelected(null)} className="rounded-full bg-white/70 px-4 py-2 text-sm font-bold text-[#202124] shadow-sm transition hover:bg-white" > - Close + {ui.close}

    @@ -464,9 +605,9 @@ export default function PortfolioExperience({ data }: { data: PortfolioData }) { {selected.impact}

      - {selected.details.map(detail => ( + {selected.details.map((detail, i) => (
    • {detail} diff --git a/src/content/blog/eggjs-init.md b/src/content/blog/backend/eggjs-init.md similarity index 99% rename from src/content/blog/eggjs-init.md rename to src/content/blog/backend/eggjs-init.md index c9745dda..3ed7cd54 100644 --- a/src/content/blog/eggjs-init.md +++ b/src/content/blog/backend/eggjs-init.md @@ -5,7 +5,7 @@ slug: eggjs-init featured: false draft: false tags: - - node + - nodejs description: "``` npm init egg --type=simple ``` yarn init 不好使。 之前曾经在本地创建过mysql的环境,但是很久不用也就没有启动了,但是这次无论如何也不能成功启动起来。" --- diff --git a/src/content/blog/nestjs-oauth2.md b/src/content/blog/backend/nestjs-oauth2.md similarity index 99% rename from src/content/blog/nestjs-oauth2.md rename to src/content/blog/backend/nestjs-oauth2.md index cda50098..fab05d4f 100644 --- a/src/content/blog/nestjs-oauth2.md +++ b/src/content/blog/backend/nestjs-oauth2.md @@ -6,7 +6,7 @@ featured: false draft: false tags: - nestjs - - node + - nodejs description: "OAuth 2.0 是一种授权机制,主要用来颁发令牌。 给快递员一个7天的授权码,这样不提供自己的密码即可让快递员进入小区,这样的授权机制是安全的,当时间到了之后如果仍然有需求可以二次授权,否则就自动过期" --- diff --git a/src/content/blog/nestjs-passport-apis.md b/src/content/blog/backend/nestjs-passport-apis.md similarity index 99% rename from src/content/blog/nestjs-passport-apis.md rename to src/content/blog/backend/nestjs-passport-apis.md index ab3c92e3..910195f4 100644 --- a/src/content/blog/nestjs-passport-apis.md +++ b/src/content/blog/backend/nestjs-passport-apis.md @@ -6,8 +6,7 @@ featured: false draft: false tags: - nestjs - - node - - translation + - nodejs description: "连同定义 HTTP 的身份验证框架,[RFC 2617](http://tools.ietf.org/html/rfc2617) 也定义了基本和摘要认证方案。这两种方案都使用用户名和密码作为凭证来认证用户,并且通常用于保护 API 端点。" --- diff --git a/src/content/blog/nestjs-passport-general.md b/src/content/blog/backend/nestjs-passport-general.md similarity index 99% rename from src/content/blog/nestjs-passport-general.md rename to src/content/blog/backend/nestjs-passport-general.md index 2ddf4734..6ce1e80e 100644 --- a/src/content/blog/nestjs-passport-general.md +++ b/src/content/blog/backend/nestjs-passport-general.md @@ -6,8 +6,7 @@ featured: false draft: false tags: - nestjs - - node - - translation + - nodejs description: "Passport 是 NodeJS 的认证中间件。他的唯一设计目的是:验证请求。书写模块化的、封装代码是一种美德,所以 Passport 将除了验证请求之外的功能都分发给应用程序来实现。关注点分离使代码能够更加整洁、可维护,同时也使 Passport 能够极易集成到应用中。" --- diff --git a/src/content/blog/nestjs-passport-operations.md b/src/content/blog/backend/nestjs-passport-operations.md similarity index 99% rename from src/content/blog/nestjs-passport-operations.md rename to src/content/blog/backend/nestjs-passport-operations.md index 7a60a63f..2fd6665b 100644 --- a/src/content/blog/nestjs-passport-operations.md +++ b/src/content/blog/backend/nestjs-passport-operations.md @@ -6,8 +6,7 @@ featured: false draft: false tags: - nestjs - - node - - translation + - nodejs description: "Passport 暴露一个 `login()` 函数在 `req`(也有 `logIn()` 这个别名)上,它能够在建立登录 session 时使用。 ```js" --- diff --git a/src/content/blog/nestjs-passport-overview.md b/src/content/blog/backend/nestjs-passport-overview.md similarity index 99% rename from src/content/blog/nestjs-passport-overview.md rename to src/content/blog/backend/nestjs-passport-overview.md index 4bfc1ef1..acaa16cf 100644 --- a/src/content/blog/nestjs-passport-overview.md +++ b/src/content/blog/backend/nestjs-passport-overview.md @@ -6,7 +6,7 @@ featured: false draft: false tags: - nestjs - - node + - nodejs description: "权限管理是所有系统都会有的一部分,所以我们要了解一下 nodejs 中权限部分是怎么做的,都涉及哪些模块。 Passport 是一个简单、低调的 nodejs 身份验证中间件。它是弹性的、模块化的,能够集成到任何基于 express 的 web 应用。支持一系列权限策略:账号密码、Facebook、" --- diff --git a/src/content/blog/nestjs-passport-providers.md b/src/content/blog/backend/nestjs-passport-providers.md similarity index 99% rename from src/content/blog/nestjs-passport-providers.md rename to src/content/blog/backend/nestjs-passport-providers.md index 84cb5430..7c261c82 100644 --- a/src/content/blog/nestjs-passport-providers.md +++ b/src/content/blog/backend/nestjs-passport-providers.md @@ -6,8 +6,7 @@ featured: false draft: false tags: - nestjs - - node - - translation + - nodejs description: "Facebook 策略允许用户使用他们的 Facebook 账户登录一个 web 应用。在内部,Facebook 认证使用 OAuth 2.0 进行工作。 Facebook 支持通过 [passport-facebook](https://github.com/jaredhanson/passpor" --- diff --git a/src/content/blog/nodejs-typescript-modern-2026.md b/src/content/blog/backend/nodejs-typescript-modern-2026.md similarity index 100% rename from src/content/blog/nodejs-typescript-modern-2026.md rename to src/content/blog/backend/nodejs-typescript-modern-2026.md diff --git a/src/content/blog/oauth2-pkce-bff-modern-auth.md b/src/content/blog/backend/oauth2-pkce-bff-modern-auth.md similarity index 99% rename from src/content/blog/oauth2-pkce-bff-modern-auth.md rename to src/content/blog/backend/oauth2-pkce-bff-modern-auth.md index 1214d988..49964d5e 100644 --- a/src/content/blog/oauth2-pkce-bff-modern-auth.md +++ b/src/content/blog/backend/oauth2-pkce-bff-modern-auth.md @@ -8,7 +8,6 @@ tags: - oauth - security - nestjs - - frontend description: 回顾 2020 年 OAuth 2.0 方案的安全隐患,深入讲解 OAuth 2.1 的核心变化、PKCE 流程、BFF 模式,以及如何用 NestJS + Passport 构建安全的现代认证网关。 --- diff --git a/src/content/blog/translate-nodejs-best-practices.md b/src/content/blog/backend/translate-nodejs-best-practices.md similarity index 99% rename from src/content/blog/translate-nodejs-best-practices.md rename to src/content/blog/backend/translate-nodejs-best-practices.md index 73945d5b..98ba4a74 100644 --- a/src/content/blog/translate-nodejs-best-practices.md +++ b/src/content/blog/backend/translate-nodejs-best-practices.md @@ -5,8 +5,7 @@ slug: translate-nodejs-best-practices featured: false draft: false tags: - - translation - - node + - nodejs description: "虽然 Node.js 才诞生 11 年,但是已经是过去 10 年来最流行的 web 开发框架之一。我是一个终极粉丝,感谢 Node.js,让我能够在浏览器外部写 JavaScript 代码,去创建一个非阻塞的、流畅的、快速的、健壮的、可扩展的服务端应用程序。" --- diff --git a/src/content/blog/daily-english-reading-2026-03-21-ai-technology.md b/src/content/blog/english/daily-english-reading-2026-03-21-ai-technology.md similarity index 99% rename from src/content/blog/daily-english-reading-2026-03-21-ai-technology.md rename to src/content/blog/english/daily-english-reading-2026-03-21-ai-technology.md index d08199f0..64a4aeb0 100644 --- a/src/content/blog/daily-english-reading-2026-03-21-ai-technology.md +++ b/src/content/blog/english/daily-english-reading-2026-03-21-ai-technology.md @@ -1,8 +1,9 @@ --- title: "Daily English Reading: How AI is Transforming Our Daily Lives in 2026" +slug: daily-english-reading-2026-03-21-ai-technology pubDatetime: 2026-03-21T04:00:00+08:00 description: "B2-level English reading article about artificial intelligence with audio narration and Chinese translation" -tags: ["English Learning", "AI", "Technology", "Daily Reading"] +tags: ["english-learning", "daily-reading", "ai"] audioUrl: "https://reading-audios-1308187607.cos.ap-shanghai.myqcloud.com/daily-reading/2026-03-21-ai-technology.mp3" --- @@ -129,6 +130,7 @@ The AI revolution is not coming—it has arrived. And it's reshaping our world i 5. **复述内容**:用自己的话总结文章要点 **重点词汇**: + - artificial intelligence (AI) - 人工智能 - revolutionize - 彻底改变 - seamless - 无缝的 diff --git a/src/content/blog/daily-english-reading-2026-03-22-mental-health.md b/src/content/blog/english/daily-english-reading-2026-03-22-mental-health.md similarity index 99% rename from src/content/blog/daily-english-reading-2026-03-22-mental-health.md rename to src/content/blog/english/daily-english-reading-2026-03-22-mental-health.md index f5176c89..8974a78e 100644 --- a/src/content/blog/daily-english-reading-2026-03-22-mental-health.md +++ b/src/content/blog/english/daily-english-reading-2026-03-22-mental-health.md @@ -1,8 +1,9 @@ --- title: "Daily English Reading: The Science of Mental Wellbeing in Modern Life" +slug: daily-english-reading-2026-03-22-mental-health pubDatetime: 2026-03-22T04:00:00+08:00 description: "B2-level English reading article about mental health and wellbeing with audio narration and Chinese translation" -tags: ["English Learning", "Mental Health", "Wellbeing", "Daily Reading"] +tags: ["english-learning", "daily-reading", "mental-health"] audioUrl: "https://reading-audios-1308187607.cos.ap-shanghai.myqcloud.com/daily-reading/2026-03-22-mental-health.mp3" --- @@ -141,6 +142,7 @@ As we move through 2026, the mental health conversation continues evolving. We'r 5. **复述内容**:用自己的话总结文章要点 **重点词汇**: + - mental wellbeing - 心理健康 - resilience - 韧性,恢复力 - cognitive behavioral - 认知行为的 diff --git a/src/content/blog/daily-english-reading-2026-03-23-climate-change.md b/src/content/blog/english/daily-english-reading-2026-03-23-climate-change.md similarity index 99% rename from src/content/blog/daily-english-reading-2026-03-23-climate-change.md rename to src/content/blog/english/daily-english-reading-2026-03-23-climate-change.md index a53dc97f..bdf12ac6 100644 --- a/src/content/blog/daily-english-reading-2026-03-23-climate-change.md +++ b/src/content/blog/english/daily-english-reading-2026-03-23-climate-change.md @@ -1,8 +1,9 @@ --- title: "Daily English Reading: Climate Action in 2026: Progress and Challenges" +slug: daily-english-reading-2026-03-23-climate-change pubDatetime: 2026-03-23T04:00:00+08:00 description: "B2-level English reading article about climate change and environmental action with audio narration and Chinese translation" -tags: ["English Learning", "Climate Change", "Environment", "Daily Reading"] +tags: ["english-learning", "daily-reading", "climate-change"] audioUrl: "https://reading-audios-1308187607.cos.ap-shanghai.myqcloud.com/daily-reading/2026-03-23-climate-change.mp3" --- @@ -153,6 +154,7 @@ The question is no longer whether we can address climate change, but whether we 5. **复述内容**:用自己的话总结文章要点 **重点词汇**: + - climate change - 气候变化 - renewable energy - 可再生能源 - carbon dioxide - 二氧化碳 diff --git a/src/content/blog/daily-english-reading-2026-03-24-ai-companions.md b/src/content/blog/english/daily-english-reading-2026-03-24-ai-companions.md similarity index 98% rename from src/content/blog/daily-english-reading-2026-03-24-ai-companions.md rename to src/content/blog/english/daily-english-reading-2026-03-24-ai-companions.md index a8d130d5..8ffe87a0 100644 --- a/src/content/blog/daily-english-reading-2026-03-24-ai-companions.md +++ b/src/content/blog/english/daily-english-reading-2026-03-24-ai-companions.md @@ -1,8 +1,9 @@ --- title: "Daily English Reading: The Rise of AI Companions" +slug: daily-english-reading-2026-03-24-ai-companions pubDatetime: 2026-03-24T04:00:00+08:00 description: "B2-level English reading article about AI companions and how technology is reshaping human connection with audio narration" -tags: ["English Learning", "Technology", "AI", "Daily Reading"] +tags: ["english-learning", "daily-reading", "ai"] audioUrl: "https://reading-audios-1308187607.cos.ap-shanghai.myqcloud.com/daily-reading/2026-03-24-ai-companions.mp3" --- diff --git a/src/content/blog/daily-english-reading-2026-03-27-digital-privacy.md b/src/content/blog/english/daily-english-reading-2026-03-27-digital-privacy.md similarity index 98% rename from src/content/blog/daily-english-reading-2026-03-27-digital-privacy.md rename to src/content/blog/english/daily-english-reading-2026-03-27-digital-privacy.md index c813f023..f96ae902 100644 --- a/src/content/blog/daily-english-reading-2026-03-27-digital-privacy.md +++ b/src/content/blog/english/daily-english-reading-2026-03-27-digital-privacy.md @@ -1,8 +1,9 @@ --- title: "Daily English Reading: Digital Privacy in the Modern Age" +slug: daily-english-reading-2026-03-27-digital-privacy pubDatetime: 2026-03-27T04:00:00+08:00 description: "B2-level English reading article about digital privacy and data protection with audio narration" -tags: ["English Learning", "Technology", "Privacy", "Daily Reading"] +tags: ["english-learning", "daily-reading", "privacy"] audioUrl: "" --- diff --git a/src/content/blog/daily-english-reading-2026-03-28-green-tech.md b/src/content/blog/english/daily-english-reading-2026-03-28-green-tech.md similarity index 98% rename from src/content/blog/daily-english-reading-2026-03-28-green-tech.md rename to src/content/blog/english/daily-english-reading-2026-03-28-green-tech.md index c1adfaaf..760a3636 100644 --- a/src/content/blog/daily-english-reading-2026-03-28-green-tech.md +++ b/src/content/blog/english/daily-english-reading-2026-03-28-green-tech.md @@ -1,8 +1,9 @@ --- title: "Daily English Reading: Green Technology and Sustainable Living" +slug: daily-english-reading-2026-03-28-green-tech pubDatetime: 2026-03-28T04:00:00+08:00 description: "B2-level English reading about sustainable technology and eco-friendly lifestyle" -tags: ["English Learning", "Environment", "Technology", "Daily Reading"] +tags: ["english-learning", "daily-reading", "environment"] audioUrl: "" --- diff --git a/src/content/blog/daily-english-reading-2026-03-29-mental-wellness.md b/src/content/blog/english/daily-english-reading-2026-03-29-mental-wellness.md similarity index 98% rename from src/content/blog/daily-english-reading-2026-03-29-mental-wellness.md rename to src/content/blog/english/daily-english-reading-2026-03-29-mental-wellness.md index 15b549df..06fa1975 100644 --- a/src/content/blog/daily-english-reading-2026-03-29-mental-wellness.md +++ b/src/content/blog/english/daily-english-reading-2026-03-29-mental-wellness.md @@ -1,8 +1,9 @@ --- title: "Daily English Reading: Mental Wellness in Modern Life" +slug: daily-english-reading-2026-03-29-mental-wellness pubDatetime: 2026-03-29T04:00:00+08:00 description: "B2-level English reading about mental health and well-being in contemporary society" -tags: ["English Learning", "Health", "Lifestyle", "Daily Reading"] +tags: ["english-learning", "daily-reading", "mental-health"] audioUrl: "" --- diff --git a/src/content/blog/daily-english-reading-2026-03-30-remote-work.md b/src/content/blog/english/daily-english-reading-2026-03-30-remote-work.md similarity index 99% rename from src/content/blog/daily-english-reading-2026-03-30-remote-work.md rename to src/content/blog/english/daily-english-reading-2026-03-30-remote-work.md index f44c600b..b66bb46c 100644 --- a/src/content/blog/daily-english-reading-2026-03-30-remote-work.md +++ b/src/content/blog/english/daily-english-reading-2026-03-30-remote-work.md @@ -1,8 +1,9 @@ --- title: "Daily English Reading: The Future of Remote Work" +slug: daily-english-reading-2026-03-30-remote-work pubDatetime: 2026-03-30T04:00:00+08:00 description: "B2-level English reading about remote work trends and digital collaboration" -tags: ["English Learning", "Work", "Technology", "Daily Reading"] +tags: ["english-learning", "daily-reading", "work"] audioUrl: "" --- diff --git a/src/content/blog/daily-english-reading-2026-03-31-space-exploration.md b/src/content/blog/english/daily-english-reading-2026-03-31-space-exploration.md similarity index 99% rename from src/content/blog/daily-english-reading-2026-03-31-space-exploration.md rename to src/content/blog/english/daily-english-reading-2026-03-31-space-exploration.md index 2d77fed1..56b70448 100644 --- a/src/content/blog/daily-english-reading-2026-03-31-space-exploration.md +++ b/src/content/blog/english/daily-english-reading-2026-03-31-space-exploration.md @@ -1,8 +1,9 @@ --- title: "Daily English Reading: The New Era of Space Exploration" +slug: daily-english-reading-2026-03-31-space-exploration pubDatetime: 2026-03-31T04:00:00+08:00 description: "B2-level English reading about space exploration, Mars missions, and commercial spaceflight" -tags: ["English Learning", "Science", "Space", "Daily Reading"] +tags: ["english-learning", "daily-reading", "science"] audioUrl: "" --- diff --git a/src/content/blog/daily-english-reading-digital-detox.md b/src/content/blog/english/daily-english-reading-digital-detox.md similarity index 99% rename from src/content/blog/daily-english-reading-digital-detox.md rename to src/content/blog/english/daily-english-reading-digital-detox.md index 9a9bf467..2dbd695e 100644 --- a/src/content/blog/daily-english-reading-digital-detox.md +++ b/src/content/blog/english/daily-english-reading-digital-detox.md @@ -5,10 +5,9 @@ slug: daily-english-reading-digital-detox featured: false draft: false tags: - - English Learning - - Daily Reading - - B2 Level - - Lifestyle & Technology + - english-learning + - daily-reading + - lifestyle description: Today's B2 level English reading article about digital detox - why unplugging from technology can improve your mental health and productivity, with audio and Chinese translation. --- diff --git a/src/content/blog/daily-english-reading-forever-chemicals.md b/src/content/blog/english/daily-english-reading-forever-chemicals.md similarity index 98% rename from src/content/blog/daily-english-reading-forever-chemicals.md rename to src/content/blog/english/daily-english-reading-forever-chemicals.md index 20a0123f..9ce3f9f0 100644 --- a/src/content/blog/daily-english-reading-forever-chemicals.md +++ b/src/content/blog/english/daily-english-reading-forever-chemicals.md @@ -5,10 +5,9 @@ slug: daily-english-reading-forever-chemicals featured: false draft: false tags: - - English Learning - - Daily Reading - - B2 Level - - Health & Science + - english-learning + - daily-reading + - health description: Today's B2 level English reading article about PFAS (forever chemicals) and their impact on health and environment, with audio and Chinese translation. --- diff --git a/src/content/blog/daily-english-reading-science-of-sleep.md b/src/content/blog/english/daily-english-reading-science-of-sleep.md similarity index 99% rename from src/content/blog/daily-english-reading-science-of-sleep.md rename to src/content/blog/english/daily-english-reading-science-of-sleep.md index 5d4566e9..d8b65c7c 100644 --- a/src/content/blog/daily-english-reading-science-of-sleep.md +++ b/src/content/blog/english/daily-english-reading-science-of-sleep.md @@ -5,10 +5,9 @@ slug: daily-english-reading-science-of-sleep featured: false draft: false tags: - - English Learning - - Daily Reading - - B2 Level - - Health & Wellness + - english-learning + - daily-reading + - health description: Today's B2 level English reading article about the science of sleep, its importance for health, and tips for better rest, with audio and Chinese translation. --- diff --git a/src/content/blog/daily-english-reading-sleep-affects-brain.md b/src/content/blog/english/daily-english-reading-sleep-affects-brain.md similarity index 99% rename from src/content/blog/daily-english-reading-sleep-affects-brain.md rename to src/content/blog/english/daily-english-reading-sleep-affects-brain.md index db8e5c6d..b8af7b34 100644 --- a/src/content/blog/daily-english-reading-sleep-affects-brain.md +++ b/src/content/blog/english/daily-english-reading-sleep-affects-brain.md @@ -5,10 +5,9 @@ slug: daily-english-reading-sleep-affects-brain featured: false draft: false tags: - - English Learning - - Daily Reading - - B2 Level - - Health & Science + - english-learning + - daily-reading + - health description: Today's B2 level English reading article about how sleep affects your brain, with audio and Chinese translation. --- diff --git a/src/content/blog/how-to-nap-like-a-pro.md b/src/content/blog/english/how-to-nap-like-a-pro.md similarity index 99% rename from src/content/blog/how-to-nap-like-a-pro.md rename to src/content/blog/english/how-to-nap-like-a-pro.md index 8d5a53e1..1ebfccb6 100644 --- a/src/content/blog/how-to-nap-like-a-pro.md +++ b/src/content/blog/english/how-to-nap-like-a-pro.md @@ -5,9 +5,9 @@ slug: how-to-nap-like-a-pro featured: false draft: false tags: - - English Learning - - Daily Reading - - B2 Level + - english-learning + - daily-reading + - health description: GQ Wellness article about how to nap properly. --- diff --git a/src/content/blog/roy-pacis-1.md b/src/content/blog/english/roy-pacis-1.md similarity index 99% rename from src/content/blog/roy-pacis-1.md rename to src/content/blog/english/roy-pacis-1.md index 7e6f6949..925fdd13 100644 --- a/src/content/blog/roy-pacis-1.md +++ b/src/content/blog/english/roy-pacis-1.md @@ -5,7 +5,8 @@ slug: english-teacher-1 featured: false draft: false tags: - - English Learning + - english-learning + - reflection description: 有的时候过来人的一句话,就会对你的生活产生巨大的改变,感恩每个遇到的人。 --- diff --git a/src/content/blog/roy-pacis-2.md b/src/content/blog/english/roy-pacis-2.md similarity index 93% rename from src/content/blog/roy-pacis-2.md rename to src/content/blog/english/roy-pacis-2.md index eeb90db2..1b4f7bb3 100644 --- a/src/content/blog/roy-pacis-2.md +++ b/src/content/blog/english/roy-pacis-2.md @@ -1,13 +1,13 @@ --- pubDatetime: 2023-12-03T10:09:00Z title: English Teacher 2 -slug: english-teacher-2 -featured: false +slug: english-teacher-2 +featured: false draft: false tags: - - English Learning -description: - 有时自己有种想拯救苍生的情节,那么就从身边的人开始。 + - english-learning + - reflection +description: 有时自己有种想拯救苍生的情节,那么就从身边的人开始。 --- Roy 之前跟我提到过,如果有其他想学习英语的学生可以推荐给他,一个是确实没有合适的人。一个是当一个人主动让你做点什么时候,自己的内心其实没那么想去做,没有动力。 @@ -22,4 +22,4 @@ Roy 之前跟我提到过,如果有其他想学习英语的学生可以推荐 Tom是程序员,可能程序员还是没有产品那么有EQ 情商吧,我总得找些话来劝说Roy,当然可能这也是程序员被黑的比较惨的一次。我其实自己也不能理解,一个人请你帮忙说些好话,以便能够招到更多学生,为什么你说的会是有负向作用的“真话”。可能真的是 EQ 的原因。 -这就是Roy的故事。后续我还会跟他继续学习,提高英语技能;同时我也会让 Julianna 我的女儿也学习英语,不过可能刚开始的时候会让她跟欧美外教学习,后面再转为菲教,小孩子的口音养成还是很重要的。 \ No newline at end of file +这就是Roy的故事。后续我还会跟他继续学习,提高英语技能;同时我也会让 Julianna 我的女儿也学习英语,不过可能刚开始的时候会让她跟欧美外教学习,后面再转为菲教,小孩子的口音养成还是很重要的。 diff --git a/src/content/blog/roy-pacis-3.md b/src/content/blog/english/roy-pacis-3.md similarity index 90% rename from src/content/blog/roy-pacis-3.md rename to src/content/blog/english/roy-pacis-3.md index 3764a5c1..54593665 100644 --- a/src/content/blog/roy-pacis-3.md +++ b/src/content/blog/english/roy-pacis-3.md @@ -1,13 +1,13 @@ --- pubDatetime: 2023-12-05T12:09:00Z title: English Teacher 3 -slug: english-teacher-3 -featured: false +slug: english-teacher-3 +featured: false draft: false tags: - - English Learning -description: - 截止到现在 2024年1月22日09:05:19,已经成功推荐5个学生(Conrad, Leo, Klay, A girl, Robert)跟Roy学习。 + - english-learning + - reflection +description: 截止到现在 2024年1月22日09:05:19,已经成功推荐5个学生(Conrad, Leo, Klay, A girl, Robert)跟Roy学习。 --- 其中3个是在电鸭论坛发文后就咨询然后跟随Roy学习,这个第4个 girl 是过了2周后,自己找到Roy学习,并没有咨询我,但是可以很确定的是通过邮件联系到Roy,还是那个帖子起了效果。 @@ -33,4 +33,4 @@ description: 推荐模式对我的启发 -如果后面有机会做独立产品,也可以采用这种模式,首先攒下种子用户,然后如果他们能够推荐给身边的人,那么就有机会被很多人使用了。 \ No newline at end of file +如果后面有机会做独立产品,也可以采用这种模式,首先攒下种子用户,然后如果他们能够推荐给身边的人,那么就有机会被很多人使用了。 diff --git a/src/content/blog/roy-pacis-4.md b/src/content/blog/english/roy-pacis-4.md similarity index 92% rename from src/content/blog/roy-pacis-4.md rename to src/content/blog/english/roy-pacis-4.md index 6f4798ac..f2544d99 100644 --- a/src/content/blog/roy-pacis-4.md +++ b/src/content/blog/english/roy-pacis-4.md @@ -5,9 +5,9 @@ slug: english-teacher-4 featured: false draft: false tags: - - English Learning -description: - 跟着Roy学习英语已经有很长一段时间了,总的来说我的想法,菲教适合入门,开口,仅此而已。 + - english-learning + - reflection +description: 跟着Roy学习英语已经有很长一段时间了,总的来说我的想法,菲教适合入门,开口,仅此而已。 --- 最开始跟着他学习的时候,学习了两本书之后,他就是因为态度不好,被巨嘴鸟平台拒签合同,然后我不知道脑子里在想什么就稀里糊涂的跟着他转到了平台之外,回想起来这是我不应该做的事情。 @@ -20,4 +20,4 @@ description: 然后目前我有在尝试新的学习平台,比如amazingTalker,ai推荐的一个平台,开始跟一个us老师学习,但是觉得老师有些慢节奏,5节课结束后,就换了新的一个us老师,老师的语速更快,感觉能够学到的东西更多一些。第二个是Trevor老师,讲的课程主要是ITLS,其实我不知道自己是否学的正确与否,因为我没有考雅思的想法,只是希望自己的口语和听力能力能够有所提高,然后能够在目前的职场上在英语方面更游刃有余一些。 -所以,总结一下,菲教和us老师,都对我的英语提高起到了一定的作用,还是很感谢他们的。那么后续在Roy的20节课结束之后,暂时不会考虑续订他的课了。然后对于Trevor目前上了6节课大概(每节课100rmb,和菲教比还是小贵),整体感受还行,再继续体会体会。那对于Roy的这种普通口语课,在探索是否能够通过跟ai学习的方式来实现。(能够想到的一个prompt如下,请帮我规划一个英语学习的idea,通过比如shadowing练习,ai读一句,我重复一句,5个句子结束后,进行总体的评分,给出反馈,同时希望这个挑战能够有上瘾性,或者说更加游戏化,从而激发参与者的兴趣。同时还应该考虑针对参与者的系统性记录,每次的句子应该针对当前参与者的level,对于错误的句子进行记录,并且考虑将费曼学习法融入进去,从而使学习者能够掌握内容,而不是复述结束就结束了)。 \ No newline at end of file +所以,总结一下,菲教和us老师,都对我的英语提高起到了一定的作用,还是很感谢他们的。那么后续在Roy的20节课结束之后,暂时不会考虑续订他的课了。然后对于Trevor目前上了6节课大概(每节课100rmb,和菲教比还是小贵),整体感受还行,再继续体会体会。那对于Roy的这种普通口语课,在探索是否能够通过跟ai学习的方式来实现。(能够想到的一个prompt如下,请帮我规划一个英语学习的idea,通过比如shadowing练习,ai读一句,我重复一句,5个句子结束后,进行总体的评分,给出反馈,同时希望这个挑战能够有上瘾性,或者说更加游戏化,从而激发参与者的兴趣。同时还应该考虑针对参与者的系统性记录,每次的句子应该针对当前参与者的level,对于错误的句子进行记录,并且考虑将费曼学习法融入进去,从而使学习者能够掌握内容,而不是复述结束就结束了)。 diff --git a/src/content/blog/angular-change-detection.md b/src/content/blog/frontend/angular/angular-change-detection.md similarity index 100% rename from src/content/blog/angular-change-detection.md rename to src/content/blog/frontend/angular/angular-change-detection.md diff --git a/src/content/blog/frontend/angular/angular-declarative-templating.md b/src/content/blog/frontend/angular/angular-declarative-templating.md new file mode 100644 index 00000000..5c48995e --- /dev/null +++ b/src/content/blog/frontend/angular/angular-declarative-templating.md @@ -0,0 +1,252 @@ +--- +pubDatetime: 2026-05-30T09:30:00+08:00 +title: "[译] Angular 声明式模板如何实现高性能渲染" +slug: angular-declarative-templating +featured: false +draft: true +isTranslation: true +tags: + - angular + - rendering + - performance + - change-detection +description: 本文从 Angular Ivy 编译产物入手,解释声明式模板如何通过创建/更新模式、节点复用、OnPush 与脏检查、以及插值记忆化来减少 DOM 操作。 +canonicalURL: https://riegler.fr/blog/2025-02-16-incremental-dom/ +--- + +> 原文地址: https://riegler.fr/blog/2025-02-16-incremental-dom/ + +像 Angular 这样的 Web 框架,最初就是为了解决“直接操作 DOM”带来的复杂度而诞生的。它们为开发者提供了更声明式、更高效的方式来构建动态 UI。使用原生 JavaScript API 直接操作 DOM 通常笨重、容易出错,而且性能开销高,因为 DOM 操作本身就很昂贵。为此,框架引入了抽象层,例如 Angular 的声明式模板,或 React 的 Virtual DOM,以优化变更应用到 DOM 的过程。通过批量更新、减少直接 DOM 访问、利用差异计算算法,框架显著提升了渲染性能与开发效率。归根结底,它们在命令式 DOM API 与现代声明式组件开发之间搭建了桥梁。 + +简单说,框架的职责之一,就是尽可能减少 DOM 操作。本文将深入 Angular 为优化 DOM 操作所采用的不同策略。 + +## Angular 的渲染方式:声明式模板 + +早在 2019 年,Angular 团队发布了新的渲染引擎 Ivy。顺带一提,Ivy 的故事在 [Honeypot 的 Angular 纪录片](https://www.youtube.com/watch?v=cRC9DlH45lA) 中也有提及。 + +Ivy 的核心思想是:每个组件模板都会被编译成一组 JavaScript 指令,这些指令组成一个模板函数。 + +
      + +
      + +#### `一个基础组件模板` + +```html +
      this is a div, with a value: {{ myValue }}
      +``` + +
      + +
      + +#### `对应的指令表示` + +```js +function MyComponentTemplateFn(rf, ctx) { + if (rf & 1) { + /* creation mode */ + ɵɵelementStart(0, "div"); + ɵɵtext(1); + ɵɵelementEnd(); + } + if (rf & 2) { + /* update mode */ + ɵɵadvance(); + ɵɵtextInterpolate1("this is a div, with a value: ", ctx.myValue, ""); + } +} +``` + +
      +
      + +你可以通过[我做的这个 demo](https://jeanmeche.github.io/angular-compiler-output/)自己试试,它可以展示你写出的任意 Angular 模板的编译指令。 + +可以看到,这个模板函数被分成两个阶段: + +- 创建阶段(creation mode) +- 更新阶段(update mode) + +### 创建阶段 + +模板函数的创建阶段,是框架初始化并创建组件 DOM 结构的阶段。 +在这个阶段里,Ivy 会执行一系列模板指令:创建 DOM 元素、绑定属性、建立初始数据绑定。 + +创建阶段本质上是渲染过程的第一步:把组件模板变成“活的”DOM 结构。 + +在前面的示例中,创建阶段会创建两个节点:一个 DIV 节点,里面有一个(此时为空的)文本节点。 + +### 更新阶段 + +创建阶段完成后,Angular 进入更新阶段。这个阶段处理组件状态变化,并按需更新 DOM。将创建与更新分离,让 Ivy 可以只在必要时修改内容,而不是整棵 DOM 重建。 + +在前面的示例中,更新阶段会把文本插值的内容写入文本节点。 + +### 可视化创建/更新差异:DOM 变化提示 + +我们可以直观看到创建阶段与更新阶段的差异。 +例如,Chrome DevTools 在 Rendering 选项卡里有一个功能:让被更新的 DOM 元素闪烁。 + +![Chrome Devtools, Rendering Tab, Paint Flashing enabled](/blog-images/angular-declarative-templating/plaint-flashing.png) + +--- + +开启后,我们来写一个简单例子:一个静态 DIV 和一个动态 DIV。 + +
      + +
      + +#### `组件与模板` + +```typescript +@Component({ + template: ` +
      This is static
      +
      This is updated {{ sig() }}
      + `, +}) +export class AppComponent { + sig = signal(0); + + constructor() { + setInterval(() => { + this.sig.update(v => v + 1); + }, 1000); + } +} +``` + +
      + +
      + +#### `编译后的模板函数` + +```js +function MyComponentTemplateFn(rf, ctx) { + if (rf & 1) { + ɵɵelementStart(0, "div"); + ɵɵtext(1, "This is static"); + ɵɵelementEnd(); + ɵɵelementStart(2, "div"); + ɵɵtext(3); + ɵɵelementEnd(); + } + if (rf & 2) { + ɵɵadvance(3); + ɵɵtextInterpolate1("This is updated ", ctx.sig(), ""); + } +} +``` + +
      +
      + +这个模板会创建两个 DIV: + +1. 第一个只有静态节点,只在创建阶段生成。 +2. 第二个包含插值,插值在更新阶段计算。 + +signal 每秒更新一次,框架会调度一次同步,重新执行模板函数。 + +![2 Divs, only the text node of the Dynamic one is updated](/blog-images/angular-declarative-templating/blink.gif) + +我们可以看到,在更新时两个 DIV 本身都没有变化。真正被更新的只有动态 DIV 中的文本节点。 + +[Stackblitz Demo](https://stackblitz.com/edit/angular-incremental-dom-blink) + +### 列表渲染与节点复用 + +Angular 的 `@for`(v17 随 @-block 控制流一起引入)在列表渲染时,会尽量复用已有 DOM 节点。 + +而 `track` 函数让这件事更高效。你可以为每个列表项指定唯一标识,Angular 就能更智能地判断哪些节点可以复用,而不是全部重建。这样可以减少无意义的 DOM 操作,提升渲染性能,并让频繁增删改排序的动态列表更新更平滑。`track` 是一个小功能,但非常有价值,也体现了 Angular 在性能和开发体验上的取向。 + +## 模板渲染的执行时机 + +你现在已经知道:Angular 是通过执行组件模板函数来更新 DOM 的。 +但问题是:模板函数在什么时候执行?哪些组件会执行? + +Angular 的同步机制(过去称为 Change Detection,CD)会遍历组件树,并执行那些需要同步的组件模板函数。 + +根据 ChangeDetection 策略不同,组件要么总会被检查(`Default`),要么只有在被标记为 dirty 时才检查(`OnPush`)。 + +```mermaid +flowchart TD + Def[ChangeDetectionStragery.Default] ~~~ A + OnPush{{ChangeDetectionStragery.OnPush}} ~~~ A + classDef cd fill:#adebad + + A[Appcomponent] + + A:::cd --> B[SideBarComponent] + A --> C{{"MainViewComponent\n(traversed)"}} + + B:::cd --> F[HeaderComponent] + B --> G[MenuList] + G --> G1[MenuListItem] + G --> G2[MenuListItem] + G --> G3[MenuListItem] + + F:::cd + G:::cd + G1:::cd + G2:::cd + G3:::cd + + C --> C1{{"SomeViewComponent"}} + C --> C2{{"SomeOtherViewComponent\n(traversed)"}} + + C2 --> C22{{"SomeDetail\n dirty"}} + + C22:::cd +``` + +在现代应用里,“组件是否 dirty”大多由 signal 与响应式依赖树驱动。signal 更新后,会把使用它的组件标记为 dirty。 + +这里你能看到第二层性能优化: +使用 `OnPush` 时,框架只同步它明确知道 dirty 的组件,其余组件会被跳过,从而节省 CPU 时间。 + +## 记忆化与脏值检查 + +我们已经看到两点: + +- Angular 只更新动态内容。 +- Angular 只在 dirty 组件上运行更新。 + +现在再看最后一层优化:记忆化(memoization)与脏值检查(dirty checking)。 + +回到一个包含动态表达式(如插值)的模板函数: + +```typescript +function MyComponentTemplateFn(rf, ctx) { + if (rf & 1) { + ... + } + if (rf & 2) { + ɵɵadvance(3); + ɵɵtextInterpolate1('This is updated ', ctx.sig(), ''); + } +} +``` + +即便插值是动态的,它仍只是这个模板函数中的一部分。 +模板函数可能会执行,但这条插值不一定真的发生变化(也许变化的是其他插值)。 + +在这种情况下,Angular 会记忆化插值结果。 +当执行到该指令时,它会把本次结果与上次结果比较,只有当值(字符串)变化时,才真正更新 DOM。 + +这进一步避免了昂贵且不必要的 DOM 操作。 + +## 总结 + +Angular 的声明式模板与基于视图的响应式渲染,在性能和效率上都非常有竞争力。它通过“增量更新 + 直接 DOM 操作”来优化渲染路径,给开发者提供了更高效、更直观的动态 UI 构建方式。尽管 React 让 Virtual DOM 更广为人知,但 Angular 的声明式策略也证明了:实现高性能 Web 应用并不只有一条路。 + +随着 Web 开发持续演进,Angular 在模板与渲染机制上的持续创新,也让它在生态中保持强劲竞争力。无论你在做小项目还是大型企业应用,理解这些渲染策略都能帮助你做出更合适的技术决策。 + +## 备注 + +Alex Rickabaugh 在 [NgGlühwein 2024 演讲](https://www.youtube.com/watch?v=uTqu_eR80ss&t=1s)中分享了一个关键思路转变:团队重新定义了对 ChangeDetection 的理解,把它视为“模型与 DOM 的同步过程”。 + +本文最初版本把 Angular 的渲染策略描述为 "Incremental DOM"。后来我了解到 Incremental DOM 实际是另一种策略。它与 VDOM 有些相似,但不会创建中间树,而是直接与真实 DOM 做差异对比。可参考 Google 的 [incremental-dom library](https://google.github.io/incremental-dom/)。 diff --git a/src/content/blog/angular-features-13-to-15.md b/src/content/blog/frontend/angular/angular-features-13-to-15.md similarity index 74% rename from src/content/blog/angular-features-13-to-15.md rename to src/content/blog/frontend/angular/angular-features-13-to-15.md index c26ef3ba..972ef596 100644 --- a/src/content/blog/angular-features-13-to-15.md +++ b/src/content/blog/frontend/angular/angular-features-13-to-15.md @@ -9,6 +9,7 @@ description: 从 Angular 13 到 15,框架经历了 Ivy 全面接管、Typed Fo --- > 本文是 Angular 升级系列教程的第一篇,覆盖 Angular 13、14、15 三个版本。系列文章: +> > - **(一)Angular 13-15 新特性详解**(本文) > - [(二)Angular 16-18 新特性详解](/posts/angular-features-16-to-18) > - [(三)Angular 19-21 新特性详解](/posts/angular-features-19-to-21) @@ -34,11 +35,12 @@ Angular 13 是一个分水岭——旧的 View Engine 渲染引擎被完全移 ```typescript // ❌ Angular 12 及之前 @Component({ - selector: 'app-parent', - template: '' + selector: "app-parent", + template: "", }) export class ParentComponent { - @ViewChild('container', { read: ViewContainerRef }) container!: ViewContainerRef; + @ViewChild("container", { read: ViewContainerRef }) + container!: ViewContainerRef; constructor(private cfr: ComponentFactoryResolver) {} @@ -50,11 +52,12 @@ export class ParentComponent { // ✅ Angular 13+ @Component({ - selector: 'app-parent', - template: '' + selector: "app-parent", + template: "", }) export class ParentComponent { - @ViewChild('container', { read: ViewContainerRef }) container!: ViewContainerRef; + @ViewChild("container", { read: ViewContainerRef }) + container!: ViewContainerRef; createComponent() { this.container.createComponent(ChildComponent); @@ -130,27 +133,27 @@ ng update @angular/core@13 @angular/cli@13 ```typescript // ❌ Angular 13 及之前 — 所有值都是 any const form = new FormGroup({ - name: new FormControl(''), + name: new FormControl(""), age: new FormControl(0), }); const name = form.value.name; // any 😢 // ✅ Angular 14+ — 完整类型推断 const form = new FormGroup({ - name: new FormControl('', { nonNullable: true }), + name: new FormControl("", { nonNullable: true }), age: new FormControl(0, { nonNullable: true }), email: new FormControl(null), }); const name = form.value.name; // string ✅ -const age = form.value.age; // number ✅ +const age = form.value.age; // number ✅ const email = form.value.email; // string | null ✅ ``` `nonNullable` 选项确保 `reset()` 时恢复初始值而非 `null`: ```typescript -const name = new FormControl('默认值', { nonNullable: true }); +const name = new FormControl("默认值", { nonNullable: true }); name.reset(); // 值变为 '默认值',而不是 null ``` @@ -174,10 +177,10 @@ ngOnInit() { ```typescript // 过渡方案 — 行为与旧版完全一致 -import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; +import { UntypedFormControl, UntypedFormGroup } from "@angular/forms"; const form = new UntypedFormGroup({ - name: new UntypedFormControl(''), + name: new UntypedFormControl(""), }); ``` @@ -189,15 +192,15 @@ Angular 14 引入了 Standalone Components 的概念——组件不再必须属 // 独立组件 — 不需要声明在任何 NgModule 中 @Component({ standalone: true, - selector: 'app-hello', + selector: "app-hello", imports: [CommonModule], // 直接在组件级别导入依赖 template: `

      Hello {{ name }}

      这是一个独立组件

      - ` + `, }) export class HelloComponent { - @Input() name = 'World'; + @Input() name = "World"; showDetails = true; } @@ -205,7 +208,7 @@ export class HelloComponent { @Component({ standalone: true, imports: [HelloComponent], // 直接导入组件 - template: '' + template: '', }) export class AppComponent {} ``` @@ -216,10 +219,10 @@ export class AppComponent {} // 路由配置 — 直接使用独立组件 const routes: Routes = [ { - path: 'dashboard', - loadComponent: () => import('./dashboard.component') - .then(m => m.DashboardComponent) - } + path: "dashboard", + loadComponent: () => + import("./dashboard.component").then(m => m.DashboardComponent), + }, ]; ``` @@ -228,12 +231,12 @@ const routes: Routes = [ 可以在构造函数之外使用 `inject()` 获取依赖: ```typescript -import { inject } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { inject } from "@angular/core"; +import { HttpClient } from "@angular/common/http"; @Component({ - selector: 'app-user', - template: '...' + selector: "app-user", + template: "...", }) export class UserComponent { // 不再需要 constructor(private http: HttpClient) @@ -242,7 +245,7 @@ export class UserComponent { private activatedRoute = inject(ActivatedRoute); loadUser() { - return this.http.get('/api/user'); + return this.http.get("/api/user"); } } ``` @@ -280,15 +283,17 @@ export class MyComponent { ```typescript const routes: Routes = [ - { path: '', component: HomeComponent, title: '首页' }, - { path: 'about', component: AboutComponent, title: '关于我们' }, - { path: 'products', component: ProductsComponent, title: '产品列表' }, + { path: "", component: HomeComponent, title: "首页" }, + { path: "about", component: AboutComponent, title: "关于我们" }, + { path: "products", component: ProductsComponent, title: "产品列表" }, ]; // 自定义标题策略 -@Injectable({ providedIn: 'root' }) +@Injectable({ providedIn: "root" }) export class CustomTitleStrategy extends TitleStrategy { - constructor(private title: Title) { super(); } + constructor(private title: Title) { + super(); + } override updateTitle(routerState: RouterStateSnapshot) { const title = this.buildTitle(routerState); @@ -299,9 +304,7 @@ export class CustomTitleStrategy extends TitleStrategy { } // 注册自定义策略 -providers: [ - { provide: TitleStrategy, useClass: CustomTitleStrategy } -] +providers: [{ provide: TitleStrategy, useClass: CustomTitleStrategy }]; ``` ### 5. 增强的模板诊断 @@ -344,17 +347,14 @@ Angular 15 将 Standalone Components/Directives/Pipes 标记为稳定版,并 ```typescript // main.ts — 不再需要 AppModule -import { bootstrapApplication } from '@angular/platform-browser'; -import { provideRouter } from '@angular/router'; -import { provideHttpClient } from '@angular/common/http'; -import { AppComponent } from './app.component'; -import { routes } from './app.routes'; +import { bootstrapApplication } from "@angular/platform-browser"; +import { provideRouter } from "@angular/router"; +import { provideHttpClient } from "@angular/common/http"; +import { AppComponent } from "./app.component"; +import { routes } from "./app.routes"; bootstrapApplication(AppComponent, { - providers: [ - provideRouter(routes), - provideHttpClient(), - ] + providers: [provideRouter(routes), provideHttpClient()], }); ``` @@ -367,7 +367,7 @@ bootstrapApplication(AppComponent, { RouterModule.forRoot(routes), HttpClientModule, BrowserAnimationsModule, - ] + ], }) export class AppModule {} @@ -377,10 +377,10 @@ bootstrapApplication(AppComponent, { provideRouter(routes), provideHttpClient( withInterceptors([authInterceptor]), - withFetch(), // 使用 fetch API 替代 XMLHttpRequest + withFetch() // 使用 fetch API 替代 XMLHttpRequest ), provideAnimations(), - ] + ], }); ``` @@ -390,13 +390,16 @@ bootstrapApplication(AppComponent, { ```typescript // ❌ Angular 14 及之前 — 类守卫 -@Injectable({ providedIn: 'root' }) +@Injectable({ providedIn: "root" }) export class AuthGuard implements CanActivate { - constructor(private auth: AuthService, private router: Router) {} + constructor( + private auth: AuthService, + private router: Router + ) {} canActivate(): boolean { if (this.auth.isLoggedIn()) return true; - this.router.navigate(['/login']); + this.router.navigate(["/login"]); return false; } } @@ -407,16 +410,16 @@ export const authGuard: CanActivateFn = (route, state) => { const router = inject(Router); if (auth.isLoggedIn()) return true; - return router.createUrlTree(['/login']); + return router.createUrlTree(["/login"]); }; // 路由配置 const routes: Routes = [ { - path: 'dashboard', + path: "dashboard", canActivate: [authGuard], - component: DashboardComponent - } + component: DashboardComponent, + }, ]; ``` @@ -424,17 +427,17 @@ const routes: Routes = [ ```typescript // 函数式 Resolver -export const userResolver: ResolveFn = (route) => { +export const userResolver: ResolveFn = route => { const userService = inject(UserService); - return userService.getUser(route.paramMap.get('id')!); + return userService.getUser(route.paramMap.get("id")!); }; const routes: Routes = [ { - path: 'user/:id', + path: "user/:id", component: UserDetailComponent, - resolve: { user: userResolver } - } + resolve: { user: userResolver }, + }, ]; ``` @@ -447,7 +450,7 @@ export class AuthInterceptor implements HttpInterceptor { intercept(req: HttpRequest, next: HttpHandler) { const token = inject(AuthService).getToken(); const authReq = req.clone({ - headers: req.headers.set('Authorization', `Bearer ${token}`) + headers: req.headers.set("Authorization", `Bearer ${token}`), }); return next.handle(authReq); } @@ -457,7 +460,7 @@ export class AuthInterceptor implements HttpInterceptor { export const authInterceptor: HttpInterceptorFn = (req, next) => { const token = inject(AuthService).getToken(); const authReq = req.clone({ - headers: req.headers.set('Authorization', `Bearer ${token}`) + headers: req.headers.set("Authorization", `Bearer ${token}`), }); return next(authReq); }; @@ -465,10 +468,8 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { // 注册 bootstrapApplication(AppComponent, { providers: [ - provideHttpClient( - withInterceptors([authInterceptor, loggingInterceptor]) - ) - ] + provideHttpClient(withInterceptors([authInterceptor, loggingInterceptor])), + ], }); ``` @@ -480,41 +481,49 @@ bootstrapApplication(AppComponent, { // 定义可复用的指令 @Directive({ standalone: true, - selector: '[tooltip]', - host: { '(mouseenter)': 'show()', '(mouseleave)': 'hide()' } + selector: "[tooltip]", + host: { "(mouseenter)": "show()", "(mouseleave)": "hide()" }, }) export class TooltipDirective { - @Input() tooltip = ''; - show() { /* 显示提示 */ } - hide() { /* 隐藏提示 */ } + @Input() tooltip = ""; + show() { + /* 显示提示 */ + } + hide() { + /* 隐藏提示 */ + } } @Directive({ standalone: true, - selector: '[highlight]', - host: { '(mouseenter)': 'onHover()', '(mouseleave)': 'onLeave()' } + selector: "[highlight]", + host: { "(mouseenter)": "onHover()", "(mouseleave)": "onLeave()" }, }) export class HighlightDirective { - @Input() highlightColor = 'yellow'; - onHover() { /* 高亮 */ } - onLeave() { /* 取消高亮 */ } + @Input() highlightColor = "yellow"; + onHover() { + /* 高亮 */ + } + onLeave() { + /* 取消高亮 */ + } } // 在组件中组合指令 @Component({ - selector: 'app-fancy-button', + selector: "app-fancy-button", hostDirectives: [ { directive: TooltipDirective, - inputs: ['tooltip'], + inputs: ["tooltip"], }, { directive: HighlightDirective, - inputs: ['highlightColor'], + inputs: ["highlightColor"], outputs: [], - } + }, ], - template: '' + template: "", }) export class FancyButtonComponent {} @@ -527,7 +536,7 @@ export class FancyButtonComponent {} Angular 15 将图片优化指令标记为稳定版: ```typescript -import { NgOptimizedImage } from '@angular/common'; +import { NgOptimizedImage } from "@angular/common"; @Component({ standalone: true, @@ -537,14 +546,18 @@ import { NgOptimizedImage } from '@angular/common'; - +
      - ` + `, }) export class ImageExampleComponent {} ``` @@ -553,11 +566,9 @@ export class ImageExampleComponent {} ```typescript // app.config.ts -import { provideImageKitLoader } from '@angular/common'; +import { provideImageKitLoader } from "@angular/common"; -providers: [ - provideImageKitLoader('https://ik.imagekit.io/your_id') -] +providers: [provideImageKitLoader("https://ik.imagekit.io/your_id")]; ``` ### 6. `DestroyRef` 和 `takeUntilDestroyed` @@ -565,28 +576,29 @@ providers: [ 更优雅地处理组件销毁时的清理工作: ```typescript -import { DestroyRef, inject } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { DestroyRef, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; @Component({ - selector: 'app-data', - template: '{{ data }}' + selector: "app-data", + template: "{{ data }}", }) export class DataComponent { - data = ''; + data = ""; constructor() { // 方式一:takeUntilDestroyed — 组件销毁时自动取消订阅 - inject(HttpClient).get('/api/data').pipe( - takeUntilDestroyed() - ).subscribe(res => this.data = res); + inject(HttpClient) + .get("/api/data") + .pipe(takeUntilDestroyed()) + .subscribe(res => (this.data = res)); } // 方式二:DestroyRef — 手动注册清理回调 private destroyRef = inject(DestroyRef); ngOnInit() { - const timer = setInterval(() => console.log('tick'), 1000); + const timer = setInterval(() => console.log("tick"), 1000); this.destroyRef.onDestroy(() => clearInterval(timer)); } } @@ -600,22 +612,22 @@ export class DataComponent { const routes: Routes = [ // 懒加载独立组件 { - path: 'profile', - loadComponent: () => import('./profile.component') - .then(m => m.ProfileComponent) + path: "profile", + loadComponent: () => + import("./profile.component").then(m => m.ProfileComponent), }, // 懒加载子路由配置 { - path: 'admin', - loadChildren: () => import('./admin/admin.routes') - .then(m => m.ADMIN_ROUTES) - } + path: "admin", + loadChildren: () => + import("./admin/admin.routes").then(m => m.ADMIN_ROUTES), + }, ]; // admin/admin.routes.ts export const ADMIN_ROUTES: Routes = [ - { path: '', component: AdminDashboardComponent }, - { path: 'users', component: AdminUsersComponent }, + { path: "", component: AdminDashboardComponent }, + { path: "users", component: AdminUsersComponent }, ]; ``` @@ -641,11 +653,11 @@ ng generate @angular/core:standalone ## 升级路径总结 -| 版本 | TypeScript | Node.js | 关键升级动作 | -|------|-----------|---------|-------------| -| 13 | 4.4 | 12.20+ / 14.15+ | 移除 View Engine 相关代码 | -| 14 | 4.6-4.7 | 14.15+ / 16.10+ | 迁移到 Typed Forms(或先用 Untyped* 过渡)| -| 15 | 4.8 | 14.20+ / 16.13+ | 迁移到 Standalone + provide* 函数 | +| 版本 | TypeScript | Node.js | 关键升级动作 | +| ---- | ---------- | --------------- | ------------------------------------------- | +| 13 | 4.4 | 12.20+ / 14.15+ | 移除 View Engine 相关代码 | +| 14 | 4.6-4.7 | 14.15+ / 16.10+ | 迁移到 Typed Forms(或先用 Untyped\* 过渡) | +| 15 | 4.8 | 14.20+ / 16.13+ | 迁移到 Standalone + provide\* 函数 | 每次升级建议使用 Angular 官方的升级指南:[https://angular.dev/update-guide](https://angular.dev/update-guide) diff --git a/src/content/blog/angular-features-16-to-18.md b/src/content/blog/frontend/angular/angular-features-16-to-18.md similarity index 73% rename from src/content/blog/angular-features-16-to-18.md rename to src/content/blog/frontend/angular/angular-features-16-to-18.md index 91ef0947..e9c730c3 100644 --- a/src/content/blog/angular-features-16-to-18.md +++ b/src/content/blog/frontend/angular/angular-features-16-to-18.md @@ -9,6 +9,7 @@ description: Angular 16-18 是框架的变革期——Signals 响应式系统诞 --- > 本文是 Angular 升级系列教程的第二篇,覆盖 Angular 16、17、18 三个版本。系列文章: +> > - [(一)Angular 13-15 新特性详解](/posts/angular-features-13-to-15) > - **(二)Angular 16-18 新特性详解**(本文) > - [(三)Angular 19-21 新特性详解](/posts/angular-features-19-to-21) @@ -24,17 +25,17 @@ Angular 16 是一个划时代的版本——**Signals 响应式系统**的诞生 Signals 是 Angular 全新的细粒度响应式系统,目标是最终取代 Zone.js: ```typescript -import { Component, signal, computed, effect } from '@angular/core'; +import { Component, signal, computed, effect } from "@angular/core"; @Component({ - selector: 'app-counter', + selector: "app-counter", template: `

      计数: {{ count() }}

      双倍: {{ doubleCount() }}

      - ` + `, }) export class CounterComponent { // signal — 可变的响应式值 @@ -70,19 +71,19 @@ Signal 的核心 API: ```typescript // 创建 -const name = signal('Angular'); // WritableSignal -const version = signal(16); // WritableSignal -const config = signal({ theme: 'dark' }); // WritableSignal +const name = signal("Angular"); // WritableSignal +const version = signal(16); // WritableSignal +const config = signal({ theme: "dark" }); // WritableSignal // 读取 — 调用信号函数 -console.log(name()); // 'Angular' +console.log(name()); // 'Angular' // 写入 -name.set('Angular 16'); +name.set("Angular 16"); version.update(v => v + 1); // mutate(用于对象/数组的就地修改,后续版本已移除,改用 update) -config.update(c => ({ ...c, theme: 'light' })); +config.update(c => ({ ...c, theme: "light" })); // computed — 只读派生信号 const greeting = computed(() => `Hello ${name()} v${version()}`); @@ -98,37 +99,38 @@ effect(() => { `@angular/core/rxjs-interop` 提供了 RxJS 和 Signals 之间的桥梁: ```typescript -import { toSignal, toObservable } from '@angular/core/rxjs-interop'; -import { interval } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { toSignal, toObservable } from "@angular/core/rxjs-interop"; +import { interval } from "rxjs"; +import { map } from "rxjs/operators"; @Component({ - selector: 'app-interop', + selector: "app-interop", template: `

      时间: {{ timer() }}

      搜索词: {{ searchTerm() }}

      - ` + `, }) export class InteropComponent { // Observable → Signal - timer = toSignal( - interval(1000).pipe(map(n => `${n}秒`)), - { initialValue: '0秒' } - ); + timer = toSignal(interval(1000).pipe(map(n => `${n}秒`)), { + initialValue: "0秒", + }); // Signal → Observable - searchTerm = signal(''); + searchTerm = signal(""); searchTerm$ = toObservable(this.searchTerm); constructor() { // 可以用 RxJS 操作符处理信号变化 - this.searchTerm$.pipe( - debounceTime(300), - distinctUntilChanged(), - switchMap(term => this.searchService.search(term)) - ).subscribe(results => { - this.results.set(results); - }); + this.searchTerm$ + .pipe( + debounceTime(300), + distinctUntilChanged(), + switchMap(term => this.searchService.search(term)) + ) + .subscribe(results => { + this.results.set(results); + }); } results = signal([]); @@ -141,13 +143,13 @@ Angular 16 引入了非破坏性水合——服务端渲染的 DOM 不再被销 ```typescript // main.ts -import { bootstrapApplication } from '@angular/platform-browser'; -import { provideClientHydration } from '@angular/platform-browser'; +import { bootstrapApplication } from "@angular/platform-browser"; +import { provideClientHydration } from "@angular/platform-browser"; bootstrapApplication(AppComponent, { providers: [ - provideClientHydration() // 启用非破坏性水合 - ] + provideClientHydration(), // 启用非破坏性水合 + ], }); ``` @@ -159,8 +161,8 @@ bootstrapApplication(AppComponent, { ```typescript @Component({ - selector: 'app-user-card', - template: '

      {{ name }}

      ' + selector: "app-user-card", + template: "

      {{ name }}

      ", }) export class UserCardComponent { @Input({ required: true }) name!: string; // 必填 @@ -178,23 +180,21 @@ export class UserCardComponent { ```typescript // app.config.ts -provideRouter(routes, withComponentInputBinding()) +provideRouter(routes, withComponentInputBinding()); // 路由配置 -const routes: Routes = [ - { path: 'user/:id', component: UserComponent } -]; +const routes: Routes = [{ path: "user/:id", component: UserComponent }]; // 组件 — 自动接收路由参数 @Component({ - selector: 'app-user', - template: '

      用户 ID: {{ id }}

      ' + selector: "app-user", + template: "

      用户 ID: {{ id }}

      ", }) export class UserComponent { @Input() id!: string; // 自动从路由参数 :id 绑定 // 也支持 query params 和 data - @Input() tab?: string; // ?tab=profile → this.tab = 'profile' + @Input() tab?: string; // ?tab=profile → this.tab = 'profile' } ``` @@ -253,7 +253,7 @@ Angular 17 是一次**品牌重塑**——新 Logo、新文档站(angular.dev ```typescript @Component({ - selector: 'app-product-list', + selector: "app-product-list", template: ` @if (products().length > 0) { @@ -270,9 +270,15 @@ Angular 17 是一次**品牌重塑**——新 Logo、新文档站(angular.dev

      {{ product.name }}

      ¥{{ product.price }}

      第 {{ $index + 1 }} 件,共 {{ $count }} 件 - @if ($first) { 首件 } - @if ($last) { 末件 } - @if ($even) { 偶数行 } + @if ($first) { + 首件 + } + @if ($last) { + 末件 + } + @if ($even) { + 偶数行 + } } @empty {

      没有找到商品

      @@ -280,38 +286,38 @@ Angular 17 是一次**品牌重塑**——新 Logo、新文档站(angular.dev @switch (status()) { - @case ('active') { + @case ("active") { 活跃 } - @case ('inactive') { + @case ("inactive") { 未激活 } - @case ('banned') { + @case ("banned") { 已封禁 } @default { 未知 } } - ` + `, }) export class ProductListComponent { products = signal([]); isLoading = signal(false); - status = signal('active'); + status = signal("active"); } ``` **`@for` 中可用的隐式变量:** -| 变量 | 类型 | 说明 | -|------|------|------| -| `$index` | number | 当前索引 | -| `$first` | boolean | 是否第一个 | -| `$last` | boolean | 是否最后一个 | -| `$even` | boolean | 是否偶数索引 | -| `$odd` | boolean | 是否奇数索引 | -| `$count` | number | 总数量 | +| 变量 | 类型 | 说明 | +| -------- | ------- | ------------ | +| `$index` | number | 当前索引 | +| `$first` | boolean | 是否第一个 | +| `$last` | boolean | 是否最后一个 | +| `$even` | boolean | 是否偶数索引 | +| `$odd` | boolean | 是否奇数索引 | +| `$count` | number | 总数量 | **自动迁移命令:** @@ -326,7 +332,7 @@ ng generate @angular/core:control-flow ```typescript @Component({ - selector: 'app-dashboard', + selector: "app-dashboard", template: `

      仪表盘

      @@ -382,7 +388,7 @@ ng generate @angular/core:control-flow } @placeholder { } - ` + `, }) export class DashboardComponent { showAdvanced = signal(false); @@ -391,15 +397,15 @@ export class DashboardComponent { `@defer` 的触发条件汇总: -| 触发条件 | 说明 | -|---------|------| -| `on idle` | 浏览器空闲时 | -| `on viewport` | 进入视口时 | -| `on interaction` | 用户交互(点击、聚焦)时 | -| `on hover` | 鼠标悬停时 | -| `on immediate` | 立即加载(但仍然是懒加载的 chunk)| -| `on timer(Xs)` | X 秒后加载 | -| `when condition` | 条件为 true 时 | +| 触发条件 | 说明 | +| ---------------- | ---------------------------------- | +| `on idle` | 浏览器空闲时 | +| `on viewport` | 进入视口时 | +| `on interaction` | 用户交互(点击、聚焦)时 | +| `on hover` | 鼠标悬停时 | +| `on immediate` | 立即加载(但仍然是懒加载的 chunk) | +| `on timer(Xs)` | X 秒后加载 | +| `when condition` | 条件为 true 时 | ### 3. esbuild + Vite 成为新项目的默认构建系统 @@ -414,7 +420,7 @@ Angular 17 新项目默认使用基于 esbuild 的 `application` 构建器: "options": { "outputPath": "dist/my-app", "index": "src/index.html", - "browser": "src/main.ts", // 注意:不再是 "main" + "browser": "src/main.ts", // 注意:不再是 "main" "tsConfig": "tsconfig.app.json" } } @@ -423,6 +429,7 @@ Angular 17 新项目默认使用基于 esbuild 的 `application` 构建器: ``` 对比旧构建器的性能提升: + - 构建速度提升 67%+ - `ng serve` 热更新几乎瞬时 - 包体积略有减小 @@ -458,6 +465,7 @@ ng generate pipe currency-format ### 5. 新文档站 angular.dev Angular 团队发布了全新的文档网站 [angular.dev](https://angular.dev),包含: + - 交互式教程 - 全新的 API 文档 - 内置 Playground @@ -467,24 +475,26 @@ Angular 团队发布了全新的文档网站 [angular.dev](https://angular.dev) 信号版的 Input/Output/ViewChild 等 API 开始预览: ```typescript -import { Component, input, output, model } from '@angular/core'; +import { Component, input, output, model } from "@angular/core"; @Component({ - selector: 'app-slider', + selector: "app-slider", template: ` - + (input)="onInput($event)" + /> {{ value() }} - ` + `, }) export class SliderComponent { // signal input min = input(0); max = input(100); - value = model(50); // 双向绑定的 signal + value = model(50); // 双向绑定的 signal // signal output valueChange = output(); @@ -507,8 +517,8 @@ export class SliderComponent { ```typescript bootstrapApplication(AppComponent, { providers: [ - provideClientHydration(), // 正式稳定 ✅ - ] + provideClientHydration(), // 正式稳定 ✅ + ], }); ``` @@ -561,7 +571,7 @@ ng generate @angular/core:control-flow } @placeholder {
      活动记录
      } - ` + `, }) export class HomeComponent { user = signal(null); @@ -574,49 +584,55 @@ Angular 18 引入了实验性的无 Zone.js 变更检测——这是 Angular 性 ```typescript // main.ts — 启用 Zoneless -import { provideExperimentalZonelessChangeDetection } from '@angular/core'; +import { provideExperimentalZonelessChangeDetection } from "@angular/core"; bootstrapApplication(AppComponent, { - providers: [ - provideExperimentalZonelessChangeDetection() - ] + providers: [provideExperimentalZonelessChangeDetection()], }); // 使用 Zoneless 后,变更检测完全由 Signals 驱动 @Component({ - selector: 'app-todo', + selector: "app-todo", template: ` @for (todo of todos(); track todo.id) {
      - + (change)="toggleTodo(todo.id)" + /> {{ todo.text }}
      } - ` + `, }) export class TodoComponent { todos = signal([]); addTodo(text: string) { - this.todos.update(list => [...list, { - id: Date.now(), text, done: false - }]); + this.todos.update(list => [ + ...list, + { + id: Date.now(), + text, + done: false, + }, + ]); } toggleTodo(id: number) { this.todos.update(list => - list.map(t => t.id === id ? { ...t, done: !t.done } : t) + list.map(t => (t.id === id ? { ...t, done: !t.done } : t)) ); } } ``` 去掉 Zone.js 的好处: + - 减少约 13KB(gzipped)的包体积 - 不再有不必要的变更检测循环 - 更好的性能和可预测的行为 @@ -627,14 +643,21 @@ export class TodoComponent { Angular 18 将大部分信号 API 标记为稳定或接近稳定: ```typescript -import { Component, input, output, model, viewChild, viewChildren } from '@angular/core'; +import { + Component, + input, + output, + model, + viewChild, + viewChildren, +} from "@angular/core"; @Component({ - selector: 'app-accordion', + selector: "app-accordion", template: `
      @if (isOpen()) {
      @@ -642,7 +665,7 @@ import { Component, input, output, model, viewChild, viewChildren } from '@angul
      }
      - ` + `, }) export class AccordionComponent { // 稳定的 signal input @@ -656,8 +679,8 @@ export class AccordionComponent { toggled = output(); // signal queries - toggleBtn = viewChild.required('toggleBtn'); - contentSections = viewChildren('content'); + toggleBtn = viewChild.required("toggleBtn"); + contentSections = viewChildren("content"); constructor() { // 用 effect 响应 input 变化 @@ -680,14 +703,17 @@ export class AccordionComponent { SSR 水合正式稳定,新增事件回放功能(开发者预览版): ```typescript -import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; +import { + provideClientHydration, + withEventReplay, +} from "@angular/platform-browser"; bootstrapApplication(AppComponent, { providers: [ provideClientHydration( withEventReplay() // 回放水合前用户的交互事件 - ) - ] + ), + ], }); ``` @@ -730,24 +756,24 @@ $theme: mat.define-theme(( const permissions = new FormRecord>({}); // 动态添加字段 -permissions.addControl('read', new FormControl(true)); -permissions.addControl('write', new FormControl(false)); -permissions.addControl('admin', new FormControl(false)); +permissions.addControl("read", new FormControl(true)); +permissions.addControl("write", new FormControl(false)); +permissions.addControl("admin", new FormControl(false)); // 统一的表单事件 — events 属性 -const nameControl = new FormControl(''); +const nameControl = new FormControl(""); nameControl.events.subscribe(event => { if (event instanceof TouchedChangeEvent) { - console.log('touched 状态变化:', event.touched); + console.log("touched 状态变化:", event.touched); } if (event instanceof PristineChangeEvent) { - console.log('pristine 状态变化:', event.pristine); + console.log("pristine 状态变化:", event.pristine); } if (event instanceof StatusChangeEvent) { - console.log('验证状态变化:', event.status); + console.log("验证状态变化:", event.status); } if (event instanceof ValueChangeEvent) { - console.log('值变化:', event.value); + console.log("值变化:", event.value); } }); ``` @@ -759,15 +785,15 @@ nameControl.events.subscribe(event => { ```typescript const routes: Routes = [ { - path: 'old-page', + path: "old-page", redirectTo: ({ queryParams }) => { - const id = queryParams['id']; + const id = queryParams["id"]; if (id) { return `/new-page/${id}`; } - return '/new-page'; - } - } + return "/new-page"; + }, + }, ]; ``` @@ -783,17 +809,19 @@ const routes: Routes = [ ```typescript // ng-content fallback @Component({ - selector: 'app-card', + selector: "app-card", template: `
      -

      默认标题

      +

      默认标题

      +
      -

      暂无内容

      +

      暂无内容

      +
      - ` + `, }) export class CardComponent {} ``` @@ -808,22 +836,22 @@ ng update @angular/core@18 @angular/cli@18 ## 版本升级路径总结 -| 版本 | TypeScript | Node.js | 关键里程碑 | -|------|-----------|---------|-----------| -| 16 | 4.9 - 5.0 | 16.14+ / 18.10+ | **Signals 诞生**(开发者预览版)| -| 17 | 5.2 | 18.13+ | 新控制流 + @defer + esbuild 默认 | -| 18 | 5.4 | 18.19+ | 控制流稳定 + Zoneless 实验 + Material 3 | +| 版本 | TypeScript | Node.js | 关键里程碑 | +| ---- | ---------- | --------------- | --------------------------------------- | +| 16 | 4.9 - 5.0 | 16.14+ / 18.10+ | **Signals 诞生**(开发者预览版) | +| 17 | 5.2 | 18.13+ | 新控制流 + @defer + esbuild 默认 | +| 18 | 5.4 | 18.19+ | 控制流稳定 + Zoneless 实验 + Material 3 | ### Signals 特性状态演进 -| 特性 | Angular 16 | Angular 17 | Angular 18 | -|------|-----------|-----------|-----------| -| signal / computed / effect | 🧪 预览 | ✅ 稳定 | ✅ 稳定 | -| signal input() | — | 🧪 预览 | ✅ 稳定 | -| signal output() | — | 🧪 预览 | ✅ 稳定 | -| signal model() | — | 🧪 预览 | ✅ 稳定 | -| viewChild / viewChildren | — | 🧪 预览 | ✅ 稳定 | -| Zoneless | — | — | 🧪 实验 | +| 特性 | Angular 16 | Angular 17 | Angular 18 | +| -------------------------- | ---------- | ---------- | ---------- | +| signal / computed / effect | 🧪 预览 | ✅ 稳定 | ✅ 稳定 | +| signal input() | — | 🧪 预览 | ✅ 稳定 | +| signal output() | — | 🧪 预览 | ✅ 稳定 | +| signal model() | — | 🧪 预览 | ✅ 稳定 | +| viewChild / viewChildren | — | 🧪 预览 | ✅ 稳定 | +| Zoneless | — | — | 🧪 实验 | ```bash # 逐版本升级(推荐) diff --git a/src/content/blog/angular-features-19-to-21.md b/src/content/blog/frontend/angular/angular-features-19-to-21.md similarity index 71% rename from src/content/blog/angular-features-19-to-21.md rename to src/content/blog/frontend/angular/angular-features-19-to-21.md index e1ab490c..56c82b14 100644 --- a/src/content/blog/angular-features-19-to-21.md +++ b/src/content/blog/frontend/angular/angular-features-19-to-21.md @@ -9,6 +9,7 @@ description: Angular 19-22 标志着框架的成熟期--Signals 全面稳定、Z --- > 本文是 Angular 升级系列教程的第三篇,覆盖 Angular 19、20、21、22 四个版本。系列文章: +> > - [(一)Angular 13-15 新特性详解](/posts/angular-features-13-to-15) > - [(二)Angular 16-18 新特性详解](/posts/angular-features-16-to-18) > - **(三)Angular 19-22 新特性详解**(本文) @@ -25,13 +26,21 @@ Angular 19 是 Signals 响应式系统的**收官之作**--几乎所有核心 Si ```typescript import { - Component, signal, computed, effect, - input, output, model, - viewChild, viewChildren, contentChild, contentChildren -} from '@angular/core'; + Component, + signal, + computed, + effect, + input, + output, + model, + viewChild, + viewChildren, + contentChild, + contentChildren, +} from "@angular/core"; @Component({ - selector: 'app-task-board', + selector: "app-task-board", template: `

      {{ title() }} ({{ activeCount() }}/{{ tasks().length }})

      @@ -40,9 +49,11 @@ import { @for (task of tasks(); track task.id) {
      - + (change)="toggleTask(task.id)" + /> {{ task.text }}
      } @empty { @@ -52,26 +63,24 @@ import {
      - ` + `, }) export class TaskBoardComponent { // signal input - 稳定 ✅ - title = input('任务看板'); + title = input("任务看板"); // model - 双向绑定信号,稳定 ✅ tasks = model([]); // computed - 稳定 ✅ - activeCount = computed(() => - this.tasks().filter(t => !t.done).length - ); + activeCount = computed(() => this.tasks().filter(t => !t.done).length); // output - 稳定 ✅ taskAdded = output(); taskToggled = output(); // viewChild - 稳定 ✅ - footer = viewChild('footer'); + footer = viewChild("footer"); constructor() { // effect - 稳定 ✅ @@ -89,7 +98,7 @@ export class TaskBoardComponent { toggleTask(id: number) { this.tasks.update(list => - list.map(t => t.id === id ? { ...t, done: !t.done } : t) + list.map(t => (t.id === id ? { ...t, done: !t.done } : t)) ); this.taskToggled.emit(id); } @@ -101,7 +110,7 @@ export class TaskBoardComponent { 全新的异步数据获取原语,与 Signals 深度集成,替代手动管理 loading/error 状态: ```typescript -import { Component, signal, resource } from '@angular/core'; +import { Component, signal, resource } from "@angular/core"; interface User { id: number; @@ -111,7 +120,7 @@ interface User { } @Component({ - selector: 'app-user-profile', + selector: "app-user-profile", template: `
      @if (userResource.isLoading()) { @@ -132,13 +141,14 @@ interface User { }
      - ` + `, }) export class UserProfileComponent { userId = signal(1); @@ -151,41 +161,43 @@ export class UserProfileComponent { // loader: 异步加载函数 loader: async ({ request, abortSignal }) => { - const res = await fetch( - `https://api.example.com/users/${request.id}`, - { signal: abortSignal } - ); + const res = await fetch(`https://api.example.com/users/${request.id}`, { + signal: abortSignal, + }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return (await res.json()) as User; - } + }, }); } ``` `resource` 的状态属性: -| 属性 | 类型 | 说明 | -|------|------|------| -| `value()` | `T \| undefined` | 加载成功的数据 | -| `error()` | `unknown` | 错误信息 | -| `isLoading()` | `boolean` | 是否正在加载 | -| `status()` | `ResourceStatus` | 状态枚举:idle / loading / resolved / error | -| `reload()` | `void` | 手动重新加载 | +| 属性 | 类型 | 说明 | +| ------------- | ---------------- | ------------------------------------------- | +| `value()` | `T \| undefined` | 加载成功的数据 | +| `error()` | `unknown` | 错误信息 | +| `isLoading()` | `boolean` | 是否正在加载 | +| `status()` | `ResourceStatus` | 状态枚举:idle / loading / resolved / error | +| `reload()` | `void` | 手动重新加载 | ### 3. `linkedSignal`(开发者预览版)🧪 创建一个可写信号,当源信号变化时自动重置为计算值: ```typescript -import { Component, signal, computed, linkedSignal } from '@angular/core'; +import { Component, signal, computed, linkedSignal } from "@angular/core"; @Component({ - selector: 'app-product-filter', + selector: "app-product-filter", template: `

      商品筛选

      - @for (cat of categories; track cat.value) { } @@ -201,34 +213,36 @@ import { Component, signal, computed, linkedSignal } from '@angular/core';

      分类: {{ category() }} | 排序: {{ sortBy() }} | 页码: {{ page() }}

      - ` + `, }) export class ProductFilterComponent { categories = [ - { value: 'electronics', label: '电子产品' }, - { value: 'books', label: '图书' }, - { value: 'clothing', label: '服装' }, + { value: "electronics", label: "电子产品" }, + { value: "books", label: "图书" }, + { value: "clothing", label: "服装" }, ]; // 源信号 - category = signal('electronics'); + category = signal("electronics"); // linkedSignal: 分类变化时,排序重置为 'price' sortBy = linkedSignal({ source: this.category, - computation: () => 'price' + computation: () => "price", }); // linkedSignal: 分类或排序变化时,页码重置为 1 page = linkedSignal({ source: () => ({ cat: this.category(), sort: this.sortBy() }), - computation: () => 1 + computation: () => 1, }); } ``` @@ -243,7 +257,7 @@ const fullName = computed(() => `${firstName()} ${lastName()}`); // linkedSignal - 可写,但源变化时会重置 const selectedCity = linkedSignal({ source: country, - computation: (country) => getDefaultCity(country) + computation: country => getDefaultCity(country), }); // selectedCity 可以手动 set/update // 但当 country 变化时,会自动重置为默认城市 @@ -256,26 +270,26 @@ Angular 19 中 `standalone: true` 成为所有组件、指令、管道的默认 ```typescript // Angular 19 - 不需要写 standalone: true @Component({ - selector: 'app-hello', + selector: "app-hello", imports: [CommonModule], - template: '

      Hello

      ' + template: "

      Hello

      ", }) export class HelloComponent {} // 等价于之前的 @Component({ - standalone: true, // 不再需要 - selector: 'app-hello', + standalone: true, // 不再需要 + selector: "app-hello", imports: [CommonModule], - template: '

      Hello

      ' + template: "

      Hello

      ", }) export class HelloComponent {} // 如果需要非独立组件,必须显式声明 @Component({ - standalone: false, // 显式声明 - selector: 'app-legacy', - template: '

      Legacy

      ' + standalone: false, // 显式声明 + selector: "app-legacy", + template: "

      Legacy

      ", }) export class LegacyComponent {} ``` @@ -285,27 +299,27 @@ export class LegacyComponent {} 可以在路由配置中为每个路由指定渲染策略: ```typescript -import { Routes } from '@angular/router'; -import { RenderMode } from '@angular/ssr'; +import { Routes } from "@angular/router"; +import { RenderMode } from "@angular/ssr"; export const routes: Routes = [ { - path: '', + path: "", component: HomeComponent, - data: { renderMode: RenderMode.Server } // SSR + data: { renderMode: RenderMode.Server }, // SSR }, { - path: 'about', + path: "about", component: AboutComponent, - data: { renderMode: RenderMode.Prerender } // SSG(静态生成) + data: { renderMode: RenderMode.Prerender }, // SSG(静态生成) }, { - path: 'dashboard', + path: "dashboard", component: DashboardComponent, - data: { renderMode: RenderMode.Client } // 纯 CSR + data: { renderMode: RenderMode.Client }, // 纯 CSR }, { - path: 'blog/:slug', + path: "blog/:slug", component: BlogPostComponent, data: { renderMode: RenderMode.Prerender }, resolve: { @@ -313,9 +327,9 @@ export const routes: Routes = [ renderParams: async () => { const slugs = await getAllBlogSlugs(); return slugs.map(slug => ({ slug })); - } - } - } + }, + }, + }, ]; ``` @@ -325,7 +339,7 @@ export const routes: Routes = [ ```typescript @Component({ - selector: 'app-page', + selector: "app-page", template: `
      页面头部 - 立即水合
      @@ -362,7 +376,7 @@ export const routes: Routes = [ @defer (hydrate on timer(5s)) { } - ` + `, }) export class PageComponent {} ``` @@ -405,18 +419,19 @@ Angular 20 是**Zoneless 时代的开端**--无 Zone.js 变更检测正式稳定 这是 Angular 历史上最重要的性能里程碑之一: ```typescript -import { bootstrapApplication } from '@angular/platform-browser'; -import { provideZonelessChangeDetection } from '@angular/core'; +import { bootstrapApplication } from "@angular/platform-browser"; +import { provideZonelessChangeDetection } from "@angular/core"; // 注意:不再有 "Experimental" 前缀 bootstrapApplication(AppComponent, { providers: [ - provideZonelessChangeDetection() // 正式稳定 ✅ - ] + provideZonelessChangeDetection(), // 正式稳定 ✅ + ], }); ``` Zoneless 的影响: + - 新项目默认不再包含 `zone.js` - 包体积减少约 13KB(gzipped) - 变更检测完全由 Signals 驱动,更精确、更高效 @@ -426,7 +441,7 @@ Zoneless 的影响: ```typescript // Zoneless 下的最佳实践 - 全面使用 Signals @Component({ - selector: 'app-search', + selector: "app-search", template: ` @@ -441,10 +456,10 @@ Zoneless 的影响:

      未找到结果

      } } - ` + `, }) export class SearchComponent { - query = signal(''); + query = signal(""); results = resource({ request: () => this.query(), @@ -452,7 +467,7 @@ export class SearchComponent { if (!q) return []; const res = await fetch(`/api/search?q=${q}`, { signal: abortSignal }); return res.json(); - } + }, }); } ``` @@ -462,8 +477,8 @@ export class SearchComponent { 基于 `HttpClient` 的响应式数据获取,是 `resource()` 的 HTTP 专用版本: ```typescript -import { Component, signal } from '@angular/core'; -import { httpResource } from '@angular/common/http'; +import { Component, signal } from "@angular/core"; +import { httpResource } from "@angular/common/http"; interface Todo { id: number; @@ -472,7 +487,7 @@ interface Todo { } @Component({ - selector: 'app-todos', + selector: "app-todos", template: `

      待办事项

      @@ -495,14 +510,15 @@ interface Todo { - ` + `, }) export class TodosComponent { userId = signal(1); // httpResource - 自动跟踪信号依赖 todosResource = httpResource({ - url: () => `https://jsonplaceholder.typicode.com/todos?userId=${this.userId()}` + url: () => + `https://jsonplaceholder.typicode.com/todos?userId=${this.userId()}`, }); } ``` @@ -516,15 +532,15 @@ const data = resource({ loader: async ({ request, abortSignal }) => { const res = await fetch(`/api/${request.id}`, { signal: abortSignal }); return res.json(); - } + }, }); // httpResource - 专为 HTTP 设计,自动使用 HttpClient // 支持拦截器、类型安全、请求配置 const data = httpResource({ url: () => `/api/${this.id()}`, - method: 'GET', - headers: () => ({ 'Authorization': `Bearer ${this.token()}` }), + method: "GET", + headers: () => ({ Authorization: `Bearer ${this.token()}` }), params: () => ({ page: this.page().toString() }), }); ``` @@ -534,15 +550,15 @@ const data = httpResource({ 从开发者预览版升级为稳定版: ```typescript -import { signal, linkedSignal, computed } from '@angular/core'; +import { signal, linkedSignal, computed } from "@angular/core"; // 地址表单示例 -const country = signal('CN'); +const country = signal("CN"); const cityMap: Record = { - 'CN': ['北京', '上海', '深圳', '广州'], - 'US': ['New York', 'Los Angeles', 'Chicago'], - 'JP': ['东京', '大阪', '京都'], + CN: ["北京", "上海", "深圳", "广州"], + US: ["New York", "Los Angeles", "Chicago"], + JP: ["东京", "大阪", "京都"], }; const availableCities = computed(() => cityMap[country()] || []); @@ -551,13 +567,13 @@ const availableCities = computed(() => cityMap[country()] || []); // 用户也可以手动选择其他城市 const city = linkedSignal({ source: country, - computation: (c) => cityMap[c]?.[0] || '' + computation: c => cityMap[c]?.[0] || "", }); console.log(city()); // '北京' -city.set('上海'); // 手动修改 +city.set("上海"); // 手动修改 console.log(city()); // '上海' -country.set('US'); // 切换国家 +country.set("US"); // 切换国家 console.log(city()); // 'New York' - 自动重置 ``` @@ -569,11 +585,11 @@ const articleResource = resource({ request: () => ({ slug: this.slug() }), loader: async ({ request, abortSignal }) => { const res = await fetch(`/api/articles/${request.slug}`, { - signal: abortSignal + signal: abortSignal, }); - if (!res.ok) throw new Error('文章不存在'); + if (!res.ok) throw new Error("文章不存在"); return res.json() as Promise
      ; - } + }, }); // 在模板中使用 @@ -592,8 +608,8 @@ Angular 20 中 `effect()` 的行为更加可预测: // effect 现在默认在组件上下文中运行 // 组件销毁时自动清理 @Component({ - selector: 'app-logger', - template: '...' + selector: "app-logger", + template: "...", }) export class LoggerComponent { count = signal(0); @@ -605,7 +621,7 @@ export class LoggerComponent { }); // 带清理函数的 effect - effect((onCleanup) => { + effect(onCleanup => { const id = setInterval(() => { this.count.update(v => v + 1); }, 1000); @@ -621,14 +637,17 @@ export class LoggerComponent { 增量水合在 Angular 20 中得到进一步完善: ```typescript -import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser'; +import { + provideClientHydration, + withIncrementalHydration, +} from "@angular/platform-browser"; bootstrapApplication(AppComponent, { providers: [ provideClientHydration( - withIncrementalHydration() // 启用增量水合 - ) - ] + withIncrementalHydration() // 启用增量水合 + ), + ], }); ``` @@ -659,7 +678,7 @@ Angular 21 带来了 **Signal Forms**、**Angular Aria 无障碍组件**、**Vit ```typescript // Signal Forms 示例(实验版) -import { signal } from '@angular/core'; +import { signal } from "@angular/core"; // Signal Forms 使用信号驱动表单状态 // 提供更直观的表单创建体验 @@ -667,6 +686,7 @@ import { signal } from '@angular/core'; ``` Signal Forms 包含的核心能力: + - `FieldState.getError()` 方法--直接获取字段的特定验证错误 - `reloadValidation` 手动触发异步验证 - `validateAsync` 和 `validateHttp` 的 debounce 选项--减少不必要的异步验证请求 @@ -694,11 +714,11 @@ ng generate @angular/core:karma-to-vitest 从开发者预览版升级为稳定版,可以放心在生产环境中使用: ```typescript -import { httpResource } from '@angular/common/http'; +import { httpResource } from "@angular/common/http"; // httpResource 现在是稳定 API ✅ const todosResource = httpResource({ - url: () => `https://api.example.com/todos?userId=${this.userId()}` + url: () => `https://api.example.com/todos?userId=${this.userId()}`, }); ``` @@ -746,17 +766,17 @@ Angular 22 目前处于 next 阶段(22.0.0-next.8,2026年4月15日),以 ```typescript // Angular 22 - 默认 OnPush @Component({ - selector: 'app-example', - template: '...' + selector: "app-example", + template: "...", }) export class ExampleComponent {} // changeDetection 默认为 OnPush // 如需旧行为 @Component({ - selector: 'app-eager', + selector: "app-eager", changeDetection: ChangeDetectionStrategy.Eager, - template: '...' + template: "...", }) export class EagerComponent {} ``` @@ -773,7 +793,7 @@ const ref = this.container.createComponent(factory); // ✅ Angular 22 的写法 const ref = this.container.createComponent(MyComponent); // 或使用独立函数 -import { createComponent } from '@angular/core'; +import { createComponent } from "@angular/core"; const ref = createComponent(MyComponent, { environmentInjector }); ``` @@ -792,6 +812,7 @@ Hammer.js 触摸手势集成被移除,开发者需要自行实现触摸手势 ### 6. AI 调试工具 Angular 22 引入了 AI 驱动的调试工具: + - Angular DI graph in-page AI tool--可视化依赖注入图 - AI runtime debugging tools--智能运行时调试 - 增强的性能分析(profiling)与文档 URL 关联 @@ -803,6 +824,7 @@ Angular 22 引入了 AI 驱动的调试工具: ### 8. Signal Forms 增强 🧪+ 在 Angular 21 实验版的基础上持续改进: + - CVA 互操作改进 - `parseErrors` 支持旧版 NG_VALIDATORS - 信号表单的 `controlValue` 改进 @@ -827,46 +849,46 @@ Angular 22 引入了 AI 驱动的调试工具: ### Signals 生态演进 -| 特性 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -|------|----|----|----|----|----|----|----| -| signal / computed | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| effect | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| input() | - | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | -| output() | - | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | -| model() | - | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | -| viewChild/viewChildren | - | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | -| linkedSignal | - | - | - | 🧪 | ✅ | ✅ | ✅ | -| resource() | - | - | - | 🧪 | ✅ | ✅ | ✅ | -| httpResource | - | - | - | - | 🧪 | ✅ | ✅ | -| Signal Forms | - | - | - | - | - | 🧪 | 🧪+ | +| 特性 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | +| ---------------------- | --- | --- | --- | --- | --- | --- | --- | +| signal / computed | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| effect | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| input() | - | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | +| output() | - | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | +| model() | - | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | +| viewChild/viewChildren | - | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | +| linkedSignal | - | - | - | 🧪 | ✅ | ✅ | ✅ | +| resource() | - | - | - | 🧪 | ✅ | ✅ | ✅ | +| httpResource | - | - | - | - | 🧪 | ✅ | ✅ | +| Signal Forms | - | - | - | - | - | 🧪 | 🧪+ | ### 构建与渲染演进 -| 特性 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -|------|----|----|----|----|-----|----|----| -| esbuild 构建器 | 🧪 | ✅ 默认 | ✅ | ✅ | ✅ | ✅ | ✅ | -| 新控制流 @if/@for | - | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | -| @defer | - | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | -| SSR 水合 | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| 增量水合 | - | - | - | 🧪 | 🧪+ | ✅ | ✅ 默认 | -| Zoneless | - | - | 🧪 | 🧪 | ✅ | ✅ 默认 | ✅ | -| Standalone 默认 | - | 默认生成 | 默认生成 | 默认值 true | ✅ | ✅ | ✅ | -| 默认 OnPush | - | - | - | - | - | - | ✅ | +| 特性 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | +| ----------------- | --- | -------- | -------- | ----------- | --- | ------- | ------- | +| esbuild 构建器 | 🧪 | ✅ 默认 | ✅ | ✅ | ✅ | ✅ | ✅ | +| 新控制流 @if/@for | - | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | +| @defer | - | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | +| SSR 水合 | 🧪 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| 增量水合 | - | - | - | 🧪 | 🧪+ | ✅ | ✅ 默认 | +| Zoneless | - | - | 🧪 | 🧪 | ✅ | ✅ 默认 | ✅ | +| Standalone 默认 | - | 默认生成 | 默认生成 | 默认值 true | ✅ | ✅ | ✅ | +| 默认 OnPush | - | - | - | - | - | - | ✅ | ### 版本依赖要求 -| Angular | TypeScript | Node.js | RxJS | -|---------|-----------|---------|------| -| 13 | 4.4 | 12.20+ / 14.15+ | 7.4+ | -| 14 | 4.6 - 4.7 | 14.15+ / 16.10+ | 7.4+ | -| 15 | 4.8 | 14.20+ / 16.13+ | 7.4+ | -| 16 | 4.9 - 5.0 | 16.14+ / 18.10+ | 7.4+ | -| 17 | 5.2 | 18.13+ | 7.4+ | -| 18 | 5.4 | 18.19+ | 7.4+ | -| 19 | 5.5 - 5.6 | 18.19+ | 7.4+ | -| 20 | 5.7 - 5.8 | 20+ | 7.4+ | -| 21 | 5.8 - 5.9 | 20+ | 7.4+ | -| 22 | 6.0+ | 20+ | 7.4+ | +| Angular | TypeScript | Node.js | RxJS | +| ------- | ---------- | --------------- | ---- | +| 13 | 4.4 | 12.20+ / 14.15+ | 7.4+ | +| 14 | 4.6 - 4.7 | 14.15+ / 16.10+ | 7.4+ | +| 15 | 4.8 | 14.20+ / 16.13+ | 7.4+ | +| 16 | 4.9 - 5.0 | 16.14+ / 18.10+ | 7.4+ | +| 17 | 5.2 | 18.13+ | 7.4+ | +| 18 | 5.4 | 18.19+ | 7.4+ | +| 19 | 5.5 - 5.6 | 18.19+ | 7.4+ | +| 20 | 5.7 - 5.8 | 20+ | 7.4+ | +| 21 | 5.8 - 5.9 | 20+ | 7.4+ | +| 22 | 6.0+ | 20+ | 7.4+ | --- @@ -889,17 +911,17 @@ ng update @angular/core@22 @angular/cli@22 ### 每个版本的关键迁移动作 -| 升级到 | 必做 | 推荐做 | -|--------|------|--------| -| 14 | 处理 Typed Forms 类型错误(或用 Untyped* 过渡)| 尝试 Standalone Components | -| 15 | — | 迁移到 Standalone + provide* 函数 | -| 16 | — | 开始使用 Signals | -| 17 | 更新 Node.js 到 18+ | 迁移到新控制流语法 `ng g @angular/core:control-flow` | -| 18 | — | 尝试 Zoneless | -| 19 | — | 全面拥抱 Signals(input/output/model)| -| 20 | 更新 Node.js 到 20+ | 启用 Zoneless,使用 resource/httpResource | -| 21 | — | 使用 Signal Forms,迁移到 Vitest | -| 22 | 移除 ComponentFactory 用法,更新 TypeScript 到 6.0+ | 迁移到 OnPush,移除 Hammer.js 依赖 | +| 升级到 | 必做 | 推荐做 | +| ------ | --------------------------------------------------- | ---------------------------------------------------- | +| 14 | 处理 Typed Forms 类型错误(或用 Untyped\* 过渡) | 尝试 Standalone Components | +| 15 | — | 迁移到 Standalone + provide\* 函数 | +| 16 | — | 开始使用 Signals | +| 17 | 更新 Node.js 到 18+ | 迁移到新控制流语法 `ng g @angular/core:control-flow` | +| 18 | — | 尝试 Zoneless | +| 19 | — | 全面拥抱 Signals(input/output/model) | +| 20 | 更新 Node.js 到 20+ | 启用 Zoneless,使用 resource/httpResource | +| 21 | — | 使用 Signal Forms,迁移到 Vitest | +| 22 | 移除 ComponentFactory 用法,更新 TypeScript 到 6.0+ | 迁移到 OnPush,移除 Hammer.js 依赖 | ### 官方升级工具 @@ -910,6 +932,7 @@ Angular 官方提供了交互式升级指南,会根据你的当前版本和目 --- > 系列文章: +> > - [(一)Angular 13-15 新特性详解](/posts/angular-features-13-to-15) > - [(二)Angular 16-18 新特性详解](/posts/angular-features-16-to-18) > - **(三)Angular 19-22 新特性详解**(本文) diff --git a/src/content/blog/angular-inject-not-service-locator.md b/src/content/blog/frontend/angular/angular-inject-not-service-locator.md similarity index 98% rename from src/content/blog/angular-inject-not-service-locator.md rename to src/content/blog/frontend/angular/angular-inject-not-service-locator.md index 2c927449..5e04d5c8 100644 --- a/src/content/blog/angular-inject-not-service-locator.md +++ b/src/content/blog/frontend/angular/angular-inject-not-service-locator.md @@ -8,12 +8,11 @@ isTranslation: true tags: - angular - dependency-injection - - frontend description: 深入探讨 Angular 中 inject() 函数与服务定位器模式的区别,理解注入上下文的基础原理,以及如何避免将 inject() 误用为服务定位器。 -canonicalURL: http://riegler.fr/blog/2025-01-08-inject-not-service-locator +canonicalURL: https://riegler.fr/blog/2025-01-08-inject-not-service-locator --- -> 原文地址: http://riegler.fr/blog/2025-01-08-inject-not-service-locator +> 原文地址: https://riegler.fr/blog/2025-01-08-inject-not-service-locator ![封面图片](/blog-images/angular-inject-not-service-locator/valeriia-miller-5y1rtaIKBj0-unsplash.jpg) diff --git a/src/content/blog/frontend/angular/angular-input-output-asymmetry.md b/src/content/blog/frontend/angular/angular-input-output-asymmetry.md new file mode 100644 index 00000000..ab7c6e15 --- /dev/null +++ b/src/content/blog/frontend/angular/angular-input-output-asymmetry.md @@ -0,0 +1,80 @@ +--- +pubDatetime: 2026-05-29T19:35:37+08:00 +title: "[译] Angular Input 与 Output 的不对称性" +slug: angular-input-output-asymmetry +featured: false +draft: true +isTranslation: true +tags: + - angular + - signals + - rxjs +description: 本文探讨了 Angular 中 input() 与 output() 看似对称却本质不同的设计:input 是基于状态的通信(信号),而 output 是基于事件的通信(Observable),理解这一差异有助于正确使用 Angular 的响应式原语。 +canonicalURL: https://riegler.fr/blog/2025-04-05-input-output +--- + +> 原文地址: https://riegler.fr/blog/2025-04-05-input-output + +![封面图片](/blog-images/angular-input-output-asymmetry/andrew-ridley-jR4Zf-riEjI-unsplash.jpg) + +在 Angular 中,inputs(输入)和 outputs(输出)是组件间通信的机制。`input()` 允许父组件向子组件传递数据,而 `output()` 则使子组件能够将信息传递回父组件。 + +`input()` 和 `output()` 常被视为对称的 API。一个典型的误解便是 `output()` 经常被错误地称为"信号输出(signal output)"。 + +接下来,我们将探讨它们实际上为何并非对称的 API,以及这意味着什么。 + +## Inputs:基于状态的通信 + +Inputs(输入)代表一种基于状态的通信方式,允许父组件通过属性向下传递数据,从而控制子组件的状态。 + +方便的是,inputs 以信号的形式来表示,这是一种响应式的、基于状态的 API。 + +```ts +@Component() +class MyButton { + disabled = input(false); +} +``` + +在这个例子中,`disabled` 输入可以在任意时刻被读取(它不是必填项,并且有初始值)。基于信号的响应式机制让组件能够在值发生变化时定义特定的行为。 + +## Output:基于事件的通信 + +Outputs(输出)代表一种基于事件的通信渠道,允许子组件在某件事情发生时通知父组件。它们通常由另一个事件所触发。 + +```ts +@Component({ + template: ` `, +}) +export class ChildComponent { + messageEvent = output(); + + sendMessage() { + this.messageEvent.emit("Hello from Child!"); + } +} +``` + +可以看出,inputs 和 outputs 之间并没有真正的对称性。 + +## 信号时代的 Inputs/Outputs + +如果你紧跟技术发展,你会了解到信号(signals)推崇基于状态的编程,通过声明式派生(`computed`、`linkedSignal`、`resource` 等)来管理状态。它们常常与另一种响应式原语 `Observable`(参见 [RxJs](https://rxjs.dev/))进行比较,而 Observable 依赖的是事件。 + +这就是为什么定义 `input()` 时我们会得到一个信号,但 `output()` 并不依赖信号的原因。由于 Outputs 是基于事件的,它们天然适合使用 `rxjs-interop` 工具:[`outputFromObservable`](https://angular.dev/api/core/rxjs-interop/outputFromObservable)。 + +```ts +@Directive({...}) +export class MyDir { + nameChange$ = ; + nameChange = outputFromObservable(this.nameChange$); +} +``` + +使用 `outputFromObservable` 可以实现一对一的行为:每当 Observable 发出值时,都会向父组件发出一个新值。 + +## 将状态变更作为 Output? + +随着信号在越来越多的代码库中被使用,我们看到了一些有趣的功能请求。例如[请求一个 `outputFromSignal` 辅助函数](https://github.com/angular/angular/issues/58680)。 + +这是个很有意思的想法,因为它会在基于状态的世界和基于事件的世界之间创建新的接口。从本质上讲,这样的辅助函数的行为等同于 `outputFromObservable(toObservable(mySignal())`。 diff --git a/src/content/blog/angular-ivy-vs-view-engine.md b/src/content/blog/frontend/angular/angular-ivy-vs-view-engine.md similarity index 100% rename from src/content/blog/angular-ivy-vs-view-engine.md rename to src/content/blog/frontend/angular/angular-ivy-vs-view-engine.md diff --git a/src/content/blog/angular-lifecycle-hook-free.md b/src/content/blog/frontend/angular/angular-lifecycle-hook-free.md similarity index 99% rename from src/content/blog/angular-lifecycle-hook-free.md rename to src/content/blog/frontend/angular/angular-lifecycle-hook-free.md index be6f4383..7180752b 100644 --- a/src/content/blog/angular-lifecycle-hook-free.md +++ b/src/content/blog/frontend/angular/angular-lifecycle-hook-free.md @@ -6,8 +6,8 @@ featured: false draft: false isTranslation: true tags: - - Angular - - 前端 + - angular + - lifecycle description: 探索如何使用 Angular 最新的信号(Signals)、DestroyRef、afterRender 等现代 API 替代传统的生命周期钩子,编写更简洁、可维护的组件。 canonicalURL: https://riegler.fr/blog/2024-12-31-lifecycle-hook-less --- diff --git a/src/content/blog/frontend/angular/angular-memory-leak.md b/src/content/blog/frontend/angular/angular-memory-leak.md new file mode 100644 index 00000000..38a784c3 --- /dev/null +++ b/src/content/blog/frontend/angular/angular-memory-leak.md @@ -0,0 +1,106 @@ +--- +pubDatetime: 2026-05-30T14:20:02+08:00 +title: "[译] 查找、调试并修复 Angular 中的内存泄漏" +slug: angular-memory-leak +featured: false +draft: true +isTranslation: true +tags: + - angular + - animations + - performance +description: "了解可帮助您调查内存泄漏的工具。" +canonicalURL: https://riegler.fr/blog/2023-07-20-angular-memory-leak +--- + +> 原文地址: https://riegler.fr/blog/2023-07-20-angular-memory-leak + +今天,我想向大家介绍一个几年前就已经报道过的问题。用户在使用动画模块时担心内存泄漏。让我们重新审视导致解决此问题的调查! + +## 确认是否存在内存泄漏 + +我们可以将范围缩小到 `:leave` 动画的使用。 +请参阅此处的最小复制品: + +``` +@Component({ + selector: '[element]', + template: 'This element is animated', + standalone: true, + animations: [trigger(`fade`, [transition(`:leave`, [])])], + host: { '[@fade]': '' }, +}) +export class ElementComponent {} + +@Component({ + selector: 'my-app', + standalone: true, + imports: [NgIf, ElementComponent], + template: ` + +
      + `, +}) +export class AppComponent { + visible = true; +} +``` + +每次移除组件时,使用切换按钮都会触发 `leave` 动画。 + +这可以通过 Chromium/Chrome/Edge DevTools 性能监视器的 DOM 节点计数器进行检测。 + +![DOM Nodes in the Performance Monitor](/blog-images/angular-memory-leak/dom-nodes.png "DOM Nodes in the Performance Monitor") + +在此图中,您可以看到 2 个特定行为。 + +1. 引导后,DOM 节点数有所下降。这就是垃圾收集器 (GC) 的工作:清理未引用的 DOM 节点。 +2. 接下来,我使用切换按钮缓慢但稳定地增加了 DOM 节点的数量。 + +为了确保这不是 GC 滞后的情况,我从 DevTools 手动触发了它。 + +![Trigger the GC manually](/blog-images/angular-memory-leak/performance-gc.png "Trigger the GC manually") + +## 寻找泄漏源头 + +既然我们知道出现了问题,那么我们可以从哪里开始寻找罪魁祸首呢? + +Edge Chromium(是的,在撰写本文时,只有 Edge 提供此功能),在 DevTools 中有一个特殊工具:Detached Elements。 + +您可以将其挂接到控制台旁边的底部面板中,以调查已从 DOM 分离的 Dom 节点。这些分离的节点仍然在内存中,因为它们仍然在代码中的某处被引用。这是内存泄漏的一个很好的提示。 + +在我们的例子中,它看起来像这样: + +![The Detached Nodes tool](/blog-images/angular-memory-leak/detached-nodes.png "The Detached Nodes tool") + +屏幕截图底部的列表代表仍在内存中的每个分离节点。 +单击节点 ID 时,我们会获取引用该对象的堆栈跟踪。 + +这里的堆栈跟踪非常明确,我们的节点舒适地位于 `TransitionAnimationEngine` 类中名为 `_statesByElement` 的 Map 中。 + +这些调试信息也可以在 Chrome DevTools 中访问,但不太直接。拍摄应用程序的内存堆快照,过滤以搜索“detached”,您将在快照中找到类似的 detacted 元素列表。 + +![Detached Element in Chrome DevTools](/blog-images/angular-memory-leak/chrome-detached.png "Detached Element in Chrome DevTools") + +> 📝 注意: +> 要调查 Angular 的代码源,您可以在 `angular.json` 设置中启用框架源映射,如下所示: +> +> ``` +> “源地图”:{ +> “脚本”:真实, +> “供应商”:真实 +> }, +> ``` + +``` + +Without entering too much in the detail of the fix provided in [this PR](https://github.com/angular/angular/pull/50929/files), the solution to our memory leak was to empty the Map at the end of the animation (which wasn't done under certain circumstances). + +Without the node being referenced in this Map, the GC can trash it as expected and we no longer have out memory leak ! + +Et voilà ! + +I hope have learned something new today, see next time ! + +Matt. +``` diff --git a/src/content/blog/angular-signals-zoneless.md b/src/content/blog/frontend/angular/angular-signals-zoneless.md similarity index 99% rename from src/content/blog/angular-signals-zoneless.md rename to src/content/blog/frontend/angular/angular-signals-zoneless.md index 8b24c3ae..1d6f79f1 100644 --- a/src/content/blog/angular-signals-zoneless.md +++ b/src/content/blog/frontend/angular/angular-signals-zoneless.md @@ -8,7 +8,6 @@ tags: - angular - signals - zoneless - - frontend description: 深入讲解 Angular Signals 体系:signal()、computed()、effect() 的核心 API,Zoneless 模式的开启方式,以及如何将存量 Zone.js 组件渐进式迁移到 Signals 架构。 --- diff --git a/src/content/blog/angular-ssr-hydration-bff.md b/src/content/blog/frontend/angular/angular-ssr-hydration-bff.md similarity index 100% rename from src/content/blog/angular-ssr-hydration-bff.md rename to src/content/blog/frontend/angular/angular-ssr-hydration-bff.md diff --git a/src/content/blog/angular-upgrade-13-17.md b/src/content/blog/frontend/angular/angular-upgrade-13-17.md similarity index 99% rename from src/content/blog/angular-upgrade-13-17.md rename to src/content/blog/frontend/angular/angular-upgrade-13-17.md index c9b45166..f7c93ff9 100644 --- a/src/content/blog/angular-upgrade-13-17.md +++ b/src/content/blog/frontend/angular/angular-upgrade-13-17.md @@ -5,7 +5,8 @@ slug: angular-upgrade-from-13-to-17 featured: false draft: false tags: - - blog + - angular + - migration description: 复杂的事情可以拆分小的任务,这样更具有可行性。 --- diff --git a/src/content/blog/angular-version-update.md b/src/content/blog/frontend/angular/angular-version-update.md similarity index 100% rename from src/content/blog/angular-version-update.md rename to src/content/blog/frontend/angular/angular-version-update.md diff --git a/src/content/blog/angular-zone.md b/src/content/blog/frontend/angular/angular-zone.md similarity index 100% rename from src/content/blog/angular-zone.md rename to src/content/blog/frontend/angular/angular-zone.md diff --git a/src/content/blog/frontend/angular/animations-async.md b/src/content/blog/frontend/angular/animations-async.md new file mode 100644 index 00000000..5183ea86 --- /dev/null +++ b/src/content/blog/frontend/angular/animations-async.md @@ -0,0 +1,124 @@ +--- +pubDatetime: 2026-05-30T14:23:11+08:00 +title: "[译] 延迟加载 Angular 的动画模块" +slug: animations-async +featured: false +draft: true +isTranslation: true +tags: + - angular + - animations + - lazy-loading +description: "主包中的内容减少了几 kB" +canonicalURL: https://riegler.fr/blog/2023-10-04-animations-async +--- + +> 原文地址: https://riegler.fr/blog/2023-10-04-animations-async + +Angular v17 的第一个 RC 即将到来,让我们来看看本周“下一个”版本中的一个有趣功能:延迟加载! + +Angular 中的 [animation package](https://angular.io/guide/animations) 允许开发人员通过定义状态、转换和触发器轻松地将动画添加到其组件中。例如,`@angular/material` 模块使用该模块。 + +虽然实用,但该软件包的成本约为 60kB(或压缩后的 16kB),占整个 `ng new` 应用程序的 72%(从路由器和通用模块中剥离约 82kB)。这使得这个包成为延迟加载的主要候选者。 + +## 当前实施情况 + +在 Angular 中,负责渲染的类应该实现 `Renderer2`。默认情况下,Angular 使用(私有)`DomRenderer2` 将模板渲染到 DOM 中。 + +当您使用 `BrowserAnimationsModule` 模块或 `provideAnimations` 函数启用动画时,将提供新的动画感知 `RendererFactory2` 实现(称为 `AnimationRendererFactory`)。该工厂用于生成渲染器的动画感知实例(称为 `AnimationRenderer`)。这两个类都在代码中直接引用,因此它们(及其所有依赖项)都急切地包含在主包中。 + +默认渲染器 `DomRenderer` 不支持动画属性。它甚至抛出著名的错误 `Found the synthetic property @...` 来提醒开发人员在应用程序上启用动画。 + +## 引入延迟加载,同时保持 API 同步 + +`Renderer2` 接口定义的渲染 API 是完全同步的,可以追溯到 Angular v4。 +使其异步将是一个巨大的突破性改变,因此这个想法被放弃了。有了这个心, +必须找到一种替代方案,在延迟加载动画渲染器的同时急切地返回渲染器。 + +选择的解决方案是依赖 [delegation pattern](https://en.wikipedia.org/wiki/Delegation_pattern):一个新的 `RendererFactory2`,`AsyncAnimationRendererFactory` 将创建 `DynamicDelegationRenderer`。在等待动画模块加载并将委托渲染器切换到 `AnimationRenderer` 时,该渲染器将依赖于默认渲染器。 + +瞧,我们刚刚制作了一个与异步加载兼容的同步 API。 + +## 使用新的动画 API + +该 API 从 `17.0.0-next.7` 开始可用。 + +要启用动画包的延迟加载,您必须通过从 `@angular/platform-browser/animations/async` 而不是 `provideAnimations()` 调用 `provideAnimationsAsync()` 来设置提供程序。它接受一个可选参数来使用 noop 动画。 + +#### **`main.ts`** + +``` +bootstrapApplication(AppComponent, { + providers: [ + provideAnimationsAsync(), + provideRouter([ ... ]), // my animated components + ], +}) +``` + +为了确保整个动画包不会被急切加载,`@angular/animation` 只能在延迟加载的组件上导入(延迟加载的路由、@defer'ed 组件等)。 + +## Implications + +### 动态渲染器的后果 + +拥有一个切换其委托实现的渲染器会对角度应用程序产生直接影响。 +让我们回顾一下缺点。 + +On bootstrap, the renderer will always be the default one: the `DomRenderer`. This renderer isn't able to process animation instructions (states, transitions...). +因此,如果我们有一个组件,那么样式将是一个空操作: + +``` +@Component({ + animations: [ + trigger('openClose', [ + state('open', style({ background: 'chartreuse' })), + state('closed', style({ background: 'blue' })), + transition('open => closed', [animate('1s')]), + transition('closed => open', [animate('0.5s')]), + ]), + ], + standalone: true, +}) +export class OpenCloseComponent { + isOpen = true; + + toggle() { + this.isOpen = !this.isOpen; + } +} +``` + +在示例中,`DomRenderer` 无法应用初始样式,该样式应为 `background: chartreuse`。 +直到 `AnimationRenderer` 被 `DynamicDelegationRenderer` 拾取后,样式才会更新为预期的颜色。 + +### 延迟加载和代码分割 + +当 `@angular/animation` 包从未被急切加载时,此功能最有意义。 +如果急切加载的组件使用此包中的函数,则整个包不会被拆分为单独的包,并且延迟加载将毫无用处。 + +这很容易检查我们自己的代码库,但在使用库时可能会变得很棘手。您可能会遇到问题的是 `@angular/material`。这个官方的 Angular 包(从 v17 开始)严重依赖动画模块。大约 15 个材料组件正在导入 `@angular/animations`。如果您在非延迟加载组件中使用其中之一,这将破坏代码分割。 + +因此,请小心您在非延迟加载组件上使用/导入的组件! + +## 我的延迟加载有效吗? + +通过检查 CLI 的服务/构建输出,可以检查您的应用程序中是否正确实现了代码分割/延迟加载。 +如果您使用基于 webpack 的构建器 `@angular-devkit/build-angular:browser`,则可以在 `angular.json` 中启用 `namedChunk` 选项并检查输出: + +``` +Lazy Chunk Files | Names | Raw Size +node_modules_angular_animations_fesm2022_browser_mjs.js | angular-animations-browser | 176.66 kB | +default-node_modules_angular_animations_fesm2022_animations_mjs.js | app-open-close-component | 39.20 kB | +src_app_open-close_component_ts.js | app-open-close-component | 4.45 kB | +``` + +在这里我们可以看到 `@angular/animations` 和 `@angular/animations/browser` 都位于单独的包中。我们的延迟加载有效🎉🎉 + +注意:使用 esbuild 构建器命名的块正在等待 [this PR](https://github.com/angular/angular-cli/pull/25913)。 + +## 最后一句话 + +新的 API 已作为开发者预览版提供,请 [report any issues](https://github.com/angular/angular/issues/new/choose) 您可能遇到的情况。 + +如果您对此新功能有任何疑问,请随时通过 [Twitter](https://twitter.com/Jean__Meche) 与我联系! diff --git a/src/content/blog/frontend/angular/cd-coalescing.md b/src/content/blog/frontend/angular/cd-coalescing.md new file mode 100644 index 00000000..bcc62680 --- /dev/null +++ b/src/content/blog/frontend/angular/cd-coalescing.md @@ -0,0 +1,138 @@ +--- +pubDatetime: 2026-05-30T16:45:00+08:00 +title: "[译] 触发变更检测,但不要太频繁" +slug: cd-coalescing +featured: false +draft: true +isTranslation: true +tags: + - angular + - change-detection + - microtasks + - event-loop +description: 本文解释 Angular 中基于 zone 的变更检测为何会在微任务队列清空后触发,并说明为什么用微任务合并比多次 setTimeout() 更适合手动调度 CD 周期。 +canonicalURL: https://riegler.fr/blog/2023-09-20-cd-coalescing +--- + +> 原文地址: https://riegler.fr/blog/2023-09-20-cd-coalescing + +![Image](/blog-images/cd-coalescing/larry-costales-YFR_x3MqpHc-unsplash.jpg) + +在 Angular 中,当涉及基于 zone 的变更检测时,我们有时需要手动触发它。写本文时,还没有可用于调度新一轮 ChangeDetection(CD)周期的 API([待解决 issue](https://github.com/angular/angular/issues/43168))。 + +## 前言 + +这篇文章会聊到 JavaScript 的内部机制,尤其是浏览器的事件循环。 + +如果你还不熟悉 _microtask_、_macrotask_ 或 _queues_ 这些术语,我推荐你先读一读 Jake Archibald 的那篇精彩文章:[任务、微任务、队列与调度](https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/)。 + +另外,为了便于说明,从这里开始我提到“入队任务”时,指的都是把函数回调加入微任务或宏任务队列。 + +## Angular 中变更检测是如何触发的 + +在 Angular 里,本质上只要调用 `ApplicationRef.tick()`,就会触发一次变更检测周期。每当微任务队列被清空时,这件事会自动发生。 + +#### **`ApplicationRef.ts`** + +```ts +this._onMicrotaskEmptySubscription = this.zone.onMicrotaskEmpty.subscribe({ + next: () => { + this.zone.run(() => { + this.applicationRef.tick(); + }); + }, +}); +``` + +这里 `zone.js` 非常关键,因为原生 API 并没有能力让你检查微任务队列和宏任务队列的状态。 + +## 调度一次变更检测 + +### 用 `setTimeout()` 入队一个宏任务 + +如果你写过一些 Angular,八成已经在某个地方写过 `setTimeout()` 来修 bug。你也会很快发现,它之所以能解决很多问题,是因为它会触发新一轮 CD 周期。 + +![不算好,也不算太糟](/blog-images/cd-coalescing/not-great.jpg) + +这只是个权宜之计,不算好,但也不算太糟。 + +它的工作方式如下: + +1. 你调用 `setTimeout()` +2. 一个宏任务被加入队列 +3. 宏任务队列中的回调被执行,微任务队列也会被执行并清空 +4. `onMicrotaskEmpty` 这个 observable 发出通知 +5. 调用 `ApplicationRef.tick()` + +`setTimeout()` 会入队一个宏任务。由于宏任务是在把执行权交还给事件循环之前,一次只执行一个任务,因此你调用多少次 `setTimeout()`,就会有多少次 CD 周期。正如前面所说,这“不算好,也不算太糟”(但确实能修好 bug)。 + +### 入队一个微任务 + +比起调用 `setTimeout()` 以及它对应的宏任务,更好的做法是依赖微任务。最常见的两个 API 是 `Promise.resolve().then(() => ...)` 和 `queueMicroTasks(() => {})`。调用其中任意一个函数,本质上都会入队一个微任务。 + +与宏任务队列不同,微任务队列会把其中所有任务一个接一个全部执行完,包括那些在执行过程中又新加入的任务。 + +> ⚠️ 请注意,递归地反复入队会导致无限循环。 + +这意味着,在把执行权交还给事件循环之前,队列中的每个回调都会先执行完。换成 Angular 的语境来说,这意味着你只会得到一次 CD 周期。 + +这有助于你优化 CD 周期的触发方式:只调度一次 ChangeDetection 周期,不多也不少。 + +## 演示 + +下面这两个函数分别会入队 3 个宏任务和 3 个微任务。 + +```ts +fireMacrotasks() { + setTimeout(() => {}, 100); + setTimeout(() => {}, 100); + setTimeout(() => {}, 100); +} + +fireMicrotasks() { + queueMicrotask(() => {}); + queueMicrotask(() => {}); + queueMicrotask(() => {}); +} +``` + +第一个函数实际上会调度 3 次 CD 周期,而第二个只会调度一次。 + +➡️ [在 StackBlitz 中运行](https://stackblitz.com/edit/angular-tasks-coalescing?file=src%2Fmain.ts) + +## 补充 + +即便是框架内部代码,也依赖微任务来调度 CD 周期。下面是 [forms 模块](https://github.com/angular/angular/blob/0ee0f780e4c19d43dde9bac0bb6468ea6431d24b/packages/forms/src/directives/ng_model.ts#L30-L47) 的一段摘录。 + +#### **`ng_model.ts`** + +````ts +/** + * 当 `ngModel` 的输入发生变化时,它会强制额外执行一次变更检测: + * 例如: + * ``` + *
      {{myModel.valid}}
      + * + * ``` + * 也就是说,`ngModel` 可以把自身导出到该元素上,然后在模板中使用。 + * 正常情况下,这会导致位于 `input` 之前、使用该导出指令的表达式 + * 仍然拿到旧值,因为它们之前已经做过脏检查。 + * 由于这在 `ngModel` 中是一个非常常见的场景,我们加入了第二轮 + * 变更检测。 + * + * 说明: + * - 无论有多少个 `ngModel` 发生变化,都只会额外执行一次。 + * - 使用带 `exportAs` 的指令时,这其实是一个通用问题! + */ +const resolvedPromise = (() => Promise.resolve())(); + +... +class NgModel { + private _updateValue(value: any): void { + resolvedPromise.then(() => { + this.control.setValue(value, {emitViewToModelChange: false}); + this._changeDetectorRef?.markForCheck(); + }); + } +} +```` diff --git a/src/content/blog/frontend/angular/cldr-angular.md b/src/content/blog/frontend/angular/cldr-angular.md new file mode 100644 index 00000000..50debe85 --- /dev/null +++ b/src/content/blog/frontend/angular/cldr-angular.md @@ -0,0 +1,494 @@ +--- +pubDatetime: 2026-05-30T16:30:00+08:00 +title: "[译] CLDR:你的 locale 真相来源" +slug: cldr-angular +featured: false +draft: true +isTranslation: true +tags: + - angular + - i18n + - cldr + - localization +description: CLDR 是 Angular 本地化格式的事实来源。本文通过真实 issue 演示如何判断显示问题到底是 bug 还是数据规范使然。 +canonicalURL: https://riegler.fr/blog/2023-09-18-cldr-angular +--- + +> 原文地址: https://riegler.fr/blog/2023-09-18-cldr-angular + +![Image](/blog-images/cldr-angular/kyle-glenn-nXt5HtLmlgE-unsplash.jpg) + +对于做过国际化(i18n)应用的人来说,你一定知道这不是一个简单的问题。或者至少过去确实很难,而现在已经好多了。 + +Angular 通过提供配置与处理 locale 的必要能力,大大降低了我们的工作量。 + +你只需要导入 locale,并配置 locale provider。 + +#### **`main.ts`** + +``` +import '@angular/common/locales/global/fr' +; +bootstrapApplication +(App, {providers: [ +{provide: LOCALE_ID +, useValue: 'fr' +} +] +} +) +; +``` + +随后这些 pipes 就会自动使用该 locale。 + +#### **`my.component.html`** + +``` + {{ date | date: 'dd MMMM yyyy'}} // 2023 年 9 月 16 日(写作时) + {{ 35.50 | currency }} // 35,50 $US + {{ 300_000_000 | number }} // 300 000 000 +``` + +就是这么简单。 + +## CLDR + +不过你有没有想过,这些信息到底来自哪里?答案就是 CLDR:Common Locale Data Repository。 + +CLDR([wiki](https://en.wikipedia.org/wiki/Common_Locale_Data_Repository))是 Unicode Consortium 的一个项目,为操作系统和其他软件提供 locale 数据。 + +尽管 CLDR 官方格式是 XML,但他们也提供了[同样内容的 JSON](https://github.com/unicode-org/cldr-json)。Angular 正是用这些数据来生成 `common` 模块中的 locale 数据。 + +## 海量且多样的数据 + +截至 CLDR v44,这个仓库已经支持 600 多种 locale。并且同一种语言可以覆盖多个国家地区(例如 fr-CA 表示加拿大法语、fr-CG 表示刚果(布)法语等)。 + +### 数字 + +[这里](https://github.com/unicode-org/cldr-json/tree/f93780d69dbd62550cd0a3eb64aa3c73b2c45e91/cldr-json/cldr-numbers-full/main)有一份数字数据示例。我们看一下 `fr` locale 中法语数字格式定义。 + +#### **`fr/numbers.json`** + +``` + "symbols-numberSystem-latn" +: { + "decimal" +: "," +, + "group" +: " " +, + "timeSeparator" +: ":" + ... + } +, + ... + "currencyFormats-numberSystem-latn" +: { + "standard" +: "#,##0.00 ¤" +, + ... + } +``` + +可以看到,小数分隔符是逗号,货币符号(`¤`)位于数字后面,等等。 +如果你想了解这些格式的具体含义,可以查看 CLDR 的[在线文档](https://cldr.unicode.org/translation/number-currency-formats/number-and-currency-patterns)。 + +### 日期 + +日期是 i18n 中最典型、也最需要正确格式化的数据。CLDR 为此定义了大量相关数据。 + +这里给一个[韩语日期](https://github.com/unicode-org/cldr-json/blob/f93780d69dbd62550cd0a3eb64aa3c73b2c45e91/cldr-json/cldr-dates-full/main/ko/ca-gregorian.json)的简短示例。 + +| 韩文 | 英文 | +| ---- | ---- | + +| **`ko/ca-gregorian.json`**`"months" +: { + "format" +: { + "wide" +: { + "1" +: "1월" +, + "2" +: "2월" +, + "3" +: "3월" +, + "4" +: "4월" +, + "5" +: "5월" +, + "6" +: "6월" +, + "7" +: "7월" +, + "8" +: "8월" +, + "9" +: "9월" +, + "10" +: "10월" +, + "11" +: "11월" +, + "12" +: "12월" + } +, + } +} +, +"days" +: { + "format" +: { + "abbreviated" +: { + "sun" +: "일" +, + "mon" +: "월" +, + "tue" +: "화" +, + "wed" +: "수" +, + "thu" +: "목" +, + "fri" +: "금" +, + "sat" +: "토" + } +, + } +} +, +... +"dateFormats" +: { + "full" +: "y년 MMMM d일 EEEE" +, + "long" +: "y년 MMMM d일" +, + "medium" +: "y. M. d." +, + "short" +: "yy. M. d." +} +,` | **`en/ca-gregorian.json`**``` +"months" +: { +"format" +: { +"wide" +: { +"1" +: "January" +, +"2" +: "February" +, +"3" +: "March" +, +"4" +: "April" +, +"5" +: "May" +, +"6" +: "June" +, +"7" +: "July" +, +"8" +: "August" +, +"9" +: "September" +, +"10" +: "October" +, +"11" +: "November" +, +"12" +: "December" +} +, +} +} +, +"days" +: { +"format" +: { +"abbreviated" +: { +"sun" +: "Sun" +, +"mon" +: "Mon" +, +"tue" +: "Tue" +, +"wed" +: "Wed" +, +"thu" +: "Thu" +, +"fri" +: "Fri" +, +"sat" +: "Sat" +} +, +} +} +, +... +"dateFormats" +: { +"full" +: "EEEE, MMMM d, y" +, +"long" +: "MMMM d, y" +, +"medium" +: "MMM d, y" +, +"short" +: "M/d/yy" +} +, + +```| + +### 还有更多 + +它不仅仅包含格式数据,内容远不止这些。 + +继续看货币数据,[这里](https://github.com/unicode-org/cldr-json/blob/f93780d69dbd62550cd0a3eb64aa3c73b2c45e91/cldr-json/cldr-core/supplemental/currencyData.json#L1093-L1104) +记录了各个国家的货币历史。以古巴为例,美元在 1959 年之前都是有效货币。这里甚至能看到一点历史课内容:1959 年正是对古巴禁运开始的时间点。 + +#### **`supplemental/currencyData.json`** + +``` + + "CU" + +: [ +{ +"CUP" +: { +"_from" +: "1859-01-01" +} +} +, +{ +"USD" +: { +"_from" +: "1899-01-01" +, +"_to" +: "1959-01-01" +} +} +, +] + +``` + +## 这是 bug 吗? + +那我该如何判断一个显示问题到底是 bug 还是“本来就该这样”的特性? +我会用 Angular 仓库中的几个 issue 来演示。 + +### 西班牙语环境下 USD 的值应该是什么? + +在 [#51671](https://github.com/angular/angular/issues/51671) 这个 issue 中,用户希望意大利语 locale 下的货币符号显示为 `$`。 + +现在我们既然知道了 CLDR 的存在,就可以直接去[源数据](https://github.com/unicode-org/cldr-json/blob/f93780d69dbd62550cd0a3eb64aa3c73b2c45e91/cldr-json/cldr-numbers-full/main/it/currencies.json#L1112C18-L1112C18)验证。 + +#### **`it/currencies.json`** + +``` + + "USD" + +: { +"displayName" +: "dollaro statunitense" +, +"displayName-count-one" +: "dollaro statunitense" +, +"displayName-count-other" +: "dollari statunitensi" +, +"symbol" +: "USD" +, +"symbol-alt-narrow" +: "$" +} +, + +```` + +可以看到,意大利语里 USD 的符号确实是 `USD`,而不是 `$`。 + +➡️ 结果:**✅ 不是 bug** + +### 西班牙语月份简称少了一个点号吗? + +再看日期格式这个例子,在 [#51317](https://github.com/angular/angular/issues/51317) 中,用户反馈使用 `MMM` 中等格式时,月份简称少了点号。 + +这项信息位于[gregorian calendar 数据](https://github.com/unicode-org/cldr-json/blob/f93780d69dbd62550cd0a3eb64aa3c73b2c45e91/cldr-json/cldr-dates-full/main/es/ca-gregorian.json#L23)里。 + +| 西班牙语 | 法语 | +| --- | --- | +| **`es/ca-gregorian.json`**``` +"abbreviated" +: { + "1" +: "ene" +, + "2" +: "feb" +, + "3" +: "mar" +, + "4" +: "abr" +, + "5" +: "may" +, + "6" +: "jun" +, + "7" +: "jul" +, + "8" +: "ago" +, + "9" +: "sept" +, + "10" +: "oct" +, + "11" +: "nov" +, + "12" +: "dic" +} +, +``` | **`fr/ca-gregorian.json`**``` + "1" +: "janv." +, + "2" +: "févr." +, + "3" +: "mars" +, + "4" +: "avr." +, + "5" +: "mai" +, + "6" +: "juin" +, + "7" +: "juil." +, + "8" +: "août" +, + "9" +: "sept." +, + "10" +: "oct." +, + "11" +: "nov." +, + "12" +: "déc." +} +, +``` | + +可以看到,CLDR 在法语简称中使用点号,但在西班牙语中没有。 + +➡️ 结果:**✅ 不是 bug** + +### 负数货币金额缺少空格 + +最后一个例子是 [#46038](https://github.com/angular/angular/issues/46038)。用户反馈 `de-CH` locale 下,货币与负号之间缺少空格。他期望 `CHF-135.00` 显示为 `CHF -135.00`。 + +同样,我们来看 [CLDR 数字数据](https://github.com/unicode-org/cldr-json/blob/f93780d69dbd62550cd0a3eb64aa3c73b2c45e91/cldr-json/cldr-numbers-full/main/de-CH/numbers.json#L97-L110): + +```` + +"currencyFormats-numberSystem-latn" +: { +"standard" +: "¤ #,##0.00;¤-#,##0.00" +, +} + +``` + +CLDR 为瑞士德语货币定义了 2 种模式。可以看到负数模式 `¤-#,##0.00` 中,货币符号(`¤`)与负号之间本来就没有空格。 + +➡️ 结果:**✅ 不是 bug** + +## 最后说明 + +现在你已经是 CLDR 专家了,可以自己去查看 CLDR 仓库,判断你看到的行为是否符合预期。 + +如果你发现 CLDR 与 Angular 之间存在差异,可以去 [open an issue](https://github.com/angular/angular/issues/new/choose),Angular 的 locale 数据可能需要同步到最新 CLDR。或者如果你 100% 确认是 CLDR 本身的数据问题,也可以向 CLDR 提交变更请求,详见[这里](https://github.com/unicode-org/cldr/blob/main/docs/requesting_changes.md)。 +``` diff --git a/src/content/blog/frontend/angular/defer-part1.md b/src/content/blog/frontend/angular/defer-part1.md new file mode 100644 index 00000000..a292b571 --- /dev/null +++ b/src/content/blog/frontend/angular/defer-part1.md @@ -0,0 +1,161 @@ +--- +pubDatetime: 2026-05-30T14:24:17+08:00 +title: "[译] Understanding Angular's deferrable views - Part. 1" +slug: defer-part1 +featured: false +draft: true +isTranslation: true +tags: + - angular + - lazy-loading + - performance +description: "@defer 是街区里的新酷孩子" +canonicalURL: https://riegler.fr/blog/2023-10-05-defer-part1 +--- + +> 原文地址: https://riegler.fr/blog/2023-10-05-defer-part1 + +延迟加载是目前构建应用程序时的一个热门话题。延迟加载越多,主包就会越小。 Angular v17 引入了可延迟视图,这是一种从模板延迟加载组件的方法。 + +让我们深入了解一下吧! + +## @defer 块 + +### 基础知识 + +模板可延迟视图由 `@defer() { ... }` 块包装。该块指示编译器提取其内容并在触发特定触发器时延迟加载它。 + +`@defer` 语法很简单,如下所示: + +#### **`app.component.html`** + +``` +
      + @defer { + + } +
      +``` + +这里,只要应用程序空闲(默认触发器),`AppComponent` 就会延迟加载 `app-child` 组件。 +当服务/构建您的应用程序时,您将能够在控制台中看到以下日志( +如果使用 `namedChunk` 构建器选项,则保留组件名称): + +``` +Lazy Chunk Files | Names | Raw Size +appchild.component-UIYBMU37.js | - | 1.62 kB | +``` + +### 触发器 + +对于 v17 版本,`@defer` 附带了一组受支持的条件和触发器,用于确定何时触发组件的加载。 + +| 触发 | 详情 | +| ---------------- | ---------------------------------------------------------------------------------------------------- | +| 何时可见 | 此条件先于触发器。它可以是变量、函数调用、管道值等。在撰写本文时,不支持 `Observable` 和 `AsyncPipe` | +| 空闲时 | 这是默认行为,当应用程序空闲时触发(使用 `requestIdleCallback`) | +| 立即 | 执行模板时触发器立即触发 | +| 定时器(5s) | 基于 `setTimeout` 的触发器,如果​​指定了 `s`,则以毫秒或秒为单位 | +| 交互(触发) | 单击模板引用,其中 `#trigger` 是模板引用 | +| 悬停时(触发) | 将鼠标悬停在模板引用上,其中 `#trigger` 是模板引用 | +| 在视口(容器)上 | 在进入视口的模板上,其中 `#container` 是模板引用 | + +所有这些触发器都可以组合在一条 @defer 指令中,并用逗号分隔。 + +``` +
      + @defer(on viewport(container), idle) { + + } +
      +``` + +## 预取 + +触发器非常适合触发组件的动态加载。但是如果我们想通过预取导入的文件来加快数据获取速度怎么办? + +这就是 `@defer` 块中 `prefetch` 选项的用途。 +`prefetch` 可以采用一组条件 (`when ...`) 和触发器 (`on ...`) 来确定何时触发动态导入的 js 模块的实际加载。 + +``` +@defer (on viewport(container); prefetch on timer(5s); prefetch when isDataLoaded()){ + +} +``` + +这样,组件加载会在数据加载后或 5 秒后开始,但只有在其容器进入视口时才会渲染! + +很棒不是吗? 🚀 + +## @defer,渲染直到什么? + +在我们的组件被加载和渲染之前,我们可能需要填充一个占位符。这是通过定义一个 +`@placeholder` 块位于 `defer` 块之后。占位符将在 DOM 中呈现,直到延迟触发器触发。 +占位符接受单个参数 `minimum`,这是触发延迟触发器后显示的最短时间。 + +如果我们想在占位符进入视口时触发,它会非常有用。 + +#### **`mycomponent.template.html`** + +``` +@defer(on viewport(myPlaceholder)) { + +} @placeholder(minimum 5000ms) { +
      ...
      +} +``` + +由于延迟加载可能不是即时的(巨大的组件,缓慢的网络),我们可能希望显示一个加载块。 +这就是 `@loading` 块的用途。与 `@placeholder` 一样,它接受 `minimum` 参数,但也接受 `after` 参数。 + +如果延迟加载失败,我们可以通过使用 `@error` 块定义它来呈现错误消息。错误块不带参数; + +``` +@defer(on viewport(myPlaceholder)) { + +} @loading (minimum 1s; after 100ms){ + Loading... +} @error { + Loading failed :( +} +``` + +`@placeholder`、`@loading` 和 `@error` 块的内容被急切加载。 + +## 用法 + +这是一个完整的(拥挤的)示例,展示了 defer 的可能性以及我们迄今为止看到的每个功能: + +- `when` 条件 +- `on` 触发器 +- `prefetch` 触发器 +- `@placeholder` 块 +- `@loading` 块 +- `@error` 块 + +#### **`mycomponent.template.html`** + +``` +@defer (when isVisible() && foo; + on hover(button), timer(10s), idle, immediate, interaction(button), viewport(container); + prefetch on immediate; prefetch when isDataLoaded()) { + +} +@placeholder (minimum 500){ + Placeholder content! +} +@loading (minimum 1s; after 100ms){ + Loading... +} +@error { + Loading failed :( +} +``` + +这里为您准备了 [stackblitz playground](https://stackblitz.com/edit/angular-at?file=src%2Fmain.ts),让您可以开始使用这个令人惊叹的新功能! + +## 待定 + +在下一篇文章中,我们将深入探讨可延迟视图的技术细节。它们如何工作、如何使用它们并针对它们优化您的模板。敬请关注。 + +编辑:第 2 部分可用 [here](blog/2023-10-08-defer-part2) diff --git a/src/content/blog/frontend/angular/defer-part2.md b/src/content/blog/frontend/angular/defer-part2.md new file mode 100644 index 00000000..5814cd13 --- /dev/null +++ b/src/content/blog/frontend/angular/defer-part2.md @@ -0,0 +1,119 @@ +--- +pubDatetime: 2026-05-30T14:25:40+08:00 +title: "[译] 了解 Angular 的可延迟视图 - 第 1 部分。 2" +slug: defer-part2 +featured: false +draft: true +isTranslation: true +tags: + - angular + - lazy-loading + - performance +description: "里面的魔法" +canonicalURL: https://riegler.fr/blog/2023-10-08-defer-part2 +--- + +> 原文地址: https://riegler.fr/blog/2023-10-08-defer-part2 + +我在 [previous article](/blog/2023-10-05-defer-part1) 中向您介绍了 Angular 中可延迟视图的基础知识。现在您知道可以用它们做什么,让我们在技术细节中提取它们的性能! + +## 他们如何工作 + +在底层,可延迟视图是 Angular 编译器的一个功能。 + +编译模板时,Angular 编译器将 `@defer` 块中使用的所有依赖项提取到一个单独的函数中,并生成许多动态导入来加载相应的 JS 块。 + +| 模板 | 编译 | +| ---- | ---- | + +| ``@推迟 { +<我的重组件/> +}` | ``` +function defer_for_block { +return [ () => import('./my-heavy-component') ] +} + +`````| + +基本上,这个 `@defer` 块会产生一个动态导入,该导入将在触发器触发时执行。 + +## 嵌套块 + +当 `@defer` 块中有多个依赖项时,它们最终会出现在同一导入函数中(位于嵌套 `@defer` 块内部的依赖项除外)。 + +|模板|编译| +| ---| ---| +| ```` +@defer { // 块 A +@if(选项==='a'){ +<重成分-a /> +} @else if (选项 === 'b') { +<重成分-b /> +} @别的 { +@defer { // 块 B +<重组件-c /> + } + } +} +``` | ``` +function defer_for_blockA { + return [ + () => import('./heavy-component-a'), + () => import('./heavy-component-b') + ] +} + +function defer_for_blockB { + return [ + () => import('./heavy-component-c') + ] +} +``` | + +在这种情况下,我们有 2 个 `@defer` 块,从而产生 2 个生成的延迟加载函数。 + +当块 BlockA 的触发器被触发时,两个块都会被加载,但只会渲染其中一个组件。只有当我们将第三个块放入最后一个 `@else` 块时,才会加载第三个块。 + +因此,如果所有组件都很“重”,您可能需要考虑将这些组件包装到单独的 `@defer` 块中(类似于我们在最后一个 `@else` 块中的内容)。如果我们用 `@switch` 块替换 `@if` 块,则适用相同的逻辑。 + +## 先 `@for` 还是先 `@defer` ? + +现在我们知道嵌套有影响,让我们比较一下将 `@for` 块与 `@defer` 块结合使用时的差异。 + +首先,我们需要了解 `@defer` 块的运行时影响。 +在运行时,`@defer` 块的内容由嵌入视图表示(就好像内容位于 `` 中一样)。接下来看看两种可能的组合: + +| `@for` 内的 `@defer` | `@for` 位于 `@defer` 内 | +| ---| ---| +| ```` +@for(项目中的项目){ +@推迟 { +<我的重组件/> + } +} +``` | ``` +@defer { + @for (item of items) { + + } +} +``` | + +在左侧示例中,对于每个项目,将创建一个作为 `@for` 循环的一部分创建的嵌入视图,以及为每个 `@defer` 块创建的嵌套嵌入视图。嵌入视图的数量将为 `items.length * 2`,延迟块实例的数量将为 `items.length`。 + +正确的示例将生成 `items.length + 1` 嵌入视图和单个延迟块实例。 + +| `@for` 内的 `@defer` | `@for` 位于 `@defer` 内 | +| ---| ---| +| - `items.length * 2` 嵌入视图 - `items.length` 延迟块实例 | - `items.length` 嵌入视图 - 单个延迟块实例 | + +因此,对于上述情况,`@for` 循环的内容不具有不同“重”组件的条件,因此选择 `@defer` 位于 `@for` 循环之外的正确示例是有意义的。 + +但是,如果 `@for` 循环的内容具有一些额外的逻辑,并且涉及根据特定条件显示不同的“重”组件,则您可能需要选择左侧示例(其中 `@defer` 位于 `@for` 循环内部)来包装特定组件或一组组件。 + +现在您已经了解了用法的含义: + +![test](/blog-images/defer-part2/81t40e.jpg) + +如果您对这个出色的新功能还有任何其他疑问,请随时通过 [Twitter](https://twitter.com/Jean__Meche) 与我联系! +````` diff --git a/src/content/blog/frontend/angular/input-setters-caveats.md b/src/content/blog/frontend/angular/input-setters-caveats.md new file mode 100644 index 00000000..a3139d35 --- /dev/null +++ b/src/content/blog/frontend/angular/input-setters-caveats.md @@ -0,0 +1,100 @@ +--- +pubDatetime: 2026-05-30T14:32:13+08:00 +title: "[译] 用 input() 信号替换 @Input 设置器" +slug: input-setters-caveats +featured: false +draft: true +isTranslation: true +tags: + - angular + - signals +description: "了解输入设置器的注意事项" +canonicalURL: https://riegler.fr/blog/2024-05-01-input-setters-caveats +--- + +> 原文地址: https://riegler.fr/blog/2024-05-01-input-setters-caveats + +拥有输入设置器是角度应用程序中非常常见的一种模式,它通常用于在输入值发生变化时(重新)采取行动。 + +#### `Input setter example` + +``` +class MyComponent { + _isEnabled: boolean = false; + @Input() set isEnabled(value: unkown) { + _isEnabled = !!value; + } +} +``` + +**首先是免责声明**,输入设置器(或与此相关的更改检测内的任何生命周期事件)本质上并不是坏事。 +例如,如果您将新值强制为布尔值或类似值,则可以使用它们。 + +但由于 setter 运行时(在变更检测期间),我们应该注意两个半独立的问题。您在其中可以/应该做什么有额外的限制,而且这些限制可能很微妙。 + +很容易意外地做错事,其后果包括性能下降、`ExpressionChanged` 以及 UI 未按预期更新等。 + +## 输入设置器,注意事项 + +### 1. 易出现故障的输入读取 + +输入设置器可能会出现问题,因为它们在每个输入单独设置时运行。在设置器中,您不能假设所有输入都有其更新值。 +当您的应用程序深深需要该更改的后果时,麻烦就开始了。我们见过人们在谈论某个话题时遇到麻烦。 + +``` +class MyComponent { + inputSubject = Subject() + @Input() value(value: MyType) { + this.inputSubject.next(value); + } +} +``` + +当您处理下一个主题时,您将使用该新值同步运行所有下游订阅者。如果这些 Observable 链也使用其他输入值,那么您刚刚创建了一个竞争条件。 + +``` +class MyComponent { + inputSubject = Subject() + @Input() value(value: MyType) { + this.inputSubject.next(value); + } + + @Input() key: string; + + constructor() { + this.inputSubject.subscribe((value) => { + update(value[this.key]) // could crash if `key` input is updated after value + }) + } +} +``` + +最好的情况是,您的订阅者将在设置下一个输入时重新运行,等等。最坏的情况是,您违反了某些不变量并崩溃。有时,输入只是碰巧以正确的顺序设置,一切正常,直到一年后有人进行随机更改,导致输入以不同的顺序设置,从而暴露了错误。 + +### 2. 变更检测期间数据流发生变化 + +更一般地说,您在更改检测期间采取的任何操作(例如,在像 `ngOnChanges` 这样的生命周期挂钩中)都有警告:您不应该针对 Angular 自上而下的单向数据流进行更改。 + +如果您只是更新本地组件状态或影响子组件的状态,那就没问题。但是,没有什么可以阻止您执行 `globalStore.dispatch(HEY_MY_INPUT_CHANGED)` 之类的操作并触发整个应用程序的更新,其中一些可能是 `NG0100 ExpressionChanged`。 + +## 利用信号进行无故障的更改 + +许多功能或信号之一是无故障:您无需考虑在更改检测过程中更新数据的限制或输入设置的顺序。 + +这就是为什么 `input()` 信号与 `computed()` 或 `effect()` 如此良好的原因。 + +``` +class MyComponent { + value = input.required(); + key = input.required(); + + private updater = effect(() => { + // No worry about the order + update(this.value()[this.key()]) + }) +} +``` + +## 笔记 + +我要感谢 [Alex Rickabaugh](https://twitter.com/synalx) 巩固了我对这个主题的想法。 diff --git a/src/content/blog/frontend/angular/lazy-loading-mockable.md b/src/content/blog/frontend/angular/lazy-loading-mockable.md new file mode 100644 index 00000000..f7b645bf --- /dev/null +++ b/src/content/blog/frontend/angular/lazy-loading-mockable.md @@ -0,0 +1,295 @@ +--- +pubDatetime: 2026-05-30T14:21:35+08:00 +title: "[译] 在 Angular 中延迟加载服务并考虑测试" +slug: lazy-loading-mockable +featured: false +draft: true +isTranslation: true +tags: + - angular + - dependency-injection + - lazy-loading +description: "我们应该能够模拟我们延迟加载的内容" +canonicalURL: https://riegler.fr/blog/2023-09-30-lazy-loading-mockable +--- + +> 原文地址: https://riegler.fr/blog/2023-09-30-lazy-loading-mockable + +构建 SPA 时,捆绑包大小是一个关键主题。它越大,启动应用程序所需的时间就越长。 +框架和开发人员越来越依赖延迟加载来延迟非必要代码的加载。最常见的是延迟加载的路由。 + +当涉及到服务时,我们可以通过使用动态 `import()` 来延迟加载它。 +我们以页面显示我们想要延迟加载的大量 WebGL 动画的情况为例。 + +我们假设 `HomeAnimation` 是 `Injectable({providedIn: 'root'})` 的 Angular 服务。 + +#### **`home.component.ts`** + +``` +class HomeComponent { + injector = inject(Injector); + ... + private async loadHomeAnimation() { + const HomeAnimation = import('./services/home-animation.service').then((c) => c.HomeAnimation), + this.injector.get(HomeAnimation); + this.homeAnimation.init(this.element); + } +} +``` + +这非常简单,但有一个主要缺点:这不允许我们在单元测试中使用模拟服务。 + +## 利用依赖注入 + +DI 是一个强大的工具,我们将在这里利用它来创建/共享实例并帮助测试。 + +首先让我们从这个辅助函数开始。 + +``` +export async function injectAsync( + injector: Injector, + providerLoader: () => Promise>, +): Promise { + const injectImpl = injector.get(InjectAsyncImpl); + return injectImpl.get(providerLoader); +} +``` + +注入器将在这里做两件事: + +- 检索 `InjectAsyncImpl` 的实例,该类提供延迟加载的实现 +- 创建我们刚刚加载的服务的实例(该类必须可供注入器使用,因此您必须将 `@Injectable({providedIn: 'root'})` 添加到要延迟加载的类中) + +`InjectAsyncImpl` 类可以非常简单: + +#### **`inject-async.ts`** + +``` +@Injectable({providedIn: 'root'}) +class InjectAsyncImpl { + async get(injector: Injector, providerLoader: () => Promise>): Promise { + const type = await providerLoader(); + + return injector.get(type) + } +} +``` + +通过此实现,我们现在可以在单元测试中重写 `InjectAsyncImpl` 的提供程序,以返回延迟加载服务的模拟实例。 + +#### **`my-test.spec.ts`** + +``` +TestBed.configureTestingModule({ + providers: [ + { provide: InjectAsyncImpl, useValue: { get () => Promise.resolve(MyMockedService) }} + ] +}); +``` + +我们当前的实现可行,但 DX 还远未达到理想水平。模拟一个延迟加载的服务很容易,我们必须为每个模拟的服务创建一个不同的类/对象。因此,让我们重构它以改进它。 + +## 改善DX + +为了简化对延迟加载服务的模拟,我们将在实现中添加对覆盖的支持。 + +#### **`inject-async.ts`** + +``` +@Injectable({ providedIn: 'root' }) +class InjectAsyncImpl { + private overrides = new WeakMap(); // no need to cleanup + override(type: Type, mock: Type) { + this.overrides.set(type, mock); + } + + async get(injector: Injector, providerLoader: () => Promise>): Promise { + const type = await providerLoader(); + + // Check if we have overrides, O(1), low overhead + if (this.overrides.has(type)) { + const module = this.overrides.get(type); + return new module(); + } + } +} +``` + +这些覆盖将由辅助函数设置 + +``` +export function mockAsyncProvider(type: Type, mock: Type) { + return [ + { + provide: ENVIRONMENT_INITIALIZER, + multi: true, + useValue: () => { + inject(InjectAsyncImpl).override(type, mock); + }, + }, + ]; +} +``` + +`ENVIRONMENT_INITIALIZER` 是一个用于初始化函数的多提供商令牌,该函数将在构建环境注入器时运行。因此,在设置每个模拟服务时都会调用 `override` 方法。 + +现在我们有了一个漂亮干净的测试设置! + +#### **`my-test.spec.ts`** + +``` + * TestBed.configureTestingModule({ + * providers: [ + * mockAsyncProvider(MyFooService, MyFakeService) + * mockAsyncProvider(MyBarService, MyBarService) + * ] + * }); +``` + +## Improving Angular integration + +当在根中提供服务时,该服务将由根注入器创建。 +这样做的一个主要缺点是永远不会触发 `DestroyRef.onDestroy`。 + +从 [official docs](https://angular.io/api/core/DestroyRef): + +> 如果 DestroyRef 被注入到组件或指令中,则回调将在该组件或指令被销毁时运行。否则,当相应的注入器被销毁时,回调将运行。 + +因此,如果我们想要一个延迟加载的服务被创建它的组件破坏,我们需要更改我们的实现。 + +我们的限制是: + +- 该服务不能是 `providedIn: 'root'`(我们希望它与 `NodeInjector` 一起被销毁) +- The service cannot be provided on the component, this would break the lazy loading +- 我们无法向注入器添加类,我们需要创建一个新类。 + +考虑到这一点,我们将添加以下内容 + +``` +if (!(injector instanceof EnvironmentInjector)) { + // We're passing a node injector to the function + + // This is the DestroyRef of the component + const destroyRef = injector.get(DestroyRef); + + // This is the parent injector of the environmentInjector we're creating + const environmentInjector = injector.get(EnvironmentInjector); + + // Creating an environment injector to destroy it afterwards + const newInjector = createEnvironmentInjector([type as Provider], environmentInjector); + + // Destroy the injector to trigger DestroyRef.onDestroy on our service + destroyRef.onDestroy(() => { + newInjector.destroy(); + }); + + // We want to create the new instance of our service with our new injector + injector = newInjector; +} +return injector.get(module)!; +``` + +如果我们回到最初的示例,这就是我们如何使用新的辅助方法来延迟加载 HomeAnimation 服务。 + +``` +export class HomeComponent { + private readonly injector = inject(Injector); // The Node injector + + ... + + private async loadHomeAnimation() { + this.homeAnimation = await injectAsync(this.injector, () => + import('./services/home-animation.service').then((c) => c.HomeAnimation), + ); + + this.homeAnimation.init(this.element); + } +} +``` + +这里重要的是注入器是 `NodeInjector` 而不是 `EnvironmentInjector`。 +前者将随组件一起被破坏,而后者将在组件中幸存下来。 + +## 最终实施 + +这是 `inject-async.ts` 辅助函数的最终实现。 + +``` +import {createEnvironmentInjector, DestroyRef, ENVIRONMENT_INITIALIZER, EnvironmentInjector, inject, Injectable, Injector, Provider, ProviderToken, Type,} from '@angular/core'; + +/** + * inject a service asynchronously + * + * @param: injector. If the injector is a NodeInjector the loaded module will be destroyed alongside its injector + */ +export async function injectAsync( + injector: Injector, + providerLoader: () => Promise>, +): Promise { + const injectImpl = injector.get(InjectAsyncImpl); + return injectImpl.get(injector, providerLoader); +} + +@Injectable({providedIn: 'root'}) +class InjectAsyncImpl { + private overrides = new WeakMap(); // no need to cleanup + override(type: Type, mock: Type) { + this.overrides.set(type, mock); + } + + async get(injector: Injector, providerLoader: () => Promise>): Promise { + const type = await providerLoader(); + + // Check if we have overrides, O(1), low overhead + if (this.overrides.has(type)) { + const module = this.overrides.get(type); + return new module(); + } + + if (!(injector instanceof EnvironmentInjector)) { + // We're passing a node injector to the function + + // This is the DestroyRef of the component + const destroyRef = injector.get(DestroyRef); + + // This is the parent injector of the environmentInjector we're creating + const environmentInjector = injector.get(EnvironmentInjector); + + // Creating an environment injector to destroy it afterwards + const newInjector = createEnvironmentInjector([type as Provider], environmentInjector); + + // Destroy the injector to trigger DestroyRef.onDestroy on our service + destroyRef.onDestroy(() => { + newInjector.destroy(); + }); + + // We want to create the new instance of our service with our new injector + injector = newInjector; + } + + return injector.get(module)!; + } +} + +/** + * Helper function to mock the lazy-loaded module in `injectAsync` + * + * @usage + * TestBed.configureTestingModule({ + * providers: [ + * mockAsyncProvider(SandboxService, fakeSandboxService) + * ] + * }); + */ +export function mockAsyncProvider(type: Type, mock: Type) { + return [ + { + provide: ENVIRONMENT_INITIALIZER, + multi: true, + useValue: () => { + inject(InjectAsyncImpl).override(type, mock); + }, + }, + ]; +} +``` diff --git a/src/content/blog/frontend/angular/performance-for-repeaters.md b/src/content/blog/frontend/angular/performance-for-repeaters.md new file mode 100644 index 00000000..6ba94ec2 --- /dev/null +++ b/src/content/blog/frontend/angular/performance-for-repeaters.md @@ -0,0 +1,121 @@ +--- +pubDatetime: 2026-05-30T14:29:54+08:00 +title: "[译] 模板中继器的默认性能" +slug: performance-for-repeaters +featured: false +draft: true +isTranslation: true +tags: + - angular + - performance + - rendering +description: "这是关于@for" +canonicalURL: https://riegler.fr/blog/2023-11-03-performance-for-repeaters +--- + +> 原文地址: https://riegler.fr/blog/2023-11-03-performance-for-repeaters + +Angular v17 中引入的主要功能之一是 [new template syntax](https://blog.angular.io/meet-angulars-new-control-flow-a02c6eee7843),称为“@-syntax”。 + +这种新语法的显着成果之一是引入了可延迟视图,这是一项强大的功能,允许开发人员从模板延迟加载组件。您可以在此 [blog post](https://riegler.fr/blog/2023-10-05-defer-part1) 中了解有关可延迟视图的更多信息。 + +除了可延迟视图之外,Angular 团队还引入了 `@for` 块,旨在取代 `ngFor` 指令。 `@for` 块提供了一种更加结构化和明确的方式来处理循环并迭代 Angular 模板中的集合。这种新方法增强了模板中控制流和声明性 HTML 之间的分离。 + +## 新的 `@for` 块 + +以下是 Angular 中新 `@for` 块的示例用法 + +``` +
        + @for (item of items; track item.id; let e = $even) { +
      • Item #{{ $index }}: {{ item.name }}
      • + } +
      +``` + +在这个例子中可以明显看出与传统结构指令的显着背离。由 `@for` 块表示的新语法在控制流和声明性 HTML 之间提供了明显的分离。 + +在 `@for` 块重复器的上下文中,您可以直接访问几个可用的隐式变量。此外,值得注意的是,您还可以为这些变量创建别名,这在处理嵌套的 `@for` 块时特别有用。 + +| 变量 | 意义 | +| -------- | -------------------- | +| `$count` | 迭代集合中的项目数 | +| `$index` | 当前行的索引 | +| `$first` | 当前行是否为第一行 | +| `$last` | 当前行是否为最后一行 | +| `$even` | 当前行索引是否为偶数 | +| `$odd` | 当前行索引是否为奇数 | + +## 性能改进 + +During each change detection cycle in Angular, when a component contains a repeater (such as an `@for` block), Angular employs a comparison algorithm to identify differences between the previous and the current iterable set. This algorithm plays a crucial role in optimizing the rendering process by reducing the number of costly changes that would otherwise be applied to the DOM. + +在 Angular 17 之前的版本中,这种比较算法在各种性能基准测试中被认为是一个显着的性能瓶颈。它有可能对 Angular 应用程序的效率产生负面影响,特别是在处理大型数据集或频繁更新时。 + +随着版本 17 中新语法的引入,Angular 团队借此机会重新审视并增强了这种比较算法。目标是使其更高效、更能响应可迭代数据的变化,最终提高 Angular 应用程序的整体性能。这种优化是 Angular 版本 17 中采用的“性能优先”方法的重要组成部分,确保在使用转发器时更改检测和渲染尽可能高效。 + +以下基准测试将 Angular 与其之前的版本以及现在与其他著名的高性能框架进行了比较。 + +| v16 `ngFor` - 与 - v17 `@for` | Angular v17 与其他框架对比 | +| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| [v16 vs v17 diffing performance comparison](/blog-images/performance-for-repeaters/16-17.png) | [Angular compared with other framworks](/blog-images/performance-for-repeaters/fw-comp.png) | + +### 性能第一 + +Angular 中使用的比较算法依赖于 track 属性来在迭代数组中的项目与其对应的 DOM 元素之间建立连接。在以前的版本中,特别是使用 `ngFor` 指令时,设置 `trackBy` 函数既是可选的,又有些麻烦。默认情况下,该行为将 DOM 元素关联到项目的引用,这导致了重大的性能挑战,尤其是在使用不可变数据集时。对一个或多个对象的任何更改都会导致比较算法将该项目与 DOM 节点关联起来,从而需要删除和重新创建 DOM 元素。 + +使用版本 17.0 中的新 `@for` 块,为 `track` 选项选择了“性能优先”方法,因此,该属性现在在 `@for` 块中是强制的。这意味着开发人员现在负责明确指定算法应如何比较提供给 `@for` 块的数组中的对象。 + +在 @for 块中设置 track 属性时,开发人员有多个选项可供选择。跟踪选项的选择取决于数据的具体特征和应用程序的要求。让我们考虑一下 `@for(item of list) { ... }` 示例: + +| 身份 | $索引 | 身份证 | +| -------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------- | +| `track: item` | `track: $index` | `track: item.id` | +| - 是 ngFor `trackBy` 中的默认值 - 仅当您不使用不变性时才是不错的选择 | - 当有疑问时,这是一个很好的默认值 - 如果项目被排序/移动/替换,将会把事情搞砸 | - 最灵活/最可靠的 - 需要每个项目有一个唯一的标识符 | + +身份跟踪器是 `ngFor` 中的默认设置,在使用不可变数据集时存在重大性能问题。由于引用在每次突变时都会发生变化,因此框架必须在每次更改时删除/创建 DOM 节点,这是一项成本高昂的操作。因此,应该避免这种情况,而选择更好的替代方案(除了像 `number`、`string` 等 JS 基元)。 + +一个好的默认设置可能是基于索引的跟踪。比较使用索引将每个项目与其 DOM 元素进行匹配。当项目在数组内未移动/重新组织时,它非常有效。 + +``` +@Component({ + template: ` +

      Should change every second but doesn't as often

      + @for(item of arr; track $index) { +
      id: {{item.id}}
      + }`, +}) +export class AppComponent { + arr = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }]; + constructor(vcr: ViewContainerRef) { + setInterval(() => { + this.arr = this.arr.sort((a, b) => 0.5 - Math.random()); // random shuffle + }, 1000); + } +} +``` + +此示例(也是 [avalable on stackblitz](https://stackblitz.com/edit/angular-for-track-index-sort?file=src%2Fmain.ts%3AL23))演示了当 `index$` 错误地不更新某些节点而中断渲染时可能会出现什么问题。将 `$index` 替换为 `item` 或 `item.id` 可解决此处的问题。 + +因此,虽然它有效,但您会发现这不是完美的默认值。 + +也许最推荐的解决方案是跟踪唯一标识符。参考唯一的 ID 为项目跟踪带来了灵活性。无论对象发生什么(可变性或不变性)。不幸的是,这仅适用于具有唯一标识符的结构,如果数据结构在设计时没有考虑到它们,则可能需要进行一些重构。 + +因此,您会看到,没有完美的解决方案,开发人员需要了解所选轨道属性的含义。但可以肯定的是,通过采用“性能第一”的方法进行设计,我们可以在控制流中明确说明幕后将发生什么。 + +### 关于迁移示意图 + +当我们讨论新的 `@for` 块时,需要注意的是,有可用的迁移原理图(可通过 `ng generate @angular/core:control-flow` 访问)帮助从基于指令的控制流过渡到新的“@-syntax”控制流。这些迁移原理图内置支持在迁移过程中处理可选的 `trackBy` 属性。 + +以下是迁移过程的处理方式: + +- **没有 `trackBy` 属性**:如果原始代码缺少 `trackBy` 属性,迁移原理图将自动将 track 属性设置为 item,这反映了以前 Angular 版本的行为。这简化了开发人员未显式定义 `trackBy` 函数的情况下的迁移过程。 +- **存在 `trackBy` 属性**:当代码包含 `trackBy` 属性时,迁移工具将保留该函数并按指定调用它 (`track: myTrackByFn($index, item)`)。这确保了 `trackBy` 函数中定义的跟踪逻辑保留在新的 `@for` 块中,从而维持可迭代数据所需的跟踪行为。 + +创建这些迁移工具所付出的努力确实值得称赞。它们极大地帮助开发人员顺利过渡到新的 @-syntax 控制流程,同时保留现有的跟踪逻辑。这体现了 Angular 团队致力于增强开发人员体验和简化升级过程的承诺,他们在这一领域的工作值得高度赞扬! 💯 + +## 提供反馈 + +新的控制流语法作为开发者预览版登陆 v17。这个稳定阶段使 Angular 核心团队能够收集有价值的反馈并完善新 API 的实现。 + +`track` 属性的要求可能会在某些开发人员之间产生一些摩擦,因为它代表了重大的行为变化。如果您对是否应该强制、可选或有后备选项有深刻的反馈或想法,可以使用 [open issue](https://github.com/angular/angular/issues/52050) 专门进行此讨论。欢迎您提出意见和建议,这将有助于此功能的持续改进。 diff --git a/src/content/blog/frontend/angular/private-fields-downleveling.md b/src/content/blog/frontend/angular/private-fields-downleveling.md new file mode 100644 index 00000000..e6ab85a2 --- /dev/null +++ b/src/content/blog/frontend/angular/private-fields-downleveling.md @@ -0,0 +1,239 @@ +--- +pubDatetime: 2026-05-30T14:33:31+08:00 +title: "[译] 了解 Angular 中的私有属性降级" +slug: private-fields-downleveling +featured: false +draft: true +isTranslation: true +tags: + - angular + - tooling +description: "以及有关 browserslist 和 Esbuild 集成到 Angular 的更多信息" +canonicalURL: https://riegler.fr/blog/2024-05-17-private-fields-downleveling +--- + +> 原文地址: https://riegler.fr/blog/2024-05-17-private-fields-downleveling + +如果您在社交网络上关注过我,您可能会看到我撰写有关 [private class properties](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties) 及其缺点的文章。 + +Here is a private properties and a property with a typescript `private` attribute. + +``` +class Foo { + #myPrivateFoo = 'foo'; // not accessible outside the class + private notSoPrivateFoo = 'foo'; // accessible at runtime outside the class +} +``` + +让我们深入探讨一下为什么在使用私有属性之前需要小心。 + +## 性能陷阱 + +目前 [Google Typescript Style Guide](https://google.github.io/styleguide/tsguide.html#class-members) 非常明确,建议不要使用私有类属性。 + +> 不要使用私有字段(也称为私有标识符) +> +> 当 TypeScript 降低级别时,私有标识符会导致大量的排放大小和性能下降,并且在 ES2015 之前不受支持。它们只能降级到 ES2015,而不能更低。同时,当使用静态类型检查来增强可见性时,它们并没有提供实质性的好处。 + +这里有一些解释,通过比较 esbuild 的 `ES2021` 中的 typescript 和 javascript 输出。 (`ES2021` 是最后一个不支持私有属性的目标)。 + +#### `Typescript class` + +``` +class Foo { + #bar = 'bar'; + + constructor() { + this.#bar + } +} +``` + +#### `Downleveled javascript class` + +``` +// Helpers functions + +var __typeError = (msg) => { + throw TypeError(msg); +}; +var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError('Cannot ' + msg); +var __privateGet = (obj, member, getter) => ( + __accessCheck(obj, member, 'read from private field'), getter ? getter.call(obj) : member.get(obj) +); +var __privateAdd = (obj, member, value) => + member.has(obj) + ? __typeError('Cannot add the same private member more than once') + : member instanceof WeakSet + ? member.add(obj) + : member.set(obj, value); + +// Class Impl + +var _bar; +class Foo { + constructor() { + __privateAdd(this, _bar, 'bar'); + __privateGet(this, _bar); + } +} +_bar = new WeakMap(); +``` + +[Live example by Esbuild](https://esbuild.github.io/try/#YgAwLjIxLjMALS10YXJnZXQ9ZXMyMDIxAGUAZW50cnkuanMAY2xhc3MgRm9vIHsKICNiYXIgPSAnYmFyJzsKCiAgIGNvbnN0cnVjdG9yKCkgewogICAgIHRoaXMuI2JhcgogICB9Cn0K)。 + +正如您所看到的,降低私有属性的级别负责将 `WeakMap` 引入 javascript 实现中。这就是前面提到的性能回归的原因。 + +## Angular、构建目标和浏览器支持 + +> 2024 年 1 月编辑:以下段落适用于 v18。 + +从 Angular 15 开始,Angular CLI 强制将 typescript 目标设置为 `ES2022`。这意味着打字稿编译器(`tsc`)输出不会降低私有属性的级别。 + +那么为什么仍然不推荐使用私有字段呢? + +让我们回到 Angular CLI 和应用程序构建器的工作原理: + +``` +flowchart LR + + +A[TS File] --> B[TSC] +B3[Some other config...] --> C +B --> C[ES Build] +B2[Browserslist] --> C +C --> D[Babel plugins] +``` + +使用应用程序生成器时,esbuild 处理来自 `tsc` 的 JavaScript 输出以及浏览器列表配置。 + +CLI 使用的默认浏览器列表配置如下: + +``` +last 2 Chrome versions +last 1 Firefox version +last 2 Edge major versions +last 2 Safari major versions +last 2 iOS major versions +Firefox ESR +``` + +[Source](https://github.com/angular/angular-cli/blob/7a539ab39ca3e0f70b3cf3b12da5a024cde72865/packages/angular/build/src/utils/supported-browsers.ts#L15-L21) + +在撰写本文时,这导致以下浏览器支持: + +``` + 'chrome124.0', 'chrome123.0', 'edge123.0', + 'edge122.0', 'firefox125.0', 'firefox115.0', + 'ios17.4', 'ios17.3', 'ios17.2', + 'ios17.1', 'ios17.0', 'ios16.6', + 'ios16.5', 'ios16.4', 'ios16.3', + 'ios16.2', 'ios16.1', 'ios16.0', + 'safari17.4', 'safari17.3', 'safari17.2', + 'safari17.1', 'safari17.0', 'safari16.6', + 'safari16.5', 'safari16.4', 'safari16.3', + 'safari16.2', 'safari16.1', 'safari16.0' +``` + +[Browserlist compatible browser](https://browsersl.ist/#q=last+2+Chrome+versions%0Alast+1+Firefox+version%0Alast+2+Edge+major+versions%0Alast+2+Safari+major+versions%0Alast+2+iOS+major+versions%0AFirefox+ESR)。 + +Esbuild 对浏览器支持哪些功能有基本的了解,并且能够降级以匹配支持的浏览器。 + +例如,如果您将 `--target=safari16` 替换为 `--target=safari15`,您将看到使用后者时您的私有属性将被降级。 [Demo here](https://esbuild.github.io/try/#YgAwLjIxLjMALS10YXJnZXQ9c2FmYXJpMTYAZQBlbnRyeS5qcwBjbGFzcyBGb28gewogI2JhciA9ICdiYXInOwoKICAgY29uc3RydWN0b3IoKSB7CiAgICAgdGhpcy4jYmFyCiAgIH0KfQo)。 + +但是...如果列表中的每个浏览器都支持私有属性,为什么我的私有属性会使用默认配置降低级别? + +## Angular 细节和部分 Safari 支持 + +虽然 Safari 支持私有属性,但当它们与静态类块一起使用时,Safari < 16.4 存在一个已知问题。 + +#### `A class with a static block` + +``` +class Foo { + static { this.foo = undefined } + #bar = 'bar'; +} +``` + +[Demo on esbuild](https://esbuild.github.io/try/#YgAwLjIxLjMALS10YXJnZXQ9c2FmYXJpMTYAZQBlbnRyeS5qcwBjbGFzcyBGb28gewogc3RhdGljIHsgdGhpcy5mb28gPSB1bmRlZmluZWQgfTsKICNiYXIgPSAnYmFyJzsKCiAgIGNvbnN0cnVjdG9yKCkgewogICAgIHRoaXMuI2JhcgogICB9Cn0K)。 + +当它们一起使用时,Esbuild 通过降低私有属性来解决该限制。 + +### Angular 的静态块 + +您自己可能没有使用静态类块,但您必须知道 Angular 会为您做到这一点。 +组件工厂 `ɵfac` 或组件元数据定义 `ɵcmp` 在静态块中定义。 + +#### `Angular Component` + +``` +@Component({ + selector: 'app-root', + standalone: true, + ... +}) +export class AppComponent { + #foo = 'foo'; +} +``` + +#### `Compiler output` + +``` +class AppComponent2 { + #foo = "foo"; + static { + this.ɵfac = function (t) { + return new (t || AppComponent2)(); + }; + } + static { + this.ɵcmp = ɵɵdefineComponent({ + selectors: [["app-root"]], + standalone: !0, + ... + }); + } +} +``` + +所以当需要支持 Safari 16 时,Esbuild 会将私有属性降级。 + +这个具体的例子就是为什么从今天开始(直到 Safari 16 超出支持范围),不建议在 Angular 项目中使用私有属性。 + +## 在 Angular 上启用本机私有属性 + +如果您想解决 Angular CLI 的默认行为,您可以通过覆盖默认的 browserslist 配置来选择退出 Safari 16 支持: + +#### `.browserslistrc` + +``` +last 2 Chrome versions +last 1 Firefox version +last 2 Edge major versions +last 1 Safari major versions +last 1 iOS major versions +Firefox ESR +``` + +## 奖金 + +如果您想更多地研究您的捆绑输出,我建议您通过禁用损坏来构建应用程序。 + +```` +class Foo { + #bar = 'bar'; + + constructor() { + this.#bar + } +} +```0 + +这样,您将能够保留所有变量和类名称,同时仍然启用诸如树摇动和死代码消除(DCE)之类的优化。 + +## 自 v19 起 + +自 v19 以来,或者更准确地说,自 browserlist 更新了“最后 2 个 iOS 主要版本”的定义以排除 v16 以来,这种情况发生在 9 月中旬发布的 iOS 18 左右,Angular CLI 的默认配置不再降低私有属性的级别。如果您依赖该默认配置,您可以安全地使用私有属性并享受它带来的微小性能改进! +```` diff --git a/src/content/blog/frontend/angular/resources-as-signals.md b/src/content/blog/frontend/angular/resources-as-signals.md new file mode 100644 index 00000000..97d39439 --- /dev/null +++ b/src/content/blog/frontend/angular/resources-as-signals.md @@ -0,0 +1,270 @@ +--- +pubDatetime: 2026-05-30T14:35:22+08:00 +title: "[译] Resource: async loading with signals" +slug: resources-as-signals +featured: false +draft: true +isTranslation: true +tags: + - angular + - signals +description: "谁说你需要效果?" +canonicalURL: https://riegler.fr/blog/2024-10-18-resources-as-signals +--- + +> 原文地址: https://riegler.fr/blog/2024-10-18-resources-as-signals + +定义 API 和围绕它们的模式是一项需要时间、反馈循环和后退一步的任务。 +随着 Signal API 在 Angular 中的成熟(其中大多数在 v19 中升级为稳定版本,`effect` 除外), +是时候看看更高级的模式了。 + +`effect` 的使用一直是一个有争议的话题,Angular 团队的框架负责人 Alex Rickabaugh 在 [recommending to use them as less as possible](https://www.youtube.com/watch?v=aKxcIQMWSNU) 上直言不讳。 +还有其他选择,通常是在 if 之上的抽象,可以更多地表达开发人员的意图。 + +## 介绍 `resource` API。 + +`resource` API 是旨在抽象 `effect` 用法并提供更具声明性的基于信号的 API 的 API 之一 +用于异步加载数据。 + +`resource` 至少需要一个返回异步数据并返回 `WritableResource` 包装对象的 `loader`。 +`loader` 函数被急切地调用(作为 `effect` 的一部分)。 + +``` +import {resource} from '@angular/core'; + +@Component({ + template: ` + value: {{ myResource.value() }} + `, +}) +export class MyComponent { + myResource: WritableResource = resource({ + loader: () => { + return Promise.resolve('My data is loaded'); + }, + }); +} +``` + +此示例将在数据加载之前不显示任何内容 (`undefined`),并在承诺解决后显示 `My data is loaded`。 + +## 加载状态作为关键信息 + +当我们异步加载数据时,跟踪加载状态非常重要。 +正在加载吗?完成了吗?我收到错误了吗? + +众所周知,`isLoading` 布尔值被认为是一种反模式,因为它缺乏大量相关信息。 + +一个`WritableResource`包含3个关键信息。 + +- `value`,加载的信号包装值 +- `status`,加载状态的信号包裹值(`'idle' | 'error' | 'loading' | 'refreshing' | 'resolved' | 'local'`) +- `error`,一个信号包装对象,用于显示错误的详细信息,加载导致错误。 + +如果我们在 `effect` 中捕获这一点,我们将观察到以下内容: + +#### `Capturing resource changes` + +``` +effect(() => { + console.log('value: ', this.myResource.value()); + console.log('status: ', this.myResource.status(), myResource.status()); + console.log('error: ', this.myResource.error()); +}); +``` + +#### `Output` + +``` +value: undefined +status: 0 Idle +error: undefined + +value: 'My data is loaded' +status: 3 Resolved +error: undefined +``` + +## 获取数据 + +您可能会使用 `resource` 通过 HTTP 获取异步数据,但它也可以是基于承诺的任何其他异步 API。 +我们以使用 `fetch` 为例。 + +### `Basic fetch resource example` + +`fetch` 返回一个承诺,该承诺被传递给作为加载器的 `resource`。 `fetch` 请求立即发出(作为 `effect()` 的一部分),无需 +任何使用 `WritableResource` 的模板或附加 `effect`。 + +``` +postResource = resource({loader: () => fetch(`https://dummyjson.com/posts/1`)}); +``` + +## 对信号变化做出反应 + +缺失的组成部分之一是对信号变化做出反应并因此重新加载数据。 +`resource` API 通过将依赖项声明为 `request` 的一部分来满足此要求。 + +### 聆听信号变化 + +The dependencies are declared as part of the `request` callback. + +``` +postResource = resource({ + request: this.postId, // this could be one of your signal inputs + loader: ({request: postId}) => return fetch(`https://dummyjson.com/posts/${postId}`), +}); +``` + +作为加载器回调的一部分读取的信号不会在更改时触发进一步加载 +(`loader` 的执行是`untracked`)。 + +如果您问自己为什么不跟踪加载程序函数,那是因为加载程序函数的异步部分不会被跟踪。 +因此,为了使其反应性更加清晰,反应部分 (`request`) 被分离为加载部分 (`loader`)。 + +#### `If the loader was a reactive context` + +``` +postResource = resource({ + request: () => this.postId(), + loader: async ({request}) => { + // signals can be tracked + const response = await fetch(`https://dummyjson.com/posts/${request}`); + const result = await response.json(); + // signals can't be tracked + }, +}); +``` + +### 聆听多个信号变化 + +更现实的用例是一次监听多个信号并对变化做出反应 +他们每个人的。 + +``` +postResource = resource({ + request: () => ({limit: this.limit(), filter: this.filter(), select: this.select()}), + loader: ({request: {limit, filter, select}}) => { + return fetch(`https://dummyjson.com/posts/search?q=${filter}&limit=${limit}&select=${select}`); + }, +}); +``` + +### 跳过请求 + +加载程序根据请求本身有条件地触发。如果它返回 `undefined`,加载程序将不会启动。 +这样您就可以跳过某些请求(状态保持为 `idle`,直到第一个请求触发)。 + +``` +postResource = resource({ + request: () => (this.postId() > 5 ? this.postId() : undefined), + loader: ({request}) => { + return fetch(`https://dummyjson.com/posts/${request}`).then((res) => res.json()); + }, +}); +``` + +### 完整的获取资源示例 + +更现实的是,我们会将响应解析为 JSON,并可能想要插入 `abortSignal`。 +当其中一个信号接收到新值时,`abortSignal` 将用于取消待处理的请求。 + +``` +postResource = resource({ + request: () => ({limit: this.limit(), filter: this.filter(), select: this.select()}), + loader: ({request: {limit, filter, select}, abortSignal}) => { + return fetch(`https://dummyjson.com/posts/search?q=${filter}&limit=${limit}&select=${select}`, { + signal: abortSignal, + }).then((res) => res.json() as Promise); + }, +}); +``` + +## 刷新数据 + +如果您的资源数据在服务器上失效,想象一下删除帖子,例如您想刷新本地数据 +从服务器获取最新状态。为此,您将使用 `reload()` 方法。 + +如果您的资源尚未加载 (`status() === 'loading'`),`reload()` 将设置您的资源状态 +到 `'reloading'`,并发送新请求。 +与此同时,您的资源将保留其先前的值,直到收到新的值。 + +``` +import {resource} from '@angular/core'; + +@Component() +export class MyComponent { + postResource = resource({ + loader: () => { + return fetch(`https://dummyjson.com/posts/search?limit=${limit}`).then( + (res) => res.json() as Promise, + ); + }, + }); + + /* Invalidate local data & request the new state */ + reload() { + this.postResource.reload(); + } +} +``` + +如果您想取消任何待处理的请求,`reload()` 它不是您正在寻找的工具。 +`resource` will cancel any request on dependency changes.这些在 `request` 属性中表示为信号。 + +## 作为可写本地状态的资源 + +使用 `resource` API 时,您将创建一个 `WritableResource`。这个`WritableResource`代表一个本地状态 +您的服务器上的资源。 + +该状态也是本地可写的,`WritableResource.set()` 将允许您以编程方式设置本地值。 +覆盖资源的值会将其设置为 `status` 符号到 `'local'`。 + +如果您想将此资源公开为只读,则 `WritableResource.asReadonly()` 将返回一个只读 `Ressource`,该资源可以与应用程序的其他部分共享,从而冒暴露可写行为的风险。 + +## RxJS 作为一等公民:`rxResource` + +Angular 团队已经多次提到过,RxJS 是一等公民, +虽然 Angular 不应该要求您学习/使用 RxJS,但将 RxJS 与 Angular 一起使用应该感觉像是一种自然、优雅的体验。 + +到目前为止,我们只讨论了 `resource` 作为基于 Promise 的 API。 v19 还将引入基于 `Observable` 的对应版本:来自 `@angular/core/rxjs-interop` 模块的 `rxResource`。 + +### 基于可观察的示例 + +```` +effect(() => { + console.log('value: ', this.myResource.value()); + console.log('status: ', this.myResource.status(), myResource.status()); + console.log('error: ', this.myResource.error()); +}); +```0 + +与 `resource` 相同,`rxResource` 返回 `WritableResource`。 +每次 `request` 中注册的信号发生变化时,加载程序(此处为 http 请求)将重新发出,并更新 `WritableResource` 及其信号。 + +#### 单发射陷阱 + +需要注意的是,如果返回到加载器的可观察对象发出多个值,则只有第一个值会传递给资源。 +这是因为资源在实际收到新值之前无法知道新请求何时开始。 + +#### `not a pulling example` + +```` + +effect(() => { +console.log('value: ', this.myResource.value()); +console.log('status: ', this.myResource.status(), myResource.status()); +console.log('error: ', this.myResource.error()); +}); + +```1 + +此示例不会实现基于信号的拉动。如前所述,只有第一个值会传递给资源。 +仅当发出 `postId` 信号时才会发出新请求。 + +## 最后的话 + +`resource` API 及其互操作对应项作为 [experimental APIs](https://angular.dev/reference/releases#experimental) 发布。 +这意味着它们可能根本不会变得稳定,或者在稳定之前发生重大变化。 + +请随时向团队报告 [any feedback](https://github.com/angular/angular/issues/new)。 +``` diff --git a/src/content/blog/signal-decision-chart.md b/src/content/blog/frontend/angular/signal-decision-chart.md similarity index 90% rename from src/content/blog/signal-decision-chart.md rename to src/content/blog/frontend/angular/signal-decision-chart.md index 9eeea37c..d221ca69 100644 --- a/src/content/blog/signal-decision-chart.md +++ b/src/content/blog/frontend/angular/signal-decision-chart.md @@ -6,13 +6,13 @@ featured: false draft: false isTranslation: true tags: - - Angular - - Signal + - angular + - signals description: 一张帮助你在 Angular 中选择合适的 Signal 原语的决策图。 -canonicalURL: http://riegler.fr/blog/2025-01-12-signal-decision-chart +canonicalURL: https://riegler.fr/blog/2025-01-12-signal-decision-chart --- -> 原文地址: http://riegler.fr/blog/2025-01-12-signal-decision-chart +> 原文地址: https://riegler.fr/blog/2025-01-12-signal-decision-chart ![封面图片](/blog-images/signal-decision-chart/alex-king-lbwjS4QdpNU.jpg) diff --git a/src/content/blog/frontend/angular/signals-subscriptions.md b/src/content/blog/frontend/angular/signals-subscriptions.md new file mode 100644 index 00000000..84f42dbb --- /dev/null +++ b/src/content/blog/frontend/angular/signals-subscriptions.md @@ -0,0 +1,177 @@ +--- +pubDatetime: 2026-05-30T14:27:54+08:00 +title: "[译] 信号和订阅" +slug: signals-subscriptions +featured: false +draft: true +isTranslation: true +tags: + - angular + - signals +description: "我需要取消订阅吗?" +canonicalURL: https://riegler.fr/blog/2023-10-24-signals-subscriptions +--- + +> 原文地址: https://riegler.fr/blog/2023-10-24-signals-subscriptions + +距离 v17 发布还有几天,我们来讨论一下 Signals 吧! + +## 我们来谈谈信号 + +首先,让我们探讨一下信号的概念。为了简洁的介绍,我将引用 [official docs](https://angular.io/guide/signals): + +> Angular Signals 是一个系统,可以精细地跟踪整个应用程序中状态的使用方式和位置,从而允许框架优化渲染更新。 + +为了实现这种跟踪,信号实现了生产者/消费者模式。其中每个 `Signal` 充当生产者,调用它们的模板充当消费者。 + +``` +function signal(initialValue: T) { + const reactiveNode = { + producerNodes: [], + consumerNodes: [], + value: initialValue; + } + const getter = (() => { + producerAccessed(reactiveNode); + return reactiveNode.value; + }); + (getter as any)[SIGNAL] = reactiveNode; + return getter; +} +``` + +本质上,`Signal` 是一个可调用对象,有两个主要用途: + +- 通知反应树正在读取信号 +- 返回它的值 + +常规信号和计算信号以及模板都被视为 ReactiveNode。 ReactiveNode 是一个维护消费者列表并了解其生产者的对象。 + +当在响应式上下文(例如模板执行)中调用信号时,`producerAccessed()` 函数将消费者(模板)注册到生产者(信号),并且消费者保留对其生产者的引用。 + +``` +flowchart LR + +A["mySignal = signal(42)\n consumers: [A,B,C]"] +A --- B("MyCompA_Template\n {{ mySignal() }}\n producers:[mySignal]") +A --- C("MyCompB_Template\n {{ mySignal() }}\n producers:[mySignal]") +A --- D("MyCompC_Template\n {{ mySignal() }}\n producers:[mySignal]") +``` + +从某种意义上说,模板在调用信号时会订阅信号。因此,当信号更新时,它可以通知每个消费者并将它们标记为“脏”。当消费者是模板时,这个过程相当于调用`changeDectectorRef.markForCheck`。这可确保您的模板将在下一个 CD(更改检测)周期中接受更改检测。 + +现在,当模板被销毁时会发生什么?您需要手动订阅信号吗? + +当然不是! Angular 框架为我们处理信号注销。就像 `AsyncPipe` 自动订阅和取消订阅 `Observable` 一样,它管理信号的订阅和取消订阅! + +当组件被销毁或模板的一部分被删除时(例如,当 `ngIf` 或 `@if` 条件变为 false 时),将调用 `consumerDestroy` 函数。消费者维护对其生产者的引用,通知他们从消费者列表中删除。 + +就这么简单,我们可以无缝订阅/取消订阅信号! + +### `computed` 信号怎么样? + +`computed()` 函数允许我们编写信号。该函数的回调也是一个反应式上下文。这意味着两件事: + +- 计算出的信号是在其回调中调用的 1+ 个信号的使用者 +- 这个计算信号是任何调用它的消费者的生产者 + +在不涉及太多细节的情况下,该框架区分了实时消费者和非实时消费者。 + +`effect()` 或模板始终是实时消费者:在任何情况下他们都不希望从生产者处获得更新。 +与计算信号相反,计算信号只有被另一个实时消费者消费时才是实时的。 + +非实时计算信号不会对生产者更新做出反应,也不会将其消费者标记为脏。 + +节点之间的生产者-消费者关系构建了一个由活跃消费者驱动的真实依赖图。如果图的一端至少有一个活跃的子消费者,节点将计算它们的值。如果不是,则可以节省计算时间。 + +``` +flowchart TB + +A(Signal) +A2(Signal) +A3(Signal) +A4("Signal\nlive") +classDef live fill:#adebad +classDef nonlive fill:#ffb3b3 + +A:::live ---> B(Computed) +A ---> C("Computed\nlive") +A2:::live ---> E(Computed) +A3:::live ---> D +A4:::nonlive ---> J +A:::live ---> D(Computed) +B:::live ---> E +B ---> J("Computed \n live") +B ---> F(Computed) +D:::live ---> F +D ---> F +E:::live ---> G{{Template}} +F:::live ---> H[/Effect\] +B -----> I{{Template}} + +G:::live +H:::live +I:::live + +C:::nonlive +J:::nonlive +``` + +这就是反应图的样子,其中包含实时和非实时信号,具体取决于叶子消费者! + +### 从这里继续前进 + +您会看到,通过信号,我们可以实现基于推送的变更检测。当信号更新时,会发生变化检测。 + +尽管 CD 调度目前仍需要 `zone.js`,但这是迈向“无区域”的重要一步。信号更新时,模板被标记为脏,但不会立即检测到更改。出于性能原因,在每次信号更新时调用 `detectChanges` 并不是一个可行的解决方案(想象 10 个更新信号触发 10 个 CD 周期)。 + +虽然这个主题几乎还在开发中,但一些令人兴奋的变化已经在进行中! + +例如,[this PR](https://github.com/angular/angular/pull/52302) 引入了一种新的部分更改检测方法。它没有为 CD 标记每个父组件(如 `markForCheck` 所做的那样,递归调用直至根组件),而是仅注册请求 CD 的组件并跳过父组件。这就是 `markAncestorsForTraversal` 所完成的任务。 + +## 我们来谈谈可观察到的 + +使用信号时,您经常会与 `Observables` 交互。这两个域之间的互操作性是通过 `toSignal` 等方法将 `Observable` 转换为 `Signal` 提供的。 + +正如您所知,从 `Observable` 读取值的唯一方法是将 `subscribe()` 写入它。 + +这正是 toSignal 所做的,如基本实现中所示: + +``` +export function toSignal(source: Observable, option: {initialValue: T}): Signal { + const state = signal(options); + + source.subscribe({ + next: value => state.set(value), + }); +``` + +但是 observable 如何取消订阅呢? + +在 [previous article](blog/2023-09-30-lazy-loading-mockable) 中,我讨论了 v16 中引入的一项功能:[`DestroyRef`](https://angular.io/api/core/DestroyRef)。 `DestroyRef` 允许您设置清理或销毁行为的回调。使用 `DestroyRef.onDestroy()` 注册的回调在调用它的注入器被销毁时执行。 + +框架再次为我们处理取消订阅操作,如更新的实现所示: + +``` +export function toSignal(source: Observable, option: {initialValue: T}): Signal { + const state = signal(options); + + const sub = source.subscribe({ + next: value => state.set(value), + }); + + cleanupRef?.onDestroy(() => sub.unsubscribe()); +``` + +反应效应依靠相同的机制来通知其生产者其销毁情况。 `DestroyRef.onDestroy` 触发 `effect()` 后面的观察者的 `destroy()` 方法。 + +但需要注意的是,与 `takeUntilDestroy` 类似,`DestroyRef.onDestroy()` 仅在注入器被销毁时才会被调用。当你在组件中调用`toSignal()`时,注入器就是`NodeInjector`,它会与组件一起被销毁。但是,如果在具有 `providedIn: 'root'` 的服务中调用 `toSignal()`,则注入器是根注入器,它几乎永远不会被破坏。所以,要警惕这种行为。 + +### 参考 + +- [`producerAccessed()`](https://github.com/angular/angular/blob/d82d58621ecc3ec4dfc40bccdb9daa7e646788e2/packages/core/primitives/signals/src/graph.ts#L185C40-L185C40) +- [`consumerDestroy()`](https://github.com/angular/angular/blob/d82d58621ecc3ec4dfc40bccdb9daa7e646788e2/packages/core/primitives/signals/src/graph.ts#L372) +- [`markAncestorsForTraversal()`](https://github.com/angular/angular/blob/d82d58621ecc3ec4dfc40bccdb9daa7e646788e2/packages/core/src/render3/util/view_utils.ts#L222C27-L222C27) +- [`toSignal()`](https://github.com/angular/angular/blob/d82d58621ecc3ec4dfc40bccdb9daa7e646788e2/packages/core/rxjs-interop/src/to_signal.ts#L96) + +如果您对信号背后的实现感兴趣的话,还有关于信号的主题。我真的推荐 [live coding session of Rainer Hahnekamp](https://www.youtube.com/watch?v=6wWteAza_FY),他在其中重新实现了信号,这有助于理解它们是如何工作的。我将值得你花时间! diff --git a/src/content/blog/signals-unified-reactivity-model.md b/src/content/blog/frontend/angular/signals-unified-reactivity-model.md similarity index 99% rename from src/content/blog/signals-unified-reactivity-model.md rename to src/content/blog/frontend/angular/signals-unified-reactivity-model.md index 46d77166..bca82cb7 100644 --- a/src/content/blog/signals-unified-reactivity-model.md +++ b/src/content/blog/frontend/angular/signals-unified-reactivity-model.md @@ -8,7 +8,6 @@ tags: - vue - angular - signals - - frontend - reactivity description: "把 Vue 2/3、Angular Signals、Solid.js 与 TC39 Signals Proposal 放进同一张坐标系:依赖追踪、变化传播、副作用调度。理解不同框架响应式系统背后的共同模型,而不是只记 API。" --- diff --git a/src/content/blog/translate-angular-interceptors.md b/src/content/blog/frontend/angular/translate-angular-interceptors.md similarity index 99% rename from src/content/blog/translate-angular-interceptors.md rename to src/content/blog/frontend/angular/translate-angular-interceptors.md index 937d2a99..bee8a1c8 100644 --- a/src/content/blog/translate-angular-interceptors.md +++ b/src/content/blog/frontend/angular/translate-angular-interceptors.md @@ -5,7 +5,6 @@ slug: translate-angular-interceptors featured: false draft: false tags: - - translation - angular description: "有许多种方式使用拦截器,我确定我们大多数人使用的很浅显。在这篇文章中,我将介绍在 Angular 中我最喜欢的 10 种使用拦截器的方式。 我使例子尽可能的简洁。我希望他们能够启发你们去思考使用拦截器的新方式。这篇文章不是关于拦截器教学的,因为已经有很多好的文章了。但是,在开始倒数之前,让我们以一些" --- diff --git a/src/content/blog/frontend/angular/tree-shakable-as-default.md b/src/content/blog/frontend/angular/tree-shakable-as-default.md new file mode 100644 index 00000000..4f9cf84c --- /dev/null +++ b/src/content/blog/frontend/angular/tree-shakable-as-default.md @@ -0,0 +1,95 @@ +--- +pubDatetime: 2026-05-30T16:10:00+08:00 +title: "[译] 默认具备 Tree-shaking 能力" +slug: tree-shakable-as-default +featured: false +draft: true +isTranslation: true +tags: + - angular + - dependency-injection + - tooling +description: "构建 Angular 库时,如何利用 providedIn: 'root' 与 useFactory 让抽象服务默认具备 Tree-shaking 能力,避免把未使用的代码带进最终产物。" +canonicalURL: https://riegler.fr/blog/2023-10-08-tree-shakable-as-default +--- + +> 原文地址: https://riegler.fr/blog/2023-10-08-tree-shakable-as-default + +![封面图片](/blog-images/tree-shakable-as-default/from-marwool-GbY8Xg5iTOA-unsplash.jpg) + +在构建一个库时,你写下的代码最终未必都会被用到。如果你在编写功能时没有把 Tree-shaking 放在心上,未使用的代码就可能进入最终产物。这并不好,未使用的代码本该在构建时被剔除。它就像背着一背包石头去跑步,毫无用处,只会拖慢你。 + +因此,在编写库时,最好从一开始就以“默认具备 Tree-shaking 能力”为目标。也就是说,你要从设计层面确保代码可以被 Tree-shaking 移除。 + +## 带默认 Provider 的抽象服务 + +在 Angular 框架中编写抽象服务时,你很可能会采用标准的 `@Injectable()`,再配上一个 provider 以及一个 `provideXXXX()` 函数,以便将默认实现保持为私有。 + +```ts +// my.service.ts +@Injectable() +export abstract class MyService {} + +// default.service.ts +/** + * @private // 这个类是私有的,我们不希望暴露它 + */ +@Injectable() +export class DefaultService() + +// providers.ts +provideAllFunctionalities() { + return [ + // ... 其他 providers 放在前面 + { provide: MyService, useClass: DefaultService } + // ... 其他 providers 放在后面 + ] +} +``` + +从功能上讲,这段代码完全能运行。对于一个不需要对服务做 Tree-shaking 的应用来说,它也完全说得通。可问题在于,`MyService` 和 `DefaultService` 都出现在 providers 数组里,因此任何调用这个函数的应用都无法把这些服务通过 Tree-shaking 剔除掉。 + +但在库的场景下,这就不太理想了:未使用的代码应该在构建时被剔除。 + +## 利用 `providedIn: 'root'` + +到了这里,该如何让这个服务具备 Tree-shaking 能力呢? + +在 v6 中,Angular 团队为服务引入了一个非常棒的特性,用来开箱即用地支持 Tree-shaking:`providedIn:'root'`。 + +当你使用 `@Injectable(providedIn: 'root')` 时,就不再需要显式声明 providers。Angular 会在服务在某处被注入时,自动把该服务的 provider 注册到根注入器上。 + +这正是我们将要用来为服务提供默认实现的工具。 + +首先,我们让服务使用 `providedIn: 'root'`,从而使它在整个应用中都可用。 + +```ts +@Injectable({provideIn:'root'}) +export class DefaultService() { + constructor(/* 这里放你需要的依赖 */) {} +} +``` + +接着,你可以把这个服务声明为你想注入的那个 Token 的默认实现。为此,我们要通过 `useFactory` 指定默认工厂。同时,我们也给 `MyService` 加上 `providedIn: 'root'`,让它本身也具备 Tree-shaking 能力。 + +```ts +@Injectable({ + providedIn: "root", + useFactory: () => inject(DefaultService), +}) +export abstract class MyService {} +``` + +因此,借助 `providedIn: 'root'`,我们无需调用 `provideAllFunctionalities`,也能直接 `inject(MyService)`。而 `useFactory` 则让 `DefaultService` 成为默认提供的实例。 + +这就是让一个抽象服务默认具备 Tree-shaking 能力的方式。 + +## 这种模式在 Angular 代码库中的应用 + +这种模式在 Angular 代码库中正被越来越多地采用,下面是几个例子: + +- [LocationStrategy](https://github.com/angular/angular/blob/00128e38538f12fe9bc72ede9b55149e0be5ead0/packages/common/src/location/location_strategy.ts#L33) +- [TitleStrategy](https://github.com/angular/angular/blob/00128e38538f12fe9bc72ede9b55149e0be5ead0/packages/router/src/page_title_strategy.ts#L38) +- [RouteReuseStrategy](https://github.com/angular/angular/blob/00128e38538f12fe9bc72ede9b55149e0be5ead0/packages/router/src/route_reuse_strategy.ts#L41) +- [UrlHandlingStrategy](https://github.com/angular/angular/blob/00128e38538f12fe9bc72ede9b55149e0be5ead0/packages/router/src/url_handling_strategy.ts#L20) +- [UrlSerializer](https://github.com/angular/angular/blob/00128e38538f12fe9bc72ede9b55149e0be5ead0/packages/router/src/url_tree.ts#L349) diff --git a/src/content/blog/frontend/angular/understanding-effects.md b/src/content/blog/frontend/angular/understanding-effects.md new file mode 100644 index 00000000..219a3ecd --- /dev/null +++ b/src/content/blog/frontend/angular/understanding-effects.md @@ -0,0 +1,222 @@ +--- +pubDatetime: 2026-05-29T20:07:42+08:00 +title: "[译] 理解 effect" +slug: understanding-effects +featured: false +draft: true +isTranslation: true +tags: + - angular + - signals + - effect + - change-detection +description: 本文介绍 Angular v19 中 Root effect 与 View effect 的差异、调度时机,以及它们在渲染与测试中的实际影响。 +canonicalURL: https://riegler.fr/blog/2024-10-15-effect-context +--- + +> 原文地址: https://riegler.fr/blog/2024-10-15-effect-context + +![封面图](/blog-images/understanding-effects/vackground-com-ZNkiEWL02mI-unsplash.jpg) + +# 理解 effect + +**v19 中的 Root effect 与 View effect** + +Matthieu Riegler - 2024 年 9 月 18 日 + +与其他 signal API 不同,`effect` 还没有稳定下来,目前仍处于 developer preview(开发者预览)阶段。这是有原因的:调度机制和响应式上下文仍需要根据开发者反馈做一些细致调整。 + +effect 是一种结构:当它读取的 signal 发生更新时,就会执行对应的响应式函数。它通过 `effect()` 函数来声明: + +#### `effect` 定义 + +``` +function effect(fn: (cleanupFn: CleanupFn) => void, options?: EffectOptions): EffectRef; + +interface EffectOptions { + injector?: Injector; + forceRoot?: true; + manualCleanup?: boolean; +} +``` + +## 与上下文相关的 effect + +effect 可以分为两类:Root effect 和 View effect。创建出来的是哪一种,取决于调用 `effect()` 函数时所处的上下文。如果在组件内部调用,就会生成 View effect;否则就会生成 Root effect。 + +需要注意的是,Angular 会根据调用上下文自动为你决定创建哪种 effect。你不需要显式在 View effect 和 Root effect 之间做选择。也可以通过 `forceRoot` 标志覆盖这一行为,强制创建 Root effect。 + +## Root effect + +Root effect 是应用中的顶层 effect,不属于任何组件层级,也独立于组件更新。例如,在根服务中创建的 effect 就是 Root effect。 + +它们适合用于以下操作: + +- 将状态变化传播到其他 signal 上(当 `computed` 不适用时)。 +- 将状态与后端或某些本地存储保持同步。 +- 执行与组件无关的渲染(例如与其他框架集成)。 +- 日志记录 / 调试。 + +Root effect 通过 macrotask 来调度:在每次 `ApplicationRef.tick` 时(并且存在 dirty 的 Root effect 时)执行。它们按 FIFO 顺序排队:先变 dirty 的 effect 会先执行。 + +dirty 的 Root effect 会一直运行到队列清空为止,这带来的一个特殊结果是:你有时会看到 effect 以一种近乎同步的方式运行。 + +```ts +sig = signal(0); + +#myRootEffect = effect(() => { + if (this.sig() < 5) { + console.log(this.sig()); + this.sig.update(s => s + 1); + } +}); +``` + +这段示例代码会在执行任何变更检测之前先打印 5 次日志。 + +在单元测试中,你可以用 `TestBed.flushEffects()` 刷新 Root effect。 + +## View effect + +View effect 位于组件层级之中,并作为变更检测周期的一部分执行。由于这个时机,View effect 可以用来响应 input signal 的变化,或更新子组件中使用的状态(包括创建和销毁子视图)。 + +之所以需要 View effect,主要是因为有两个重要使用场景 / 关注点是 Root effect 无法覆盖的: + +1. Signal input。 + +Input signal 会在变更检测过程中被设置。这个时机对那些要监听它们的 effect 很重要。更关键的是,必填的 signal input 在收到第一个值之前是不能读取的。这意味着,在组件 inputs 完成设置之前,effect 不能被调度。 + +尤其关键的是,`input.required` signal 在收到初始值之前不允许被读取。这意味着,在组件 inputs 完成设置之前,effect 不能被调度。 + +2. 会影响组件状态或子组件状态的 effect。 + +在组件中创建 effect 的一个重要原因,是为了对 input 变化作出反应,并更新组件自身或子组件的状态:要么为组件或其子组件派生出新值,要么创建或销毁嵌入视图。例如,下面的 effect 实现了 `@if` 控制流的响应式版本: + +#### `需要在 CD 之前运行的 effect` + +```ts +const show = computed(() => !!cond()); +let view; +effect(() => { + if (show()) { + view = this.vcr.createEmbeddedView(this.childView); + } else { + view.destroy(); + } +}); +``` + +现在设想这个 effect 在变更检测之后才运行,会带来两个主要后果: + +- 如果创建了一个子视图,它就不得不再调度一次新的变更检测周期,这会造成低效。 +- 如果 `cond` 变成 `false`(因此 `show` 也会变成 `false`),那么这个嵌入视图会在被销毁之前先参与一次变更检测。这会破坏该 effect 试图维持的不变量,而且这个嵌入视图会在拿到它本不该处理的空值时执行变更检测。 + +换句话说,那些会影响子节点渲染的 effect,必须在这些子节点进入变更检测之前运行。否则既可能打破不变量、导致崩溃,也会带来性能低效。View effect 正是为此提供保证。因此,View effect 会在变更检测期间、每次组件检查开始时运行。 + +可以看[这个 stackblitz 示例](https://stackblitz.com/edit/angular-18-component-effect-timing?file=src%2Fmain.ts),了解 v18 effect 时序下的这个问题。 + +### 调度 + +View effect 会与模板中的某个特定节点关联。比如下面这个模板: + +```html +
      ...
      + +``` + +`tooltipDirective` 的 effect 会在更新过程走到 `
      ` 节点时执行,而 `` 和 `childDirective` 的 effect 会在更新过程走到 `` 节点时执行。这与当前指令生命周期钩子和 host binding 所使用的机制相同。 + +View effect 应该在 host binding 之前运行,因为它们可能会更新 host binding 会读取的状态。这个时机与 `ngOnChanges` / `ngDoCheck` 类似。需要注意的是,这要求关联节点的父视图被刷新,才能触发该 effect。通常这本来就会发生(最常见的情况是,该 effect 正是由这个视图里的某个 input signal 触发的),但如果触发 effect 的 signal 与父视图毫无关系,这种方式就不是最优的。我们当然可以投入一些工作来消除这种低效,但它未必值得引入额外复杂度。 + +### `AfterRenderEffect` + +既然 View effect 会在组件更新与变更检测之前触发,我们仍然需要一种在应用完全渲染后才触发的响应式原语。 + +`AfterRenderEffect` 就是为此而设计的,它会在特定阶段执行已注册的 effect。 + +- `earlyRead` + - 在后续 `write` 回调执行之前,使用这个阶段从 DOM 中**读取**数据,例如执行浏览器原生并不支持的自定义布局。如果读取操作可以等到 write 阶段之后,优先使用 `read` 阶段。**绝不要**在这个阶段向 DOM 写入。 +- `write` + - 使用这个阶段向 DOM **写入**。**绝不要**在这个阶段从 DOM 读取。 +- `mixedReadWrite` + - 使用这个阶段同时读取和写入 DOM。只要有可能把工作拆分到其他阶段,就**绝不要**使用这个阶段。 +- `read` + - 使用这个阶段从 DOM **读取**。**绝不要**在这个阶段向 DOM 写入。 + +#### `afterViewEffect` + +```ts +afterRenderEffect({ + earlyRead: () => ..., + write: () => ..., + mixedReadWrite: ..., + read: () => ..., +}); +``` + +## 错误处理与测试 + +由于这两种 effect 都是在变更检测过程中运行的,它们都会向变更检测的 `ErrorHandler` 上报错误。这意味着,如果 effect 内部发生错误,异常会在顶层被抛出,也就是在 `ApplicationRef.tick()` 上。 + +下面是一个在 effect 执行期间检查错误的例子。 + +```ts +it("should throw error...", () => { + // 创建一个会抛出异常的 effect + const appRef = TestBed.inject(ApplicationRef); + effect( + () => { + throw new Error("fail!"); + }, + { injector: appRef.injector } + ); + + // 显式运行变更检测,并检查抛出的异常 + expect(() => appRef.tick()).toThrowError("fail!"); +}); +``` + +对于更复杂的错误处理场景,我们可以转而监听 `ErrorHandler` 本身。 + +由于 effect 执行期间发生的异常会冒泡到变更检测流程中,因此在测试里需要通过设置 `rethrowApplicationErrors: false,` 来关闭这一行为(否则 `tick()` 会直接抛错)。 + +下面是更新后的示例,其中异常通过 `ErrorHandler` 来检查。 + +```ts +it("should throw error...", () => { + let lastError: any = null; + class FakeErrorHandler extends ErrorHandler { + override handleError(error: any): void { + lastError = error; + } + } + + TestBed.configureTestingModule({ + providers: [ + { provide: ErrorHandler, useFactory: () => new FakeErrorHandler() }, + ], + // 确保 tick() 不会抛出异常。 + rethrowApplicationErrors: false, + }); + + // 创建一个会抛出异常的 effect + const appRef = TestBed.inject(ApplicationRef); + effect( + () => { + throw new Error("fail!"); + }, + { injector: appRef.injector } + ); + + // 显式运行变更检测 + appRef.tick(); + // 检查上报的错误 + expect(lastError.message).toBe("fail!"); +}); +``` + +## 最后 + +正如 [Angular 博客中关于 `effect` 的文章](https://blog.angular.dev/latest-updates-to-effect-in-angular-f2d2648defcd) 所提到的,由于时序相关的改动,`effect` 仍会保持在 developer preview 阶段,以收集更多反馈。 + +如果你在这些变更中遇到问题,欢迎[提交 issue](https://github.com/angular/angular/issues/new)。 diff --git a/src/content/blog/frontend/angular/v17-builders.md b/src/content/blog/frontend/angular/v17-builders.md new file mode 100644 index 00000000..f7b7065e --- /dev/null +++ b/src/content/blog/frontend/angular/v17-builders.md @@ -0,0 +1,164 @@ +--- +pubDatetime: 2026-05-30T14:26:34+08:00 +title: "[译] ESBuild-based builder, to infinity and beyond" +slug: v17-builders +featured: false +draft: true +isTranslation: true +tags: + - angular + - tooling +description: "v17 构建者方面的新增内容" +canonicalURL: https://riegler.fr/blog/2023-10-13-v17-builders +--- + +> 原文地址: https://riegler.fr/blog/2023-10-13-v17-builders + +最近,人们对基于 ESBuild 的构建器抱有相当大的期待,该构建器在 Angular v16 中作为开发者预览版引入。在本文中,我们将深入探讨与这一令人兴奋的新功能相关的最新进展和见解。 + +## 趋于稳定 + +自从基于 ESBuild 的构建器诞生以来,Angular 工具团队就积极收集社区的反馈。通过对该构建器的最新增强,团队现在有足够的信心将其在 Angular v17 中提升到稳定状态。 + +此更新带来了重大变化 - 基于 ESBuild 的构建器现在将成为使用 CLI 创建新项目时的默认构建器。此外,默认的项目创建模式现在将是“独立”。 + +值得注意的是,现有的基于 Webpack 的构建系统将继续获得对希望继续使用它的应用程序的全面支持。 + +## 应用程序构建者,我们团结起来构建 + +Angular v17 引入了全新的应用程序构建器。这个单一的构建器是一个多功能的强大工具,支持浏览器应用程序、预渲染和服务器端渲染 (SSR)。 + +好消息是您已经可以探索此功能了。首先,您可以使用预发布 CLI 创建一个新项目: + +``` +npm create @angular@next +``` + +### 配置 + +让我们仔细看看为启用预渲染和 SSR 的项目生成的配置文件: + +#### **`angular.json`** + +``` +"architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", // 🎉 new builder 🎉 + "options": { + "outputPath": "dist/myApp", + "index": "src/index.html", + "browser": "src/main.ts", + "server": "src/main.server.ts", + "prerender": true, + "ssr": { // could also be `"ssr": true` + "entry": "server.ts" + }, + ... // assets, styles, tsconfig, polyfills etc. + }, + } +} +``` + +在此配置中,主条目已被浏览器取代,以适应服务器条目的引入。浏览器入口作为浏览器应用程序的入口点,而服务器入口则用于SSR和预渲染。 + +此更改简化了配置,并且无需为各种场景(例如浏览器、SSR 和预渲染)单独构建目标。 + +❇️ 需要注意的一个有趣的地方是,如果在配置文件中将 `ssr` 或 `prerender` 设置为 `true`,则使用 `ng serve` 提供服务的页面将在服务器端生成。 +因此,不要忘记使用 [`provideClientHydration`](https://angular.io/api/platform-browser/provideClientHydration) 启用水合作用! 🔥🔥🔥 + +由于这个新的构建器同时支持客户端应用程序和服务器应用程序,因此构建的工件将分别位于 `dist/myApp` 中,分别位于 `dist/myApp/browser` 和 `dist/myApp/server` 中。 + +这是新构建器的输出示例: + +``` +├── dist/myApp +│ ├── server +│ │ ├── index.server.html +│ │ ├── main.server.mjs +│ │ ├── server.mjs +│ │ ├── some.component-PTPK7J6R.mjs +│ │ ├── other.component-PT0PDR.mjs +│ │ ├── other components .... +│ ├── browser +│ │ ├── some-page +│ │ │ ├── other-page.component-PTK5V6R.mjs +│ │ ├── some-other-page +│ │ │ ├── some-page.component-PTK5V6R.mjs +│ │ ├── main-BJXWUQT3.js +│ │ ├── polyfills-4DMFFUAS.js +│ │ ├── chunk-4GGQ6HNR.js +│ │ ├── some.component-PTPK7J6R.mjs +│ │ ├── other components.. +``` + +启用预渲染后,您将看到每个页面都有一个单独的目录,如预期的那样。 + +### 预渲染 + +通过将 `prerender` 选项设置为 `true` 或具有 2 个条目的配置对象来启用预渲染: + +- `routesFile` :包含由换行符分隔的路由的文件的路径。 +- `discoverRoutes` :构建器是否应使用 Angular 路由器发现路由器。 + +预渲染需要有一个 `server` 条目,应用程序将在构建过程中使用该条目来预渲染应用程序的每个页面。 + +### 固态继电器 + +一个值得注意的进展是将通用存储库合并到 CLI 存储库中。此举旨在统一与SSR相关的依赖关系。从现在开始,服务器组件的依赖项将包含在新的 `@angular/ssr` 包中。 + +将使用 `@angular/ssr` 包导出的 `CommonEngine`,而不是 `@nguniversal/express-engine` 中的 Express 引擎。您可以在前面提到的 server.ts 文件中看到此更改。 + +#### _`server.ts`_ + +``` +export function app(): express.Express { + const server = express(); + const serverDistFolder = dirname(fileURLToPath(import.meta.url)); + const browserDistFolder = resolve(serverDistFolder, '../browser'); + const indexHtml = join(serverDistFolder, 'index.server.html'); + + const commonEngine = new CommonEngine(); + + server.set('view engine', 'html'); + server.set('views', browserDistFolder); + + // Example Express Rest API endpoints + // server.get('/api/**', (req, res) => { }); + // Serve static files from /browser + server.get('*.*', express.static(browserDistFolder, { + maxAge: '1y' + })); + + // All regular routes use the Angular engine + server.get('*', (req, res, next) => { + commonEngine + .render({ + bootstrap, + documentFilePath: indexHtml, + url: req.originalUrl, + publicPath: browserDistFolder, + providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }], + }) + .then((html) => res.send(html)) + .catch((err) => next(err)); + }); + + return server; +} +``` + +## 过渡 + +![Let's talk about transitions](/blog-images/v17-builders/transitions.png) + +Angular 团队致力于尽可能顺利地从 Webpack 过渡到 ESBuild。 + +要转换现有应用程序并尝试新的构建系统,最简单的方法是在项目配置构建目标中将项目的浏览器构建器从 `browser` 更改为 `browser-esbuild`。 `browser-esbuild` 构建器充当 `application` 构建器的兼容层,允许项目切换或切换回来,而无需进行额外的配置更改。 + +请务必注意,`browser-esbuild` 构建器不提供对新的集成 SSR 和预渲染功能的访问。要启用这些功能,您需要直接转换到 `application` 构建器并使用我们之前讨论的配置。 + +## 提供反馈 + +尽管基于 ESBuild 的构建器在 Angular v17 中被提升到稳定状态,但 Angular 工具团队始终渴望收到反馈和建议。如果您遇到任何问题或有宝贵的见解,请随时通过 [CLI repo](https://github.com/angular/angular-cli) 直接与他们联系。 + +我们将非常感谢您的反馈! ! diff --git a/src/content/blog/frontend/angular/v17-change-detection.md b/src/content/blog/frontend/angular/v17-change-detection.md new file mode 100644 index 00000000..e4e80527 --- /dev/null +++ b/src/content/blog/frontend/angular/v17-change-detection.md @@ -0,0 +1,136 @@ +--- +pubDatetime: 2026-05-30T14:28:37+08:00 +title: "[译] Angular v17 中变更检测的状态" +slug: v17-change-detection +featured: false +draft: true +isTranslation: true +tags: + - angular + - change-detection +description: "全球本地化:全球化为本地化" +canonicalURL: https://riegler.fr/blog/2023-11-02-v17-change-detection +--- + +> 原文地址: https://riegler.fr/blog/2023-11-02-v17-change-detection + +Angular v16 引入了 `Signal`,这是一个全新的 API,旨在在 Angular 应用程序中引入细粒度的反应性。 + +在 v17 中,信号 API 已提升为稳定版(`effect()` 除外),因此让我们看看最新版本中的更改检测情况。 + +## 现有的变更检测机制 + +自诞生以来,Angular 就依赖 `zone.js` 和 `NgZone` 通过调用 `ApplicationRef.tick()` 来安排更改检测。 +该函数负责在根组件处启动变更检测。 + +从根组件开始,更改检测过程检查组件树中的每个组件。它检查标记为“脏”的组件以及使用默认更改检测策略的组件。这种全面的方法可确保根据需要检测更改并在整个组件层次结构中传播更改,从而维护应用程序数据流和用户界面的完整性。 + +这种变化检测方法是全局性的。该算法遍历(几乎)树的每个分支。 + +``` +flowchart TD + Def[ChangeDetectionStragery.Default] ~~~ A + OnPush{{ChangeDetectionStragery.OnPush}} ~~~ A + classDef cd fill:#adebad + + A[Appcomponent] + + A:::cd --> B[SideBarComponent] + A --> C{{"MainViewComponent\n(dirty)"}} + + B:::cd --> F[HeaderComponent] + B --> G[MenuList] + G --> G1[MenuListItem] + G --> G2[MenuListItem] + G --> G3[MenuListItem] + + F:::cd + G:::cd + G1:::cd + G2:::cd + G3:::cd + + C -.-> C1{{"SomeViewComponent"}} + C --> C2{{"SomeOtherViewComponent\n (dirty)"}} + + C2 -.-> C22{{"SomeDetail"}} + + C:::cd + C2:::cd +``` + +在此图中,正在检查所有绿色组件(也由普通箭头指向)是否有更改。 `SomeViewComponent` 或 `SomeDetail` 不是,因为它们使用 `OnPush` 策略并且没有标记为脏。 + +通过在给定组件上调用 `changeDetectorRef.markForCheck()`,组件被递归标记为脏组件,直到根组件。这就是 `AsyncPipe` 所做的事情。在我们的示例中,`SomeOtherViewComponent` 将自身及其父级标记为脏。 + +要更深入地了解所有更改检测详细信息,您可以查看 [this article by Antonio Pekeljević](https://medium.com/@antoniopk/angular-change-detection-explained-169aea595423)。 + +## v17 引入本地更改检测 + +信号的引入使得本地变化检测成为可能。在 v16 中,实时信号(在模板中调用的信号)在通过 `Signal.set()`、`Signal.mutate()` 或 `computed()` 信号更新时确实将组件标记为脏。通过调用内部 [`markViewDirty()`](https://github.com/angular/angular/blob/e092184a5c3d98f4be329e4037c9039c1b420d75/packages/core/src/render3/reactive_lview_consumer.ts#L62) 函数将组件标记为脏。这与 `changeDetectionRef.markForCheck` 调用的函数相同。 + +在 v17 中,该函数调用已被 [`markAncestorsForTraversal()`](https://github.com/angular/angular/blob/83a3b85c355f6cb9f9b698bd90c2920341466032/packages/core/src/render3/reactive_lview_consumer.ts#L49) 取代。 + +这两个函数之间的主要区别在于,`markViewDirty` 以递归方式将其“脏”标记扩展到所有祖先组件,而 `markAncestorsForTraversal`` 将“脏”标记限制到当前组件,并安排其祖先以供将来遍历,而无需立即进行更改检测。 + +因此,虽然仍然依赖基于树的变更检测,但我们现在可以访问“glocal”(全局+本地)变更检测: + +- 它仍然从根组件开始(因此是全局的) +- 具有默认策略的组件仍将被检查 +- 仅当脏时,OnPush 组件仍会被检查 +- 但我们现在可以将单个组件标记为脏(因此是本地) + +如果您严重依赖使用 `OnPush` 策略,则这种全局方法将极大地减少更改检测所检查的组件数量。 + +从 17.0 开始,此本地更改检测仅在使用 `Signals` 时可用。 `markAncestorsForTraversal` 是私有 API。 + +``` +flowchart TD + Def[ChangeDetectionStragery.Default] ~~~ A + OnPush{{ChangeDetectionStragery.OnPush}} ~~~ A + classDef cd fill:#adebad + + A[Appcomponent] + + A:::cd --> B[SideBarComponent] + A --> C{{"MainViewComponent\n(traversed)"}} + + B:::cd --> F[HeaderComponent] + B --> G[MenuList] + G --> G1[MenuListItem] + G --> G2[MenuListItem] + G --> G3[MenuListItem] + + F:::cd + G:::cd + G1:::cd + G2:::cd + G3:::cd + + C -.-> C1{{"SomeViewComponent"}} + C --> C2{{"SomeOtherViewComponent\n(traversed)"}} + + C2 --> C22{{"SomeDetail\n dirty"}} + + C22:::cd +``` + +这个更新的示例显示了全球本地化/混合变化检测系统。默认策略组件上的更改检测保持不变,并且信号允许 OnPush 组件上的新本地更改检测。 + +## 变更检测演示 + +几年前(v8 时代)[Daniel Wiehl](https://github.com/danielwiehl/edu-angular-change-detection) 编写了一个很棒的现场演示,展示了 Angular 中变更检测的各个方面。该演示涵盖了变更检测何时发生、过程中到底发生了什么以及发生在哪里。 + +随着 Angular 的发展以及信号驱动的本地变化检测等新功能的引入,我们现在可以拥有一个更新的演示,其中包含了这些最新的变化和增强功能。 + +![Awesome demo](/blog-images/v17-change-detection/demo.png) + +您可以在这里使用它:🚦[Awesome demo](https://jeanmeche.github.io/angular-change-detection/)🚦。 + +## 关于未来 + +如前所述,`NgZone` 仍然是自动更改方向的调度程序:`zone.js` 修补的 API 正在对微任务进行排队,一旦微任务队列为空,`NgZone` 就会调用 `tick()`。 + +为了实现真正的本地变化检测,有必要设计和实现一个新的调度程序。这个新的调度程序可以为官方对无区应用程序的支持铺平道路,这也是人们所期待的。 + +同时,我建议探索 [RxAngular](https://www.rx-angular.io/) 库,它提供了用于使用无区域应用程序的工具和解决方案。对于希望了解 Angular 应用程序中复杂的变更检测和性能优化的开发人员来说,该库可以成为宝贵的资源。 diff --git a/src/content/blog/frontend/angular/zoneless-preview.md b/src/content/blog/frontend/angular/zoneless-preview.md new file mode 100644 index 00000000..f2fbe5f9 --- /dev/null +++ b/src/content/blog/frontend/angular/zoneless-preview.md @@ -0,0 +1,151 @@ +--- +pubDatetime: 2026-05-30T14:30:47+08:00 +title: "[译] Angular 17.1 预览版中为所有人提供无区域变更检测" +slug: zoneless-preview +featured: false +draft: true +isTranslation: true +tags: + - angular + - zoneless + - change-detection +description: "你准备好了吗?" +canonicalURL: https://riegler.fr/blog/2024-01-11-zoneless-preview +--- + +> 原文地址: https://riegler.fr/blog/2024-01-11-zoneless-preview + +Zoneless 是指无需 `zone.js` 即可进行变更检测的框架功能。 + +尽管 zoneless 尚未在独立应用程序中获得官方支持,但许多开发人员已成功地将其与 [RxAngular](https://www.rx-angular.io/) 等库结合使用。 + +无区仅影响变更检测的调度。底层的变化检测机制是一个独特的主题,并且无论是否使用区域都保持一致。 + +## 无区域化的原因 + +开发人员考虑从应用程序堆栈中删除 zone.js 有多种动机。列举几个: + +- `zone.js` 引入了大约 30kB 原始的非延迟可加载有效负载。 +- `zone.js` 对调试功能有负面影响(使调用堆栈有点难以阅读) +- 本地 api 的猴子修补会对性能产生负面影响(由于 `setInterval`、`requestAnimationFrame`、`addEventListeners` 等,CD 可能会被频繁触发) +- 在您的应用程序中拥有区域需要您来处理它们。一些 API 回调在角度区域之外运行,所以您会这样做。 +- 静态地,很难知道您当前位于哪个区域,因为这是运行时上下文。 +- 原生 async/await 无法修补,因此 Angular 依赖于 babel `plugin-transform-async-to-generator` 插件。 + +## 无区域变更检测在 17.1 预览版中如何工作? + +### 无区域变更检测调度程序 + +我之前已经讨论过 [previous article](/blog/2023-11-02-v17-change-detection) 中的更改检测。到目前为止,Angular 依赖 `zone.js` 和 `NgZone` 通过调用 `ApplicationRef.tick()` 来安排更改检测。 + +为了实现无区域,框架必须依赖一个新的调度程序来负责调用 `ApplicationRef.tick()`。 +The new scheduler is defined by the `ChangeDetectionScheduler` interface with a `notify()` method that should be called to schedule CD. +我们谈论调度程序是因为对 `notify()` 的调用应该合并以防止框架运行无用的 CD 周期。 + +对于 v17.1,团队选择依赖基于 `setTimeout` 的调度程序,这实际上非常简单: + +#### **`zoneless_scheduling_impl.ts`** + +``` +class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler { + private appRef = inject(ApplicationRef); + private taskService = inject(PendingTasks); + private pendingRenderTaskId: number | null = null; + + notify(): void { + if (this.pendingRenderTaskId !== null) return; + + this.pendingRenderTaskId = this.taskService.add(); + setTimeout(() => { + try { + if (!this.appRef.destroyed) { + this.appRef.tick(); + } + } finally { + // If this is the last task, the service will synchronously emit a stable notification. If + // there is a subscriber that then acts in a way that tries to notify the scheduler again, + // we need to be able to respond to schedule a new change detection. Therefore, we should + // clear the task ID before removing it from the pending tasks (or the tasks service should + // not synchronously emit stable, similar to how Zone stableness only happens if it's still + // stable after a microtask). + const taskId = this.pendingRenderTaskId!; + this.pendingRenderTaskId = null; + this.taskService.remove(taskId); + } + }); + } +} +``` + +要安排更改检测,框架需要调用 `notify()` 方法。 + +### 有信号 + +设计时,信号的目标之一是能够放弃 `zone.js` 的要求,以便能够安排更改检测。 + +因此,信号是编写无区域应用程序的最简单方法:每次更新使用的信号时, +该框架将调用 `ChangeDetectionScheduler.notify()` 来安排变更检测周期。 + +没有什么可以为你做的。您更新一个或多个信号,您将获得一个 CD 周期。 + +很酷啊?! + +### 无信号 + +虽然信号使 Zoneless 非常容易支持,但它们不是必需的,并且存在替代方案! +是的,您理解正确,即使您尚未迁移到信号,您现在也可以启用无区! + +在框架的核心,一个名为 `markViewDirty` 的私有函数也会调用 `ChangeDetectionScheduler.notify()`。 + +#### **`mark_view_dirty.ts`** + +``` +export function markViewDirty(lView: LView): LView|null { + lView[ENVIRONMENT].changeDetectionScheduler?.notify(); + ... +} +``` + +这个函数实际上是一个老函数,它已经在框架中存在很多年了,并且已经在一些情况下被调用。 +其中 `AsyncPipe`(通过 `markForCheck()`)和模板事件监听器确实调用此函数。 + +这意味着之前打过补丁的 API(例如 `setTimeout`、`setInterval`、`requestAnimationFrame`、`fetch` 或已解决/已拒绝的 Promise)不会触发更改检测。 + +因此,如果您的应用程序恰好严重依赖 Observables/`AsyncPipe` 模式,您的应用程序可能还会支持无区更改检测。 + +## 启用无区域预览 + +无区域变更检测目前处于私人预览版:它依赖于一种私人方法,预计在该功能正式落地之前该方法将被重命名。预览旨在帮助团队收集有关该功能的反馈,以便改进它。 + +要启用无区变更检测,您需要满足以下两个条件: + +- 将 `ɵprovideZonelessChangeDetection` 添加到您的提供商。 +- 从 `angular.json` 配置文件中的 `polyfills` 条目中删除 `zone.js`。 + +#### **`main.ts`** + +``` +bootstrapApplication(AppComponent, { + /* ɵprovideZonelessChangeDetection() + * enables zoneless magic + */ + providers: [ɵprovideZonelessChangeDetection()], +}); +``` + +`ɵprovideZonelessChangeDetection` 从 `17.1.0-rc.0` 开始可用。 + +我不建议在生产应用程序上使用无区域更改检测(或者需要您自担风险,就像使用私有 API 一样)。 + +## 演示 + +享受一个小型的无区域演示应用程序,其中包含使用信号、异步管道和侦听器进行更改检测的示例。 + +[Here on stackblitz](https://stackblitz.com/edit/angular-zoneless-demo?file=package.json,src%2Fmain.ts) + +## 笔记 + +正如前面提到的,这不是一个已发布的功能,有些部分仍然缺失。 +CLI 目前不允许配置 babel 插件。此问题已跟踪 [on the CLI repo](https://github.com/angular/angular-cli/issues/22191)。 + +如果您决定测试此功能并遇到任何困难,请不要犹豫 [create a new issue](https://github.com/angular/angular/issues/new/choose) 并提供复制品(stackblitz 或 github 存储库)。 diff --git a/src/content/blog/frontend/angular/zoneless-with-zoneless-hybrid.md b/src/content/blog/frontend/angular/zoneless-with-zoneless-hybrid.md new file mode 100644 index 00000000..f5e5cb06 --- /dev/null +++ b/src/content/blog/frontend/angular/zoneless-with-zoneless-hybrid.md @@ -0,0 +1,102 @@ +--- +pubDatetime: 2026-05-30T14:31:29+08:00 +title: "[译] v18 中的混合变更检测" +slug: zoneless-with-zoneless-hybrid +featured: false +draft: true +isTranslation: true +tags: + - angular + - zoneless + - change-detection +description: "无区域有助于基于区域。" +canonicalURL: https://riegler.fr/blog/2024-04-17-zoneless-with-zoneless-hybrid +--- + +> 原文地址: https://riegler.fr/blog/2024-04-17-zoneless-with-zoneless-hybrid + +你喜欢无区吗?好吧,这是相关的,但运行区域应用程序的每个人都会感兴趣! + +您可能知道,Angular v18 将为无区域变更检测提供实验性支持。这里就不做过多介绍了,推荐你看一下[the article by my buddy @Enea](https://justangular.com/blog/a-new-era-for-angular-zoneless-change-detection)。 + +虽然 Zoneless 是一个非常有趣的话题,但 Zoneful 应用程序将从围绕官方 zoneless 支持的工作中受益匪浅。有关技术细节,您可以查看 [this PR](https://github.com/angular/angular/pull/55102),它引入了更改检测的更改。我们将其称为混合模式。 + +## 区域内、区域外 + +直到 v17,基于区域的应用程序依赖 `Zone.js` 来安排更改检测。 + +基本上,更改检测是在调用修补的浏览器 API 时安排的(`setTimeout`、`setInterval`、`Promise.then`、`addEventListener` 等)。 + +这有一个警告,只有当您位于 Angular 区域时才会触发更改检测。这会对性能产生影响,导致我们编写分别使用 `ngZone.runOutSideAngular(() => ...)`/`ngZone.run(() => ...)` 跳入和跳出 Angular 区域的代码。 +这意味着执行上下文很重要,而这是很难静态确保的。 + +我们看到开发人员遇到的常见问题之一是在 Angular 区域之外更新信号而不安排更改检测周期。 + +这不被认为是伟大的DX。更新信号实际上是在告诉框架,“嘿数据发生了变化,请刷新我的组件”。 Angular 不应该忽略区域之外发生的变化。 + +## 无区域调度 + +放弃用于 CD 调度的 `zone.js` 意味着必须实现新的调度程序。在 v18 中,它可以在名为 `provideExperimentalZonelessChangeDetection()` 提供程序函数的奇特函数后面使用。 + +它的工作基本上是在以下情况下安排 CD 周期: + +- 更新信号(通过 `set()`/`update()`) +- 调用 `changeDetectorRef.markForCheck()` +- 订阅了 `AsyncPipe` 的 `Observable` 收到一个新值 +- 连接/分离组件(通过 `detach()`/`attach`) +- 设置输入(`ComponentRef.setInput()`) + +## 混合调度 + +在 v18 中,对于基于区域的应用程序,该框架将利用相同的调度程序以及现有的基于区域的调度程序:欢迎使用混合模式。 + +变更检测将像以前一样工作,但会带来 DX 改进。 + +想象一下您跳出角度区域的情况。 + +``` +ngZone.runOutsideAngular(() => { + setInterval(() => { + someFunctionSomewhere(); + }, 1000); +}) + +function someFunctionSomewhere() { + mySignal.set(newValue) +} +``` + +在此示例中,不会安排任何更改检测。 `setInterval`,由于上下文(在角度区域之外)不会触发 CD,并且信号本身不会调度任何 CD,它们只是将组件标记为脏。这使得这段代码有点脆弱。由于执行上下文,您不知道 `someFunctionSomewhere` 是否实际上会产生更新的视图。 + +在这里,混合模式将派上用场并消除执行上下文问题:信号更新将始终安排 CD 周期。 +你要求更新,你就会得到更新,无论上下文如何。伟大的 DX 胜利不是吗? + +总结一下这些变化,以下是安排变更检测的新情况: + +- You're explicitly instructing the framework to update : `signal.set()`/`signal.update()`/ `changeDetectorRef.markForCheck()` +- 新值被传递到 `AsyncPipe`。它基于 `markForCheck`,现在也将始终安排 CD +- 附加/分离视图时 + +## 我是否会使用混合模式进行大量 CD 调度? + +我看到你的问题来了!我告诉你,CD调度将发生在角度区域之外。这会影响表演吗? + +是的,跑出角度区域是为了减少CD调度的次数。对于所有 `zone.js` 修补的 API,它将保持这种方式。 `setInterval`仍然不会安排CD,你不必担心这一点。 + +What will change is that, if ever you were telling the framework to update its state (via `signal.set` of `markForCheck()`); it will now schedule change detection because you explicitly told him to ! + +## v18 中默认使用混合模式 + +您如何从中受益?自 `18.0.0-next.5` 预发布以来,此功能默认启用。 + +如果您对此功能有疑问,仍然可以使用 [`provideZoneChangeDetection`](https://angular.dev/api/core/provideZoneChangeDetection) 提供商功能选择退出: + +``` +bootstrapApplication(App, { + providers: [ + provideZoneChangeDetection({ ignoreChangesOutsideZone: true }), + ], +}); +``` + +➡️ 享受这个简短的混合调度 [stackblitz demo](https://stackblitz.com/edit/v18-hybrid-mode)。 diff --git a/src/content/blog/2018-web-dev-guide.md b/src/content/blog/frontend/general/2018-web-dev-guide.md similarity index 99% rename from src/content/blog/2018-web-dev-guide.md rename to src/content/blog/frontend/general/2018-web-dev-guide.md index d8d11b32..a36a98d9 100644 --- a/src/content/blog/2018-web-dev-guide.md +++ b/src/content/blog/frontend/general/2018-web-dev-guide.md @@ -5,7 +5,7 @@ slug: 2018-web-dev-guide featured: false draft: false tags: - - frontend + - web-development description: '偶然间看到的视频,视频中主要介绍现在的web开发比较常用的软件、框架(UI/JS)以及全栈工程师可以掌握的几个点。