我的客户需要一个功能丰富的客户端渲染网络应用程序,该应用程序同时在 Google PageSpeed Insights 上的得分为 100/100,并且在第一次加载时渲染速度非常快,并且缓存为空。她希望将同一个站点同时用作 Web 应用程序和登录页面,并让任何搜索引擎都可以通过良好的 SEO 轻松抓取整个站点。
这可以使用 Meteor 吗?怎么做到呢?
我的客户需要一个功能丰富的客户端渲染网络应用程序,该应用程序同时在 Google PageSpeed Insights 上的得分为 100/100,并且在第一次加载时渲染速度非常快,并且缓存为空。她希望将同一个站点同时用作 Web 应用程序和登录页面,并让任何搜索引擎都可以通过良好的 SEO 轻松抓取整个站点。
这可以使用 Meteor 吗?怎么做到呢?
是的,使用 Meteor 1.3、一些额外的包和一个小技巧,这是可能的并且很容易。
有关示例,请参见bc-real-estate-math.com。(这个网站只有 97 分,因为我没有调整图片大小,而且 Analytics 和 FB 跟踪的缓存寿命很短)
传统上,像 Meteor 这样的客户端渲染平台在第一次加载时由于 Javascript 负载很大而在缓存为空的情况下很慢。第一个页面的服务器端渲染(使用 React)几乎解决了这个问题,除了 Meteor 开箱即用不支持异步 Javascript 或内联 CSS 从而减慢您的第一次渲染并降低您的 Google PageSpeed Insights 分数(并争辩为您可能会考虑该指标,它会影响我客户的 AdWord 价格,因此我会针对它进行优化)。
这是您可以通过此答案的设置实现的目标:
此设置无法实现的功能:
基本上你可以做到的是:
如何做到这一点
我使用了 Meteor 1.3 和这些附加包:
React 与服务器端渲染配合得很好,我还没有尝试过任何其他渲染引擎。react-helmet 用于轻松添加和修改<head>
客户端和服务器端的每个页面(例如,设置每个页面的标题所需的)。我使用 autoprefixer 将所有特定于供应商的前缀添加到我的 CSS/SASS 中,本练习当然不需要。
按照 react-router、reac-router-ssr 和 react-helmet 文档中的示例,该站点的大部分内容都非常简单。有关它们的详细信息,请参阅这些包的文档。
首先,一个非常重要的文件应该在共享 Meteor 目录中(即不在服务器或客户端文件夹中)。此代码设置 React 服务器端渲染、<head>
标签、Google Analytics、Facebook 跟踪,并滚动到 #hash 锚点。
import { Meteor } from 'meteor/meteor';
import { ReactRouterSSR } from 'meteor/reactrouter:react-router-ssr';
import { Routes } from '../imports/startup/routes.jsx';
import Helmet from 'react-helmet';
ReactRouterSSR.Run(
Routes,
{
props: {
onUpdate() {
hashLinkScroll();
// Notify the page has been changed to Google Analytics
ga('send', 'pageview');
},
htmlHook(html) {
const head = Helmet.rewind();
html = html.replace('<head>', '<head>' + head.title + head.base + head.meta + head.link + head.script);
return html; }
}
},
{
htmlHook(html){
const head = Helmet.rewind();
html = html.replace('<head>', '<head>' + head.title + head.base + head.meta + head.link + head.script);
return html;
},
}
);
if(Meteor.isClient){
// Google Analytics
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-xxxxx-1', 'auto', {'allowLinker': true});
ga('require', 'linker');
ga('linker:autoLink', ['another-domain.com']);
ga('send', 'pageview');
// Facebook tracking
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,
document,'script','https://connect.facebook.net/en_US/fbevents.js');
fbq('init', 'xxxx');
fbq('track', "PageView");
fbq('trackCustom', 'LoggedOutPageView');
}
function hashLinkScroll() {
const { hash } = window.location;
if (hash !== '') {
// Push onto callback queue so it runs after the DOM is updated,
// this is required when navigating from a different page so that
// the element is rendered on the page before trying to getElementById.
setTimeout(() => {
$('html, body').animate({
scrollTop: $(hash).offset().top
}, 1000);
}, 100);
}
}
以下是路线的设置方式。请注意稍后提供给 react-helmet 以设置<head>
内容的标题属性。
import React from 'react';
import { Router, Route, IndexRoute, browserHistory } from 'react-router';
import App from '../ui/App.jsx';
import Homepage from '../ui/pages/Homepage.jsx';
import ExamTips from '../ui/pages/ExamTips.jsx';
export const Routes = (
<Route path="/" component={App}>
<IndexRoute
displayTitle="BC Real Estate Math Online Course"
pageTitle="BC Real Estate Math Online Course"
isHomepage
component={Homepage} />
<Route path="exam-preparation-and-tips">
<Route
displayTitle="Top 3 Math Mistakes to Avoid on the UBC Real Estate Exam"
pageTitle="Top 3 Math Mistakes to Avoid on the UBC Real Estate Exam"
path="top-math-mistakes-to-avoid"
component={ExamTips} />
</Route>
);
App.jsx -- 外部应用程序组件。请注意<Helmet>
根据特定页面组件的属性设置一些元标记和页面标题的标记。
import React, { Component } from 'react';
import { Link } from 'react-router';
import Helmet from "react-helmet";
export default class App extends Component {
render() {
return (
<div className="site-wrapper">
<Helmet
title={this.props.children.props.route.pageTitle}
meta={[
{name: 'viewport', content: 'width=device-width, initial-scale=1'},
]}
/>
<nav className="site-nav">...
一个示例页面组件:
import React, { Component } from 'react';
import { Link } from 'react-router';
export default class ExamTips extends Component {
render() {
return (
<div className="exam-tips blog-post">
<section className="intro">
<p>
...
如何添加延迟字体。
这些字体将在初始渲染后加载,因此不会延迟首次渲染时间。我相信这是在不降低 PageSpeed 分数的情况下使用 webfonts 的唯一方法。然而,它确实会导致短暂的错误字体闪烁。将其放入客户端中包含的脚本文件中:
WebFontConfig = {
google: { families: [ 'Open+Sans:400,300,300italic,400italic,700:latin' ] }
};
(function() {
var wf = document.createElement('script');
wf.src = 'https://ajax.googleapis.com/ajax/libs/webfont/1/webfont.js';
wf.type = 'text/javascript';
wf.async = 'true';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
如果您使用像fontello.com这样的优质服务,并且只手工挑选您真正需要的图标,您可以将它们嵌入到您的内联<head>
CSS 中,并在第一次渲染时获取图标,而无需等待大字体文件。
黑客
这几乎足够了,但问题是我们的脚本、CSS 和字体正在同步加载,从而减慢了渲染速度并扼杀了我们的 PageSpeed 分数。不幸的是,据我所知,Meteor 1.3 不正式支持内联 CSS 或将 async 属性添加到脚本标签的任何方式。我们必须破解核心样板生成器包的 3 个文件中的几行代码。
~/.meteor/packages/boilerplate-generator/.1.0.8.4n62e6++os+web.browser+web.cordova/os/boilerplate-generator.js
...
Boilerplate.prototype._generateBoilerplateFromManifestAndSource =
function (manifest, boilerplateSource, options) {
var self = this;
// map to the identity by default
var urlMapper = options.urlMapper || _.identity;
var pathMapper = options.pathMapper || _.identity;
var boilerplateBaseData = {
css: [],
js: [],
head: '',
body: '',
meteorManifest: JSON.stringify(manifest),
jsAsyncAttr: Meteor.isProduction?'async':null, // <------------ !!
};
....
if (item.type === 'css' && item.where === 'client') {
if(Meteor.isProduction){ // <------------ !!
// Get the contents of aggregated and minified CSS files as a string
itemObj.inlineStyles = fs.readFileSync(pathMapper(item.path), "utf8");;
itemObj.inline = true;
}
boilerplateBaseData.css.push(itemObj);
}
...
~/.meteor/packages/boilerplate-generator/.1.0.8.4n62e6++os+web.browser+web.cordova/os/packages/boilerplate-generator/boilerplate_web.browser.html
<html {{htmlAttributes}}>
<head>
{{#each css}}
{{#if inline}}
<style>{{{inlineStyles}}}</style>
{{else}}
<link rel="stylesheet" type="text/css" class="__meteor-css__" href="{{../bundledJsCssUrlRewriteHook url}}">
{{/if}}
{{/each}}
{{{head}}}
{{{dynamicHead}}}
</head>
<body>
{{{body}}}
{{{dynamicBody}}}
{{#if inlineScriptsAllowed}}
<script type='text/javascript'>__meteor_runtime_config__ = JSON.parse(decodeURIComponent({{meteorRuntimeConfig}}));</script>
{{else}}
<script {{../jsAsyncAttr}} type='text/javascript' src='{{rootUrlPathPrefix}}/meteor_runtime_config.js'></script>
{{/if}}
{{#each js}}
<script {{../jsAsyncAttr}} type="text/javascript" src="{{../bundledJsCssUrlRewriteHook url}}"></script>
{{/each}}
{{#each additionalStaticJs}}
{{#if ../inlineScriptsAllowed}}
<script type='text/javascript'>
{{contents}}
</script>
{{else}}
<script {{../jsAsyncAttr}} type='text/javascript' src='{{rootUrlPathPrefix}}{{pathname}}'></script>
{{/if}}
{{/each}}
</body>
</html>
现在计算您编辑的那两个文件中的字符数,并在~/.meteor/packages/boilerplate-generator/.1.0.8.4n62e6++os+web.browser中这些文件条目的长度字段中输入新值+web.cordova/os.json
然后删除project/.meteor/local文件夹以强制 Meteor 使用新的核心包并重新启动您的应用程序(热重载将不起作用)。您只会看到生产模式的变化。
这显然是一个 hack,并且会在 Meteor 更新时中断。我希望通过发布这个并引起一些兴趣,我们将朝着更好的方式努力。
去做
需要改进的地方是: