使用 Meteor 在 Google PageSpeed Insights 上得分 100(即网络应用登陆页面)

Score 100 on Google PageSpeed Insights using Meteor (ie. a web-app landing page)

我的客户要求功能丰富的客户端呈现网络应用程序,同时在 Google PageSpeed Insights 上得分为 100/100,并且在第一次加载时呈现速度非常快,缓存为空。她想将同一个网站用作网络应用程序和登录页面,并让任何搜索引擎轻松抓取具有良好 SEO 的整个网站。

这可以使用 Meteor 吗?怎么做到的?

是的,使用 Meteor 1.3、一些额外的软件包和一个小技巧,这很简单os。

有关示例,请参阅 bc-real-estate-math.com。 (这个网站只有 97 分,因为我没有调整图片的大小,而且 Analytics 和 FB 跟踪的缓存寿命很短)

传统上,像 Meteor 这样的 client-side 渲染平台在第一次加载空缓存时速度很慢,因为 Javascript 负载很大。 Server-side 渲染(使用 React)第一页 almost 解决了这个问题,除了 Meteor out-of-the-box 不支持异步 Javascript 或内联 CSS 从而减慢速度您的第一个渲染并杀死您的 Google PageSpeed Insights 分数(并且您可能会争论该指标,它会影响我客户的 AdWord 价格,因此我会针对它进行优化)。

这是您可以通过此答案的设置实现的:

  • 在空缓存上非常快 time-to-first-render,大约 500 毫秒
  • 没有"flash of styled content"
  • Google PageSpeed Insights 得分 100/100
  • 在不破坏您的 PageSpeed 分数的情况下使用任何网络字体
  • 包括页面标题和元数据在内的完整 SEO 控制
  • 与 Google 分析和 Facebook 像素的完美集成 准确记录每个页面视图,无论服务器或 client-side渲染
  • Google 搜索机器人和其他抓取工具无需 运行 脚本
  • 即可立即查看您所有页面的真实 HTML
  • 无缝处理 #hash URL 以滚动到页面的某些部分
  • 使用少量(如 < 30)图标字体 不添加请求或损害速度得分的字符
  • 在不影响着陆页体验的情况下扩展到 Javascript 的任意大小
  • 完整流星的所有常规威力web-app

此设置无法实现的内容:

  • 大型单体 CSS 框架将开始扼杀您的 PageSpeed 分数并减慢 time-to-first-render。 Bootstrap 在您开始看到问题之前,您可以做到最大程度
  • 您无法避免 flash-of-wrong-font 并仍然保持 100/100 PageSpeed。第一个渲染将是客户端的 web-safe 字体,第二个渲染将使用您之前推迟的任何字体。

基本上你可以实现的是:

  • 客户请求您站点内的任何 url
  • 服务器发回一个 使用内联 CSS、异步 Javascript 和延迟完成 HTML 文件 字体
  • 客户端请求图像(如果有),服务器发送它们
  • 客户端现在可以呈现页面
  • 延迟字体(如果有)到达并且页面可能 re-render
  • Javascript母舰有效载荷到达 背景
  • Meteor 启动,你有一个功能齐全的 web-app 所有的花里胡哨,没有 first-load 惩罚
  • 只要你 给用户几行文字和一张漂亮的图片 看,他们永远不会注意到静态 HTML 的转换 页面到 full-blown web-app

如何完成这个

我使用了 Meteor 1.3 和这些附加包:

  • 反应
  • react-dom
  • react-router
  • react-router-ssr
  • react-helmet
  • postcss
  • 自动前缀
  • meteor-node-stubs

React 与 server-side 渲染配合得很好,我还没有尝试过任何其他渲染引擎。 react-helmet 用于轻松添加和修改客户端和 server-side 每个页面的 <head>(例如,需要设置每个页面的标题)。我使用 autoprefixer 将所有 vendor-specific 前缀添加到我的 CSS/SASS,当然本练习不需要。

按照 react-router、reac-router-ssr 和 react-helmet 文档中的示例,站点的

Most 非常简单。有关详细信息,请参阅 os 软件包的文档。

首先,一个非常重要的文件应该在共享的 Meteor 目录中(即不在服务器或客户端文件夹中)。此代码设置 React server-side 渲染、<head> 标签、Google 分析、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>
            ...

如何添加延迟字体。

这些字体将在初始渲染后加载,因此不会延迟 time-to-first-render。我相信这是在不降低 PageSpeed 分数的情况下使用 webfonts 的唯一方法。然而,它确实导致了一个简短的 flash-of-wrong-font。将其放入客户端包含的脚本文件中:

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 和 hand-pick 这样的优质服务,只有您真正需要的图标,您可以将它们嵌入到您的内联 <head> CSS 中并获取图标冷杉无需等待大字体文件即可呈现。

黑客

这还不够 os 但问题是我们的脚本 CSS 和字体正在同步加载并减慢渲染速度并破坏我们的 PageSpeed 分数。不幸的是,据我所知,Meteor 1.3 不正式支持任何内联 CSS 或将 async 属性添加到脚本标签的方法。我们必须破解核心 boilerplate-generator 包的 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>

现在计算您编辑的ose 2 个文件中的字符数,并在中第ose 个文件条目的长度字段中输入新值~ /.meteor/packages/boilerplate-generator/.1.0.8.4n62e6++os+web.browser+web.cordova/os.json

然后删除project/.meteor/local文件夹强制Meteor使用新的核心包并重启你的应用程序(热重载将不起作用)。您只会看到生产模式的变化。

这显然是一个 hack,会在 Meteor 更新时中断。我希望通过 pos 发布并引起一些兴趣,我们将朝着更好的方向努力。

待办事项

需要改进的地方是:

  • 避免黑客攻击。让 MDG 正式支持异步脚本和内联 CSS 灵活的方式
  • 允许精细控制 CSS 内联和延迟
  • 允许精细控制哪些 JS 异步,哪些同步 要内联的。