一、How they work

1、Tutorial

Tutorial的主要代码组成相对比较简单,主要包含各种示例代码,以章节为目录进行顺序排列,每个目录中包含一个或多个Go+的代码文件;在这些代码文件中,包含一个或多个代码片段,每个代码片段可能会包含对应的注释以及文档说明。此外项目中还包含一个基于Go开发的server负责对内容进行解析,渲染成在Tutorial页面中的代码形式。上图中左侧的values.gop文件,通过server渲染后的展现形式即为下图:

所有的Go+代码被集中在右侧的代码块中,每行代码的注释及文档会被渲染在页面的左侧,与右侧的代码段一一对应。其中,左侧的注释部分同样支持简单的Markdown格式,如其中的+便是一个Markdown格式的code。上图为Tutorial的简单部署流程图。当Master代码被更新时,会有一个自动化的部署工具,拉取Tutorial最新的代码,对其中的Go server进行编译,并将其中的模板与编译完的二进制文件一起上传到服务器中。用户在请求服务器时,Go server会根据用户请求的request获取到对应的代码示例文件,转换成上述页面的形式。Tutorial站点的一个特性是,其的内容具有非常强的共建性质。目前Tutorial当中很多章节的内容是缺失或不完善的,希望大家可以一同参与共建,共同完善Go+Tutorial。

2、goplus.org

官网的页面如上图所示,主要承载的内容用于帮助初学者了解Go+整体的定位,并提供了一个Try Go+模块可以用于直接编辑、运行Go+的示例代码,提供最直接的使用感受。上图为goplus.org代码的目录结构,大家可以发现这是一个非常典型的前端项目。基于Next.js框架,使用React进行组件的构建,从而实现页面功能。goplus.org的部署比Tutorial要相对复杂一些。当goplus.org repo中的代码发生变更时,自动化的构建工具会拉取所有的代码并进行编译,在编译过程中,会将React Component渲染为HTML files,然后将HTML files和当中可能包含的JavaScript、CSS共同上传到静态文件存储服务(即图中的Static File Storage)中,例如七牛的对象存储服务。用户访问站点时会先请求到CDN,CDN节点再去请求静态文件存储服务。
如官网图片所示,goplus.org中包含部分动态内容,如Try Go+模块的编辑器。在编辑器中运行和编辑代码时,具体的实现过程是由页面直接发送API请求到API Server,也就是playground——现阶段执行代码的API全部是由playground提供的能力。
goplus.org的逻辑图并不复杂,和tutorial最大的一个区别在于Web应用本身的分发方式。类似goplus.org这样将Web应用直接存储到静态文件存储服务,通过CDN进行分发的方式是目前流行的一种做法,一般也被称为Jamstack,意思是应用由三部分组成:

JavaScript+API+Markup stack

其中最重要的是Markup。Markup一般是由JavaScript源代码,在经过预编译后得到的,站点的主体内容的HTML描述,这部分内容加上包含站点动态逻辑的JavaScript及部分必要的服务端API,便构成了整个Web应用。
大部分传统的Web应用并不会采取这样的构建方式。这种做法之所以变得流行,大致有以下几个原因:

· 低成本的扩展能力(Cheaper Scaling)

· 更好的性能(Better Peformance)

· 更简单的部署(Easier Deployment)

· 把Web视作一般的端(Web as a Client)

首先,应用本身是静态存储、通过CDN进行分发的,当访问量变大时,可以以非常低成本的方式来进行扩展,因为CDN还有对象存储这类的基础设施天然便具备应付海量访问的能力。此外站点或应用本身被生成为静态文件,非常适合被下发并缓存到CDN节点中,从而可以给用户更快的访问速度。对Web应用开发者来说,相比去维护一个一直运行着的服务器,基于Jamstack的应用部署也会更简单。部署一个自身包含server的Web应用,意味着要把构建完的server二进制文件进行替换,并重启服务;而对于Jamstack方式,部署时做的事情就是简单的文件上传,因此无论是复杂度还是操作的风险都会小很多。还有很重要的一点,用Jamstack的方式搭建Web应用,是把Web视作一般的端体现。在传统的Web应用开发过程中,我们会采取跟其他的端非常不一样的开发部署方式;常规的Web应用会有自己的web server、有特别的鉴权逻辑、以及自己的一套API访问方式。而如果我们将此前展示的Jamstack部署逻辑进行扩展,可以得到如下的图:从图中可以看到,中间的流程线对应的是Web Developer,搭建好Web应用后直接上传到静态文件存储服务,然后通过CDN进行下发,用户通过浏览器请求到的即为应用本身。应用会运行在用户的浏览器中,请求基于HTTP的API服务,甚至去请求一些第三方的API服务。不难发现,在Jamstack的方式下,Web浏览器中的应用和Android、iOS、小程序中的应用是对等的地位,都是通过静态存储以及CDN进行应用的分发,可以依赖相同的API Service(并走相同的API gateway)来实现动态功能。通过这个视角来开发Web应用或组织开发人员时,会发现Web是一个最普通的端,我们往往不需要单独去开发一个web server,对应的,也不再需要既有服务端开发和运维经验、又了解前端业务的人来进行维护。

二、Reuse

下面我们讲一些在构建Go+站点过程中遇到的复用问题。访问过Go+官网和Tutorial的朋友应该会发现,当中有很多一致的内容,比如header、代码块(code)等等。但如开头所说,goplus.org和Tutorial维护在两个不同的项目中,因此对于其中一致的内容需要考虑如何进行复用,而不是分别进行实现。类似这样的诉求在Web应用中非常普遍,下图是一些常见的例子:左侧的是在Medium中嵌入一个GitHub的代码片段,右上角的例子是在第三方站点中嵌入一条Twitter推文,右下角则为在任一站点中嵌入一段Youtube视频。如果我们想将中Go+的代码编辑模块嵌入到某个第三方站点或某人的博客中,面临的问题是如何在各种不同技术栈的项目中,进行界面或者功能的复用。此前提到,goplus.org本身是基于Next.js(React)+Typescript的项目;Tutorial的页面主体则是由基于Golang实现的HTTP服务进行渲染,页面的动态逻辑则是通过非常简单的JavaScript进行点缀。除了goplus.org以及Tutorial,考虑更多的复用场景,涉及到的技术栈会更加复杂、更加多样。除了需要跨不同的技术方案外,还有一个挑战是要在所有的复用场景中保持最新的行为——Deploy once,up to date everywhere.比如我们对现有的代码块(code)组件进行了某个优化,希望在重新发布后嵌入这个组件的第三方站点不需要进行任何操作,就能够自动升级,各站点的用户都可以直接使用到最新的组件功能。这是我们在做复用时希望能够达到的一个状态。基于这些前提,我们最后设计了一个我们称之为Widgets的方案。这个方案的结果如截图中的HTML代码所示。我们预期站点通过HTML script插入widgets/loader文件,通过data-widgets属性告知我们需要加载哪些widgets。用户可以在页面的任意位置,以类似使用div、p、header等HTML标签的方式来使用goplus-header、goplus-code等widget。goplus-header目前主要是Go+的几个官方站点在复用,goplus-code则希望可以应用在更多不一样的站点中。如上图所示,对于goplus-code我们可以通过editable来控制代码块是否可编辑,即是否展示编辑按钮,我们也可以通过嵌入Go+代码来控制代码块中的代码内容。那么Widgets是如何被构建的呢?主要有以下几步:

· 将React组件包裹为widgets

· 使用Next.js编译器生成widgets文件

· 收集编译结果(manifest)并生成loader文件

· 部署widget文件及loader文件

目前,Widgets是在goplus.org这个项目中去维护的。前面我们提到,这是一个React+TypeScript的项目,我们看到的widgets首先被实现为React组件,因此我们第一步要做的便是将React组件包装成widgets,之后使用Next.js编译器编译生成结果widgets文件。这一步骤后,因为我们会有多个widgets文件,每个widget往往会对应一个或多个文件,所以我们会收集结果信息(manifest),得到一份从widget名字到widget对应的结果文件实际位置的映射表,借助这份映射表生成loader文件。最后我们会将widgets文件和loader文件和goplus.org站点本身共同部署。对具体的代码细节感兴趣的朋友,可以在repo中进行查看:https://github.com/goplus/www/blob/master/goplus.org/widgets/完成这些步骤后,嵌入widgets的页面又是如何加载widgets的呢?上图为一个简易的加载逻辑流程图。我们以Tutorial的页面为例。用户首先会打开Tutorial的页面,这个页面是从tutorial.goplus.org加载的。页面上包含widgets loader,它会再到goplus.org去加载widget,最后进行渲染。Widgets的渲染基于Web Components技术;Web Components技术主要由custom elements和shadow DOM等组成,渲染的步骤大致如下:

· 定义自定义元素(custom elements)

· 添加shadow root

· 使用React渲染内容

首先我们会定义一个custom element,从而让浏览器可以识别类似goplus-code、goplus-header这样的HTML标签,并执行对应的我们提供的代码。我们的代码会在HTML元素中添加shadow root。shadow root是shadow DOM API的一部分,shadow DOM的意义在于,它像一个容器一样,可以将widgets渲染的内容跟宿主页面隔离开。熟悉Web开发的同学会知道,CSS的影响范围是全局的,这意味着在宿主页面中任何一个样式规则都可能会影响到渲染内容,而shadow DOM的作用便是隔离这种影响。最后一步便是用React渲染内容,并将内容添加到此前创建的shadow root中,完成整个渲染流程。

三、Better UX for Widgets

在以上工作的基础上,widgets方案便是一个「勉强可用」的状态了,但仍会面临一些或大或小的体验问题。

1、加载性能(Performance)

上图是此前介绍过的widgets加载逻辑流程图,从图中可以看到,widgets加载自goplus.org对应的服务;在前面的诉求中我们也提到希望在更新widgets的时候,所有使用widgets的地方都可以实现同步更新,这意味着这部分内容是不能够被缓存(或只能被短时间地缓存)的。但这部分内容包含了widgets的结果文件,在极端情况下体积可能会很大,尤其是像Go+代码编辑器这样的widget。目前goplus-code对应的结果文件在压缩后大概是几百KB,未来如果添加就地编辑的能力、或优化语法提示后,其体积可能会变得更大。如果我们希望这部分内容在嵌入第三方页面后,也能实现同步的更新,意味着这部分内容需要在每次访问时都重新加载,从而带来性能上的损耗,这是不能接受的。因此我们对加载细节进行了优化,下图是具体的逻辑:在加载widget文件之前,会先有一个loader文件(前面我们提到过),这将整个加载过程拆解成两部分:第一部分是去加载loader文件,第二部分是由loader来加载widget文件。loader本身的内容非常少,但当中包含一个映射表(mainfest),可以准确的知道每一个widget的文件地址。而widget文件往往会包括含逻辑的Javascript内容,以及含样式的CSS内容。如上图所示,我们会在js以及css文件名中添加其内容对应的hash,即,我们可以认为如果内容不变,那么文件地址也不会变,文件地址不变,内容也就不会变;因此可以给它们设置一个很长的缓存时间。而loader会准确的知道当前每个widget对应的最新实现的结果文件地址,也就是知道hash。而loader本身体积很小,可以不用被缓存或者开启非常短期的缓存;这样用户加载的过程就变成首先以大部分情况不缓存的模式去加载一个很小的loader文件,然后再加载widget文件。因为widget大部分时候是不发生变化的,因为升级的频率不会特别高,所以大部分情况下widget文件的内容是可以直接从缓存获得的。如果我们有不止一个站点,例如Tutorial和playground,都使用了同一个widget,那么用户在访问完Tutorial后再访问playground时,两个站点间关于这一个widget的缓存便可以被共享。这使得在绝大部分的用户访问中,widget文件对应的请求都可以走缓存,对应的加载速度可以得到很大的提高。上图展示了Tutorial站点加载widget过程中对应的请求。首先浏览器会加载一个loader文件,这个loader文件非常小,只有68B,是一个304请求,表示内容没有发生变化。loader文件对应的请求每次都会被发出,以保证总是能请求到最新的loader。而后续的header、footer、code这些widget对应的文件几乎总是可以走缓存,在图中显示为disk cache,也就是磁盘缓存。
2、Stable UI

我们还是以Tutorial站点为例。当网络速度不佳时,可能会出现header部分从无到有的一个过程。因为Tutorial站点的header部分嵌入的是goplus-header这样的一个HTML标签。在widget加载完成前,浏览器无法识别这一标签,所以不显示这部分内容。当widget加载完成后,这部分内容也会相应的渲染出来,Tutorials部分的内容向下顺移。widget加载完成前widget加载完成后

这种界面的不稳定的情况很常见,尤其是在客户端逻辑较为复杂的web应用中。而界面不稳定对用户的感受是很不好的,哪怕只是非常短暂的不同步,也会导致页面的闪烁和主体内容的移动,从而影响到用户的实际体验。对此我们进行了优化。同样以header部分为例,优化后的流程如下:

· 占位内容

· 加载widget

· 定义自定义元素(custom elements)

· 添加shadow root

· 第二块占位内容

· 使用React渲染内容

我们在渲染内容前先放置了一个占位内容(Placeholder),也就是一个空的HTML节点,我们会给它一个初始的高度。然后是前面提到的定义自定义元素、添加shadow root过程。因为shadow root的特性,自定义元素中挂上shadow root后会忽略掉此前放置的占位内容,因此我们需要将一个相同size的元素添加到shadow root中,即第二个占位内容。最后再使用React去渲染内容,第二个Placeholder会在内容渲染出来前撑开容器的高度。效果如下:widget加载完成前

widget加载完成后

因为占位的存在,页面内容的高度始终是一致的,这避免了页面内容的抖动。但在网速较差时,会出现一个中间状态:在这个状态中,header位置出现的是无样式的内容。这个问题的原因是,内容渲染完成了,但样式还未加载完成,也就是CSS文件还未加载完成。这个短暂的状态在视觉观感上仍是较为明显的,也会造成用户注意力的分散,对于这部分,我们进行了进一步的优化。优化后的流程如下:

· 占位内容

· 加载widget

· 定义自定义元素(custom elements)

· 添加shadow root

· 第二块占位内容

· 使用React渲染内容

· 确保样式就绪

我们在最后增加了确保样式就绪的步骤,也就是,当内容渲染好但CSS尚未加载完成时,会先进行等待,直到CSS加载完毕后再进行内容的展示。如果页面逻辑更为复杂(包含除了CSS加载外的异步行为),则会等整体的UI稳定后,再进行内容的展示。我们在线上的Tutorial站点实践了这套运行逻辑,这让站点初始化时的体验较之前有了明显的改善。