這期來關注一下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 進行打包,最後運行一個開發服務器才能在瀏覽器跑起來.github
實際上 CodeSandbox 打包和運行並不依賴於服務器, 它是徹底在瀏覽器進行的. 大概的結構以下:web
VsCode
, 文件變更後會通知 Sandbox
進行轉譯. 計劃會有文章專門介紹CodeSandbox的編輯器實現CodeSandbox 的做者 Ives van Hoorne 也嘗試過將 Webpack
移植到瀏覽器上運行,由於如今幾乎全部的 CLI 都是使用 Webpack 進行構建的,若是能將 Webpack 移植到瀏覽器上, 能夠利用 Webpack 強大的生態系統和轉譯機制(loader/plugin),低成本兼容各類 CLI.
然而 Webpack 過重了😱,壓縮事後的大小就得 3.5MB,這還算勉強能夠接受吧;更大的問題是要在瀏覽器端模擬 Node 運行環境,這個成本過高了,得不償失。
因此 CodeSandbox 決定本身造個打包器,這個打包器更輕量,而且針對 CodeSandbox 平臺進行優化. 好比 CodeSandbox 只關心開發環境的代碼構建, 目標就是能跑起來就好了, 跟 Webpack 相比裁剪掉了如下特性:
生產模式. CodeSandbox 只考慮 development 模式,不須要考慮 production一些特性,好比
因此能夠認爲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 的客戶端是開源的,否則就沒有本文了,它的基本目錄結構以下:
packages
app CodeSandbox應用
sandbox 運行沙盒,在這裏執行代碼構建和預覽,至關於一個縮略版的 Webpack. 運行在單獨的 iframe 中
eval
preset
transpiler
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 的模塊導入原理,你能夠很容易理解這個過程:
index.html
文件,將 document.body.innerHTML 設置爲 html 模板的 body 內容.④ 全部模塊都會被轉譯成 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 的源碼.
一不當心又寫了一篇長文,要把這麼複雜代碼講清楚真是一個挑戰, 我還作的不夠好,按照以往的經驗,這又是一篇無人問津的文章, 別說是大家, 我本身都不怎麼有耐心看這類文章, 後面仍是儘可能避免吧!