你的网站或许不需要前端构建(二)

前一阵,有朋友问我,能否在不进行前端编译构建的情况下,用现代语法开发网站界面。

于是,就有了这篇文章中提到的方案。

写在前面

这篇文章,依旧不想讨论构建或不构建,哪一种方案对开发更友好,更适合某个团队,只是在大环境都在构建,似乎不构建就无法写项目的环境下,分享一个相对轻的方案。

本篇文章中的代码,开源在 soulteary/You-Dont-Need-Build-JavaScript,有需要可以自取,欢迎“一键三连”。

2019 年,我写过一篇文章《你的网站或许不需要前端构建》,文章中方案开源在 GitHub:soulteary/You-Dont-Need-Webpack。当时,用这个技巧实现了美团内部的一个轻量的展示型的应用,得益于浏览器的资源加载机制,和脚本运行机制,这种取巧的方案赢得了非常好的性能表现。

关于这个方案背后的故事,如果你感兴趣,可以阅读文章尾部“机缘巧合出现的想法”。

不过,时过境迁,在 2024 年,或许方案中的技术栈应该有更稳定和有趣的替代品,我的选择是:百度 EFE 团队出品的 “SAN” 框架和周边生态。

大概只需要百十来行代码,就能够折腾出一个简略的“ MIS 后台模样”:

你的网站或许不需要前端构建(二)_第1张图片

并且不同于各种慢吞吞的后台,这个用 SAN 搭建的免编译构建的方案,页面展示速度非常的快:

你的网站或许不需要前端构建(二)_第2张图片

技术选型

在聊实现之前,我们先来聊聊技术选型。

基础框架:Baidu 的 San

baidu/san 是一个轻量的前端开源框架,官方有一篇写的很好的介绍:《San介绍以及在百度APP的实践》,感兴趣可以自行翻阅,我这里就不多做赘述了。

你的网站或许不需要前端构建(二)_第3张图片

如果用关键词来描述它,我直接能够想到的是:良好的前端兼容性、稳定更新接近十年、有稳定的生态周边、有大厂大流量应用踩坑背书、没有商业化诉求、相对纯粹的技术项目。

当然,之所以选择它作为本文的基础选型,还有一些客观和主观原因。文末的“主观原因和客观原因中有提”,这里就不展开了。

如果你想要深入跟随本文的方案,折腾你的应用,有两篇扩展阅读内容:最简单的AMD 模块规范的 Todos App 的代码 和 San 在线文档的基础语法部分。

之所以使用 AMD 作为模块规范,是因为相比其他的流行规范,AMD 拥有更好的浏览器兼容性,以及 EFE 团队恰好有一个很棒的加载器选型可用:ESL。

前端加载器:ESL (Enterprise Standard Loader)

ecomfe/esl 是百度 EFE 团队另外一个产品,可以看作是 requirejs 的强化版本,拥有比 requirejs 更小的尺寸、更高的性能和更健壮的程序。

程序的设计和用法都非常简单,一篇简单的文档足够你了解它该怎么使用:ESL 配置文档。如果你对 AMD 模块不熟悉,可以参考这篇模块定义的文档。

我们想不折腾构建,其中一个条件就是前端程序是能够按照我们的需求进行可靠的顺序加载,以及解析执行的,靠这个不到 5KB 的小工具就行啦。

前端路由器:San Router

baidu/san-router是 San 配套的项目,用来支持动态路由,嵌套路由,路由懒加载以及导航守卫等功能。如果你不想实现多页路由,或者想在当前页面折腾一些有趣的功能,这个简单有用的组件就派上用场了。

它的文档同样比较简单,不到十页的文档。

前端组件库:Santd

ecomfe/santd是 EFE 团队提供的适配 San 语法的 Ant Design 组件库实现。想要快速折腾出样式还过得去的界面,又不想太折腾 CSS 样式,用这类现成的样式库能够节约非常多的时间。样式库的文档在这里,需要什么组件的时候,翻出来直接复制粘贴用就行,非常方便。

当然,这个样式库的实现中,还有一些子依赖:包括日期组件库(dayjs)、响应式兼容垫片(enquire),在折腾的时候,我们需要做一些额外处理。不过,我们不需要直接和它们进行交互,所以也不需要查看它们的文档。

实践:搭起基础架子

其实做一个不需要编译构建的前端网站的基础的架子很简单,一个 HTML5 标准的页面结构,搭配上一些基础的样式和脚本依赖,然后将其他的资源用加载器加载就好了:

DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Show casetitle>

    <link rel="stylesheet" href="...">

    <script src="...">script>

    <script>
      require.config({baseUrl: "./app"});
    script>
  head>
  <body>
    <div id="app">div>
    <script>
      require(["main"], function (app) {
        app.init();
      });
    script>
  body>
html>

虽然,我们可以将除了加载器之外的代码都用加载器来进行加载,来用“JS管理一切”,这个做法在早些年的淘宝很流行,但是在这个场景下没有必要。适当的将页面的基础依赖直接在页面中引入,有至少三个好处:

  1. 让页面能够更早的加载需要的资源,相比较 JS 程序抢占式的资源加载,页面渲染速度更快,还能够最大化利用浏览器对于资源的加载和执行优化。
  2. 减少了 JS 程序中的复杂的依赖管理,减少了闭包作用域绑定(加载器),降低了程序“副本数量”,节约了运行资源的同时,也提升了程序运行时的性能。
  3. 加载器加载的程序文件,也可以写的更简单,因为这些基础依赖都全局共享了,不需要声明和定义在模块内部。写的更少,出错更少。

你的网站或许不需要前端构建(二)_第4张图片

在浏览器性能分析页面,我们能够看到因为相对合理的程序拆分和直接加载,程序的加载和解析速度是非常快的。当然,也离不开 SAN 本身就很快的原因。

我们以实际情况为例,比如本文使用的前端资源,如果全部列举将会是下面这样(soulteary/You-Dont-Need-Build-JavaScript/src/dev.html页面示例):


<link rel="stylesheet" href="lib/[email protected]/santd.min.css">

<script src="lib/[email protected]/dayjs.min.js">script>
<script src="lib/[email protected]/locale/zh-cn.min.js">script>
<script src="lib/[email protected]/plugin/utc.min.js">script>
<script src="lib/[email protected]/plugin/localeData.min.js">script>
<script src="lib/[email protected]/plugin/customParseFormat.min.js">script>
<script src="lib/[email protected]/plugin/weekOfYear.min.js">script>
<script src="lib/[email protected]/plugin/weekYear.min.js">script>
<script src="lib/[email protected]/plugin/advancedFormat.min.js">script>
<script src="lib/[email protected]/enquire.min.js">script>

<script src="lib/[email protected]/san.min.js">script>

<script src="lib/[email protected]/san-router.min.js">script>

<script src="lib/[email protected]/santd.js">script>

<script src="lib/[email protected]/esl.min.js">script>

尽管我不推荐任何程序的早期优化,以及现代浏览器对于这样的资源的加载已经有很好的优化了(并发数和多种缓存机制),但是摆在面上的、不费劲的低成本可优化点,我们可以顺手优化掉:

<link rel="stylesheet" href="lib/[email protected]/santd.min.css">
<script src="lib/[email protected]/core.min.js">script>
<script src="lib/[email protected]/santd.min.js">script>
<script src="lib/[email protected]/esl.min.js">script>

我们根据程序的更新频率和基础依赖状况,可以将不同组件进行合并,比如将组件库和加载器之外的程序都合并成核心依赖 core.min.js,这样可以减少十个请求,让程序整体加载速度在尤其是非 HTTP2 环境下更快一些。

上面的架子在实际运行过程中,会遇到一些小的问题,问题基本都在组件依赖库 Santd 和它的依赖 Dayjs 中。

解决不完全适配的模块问题

在 JavaScript 程序中,有很多种不同的模块化方案,而不同的方案导出的程序文件也是不同的,如果不依赖编程显示声明引入依赖的方式,以及搭配构建,那么有可能不同的组件在“装配连接”的时候,可能会有一些小问题,比如“名字对不上”(模块声明名称不匹配)。

如果我们将 esl 放置在 santd 前面,那么组件库在加载的时候,将完全遵守 AMD 模块加载方案来执行。

<script src="lib/[email protected]/esl.min.js">script>
<script src="lib/[email protected]/santd.min.js">script>

然后组件库会按照它程序声明中的顺序完成对 dayjs 和它的各种组件的加载:

typeof define === 'function' && define.amd ? define(['exports', 'san', 'dayjs', 'dayjs/plugin/utc', 'dayjs/plugin/localeData', 'dayjs/plugin/customParseFormat', 'dayjs/plugin/weekOfYear', 'dayjs/plugin/weekYear', 'dayjs/plugin/advancedFormat'], factory)

上面是 Santd 中对 dayjs 的依赖引用,不过 dayjs 默认没有像 San 生态一样,推出符合 AMD 模块的浏览器可直接使用的程序格式。虽然我们可以将 dayjs 进行适配和封装,但是这样不还得“编译构建”嘛。

我是真的一点都不想折腾和维护“编译构建”,那么有没有简单的点的做法呢?

dayjs 和它的组件在被浏览器执行后,会生成全局对象,santd 运行必要的要素其实是完备的,只是因为上面提到的原因,“它的对象名字和组件内引用对象对不上”。

仔细观察 santd 在 AMD 模块加载后,无非也就是执行了两个操作:

// 第一步:做模块的声明引入
var dayjs__default = 'default' in dayjs ? dayjs['default'] : dayjs;
// 问题:对齐导出组件的名称,dayjs 没啥问题,主要是它组件加载出问题了
utc = utc && Object.prototype.hasOwnProperty.call(utc, 'default') ? utc['default'] : utc;
localeData = localeData && Object.prototype.hasOwnProperty.call(localeData, 'default') ? localeData['default'] : localeData;
// ...

// 第二步:使用 dayjs
function getTodayTime(value) {
  var locale = value.locale();
  // 问题:浏览器引入的 dayjs 默认没有 amd 模块化,所以这样的模块加载方式会出错
  require("dayjs/locale/".concat(locale, ".js"));
  return dayjs__default().locale(locale).utcOffset(value.utcOffset());
}

一个是模块引入,另外一个是组件内部模块的加载 require,相关问题在程序注释中我都有提到,就不再展开。

想要让这个程序顺利的执行,我们只需要做一些字符串替换就够了:

var dayjs__default = dayjs;
dayjs.extend(window.dayjs_plugin_utc);
dayjs.extend(window.dayjs_plugin_localeData);
...

function getTodayTime(value) {
  var locale = value.locale();
  return dayjs__default().locale(locale).utcOffset(value.utcOffset());
}

而这个事情,为了避免遗漏,我们可以写个小的文本替换程序来处理。你可以用任意你喜欢的程序来解决类似上面的问题,我用 Go 写了一个一百来行的简单程序,包含了上面的处理和文件的字符串压缩:optimizer/optimizer.go。因为本文主要聊前端,我就不展开这部分了,感兴趣的同学可以自行翻阅。

架子部分搭起来后,我们就可以开始不涉及前端编译构建的方式来写代码了。先聊聊编写模块入口程序。

实践:编写入口程序

程序入口程序,我们在上面其实已经聊过。在 HTML 页面中,架子中有关加载器的例子是这样写的:

<script src="lib/[email protected]/esl.min.js"></script>
<script>
  require.config({ baseUrl: "./app" });
</script>
<script>
  require(["main"], function (app) {
    app.init();
  });
</script>

上面的程序执行后,会请求网站当前路径下的 ./app/main.js 文件,然后在文件加载完毕后,调用程序的 .init() 方法,完成应用的初始化。

你的网站或许不需要前端构建(二)_第5张图片

光看代码有点抽象,结合上面的浏览器资源请求详情和资源加载次序,是不是更直观啦。

如果你依赖多个文件,可以在 require( ... ) 中添加所有你需要的程序,以及在后面的(回调)函数中完成具体的逻辑,你无需考虑依赖是否下载完毕,加载器会确保你的所有依赖都下载完毕后,再执行你的具体程序逻辑。

实践:编写程序的 Main 函数

接下来我们来完成被入口程序引用的第一个程序 main.js

define(["./components/container"], function (Container, require) {
  var router = sanRouter.router;
  router.add({ rule: "/", Component: Container, target: "#app" });

  return {
    init: function () {
      router.start();
    },
  };
});

上面的程序使用了 San Router 来初始化一个单页应用,你可以参考上文提到的文档,在页面中添加更多路由。如果你选择制作多页应用,那么只注册一个 / 根路由也就足够了。

程序执行后,会将它依赖的 ./components/container 程序下载并挂载为页面的组件。如果你不喜欢在 define 中进行依赖声明,也可以用下面的方式,它们是等价的:

var Container = require("./components/container");

实践:编写第一个页面

下面的内容,主要来自 Santd 的示例,我们只需要将示例内容包裹在我们的模版代码中,就能够完成一个现代 SFC 写法,支持双向绑定,模版和逻辑分离的页面程序了:

define(function (require) {
  var template = require("tpl!./container.html");

  // --- santd 示例开始
  var Layout = santd.Layout;
  var Menu = santd.Menu;
  var Icon = santd.Icon;
  var Breadcrumb = santd.Breadcrumb;

  return san.defineComponent({
    components: {
      "s-layout": Layout,
      "s-header": Layout.Header,
      "s-content": Layout.Content,
      "s-sider": Layout.Sider,
      "s-menu": Menu,
      "s-sub-menu": Menu.Sub,
      "s-menu-item": Menu.Item,
      "s-icon": Icon,
      "s-breadcrumb": Breadcrumb,
      "s-brcrumbitem": Breadcrumb.Item,
    },
    initData() {
      return {
        inlineCollapsed: false,
      };
    },
    toggleCollapsed() {
      this.data.set("inlineCollapsed", !this.data.get("inlineCollapsed"));
    },
	// --- santd 示例结束

    template: template,
  });
});

ESL 插件:模版加载函数

下面的模版加载函数,来自 baidu/san/example/todos-amd/src/tpl.js:

/* global ActiveXObject */
define(function (require) {
  return {
    load: function (resourceId, req, load) {
      var xhr = window.XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject("Microsoft.XMLHTTP");

      xhr.open("GET", req.toUrl(resourceId), true);

      xhr.onreadystatechange = function () {
        if (xhr.readyState === 4) {
          if (xhr.status >= 200 && xhr.status < 300) {
            var source = xhr.responseText;
            load(source);
          }

          /* jshint -W054 */
          xhr.onreadystatechange = new Function();
          /* jshint +W054 */
          xhr = null;
        }
      };

      xhr.send(null);
    },
  };
});

这个以 XHR 请求为核心的函数主要做一件事,就是将 HTML 以文本(xhr.responseText)的形式传递给我们的调用函数,更灵活以及无副作用的的请求模版。

当然,如果你不喜欢这样获取模版,我们还有其他的方案,比如预置模版在