探索組件在線預(yù)覽和調(diào)試

背景
前端人員在開(kāi)發(fā)過(guò)程中,如何快速感知到組件的功能和屬性?現(xiàn)狀是通過(guò)閱讀組件相關(guān)文檔,好在基礎(chǔ)組件庫(kù)的文檔相對(duì)完整和清晰,手動(dòng)補(bǔ)全示例。業(yè)務(wù)組件相關(guān)文檔目前只能在內(nèi)部 NPM 私庫(kù)上查看,靜態(tài)的 API 文檔,沒(méi)有組件的 Demo。對(duì)于非前端人員,如何預(yù)覽和調(diào)試組件呢?比如:某一天,產(chǎn)品想提前調(diào)研其它業(yè)務(wù)線的業(yè)務(wù)組件功能能否滿足業(yè)務(wù)訴求;業(yè)務(wù)組件開(kāi)發(fā)完成,測(cè)試和設(shè)計(jì)可以介入組件相關(guān)功能的驗(yàn)證;運(yùn)營(yíng)人員可以在低代碼搭建平臺(tái),預(yù)覽和調(diào)試相關(guān)組件等。

基于以上痛點(diǎn)問(wèn)題,我們從需求點(diǎn)出發(fā),逐步探索實(shí)現(xiàn)方案。

需求
場(chǎng)景分析
功能
組件預(yù)覽
組件調(diào)試 面向不同的用戶群體,組件功能調(diào)試的交互分為兩種,一種是代碼調(diào)試,即通過(guò)代碼編輯器修改示例代碼,另一種是組件 schema 調(diào)試,通過(guò) schema  JSON 數(shù)據(jù)來(lái)描述組件的屬性,然后 通過(guò) schema 渲染器渲染成組件屬性面板,這樣非研發(fā)人員也可以方便的調(diào)試組件功能。
分類(lèi)
基礎(chǔ)組件
業(yè)務(wù)組件
低代碼組件 大致整理了下:

這里的低代碼組件是指提供給低代碼搭建平臺(tái)使用的自定義組件,目前公司的低代碼搭建平臺(tái)主要有“魯班”,對(duì)此感興趣的小伙伴可以翻一下往期關(guān)于“魯班”的文章。

針對(duì)組件 schema 調(diào)試,低代碼組件本身自帶 schema 文件,如:“魯班”自定義組件會(huì)有一份 schema.json 文件,需要開(kāi)發(fā)者去編寫(xiě)和維護(hù)這份文件。

如:

{
  "props": {
    "linkList": {
      "group": "鏈接配置",
      "title": "鏈接列表",
      "type": "array",
      "fields": [
        {
          "name": "imageAddress",
          "title": "圖鏈接圖片地址",
          "type": "string"
        },
        {
          "name": "imageLink",
          "title": "鏈接跳轉(zhuǎn)地址",
          "type": "string"
        }   
      ]
    }
  },
  "models": {
    "linkList": [
      {
        "imageAddress": "",
        "imageLink": ""
      },
      {
        "imageAddress": "",
        "imageLink": ""
       }
    ]
  }   
}

同樣,業(yè)務(wù)組件也需要同一份 schema 協(xié)議的 JSON 文件,這樣就可以動(dòng)態(tài)調(diào)試組件的屬性。但是,不會(huì)讓開(kāi)發(fā)組件的同學(xué)去手動(dòng)編寫(xiě)。

自動(dòng)生成 schema 文件大致思路:


應(yīng)用
基礎(chǔ)組件的示例在線預(yù)覽和調(diào)試
業(yè)務(wù)組件的 Demo 在線預(yù)覽和調(diào)試
面向人群
研發(fā)
非研發(fā):產(chǎn)品、測(cè)試、運(yùn)營(yíng) 研發(fā)主要用到組件的調(diào)試功能,而像運(yùn)營(yíng)和產(chǎn)品這樣非研發(fā)人員,他們的訴求簡(jiǎn)單快捷,就是直接預(yù)覽該組件,并且可以通過(guò)修改組件的 props 看到實(shí)時(shí)效果,那么問(wèn)題來(lái)了,如何修改組件當(dāng)前的 props 屬性?玩過(guò)低代碼的同學(xué)應(yīng)該很清楚,有個(gè)組件屬性面板?;谝陨?,我們可能需要代碼編輯面板、組件屬性面板以及組件功能模塊。
大致畫(huà)了下頁(yè)面的結(jié)構(gòu)圖:


調(diào)研
市面上成熟的產(chǎn)品
Stackblitz 一款非常優(yōu)秀的在線 IDE,移植了很多 VS Code 的功能和特性。目前支持了很多框架模版,如:React、Angular、Vue3、Next.js、Nuxt3 及自定義模版等,其中, StackBlitz 提供的 WebContainers 可以在瀏覽器端運(yùn)行 Node.js 環(huán)境。

CodeSandbox 為 Web 應(yīng)用程序而開(kāi)發(fā)而構(gòu)建的在線編輯器,同樣也提供了多種模版方便開(kāi)發(fā)者使用。大部分核心代碼也開(kāi)源了,網(wǎng)上也有相關(guān)的原理解析和搭建在線 IDE 方案的資料,有興趣的同學(xué)可以去看看。






小結(jié)
需求和應(yīng)用場(chǎng)景已經(jīng)很明確了,考慮到不同的用戶群體,交互方式也有差別,重點(diǎn)是組件調(diào)試功能的差異性,對(duì)于研發(fā)人員可通過(guò)代碼編輯器去修改代碼達(dá)到調(diào)試效果,非研發(fā)人員則通過(guò)修改屬性面板的組件屬性值。而市面上的成熟產(chǎn)品會(huì)提供一些設(shè)計(jì)思路,具體實(shí)現(xiàn)方案下面會(huì)細(xì)講。

方案
從頁(yè)面結(jié)構(gòu)圖,我們先聊下代碼編輯器、組件屬性面板、工具欄、預(yù)覽區(qū)的設(shè)計(jì)方案。

代碼編輯器
目前主流的有兩種:

MonacoEditor
Codemirror MonacoEditor 相對(duì)來(lái)說(shuō)功能強(qiáng)大,集成度高,但隨之帶來(lái)的是比較重,而 Codemirror 輕量小巧,核心文件壓縮后僅 70+ KB 左右,根據(jù)所需要支持的語(yǔ)言按需打包。
兩種代碼編輯器都能滿足我們的需求,在線修改一些組件 Demo 的部分代碼,其實(shí) Codemirror 夠用了。

組件屬性面板
了解低代碼搭建平臺(tái)的朋友應(yīng)該很熟悉了,其實(shí)就是通過(guò)表單去動(dòng)態(tài)修改組件的屬性參數(shù),因此,需要一份通用的 schema 協(xié)議,來(lái)描述組件的自定義屬性??梢杂婶敯嗪痛髷?shù)據(jù)搭建平臺(tái)那邊提供 schema 數(shù)據(jù),我們負(fù)責(zé)渲染即可。

大致列了下組件屬性的類(lèi)型和操作表單類(lèi)型的對(duì)應(yīng)關(guān)系:


工具欄
工具欄包含的主要功能有:

賬號(hào)登陸
接口代理 業(yè)務(wù)組件和低代碼組件需要被調(diào)試時(shí),比如測(cè)試人員需要介入測(cè)試組件功能,需要用到賬號(hào)登陸和接口代理功能。組件內(nèi)涉及到業(yè)務(wù)接口的請(qǐng)求頭需要攜帶當(dāng)前登陸用戶的 token 信息,先通過(guò)請(qǐng)求 oauth 接口拿到對(duì)應(yīng)的 token,然后塞到請(qǐng)求頭的 Authorization 字段上。
上面實(shí)現(xiàn)的前提是需要一個(gè)代理服務(wù),在本地開(kāi)發(fā)環(huán)境我們可以用 http-proxy 插件創(chuàng)建本地代理服務(wù),那么問(wèn)題來(lái)了,在瀏覽器端如何做代理服務(wù)?

目前主流的方案都是通過(guò) Chrome 插件形式,需要用戶手動(dòng)填寫(xiě)代理接口等信息。在我們的場(chǎng)景下,這個(gè)方案對(duì)用戶體驗(yàn)顯然不夠友好。還有個(gè)方案可以利用瀏覽器的黑科技 —— Service Worker,它可以攔截網(wǎng)頁(yè)發(fā)出的請(qǐng)求,并能自定義返回內(nèi)容,相當(dāng)于在瀏覽器內(nèi)部實(shí)現(xiàn)了一個(gè)反向代理。

預(yù)覽區(qū)
核心會(huì)涉及到兩點(diǎn):

容器
通信 容器是指頁(yè)面容器,業(yè)界通用做法都是通過(guò) iframe,將編譯好的組件代碼掛載到 iframe 里一個(gè) root 節(jié)點(diǎn)上,主要有環(huán)境隔離和動(dòng)態(tài)生成預(yù)覽頁(yè)面的訪問(wèn)鏈接作用。編輯器、核心包、預(yù)覽區(qū)之間的通信可以用 postMessage。
通信時(shí)序圖:


核心包
設(shè)計(jì)思路,主要參考了 CodeSandbox 的核心源碼,主要涉及到代碼轉(zhuǎn)譯和代碼執(zhí)行。核心模塊有 Manger、Transpiler、Preset、Transpiled-module、Runtime。

架構(gòu)圖:


大致流程:








Manger 模塊
顧名思義“管理者“,即管理其它核心模塊,主要負(fù)責(zé)代碼轉(zhuǎn)譯和執(zhí)行的一系列過(guò)程。

核心方法有:

addTranspiledModule
resolveTranspiledModuleSync
resolveTranspiledModuleAsync
evaluateTranspiledModule首先將轉(zhuǎn)譯后的模塊緩存起來(lái)放到 transpiledModules 對(duì)象 ,需要的話可以從緩存里同步或異步加載轉(zhuǎn)譯后的模塊,如果需要執(zhí)行轉(zhuǎn)譯的模塊,可以調(diào)用  evaluateTranspiledModule  方法。
transpiledModules 的類(lèi)型定義:

type IModule = {
  path: string;
  url?: any;
  code: string;
  requires?: Array<string>;
  parent?: Module;
};

interface ITranspiledModules {
    [path: string]: {
      module: IModule;
      tModules: {
        [query: string]: ITranspiledModule; // ITranspiledModule 類(lèi)型定義放在 Transpiled-module 模塊
      };
    };
  }
Transpiler 模塊
類(lèi)比 Webpack 的 loader,對(duì)指定類(lèi)型的文件進(jìn)行編譯,如:Babel、Typescript、vue、tsx、jsx 等。

介紹下部分內(nèi)置的 Transpiler 模塊:

babelTranspiler
stylesTranspiler
rawTranspiler
noopTranspiler
vueTranspilerrawTranspiler 跟 Webpack 的 raw-loader 作用一樣,將模塊的內(nèi)容作為字符串導(dǎo)入,從而實(shí)現(xiàn)靜態(tài)資源內(nèi)聯(lián)。
實(shí)現(xiàn)原理也很簡(jiǎn)單:

module.exports = JSON.stringify(sourceCode)
babelTranspiler 這里實(shí)現(xiàn)了簡(jiǎn)化版,script 標(biāo)簽引入 bable-standalone.js,拿到全局對(duì)象 Babel。

部分核心代碼:

import babelPluginRenameImports from './plugins/babel-plugin-rename-imports';

const transpiledCode = window.Babel.transform(code, {
  plugins: [babelPluginRenameImports],
  presets: ['es2015', 'es2016', 'es2017'],
}).code;
vueTranspiler ,這里默認(rèn)是 vue2.0 版本,核心依賴了 vue-template-compiler、vue-template-es2015-compiler。

將 vue 單文件組件轉(zhuǎn)換為 SFC 對(duì)象:

import * as compiler from 'vue-template-compiler';
import type {SFCDescriptor} from 'vue-template-compiler';

const sfc:SFCDescriptor = compiler.parseComponent(content, { pad: 'line' });

解析 Vue template 部分核心代碼:

import * as compiler from 'vue-template-compiler';
import transpile from 'vue-template-es2015-compiler';   
 
function vueTemplateCompiler(html, options) {
  const bubleOptions = options.buble;
  const vueOptions = options.vueOptions || {};
  const userModules = vueOptions.compilerModules || options.compilerModules;
  const stripWith = bubleOptions.transforms.stripWith !== false;
  const { stripWithFunctional } = bubleOptions.transforms;
  const staticRenderFns = compiled.staticRenderFns.map((fn) =>
     toFunction(fn, stripWithFunctional)
 ); // 靜態(tài)渲染函數(shù)放到數(shù)組中
  const compilerOptions: compiler.CompilerOptionsWithSourceRange = {
    preserveWhitespace: options.preserveWhitespace, // 是否保留 HTML 標(biāo)記之間的所有空白字符
    modules: defaultModules.concat(userModules || []), // 自定義編譯模版
    directives: vueOptions.compilerDirectives || options.compilerDirectives || {}, // 自定義指令
    comments: options.hasComment, // 是否保留注釋
    scopeId: options.hasScoped ? options.id : null, /
  };
 const compiled = compiler.compile(html, compilerOptions);
 
  // 生成渲染函數(shù)和靜態(tài)子樹(shù)
 let code = transpile(
    'var render = ' +
     toFunction(compiled.render, stripWithFunctional) +
    '\n' +
    'var staticRenderFns = [' +
    staticRenderFns.join(',') +
     ']') + '\n';
    // mark with stripped (this enables Vue to use correct runtime proxy detection)
    if (stripWith) {
      code += `render._withStripped = true\n`;
    }

    const exports = `{ render: render, staticRenderFns: staticRenderFns }`;
    code += `module.exports = ${exports}`;
 
   return code;
}

function toFunction(code, stripWithFunctional) {
  return 'function (' + (stripWithFunctional ? '_h,_vm' : '') + ') {' + code + '}';
}

Vue 在渲染階段將模板編譯為 AST,然后根據(jù) AST 生成 render 函數(shù),底層通過(guò)調(diào)用 render 函數(shù)會(huì)生成 VNode 創(chuàng)建虛擬 DOM。

Preset 模塊
組件預(yù)設(shè)構(gòu)建模版,針對(duì)不同組件的框架類(lèi)型,如:Vue2、React 等,預(yù)設(shè)默認(rèn)該類(lèi)型組件所需的 Transpiler 模塊。類(lèi)似于 vue-cli、create-react-app。

核心方法:

registerTranspiler
getTranspilersregisterTranspiler 作用是注冊(cè) Transpiler 模塊。
部分偽代碼:

vuePreset.registerTranspiler(
  (module) => /\.(m|c)?jsx?$/.test(module.path),
  [{ transpiler: babelTranspiler }]
);
vuePreset.registerTranspiler(
  (module) => /\.vue$/.test(module.path),
  [{ transpiler: vueTranspiler }]
);
Transpiled-module 模塊
即轉(zhuǎn)譯后的模塊,維護(hù)轉(zhuǎn)譯的結(jié)果、代碼執(zhí)行的結(jié)果、依賴的模塊信息,負(fù)責(zé)驅(qū)動(dòng)具體模塊的轉(zhuǎn)譯(調(diào)用 Transpiler)和執(zhí)行。

Runtime 模塊
執(zhí)行轉(zhuǎn)譯后的模塊入口,使用 eval 執(zhí)行入口文件,若遇到 require 函數(shù),加載轉(zhuǎn)譯后的依賴模塊然后使用 eval 執(zhí)行執(zhí)行。

核心代碼:

export default function (
  code: string,
  require: Function,
  module: { exports: any },
  env: Object = {},
  globals: Object = {},
  { asUMD = false }: { asUMD?: boolean } = {}
) {
  const { exports } = module;
 
  const g = typeof window === 'undefined' ? self : window;
  const global = g;
  g.global = global;
 
  // 兼容 Node.js 環(huán)境,列舉了一部分
  const process = {
    env: { NODE_ENV: 'development', ...env },
    cwd: () => { return '/' },
    umask: () => { return 0 }
  };
 
  // 全局變量
  const allGlobals: { [key: string]: any } = {
    require, // require 函數(shù)
    module,
    exports,
    process,
    global,
    ...globals,
  };

  // 是否 UMD 模塊
  if (asUMD) {
    delete allGlobals.module;
    delete allGlobals.exports;
    delete allGlobals.global;
  }
 
  const allGlobalKeys = Object.keys(allGlobals);
  const globalsCode = allGlobalKeys.length ? allGlobalKeys.join(', ') : '';
  const globalsValues = allGlobalKeys.map((k) => allGlobals[k]);
 
  const newCode = `(function $csb$eval(` + globalsCode + `) {` + code + `\\n})`;
 (0, eval)(newCode).apply(allGlobals.global, globalsValues);

}

小結(jié)
從頁(yè)面功能模塊到組件的構(gòu)建核心包設(shè)計(jì),相信各位看官已經(jīng)有了初步的了解。有兩點(diǎn)沒(méi)有提到,在這里簡(jiǎn)單補(bǔ)充下。

第一點(diǎn)是依賴包的數(shù)據(jù)源問(wèn)題,簡(jiǎn)單粗暴點(diǎn)就是創(chuàng)建 manifest 文件,事先預(yù)存一份底層通用的依賴包數(shù)據(jù),如:Babel 插件相關(guān)等,如果需要?jiǎng)討B(tài)添加依賴包,可以使用 import-maps 特性。

第二點(diǎn)在 Transpiler 模塊沒(méi)有提到針對(duì) react 組件的構(gòu)建方案,添加相關(guān) Babel 插件就好了,如:transform-runtime 、@babel/plugin-transform-react-jsx-source 等。

最后
背景、需求、調(diào)研、方案這四個(gè)層面,其中背景和需求更多是從產(chǎn)品的角度去思考和設(shè)計(jì),這樣做出來(lái)的東西才更符合用戶需求和提升用戶體驗(yàn)。我們技術(shù)人員不僅僅只關(guān)心技術(shù)層面的設(shè)計(jì),更多時(shí)候還要從產(chǎn)品的角度去思考。

組件作為項(xiàng)目開(kāi)發(fā)不可分割的一部分,從基礎(chǔ)組件到業(yè)務(wù)組件,我們前端開(kāi)發(fā)人員每天都在跟組件打交道。圍繞著組件我們可以有很多專(zhuān)題,如何打造高質(zhì)量組件?如何提升組件的復(fù)用率?如何提升組件的感知度?等等,貫穿組件的整個(gè)生命周期,那么如何治理好組件,需要我們共同努力和思考。

參考資料
CodeSandbox 核心源碼:https://github.com/codesandbox/codesandbox-client/tree/master/packages/sandpack-core

CodeSandbox 瀏覽器端的 webpack 是如何工作的?:https://www.yuque.com/wangxiangzhong/aob8up/nb1gp2










作者:梓安


歡迎關(guān)注微信公眾號(hào) :政采云前端團(tuán)隊(duì)