手把手搭建一個屬於自己的在線 IDE
背景
這幾個月在公司內做一個跨前端項目之間共享組件/區塊的工程,主要思路就是在 Bit 的基礎上進行開發。Bit 主要目的是實現不同項目 共享 與 同步 組件/區塊,大致思路如下:
在 A 項目中通過執行 Bit 提供的命令行工具將需要共享的組件/區塊的源碼推送到遠端倉庫,然後在 B 項目中就可以同樣通過 Bit 提供的命令行工具拉取存儲在 Bit 遠程倉庫的組件/區塊。聽起來比較像 Git,主要的區別是 Bit 除了推送源碼之外,還會包括組件的依賴圖譜分析、組件的版本管理等功能。下面這張圖就描述了 Bit 的實現思路。更多細節可以參考 Bit 官方文檔 Bit-Docs
雖然 Bit 開源了命令行工具,但並沒有開源共享組件/區塊的展示站點,類似 Bit 官方提供的網站 bit.dev。也就是說使用者無法通過瀏覽組件/區塊的構建後的視圖的方式,來查找保存在 Bit 遠程倉庫的組件/區塊程式碼。Bit 網站效果如下圖:
接下來就需要自己實現一個類似的網站,進而就會發現其中最難的部分就是實現一個在線 IDE,用於展示組件/區塊程式碼,並支援程式碼實時構建以及獲取構建後的頁面截圖等功能。效果如下圖:
使用目前提供的在線 IDE 的問題
看到這裡你可能會有個疑問,為什麼不能直接使用現有免費的在線 IDE?例如 CodeSandbox、CodePen、Stackblitz 等。主要有如下原因:
-
對於稍具一定規模的公司,都會有自己的私有 npm 源,而在線 IDE 無法獲取到這些 npm 包;
-
前端項目構建中一些特定的配置,而現有的在線 IDE 無法支援;
例如 CodeSandbox 只能設置構建模板的類型,例如 create-react-app,並沒有提供外部修改具體的構建配置的 API。例如項目中用到了 less 文件,選擇 create-react-app 模板是無法構建的該類型文件的。
-
特殊的功能無法實現,例如點擊頁面的按鈕,可以實現對在線 IDE 右側構建出來的頁面進行截圖,並將圖片數據傳輸出來;
-
使用在線 IDE 提供的服務,一般意味著你的組件/區塊是暴露在公網上的,然而可能有些程式碼涉密,是不能上傳到公網上的。
-
部分構建工具依賴 node_modules 等文件,無法在沒有 node_modules 的瀏覽器中正常工作。例如 babel 插件等。這個在後面的訂製 CodeSandbox 功能部分會舉個例子細說。
所以我們需要搭建一個屬於自己的在線 IDE ,以解決上面提的幾個問題。那麼接下來有兩種方式:一種是完全從零開發一個在線 IDE,另一種是找到一個開源的項目,並在此基礎上進行訂製。
最開始筆者選擇了自己開發,但是開發一段時間後,發現花費了大量精力實現出來 IDE 和已有的產品相比,不論是從功能豐富度還是易用性上,都完全落敗。再加上筆者主要想實現的是一個跨前端項目區塊復用的平台,在線 IDE 只是其中一個非必要的組成部分(註:其實也可以將共享的組件/區塊的源程式碼直接在頁面上展示,通過組件/區塊命稱來區分,雖然這種方式確實很 low)。所以最終還是選擇在已經開源的在線 IDE 基礎上二次開發。
CodeSandbox 基本原理
筆者主要研究的是 Codesandbox 以及 Stackblitz 。這兩個都是商業化的項目,其中 Stackblitz 的核心部分並沒有開源出來,而 CodeSandbox 絕大部分的功能都已經開源出來了,所以最終選擇了 CodeSandbox。
為了方便後續講解如何訂製和部署 CodeSandbox,這裡大概說一下它的基本原理(下面主要引用了CodeSandbox 如何工作? 上篇 的部分內容):
CodeSandbox 最大的特點是採用在瀏覽器端做項目構建,也就是說打包和運行不依賴伺服器。由於瀏覽器端並沒有 Node 環境,所以 CodeSandbox 自己實現了一個可以跑在瀏覽器端的簡化版 webpack。
CodeSandbox 組成部分
如下圖所示,CodeSandbox 主要包含了三個部分:
-
Editor 編輯器:主要用於編輯程式碼,程式碼變動後會通知 Sandbox 進行轉譯
-
Sandbox 程式碼運行沙盒:在一個單獨的 iframe 中運行,負責程式碼的編譯 Transpiler 和運行 Evalation
-
Packager npm 在線打包器:給 Sandbox 提供 npm 包中的文件內容
CodeSandbox 構建項目過程
構建過程主要包括了三個步驟:
-
Packager–npm 包打包階段:下載 npm 包並遞歸查找所有引用到的文件,然後提供給下個階段進行編譯
-
Transpilation–編譯階段:編譯所有程式碼, 構建模組依賴圖
-
Evaluation–執行階段:使用 eval 運行編譯後的程式碼,實現項目預覽
Packager–npm 包打包階段
Packager 階段的程式碼實現是在 CodeSandbox 託管在 GitHub 上的倉庫 dependency-packager 里,這是一個基於 express 框架提供的服務,並且部署採用了 Serverless(基於 AWS Lambda) 方式,讓 Packager 服務更具伸縮性,可以靈活地應付高並發的場景。(註:在私有化部署中如果沒有 Serverless 環境,可以將源碼中有關 AWS Lambda 部分全部注釋掉即可 )
以 react 包為例,講解下 Packager 服務的原理,首先 express 框架接收到請求中的包名以及包版本,例如 [email protected]。然後通過 yarn 下載 react 以及 react 的依賴包到磁碟上,通過讀取 npm 包的 package.json 文件中的 browser、module、main、unpkg 等欄位找到 npm 包入口文件,然後解析 AST 中所有的 require 語句,將被 require 的文件內容添加到 manifest 文件中,並且遞歸執行剛才的步驟,最終形成依賴圖。這樣就實現將 npm 包文件內容轉移到 manifest.json 上的目的,同時也實現了剔除 npm 模組中多餘的文件的目的。最後返回給 Sandbox 進行編譯。下面是一個 manifest 文件的示例:
{ // 模組內容 "contents": { "/node_modules/react/index.js": { "content": "'use strict';↵↵if ....", // 程式碼內容 "requires": [ // 依賴的其他模組 "./cjs/react.development.js", ], }, //... }, // 模組具體安裝版本號 "dependencies": [{ name: "@babel/runtime", version: "7.3.1" }, /*…*/ ], // 模組別名, 比如將react作為preact-compat的別名 "dependencyAliases": {}, // 依賴的依賴, 即間接依賴資訊. 這些資訊可以從yarn.lock獲取 "dependencyDependencies": { "object-assign": { "entries": ["object-assign"], // 模組入口 "parents": ["react", "prop-types", "scheduler", "react-dom"], // 父模組 } //... } }
值得一提的是為了提升 npm 在線打包的速度,CodeSandbox 作者使用了 AWS 提供的 S3 雲存儲服務。當某個版本的 npm 包已經打包過一次的話,會將打包的結果 manifest.json
文件存儲到 S3 上。在下一次請求同樣版本的包時,就可以直接返回儲存的 manifest.json
文件,而不需要重複上面的流程了。在私有化部署中可以將 S3 替換成你自己的文件存儲服務。
Transpilation–編譯階段
當 Sandbox 從 Editor 接收到前端項目的源程式碼、npm 依賴以及構建模板 Preset。Sandbox 會初始化配置,然後從 Packager 服務下載 npm 依賴包對應的 manifest 文件,接著從前端項目的入口文件開始對項目進行編譯,並解析 AST 遞歸編譯被 require 的文件,形成依賴圖(註:和 webpack 原理基本一致)。
注意 CodeSandbox 支援外部預定義項目的構建模板 Preset。Preset 規定了針對某一類型的文件,採用哪些 Transpiler(相當於 Webpack 的 Loader)對文件進行編譯。目前可供選擇的 Preset 選項有: vue-cli
、 create-react-app
、create-react-app-typescript
、 parcel
、angular-cli
、preact-cli
。但是不支援修改某個 Preset 中的具體配置,這些都是內置在 CodeSandbox 源碼中的。Preset 具體配置示例如下:
import babelTranspiler from "../../transpilers/babel"; ... 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 ); ... } } );
Evaluation–執行階段
Evaluation 執行階段是從項目入口文件對應的編譯後的模組開始,遞歸調用 eval 執行所有被引用到的模組。
由於本文主要是闡述如何搭建自己的在線 IDE,所以 CodeSandbox 更多的實現細節可以參考如下文章:
私有化部署 CodeSandbox
了解完 CodeSandbox 基本原理後,接下來就到了本文的核心內容:如何私有化部署 CodeSandbox。
在線打包服務 Packager
首先是 npm 在線打包服務 dependency-packager。筆者是通過鏡像部署到自己的伺服器上的。
接著是將 npm 源改成公司的私有 npm 源,可以通過兩種方式,一種是在鏡像中通過 npm config 命令全局修改,例如如下 Dockerfile:
FROM node:12-alpine COPY . /home/app # 設置私有 npm 源 RUN cd /home/app && npm config set registry //npm.xxx.com && npm install -f WORKDIR /home/app CMD ["npm", "run", "dev"]
第二種方式是在源碼中通過 yarn 下載 npm 包的命令後面添加參數 --registry=//npm.xxx.com
,相關程式碼在 functions/packager/dependencies/install-dependencies.ts 文件中。
另外該服務依賴了 AWS 的 Lambda 提供的 Serverless,並採用 AWS 提供的 S3 存儲服務快取 npm 包的打包結果。如果讀者沒有這些服務的話,可以將源碼中這部分內容注釋掉或者換成對應的其他雲計算廠商的服務即可。dependency-packager 本質上就是一個基於 express 框架的 node 服務,可以簡單地直接跑在伺服器中。
編輯器 Editor
在 CodeSandbox-client 工程中的 standalone-packages/react-sandpack 項目,就是 CodeSandbox 提供的基於 react 實現的的編輯器項目。區別於主項目實現的編輯器,這個編輯器主要是為了給使用者進行訂製,所以實現的比較簡陋,使用者可以根據自己的需求在這個編輯器的基礎上加入自己需要的功能。當然如果沒有自定義編輯器的需求,可以直接使用 react-sandpack 項目對應的 npm 包 react-smooshpack,使用方式如下:
import React from 'react'; import { render } from 'react-dom'; import { FileExplorer, CodeMirror, BrowserPreview, SandpackProvider, } from 'react-smooshpack'; import 'react-smooshpack/dist/styles.css'; const files = { '/index.js': { code: "document.body.innerHTML = `<div>${require('uuid')}</div>` ", }, }; const dependencies = { uuid: 'latest', }; const App = () => ( <SandpackProvider files={files} dependencies={dependencies} entry="/index.js" bundlerURL= `http://sandpack-${version}.codesandbox.io` > <div style={{ display: 'flex', width: '100%', height: '100%' }}> <FileExplorer style={{ width: 300 }} /> <CodeMirror style={{ flex: 1 }} /> <BrowserPreview style={{ flex: 1 }} /> </div> </SandpackProvider> ); render(<App />, document.getElementById('root'));
其中子組件 FileExplorer、CodeMirror、BrowserPreview 分別是左側的文件目錄樹、中間的程式碼編輯區和右側的項目構建後的頁面預覽區。
通過查看這個獨立庫的源碼,可以知道除了這三個子組件之外,SandpackProvider 還會再插入一個 iframe 標籤,主要用於顯示項目構建後的頁面,而右側預覽區組件 BrowserPreview 中的 Preview 組件會將這個 ifame 插入到自己的節點,這樣就實現了將項目構建的頁面實時顯示出來的目的。
而 iframe 載入的 bundlerUrl 默認是官方提供的地址 //sandpack-${version}.codesandbox.io
,其中這個域名對應的服務其實就是 CodeSandbox 的核心–在瀏覽器端構建前端項目的服務,大致原理剛剛已經闡述過了。下一小節會闡述如何將官方提供的構建服務替換成自己的。
至於程式碼編輯區的程式碼/依賴如何同步到 iframe 中載入的構建服務,其實它依賴了另一個獨立庫 sandpack(和 react-sandpack 同級目錄),其中有一個 Manager 類就是在程式碼編輯區和右側預覽區的構建服務之間搭建橋樑,主要是用了 codesandbox-api 包提供的 dispatch 方法進行編輯器和構建服務之間的通訊。
程式碼運行沙盒 SandBox
怕大家誤解先提前說明下,上一小節提到的構建服務並不是後端服務,這個服務其實就是 CodeSandbox 構建出來的前端頁面。基本原理部分已經闡述了 CodeSandbox 實際上在瀏覽器里實現了一個 webpack,項目的構建全部是在瀏覽器中完成的。
而 CodeSandbox 前端構建的核心部分的目錄在 CodeSandbox-client 工程中 packages/app 項目,其中的原理已經在上面闡述過了,這裡只需要將該項目構建出來的 www 文件夾部署到伺服器即可。由於該核心庫又依賴了其他庫,所以也需要先構建下依賴庫。下面筆者寫了一個 build.sh 文件,放置在整個項目的一級目錄即可。
# # 運行和構建需要 Node 10 環境 # nvm use 10 # 安裝依賴 yarn # 構建依賴庫 yarn run build:deps # 進入到核心庫 packages/app 進行構建 cd packages/app yarn run build:sandpack-sandbox # 由於一些原因,一些需要的靜態文件需要從整體項目的構建目錄中獲取 # 因此需要在執行該 shell 腳本之前,將整個項目構建一次,即執行 npm run build 即可(這個構建的時間會比較久) cp -rf ../../www/static/* ./www/static
當執行完上面的 shell 腳本之後,就可以將 packages/app 目錄下構建的產物 www 部署到伺服器上,筆者採用的是容器部署,下面是 dockerfile 文件內容。
FROM node:10.14.2 as build WORKDIR / ADD . . RUN /bin/sh build.sh FROM nginx:1.16.1-alpine COPY --from=build /packages/app/www /usr/share/nginx/html/
注意這裡採用了分階段構建鏡像,即先構建 CodeSandbox 項目,再構建鏡像。但在實踐中發現 CodeSandbox 項目放在伺服器上構建不是很順利,所以最終還是選擇在本地構建該項目,然後將構建產物一併上傳到遠程 git 倉庫,這樣在打包機上只需要構建鏡像並運行即可。
整個部署的靈感來自 GitLab 的官方倉庫的一個 issue: GitLab hosted Codesandbox
訂製 CodeSandbox 功能
上個小節讀者可能會有個疑問,為什麼直接使用 CodeSandbox 提供的默認構建服務?其實就是為了對 CodeSandbox 的構建流程進行訂製,接下來舉四個例子來說明下。
替換組件樣式自動引入的 babel 插件功能
針對公司自建的組件庫,一般都會開發類似 babel-plugin-import 這樣的插件,以便在程式碼中使用組件時無需額外再引入組件的樣式文件,babel-plugin-import 插件會在 js 編譯階段自動插入引入樣式的程式碼。但這種插件可能會需要遍歷組件的 package.json 中的依賴中是否有其他組件,如果有也要把其他組件的樣式文件的引入寫到編譯後的 js 中,並遞歸執行剛才的過程。這裡就需要讀入 node_modules 中的相關文件。但是諸如 CodeSandbox、Stackblitz 等都是在瀏覽器中進行構建,並沒有 node_modules。
針對這個問題,筆者最終放棄了利用 babel 插件在 js 編譯階段進行插入引入樣式文件程式碼的方式,而是在程式碼運行階段從 npm 在線打包服務中獲取組件的樣式文件,然後將樣式文件內容通過 style 標籤動態插入到 head 標籤上面。下面是具體改動:
在線 npm 打包服務側
在線 npm 打包服務一般只會返回 js 文件,所以需要在該服務基礎上增加一個功能:當判斷請求的 npm 包為內建組件,則還要額外返回樣式文件。下面是 dependence-packager 項目中添加的核心程式碼:
為了提供獲取私有組件樣式文件的方法,可以在 functions/packager/utils 目錄下新建一個文件 fetch-builtin-component-style.ts
,核心程式碼如下:
// 根據組件 npm 包名以及通過 yarn 下載到磁碟上的 npm 包路徑,讀入對應的樣式文件內容,並寫入到 manifest.json 的 contents 對象上 const insertStyle = (contents: any, packageName: string, packagePath: string) => { const stylePath = `node_modules/${packageName}/dist/index.css`; const styleFilePath = join( packagePath, `node_modules/${packageName}/dist/index.css` , ); if (fs.existsSync(styleFilePath)) { contents[stylePath] = { contents: fs.readFileSync(styleFilePath, "utf-8"), isModule: false, }; } }; // 獲取內建組件的樣式文件,並寫入到返回給 Sandbox 的 manifest.json 文件中 const fetchBuiltinComponentStyle = ( contents: any, packageName: string, packagePath: string, dependencyDependencies: any, ) => { // 當 npm 包或者其依賴以及依賴的依賴中有內建組件,則將該內建組件對應的樣式文件寫入到 manifest.json 文件中 if (isBuiltinComponent(packageName)) { insertStyle(contents, packageName, packagePath); Object.keys(dependencyDependencies.dependencyDependencies).forEach( (pkgName) => { if (isBuiltinComponent(pkgName)) { insertStyle(contents, pkgName, packagePath); } }, ); } };
並在 functions/packager/index.ts 文件中調用該方法。程式碼如下:
+ // 針對私有組件,將組件樣式文件也寫到返回給瀏覽器的 manifest.json 文件中 + fetchBuiltinComponentStyle( + contents, + dependency.name, + packagePath, + dependencyDependencies, + ); // 作為結果返回 const response = { contents, dependency, ...dependencyDependencies, };
瀏覽器 CodeSandbox 側
瀏覽器 CodeSandbox 側需要提供處理私有組件樣式的方法,主要是在 Evaluation 執行階段將樣式文件內容通過 style 標籤動態插入到 head 標籤上面,可以在 packages/app/src/sandbox/eval/utils 目錄下新建一個文件 insert-builtin-component-style.ts
,下面是核心程式碼:
// 基於樣式文件內容創建 style 標籤,並插入到 head 標籤上 const insertStyleNode = (content: string) => { const styleNode = document.createElement('style'); styleNode.type = 'text/css'; styleNode.innerHTML = content; document.head.appendChild(styleNode); } const insertBuiltinComponentStyle = (manifest: any) => { const {contents, dependencies, dependencyDependencies} = manifest; // 從依賴以及依賴的依賴中根據 npm 包名篩選出內建組件 const builtinComponents = Object.keys(dependencyDependencies).filter(pkgName => isBuiltinComponent(pkgName)); dependencies.map((d: any) => { if (isBuiltinComponent(d.name)) { builtinComponents.push(d.name); } }); // 根據基於內建組件 npm 名稱拼裝成的 key 查找到具體的文件內容,並調用 insertStyleNode 方法插入到 head 標籤上 builtinComponents.forEach(name => { const { content } = contents[ `/node_modules/${name}/dist/index.css` ]; if (content) { insertStyleNode(content); } }); }
並在 Evaluation 執行階段調用該方法,相關文件在 packages/sandpack-core/src/manager.ts ,具體修改如下:
添加預覽區域截圖功能
在區塊復用平台項目中,在點擊保存按鈕時,不僅要保存編輯好的程式碼,還需要對構建好的右側預覽區域進行截圖並保存。如下圖所示:
右側預覽區域所展示的內容是 SandpackProvider 組件插入的 iframe,所以只需要找到這個 iframe,然後通過 postMessage 與 iframe 內頁面進行通訊。當 iframe 內部頁面接收到截圖指令後,對當前 dom 進行截圖並傳出即可,這裡筆者用的是 html2canvas 進行截圖的。下面是 CodeSandbox 側的程式碼改造,文件在 packages/app/src/sandbox/index.js 中,主要是在文件結尾處添加如下程式碼:
const fetchScreenShot = async () => { const app = document.querySelector('#root'); const c = await html2canvas(app); const imgData = c.toDataURL('image/png'); window.parent.postMessage({ type: 'SCREENSHOT_DATA', payload: { imgData } }, '*'); }; const receiveMessageFromIndex = (event) => { const { type } = event.data; switch (type) { case 'FETCH_SCREENSHOT': fetchScreenShot(); break; default: break; } }; window.addEventListener('message', receiveMessageFromIndex, false);
在 CodeSandbox 使用側,則需要在需要截圖的時候,向 iframe 發送截圖指令。同時也需要監聽 iframe 發來的消息,從中篩選出返回截圖數據的指令,並獲取到截圖數據。由於實現比較簡單,這裡就不展示具體程式碼了。
create-react-app 模板中添加對 less 文件編譯的支援
主要是對 create-react-app 這個 preset 的配置做一些修改,文件地址 packages/app/src/sandbox/eval/presets/create-react-app/v1.ts。修改程式碼如下:
... + import lessTranspiler from '../../transpilers/less'; + import styleProcessor from '../../transpilers/postcss'; export default function initialize() { ... + preset.registerTranspiler(module => /\.less$/.test(module.path), [ + { transpiler: lessTranspiler }, + { transpiler: styleProcessor }, + { + transpiler: stylesTranspiler, + options: { hmrEnabled: true }, + }, + ]); ... }
修改 CodeSandbox 請求的 npm 打包服務地址
可以將打包 npm 的服務換成上面私有化部署的服務,以解決無法獲取私有 npm 包等問題。相關文件在 packages/sandpack-core/src/npm/preloaded/fetch-dependencies.ts 。修改程式碼如下:
const PROD_URLS = { ... // 替換成自己的在線 npm 打包服務即可 - bucket: '//prod-packager-packages.codesandbox.io', + bucket: '//xxx.xxx.com' }; ... function dependencyToPackagePath(name: string, version: string) { - return `v${VERSION}/packages/${name}/${version}.json` ; + return `${name}@${version}` ; }
這四個例子就講完了,讀者可以根據自己的需求進行更多的訂製。當你明白了整個 CodeSandbox 的運行機制後,就會發現訂製並沒有那麼難。
結束語
到此為止,私有化部署一個屬於自己並且可以任意訂製的在線 IDE 的目標就已經達成了。當然在線 IDE 的項目構建不僅僅局限在瀏覽器中,還可以將整個構建過程放在服務端,藉助於雲+容器化的能力,使得在線 IDE 有著跟本地 IDE 幾乎完全一樣的功能。其實這兩者應用的場景不多,完全基於瀏覽器構建更適用於單一頁面項目的實時預覽,而基於服務端構建是完全可以適用於真實的項目開發的,並且不僅僅局限於前端項目。筆者也在嘗試探索基於服務端構建 IDE 的可能性,期待後面能夠有些產出分享給大家。
END
為了更高的交流,歡迎大家關注我的公眾號,掃描下面二維碼即可關注,謝謝:
作者:網易雲音樂大前端團隊
鏈接://juejin.im/post/6882541950205952013
來源:掘金