WebIde 如何管理複雜前端代碼--gitchat 實錄

WebIde 如何管理複雜前端代碼--gitchat 實錄

來自專欄前端架構屋4 人贊了文章

這篇文章是去年12月我的一場 gitchat ,當日有 172 人參與問答。這篇文章記載了我在去年 Coding 工作期間做 WebIDE 架構時的一些經驗,當時因為工作交接期命名為「如何優雅的管理前端代碼」, 現在還是還原原題。而 WebIDE 已更名成為 CloudStudio 產品。之所以現在才正式對外,一個是希望對 chat 付費用戶負責,一個也是希望覺得此文應該屬於比較好的自己作品整理。

      • 1.0 選題背景
        • 1.1 何為複雜
        • 1.2 現有的一些方式概述
      • 2.0 分離代碼技術
        • 2.1 編譯前
        • 2.2 預編譯
        • 2.3 編譯時
        • 2.4 部署時
        • 2.5 運行時
      • 3.0 運行時插件體系
        • 3.1 插件組織簡單介紹
        • 3.2 插件下載機制
        • 3.3 插件生命周期管理
        • 3.4 插件裝載機制
        • 3.5 插件 volumn 中心
        • 3.6 插件與主項目通信
        • 3.7 可插拔組件
        • 3.8 樣式與主題
        • 3.9 插件開發
      • 4.0 綜合運用
        • 4.1 代碼可見性
        • 4.2 千人千面需求
        • 4.3 內部較確定的兩個版本
        • 4.4 私有部署
      • 5.0 前端運行時微服務展望

1.0 選題背景

現代的企業級前端開發隨著業務與面向群體不同,遇到越來越多 「複雜」 情況。代碼隨著功能模塊不斷增加,面向群體的需求不同,開發模式,開發技術棧的多樣化而越來越零散,如何組織這些複雜的前端代碼成為一個非常棘手的問題。這次的 chat 和大家分享一下我對這塊的理解和在 WebIDE 項目中的一些實踐,著重介紹運行時插件體系的方案。項目:github.com/Coding/WebID

1.1 何為複雜

如題目,首先要解釋一下題目中的 「複雜」 兩個字。從業務流水線的大方向上來分類,我們一個項目一般會有開源版,平台收費版,企業版, 私有版...。他們之間的關係基本上是,開源版包含最基礎的一些功能,比如我們 ide 里的編輯功能,文件樹等等,這些功能無論是後面什麼版都需要;平台版則根據用戶等級的不同拓展出更多集成的功能,企業版則在平台版基礎上額外提供一些面向企業的功能,比如可以管理用戶,管理公司等附加功能;私有版則是項目賣給第三方做私有部署時,會根據客戶需求再定製一些特別的需求。這些分類每個也都會有自己的版本,自己的繼承關係。比如大家都會繼承開源版。根據每個公司不同的策略,這些版本會有所不同。

從用戶角度項目又會有更多定製可能,比如對於某一用戶組,都會載入這些某一特殊的功能,或者用戶自己也可以從市場安裝第三方插件,在運行時載入。此時對於這個用戶而言功能也被拓展了,並且不同的用戶完全會獲得不同的功能和頁面,這些功能在開發基礎版本的時候完全是不可預知的,並且這些插件也會有自己的版本以及自己對其他插件的依賴。從部署的角度,不同的部署 target,功能也會有不同,比如在國內部署和國外部署就會有功能模塊的差異,部署到移動端 hybird 容器里也會和web的部署會有差異。從開發架構上說,功能模塊也可能是不同團隊開發的,所用的前端技術棧,環境與依賴也不同。從代碼基的角度上說,不同版本的代碼可見性要求是不同的,比如開源版代碼基就不應該存在收費版的任何邏輯。從測試角度,不同的模塊應該更便於單元測試、集成測試。以上說的需求其實大家應該也都有遇到,也都有自己的實踐方式。那麼這些複雜的背後,蘊涵著什麼規律,我們如何既考慮可拓展性,健壯性又用更易於開發的方式優雅的管理它呢?

1.2 現有的一些方式概述

上一節所提到的 「複雜」 需求,其實在很多團隊早已有自己的方案。比如有的團隊是每個版本形成一套代碼基。在部署的時候通過不同的環境變數選擇編譯的項目。 比如有的團隊用 git submodule 方式在編譯的時候尋找最適配的 module。比如有的則在代碼基里用一些規則標記不同的版本,然後在編譯的時候去做選擇。有的則用npm包的方式,在編譯的時候載入安裝。有的則在代碼里加各種邏輯開關,然後在編譯或運行的時候通過環境變數來選擇… 其實大家的方法都不錯,能解決團隊自己的需求,合適就是好方法。但是這些方案有自己的優缺點,可能解決了某一個問題,但是不能解決更多定製需求。而我今天要對這些方案做一個歸納總結,並且提供一個實踐下來能相對比較好的應對上述的「複雜」的方案。

2.0 分離代碼技術

我們根據 CI/CD 以及運行時這幾個階段,結合經驗講解一下可能存在於各個階段的代碼分離技術,以及他們的適用場景

2.1 編譯前

編譯前為項目 repo 的組織方式。

2.1.1 fork + .gitignore

編譯前完成。fork 代碼是一種簡單粗暴的方式,整個發布就是一個完整新的版本。搭配上 gitignore 可以把某個 repo 不想出現的代碼隱藏。在編譯前完成,優點自然是簡單粗暴,fork 完主項目改一下特異的部分立馬發布,尤其針對外包和一些私有部署很有效。缺點當然是可維護性非常差,每次更新需要同步代碼並打上 patch,極其不靈活且沒有依賴和繼承體系,需要人工維護整套代碼。我的實踐中也只有一次性作為 prototype 演示使用的私有部署這樣使用過。

2.1.2 git submodule

編譯前完成。利用 git 的公共代碼庫管理方法在項目的 .gitsubmodule 中申明子模塊的 url,在使用的時候拉下來。好處是復用了主幹代碼系統,submodule 是獨立的 git 項目可以有自己的版本管理。缺點也很明顯,只能做粗略的文件系統作為分離點的分割,對更深入細緻的分割無能為力。實踐下來在整個功能系統組織上使用是很不錯的,比如項目有前端,有後端,移動端...分別是submodule 嵌入一個抽象總項目中。前端里又可以放幾個無關的大模塊。

2.1.3 git lfs

編譯前完成。git lfs 是應對項目中有一些文件較大的 asserts,如圖片等信息。一般不放入 git 倉庫,而關聯在 lfs 上。多個項目可以公用素材

2.2 預編譯

預編譯為產生了 git 指令後到正式編譯的階段,在前端開發中主要可能涉及 git hooks,npm install 這兩個過程。

2.2.1 git hooks

預編譯時完成。git hooks 申明的處理者可能是 CI 系統或者本地的 git 鉤子。它們會收到 git 的事件後響應一些定義好的預先處理腳本。這個時間點可能要做的事情有很多,包括 notification,拉代碼等等。從代碼分離編譯的角度,可以根據不同提交者的身份執行不同的編譯指令,在此時往代碼庫里塞一個配置文件,後面編譯時會用到。它的信息提供者為 git 相關的信息,例如提交人,提交更改的文件名,文件內容。這個對於幾個項目合併在一個 repo 的場景很有用,我改動哪塊編譯哪塊,或者我沒有許可權改動則直接彈回。

2.2.2 npm package

預編譯時完成。前端社區更傾向與 npm 依賴的方式管理項目,因為他的可拆代碼力度更細,可以深入到文件級,並且每個組件可以版本化。在 package.json 中申明此次編譯需要用到的依賴,並且在編譯時完成鏈接。例如 uikit, 一些自己的功能組件我們也許丟 npm,也可以丟代碼倉庫 用ssh方式鏈進來。這個方案在我們的實踐中運用也是特別多,一些基礎工具性的基礎設施代碼,中間件更傾向於這種方式依賴進來。缺點則是,可拆力度只能深入到文件級,還無法進入代碼級,並且編譯時和部署時和運行時無法再干預它執行不同的邏輯,所以只對底層工具型依賴更為合適。

"webide-plugin-sdk": "https://github.com/Coding/WebIDE-Plugin-SDK.git#1.1.3.alpha.05"

2.3 編譯時

編譯時則為從代碼拉下到打包成發布交付物的過程

2.3.1 編譯指令選擇

編譯時完成。編譯指令可以根據編譯申請方的環境變數選擇不同的編譯指令,達到一個項目不同的編譯結果目標。一般在不同部署模式場景下應用更多,例如開發模式、staging、production模式。

NODE_ENV=xxx yarn build

2.3.2 webpack loader 攔截需要載入的文件

編譯時完成。在 React Native 實踐中我們知道可以通過文件名的格式來告訴編譯器載入哪個版本的文件。比如 list.android.js,list.ios.js。那對應 web端 則可以在 webpack 的 loader 層做一個更細顆粒度的攔截。我們可以寫個 loader 對 login.enterprise.js, login.js, login.platform.js。針對不同的編譯環境變數或者配置,他會 load 不同的文件以此達到分離。比較明顯的缺點則是,很難做到這些文件的隱藏,如果用 gitignore 來隱藏不能在某個倉庫出現的代碼文件,則會出現多個地方隱藏不同的排他文件。對於不需要做代碼倉庫隱藏的內部項目很不錯。

案例:

var Cache = { ENABLE: true, NOT_EXIST: NOT_EXIST, _cache: {}, get: function (path) { return this._cache[path]; }, set: function (path, cache) { return this._cache[path] = cache; }};/** * 根據 env 以及目錄下的 version.json 載入不同的版本: * * 如假設某目錄下有: * ├── list.js * ├── list.2.js * └── version.json * 如果沒有指定`DEMO_TEST`環境變數,則`require(list.js)`,會載入`version.json`裡面指定的版本2,既`list.2.js`文件 * * 如果指定了環境變數`DEMO_TEST=3`,則`version.json`指定的版本值會被覆蓋,既載入`list.3.js`文件 * * 如果指定的版本是1,則直接載入`list.js`文件 * * 如果目錄下沒有`version.json`文件,則直接載入`require(list.js)`指定的`list.js`文件 * */module.exports = function (content) { this.cacheable(); // 不處理node_modules里的文件 if (this.resourcePath.indexOf(node_modules) != -1) { return content; } // 讀取目錄下的 version.json 文件 var versionFile = path.join(this.context, version.json); var versionJson; if (Cache.ENABLE) { var c = Cache.get(versionFile); if (c === Cache.NOT_EXIST) { return content; } // 沒有cache過 if (c === undefined) { ... } versionJson = c; } else { ... } // 獲取版本號:env > version.json[filename][version]... // file.js => file.1.js var target = fileName.replace(/(.*).([^.]*)$/, $1. + version + .$2); var targetPath = path.join(this.context, target); // 沒有exp文件 if (!fs.existsSync(targetPath)) { return content; } // 標記目標文件為依賴,當文件修改時可以讓 cache 失效 this.addDependency(targetPath); var basePath = ... console.log(`[Version] using version ${version} of file: source: ${path.relative(basePath, this.resourcePath)} target: ${path.relative(basePath, targetPath)}`); targetPath = loaderUtils.stringifyRequest(this, targetPath); return module.exports = require( + targetPath + );};// ./module/xxx => xxxfunction getLastItem(context) { var paths = context.split(path.sep); return paths[paths.length - 1];}

2.4 部署時

前端的部署時主要是 cdn 上傳時的線路的選擇,cdn 文件過濾,nginx 配置等。在 node 後端部署時則為不同部署 target container 的啟動參數和環境。在這塊主要就是根據不同的環境變數和機器設置做一個選擇,本文不深入講解

2.5 運行時

運行時主要為用戶實際運行頁面的過程。WebIDE 在運行時做了整套插件系統,使得非常複雜的系統可拆解。

2.5.1 運行時開關

運行時完成,比較傳統的運行時方案。根據編譯時打包進來的變數,比如 webpack define plugin 定義的配置,在業務代碼做一些邏輯開關。或者根據 cookie 里定義的配置做一些開關。好處是深入代碼級,壞處是無法代碼隱藏,繁多的邏輯開關也很難保證代碼的質量和可讀性。當然有時候只有一兩行變動,加一兩個開關就解決需求,當然也是很棒的選擇。

if (__PLATFORM__ || getCookie(showList)) {do someing...}

2.5.2 運行時插件體系

這點是本次分享主要想和大家推薦的,本節不深入講解只簡單概括一下,在第三章會詳細分享。webpack 的非同步載入和後端微服務思想給了我們很多啟發,如果我們可以根據業務需求把不同的代碼邏輯拆離,由我們自己控制非同步裝載管理,是不是就能達到前端的微"服務"。以此思路,實現了前端的插件體系,目標是根據後端提供的每個 session 需要裝載的插件列表,用戶自定義的市場插件,在運行時動態裝載代碼。在不同的生命周期往主項目代碼注入點注入component,注入 router,注入邏輯等等。插件和插件可以共享變數,互相依賴,互相嵌套。並且主項目可以感知插件的運行狀態,並可以隨時 「重啟」,更新,安裝新插件。插件也應該具備更友好的開發打包方式,方便第三方開發,也方便自己團隊快速敏捷開發。好處是可以做到千人千面,每個人看到不同的功能,代碼可拆解,可拓展易快速重構。缺點則是傳輸性能,載入性能,瀏覽器兼容性問題,載入時機等需深入優化。

3.0 運行時插件體系

3.1 插件組織簡單介紹

每個插件包是一個獨立的前端項目,當然會包含自己的 package.json,只不過在裡面我們更需要申明插件相關的一些內容。比如:

"codingIdePackage": { "name": "platform", "displayName": "Platform Plugin", "description": "WebIDE Platform Integration for Coding WebIDE", "type": "integration" }

每個插件包有一個 index.js 作為入口,執行 sdk 的 appRegistry方法,往主項目 pluginRegister 註冊安裝子插件。比如:

appRegistry([{ key: platformInitialize, Manager: require(./initialize).default,},{ key: menuBarWidget, Manager: require(./menuBarWidget).default,}])

每個 manager 會在生命周期函數註冊邏輯,例如:

export default class extends Manager { init(initialize) { // 收費版不需要 getsettings initialize.delete(checkExist); // 在 getSpaceKey 後增加 platform 第三種狀態 initialize.insertAfter(getSpaceKey, { key: getSpaceKeyByContext, desc: get spaceKey by ownerName and projectName -- platform, enable: () => !config.spaceKey, func: () => console.log() }) pluginWillMount() { inject(position.SIDEBAR.BOTTOM, { key: TaskOutput, text: i18n`output.labelName`, icon: fa fa-binoculars, weight: 1, }, (extension) => { return React.createElement(extension.app); }); } }

如上一段代碼表達在初始化過程對開源版初始化邏輯做修改,並在掛載時往一個標記點注入 component。這些生命周期則由我們的 SDK 實現,比如這裡的manager,則會包含如 init ,pluginWillMount,, pluginWillUnmount,以及 customLisitener 等生命周期。分別向主項目的生命周期 eventEmitter 綁上回調。

3.2 插件下載機制

插件下載需要主項目感知插件列表,時間分為,首幀、過程中觸發等。首幀則在一開始伺服器渲染回的 task 中就包含預先要下載的插件組。

有些則再之後的幾個時機再下載,比如獲得項目類型後根據類型載入不同的插件,用戶購買市場上的插件等情況。

為了避免過多請求,可以選擇插件包集合下載,用 nginx 的 http-concat 模塊實現 group 的插件組訪問。

/??env/0.1.2-4-g65c5890/index.js,access-url/0.1.0-8-ged8d68c/index.js,temporary/0.0.1-beta.02/index.js 抓到的

3.3 插件生命周期管理

主項目可以感知插件生命運行周期相關的時機,把這些時間點拋出eventEmitter,插件項目則往上綁方法。比如 當我點擊插件中心的 disable ,我會執行插件綁定的 pluginWillUnmount 事件的方法,消除插件對主項目的影響。

3.4 插件裝載機制

主項目並不感知插件業務,當插件被下載同時也就立即 parse,插件則會聽主項目或者其他插件暴露出來的一些 observable 對象變化並執行插件業務邏輯。這裡主要用到的技術就是 mobx.when和autorun,利用這套技術會非常容易實現。

例如:

pluginWillMount() { mobx.when(() => config.isPlatform, () => { inject.... })}

3.5 插件 volumn 中心

每個插件會有自己的私有響應式存儲池,公共響應式存儲池,持久化存儲池。插件會在 appRegister 方法注入自己需要暴露給主項目的 observable 對象,供主項目或其他插件觀察。主項目也提供 registry 和 cache 緩存插件注入的對象,並使用 localforge 存儲持久化對象。

3.6 插件與主項目通信

這一節我們運用了兩個思想,一個是 SDK 方案,也是最傳統的第三方插件方案。插件利用主項目提供的一些 api 開發。我們一開始也採用這個方案,難點在業務路線都不是很清晰的情況下 api 的定義是非常艱難的,尤其在時間很緊的情況下幾乎不可能。另一種思想則是 sketch 插件的方案,把整個運行時直接暴露出來,開發插件如同本地開發, 並且可以暴露一些lib,插件打包則不需要再打進去。這裡運用到的技術為 webpack external。

例如,當在插件 sdk 的 webpack 中配置:

externals: [ (context, request, callback) => { if (request === react) request = lib/react if (/^app/.+/.test(request) || /^lib/.+/.test(request)) { const newRequest = request .replace(///g, .) .replace(/-(.)/g, (__, m) => m.toUpperCase()); return callback(null, `root ${newRequest}`); } callback(); }, generalExtenalAlias, ],

很明顯,接下來我可以的 require (react) 等同於 window.lib.react, 這樣 react 這類公用包不需要再被打包進插件,並且當我寫 require(app/components/listView) 等同獲取 window.app.components.listView, 只要主項目按照自己的目錄結構暴露出來這樣的對象。插件即可按相同的方式 import,開發體驗和主項目一致。 我們最後混用這兩個思想,在比較確定 api 的部分使用api,並隨著業務不斷確定,不斷優化api的設計,當然一些複雜功能直接就使用第二種方案了。

3.7 可插拔組件

對於組件響應式注入的實現,就是在主項目里埋響應式的點,監聽 pluginArea 不同位置的component變化,如果有變化則跟著變。為了和主項目保持一致,我們會有各種時機注入的組件。比如編譯時就注入的內部組件,優先順序最高。當然也有插件載入時動態注入組件,插件 disable 時拔出的組件。

const PluginArea = observer(({ position = , childProps = {}, children, getChildView, filter, ...others }) => { const pluginsArray = store.plugins.values().filter(plugin => plugin.position === position) const pluginComponents = pluginsArray .filter(filter || (() => true)) .sort((pluginA, pluginB) => (pluginA.label.weight || 0) < (pluginB.label.weight || 0) ? 1 : -1) .map((plugin) => { const view = store.views[plugin.viewId] return getChildView ? getChildView(plugin, view) : React[React.isValidElement(view) ? cloneElement : createElement](view, { key: plugin.viewId, ...childProps, }) }) // 允許提供children 必有不可插拔項 return ( <div {...others}> {getChildren(children).concat(pluginComponents)} </div> )})

3.8 樣式與主題

一個子插件可以小到只是一個樣式修改,樣式在主項目我們用的是stylus,主題則會有一套 ui-variables.styl。在主項目直接用 style loader 的運行時 use 方法選擇需要的 .styl 切換主題。那麼插件主題怎麼聯動呢。按照暴露運行時的做法很難實現,除非把 postCss 打到運行時。於是採用了 styled-components 的方案結合 stylus 實現。在主項目就直接用 stylus 的 :export 暴露到 window 主題變數觀察者對象,然後再插件項目用 styled components 實現。

const TopBarWrapper = styled.div` position: absolute; height: 28px; background-color: ${({ theme }) => theme.baseBackgroundColor}; border-bottom: 1px solid ${({ theme }) => theme.tabBorderColor}; color: ${({ theme }) => theme.textColor}; display: flex; align-items: center; justify-content: space-between; padding-left: 10px; width: 100%;`;export const TopBar = (props) => { return ( <TopBarWrapper> {props.children} </TopBarWrapper> );};export default withTheme(TopBar);

3.9 插件開發

插件開發需要提供類似 hotReload 支持,並且和主項目連調。我們使用 pm2 管理本地開發時插件的啟停。

在 sdk 中, 在 on complie change 時觸發一個 socket emit,到主項目 reload 插件。

app.use(webpackDevInstance);compiler.watch({}, (err) => { if (!err) { console.log(send reload command to frontend); io.emit(change, mappedPackage); }});

4.0 綜合運用

每個技術方案都不是完備的,在團隊尋求解的道路上我們需要回過頭來分析一開始介紹的 「複雜需求」 綜合運用上面的技術。

4.1 代碼可見性

代碼可見性這個分類標準其實是一個非常關鍵的點,比如 」開源版不能包含收費版的邏輯「,這條鐵律就只給我們三個選擇:1.在編譯前運用fork,submodule之類 2. 收費版拆成npm package 3.運行時動態載入收費版插件。IDE 此時選擇的是第三種方案。收費版會有許可權驗證,試用時間等邏輯,並且會有相應的顯示和後續功能區別。於是在下載收費版插件的時候,他會往界面注入顯示,並增加這些邏輯。

4.2 千人千面需求

這個需求則為每個用戶可能看見不同的功能,就像 Chrome,我可以安裝不同的插件以達到不同的功能。一開始我可能是一個基礎版,根據本地的配置,遠程的配置載入出更多功能,我們也肯定選擇運行時插件。

4.3 內部較確定的兩個版本

比如公司內部只有一個企業版和平台版,企業版在平台版的基礎上增加了admin功能。這時候的版本是非常確定的,並且不需要關心代碼可見性。我們選擇編譯時 loader 的方案搭配一些運行時的開關組合。這樣我可以寫 admin.enterprise.js 這樣供編譯時直接按需編譯,並在運行時共用的邏輯部分做一些開關。因為不太會有太多團隊來維護這個代碼基,有一些開關(可以寫成工廠模式)也是可以接受的。

4.4 私有部署

私有部署目前的方案主要是編譯前解決,有時候也做一些運行時的插件包更新。

5.0 前端運行時微服務展望

運行時插件體系是我實踐中常用的方案,目前能解決絕大部分需求。但還不是一個更為通用的標準,我設想如果主項目運行時如 docker 一樣提供一些運行時的基礎設施封裝,比如網路,存儲,載入等機制。一切皆動態服務化,前端的微服務和後端不同,是觀察與被觀察,事件注入與被注入的關係。這樣前端也有能力監管所有的子插件。

6.0 插件與離線包

像 IDE 這麼複雜的前端產品,我們會經常使用 feature toggle 。也就是對不同的人分發不同的插件版本包,插件緩注入體系天然就是前端離線包。我們可以從服務端控制每個成員要看到的離線插件內容,順序,版本,當主程序載入後,自然會用一個請求去 pull 最新的插件組,到本地拆包後緩存本地。在載入主程序 js 後就會一個一個 mount 合適的版本。對注入器我們做了三級緩存策略,這個等之後的文章細講啦

7.0 首屏加速與性能優化

首屏加速的方案基本上社區已經有很多了。目的就是為了 fmp(first meaningful point) 更漂亮些。不跑在 chrome 端 IDE 這種工具類重型應用下,這個 meaningful 並不意味著一上來就看到的 appshell (雖然已經那麼做了),因為那並沒有什麼用,而是編輯器作為交互第一優先順序(我們把不同的插件模塊,加上了載入策略),比如插件相對於框架的載入順序&離線緩存策略& volumn 注入時機策略,在第一次打開時,會有較長loading,而後每次則更像本地應用,對主容器緩存,子插件按載入策略載入。載入與拖數據都放在web-worker里進行。保證用戶第二次一打開快速按照交互比重以此看到現場。每次有容器版本更新,則默默的在背後替換掉過期插件。

8.0 gitchat 當日現場問答

內容提要:

  • 插架支持度如何?對TypeScript支持度如何?WebIDE對團隊協作開發有何支持?
  • springmvc這種xml配置型的框架,怎麼搞定js模板化管理?
  • 前端代碼越來越複雜之後,feature toggle的粒度怎麼操作比較好?
  • 目前團隊使用了gitflow的開發和發布分支,已經明顯感覺到合併代碼時的複雜度,想切換到trunk based的方案。想問單分支方案而言,在前端領域主要的技術難點有哪些,對團隊成員和工作方式會有哪些要求?
  • 聊聊前端數據流事件流的通信機制可以嗎?
  • 剛才提到feature toggle時的數據流,如果各部分feature間存在相互依賴的全局狀態,這部分有什麼好的方案,能詳細說說嗎?
  • 關於這個前端項目的重構。我們是個app項目,沒有上TS,就導致部分重構(基本上除了局部重命名)無法由IDE完美支持,然後app本身自動化測試又還沒(比較難)搭起來,所以感覺這會讓團隊趨向於不敢重構,因為每次重構都需要進行一次手動回歸,太痛苦。想了解對前端項目,特別是複雜項目,弱類型JS背景下重構方面的技術實踐、流程實踐有什麼看法嗎?

問:插架支持度如何?對TypeScript支持度如何?WebIDE對團隊協作開發有何支持?

答:第一個小問,插件支持度。我不是太清楚,是哪方面的支持度。 目前主要服務於我們的ide項目,實現ide的api。你可以理解為vscode 或者chrome的第三方插件中心的插件。

第二個小問,typescript 當然都是支持的,因為它只是一個預編譯情況,插件本身只需要實現運行時的主項目的api即可。

第三個小問,ide目前已經支持協同開發,有協同插件。可以多人在網上同時開發,可以去體驗一下。

問:springmvc這種xml配置型的框架,怎麼搞定js模板化管理?

答:因為springmvc更多的是後端提供服務的情況,然後js模版話管理我估計你理解的是js的模版引擎之類的。我能推薦我用的大概就是jade、ejs之類的。模版化處理,我能聯想到的是伺服器渲染。在現代前端開發中,有一些東西是需要伺服器提供渲染服務的,尤其是在首幀,或者一些中間需要cpu密集型操作的地方,例如小遊戲之類的。最多的其實還是首幀加速和搜索優化。如果說的是這個的話倒是會依賴於模版引擎,然後套一下第一次渲染完的js丟下去。

問:前端代碼越來越複雜之後,feature toggle的粒度怎麼操作比較好?

答: feature toggle原先的提出主要是feature分支,長線作戰,分期運行時測試的一個痛點。主要需要解決的問題是在於保持一根主線正常的更新,然後feature的情況慢慢的測試和更迭,細粒度其實跟易切換性、易管理性、可測試性這幾個影響因素很敏感。

我們在做ide的時候,其實是很多版本同時開發。比如開源版、收費版、甚至私有版等,功能會有不同,當然開源版會是一個非常基礎的功能穩步前進。為了保障這些開發的進行,我才會想到這次分享的這個主題,怎麼在各個時機去做代碼分離。這裡的代碼分離其實就是代碼開關,不同的編譯時、運行時。有些功能我們需要在運行時測試,但又不能暴露給用戶,他當然也會是一個運行時一個插件,只不過是一個非常微小的插件,可能只是改動了一個很小的邏輯。 當然這樣的小插件是一個單元功能型插件。只要我在插件里injectComponent,show是false,就不會顯示,但他實際已經作用了。我可以很方便的在後端管理中心開關這些插件組。等到這個功能測試的很不錯了,我可以選擇把它繼承到某個版本的代碼庫里,成為編譯時的版本。那這個其實也是解決feature toggle的一個我認為很好的實踐。

現在來說細粒度,什麼才是最好的拆解方式,一個功能其實是有分類的,比如他是不是單元型。所謂單元型我簡單的說就是,他拿掉和不拿掉並不影響其他功能。他可能只讀只觀察其他api,但並不修改其他數據,然後增加相應的功能,比如一個組件、一個ui、一個route。當然這說的有點理想化,因為實際上很難出現那麼存粹的一個原子feature。原則只能是儘可能的需要mock少的方式拆解,因為這樣進行切換測試的時候,比較能控制變數。如果新增的1功能和原有的2功能時強依賴,而1和2和其他的組件倒是沒什麼依賴,只是單線情況。那麼1和2便會組成一個小插件一起上,在灰度的時候比如在console里打一個setVersion(2) 就1和2會變成新的測試。實際業務場景的情況經常是我都不知道業務如何設計的情況就要出下一個版本,所以其實拆解的也不是那麼好,因為業務是做著做著才知道什麼是業務,只是提供了這些機制方便靈活運用,有些放運行時、有些編譯時、有些部署時。具體的情況是會變動的,但是架構在這裡給開發很多便利和選擇。但我的實踐里,運行時插件體系確實解決了這絕大部分問題。就像以前大家討論的微服務的好處一樣有點類似。

問:目前團隊使用了gitflow的開發和發布分支,已經明顯感覺到合併代碼時的複雜度,想切換到trunk based的方案。想問單分支方案而言,在前端領域主要的技術難點有哪些,對團隊成員和工作方式會有哪些要求?

答:這個問題是一個前端團隊工作流的問題感覺,gitflow很明顯不適合一個大團隊。如果兩三個人,然後code review模式是蠻好的 ,但是當release的target多樣化的情況下就很麻煩了,因為它支持多feature卻很難支持多release。feature多其實本身沒什麼問題,只要每個人做好自己的rebase,難點在於review和test。分支操作因為也是在編譯前完成的,一旦打入包就不好動了。所以feature原則上是真的是一個feature也就是疊加型的功能是最好的,但如果你的一個feature和原有feature是衝突的,或者說有一些地方排斥了,你更新了一些api。

那麼此時就不能時小版本的那種feature了,是多release的概念了。但是前端開發上來講,這種情況是太正常了,改一些頁面就很有可能是有衝突。不像後端一般不會隨意調整一個api,api也不是隨意寫的,都有測試保證的。這種衝突多了之後,在很後面合併的時候會有有很多問題。因為出了問題也不知道是哪個地方改壞的,都有可能動到。用git簡單的編譯前這樣管理是有點難度的。當然我說的是很複雜的項目,一般的項目同時feature還是可以協商清楚的。

然後說解決方案,我只能說我實踐的一些,那就是拆。因為肯定有一些是不太會動的基礎功能和頁面,包括核心架構之類的。然後往樹上加葉子而言,就可以時一個一個module。他都會包含很類似的特徵文件和聲明,其實也就是我理解的可插拔代碼,或者說一個內部小插件。當然我們用傳統的理解也可以不叫他插件,就是一個module文件夾。我能保證這個module和其他module是不強依賴的,都是實現了一些數據流api。當然有的人用了redux,當然我是後來放棄了。反正類似的會封裝統一的數據流api,至於框架用什麼,那都不是很重要。 一個moudule往往是一個新功能池 。那麼我需要的就是對這個小塊分而治之的去測試和管理,它可以版本化,他的載入可以在編譯前、編譯時、運行時,部署時掛載上去。這樣就很方便的能做測試,也不影響其他版本。當然,有的時候總還是會有一些交叉的東西,那就只能打patch去聯調了。只是從邏輯上儘可能把這件事歸在頻率最小的使用情況下,大部分的業務都是模塊化的。當然每個團隊業務的組織情況不同,還要根據實際情況看。

問:聊聊前端數據流事件流的通信機制可以嗎?

答:對於事件而言,很簡單,就是a告訴別人要做什麼事。難點在於他怎麼告訴。當然傳統的就是a直接調用b,命令他立即執行。一種就是a告訴一個bus,bus廣播,b聽到自己的然後做相應事。當然這個複雜講下去就會有生產者消費者的概念,一個知識點b去訂閱bus,bus有變化了,b響應的去改變。當然這裡可以拓展出來的就是b是不是立即變化,bus是不是理解變化等等。這些都有相應的框架和庫在實現類似的思想,還有就是a是單播、組播還是多播。

這些也直接影響了我採用什麼樣的方式去告訴別人,然後大家的執行順序又是怎麼安排的。在ide裡面有特別多的這些過程,因為事件非常複雜,類似一個複雜的路由模式。這塊不能再深入了,說到根源就是大家對observable的理解。具體很建議大家看看rx系列的思想,怎麼去理解觀察者、監聽者、信道這些概念。前端所謂的微服務當然和後端的不同,自己並沒有提供什麼服務,因為parse完就執行完了,只是會有觀察者、被觀察、事件被綁定這些概念。

如果要消除一個插件影響就要去踢出這些監聽器以及他們帶來的數據殘留。對於數據流的話,當然現在社區很重視數據中心模式,類似前端響應式資料庫。數據一旦改變就丟那邊,各個消費者去聽然後做相應的事,比如聽到數據變了然後去rerender,然後就是各種去設計數據結構、事件模型。有的思想是真的按mongodb類似的組織全局數據。有的就分而治之,管理自己的內部數據。主要看業務需求和性能影響。

問:剛才提到feature toggle時的數據流,如果各部分feature間存在相互依賴的全局狀態,這部分有什麼好的方案,能詳細說說嗎?

答:其實這個問題更多在討論數據的組織方式,當然從前端資料庫的思想而言,每個資源型請求都會去同步一個表。保持前端為後端資料庫的子集,而事務型的則丟給模塊自己handle,或者兩個模塊共同聽的則放bus。有的數據是a和b私有的數據,那麼只需要他們兩個的bus即可。這裡說的可能有點抽象,因為實現細節可以是很多技術。比如redux、mobx或者rxjs、object.observe等等,這個都不是很重要,到時候去尋找最好的方案即可,從模型上我們要看到的是前端響應式數據的特點和分類。

再來說依賴,如果兩者有依賴,那麼要看是什麼樣的依賴。如果新加的會改寫老的,那麼對於新加的在toggle測試階段就不能改寫老的,而是xxx.new。如果只是讀,那無所謂。如果是刪,那麼肯定也要打上標記,原則上是不能影響現有的功能,只有當運行時toggle到新的才會生效。當然這是一個方案,還有一種我比較喜歡就是做投影。每個功能模塊聽到的本身就是視圖而不是真實表,這時候我只需要在newFeature的時候給他測試的視圖就好了,原有的邏輯不會調整,可以理解為對錶的fork。

這個視圖不是ui視圖的意義,而是本身獲取數據的時候就加了一層。它可以是直接的store,也可以是fakeStore,fakeStore是單向async真實store的。當我是真實環境store就是真實的,這樣其實提高了複雜度,對於經常出現這個問題的很有用。簡單的就我剛才說的就好了,主要對寫操作打標記就好,到時候也可以清理。

問:關於這個前端項目的重構。我們是個app項目,沒有上TS,就導致部分重構(基本上除了局部重命名)無法由IDE完美支持,然後app本身自動化測試又還沒(比較難)搭起來,所以感覺這會讓團隊趨向於不敢重構,因為每次重構都需要進行一次手動回歸,太痛苦。想了解對前端項目,特別是複雜項目,弱類型JS背景下重構方面的技術實踐、流程實踐有什麼看法嗎?

答:因為他不僅僅是技術問題,更是一個公司層面的問題他要考慮成本、維護性、風險性等等。當然我只能說技術上的方案。產品角度直接是掛幾個version給用戶切換,這個沒辦法的。新方案沒有運行檢驗不敢直接替換老的,此時新老會有一段時間並行,然後重構其實首先要做到的事等價性。等價性這件事其實有幾類,如果只是語法寫法更替幾乎是不需要測試,因為它是顯然等價。但是有的是對架構的破壞,對一些數據流的更換。這個最好的做法當然是先拆後更,拆一個保證一個等價 ,拆什麼就是從可測試性上來衡量。拆最能測試清楚的模塊,然後一點點吃。

其實我的實踐來看,如果每做一個都是這樣的話到還好。千萬不要從頭寫,後面最難啃的骨頭往往是代碼滲透太強的一些框架。比如我不太喜歡的redux,因為有很多暗箱操作已經被約定,這也是以前大家為什麼不喜歡雙向綁定的原因。不是他不好,主要是暗箱太多出現一些不可控。尤其是前端,要多寫一些抽象層,這對重構是很好的。最後說一下,不要怕重構,要勇於重構,但是要保證每一步的等價性。我個人不是很欣賞測試驅動開發,到更欣賞重構驅動開發。還有就是不要被框架限制了思維,而是要做好各種邏輯關係。去搭我要用的工具,覺得不好隨時也可以更換。

推薦閱讀:

再談DOMContentLoaded與渲染阻塞—分析html頁面事件與資源載入
so easy 前端簡單實現多語言

TAG:科技 | 前端架構 | 前端工程師 |