避免自动内联代码的陷阱

已发表: 2022-03-10
快速总结 ↬ 过度使用内联 CSS 或 JS 代码,而不是通过静态资源提供代码,会损害网站的性能。 在本文中,我们将学习如何通过静态文件加载动态代码,避免内联代码过多的弊端。

内联是将文件内容直接包含在 HTML 文档中的过程:CSS 文件可以内联在style元素中,而 JavaScript 文件可以内联在script元素中:

 <style> /* CSS contents here */ </style> <script> /* JS contents here */ </script>

通过打印 HTML 输出中已经存在的代码,内联避免了呈现阻塞请求并在呈现页面之前执行代码。 因此,它对于提高站点的感知性能(即页面变为可用所需的时间)很有用。例如,我们可以使用加载站点时立即传递的数据缓冲区(大约 14kb)内联关键样式,包括首屏内容的样式(就像在之前的 Smashing Magazine 网站上所做的那样),以及字体大小和布局宽度和高度,以避免在传递其余数据时重新呈现跳跃的布局.

但是,当过度内联代码也会对站点的性能产生负面影响:因为代码不可缓存,相同的内容会重复发送到客户端,并且无法通过 Service Worker 进行预缓存,或者从内容交付网络缓存和访问。 此外,在实施内容安全策略 (CSP) 时,内联脚本被认为是不安全的。 然后,它制定了一个明智的策略来内联 CSS 和 JS 的那些关键部分,这些部分使网站加载速度更快,但尽可能避免。

为了避免内联,在本文中,我们将探讨如何将内联代码转换为静态资源:我们不将代码打印在 HTML 输出中,而是将其保存到磁盘(有效地创建静态文件)并添加相应的<script><link>标签来加载文件。

让我们开始吧!

推荐阅读WordPress 安全作为一个进程

跳跃后更多! 继续往下看↓

何时避免内联

没有什么神奇的秘诀可以确定某些代码是否必须内联,但是,当某些代码不能内联时,这一点非常明显:当它涉及大量代码时,以及何时不需要立即内联。

例如,WordPress 站点内联 JavaScript 模板以呈现媒体管理器(可在/wp-admin/upload.php下的媒体库页面中访问),打印大量代码:

媒体库页面源代码截图
由 WordPress 媒体管理器内联的 JavaScript 模板。

占用整整 43kb,这段代码的大小是不可忽略的,因为它位于页面底部,所以不需要立即使用。 因此,通过静态资产提供此代码或将其打印在 HTML 输出中会很有意义。

接下来让我们看看如何将内联代码转换为静态资产。

触发创建静态文件

如果内容(要内联的内容)来自静态文件,那么除了简单地请求该静态文件而不是内联代码之外,没有什么可做的。

但是,对于动态代码,我们必须计划如何/何时生成包含其内容的静态文件。 例如,如果站点提供配置选项(例如更改配色方案或背景图像),那么应该何时生成包含新值的文件? 我们有以下机会从动态代码创建静态文件:

  1. 根据要求
    当用户第一次访问内容时。
  2. 改变时
    当动态代码的来源(例如配置值)发生变化时。

让我们先根据要求考虑。 用户第一次访问该站点时,比如说通过/index.html ,静态文件(例如header-colors.css )还不存在,因此必须在那时生成它。 事件顺序如下:

  1. 用户请求/index.html
  2. 在处理请求时,服务器会检查文件header-colors.css存在。 既然没有,就获取源代码并在磁盘上生成文件;
  3. 它向客户端返回响应,包括标签<link rel="stylesheet" type="text/css" href="/staticfiles/header-colors.css">
  4. 浏览器获取页面中包含的所有资源,包括header-colors.css
  5. 到那时这个文件已经存在,所以它被提供了。

但是,事件的顺序也可能不同,导致结果不理想。 例如:

  1. 用户请求/index.html
  2. 该文件已经被浏览器(或其他代理,或通过 Service Worker)缓存,因此请求永远不会发送到服务器;
  3. 浏览器获取页面中包含的所有资源,包括header-colors.css 。 然而,这个图像并没有缓存在浏览器中,所以请求被发送到服务器;
  4. 服务器尚未生成header-colors.css (例如,它刚刚重新启动);
  5. 它将返回 404。

或者,我们可以不在请求/index.html时生成header-colors.css ,而是在请求/header-colors.css本身时生成。 但是,由于该文件最初不存在,因此该请求已被视为 404。即使我们可以绕过它,更改标头以将状态代码更改为 200,并返回图像的内容,这是一种糟糕的做事方式,所以我们不会考虑这种可能性(我们比这要好得多!)

只剩下一个选择:在源更改后生成静态文件。

源更改时创建静态文件

请注意,我们可以从依赖于用户和依赖于站点的源创建动态代码。 例如,如果主题允许更改站点的背景图像并且该选项由站点管理员配置,则可以生成静态文件作为部署过程的一部分。 另一方面,如果站点允许其用户更改其配置文件的背景图像,则必须在运行时生成静态文件。

简而言之,我们有以下两种情况:

  1. 用户配置
    当用户更新配置时,必须触发该过程。
  2. 站点配置
    当管理员更新站点的配置时或部署站点之前,必须触发该过程。

如果我们独立考虑这两种情况,对于#2,我们可以在我们想要的任何技术堆栈上设计流程。 但是,我们不想实现两种不同的解决方案,而是一种可以解决这两种情况的独特解决方案。 并且因为从#1 开始,生成静态文件的过程必须在运行的站点上触发,因此围绕站点运行的相同技术堆栈设计这个过程是很有说服力的。

在设计流程时,我们的代码需要处理#1和#2的具体情况:

  • 版本控制
    必须使用“版本”参数访问静态文件,以便在创建新静态文件时使先前的文件无效。 虽然#2 可以简单地与站点具有相同的版本控制,但#1 需要为每个用户使用动态版本,可能保存在数据库中。
  • 生成文件的位置
    #2 为整个站点生成一个唯一的静态文件(例如/staticfiles/header-colors.css ),而 #1 为每个用户创建一个静态文件(例如/staticfiles/users/leo/header-colors.css )。
  • 触发事件
    对于#1,静态文件必须在运行时执行,而对于#2,它也可以在我们的暂存环境中作为构建过程的一部分执行。
  • 部署和分发
    #2 中的静态文件可以无缝集成到站点的部署包中,没有任何挑战; 但是,#1 中的静态文件不能,因此该过程必须处理其他问题,例如负载均衡器后面的多个服务器(静态文件是仅在 1 个服务器中创建,还是在所有服务器中创建,以及如何创建?)。

接下来让我们设计和实现该流程。 对于要生成的每个静态文件,我们必须创建一个包含文件元数据的对象,从动态源计算其内容,最后将静态文件保存到磁盘。 作为指导以下解释的用例,我们将生成以下静态文件:

  1. header-colors.css ,具有数据库中保存的值的一些样式
  2. welcomeuser-data.js ,包含一个 JSON 对象,其中包含某个变量下的用户数据: window.welcomeUserData = {name: "Leo"}; .

下面,我将描述为 WordPress 生成静态文件的过程,我们必须将堆栈基于 PHP 和 WordPress 函数。 部署前生成静态文件的功能可以通过加载执行短代码[create_static_files]的特殊页面来触发,正如我在上一篇文章中描述的那样。

进一步推荐阅读制作服务工作者:案例研究

将文件表示为对象

我们必须将文件建模为具有所有相应属性的 PHP 对象,因此我们既可以将文件保存在磁盘上的特定位置(例如,在/staticfiles//staticfiles/users/leo/下),并且知道如何请求因此归档。 为此,我们创建了一个接口Resource ,返回文件的元数据(文件名、目录、类型:“css”或“js”、版本以及对其他资源的依赖项)及其内容。

 interface Resource { function get_filename(); function get_dir(); function get_type(); function get_version(); function get_dependencies(); function get_content(); }

为了使代码可维护和可重用,我们遵循 SOLID 原则,为此我们为资源设置了一个对象继承方案,以逐步添加属性,从抽象类ResourceBase开始,我们所有的 Resource 实现都将从该抽象类 ResourceBase 开始:

 abstract class ResourceBase implements Resource { function get_dependencies() { // By default, a file has no dependencies return array(); } }

遵循 SOLID,只要属性不同,我们就会创建子类。 如前所述,生成的静态文件的位置以及请求它的版本控制将根据与用户或站点配置有关的文件而有所不同:

 abstract class UserResourceBase extends ResourceBase { function get_dir() { // A different file and folder for each user $user = wp_get_current_user(); return "/staticfiles/users/{$user->user_login}/"; } function get_version() { // Save the resource version for the user under her meta data. // When the file is regenerated, must execute `update_user_meta` to increase the version number $user_id = get_current_user_id(); $meta_key = "resource_version_".$this->get_filename(); return get_user_meta($user_id, $meta_key, true); } } abstract class SiteResourceBase extends ResourceBase { function get_dir() { // All files are placed in the same folder return "/staticfiles/"; } function get_version() { // Same versioning as the site, assumed defined under a constant return SITE_VERSION; } }

最后,在最后一层,我们为要生成的文件实现对象,通过函数get_content添加文件名、文件类型和动态代码:

 class HeaderColorsSiteResource extends SiteResourceBase { function get_filename() { return "header-colors"; } function get_type() { return "css"; } function get_content() { return sprintf( " .site-title a { color: #%s; } ", esc_attr(get_header_textcolor()) ); } } class WelcomeUserDataUserResource extends UserResourceBase { function get_filename() { return "welcomeuser-data"; } function get_type() { return "js"; } function get_content() { $user = wp_get_current_user(); return sprintf( "window.welcomeUserData = %s;", json_encode( array( "name" => $user->display_name ) ) ); } }

有了这个,我们将文件建模为 PHP 对象。 接下来,我们需要将其保存到磁盘。

将静态文件保存到磁盘

将文件保存到磁盘可以通过语言提供的本机功能轻松完成。 对于 PHP,这是通过函数fwrite完成的。 此外,我们创建了一个实用程序类ResourceUtils ,其函数提供磁盘上文件的绝对路径,以及相对于站点根目录的路径:

 class ResourceUtils { protected static function get_file_relative_path($fileObject) { return $fileObject->get_dir().$fileObject->get_filename().".".$fileObject->get_type(); } static function get_file_path($fileObject) { // Notice that we must add constant WP_CONTENT_DIR to make the path absolute when saving the file return WP_CONTENT_DIR.self::get_file_relative_path($fileObject); } } class ResourceGenerator { static function save($fileObject) { $file_path = ResourceUtils::get_file_path($fileObject); $handle = fopen($file_path, "wb"); $numbytes = fwrite($handle, $fileObject->get_content()); fclose($handle); } }

然后,每当源更改并且需要重新生成静态文件时,我们执行ResourceGenerator::save并将表示文件的对象作为参数传递。 下面的代码重新生成文件“header-colors.css”和“welcomeuser-data.js”并将其保存在磁盘上:

 // When need to regenerate header-colors.css, execute: ResourceGenerator::save(new HeaderColorsSiteResource()); // When need to regenerate welcomeuser-data.js, execute: ResourceGenerator::save(new WelcomeUserDataUserResource());

一旦它们存在,我们就可以通过<script><link>标签将要加载的文件排入队列。

将静态文件排队

将静态文件排入队列与将 WordPress 中的任何资源排入队列没有什么不同:通过函数wp_enqueue_scriptwp_enqueue_style 。 然后,我们简单地迭代所有对象实例并根据它们的get_type()值是"js"还是"css"使用一个或另一个钩子。

我们首先添加实用函数来提供文件的 URL,并告诉类型是 JS 还是 CSS:

 class ResourceUtils { // Continued from above... static function get_file_url($fileObject) { // Add the site URL before the file path return get_site_url().self::get_file_relative_path($fileObject); } static function is_css($fileObject) { return $fileObject->get_type() == "css"; } static function is_js($fileObject) { return $fileObject->get_type() == "js"; } }

ResourceEnqueuer类的实例将包含所有必须加载的文件; 调用时,其函数enqueue_scriptsenqueue_styles将通过执行相应的 WordPress 函数(分别为wp_enqueue_scriptwp_enqueue_style )来进行排队:

 class ResourceEnqueuer { protected $fileObjects; function __construct($fileObjects) { $this->fileObjects = $fileObjects; } protected function get_file_properties($fileObject) { $handle = $fileObject->get_filename(); $url = ResourceUtils::get_file_url($fileObject); $dependencies = $fileObject->get_dependencies(); $version = $fileObject->get_version(); return array($handle, $url, $dependencies, $version); } function enqueue_scripts() { $jsFileObjects = array_map(array(ResourceUtils::class, 'is_js'), $this->fileObjects); foreach ($jsFileObjects as $fileObject) { list($handle, $url, $dependencies, $version) = $this->get_file_properties($fileObject); wp_register_script($handle, $url, $dependencies, $version); wp_enqueue_script($handle); } } function enqueue_styles() { $cssFileObjects = array_map(array(ResourceUtils::class, 'is_css'), $this->fileObjects); foreach ($cssFileObjects as $fileObject) { list($handle, $url, $dependencies, $version) = $this->get_file_properties($fileObject); wp_register_style($handle, $url, $dependencies, $version); wp_enqueue_style($handle); } } }

最后,我们用代表每个文件的 PHP 对象列表实例化ResourceEnqueuer类的对象,并添加一个 WordPress 挂钩来执行入队:

 // Initialize with the corresponding object instances for each file to enqueue $fileEnqueuer = new ResourceEnqueuer( array( new HeaderColorsSiteResource(), new WelcomeUserDataUserResource() ) ); // Add the WordPress hooks to enqueue the resources add_action('wp_enqueue_scripts', array($fileEnqueuer, 'enqueue_scripts')); add_action('wp_print_styles', array($fileEnqueuer, 'enqueue_styles'));

就是这样:在排队时,将在客户端加载站点时请求静态文件。 我们成功地避免了打印内联代码和加载静态资源。

接下来,我们可以应用一些改进来获得额外的性能提升。

推荐阅读使用 PHPUnit 自动测试 WordPress 插件的介绍

将文件捆绑在一起

尽管 HTTP/2 减少了捆绑文件的需求,但它仍然使站点更快,因为文件压缩(例如通过 GZip)会更有效,并且因为浏览器(例如 Chrome)处理许多资源的开销更大.

到目前为止,我们已经将文件建模为 PHP 对象,这允许我们将此对象视为其他进程的输入。 特别是,我们可以重复上述相同的过程,将同一类型的所有文件捆绑在一起,并提供捆绑版本而不是所有独立文件。 为此,我们创建了一个函数get_content ,它简单地从$fileObjects下的每个资源中提取内容,然后再次打印它,生成来自所有资源的所有内容的聚合:

 abstract class SiteBundleBase extends SiteResourceBase { protected $fileObjects; function __construct($fileObjects) { $this->fileObjects = $fileObjects; } function get_content() { $content = ""; foreach ($this->fileObjects as $fileObject) { $content .= $fileObject->get_content().PHP_EOL; } return $content; } }

我们可以通过为这个文件创建一个类来将所有文件捆绑到文件bundled-styles.css

 class StylesSiteBundle extends SiteBundleBase { function get_filename() { return "bundled-styles"; } function get_type() { return "css"; } }

最后,我们只是像以前一样将这些捆绑的文件排入队列,而不是所有独立的资源。 对于 CSS,我们创建一个包含文件header-colors.cssbackground-image.cssfont-sizes.css ,为此我们只需使用 PHP 对象为这些文件中的每一个实例化StylesSiteBundle (同样我们可以创建 JS捆绑文件):

 $fileObjects = array( // CSS new HeaderColorsSiteResource(), new BackgroundImageSiteResource(), new FontSizesSiteResource(), // JS new WelcomeUserDataUserResource(), new UserShoppingItemsUserResource() ); $cssFileObjects = array_map(array(ResourceUtils::class, 'is_css'), $fileObjects); $jsFileObjects = array_map(array(ResourceUtils::class, 'is_js'), $fileObjects); // Use this definition of $fileEnqueuer instead of the previous one $fileEnqueuer = new ResourceEnqueuer( array( new StylesSiteBundle($cssFileObjects), new ScriptsSiteBundle($jsFileObjects) ) );

而已。 现在我们将只请求一个 JS 文件和一个 CSS 文件,而不是很多。

感知性能的最终改进涉及通过延迟加载那些不需要立即使用的资产来确定资产的优先级。 让我们接下来解决这个问题。

JS 资源的async / defer属性

我们可以将属性asyncdefer添加到<script>标签,以改变 JavaScript 文件何时被下载、解析和执行,以优先考虑关键 JavaScript 并尽可能晚地推送所有非关键内容,从而减少网站的明显加载时间。

为了实现这个特性,遵循 SOLID 原则,我们应该创建一个包含函数is_asyncis_defer的新接口JSResource (它继承自Resource )。 然而,这将关闭<style>标签最终也支持这些属性。 因此,考虑到适应性,我们采取了一种更开放的方法:我们只需将通用方法get_attributes添加到接口Resource以保持灵活地添加到任何属性(已经存在或尚未发明的<script><script><link>标签:

 interface Resource { // Continued from above... function get_attributes(); } abstract class ResourceBase implements Resource { // Continued from above... function get_attributes() { // By default, no extra attributes return ''; } }

WordPress 没有提供一种简单的方法来为入队的资源添加额外的属性,所以我们以一种相当老套的方式来做,添加一个钩子,通过函数add_script_tag_attributes替换标签内的字符串:

 class ResourceEnqueuerUtils { protected static tag_attributes = array(); static function add_tag_attributes($handle, $attributes) { self::tag_attributes[$handle] = $attributes; } static function add_script_tag_attributes($tag, $handle, $src) { if ($attributes = self::tag_attributes[$handle]) { $tag = str_replace( " src='${src}'>", " src='${src}' ".$attributes.">", $tag ); } return $tag; } } // Initize by connecting to the WordPress hook add_filter( 'script_loader_tag', array(ResourceEnqueuerUtils::class, 'add_script_tag_attributes'), PHP_INT_MAX, 3 );

我们在创建相应的对象实例时为资源添加属性:

 abstract class ResourceBase implements Resource { // Continued from above... function __construct() { ResourceEnqueuerUtils::add_tag_attributes($this->get_filename(), $this->get_attributes()); } }

最后,如果资源welcomeuser-data.js不需要立即执行,我们可以将其设置为defer

 class WelcomeUserDataUserResource extends UserResourceBase { // Continued from above... function get_attributes() { return "defer='defer'"; } }

因为它是作为延迟加载的,所以脚本将稍后加载,从而提前用户可以与站点交互的时间点。 关于性能提升,我们现在都准备好了!

在我们放松之前,还有一个问题需要解决:当网站托管在多台服务器上时会发生什么?

处理负载均衡器后面的多个服务器

如果我们的站点托管在负载均衡器后面的多个站点上,并且重新生成了与用户配置相关的文件,则处理请求的服务器必须以某种方式将重新生成的静态文件上传到所有其他服务器; 否则,从那一刻起,其他服务器将提供该文件的陈旧版本。 我们如何做到这一点? 让服务器相互通信不仅复杂,而且最终可能被证明是不可行的:如果站点运行在来自不同地区的数百台服务器上会发生什么? 显然,这不是一个选择。

我想出的解决方案是添加一个间接级别:不是从站点 URL 请求静态文件,而是从云中的某个位置(例如从 AWS S3 存储桶)请求它们。 然后,在重新生成文件后,服​​务器会立即将新文件上传到 S3 并从那里提供服务。 这个解决方案的实现在我之前的文章通过 AWS S3 在多个服务器之间共享数据中进行了解释。

结论

在本文中,我们认为内联 JS 和 CSS 代码并不总是理想的,因为代码必须重复发送到客户端,如果代码量很大,这可能会影响性能。 例如,我们看到 WordPress 如何加载 43kb 的脚本来打印媒体管理器,这些脚本是纯 JavaScript 模板,可以完美地作为静态资源加载。

因此,我们设计了一种通过将动态 JS 和 CSS 内联代码转换为静态资源来提高网站速度的方法,这可以增强多个级别(在客户端、Service Workers、CDN 中)的缓存,允许进一步将所有文件捆绑在一起只放入一个 JS/CSS 资源中,以提高压缩输出时的比率(例如通过 GZip)并避免浏览器同时处理多个资源(例如在 Chrome 中)的开销,此外还允许添加属性asyncdefer<script>标签以加快用户交互性,从而改善网站的明显加载时间。

作为一个有益的副作用,将代码拆分为静态资源还可以让代码更清晰,处理代码单元而不是 HTML 的大块,这可以更好地维护项目。

我们开发的解决方案是用 PHP 完成的,包括一些特定的 WordPress 代码,但是,代码本身非常简单,几乎没有几个定义属性的接口和遵循 SOLID 原则实现这些属性的对象,以及一个保存文件到磁盘。 差不多就是这样。 最终结果是简洁紧凑,可以直接为任何其他语言和平台重新创建,并且不难引入现有项目 - 提供轻松的性能提升。