這期來關注一下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 的源碼.
一不當心又寫了一篇長文,要把這麼複雜代碼講清楚真是一個挑戰, 我還作的不夠好,按照以往的經驗,這又是一篇無人問津的文章, 別說是大家, 我本身都不怎麼有耐心看這類文章, 後面仍是儘可能避免吧!