Angular静态站点生成:Scully实现Jamstack架构实战

1. 这不是传统网站,而是一套“静态优先”的现代前端工作流

你可能已经听过 Jamstack 这个词——它不是某个新框架,也不是某家公司的私有技术,而是一种明确的架构哲学: 以预渲染的静态文件为交付核心,通过 API 和客户端 JavaScript 按需增强交互能力 。它不依赖服务器端模板渲染,也不在每次请求时动态拼接 HTML,而是把构建阶段(build time)能确定的内容全部提前生成好,扔进 CDN,用户打开页面时,0 毫秒等待就能看到完整结构。这种模式带来的直接好处是:加载快、抗流量洪峰、部署极简、安全面更窄(没有运行时服务端代码,攻击面天然收缩)、运维成本趋近于零。

Angular 11 是这个项目的技术底座。注意,这里说的不是“用 Angular 写个单页应用再丢上去”,而是 让 Angular 本身成为静态站点生成器(SSG)的输入源 。Angular 原生并不支持静态导出——它的 Router 默认依赖浏览器 History API,服务端无法识别路由;它的依赖注入、模块懒加载、服务生命周期都深度绑定在浏览器运行时。所以,光靠 Angular CLI 的 ng build --prod 输出,得到的只是一堆带哈希的 JS/CSS/HTML,但所有路由都指向同一个 index.html ,刷新任意子页面会 404。这正是 Jamstack 架构下最典型的“静态化断点”。

Scully 就是专为解决这个断点而生的工具。它不是 Angular 的插件,而是一个独立的、基于 Node.js 的静态站点生成器,其核心设计思想是:“ 让 Angular 应用自己说出‘我有哪些可访问的路由’,然后 Scully 去模拟真实用户访问这些路由,抓取最终渲染的 HTML,再把它们保存为纯静态文件 ”。它通过启动一个临时的本地开发服务器,读取 Angular 应用的路由配置( Routes 数组),递归解析所有 routerLink navigate() 调用,甚至能处理带参数的动态路由(如 /blog/:id ),再用 Puppeteer 启动无头浏览器,逐个访问、等待 Angular 完成渲染、截取 DOM 快照,最后将 <app-root> 内部的完整 HTML 结构提取出来,写入对应路径的 .html 文件。整个过程不修改你的 Angular 代码逻辑,不侵入你的业务服务,只是在构建流水线中加了一道“快照生成”工序。

这个组合的价值非常具体:你依然用熟悉的 Angular 写组件、写服务、写状态管理,享受强类型、模块化、CLI 工具链带来的开发体验;但最终交付给用户的,却是一个零后端、零数据库、零服务器运维的静态站点。它适合个人作品集、企业官网、文档站、博客、营销落地页等对 SEO 和首屏性能要求高、内容更新频率中低频的场景。尤其对前端开发者而言,这意味着你可以把全部精力聚焦在 UI/UX 和业务逻辑上,而不用再为 Nginx 配置、CDN 缓存策略、HTTPS 证书续期、DDoS 防护这些运维琐事分心。我去年帮一位 UI 设计师朋友搭作品集,从初始化到上线 GitHub Pages,全程不到 90 分钟,中间还陪她调了 20 分钟配色——这才是 Jamstack 真正想解放的生产力。

2. 核心思路拆解:为什么必须是 Scully,而不是其他方案?

很多人第一反应是:“Angular 不是有 Angular Universal 吗?为什么不用 SSR?” 这是个极好的问题,也是理解本方案设计逻辑的关键入口。Universal 确实能服务端渲染 Angular 应用,但它走的是另一条路: 在 Node.js 服务器上启动一个真实的 Angular 应用实例,每次 HTTP 请求到来时,由该实例完成组件渲染,再把 HTML 返回给客户端 。这本质上仍是“请求-响应”模型,需要持续运行的服务进程,有内存占用、冷启动延迟、并发瓶颈等问题。它解决了首屏白屏和 SEO,但没解决运维复杂度和扩展性问题——你依然得管服务器、进程、日志、监控。

而 Scully 的定位完全不同:它是一个 构建时(build-time)工具 ,目标是产出静态文件。它不追求实时渲染,只追求“构建那一刻,所有可能的路由快照都已就绪”。这就带来了几个决定性优势:

第一, 零运行时依赖 。生成的 dist/ 目录里只有 HTML、CSS、JS、图片,没有任何 Node.js 进程、Express 服务或数据库连接。你可以把它扔到任何能托管静态文件的地方:GitHub Pages、Vercel、Netlify、Cloudflare Pages、甚至一台树莓派上跑的 Nginx。部署命令就是 git push 或拖拽上传,没有 npm start ,没有 pm2 restart ,没有 systemctl reload nginx

第二, 构建即验证 。Scully 在生成快照时,会主动检查每个路由的渲染结果。如果某个组件抛出异常、某个异步数据请求失败、某个 *ngIf 条件导致关键内容未渲染,Scully 会在控制台报错并中断构建。这相当于把一部分运行时错误提前到了构建阶段捕获,极大提升了线上稳定性。我曾在一个项目里发现,某个产品详情页的 ngOnInit 里调用了未 mock 的 window.location ,本地开发一切正常,但 Scully 抓取时直接崩溃——这个 bug 如果等到用户访问才暴露,排查成本会高得多。

第三, 与 Angular 生态无缝融合 。Scully 不是另起炉灶,它深度解析 angular.json tsconfig.json app-routing.module.ts ,能自动识别 RouterModule.forRoot(routes) 中定义的所有路由,包括 children 嵌套路由、 canActivate 守卫(会跳过需要登录的路由)、 data 属性(可用于生成 SEO 元信息)。它甚至能解析 @angular/common/http HttpClient 调用,如果你在组件中用 http.get() 获取 Markdown 博客内容,Scully 可以在构建时预请求这些 API,把返回的 JSON 或 Markdown 渲染结果一并注入到快照 HTML 中,实现真正的“静态化数据驱动”。

当然,Scully 也有明确的适用边界。它不适合高频实时更新的内容(如股票行情、聊天消息),因为每次内容变更都需要重新触发构建流程;它也不适合需要个性化服务端逻辑的场景(如根据用户 session 动态返回不同菜单),因为静态文件是全局共享的。但对个人作品集而言,这些恰恰是优点——你的项目列表、技能标签、联系方式、博客文章,更新频率以周或月为单位,内容对所有访客一致,完美匹配 Scully 的能力模型。

另一个常被拿来对比的是直接使用 @angular/platform-server 手写 SSR。这理论上可行,但工程成本极高:你需要维护两套渲染逻辑(客户端和服务端)、处理 document / window 对象的兼容性、管理服务端渲染特有的生命周期钩子、解决样式隔离问题、配置 Webpack 服务端打包……而 Scully 把这一切封装成了 scully run 一条命令。它背后是大量针对 Angular 特性的深度适配,比如它知道如何绕过 BrowserModule 的限制,如何在无浏览器环境中模拟 Renderer2 ,如何序列化 ChangeDetectorRef 的状态。这些细节,正是它比通用 SSG(如 Hugo、Jekyll)更适合 Angular 项目的根本原因。

3. 核心细节解析:Scully 的工作原理与关键配置项

要真正掌控这个流程,不能只停留在“运行命令就完事”的层面。Scully 的核心机制可以拆解为三个相互咬合的齿轮: 路由发现(Route Discovery)、内容抓取(Content Scraping)、文件生成(File Generation) 。理解每个齿轮的齿形和转速,才能在出问题时快速定位。

3.1 路由发现:Scully 如何“读懂”你的 Angular 应用?

Scully 并非魔法,它读取路由的方式非常务实: 直接解析 TypeScript 源码 。当你执行 npx scully 时,它首先会启动一个 TypeScript 编译器实例,加载你的 tsconfig.app.json ,然后扫描 src/app/app-routing.module.ts (或你指定的路由模块文件)。它寻找 const routes: Routes = [...] 这样的声明,并递归解析数组中的每一个对象。对于静态路由(如 { path: 'about', component: AboutComponent } ),它直接提取 path 值;对于带参数的动态路由(如 { path: 'project/:id', component: ProjectComponent } ),它会识别 :id 占位符,并标记为“需要提供参数值”。

但仅靠路由配置还不够。很多 Angular 应用的导航是通过 router.navigate(['/project', id]) <a [routerLink]="['/project', id]"> 实现的,这些路径不会出现在 Routes 数组里。Scully 为此提供了 --guess-routes 模式 (默认开启)。它会启动一个轻量级的 AST(抽象语法树)分析器,扫描整个 src/app/ 目录下的 .ts 文件,查找所有 router.navigate 调用和 routerLink 指令的字符串字面量,提取其中的路径数组。例如,它会从 this.router.navigate(['/blog', this.post.id, 'edit']) 中提取出 ['/blog', 'id', 'edit'] ,并推断 id 是一个变量,从而生成 /blog/:id/edit 这样的动态路由模板。

提示: --guess-routes 是双刃剑。它能发现隐式路由,但也可能误报。比如你在某个服务里写了 router.navigate(['/admin']) ,但该路由实际受权限守卫保护,普通用户永远访问不到。Scully 会把它当作有效路由抓取,导致生成一个空页面或重定向页。因此,生产环境建议关闭此选项,改用显式声明:在 scully.config.ts 中的 routes 字段里,手动列出所有你希望静态化的路由,并为动态路由提供 type 配置。

3.2 内容抓取:Puppeteer 如何“看懂” Angular 渲染?

这是最易被误解的一环。很多人以为 Scully 是简单地 fetch() 一下 HTML,然后 innerHTML 替换。完全错误。Angular 是一个复杂的运行时环境,组件渲染涉及 Change Detection、Zone.js 异步调度、View Engine 或 Ivy 的模板编译。Scully 使用 Puppeteer 启动一个真实的 Chromium 浏览器实例,其工作流程如下:

  1. 启动 Dev Server :Scully 首先调用 ng serve --port 4200 --disable-host-check (或你配置的端口),启动 Angular 开发服务器。
  2. 导航与等待 :Puppeteer 访问 http://localhost:4200/project/123 ,Angular Router 捕获 URL,加载 ProjectComponent
  3. 触发变更检测 :Scully 注入一段脚本,监听 ApplicationRef.isStable 。这个 Observable 在 Angular 认为“所有异步操作(HTTP 请求、setTimeout、Promise)都已完成,且视图已稳定渲染”时发出 true
  4. 截取快照 :一旦 isStable 发出,Puppeteer 立即执行 page.content() ,获取当前页面的完整 HTML 字符串。注意,此时获取的是 <app-root> 内部的、经过 Angular 完整渲染后的 DOM,所有 *ngFor *ngIf [class] 绑定都已计算完毕,所有异步数据都已填充。
  5. 清理与复用 :关闭当前页面,为下一个路由重复步骤 2-4。

这个过程确保了生成的静态 HTML 与用户在浏览器中看到的完全一致。但这也意味着, 任何阻塞 isStable 的操作都会导致 Scully 超时失败 。最常见的陷阱是:

  • 组件中使用了未取消的 setInterval (比如轮询状态);
  • ngOnInit 里调用了 window.addEventListener('online', ...) 这类全局事件监听;
  • 第三方库(如某些地图 SDK)在初始化时创建了长生命周期的定时器。

解决方案是在 ngOnDestroy 中彻底清理,或在 Scully 环境下禁用这些副作用。Scully 提供了 SCULLY_RUNNING 全局变量,你可以在组件中这样写:

ngOnInit() {
  if (!SCULLY_RUNNING) {
    this.startPolling();
  }
}

3.3 文件生成:从快照到可部署的静态文件

抓取到的 HTML 字符串不会原样写入磁盘。Scully 会进行几项关键后处理:

  • 移除 Angular 运行时脚本 :生成的 HTML 中, <script> 标签里不再包含 main.js runtime.js 等 Angular 运行时 bundle。因为静态页面不需要再次启动 Angular,这些 JS 只会增加体积和潜在错误。Scully 会保留 polyfills.js (如果需要)和 styles.css ,但剥离所有 main.*.js
  • 注入基础 SEO 元信息 :如果你在路由配置的 data 属性中设置了 title description ,Scully 会自动将其注入 <head> <title> <meta name="description"> 标签。例如:
    { path: 'about', component: AboutComponent, data: { title: '关于我 - 个人作品集' } }
    
  • 重写资源路径 :所有相对路径(如 ./assets/logo.png )会被转换为绝对路径( /assets/logo.png ),确保在任意子目录下都能正确加载。
  • 生成 index.html 的副本 :对于根路径 / ,Scully 会生成 index.html ;对于 /projects ,生成 projects/index.html ;对于 /project/123 ,生成 project/123/index.html 。这是为了兼容大多数静态托管服务的“目录索引”规则。

这些后处理逻辑都定义在 Scully 的 renderPlugin 中,你可以通过编写自定义插件来扩展。比如,你想为每篇博客文章自动生成 Open Graph 图片,就可以写一个插件,在 render 阶段调用 Puppeteer 截图并保存。

4. 实操过程:从零开始搭建一个可部署的作品集

现在,我们把所有理论付诸实践。以下步骤基于 Angular 11 CLI 创建的标准项目,全程使用官方推荐方式,不依赖任何第三方脚手架。我假设你已安装 Node.js 14+ 和 npm。

4.1 初始化 Angular 项目与基础配置

首先,创建一个干净的 Angular 项目:

ng new my-portfolio --routing=true --style=scss --skip-git
cd my-portfolio

选择 Yes 启用路由, SCSS 作为样式预处理器(更灵活),跳过 Git 初始化(稍后我们自己管理)。

接下来,安装 Scully 及其依赖:

ng add @scullyio/init

这条命令会自动完成三件事:

  1. 安装 @scullyio/scully @scullyio/ng-lib puppeteer 等核心包;
  2. angular.json 中添加 scully 构建目标;
  3. 创建 scully.config.ts 配置文件。

此时,项目结构中会出现 scully.config.ts 。打开它,你会看到类似这样的内容:

export const config: ScullyConfig = {
  projectRoot: './src',
  projectName: 'my-portfolio',
  outDir: './dist/static',
  routes: {}
};

outDir 指定了静态文件的输出目录,默认是 ./dist/static ,这与 Angular CLI 的 dist/my-portfolio 分开,避免冲突。

4.2 构建核心页面与路由

一个作品集至少需要首页、关于页、项目页、联系页。我们在 src/app/ 下创建对应模块:

ng g m home --route=home --module=app-routing
ng g m about --route=about --module=app-routing
ng g m projects --route=projects --module=app-routing
ng g m contact --route=contact --module=app-routing

CLI 会自动更新 app-routing.module.ts ,添加路由配置。现在,编辑 app-routing.module.ts ,为每个路由添加 data 属性,用于 SEO:

const routes: Routes = [
  { path: '', redirectTo: '/home', pathMatch: 'full' },
  { path: 'home', loadChildren: () => import('./home/home.module').then(m => m.HomeModule), data: { title: '首页 - 个人作品集' } },
  { path: 'about', loadChildren: () => import('./about/about.module').then(m => m.AboutModule), data: { title: '关于我 - 个人作品集' } },
  { path: 'projects', loadChildren: () => import('./projects/projects.module').then(m => m.ProjectsModule), data: { title: '我的项目 - 个人作品集' } },
  { path: 'contact', loadChildren: () => import('./contact/contact.module').then(m => m.ContactModule), data: { title: '联系我 - 个人作品集' } }
];

注意,我们使用了 loadChildren 懒加载,这对构建性能很重要——Scully 抓取每个路由时,只会加载该路由对应的模块,不会把整个应用的 JS 都下载下来。

4.3 处理动态项目路由与数据

作品集的核心是“项目列表”和“项目详情”。我们通常用一个 ProjectService 从 JSON 文件或 API 获取项目数据。为了演示,我们创建一个 projects.json 放在 src/assets/data/ 目录下:

[
  {
    "id": "1",
    "title": "电商后台管理系统",
    "description": "基于 Angular 11 和 NgZorro 的企业级后台",
    "tech": ["Angular", "NgZorro", "NestJS"]
  },
  {
    "id": "2",
    "title": "智能健身教练 App",
    "description": "集成运动传感器数据的 PWA 应用",
    "tech": ["Angular", "Capacitor", "TensorFlow.js"]
  }
]

projects 模块中,创建 ProjectsListComponent 显示列表, ProjectDetailComponent 显示详情。关键点在于 ProjectDetailComponent 的路由配置:

// projects-routing.module.ts
const routes: Routes = [
  { path: '', component: ProjectsListComponent },
  { 
    path: ':id', 
    component: ProjectDetailComponent,
    data: { title: '项目详情 - 个人作品集' } 
  }
];

为了让 Scully 知道 :id 的具体值,我们必须在 scully.config.ts 中显式声明:

export const config: ScullyConfig = {
  // ... 其他配置
  routes: {
    '/projects/:id': {
      type: 'json',
      // 指向项目数据文件
      data: {
        jsonFile: './src/assets/data/projects.json'
      }
    }
  }
};

Scully 会读取这个 JSON 文件,遍历数组,为每个对象生成一个 /projects/1 /projects/2 的快照。 jsonFile 路径是相对于项目根目录的。

4.4 运行 Scully 构建与本地验证

现在,执行构建:

npm run scully

这会依次运行:

  • ng build --prod :构建 Angular 应用,输出到 dist/my-portfolio
  • scully :启动 dev server,抓取所有路由,生成静态文件到 dist/static

构建成功后, dist/static 目录结构应类似:

dist/static/
├── index.html          # 对应 /
├── about/
│   └── index.html      # 对应 /about
├── projects/
│   ├── index.html      # 对应 /projects
│   ├── 1/
│   │   └── index.html  # 对应 /projects/1
│   └── 2/
│       └── index.html  # 对应 /projects/2
└── ...

为了验证效果,不要直接打开 index.html (会因 CORS 报错),而是用一个静态服务器:

npx http-server dist/static -p 8080

然后访问 http://localhost:8080 。你应该看到一个完全静态的、无需任何 JS 就能显示完整内容的作品集。尝试刷新 /projects/1 页面,确认不会 404。

4.5 部署到 GitHub Pages(零成本)

GitHub Pages 是部署静态站点的最佳选择之一,完全免费,且与 Git 深度集成。首先,确保你的项目已初始化为 Git 仓库:

git init
git add .
git commit -m "initial commit"
git branch -M main
git remote add origin https://github.com/your-username/my-portfolio.git
git push -u origin main

然后,安装 gh-pages 包:

npm install gh-pages --save-dev

package.json scripts 中添加部署脚本:

"scripts": {
  "deploy": "npm run scully && npx angular-cli-ghpages --dir=dist/static"
}

注意,这里我们指定了 --dir=dist/static ,即 Scully 的输出目录。执行:

npm run deploy

几分钟后,访问 https://your-username.github.io/my-portfolio/ ,你的作品集就在线了。所有页面都是纯静态,CDN 加速,全球访问毫秒级响应。

5. 常见问题与排查技巧实录

在真实项目中,Scully 的“黑盒”特性常常让人一头雾水。下面是我踩过的坑和总结的排查方法论,按发生频率排序。

5.1 “Scully 抓取超时,页面未稳定” —— 最高频问题

现象 :控制台报错 TimeoutError: waiting for function failed: timeout 30000ms exceeded ,或 Page did not become stable within 30000ms

根本原因 :Scully 等待 ApplicationRef.isStable 超时。Angular 认为页面“不稳定”,通常是因为有未完成的异步操作。

排查步骤

  1. 检查 ngOnDestroy :确保所有 setInterval setTimeout addEventListener 都在组件销毁时被清除。在 ngOnDestroy 中添加 console.log('destroyed') ,确认它被调用。
  2. 检查第三方库 :禁用所有非核心的第三方库(如 ngx-markdown @swimlane/ngx-charts ),逐个启用,定位问题源。
  3. 检查 HttpClient 请求 :如果组件中有 http.get().subscribe() ,确保它在 ngOnDestroy unsubscribe() 。更好的做法是使用 async 管道,它会自动取消订阅。
  4. 临时增加超时时间 :在 scully.config.ts 中添加:
export const config: ScullyConfig = {
  // ...
  puppeteerLaunchOptions: {
    args: ['--no-sandbox', '--disable-setuid-sandbox']
  },
  defaultPostRenderers: ['seoHrefOptimise'],
  // 增加等待超时
  waitForSelector: 'app-root',
  timeout: 60000 // 60秒
};

5.2 “生成的页面是空白,或只有 loading 状态” —— 数据未加载

现象 dist/static/projects/1/index.html 打开后,只显示 “Loading...” 或一片空白。

根本原因 :Scully 抓取时,Angular 组件的异步数据请求(如 http.get )失败或未完成,导致视图未渲染。

解决方案

  • 预请求数据 :Scully 提供了 preRender 插件。在 scully.config.ts 中配置:

    import { JsonPreRenderer } from '@scullyio/scully';
    
    export const config: ScullyConfig = {
      // ...
      routes: {
        '/projects/:id': {
          type: 'json',
          preRender: true, // 关键!启用预渲染
          data: {
            jsonFile: './src/assets/data/projects.json'
          }
        }
      }
    };
    

    这样,Scully 会在抓取前,先读取 JSON 文件,把数据作为 window.__SCULLY_DATA__ 注入页面,你的组件可以直接从 window 对象读取,绕过 HTTP 请求。

  • 在组件中优雅降级 :在 ProjectDetailComponent ngOnInit 中:

    ngOnInit() {
      if (SCULLY_RUNNING && window['__SCULLY_DATA__']) {
        // 从预渲染数据中读取
        this.project = window['__SCULLY_DATA__'].find(p => p.id === this.route.snapshot.paramMap.get('id'));
      } else {
        // 正常 HTTP 请求
        this.projectService.getProject(this.route.snapshot.paramMap.get('id')).subscribe(...);
      }
    }
    

5.3 “路由生成不全,缺少某些页面” —— 路由发现失败

现象 dist/static 目录里没有 about/index.html ,或者 /projects/2 缺失。

排查清单

  • 检查路由模块导入 :确认 about.module.ts 被正确导入到 app-routing.module.ts loadChildren 中,且路径拼写完全一致(大小写敏感)。
  • 检查 --guess-routes :如果依赖猜测,确保 routerLink 的路径是字符串字面量,而非变量。例如, [routerLink]="['/about']" 可以被猜到,但 [routerLink]="linkArray" 则不行。
  • 检查 scully.config.ts routes 配置 :动态路由必须显式声明。如果漏写了 /projects/:id ,Scully 就不会生成任何 /projects/* 页面。
  • 检查 angular.json architect.build.options.assets :确保 src/assets/data/projects.json 被包含在 assets 数组中,否则构建时不会被复制到 dist/ ,Scully 就找不到它。

5.4 “部署后页面 404,刷新子路由失效” —— 静态托管配置错误

现象 :GitHub Pages 上,首页能打开,但点击“项目”链接后,URL 变成 /projects ,刷新页面显示 404。

原因 :GitHub Pages 默认只提供 index.html ,当请求 /projects 时,它试图找 projects 这个文件或目录,找不到就 404。我们需要告诉服务器,所有未匹配的请求都返回 index.html ,由 Angular Router 处理。

GitHub Pages 解决方案 :在 scully.config.ts 中添加 handle404 配置:

export const config: ScullyConfig = {
  // ...
  handle404: 'index.html' // 关键!
};

这会让 Scully 在 dist/static 目录下生成一个 404.html 文件,其内容与 index.html 完全相同。GitHub Pages 会自动将所有 404 请求重定向到这个文件,从而实现 Angular 的客户端路由回退。

其他平台 :Vercel 和 Netlify 默认支持 SPA 回退,无需额外配置;Cloudflare Pages 需要在 wrangler.toml 中设置 rules = [{ type = "redirect", generated_file = "index.html" }]

5.5 “SEO 元信息未生效,标题还是默认值” —— data 属性未被识别

现象 :生成的 HTML 中 <title> 标签内容是 My Portfolio ,而不是你在 data: { title: '关于我' } 中设置的值。

原因 :Scully 的 SEO 插件未启用,或 data 属性未被正确注入。

解决方案

  • 确保 scully.config.ts 中启用了 seoHrefOptimise 插件:
    export const config: ScullyConfig = {
      // ...
      defaultPostRenderers: ['seoHrefOptimise'], // 必须包含
    };
    
  • 确保 app-routing.module.ts 中的 data 属性是直接写在路由对象上的,而不是在 loadChildren 的模块内部。Scully 只解析顶层路由配置。

注意:Scully 的 SEO 插件目前主要处理 <title> <meta name="description"> 。如果你想添加 Open Graph 或 Twitter Card 标签,需要编写自定义插件,或在 index.html <head> 中使用 ngIf 动态插入,但这在静态化后会失效,所以推荐用插件。

6. 性能优化与后续演进方向

当作品集上线后,下一步不是“庆祝”,而是思考如何让它更快、更智能、更可持续。以下是几个经过实战验证的优化点。

6.1 构建速度优化:Scully 的瓶颈在哪里?

Scully 的构建时间主要消耗在 Puppeteer 启动浏览器和逐个抓取页面上。一个 20 页的站点,可能耗时 2-3 分钟。优化思路有三:

  • 并行抓取 :Scully 默认是串行抓取。在 scully.config.ts 中启用 maxRenderThreads

    export const config: ScullyConfig = {
      // ...
      maxRenderThreads: 4 // 同时抓取4个页面
    };
    

    这能显著缩短总时间,但会增加内存占用。建议根据 CI 机器的 CPU 核心数设置(通常设为 os.cpus().length - 1 )。

  • 跳过非关键页面 :首页、项目页、关于页是核心,而 404.html robots.txt 等可以跳过。在 scully.config.ts routes 中,为不需要抓取的路由设置 type: 'none'

  • 增量构建 :Scully 1.0+ 支持 --scanRoutes ,它会对比上次构建的路由快照,只重新抓取发生变化的路由。在 CI 脚本中,可以缓存 scully-routes.json 文件,实现真正的增量。

6.2 内容管理演进:从 JSON 文件到 Headless CMS

projects.json 方式适合小规模内容,但当项目增多、需要多人协作、或希望非技术人员也能更新时,就需要 CMS。Scully 完美支持 Headless CMS,如 Contentful、Sanity、Strapi。

以 Contentful 为例:

  1. 在 Contentful 中创建 Project 内容类型,定义字段(title、description、tech、image);
  2. scully.config.ts 中,将 routes 配置改为:
'/projects/:id': {
  type: 'contentful',
  contentful: {
    space: 'your-space-id',
    accessToken: 'your-access-token',
    environment: 'master',
    contentType: 'project'
  }
}
  1. Scully 会在构建时,调用 Contentful API 获取所有 project 条目,并为每个条目生成快照。

这种方式将内容与代码分离,设计师更新项目描述,开发者无需改代码,只需重新运行 npm run scully

6.3 交互增强:在静态页面上添加“活”的功能

静态不等于“死”。我们可以在生成的 HTML 上,用轻量级 JS 添加交互,而不引入整个 Angular。

  • 表单提交 :联系页的表单,可以用 fetch() 直接 POST 到 Formspree 或 Netlify Forms,无需后端。
  • 搜索功能 :用 Lunr.js 或 Fuse.js 在客户端实现全文搜索,数据源是 Scully 生成的 scully-routes.json
  • 暗色模式 :用 localStorage 存储用户偏好,通过 CSS 变量切换主题,所有逻辑都在 index.html <script> 中。

这些增强,让作品集既有静态站点的速度和可靠性,又有现代 Web 应用的交互体验。

我在实际操作中发现,最值得投入时间的是 自动化测试 。为 Scully 构建流程写一个简单的 Cypress E2E 测试,检查首页是否加载、所有项目链接是否可点击、 <title> 是否正确,能在每次 PR 时自动拦截构建错误,远比人工检查高效。这个习惯,让我在过去一年里,零次因部署问题导致作品集离线。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值