如果说 React 有 Gatsby 和 Next,Vue.js 有 Gridsome 和 Nuxt,那么 Angular 未来可能就是刚刚发展起来的 Scully 。Scully 在 2019 年末左右公开,它是第一个尝试把 JAMstack 带进 Angular 的产品,也是第一个专门的 Angular 静态站点生成器。那么 Scully 如何从 Gatsby & Next,Gridsome & Nuxt 获取到的灵感呢?
原来, Scully 创始者 Aaron Frost 认为,Gatsby 、Next 、Gridsome 和 Nuxt 他们都具有一个同样的缺陷。 就拿 Gatsby 来说,Gatsby 作为 SSG 却和 React 本身的框架是两种不同的 "paradigms"。这样就导致了,如果想让项目使用 Gatsby 这个静态站点生成器,那么你必须得从项目最开始就配置使用它。但是在这方面 Scully 却是不同的:
You write your Angular app, then use Scully to pre-render it and generate static HTML. It doesn’t get in the way of your regular Angular development.
在你已有的 Angular 项目的基础上,用 Scully 来预渲染整个项目,生成一系列静态的 HTML 文件。 Scully 不会对正常的 Angular 应用开发产生任何影响。
Scully 最美妙的地方在于,即使是一个已经写好的项目,你也可以临时配置 Scully 来为项目生成 static sites 。
Scully work with your Angular app, without any change in your code.
Scully 和 Angular 应用兼容,不会对应用的源代码带来任何改变。
如果换句话评价一下 Scully 的优势,"Scully is less opinionated" 。由此可见,Scully 正是 Angular Community 当下最需要的。
Aaron Frost 也提到了他对 Scully 的野心。他说,在未来 Scully 对 Angular 的支持发展稳定之后,研发团队计划继续拓展到 React 市场。由于 Scully 的这个优势,它将是 Gatsby & Next 的有力竞争对手。
如果你对于 JAMstack 和 SSG 这些概念不太熟悉,那么我们来简单讲讲为什么要使用 SSG 。
静态站点生成器带来的好处。
- 更好的性能。网站内容都是通过内容分发网络(CDN)获取,不需要通过相对很慢的服务端访问。
- 更高的安全性。不使用服务端访问来获取网站内容也意味着更少的安全隐患。
- 更效率的开发周期。不需要建立和维护很复杂的项目架构或者 "infrastructure" 。
当然,Scully 只是刚刚发展起来,它的官方文档也还有很多可以提升的空间。但是这并不是说不值得去阅读他的官方文档。如果你想更好的了解 Scully ,请前往 Scully.io 。
接下来的部分,我会总结一下 Scully 的使用方法。
Scully 的使用。
简单的来说,项目组使用 Scully 会有以下流程:
- 正常的创建并开发一个 Angular 应用,包括 Services 和 Components (以及 Lazy-loading Modules )。
- 给 Angular 应用添加 Scully 。一个名叫
scully.{projectName}.config.js
的文件会在项目根目录下默认生成。 - 按实际项目的情况,定义并注册自己需要的 plugins (包括 Router Plugins,Render Plugins 和 File Handler Plugins ,具体的会在文章后面介绍)。
- 在
scully.{projectName}.config.js
文件中根据ScullyConfig Interface
配置 Scully。这个步骤涉及到根据路由匹配来配置你上一步里定义并注册的 plugins 。 - Build Angular 应用。这会在
/dist/{projectName}
下生成 Angular 应用的 distribution 文件。 - Run Scully 。根据需要加上 Scully CLI options 。这会 build Scully 并 pre-render 项目从而在指定的路径下生成静态站点的文件。你也可以加上
serve
option 默认创建两个服务器,一个是为了 Angular 应用,另一个是为了 Scully build 。
为了更好的理解,我接下来将使用一个简单的项目 Scully Example 当作例子。具体代码请参考Github scully-example 。
Scully Example 网站使用的新闻信息是从 HackerNews 官方的 Firebase 随机挑选的。为了方便演示 Scully ,所有信息是存储在 new.json 静态文件 (为了简化,只存储了三个 news 的 JSON 信息)。该项目使用了一个名为 news
的 Lazy-loading module 。具体文件结构如下:
接着,我们就来展示如何把 Scully 加进去吧。
按照提到的第一步,打开你的 terminal ,输入以下 command :
ng add @scullyio/init
这产生如下改变:
- 将 Scully 加入到 dependencies 中。
- 更新
src/app/app.module.ts
文件。将ScullyLibModule
添加到imports
里。
import { ScullyLibModule } from '@scullyio/ng-lib';
@NgModule({
declarations: [AppComponent, AboutComponent, HomeComponent],
imports: [BrowserModule, AppRoutingModule, HttpClientModule, ScullyLibModule],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
- 更新
src/polyfills.ts
文件。添加如下代码:
/***************************************************************************************************
* SCULLY IMPORTS
*/
// tslint:disable-next-line: align
import 'zone.js/dist/task-tracking';
- 更新
package.json
文件。 - 创建
scully.{projectName}.config.js
文件。对于我们的项目来说,也就是scully.scully-example.config.js
文件。文件最初包含如下代码:
exports.config = {
projectRoot: './src',
projectName: 'scully-example',
outDir: './dist/static',
routes: {},
};
outDir
属性是指 Scully build 生成预渲染文件的放置路径,默认就是 ./dist/static
。
重点就是 routes
属性,它用于配置那些需要额外处理的 routes
,之后定义并注册的 plugins 都是在这里面配置。
如果我们直接 build 我们的应用和 Scully,
ng build
npm run scully
我们会发现:
截图最后一行告诉了我们,我们可能需要为提到的 route
进行额外配置。这时就涉及到了 Scully Plugins 的定义了。
我们还可以去查看 /dist/static/assets/scully-routes.json
文件,我们会发现下列代码:
[{ "route": "/" }, { "route": "/about" }, { "route": "/news" }]
scully-routes.json
文件会为我们列出所有 Scully 已经生成的预渲染的 routes
。Scully 会自动预渲染这些 routes
是因为它们本身就只包含静态的内容。其中 /news
会被预渲染也是因为列表里的 news titles 数据都是来自本地静态 assets/news.json
文件。
由于 Scully 是一个很新的 SSG,目前支持三种 Plugins:
- Router Plugin 。任何在网页路径里含有
router-params
的route
都需要在 Router Plugin 里面进行配置。 Router Plugin 的作用就是告诉 Scully 如何根据router-params
来获得 static 文件需要的将被预渲染数据。 - Render Plugin 。 所有在 Angular 完成渲染之后的 HTML 文件都将被提供给 Render Plugin 进行再一次的改变。
- File Handler Plugin 。 在渲染过程中,File Handler Plugin 会被
contentFolder
Plugin 调用。contentFolder
Plugin 用于对指定类型的文件进行解析,例如Markdown
类型文件。
要自定义一个新的 Plugin ,Scully 为我们提供了 registerPlugin()
方法。 registerPlugin()
方法需要四个参数:
type
:render
,router
或fileHandler
。name
:plugin 的名字, 用于配置。plugin
: plugin 方法本身。validator
:一个 validation 方法。
需要注意的是,
任何新的 Scully plugin 都需要在.js
文件中定义并被exported
。同时他们也必须在scully.{projectName}.config.js
文件中通过require()
而被 required。
Router Plugin
在这个项目中我们需要为 /news/:id
进行 plugin 配置。
Scully 为我们提供了一个 router plugin 的 模版:
function exampleRouterPlugin(route: string, config: any): Promise<HandledRoute[]> {
// Must return a promise
}
interface HandledRoute {
route: string;
}
根据这些所有的信息,我们首先创建一个 plugins/newsPlugin.js
文件,在里面定义我们的 Plugin function 和 validator。如下列代码所示,其实 newsPlugin
就是返回 handledRoutes
,一串 HandledRoute
类型的数组。
const { registerPlugin, routeSplit } = require("@scullyio/scully");
const { httpGetJson } = require("@scullyio/scully/utils/httpGetJson");
const NewsPlugin = "news";
const newsPlugin = async (route, config) => {
const list = await httpGetJson(config.url);
const { createPath } = routeSplit(route);
const handledRoutes = [];
for (let item of list) {
handledRoutes.push({
route: createPath(item.id),
});
}
return handledRoutes;
};
const newsPluginValidator = async (conf) => [];
registerPlugin("router", "news", newsPlugin, newsPluginValidator);
exports.NewsPlugin = NewsPlugin;
config
参数包含了所有我们需要用来创建 "list of urls" 的配置信息。该例子中,config.url
包含的就是我们配置时提供的 url
。
createPath
方法通过 route
参数生成。该方法基于通过 httpGetJson()
获取到的 JSON 数据作为参数,以及原本的 route,来生成 string
类型的 url 。
至于 validator
,validator
一般的作用是在执行 plugin function 之前对 config
里面的数据进行一系列的验证,将没有通过验证的数据对应的 error
推进 errors
数组里。最终返回 errors
数组。
由于这个例子我们不需要使用任何的 validation 方法,所以 newsPluginValidator
只需要返回 []
就好。
接下来我们只需要调用 Scully 提供的 registerPlugin()
方法,同时别忘记 export 我们新定义的 Plugin 。请注意我们只需要 export 一串自定义的字符串 'news'
,而不是 Plugin function 本身。
// ABOVE CODE...
// DO NOT FORGET TO REGISTER THE PLUGIN
registerPlugin("router", "news", newsPlugin, newsPluginValidator);
exports.NewsPlugin = NewsPlugin;
最后一步,我们只需要在 scully.scully-example.config.js
文件里配置 newsPlugin
。这里的 url 就是指能直接 fetch 我们的 JSON 数据的 url 。
const { NewsPlugin } = require("./plugins/newsPlugin");
exports.config = {
projectRoot: "./src",
projectName: "scully-example",
outDir: "./dist/static",
routes: {
"/news/:id": {
type: NewsPlugin,
url: "http://my-json-server.typicode.com/yangjunhan/demo/news",
},
},
};
现在我们重新 build 项目并 run Scully 。由于我们添加了一个 route
的额外配置,我们需要加上 --scanRoutes
的 Command Line Options 。 --scanRoutes
options 会完整地检测一遍所有的 routes
。
ng build
npm run scully -- --scanRoutes
这次之后果然 Scully 查找到了 /news/:id
的配置,并预渲染了所有本地静态 JSON 文件对应 id
的 routes
下的网页 。
如果我们再确认一次 /dist/static/assets/scully-routes.json
文件:
[
{ "route": "/" },
{ "route": "/about" },
{ "route": "/news" },
{ "route": "/news/23035019" },
{ "route": "/news/23029396" },
{ "route": "/news/23039355" }
]
在 /dist/static/
路径下创建了名为 news
的文件夹,其中包含了一系列被 Scully 预渲染而生成的 HTML 文件。
以上展示了如何配置自定义的 Router Plugin 。
Render Plugin
接下来我们再尝试配置一个简单的 Render Plugin 。
我们首先创建一个新的 .js
文件,/plugins/colorPlugin.js
。colorPlugin
的目标是为所有 <p>
tag 加上 color: red
的 style 设置。
跟 Router Plugin 一样的, 我们需要再次使用到 registerPlugin()
方法。因此,我们首先还是定义我们的 Render Plugin function 和 validator 。
这是官方提供的自定义 Render Plugin Interface
。
function exampleContentPlugin(HTML: string, route: HandledRoute): Promise<string> {
// Must return a promise
}
遵循该 Interface,我们需要保证最终返回值必须是 Promise 。
const { registerPlugin } = require("@scullyio/scully");
const ColorPlugin = "color";
const colorPlugin = async (html, route) => {
const splitter = "</head>";
const [begin, end] = html.split(splitter);
const colorStyle = "<style>p {color:red}</style>";
return Promise.resolve(`${begin}${colorStyle}${splitter}${end}`);
};
const colorPluginValidator = async () => [];
colorPlugin
方法就是通过在 <head></head>
之间加入字符串 <style>p {color:red}</style>
,最终用 Promise.resolve()
把重新拼凑的完整 HTML 字符串包裹起来,从而把字符串转换为 Promise 类型。
同样的,我们还是不考虑 validator 的问题。
定义好了 Plugin function 和 validator,我们接下来只需要按照注册 Router Plugin 相似的步骤:
// ABOVE CODE...
// DON NOT FORGET REGISTER THE PLUGIN
registerPlugin("render", ColorPlugin, colorPlugin, colorPluginValidator);
exports.ColorPlugin = ColorPlugin;
最后,我们在 scully.scully-example.config.js
里 require 并配置我们的 Renderer:
const { NewsPlugin } = require("./plugins/newsPlugin");
const { ColorPlugin } = require("./plugins/colorPlugin");
exports.config = {
projectRoot: "./src",
projectName: "scully-example",
outDir: "./dist/static",
routes: {
"/news/:id": {
type: NewsPlugin,
url: "http://my-json-server.typicode.com/yangjunhan/demo/news",
postRenderers: [ColorPlugin],
},
},
};
注意这里的 postRenderers
是 /news/:id
下的属性,这代表了 ColorPlugin
只会在这些 routes
下被使用。
当然,我们也可以通过 Scully Config Interface 下的 defaultPostRenderers: string[];
属性来设置全局的默认 Render Plugins 。需要注意的是, defaultPostRenderers
会在有设置 postRenderers
的 routes
下被覆盖掉。
最后,我们只需要重新 build Scully 并运行 Scully 提供的 Scully static server
(默认端口:1668) 。 Scully serve 同时也会启动 Angular distribution server
(默认端口:1864) 。两个 servers 的端口都可以在 Scully Config Interface 里重新设置。
npm run scully -- --scanRoutes
npm run scully:serve
颜色果然成功变成红色,我们的 ColorPlugin
配置成功!
现在,我们已经大概了解了 Scully 的 Router Plugins 和 Render Plugins。当然, Scully 目前已经为我们提供了一些现成的 Plugins。如果想要了解,欢迎前往他们的官方文档 - List of Plugins。
File Handler Plugin
最后,关于 File Handler Plugin,Scully 同样已经提供了对 asciidoc
和 Markdown
类型文件的 fileHandler Plugins 支持。如果你需要自定义对一些特殊文档类型支持,请参考以下 fileHandler Plugin Interface:
function exampleFileHandlerPlugin(rawContent: string): Promise<string> {
// Must return a promise
}
一般来说,File Handler Plugin 的目的就是为了"格式化" rawContent
,例如利用正则表达式对 rawContent
进行字符串匹配,并用 Table 的 HTML tags 把匹配的内容包起来。
Scully 的 Blogging
Scully 本身也提供了在 Angular 项目里使用静态 Blog 的支持。Blog 本质上就是 Markdown
类型的文件,而 Scully 则会把这些 .md
文件的内容自动预渲染成静态的 HTML 文件。
让我们继续使用上面的同一个项目作为演示。首先,我们需要为项目添加 Blog 的支持。
ng g @scullyio/init:blog
这行 Command 会自动为项目添加一个名为 blog
的 lazy-loading module 。同时,它会在项目路径下创建一个blog
文件夹,文件夹里面自动含有一个默认的 Markdown
文件。
下面就是 blog .md
文件的默认格式:
---
title: 2020-05-06-blog
description: blog description
published: false
---
# 2020-05-06-blog
接下来我们生成一个新的 blog post ,我们叫它 "Demo Blog"。
ng g @scullyio/init:post --name="Demo Blog"
于是,一个新的名为 demo-blog.md
的文件会在 /blog/
文件夹里生成。
我们将它的内容更改成以下:
---
title: Demo Blog
description: blog description
published: true
---
# Demo Blog
Scully is the best option for moving a blog to Angular!
注意,blog 里面的 published
必须设为 true
内容才能被访问。
默认 Scully Blog 支持的设置里,Blog 是通过 /blog/:slug
路径被访问的。这里的 slug
默认是指 Blog 的 title
,在我们的演示项目里,也就是 /blog/demo-blog
。
接下来,我们简单的在 /src/app.component.html
文件里添加一个导航按钮:
<li><a href="blog/demo-blog">Demo Blog</a></li>
最后,我们只需要重新 build 我们的项目以及 Run Scully:
ng build && npm run scully -- --scanRoutes
再启动我们的服务器:
npm run scully:serve
Blog 的内容成功的加载出来啦!
需要注意的是,Blog 的内容只能在 Scully static server
里成功显示出来。如果我们尝试在一般的 Angular distribution server
里显示 Blog 的话。。。
结尾
据我所知,每个 SSG 的核心其实就是看他们为使用者提供的 Plugins。 目前来说,由于 Scully 才刚刚起步,现成的 Plugins 确实还太少,但是相信不久的将来,Scully 会拥有数量可观的现成 Plugins 以及用户群。