Flutter Webでブログを作ってSEOスコア100点を取ってみた
Flutter Advent Calendar 2022 の11日目の記事です。
概要
みなさん、Flutter Webはどれくらい活用していますか?
先日開催された『Flutter Kaigi 2022』でもFlutter Webを活用した実例のセッションがありました。
まだVueやReactに取って代わることはないですが、Flutter WebでSPAなWebアプリを開発するという選択も視野に入ってきました。
しかしながら、Flutter WebはSSG未対応なため、NuxtやNextと異なりFlutter Webでメディア制作をするのには適していません。
そこで、今回はFlutter Webを使ってSPAなブログを制作したうえで、SEOスコア100点を取るためにやったことのうち、Flutter関連のものを洗い出してみました。
今回作ったサイトと PageSpeedInsights
のスコアを貼っておきます。
TOPページ
記事詳細
使ったツール
ブログ制作に使ったツールを紹介します。
厳密にはpackage等もありますが、それは個別の機能説明の際に紹介します。
- Flutter Web
- Spearly CMS
- Firebase(Hosting, Functions)
Flutter Web
今回の主役のFlutter Webです。
Flutterのweb開発機能は正式版として提供されているので、プロジェクトを作成するだけでweb開発機能も利用できます。
既存プロジェクトでweb開発機能を有効にするには各々で設定が必要です。
Spearly CMS
毎度紹介しているヘッドレスCMSです。
今回Spearlyを利用した理由は以下の2つの理由です。
- api実装をせずに、埋込タグを設置するだけでブログ一覧・ブログ詳細ページが作成できる
- アップロードした画像をwebpに変換できる
Firebase(Hosting, Functions)
こちらも説明は不要なほど有名なGoogleのmBaaSです。
今回はサイトのホスティングとSEO対策のサイトマップ生成に利用しました。
実装(対応)した機能
ブログとしての必須機能やSEO対策に必要な実装として以下の機能を実装しました。
- 記事一覧
- 記事詳細
- ルーティング
- バージョン管理
- SNSシェア
- サイトマップ
- RSSフィード
- OGP
この機能のうち、サイトマップ以降はFlutter関係なく必要な実装なので今回は説明を割愛します。
実際やっていることはFunctionsからSpearly CMSのapiを叩いて、HTMLやXMLを生成しているだけなので
実装内容
リポジトリが整理できていないので、フォルダ構成だけ記載します。
基本的には、flutter create
で生成されたファイルと firebase init
で生成したHosting, Functionsのファイルがあるのみです。
しかし、いくつか注意すべき実装があったので、一部抜粋して紹介します。
├── assets
│ ├── scss
│ └── view
├── firebase.json
├── functions
│ ├── index.ts
│ ├── package.json
│ ├── src
│ ├── tsconfig.dev.json
│ └── tsconfig.json
├── lib
│ ├── main.dart
│ └── provider
├── package.json
├── pubspec.yaml
└── web
├── favicon.png
├── icons
├── index.html
├── manifest.json
├── ogp.jpg
└── robots.txt
記事一覧・記事詳細
ブログの各ページの実装については、Spearly CMSの埋め込みタグを HtmlElementView
で読み込んで実装しています。
HtmlElementView
生成については以下の記事で細かく記載していますが、要するに 「ブログ用のiframe」を設置し、その中に記事情報を描画する ようにしました。
なぜなら、FlutterはWidgetでUIを作成しており、ヘッドレスCMSのapiが返すHTMLに対応するWidgetを描画するのが難しいと考えたためです。
(いずれはHTMLに呼応するWidgetを生成するようにしたい…)
しかし、これだとiframe内の更新があっても親のURLが変わらないため、実際のURLとiframe内のコンテンツのズレが生じてしまいます。
そのため、WebAPIの window.postMessage
を使って、一覧のリンクがクリックされたことを検知してFlutter内の状態を更新する方針で実装しました。
実際のコードとともに簡単に役割をまとめると以下のとおりです。
main.dart
-
HtmlElementView
で記事描画用のiframeを生成する - 現在のルーティングを元にiframeへ渡すHTMLファイルを更新する
-
postMessage
で受け取ったidの記事詳細ページに遷移する
index.html
- 記事一覧を生成する
- リンクのクリックを検知して、クリックした記事のidを
postMessage
でFlutterに伝える
item.html
- 記事詳細を生成する
class HomePage extends StatelessWidget {
HomePage({Key? key}) : super(key: key);
final viewId = 'column';
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final screenHeight = MediaQuery.of(context).size.height;
final IdProvider idProvider =
Provider.of<IdProvider>(context, listen: true);
final VersionProvider versionProvider =
Provider.of<VersionProvider>(context, listen: true);
// ignore: undefined_prefixed_name
ui.platformViewRegistry.registerViewFactory(
viewId,
(int id) => html.IFrameElement()
..width = MediaQuery.of(context).size.width.toString()
..height = MediaQuery.of(context).size.height.toString()
..src = _fileName(idProvider.id, versionProvider.version)
..style.border = 'none');
window.addEventListener("message", (event) {
final data = (event as MessageEvent).data ?? '-';
final contentId = data.substring(22);
if (contentId.contains('c-') && contentId != idProvider.id) {
idProvider.updateId(contentId);
context.push('/column/$contentId');
}
});
return MaterialApp(
title: 'メタバースで遊ぶ鹿児島のアプリエンジニア',
home: Scaffold(
appBar: AppBar(...),
body: Column(
children: [
SizedBox(
width: screenWidth,
height: screenHeight - 106,
child: HtmlElementView(
viewType: viewId,
),
),
_footer(context)
],
),
floatingActionButton: _floatingActionButton(context)),
);
}
}
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./css/style.css">
<title>メタバースで遊ぶ鹿児島のアプリエンジニア</title>
<script src="https://static.spearly.com/js/cms.js" defer></script>
<script>window.addEventListener('DOMContentLoaded',()=>{const t=document.querySelectorAll(':not(:defined)');for(const e of t) {e.style.visibility="hidden";}; window.spearly.config.AUTH_KEY="{Spearly Docを参照}"},{once:true})</script>
</head>
<body>
<ul>
<li cms-loop cms-content-type="column" cms-option-order_direction>
<a href="#" onclick="sendData('{%= column_#url %}')" class="column-list">
<div class="column-list__cell">
<img alt="{%= column_title %}" class="column-list__thumbnail" decoding="async" loading="lazy"
src="{%= column_image %}"/>
<h3 class="column-list__title">{%= column_title %}</h3>
</div>
<div class="column-list__meta">
<ul class="column-list__tag">
<li cms-loop cms-field="tag">
{%= column_tag_title %}
</li>
</ul>
{%= column_created_at %}
</div>
</a>
</li>
</ul>
<script>
const sendData = (url) => {
window.parent.postMessage(url, "*");
}
</script>
<script src="./js/main.js" defer></script>
</body>
</html>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700&display=swap" rel="stylesheet">
<link href="https://use.fontawesome.com/releases/v5.15.4/css/all.css" rel="stylesheet">
<link rel="stylesheet" href="./css/style.css">
<title>メタバースで遊ぶ鹿児島のアプリエンジニア</title>
<script src="https://static.spearly.com/js/cms.js" defer></script>
<script>window.addEventListener('DOMContentLoaded',()=>{const t=document.querySelectorAll(':not(:defined)');for(const e of t) {e.style.visibility="hidden";}; window.spearly.config.AUTH_KEY="{Spearly Docを参照}"},{once:true})</script>
</head>
<body>
<main>
<article cms-item cms-content-type="column" cms-content="" class="column">
<h1 class="column__title">{%= column_title %}</h1>
<ul class="column__tag">
<li cms-loop cms-field="tag">
{%= column_tag_title %}
</li>
</ul>
<div class="column__meta">
<span>投稿日: {%= column_created_at %}</span>
<span>更新日: {%= column_updated_at %}</span>
</div>
<img alt="{%= column_title %}" class="column__hero" decoding="async" loading="lazy"
src="{%= column_image %}"/>
<div class="column__body">
{%= column_description %}
</div>
</article>
</main>
<script src="./js/main.js" defer></script>
</body>
</html>
ルーティング
ルーティング管理用のpackageとして go_router
を利用しました。
ルーティング用に /column/{id}
の id
をProviderで管理しています。
import 'package:flutter/material.dart';
class IdProvider with ChangeNotifier {
String? id;
IdProvider(String? value) {
updateId(value);
}
void updateId(String? value) {
id = value;
notifyListeners();
}
}
idが変わっても描画するWidgetは変わりません。
同じWidget内の _fileName
関数で記事一覧か記事詳細(詳細の場合にはそのid)を判定して、iframeに渡すHTMLを決定するようにしています。
final GoRouter _router = GoRouter(
routes: <GoRoute>[
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
print(state);
return HomePage();
},
),
GoRoute(
path: '/column/:id',
builder: (BuildContext context, GoRouterState state) {
final id = state.params['id'];
final IdProvider idProvider =
Provider.of<IdProvider>(context, listen: true);
idProvider.updateId(id);
return HomePage();
},
),
GoRoute(
path: '/co1umn/:id',
redirect: (GoRouterState state) {
final id = state.params['id'];
if (id != null) {
return '/column/$id';
}
return "/";
}),
],
);
class Sample extends StatelessWidget {
String _fileName(String? id, String version) {
const isRelease = bool.fromEnvironment('dart.vm.product');
if (isRelease) {
return 'assets/assets/view/${_baseFile(id, version)}';
} else {
return 'assets/view/${_baseFile(id, version)}';
}
}
String _baseFile(String? id, String version) {
if (id != null) {
return 'item.html?contentId=$id&t=$version';
} else {
return 'index.html?t=$version';
}
}
}
バージョン管理
PageSpeedInsights
のアドバイスもあり、HTMLやCSS,JS等のassetsはキャッシュ期間を長めに設定しています。
しかしキャッシュ期間が長いと、ちょっとしたデザイン変更でCSSを更新してもキャッシュが効いているために反映されない問題が起こりえます。
そこで、ファイル末尾に ?v=xxx
というクエリを加える常套手段をとることにしました。
クエリを加えることでキャッシュを効かせつつ、assetsの更新は即座に反映できます。
URLのパラメータにはFlutterのpackageVersionを使うことでリリースのたびに自然にキャッシュ更新をできる想定です。
Future<void> main() async {
setUrlStrategy(PathUrlStrategy());
String? id = Uri.base.queryParameters["id"];
String? token = Uri.base.queryParameters["token"];
PackageInfo packageInfo = await PackageInfo.fromPlatform();
runApp(MyApp(id: id, token: token, version: packageInfo.version));
}
バージョン情報については、idと同じようにProviderで管理しています。
import 'package:flutter/material.dart';
class VersionProvider with ChangeNotifier {
String version = '0';
VersionProvider(String value) {
updateVersion(value);
}
void updateVersion(String value) {
version = value;
notifyListeners();
}
}
これでHTMLについては **.html?t=xxx
というURLパラメータが付加できましたが、CSSは更新できていません。
CSSはassets内にあり、Flutterからは干渉できないのでjsでHTMLのパラメータをCSSにも付加するようにしました。
const getParam = (name, url) => {
if (!url) url = window.location.href;
name = name.replace(/[\[\]]/g, "\\$&");
const regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)");
const results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, " "));
}
const stylesheets = document.querySelectorAll('link[rel="stylesheet"]');
stylesheets.forEach(function(value) {
const version = getParam('t') ?? '0';
value.href = value.href.replace("style.css", `style.css?v=${version}`);
});
ここまで実装することで、pubspec.yaml
の version
を更新するだけで、HTMLやCSS, JSのassetsが更新可能になりました。
(厳密には上で示した main.js
は更新できないので、js更新の際には新規のjsファイルを追加する必要があります。)
また、キャッシュの管理についてはFirebaseの設定になるのでここでは割愛します。
SNSシェア
ブログによくあるSNSシェアのボタンも設置しました。
こちらは share_plus
のpackageを導入してその機能を呼び出しただけなのですが、PCでシェア機能を呼び出すとメーラーが起動してしまう問題が発生しました。
そのため、下記のようにスマホとPC版で表示するボタンを変えるレスポンシブ対応を実装することで、メーラーが起動する問題を解消しました。
(もし良い方法を知っている人がいたらぜひ教えて下さい!)
class Sample extends StatelessWidget {
Widget _floatingActionButton(BuildContext context) {
if (isSp(context)) {
return _shareButton(context);
} else {
return _twitterButton(context);
}
}
Widget _shareButton(BuildContext context) {
return FloatingActionButton(
backgroundColor: Colors.lime,
child: FaIcon(
FontAwesomeIcons.shareNodes,
color: Colors.white,
semanticLabel: 'share',
),
onPressed: () async {
await Share.share("メタバースで遊ぶ鹿児島のアプリエンジニア ${window.location.href}");
},
);
}
Widget _twitterButton(BuildContext context) {
return FloatingActionButton(
backgroundColor: Colors.blue,
child: FaIcon(
FontAwesomeIcons.twitter,
color: Colors.white,
semanticLabel: 'share',
),
onPressed: () async {
final Map<String, dynamic> tweetQuery = {
"text": "メタバースで遊ぶ鹿児島のアプリエンジニア",
"url": window.location.href,
};
final Uri tweetScheme =
Uri(scheme: "twitter", host: "post", queryParameters: tweetQuery);
final Uri tweetIntentUrl =
Uri.https("twitter.com", "/intent/tweet", tweetQuery);
await canLaunchUrl(Uri.parse(tweetScheme.toString()))
? await _launchURL(tweetScheme.toString())
: await _launchURL(tweetIntentUrl.toString());
},
);
}
}
その他
この記事には記載していないTipsがありますが、それは以下の記事の内容を無思考に実装してるものです。
実際のコード
それらのTipsも含めた実際にブログを動かしているコードについてもここに貼り付けます。
個人用途なのでちゃんと切り分けられていない…
// ignore: avoid_web_libraries_in_flutter
import 'dart:html' as html;
import 'dart:html';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import 'provider/id_provider.dart';
import 'provider/version_provider.dart';
Future<void> main() async {
setUrlStrategy(PathUrlStrategy());
String? id = Uri.base.queryParameters["id"];
String? token = Uri.base.queryParameters["token"];
PackageInfo packageInfo = await PackageInfo.fromPlatform();
runApp(MyApp(id: id, token: token, version: packageInfo.version));
}
final GoRouter _router = GoRouter(
routes: <GoRoute>[
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
print(state);
return HomePage();
},
),
GoRoute(
path: '/column/:id',
builder: (BuildContext context, GoRouterState state) {
final id = state.params['id'];
final IdProvider idProvider =
Provider.of<IdProvider>(context, listen: true);
idProvider.updateId(id);
return HomePage();
},
),
GoRoute(
path: '/co1umn/:id',
redirect: (GoRouterState state) {
final id = state.params['id'];
if (id != null) {
return '/column/$id';
}
return "/";
}),
],
);
// ignore: must_be_immutable
class MyApp extends StatelessWidget {
String? token, id;
String version;
MyApp({Key? key, required this.version, this.id, this.token})
: super(key: key);
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider<IdProvider>(
create: (context) => IdProvider(id),
),
ChangeNotifierProvider<VersionProvider>(
create: (context) => VersionProvider(version),
),
],
child: MaterialApp.router(
routeInformationProvider: _router.routeInformationProvider,
routeInformationParser: _router.routeInformationParser,
routerDelegate: _router.routerDelegate,
title: 'メタバースで遊ぶ鹿児島のアプリエンジニア',
theme: ThemeData(
primarySwatch: Colors.lightGreen,
),
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('ja', 'JP'),
],
locale: const Locale('ja', 'JP'),
),
);
}
}
class HomePage extends StatelessWidget {
HomePage({Key? key}) : super(key: key);
final viewId = 'column';
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final screenHeight = MediaQuery.of(context).size.height;
final IdProvider idProvider =
Provider.of<IdProvider>(context, listen: true);
final VersionProvider versionProvider =
Provider.of<VersionProvider>(context, listen: true);
// ignore: undefined_prefixed_name
ui.platformViewRegistry.registerViewFactory(
viewId,
(int id) => html.IFrameElement()
..width = MediaQuery.of(context).size.width.toString()
..height = MediaQuery.of(context).size.height.toString()
..src = _fileName(idProvider.id, versionProvider.version)
..style.border = 'none');
window.addEventListener("message", (event) {
final data = (event as MessageEvent).data ?? '-';
final contentId = data.substring(22);
if (contentId.contains('c-') && contentId != idProvider.id) {
idProvider.updateId(contentId);
context.push('/column/$contentId');
}
});
return MaterialApp(
title: 'メタバースで遊ぶ鹿児島のアプリエンジニア',
home: Scaffold(
appBar: AppBar(
backgroundColor: Colors.lime,
centerTitle: true,
title: TextButton(
onPressed: () {
// TODO: パフォーマンスが落ちるので消す
window.location.href = '/';
},
child: Text(
'メタバースで遊ぶ鹿児島のアプリエンジニア',
style: TextStyle(
color: Colors.white, fontSize: isSp(context) ? 16 : 18),
textAlign: TextAlign.center,
)),
actions: [
isSp(context)
? Container()
: IconButton(
icon: const FaIcon(
FontAwesomeIcons.user,
color: Colors.white,
semanticLabel: 'Spatial',
),
onPressed: () {
_launchURL(
'https://www.spatial.io/s/qst-exe-room-636f64a8a6af41000126501f?share=70500357164265573');
}),
IconButton(
icon: const FaIcon(
FontAwesomeIcons.blog,
color: Colors.white,
semanticLabel: 'blog',
),
onPressed: () {
_launchURL('https://blog.hhg-exe.jp/');
}),
IconButton(
icon: const FaIcon(
FontAwesomeIcons.twitter,
color: Colors.white,
semanticLabel: 'Twitter',
),
onPressed: () {
_launchURL('https://twitter.com/qst_exe');
}),
isSp(context)
? Container()
: IconButton(
icon: const FaIcon(
FontAwesomeIcons.rss,
color: Colors.white,
semanticLabel: 'RSS',
),
onPressed: () {
_launchURL('https://column.hhg-exe.jp/feed.xml');
}),
],
),
body: Column(
children: [
SizedBox(
width: screenWidth,
height: screenHeight - 106,
child: HtmlElementView(
viewType: viewId,
),
),
_footer(context)
],
),
floatingActionButton: _floatingActionButton(context)),
);
}
Widget _floatingActionButton(BuildContext context) {
if (isSp(context)) {
return _shareButton(context);
} else {
return _twitterButton(context);
}
}
Widget _shareButton(BuildContext context) {
return FloatingActionButton(
backgroundColor: Colors.lime,
child: FaIcon(
FontAwesomeIcons.shareNodes,
color: Colors.white,
semanticLabel: 'share',
),
onPressed: () async {
await Share.share("メタバースで遊ぶ鹿児島のアプリエンジニア ${window.location.href}");
},
);
}
Widget _twitterButton(BuildContext context) {
return FloatingActionButton(
backgroundColor: Colors.blue,
child: FaIcon(
FontAwesomeIcons.twitter,
color: Colors.white,
semanticLabel: 'share',
),
onPressed: () async {
final Map<String, dynamic> tweetQuery = {
"text": "メタバースで遊ぶ鹿児島のアプリエンジニア",
"url": window.location.href,
};
final Uri tweetScheme =
Uri(scheme: "twitter", host: "post", queryParameters: tweetQuery);
final Uri tweetIntentUrl =
Uri.https("twitter.com", "/intent/tweet", tweetQuery);
await canLaunchUrl(Uri.parse(tweetScheme.toString()))
? await _launchURL(tweetScheme.toString())
: await _launchURL(tweetIntentUrl.toString());
},
);
}
Widget _footer(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
return Container(
color: Colors.blueGrey[100],
width: screenWidth,
height: 50,
child: const Center(
child: Text(
'© 2022 メタバースで遊ぶ鹿児島のアプリエンジニア',
style: TextStyle(color: Colors.black),
),
),
);
}
bool isSp(BuildContext context) {
return MediaQuery.of(context).size.width < 765;
}
String _fileName(String? id, String version) {
const isRelease = bool.fromEnvironment('dart.vm.product');
if (isRelease) {
return 'assets/assets/view/${_baseFile(id, version)}';
} else {
return 'assets/view/${_baseFile(id, version)}';
}
}
String _baseFile(String? id, String version) {
if (id != null) {
return 'item.html?contentId=$id&t=$version';
} else {
return 'index.html?t=$version';
}
}
Future _launchURL(String url) async {
final Uri uri = Uri.parse(url);
if (!await launchUrl(uri)) {
throw 'Could not launch $url';
}
}
}
最後に
以上のような方法でSPAなブログを作成できました。
どの実装も無理くり丸め込んだような実装だったので、もし良い実装方法を知っている人がいればぜひ教えて下さい!
作成したブログはコラム的な内容を書く用途として使い、技術系の話(特にコメントが欲しいもの)はこちらのZennかQiitaに書いていくつもりです。
SEOスコアを100点にするための作業のうちFlutterが関与しない部分は、次回(12/25)のアドベントカレンダーで記載していきます。
最後まで読んでいただいてありがとうございました!
よろしければ、いいね!やコメントをしていただけるととっても嬉しいです!!
Discussion