基于webpack打造前端在线编译器

4,399 阅读14分钟
原文链接: www.jianshu.com

需求

公司内部的 UI 组件使用示例一直是仅仅以 markDown 格式展示代码,在比较复杂的组件中示例代码就会非常冗长,难以使用文件目录的结构来展示代码。所以该项目一直以来就有这样的一个需求:

  • 组件的示例代码能够按照一定的目录结构组织与展示
  • 用户能够修改示例代码并实时查看其效果

首先分析需求:两者综合起来就是一个针对前端开发者的在线开发平台,类似于 codepen,只是我们需要更好的将其集成到当前的网站中来。

为什么我们还需要自己实现这样的工具?

现在的工具已经不少了,再比如webpackbincodeSandBoxjsfiddlejsbin等,除了嵌入模式的支持度不是很好,最大的问题是我们的组件尚未开源,也就是编译的过程需要我们自己控制,在这一点上就决定了我们要自行开发。

确定主要思路

丑陋的交互图

默认只能展示代码,并且可以切换文件。当点击编辑按钮之后编辑器变为可写模式,同时生成唯一的链接保存与展示用户代码,cmd/ctrl+S操作与点击save按钮都能触发编译过程,并且会异步的将最新代码保存到数据库中。

技术方案

整体的数据流动如上图所示,整个开发过程感触较深的有以下几点:

  • 为什么需要管理前端状态?
  • 在哪儿存储用户的临时文件以能更高效地实现编译过程?
  • 错误处理:编译时的错误要能够展示到网页
  • 如何根据当前浏览的组件页面确定代码区的数据获取?
  • ...

为什么需要管理前端状态

在最初搭建基本环境的时候,我是没有将这一部分直接加进项目中的。开发时,能实现需求时肯定是越简单越好。可是后来我发现,如果想要在全局得到/共享/监听某个状态,像当前页面是否可编辑这个状态,就影响着右上角的按钮显示edit还是'save', 编辑区是否可以读写。当前页面是否有数据,没有就需要去指定接口拉取。就要自己去实现,比如事件机制,或者发布订阅模式,实现不同组件间的通信。可是当需要监听的状态多了以后,用于监听的代码就散落在各个组件中,自己有时也想不清该去哪个组件中修改了。

这开发的时候都蒙圈,后边维护修改就更难了,看来需要一个工具来帮我们集中管理页面的状态。

mobx VS redux

因为以前了解过 redux,最先想到的就是 redux,但是当想要动手时,一想到要写 action, reducer 还要将组件与状态连接,将相应的状态注入到组件中,实在是太麻烦了。正巧,搜了一下除了 redux之外的其他工具,发现了 mobx, 语法很简洁,然后翻阅了他的文档,发现用起来也比较简单,基于订阅的模式来修改影响到的节点的更新机制也很高效。(PS:具体的使用网上资源众多,在此就略过了)

如果直接说 mobx就比 redux 好用,我想这样是不负责任的,任何脱离了使用场景来谈技术的都是双流氓。 那么这两者的差别是什么呢?经过我在使用过程中的感受以及思考,mobx基于可变数据,适合小项目,小团队。redux基于不可变数据,状态是可预测的,适合大项目,多人协作开发,相对繁琐的流程就是为了限制或者约束一些非法的行为。和现实世界一样,不同规模的组织,肯定有着不同的管理方式。所以当我们进行这种类似的技术选择的时候,如果在一个不大的项目,使用一个工具用起来很累,很大概率是选择错了。

总结:所谓的状态管理,其实就是为了在如今高度模块化、组件化的应用中,实现组件间简单方便的通信。

在哪儿存储用户的临时文件以能更高效地实现编译过程?

每次用户修改代码触发保存操作(cmd+S or click 'save' button )时,都会将代码提交,保存到临时文件夹中,然后开始编译流程。每次提交的代码都存到硬盘?太慢了,尤其是多个用户同时编辑时,想一想这个流程,一个用户编辑代码,创建一个专属的文件夹,用户提交代码,后台收到请求,创建文件并写入内容,编译,又生成2个文件(html,bundle.js),再次写入硬盘,然后程序读取内容发送回页面,涉及了多次对硬盘的读写操作,而所有的操作都受限于硬盘的读写速度。速度慢,开销大。

如果曾经有一个优秀的方法出现过,那么其他的方法就都成了将就。

想一下 webpack-dev-server 是怎么做的吧,入口文件从本地读取,但是编译结果是放到内存中的,因为我们每次的保存操作都会触发一次编译,会不断的生成一些临时的打包文件,将这些文件放入内存中,有以下好处:

  • 基于内存比从硬盘读写快,本地单一用户可能区别不是很明显,但是部署上线之后多用户同时操作时差别就会比较大了。
  • 也没有必要添加.gitignore,当应用挂掉重启时没有东西需要清理。
  • 也不用太担心内存不够用,一个上百 M 的文件可能在哪儿都不适合。

那么我们的需求就是 entries(用户提交的代码文件)和 output 基于内存,但是 loaders 还是从本地也就是 node_modules 里找。

发现 webpack提供了 custom file system ,那么根据我们的需求配置如下:

compiler.inputFileSystem = memoryFs;
compiler.outputFileSystem = memoryFs;
compiler.resolvers.normal.fileSystem = memoryFs;
//context 需要和inputFileSystem保持一致
compiler.resolvers.context.fileSystem =fs;
compiler.resolvers.loader.fileSystem =fs;

但是发现,loader 仍然是从 memory-fs 中找,那么当然是找不到 node_modules的内容。而node_modules文件很大,在机器配置有限的情况下,不建议将其也放到内存中,所以当前的问题是待编译文件存于内存,但是依赖项都在物理硬盘中。经过一番探索,发现一个至少能解决问题的答案:重写 memory-fs 中的读文件的方法,当找不到时就去物理硬盘上去找,参考这里

当仅仅是基于 react 的组件进行编译时,速度还可以接受 不到2s 即可完成编译,但是当要编译基于我们公司内部的组件时,时间就要爆炸。每次都要10+s,这还是我们在把所有的第三方包提前打到了 dll 文件中的情况下。那么为什么会这么慢呢,我们的包中依赖太多了,在编译时需要查找依赖时就要经历从 memory-fs 到physical disk 的切换,在一个组件编译过程中,要经历这种文件系统的切换达5700多次!但是对此我们却难以解决,或许可以搞清在不断切换文件系统的过程中,程序到底是在找什么,或许通过提前帮助它进行路径选择,会缩短一些时间。但是,我们是不会考虑完备所有的情况的,而且对于路径的决策也只能是点到为止。为此,我们需要换一个思路。

在我一筹莫展的时候,一个同学提到了 redis,想一想它也不能满足我们的需求,因为为了满足 webpack 的编译我们才要把用户的内容写到文件中的,所以只能是存储文件,但是,redis 给了我思路。为什么提起缓存他就想起了 redis?它基于内存实现数据缓存的,速度极快。要知道,基于内存对文件进行读写要比 SSD 还快上数百甚至上千倍。所以该项目的重点是实现基于内存读写!为什么因为我见到了 webpack基于 js 实现的 memeory-fs 我就抓着不放手了呢? 思路打开之后,开始浏览各种内存相对的文章,最终 linux 自带的 tmpfs 成了我的目标,基于此我们可以十分简单的直接利用系统提供的内存系统进行读写操作。

所以最终我的解决方案是: 基于 linux系统的 tmpfs 对用户的临时文件进行读写,只要将 entry 和 output 设为该 tmpfs 所在的路径即可,并且webpack 对此无感知。唯一可能需要注意的就是:在配置 webpack 时,尤其是 options 中的插件时,需要使用 require.resolve('')的形式,否则会去entry 设置的路径中查找,当然肯定也会找不到。比如对于 babel:

module:{
    rules:[
        {
            test: /\.(jsx|js)$/,
            exclude: /node_modules/,
            use: [{
                loader:'babel-loader?cacheDirectory=true',
                options: {
                    presets: ['babel-preset-es2015', 'babel-preset-react','babel-preset-stage-0'].map(require.resolve),
                    plugins:[[require.resolve('babel-plugin-transform-react-jsx'),{
                      pragma: "require('react').createElement"
                     }]]
                }
            }]
        }
    ]
}

Tips:
tmpfs 是虚拟内存文件系统,存储空间在 VM,由 real memory(RM) 和 swap 组成。RM 就是物理内存,swap 是通过硬盘虚拟出来的内存空间,读写速度相对 RM 要慢很多。当没有足够的 RM 时就会把 RM 里不常用的一下数据交换到 Swap 中去,重新使用时再次交换到 RM 中。增加 Swap 交换分区。tmpfs 的配置大小只是最多占用空间,实际占用空间是根据使用情况动态调整的。tmpfs 默认是 RM的一半。1、直接挂载到需要的目录: mount -t tmpfs -o size=500m tmpfs /tmp。2、写入/etc/fstab,这样重启后也有效。

tmpfs 只支持 linux,在 mac上没有直接的命令可用,但是聪明的程序员们早已实现了‘曲线救国’的壮举

错误处理:编译时的错误要能够展示到网页

继续我们的编译流程,当用户提交代码后,我们将其存储到临时文件夹,开始编译,并将编译结果作为用户提交代码的 response,编译正确时会生成 html 文件,我们直接将其返回给用户即可,当编译过程发生错误呢?此时程序不应该挂掉,并且应该像返回正常结果一样,返回一个包含错误信息的 html。(基本代码如下,有删减)
关于 webpack 中的 compiler 模块,可见官网介绍

//在 webpack的编译过程中,最终返回一个 promise 对象
return new Promise(function(resolve, reject) {
    compiler.run(function(err, stats) {
        if (err || stats.hasErrors()) {
            resolve(stats.compilation.errors);
        } else {
            resolve(stats.compilation.assets);
        }
    });
});

因为编译错误时返回的只是错误信息,而不是 html,为了能够正确的显示到 playground 的预览区,我们为其添加 html 模板:

export default function generateErrorTemplate(err) {
    const strToHtml = str => {
        return (str || "")
            .replace(/&/g, "&")
            .replace(/</g, "<")
            .replace(/>/g, ">")
            .replace(/"/g, """)
            .replace(/'/g, "'")
            .replace(/\[(\d+)m/g, "")
            .replace(/ /g, " ")
            .replace(/\n/g, "<br />");
    };
    let template = `
            <!DOCTYPE html> 
            <html>
            <head>
            </head>
            <body>
              <div>
                ${strToHtml(err.toString()) || ""}
              </div>
            </body>
            </html>`;
    return template;
}

在 routerHandler 中: response.html = generateErrorTemplate(err) ;

如何根据当前浏览的组件页面确定代码区的数据获取?

本次的任务主要是实现嵌入模式下的展示,所以更多的精力将放到将 playground 与现有网站的集成中。不难想象,我们的 playground 必然是作为 iframe嵌入到主站中,但是在 playground 中如何才能根据主站打开的页面自动去获取相应的数据(即 代码)呢?

当在网站 A 中以 iframe 的形式打开网站 B 时,向网站 B 请求数据时的 request header 中会有referer 表明调用的网站网址,那么依据此信息,我们可以通过正则匹配获取到主站页面url 中的有用信息,然后再 B 网站中依据此参数获取相应的数据即可。(此处比较容易,不再代码展示)

当多用户时,如何尽可能降低机器的内存占用?

前面提到,对于用户提交的代码我们将其保存在了 挂载到tmpfs 下的文件夹,也就是放到了内存中,基于内存的读写效率很高,当然其空间也是很宝贵的,我们应该及时有效地清理该文件夹中的无用数据。每个用户在进入不同组件页面修改代码时都会以 Unique id 为名创立文件夹,当该文件夹下的内容无活动时(理解为用户已离线,因为该系统为内部使用且目前没有加入用户管理系统),将该文件夹删除。因为项目中没有 添加session,所以设置定时器,在最后一次编译完成后的30分钟删除掉该文件夹。(PS: 不过也正因为此设计,在用户修改代码提交时我们必须是将文件夹全量提交,否则就会出现用户离开页面一段时间后再次提交代码时编译结果报错的问题。当然因为每个组件的示例代码最多也不过几个文件夹,测试中发现全量提交代码与增量提交已修改的代码文件对整个编译时间的影响最多也不会超过几十毫秒,这也全仰仗于基于内存的读写速度之快)。在设计删除任务时,要针对每个 id 单独记录其删除任务,最好通过建立 gcTask={} 的对象,以 unique id 为 key, 定时任务作为 value,在任务结果后通过delete gcTask[id] 删除掉该 key,以减轻全局对象 gcTask 的负担。

此外,在每个文件夹内,用户可能针对一个组件做出很多次保存的操作,也就是会生成多个编译结果文件bundle.[hash].js。对于此,我们在控制编译的 webpack.config.js 中最好添加clean-webpack-plugin,通过new CleanWebpackPlugin(`${tmpPath}/${id}/bundle.*.js`,cleanOptions)在每次编译前删除之前的编译结果。正如世界上没有十全十美,这样粗暴的删除文件,也会使得上次的编译结果不再缓存可用,比如只是修改了一处代码,编译之后报错,撤销修改,再次编译,此种情况下是应该可以直接利用上次的编译结果的,只是这种使用场景概率比较低,且影响较小,所以权衡之下,选择尽力保证机器的内存空间能够得到有效释放。

如何更新预览区的内容?

因为在设计 playground 时没有把预览区单独拿出来作为一个应用,所以只是作为 playground 应用的一个二级链接访问。那么更新预览区的内容时就只能通过更新子 iframe 的方式。

refreshIframe(html) {
    let frame = document.querySelector("#preview-iframe");
    let iframe =
        frame.contentWindow ||
        frame.contentDocument.document ||
        frame.contentDocument;
    iframe = iframe.document;
    iframe.open("text/htmlreplace");
    iframe.write(html);
    iframe.close();

这种更新方式都会带来一瞬间的闪烁,因为内容是先清空再重写。如果将 iframe 部分独立成一个应用,通过 postMessage 等方法接受到待更新的内容后,可以直接通过document.body.html=toPreviewHtml修改预览区的内容,并且不会造成闪烁。当前只能通过添加 loading 动画来美化预览区更新时因为页面空白带来的闪烁问题。

对集成时问题的补充

  • 初始化示例代码到数据库

    playground 中的数据都是从数据库中读取的。用户修改后的代码提交时直接保存到了数据库,组件页面打开时初始展示的代码呢?这些代码是 ui 组的同事为了帮助开发者了解组件使用写的示例代码,每次从网站打开组件的使用时都是默认要展示这些代码的,所以在网站启动时需要从 git 仓库中读取这些示例组件的代码并将其存到数据库。

  • 通过 webhook 自动更新示例代码

    当 UI 组的同事更新了组件的示例之后,playground 中的数据应该也自动得到及时的更新。通过webhook 实现对示例代码所在仓库的动态跟踪,在有关组件的示例代码更改后更新数据库中该组件的代码。

  • 将待编译项目的依赖文件单独配置

    因为嵌入的 playground 就是用来对公司内部组件进行编译的,为了防止这些组件的依赖与开发 playground 的依赖发生冲突,特将用于编译的依赖文件独立到一个文件夹下,防止在同一个 package.json 中因为版本不同带来的某些问题。比如我在 playground 中使用了 react-router-dom@4,但是公司前端组件中依赖的是 react-router@3。

  • 对 playground 中文件目录的实现独立成文:打造在线编译器 之 对文件目录的操作

写在最后

这是很有意思的一个项目,并且后续还可以有很多工作可做,比如对该项目运行状态的监控(并发时的内存占用等)、数据库读写效率与空间占用、编译速度的进一步提升等。

PS:该 playground 是基于 react+mobx+koa2+webpack3进行的开发,也提取出了一套个人感觉还算不错的脚手架:koa-react-scaffold 。其中对webpack的配置已经尽可能做了优化(包括dll,区分 dev与 prod 模式)、所有代码(包括 node 部分)都可以使用 ES6编写,也包含了针对 mongoose 的 crud 方法,如果喜欢,欢迎使用以及 star~