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

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

写在前面

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

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

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

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

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

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

百十来行 SAN 代码的折腾效果

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

非常快速的渲染

技术选型

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

基础框架:Baidu 的 San

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

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

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

如果你想要深入跟随本文的方案,折腾你的应用,有两篇扩展阅读内容:最简单的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 case</title>

    <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. 加载器加载的程序文件,也可以写的更简单,因为这些基础依赖都全局共享了,不需要声明和定义在模块内部。写的更少,出错更少。

页面程序执行性能分析

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

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

<!-- 组件库样式文件 -->
<link rel="stylesheet" href="lib/santd@1.1.3/santd.min.css">
<!-- 组件库依赖的脚本程序 -->
<script src="lib/dayjs@1.11.10/dayjs.min.js"></script>
<script src="lib/dayjs@1.11.10/locale/zh-cn.min.js"></script>
<script src="lib/dayjs@1.11.10/plugin/utc.min.js"></script>
<script src="lib/dayjs@1.11.10/plugin/localeData.min.js"></script>
<script src="lib/dayjs@1.11.10/plugin/customParseFormat.min.js"></script>
<script src="lib/dayjs@1.11.10/plugin/weekOfYear.min.js"></script>
<script src="lib/dayjs@1.11.10/plugin/weekYear.min.js"></script>
<script src="lib/dayjs@1.11.10/plugin/advancedFormat.min.js"></script>
<script src="lib/enquire.js@2.1.6/enquire.min.js"></script>
<!-- SAN 框架 -->
<script src="lib/san@3.13.3/san.min.js"></script>
<!-- SAN 路由 -->
<script src="lib/san-router@2.0.2/san-router.min.js"></script>
<!-- SAN 组件库 -->
<script src="lib/santd@1.1.3/santd.js"></script>
<!-- 加载器 -->
<script src="lib/esljs@2.2.2/esl.min.js"></script>

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

<link rel="stylesheet" href="lib/santd@1.1.3/santd.min.css">
<script src="lib/core@2023.12.04/core.min.js"></script>
<script src="lib/santd@1.1.3/santd.min.js"></script>
<script src="lib/esljs@2.2.2/esl.min.js"></script>

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

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

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

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

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

<script src="lib/esljs@2.2.2/esl.min.js"></script>
<script src="lib/santd@1.1.3/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/esljs@2.2.2/esl.min.js"></script>
<script>
  require.config({ baseUrl: "./app" });
</script>
<script>
  require(["main"], function (app) {
    app.init();
  });
</script>

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

页面实际资源加载情况

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

如果你依赖多个文件,可以在 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)的形式传递给我们的调用函数,更灵活以及无副作用的的请求模版。

当然,如果你不喜欢这样获取模版,我们还有其他的方案,比如预置模版在 <textarea> 这类不会转译内容的 HTML 块元素中,或在 script 等容器标签中声明 type="text/html",避免程序执行的情况下,来完成模版的保存。

实践:编程页面模版

下面的页面模版同样来自 Santd 的示例,虽然行数不多,但是实现了一个完整的有顶部导航、侧边栏的经典布局:

<div>
  <s-layout>
    <s-header class="header">
      <div class="logo"></div>
      <s-menu theme="dark" mode="horizontal" defaultSelectedKeys="{{['1']}}" style="line-height: 64px">
        <s-menu-item key="1">Nav 1</s-menu-item>
        <s-menu-item key="2">Nav 2</s-menu-item>
        <s-menu-item key="3">Nav 3</s-menu-item>
      </s-menu>
    </s-header>
    <s-layout>
      <s-sider width="{{200}}" style="{{{background: '#fff'}}}">
        <s-menu mode="inline" defaultSelectedKeys="{{['3']}}" defaultOpenKeys="{{['sub1']}}">
          <s-sub-menu key="sub1">
            <template slot="title">
              <s-icon type="form" />
              <span>Navigation One</span>
            </template>
            <s-menu-item key="1"> <span>option1</span></s-menu-item>
            <s-menu-item key="2"> <span>option2</span></s-menu-item>
            <s-menu-item key="3"> <span>option3</span></s-menu-item>
            <s-menu-item key="4"> <span>option4</span></s-menu-item>
          </s-sub-menu>
          <s-sub-menu key="sub2">
            <template slot="title">
              <s-icon type="copy" />
              <span>Navigation Two</span>
            </template>
            <s-menu-item key="5"> <span>option5</span></s-menu-item>
            <s-menu-item key="6"> <span>option6</span></s-menu-item>
            <s-menu-item key="7"> <span>option7</span></s-menu-item>
          </s-sub-menu>
        </s-menu>
      </s-sider>
      <s-layout style="{{{padding: '0 24px 24px'}}}">
        <s-breadcrumb style="{{{margin: '16px 0'}}}">
          <s-brcrumbitem href="/">Home</s-brcrumbitem>
          <s-brcrumbitem href="#">List</s-brcrumbitem>
          <s-brcrumbitem>App</s-brcrumbitem></s-breadcrumb
        >
        <s-content style="{{{padding: '24px', background: '#fff', minHeight: '280px'}}}">Content</s-content></s-layout
      >
    </s-layout>
  </s-layout>
</div>

Santd 的文档中有非常多的例子,你可以根据自己的需求进行组合,做一个网站,大概的操作就是参考官方示例 “复制粘贴”、刷新、“所见即所得”。

好了,写到这里,这个方案里所有的细节就都介绍完啦,如果你感兴趣,不妨把代码下载下来自己玩玩看。

其他

上面我们聊完了选型,和组合将这些技术组件正确的装配在一起,以及如何权衡和解决组件不适配的问题,都偏技术实践细节。

下面分享一些和这个方案有关的事情,以及这个选型偏好原因。

机缘巧合出现的想法

在 2019 年,我在美团技术学院,担任美团技术布道师,作为团队里唯一熟悉 Coding 的同学,当时需要折腾一个偏资讯展示的内部的技术门户的任务就落在了我的头上。作为一个在淘宝 UED、美团平台都干过前端岗的工程师,考虑到前端构建工具和依赖每年推陈出新,考虑到构建效率再高(累计)也要花不少时间、项目后续维护成本也还是蛮高的,不由的开始想折腾一个简单一些 “所见即所得”,不用维护构建、不用维护依赖的方案。

当然,也有一些内部的原因,比如哪怕是有一群朋友帮忙,各种审批一路绿灯,但是在集团内部,想把非技术部门的完整的研发资源的系统流程跑通,并不是一件容易的事情。系统流程上也有非常多的挑战,甚至需要挑战非常多固化好的逻辑,需要从代码仓库折腾到服务中心、数据和文件存储等等,折腾包括 EE、SRE、安全、各种服务相关的维护方等等,不亚于在公司内部系统折腾一个新研发部门上线。

当时有几个朋友善意提醒,何必整这么复杂,不如把项目挂靠在某个稳定服务下,然后解决掉域名指向和程序托管就行了。经过一番查找,还真找到了两个合适挂靠的服务,都是公司级的应用,可靠性非常高。

不过,这两个朋友的服务,综合分析下来,前者最好只借用域名,后者只有存取静态页面、静态资源存取的能力。当然,我也不太好意思往朋友成体系的服务里塞一些不大相关技术站的前后端代码。也因为前面的原因,我不得不思考资源依赖最少的方案,包括不进行前端构建(省得借朋友的机器,给人找麻烦)。

于是,在这样的环境下,我折腾出了一套方案,其中前端的方案,在短暂折腾之后,写成了一篇《你的网站或许不需要前端构建》,文章中的代码示例也开源在了 GitHub:soulteary/You-Dont-Need-Webpack

San 选型的主观和客观原因

先来聊聊客观原因。

  1. 在 2024 年,SAN 是为数不多保证现代语法开发的情况下,还在努力保持向前兼容的框架,没有一言不合的 break change。在接近十年的更新周期内,一直有稳定的更新,值得信赖。
  2. 大厂有许多产品基于它构建,有大量有流量验证的应用案例背书,该踩的坑别人都替你踩完了,不需要太过担心。
  3. 团队相对稳定,项目没有营收压力,如果你翻阅提交记录和社区跟进记录,不难分析出是 Geek Leader 带着一群技术专家为爱发电的纯粹的项目。

当然,也有两个主观原因:

  1. 折腾了各种前端项目后,越来越厌倦构建,尤其是时隔一两年再拿出项目,如果需要重新初始化环境,刷屏出现的各种依赖废弃提醒。而且,也比较浪费笔记本性能,即使我的设备性能都不差,内存也都挺大(24G~64G)。
  2. 我也好,和我一起用这套方案的同学也罢,大家不需要靠前端项目复杂性来玩爬格子晋升的游戏,也不需要依赖这类项目技术栈找工作,写代码可以纯粹一些。什么简单有效,就用什么。

最后

如果你觉得文章、方案、或者文章中使用的开源软件 SAN 不错,欢迎不吝一键三连。当然,如果是针对项目的 Pull Request 就更好啦。

这篇文章只是一个开始,接下来,“有关界面”的折腾文章里,我会不断的更新和完善这个“不构建”的方案。

–EOF