这期来关注一下CodeSandbox
, 这是一个浏览器端的沙盒运行环境,支持多种流行的构建模板,例如 create-react-app
、 vue-cli
、parcel
等等。 能够用于快速原型开发、DEMO 展现、Bug 还原等等.css
类似的产品有不少,例如codepen
、JSFiddle
、WebpackBin
(已废弃).html
CodeSandbox 则更增强大,能够视做是浏览器端的 Webpack 运行环境, 甚至在 V3 版本已经支持 VsCode 模式,支持 Vscode 的插件和 Vim 模式、还有主题.前端
另外 CodeSandbox 支持离线运行(PWA)。基本上能够接近本地 VSCode 的编程体验. 有 iPad 的同窗,也能够尝试基于它来进行开发。因此快速的原型开发我通常会直接使用 CodeSandboxvue
目录node
笔者对 CodeSandbox 的第一印象是这玩意是运行在服务器的吧? 好比 create-react-app
要运行起来须要 node 环境,须要经过 npm 安装一大堆依赖,而后经过 Webpack 进行打包,最后运行一个开发服务器才能在浏览器跑起来.react
实际上 CodeSandbox 打包和运行并不依赖于服务器, 它是彻底在浏览器进行的. 大概的结构以下:webpack
VsCode
, 文件变更后会通知 Sandbox
进行转译. 计划会有文章专门介绍CodeSandbox的编辑器实现CodeSandbox 的做者 Ives van Hoorne 也尝试过将 Webpack
移植到浏览器上运行,由于如今几乎全部的 CLI 都是使用 Webpack 进行构建的,若是能将 Webpack 移植到浏览器上, 能够利用 Webpack 强大的生态系统和转译机制(loader/plugin),低成本兼容各类 CLI.git
然而 Webpack 过重了😱,压缩事后的大小就得 3.5MB,这还算勉强能够接受吧;更大的问题是要在浏览器端模拟 Node 运行环境,这个成本过高了,得不偿失。github
因此 CodeSandbox 决定本身造个打包器,这个打包器更轻量,而且针对 CodeSandbox 平台进行优化. 好比 CodeSandbox 只关心开发环境的代码构建, 目标就是能跑起来就好了, 跟 Webpack 相比裁剪掉了如下特性:web
因此能够认为CodeSandbox是一个简化版的Webpack, 且针对浏览器环境进行了优化,好比使用worker来进行并行转译
CodeSandbox 的打包器使用了接近 Webpack Loader
的 API, 这样能够很容易地将 Webpack 的一些 loader 移植过来. 举个例子,下面是 create-react-app
的实现(查看源码):
import stylesTranspiler from "../../transpilers/style";
import babelTranspiler from "../../transpilers/babe";
// ...
import sassTranspiler from "../../transpilers/sass";
// ...
const preset = new Preset(
"create-react-app",
["web.js", "js", "json", "web.jsx", "jsx", "ts", "tsx"],
{
hasDotEnv: true,
setup: manager => {
const babelOptions = {
/*..*/
};
preset.registerTranspiler(
module =>
/\.(t|j)sx?$/.test(module.path) && !module.path.endsWith(".d.ts"),
[
{
transpiler: babelTranspiler,
options: babelOptions
}
],
true
);
preset.registerTranspiler(
module => /\.svg$/.test(module.path),
[
{ transpiler: svgrTranspiler },
{
transpiler: babelTranspiler,
options: babelOptions
}
],
true
);
// ...
}
}
);
复制代码
能够看出, CodeSandbox的Preset和Webpack的配置长的差很少. 不过, 目前你只能使用 CodeSandbox 预约义的 Preset, 不支持像 Webpack 同样进行配置, 我的以为这个是符合 CodeSandbox 定位的,这是一个快速的原型开发工具,你还折腾 Webpack 干吗?
目前支持这些Preset:
CodeSandbox 的客户端是开源的,否则就没有本文了,它的基本目录结构以下:
packager -> transpilation -> evaluation
Sandbox 构建分为三个阶段:
eval
运行模块代码进行预览下面会按照上述的步骤来描述其中的技术点
尽管 npm 是个'黑洞',咱们仍是离不开它。 其实大概分析一下前端项目的 node_modules
,80%是各类开发依赖组成的.
因为 CodeSandbox 已经包揽了代码构建的部分,因此咱们并不须要devDependencies
, 也就是说 在CodeSandbox 中咱们只须要安装全部实际代码运行须要的依赖,这能够减小成百上千的依赖下载. 因此暂且不用担忧浏览器会扛不住.
CodeSandbox 的依赖打包方式受 WebpackDllPlugin
启发,DllPlugin 会将全部依赖都打包到一个dll
文件中,并建立一个 manifest
文件来描述dll的元数据(以下图).
Webpack 转译时或者 运行时能够根据 manifest 中的模块索引(例如__webpack_require__('../node_modules/react/index.js')
)来加载 dll 中的模块。 由于WebpackDllPlugin
是在运行或转译以前预先对依赖的进行转译,因此在项目代码转译阶段能够忽略掉这部分依赖代码,这样能够提升构建的速度(真实场景对npm依赖进行Dll打包提速效果并不大):
manifest文件
基于这个思想, CodeSandbox 构建了本身的在线打包服务, 和WebpackDllPlugin不同的是,CodeSandbox是在服务端预先构建Manifest文件的, 并且不区分Dll和manifest文件。 具体思路以下:
简而言之,CodeSandbox 客户端拿到package.json
以后,将dependencies
转换为一个由依赖和版本号组成的Combination
(标识符, 例如 v1/combinations/babel-runtime@7.3.1&csbbust@1.0.0&react@16.8.4&react-dom@16.8.4&react-router@5.0.1&react-router-dom@5.0.1&react-split-pane@0.1.87.json
), 再拿这个 Combination 到服务器请求。服务器会根据 Combination 做为缓存键来缓存打包结果,若是没有命中缓存,则进行打包.
打包实际上仍是使用yarn
来下载全部依赖,只不过这里为了剔除 npm 模块中多余的文件,服务端还遍历了全部依赖的入口文件(package.json#main), 解析 AST 中的 require 语句,递归解析被 require 模块. 最终造成一个依赖图, 只保留必要的文件.
最终输出 Manifest 文件,它的结构大概以下, 他就至关于WebpackDllPlugin的dll.js+manifest.json的结合体:
{
// 模块内容
"contents": {
"/node_modules/react/index.js": {
"content": "'use strict';↵↵if ....", // 代码内容
"requires": [ // 依赖的其余模块
"./cjs/react.development.js",
],
},
"/node_modules/react-dom/index.js": {/*..*/},
"/node_modules/react/package.json": {/*...*/},
//...
},
// 模块具体安装版本号
"dependencies": [{name: "@babel/runtime", version: "7.3.1"}, {name: "csbbust", version: "1.0.0"},/*…*/],
// 模块别名, 好比将react做为preact-compat的别名
"dependencyAliases": {},
// 依赖的依赖, 即间接依赖信息. 这些信息能够从yarn.lock获取
"dependencyDependencies": {
"object-assign": {
"entries": ["object-assign"], // 模块入口
"parents": ["react", "prop-types", "scheduler", "react-dom"], // 父模块
"resolved": "4.1.1",
"semver": "^4.1.1",
}
//...
}
}
复制代码
Serverless 思想
值得一提的是 CodeSandbox 的 Packager 后端使用了 Serverless(基于 AWS Lambda),基于 Serverless 的架构让 Packager 服务更具伸缩性,能够灵活地应付高并发的场景。使用 Serverless 以后 Packager 的响应时间显著提升,并且费用也下去了。
Packager 也是开源的, 围观
AWS Lambda函数是有局限性的, 好比/tmp
最多只能有 500MB 的空间. 尽管大部分依赖打包场景不会超过这个限额, 为了加强可靠性(好比上述的方案可能出错,也可能漏掉一些模块), Packager还有回退方案.
后来CodeSanbox做者开发了新的Sandbox,支持把包管理的步骤放置到浏览器端, 和上面的打包方式结合着使用。原理也比较简单: 在转译一个模块时,若是发现模块依赖的npm模块未找到,则惰性从远程下载回来. 来看看它是怎么处理的:
在回退方案中CodeSandbox 并不会将 package.json 中全部的包都下载下来,而是在模块查找失败时,惰性的去加载。好比在转译入口文件时,发现 react 这个模块没有在本地缓存模块队列中,这时候就会到远程将它下载回来,而后接着转译。
也就是说,由于在转译阶段会静态分析模块的依赖,只须要将真正依赖的文件下载回来,而不须要将整个npm包下载回来,节省了网络传输的成本.
CodeSandbox 经过 unpkg.com
或 cdn.jsdelivr.net
来获取模块的信息以及下载文件, 例如
https://unpkg.com/react@latest/package.json
https://unpkg.com/antd@3.17.0/?meta
这个会递归返回该包的全部目录信息https://unpkg.com/react@16.8.6/cjs/react.production.min.js
或者 https://cdn.jsdelivr.net/npm/@babel/runtime@7.3.1/helpers/interopRequireDefault.js
讲完 Packager 如今来看一下 Transpilation, 这个阶段从应用的入口文件开始, 对源代码进行转译, 解析AST,找出下级依赖模块,而后递归转译,最终造成一个'依赖图':
CodeSandbox 的整个转译器是在一个单独的 iframe 中运行的:
Editor 负责变动源代码,源代码变动会经过 postmessage 传递给 Compiler,这里面会携带 Module+template
create-react-app
、vue-cli
, 定义了一些 loader 规则,用来转译不一样类型的文件, 另外preset也决定了应用的模板和入口文件。 经过上文咱们知道, 这些 template 目前的预约义的.在详细介绍 Transpilation 以前先大概看一些基本对象,了解这些对象之间的关系:
vue-cli
、create-react-app
. 配置了项目文件的转译规则, 以及应用的目录结构(入口文件)Manager是一个管理者的角色,从大局上把控整个转译和执行的流程. 如今来看看总体的转译流程:
大局上基本上能够划分为如下四个阶段:
.vue
文件。TranspiledModule用于管理某个具体的模块,这里面会维护转译和运行的结果、模块的依赖信息,并驱动模块的转译和执行:
TranspiledModule 会从Preset中获取匹配当前模块的Transpiler列表的,遍历Transpiler对源代码进行转译,转译的过程当中会解析AST,分析模块导入语句, 收集新的依赖; 当模块转译完成后,会递归转译依赖列表。 来看看大概的代码:
async transpile(manager: Manager) {
// 已转译
if (this.source) return this
// 避免重复转译, 一个模块只转译一次
if (manager.transpileJobs[this.getId()]) return this;
manager.transpileJobs[this.getId()] = true;
// ...重置状态
// 🔴从Preset获取Transpiler列表
const transpilers = manager.preset.getLoaders(this.module, this.query);
// 🔴 链式调用Transpiler
for (let i = 0; i < transpilers.length; i += 1) {
const transpilerConfig = transpilers[i];
// 🔴构建LoaderContext,见下文
const loaderContext = this.getLoaderContext(
manager,
transpilerConfig.options || {}
);
// 🔴调用Transpiler转译源代码
const {
transpiledCode,
sourceMap,
} = await transpilerConfig.transpiler.transpile(code, loaderContext); // eslint-disable-line no-await-in-loop
if (this.errors.length) {
throw this.errors[0];
}
}
this.logWarnings();
// ...
await Promise.all(
this.asyncDependencies.map(async p => {
try {
const tModule = await p;
this.dependencies.add(tModule);
tModule.initiators.add(this);
} catch (e) {
/* let this handle at evaluation */
}
})
);
this.asyncDependencies = [];
// 🔴递归转译依赖的模块
await Promise.all(
flattenDeep([
...Array.from(this.transpilationInitiators).map(t =>
t.transpile(manager)
),
...Array.from(this.dependencies).map(t => t.transpile(manager)),
])
);
return this;
}
复制代码
Transpiler等价于webpack的loader,它配置方式以及基本API也和webpack(查看webpack的loader API)大概保持一致,好比链式转译和loader-context. 来看一下Transpiler的基本定义:
export default abstract class Transpiler {
initialize() {}
dispose() {}
cleanModule(loaderContext: LoaderContext) {}
// 🔴 代码转换
transpile(
code: string,
loaderContext: LoaderContext
): Promise<TranspilerResult> {
return this.doTranspilation(code, loaderContext);
}
// 🔴 抽象方法,由具体子类实现
abstract doTranspilation(
code: string,
loaderContext: LoaderContext
): Promise<TranspilerResult>;
// ...
}
复制代码
Transpiler的接口很简单,transpile
接受两个参数:
code
即源代码.
loaderContext
由TranspiledModule提供, 能够用来访问一下转译上下文信息,好比Transpiler的配置、 模块查找、注册依赖等等。大概外形以下:
export type LoaderContext = {
// 🔴 信息报告
emitWarning: (warning: WarningStructure) => void;
emitError: (error: Error) => void;
emitModule: (title: string, code: string, currentPath?: string, overwrite?: boolean, isChild?: boolean) => TranspiledModule;
emitFile: (name: string, content: string, sourceMap: SourceMap) => void;
// 🔴 配置信息
options: {
context: string;
config?: object;
[key: string]: any;
};
sourceMap: boolean;
target: string;
path: string;
addTranspilationDependency: (depPath: string, options?: { isAbsolute?: boolean; isEntry?: boolean; }) => void;
resolveTranspiledModule: ( depPath: string, options?: { isAbsolute?: boolean; ignoredExtensions?: Array<string>; }) => TranspiledModule;
resolveTranspiledModuleAsync: ( depPath: string, options?: { isAbsolute?: boolean; ignoredExtensions?: Array<string>; }) => Promise<TranspiledModule>;
// 🔴 依赖收集
addDependency: ( depPath: string, options?: { isAbsolute?: boolean; isEntry?: boolean; }) => void;
addDependenciesInDirectory: ( depPath: string, options?: { isAbsolute?: boolean; isEntry?: boolean; }) => void;
_module: TranspiledModule;
};
复制代码
先从简单的开始,来看看JSON模块的Transpiler实现, 每一个Transpiler子类须要实现doTranspilation,接收源代码,并异步返回处理结果:
class JSONTranspiler extends Transpiler {
doTranspilation(code: string) {
const result = ` module.exports = JSON.parse(${JSON.stringify(code || '')}) `;
return Promise.resolve({
transpiledCode: result,
});
}
}
复制代码
并非全部模块都像JSON这么简单,好比Typescript和Babel。 为了提升转译的效率,Codesandbox会利用Worker来进行多进程转译,多Worker的调度工做由WorkerTranspiler
完成,这是Transpiler的子类,维护了一个Worker池。Babel、Typescript、Sass这类复杂的转译任务都是基于WorkerTranspiler实现的:
其中比较典型的实现是BabelTranspiler, 在Sandbox启动时就会预先fork三个worker,来提升转译启动的速度, BabelTranspiler会优先使用这三个worker来初始化Worker池:
// 使用worker-loader fork三个loader,用于处理babel编译
import BabelWorker from 'worker-loader?publicPath=/&name=babel-transpiler.[hash:8].worker.js!./eval/transpilers/babel/worker/index.js';
window.babelworkers = [];
for (let i = 0; i < 3; i++) {
window.babelworkers.push(new BabelWorker());
}
复制代码
这里面使用到了webpack的worker-loader, 将指定模块封装为 Worker 对象。让 Worker 更容易使用:
// App.js
import Worker from "./file.worker.js";
const worker = new Worker();
worker.postMessage({ a: 1 });
worker.onmessage = function(event) {};
worker.addEventListener("message", function(event) {});
复制代码
BabelTranpiler具体的流程以下:
WorkerTranspiler会维护空闲的Worker队列
和一个任务队列
, 它的工做就是驱动Worker来消费任务队列。具体的转译工做在Worker中进行:
虽然称为打包器(bundler), 可是 CodeSandbox 并不会进行打包,也就是说他不会像 Webpack 同样,将全部的模块都打包合并成 chunks 文件.
Transpilation
从入口文件
开始转译, 再分析文件的模块导入规则,递归转译依赖的模块. 到Evaluation
阶段,CodeSandbox 已经构建出了一个完整的依赖图. 如今要把应用跑起来了🏃
Evaluation 的原理也比较简单,和 Transpilation 同样,也是从入口文件开始: 使用eval
执行入口文件,若是执行过程当中调用了require
,则递归 eval 被依赖的模块。
若是你了解过 Node 的模块导入原理,你能够很容易理解这个过程:
① 首先要初始化 html,找到index.html
文件,将 document.body.innerHTML 设置为 html 模板的 body 内容.
② 注入外部资源。用户能够自定义一些外部静态文件,例如 css 和 js,这些须要 append 到 head 中
③ evaluate 入口模块
④ 全部模块都会被转译成 CommonJS 模块规范。因此须要模拟这个模块环境。大概看一下代码:
// 实现require方法
function require(path: string) {
// ... 拦截一些特殊模块
// 在Manager对象中查找模块
const requiredTranspiledModule = manager.resolveTranspiledModule(
path,
localModule.path
);
// 模块缓存, 若是存在缓存则说明不须要从新执行
const cache = requiredTranspiledModule.compilation;
return cache
? cache.exports
: // 🔴递归evaluate
manager.evaluateTranspiledModule(
requiredTranspiledModule,
transpiledModule
);
}
// 实现require.resolve
require.resolve = function resolve(path: string) {
return manager.resolveModule(path, localModule.path).path;
};
// 模拟一些全局变量
const globals = {};
globals.__dirname = pathUtils.dirname(this.module.path);
globals.__filename = this.module.path;
// 🔴放置执行结果,即CommonJS的module对象
this.compilation = {
id: this.getId(),
exports: {}
};
// 🔴eval
const exports = evaluate(
this.source.compiledCode,
require,
this.compilation,
manager.envVariables,
globals
);
复制代码
⑤ 使用 eval 来执行模块。一样看看代码:
export default function(code, require, module, env = {}, globals = {}) {
const exports = module.exports;
const global = g;
const process = buildProcess(env);
g.global = global;
const allGlobals = {
require,
module,
exports,
process,
setImmediate: requestFrame,
global,
...globals
};
const allGlobalKeys = Object.keys(allGlobals);
const globalsCode = allGlobalKeys.length ? allGlobalKeys.join(", ") : "";
const globalsValues = allGlobalKeys.map(k => allGlobals[k]);
// 🔴将代码封装到一个函数下面,全局变量以函数形式传入
const newCode = `(function evaluate(` + globalsCode + `) {` + code + `\n})`;
(0, eval)(newCode).apply(this, globalsValues);
return module.exports;
}
复制代码
Ok!到这里 Evaluation 就解释完了,实际的代码比这里要复杂得多,好比 HMR(hot module replacement)支持, 有兴趣的读者,能够本身去看 CodeSandbox 的源码.
一不当心又写了一篇长文,要把这么复杂代码讲清楚真是一个挑战, 我还作的不够好,按照以往的经验,这又是一篇无人问津的文章, 别说是大家, 我本身都不怎么有耐心看这类文章, 后面仍是尽可能避免吧!