6

我的客户需要一个功能丰富的客户端渲染网络应用程序,该应用程序同时在 Google PageSpeed Insights 上的得分为 100/100,并且在第一次加载时渲染速度非常快,并且缓存为空。她希望将同一个站点同时用作 Web 应用程序和登录页面,并让任何搜索引擎都可以通过良好的 SEO 轻松抓取整个站点。

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

4

1 回答 1

25

是的,使用 Meteor 1.3、一些额外的包和一个小技巧,这是可能的并且很容易。

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

传统上,像 Meteor 这样的客户端渲染平台在第一次加载时由于 Javascript 负载很大而在缓存为空的情况下很慢。第一个页面的服务器端渲染(使用 React)几乎解决了这个问题,除了 Meteor 开箱即用不支持异步 Javascript 或内联 CSS 从而减慢您的第一次渲染并降低您的 Google PageSpeed Insights 分数(并争辩为您可能会考虑该指标,它会影响我客户的 AdWord 价格,因此我会针对它进行优化)。

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

  • 空缓存的首次渲染时间非常快,例如 500 毫秒
  • 没有“样式化内容的闪光”
  • 在 Google PageSpeed Insights 上得分 100/100
  • 使用任何网络字体而不破坏您的 PageSpeed 分数
  • 完整的 SEO 控制,包括页面标题和元数据
  • 与 Google Analytics 和 Facebook Pixels 完美集成,无论服务器端或客户端呈现如何,都能准确记录每个页面视图
  • Google 搜索机器人和其他抓取工具可以立即查看您所有页面的真实 HTML,而无需运行脚本
  • 无缝处理 #hash URL 以滚动到页面的某些部分
  • 使用少量(如 < 30)的图标字体字符,而不会添加请求或损害速度分数
  • 在不影响着陆页体验的情况下扩展到任何大小的 Javascript
  • 完整 Meteor 网络应用程序的所有常规功能

此设置无法实现的功能:

  • 大型单体 CSS 框架将开始降低您的 PageSpeed 分数并减慢首次渲染时间。在您开始发现问题之前,Bootstrap 已尽您所能
  • 您无法避免出现错误字体并仍然保持 100/100 PageSpeed。第一个渲染将是客户端的网络安全字体,第二个渲染将使用您之前延迟的任何字体。

基本上你可以做到的是:

  • 客户请求您网站内的任何网址
  • 服务器发回包含内联 CSS、异步 Javascript 和延迟字体的完整 HTML 文件
  • 客户端请求图像(如果有)并且服务器发送它们
  • 客户端现在可以呈现页面
  • 延迟字体(如果有)到达并且页面可能会重新呈现
  • Javascript 母船有效载荷到达后台
  • Meteor 启动,您拥有一个功能齐全的网络应用程序,具有所有的花里胡哨,没有首次加载惩罚
  • 只要你给用户几行文字阅读和一张漂亮的图片,他们永远不会注意到从静态 HTML 页面到成熟的 web 应用程序的过渡

如何做到这一点

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

  • 反应
  • 反应域
  • 反应路由器
  • 反应路由器-ssr
  • 反应头盔
  • postcss
  • 自动前缀
  • 流星节点存根

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 更新时中断。我希望通过发布这个并引起一些兴趣,我们将朝着更好的方式努力。

去做

需要改进的地方是:

  • 避免黑客攻击。让 MDG 以灵活的方式正式支持异步脚本和内联 CSS
  • 允许对哪些 CSS 内联和哪些延迟进行精细控制
  • 允许精细控制哪些 JS 要异步、哪些要同步以及哪些要内联。
于 2016-07-12T22:20:38.763 回答