回到最初:開發不需要“編譯” 的 WebApp

mantou132發表於2018-11-05

目前開發 WebApp 最流行的方式就是使用 React, Vue, Webpack 或者類似的工具,他們解決的最大的問題是元件式開發。但使用他們帶來很大的開發成本:他們更新速度很快,你需要不斷的學習他們,而且靈活度也受到了框架的限制。那有沒有一件法寶讓我們學了就可以一直使用,專注於開發應用而不必關心工具呢?下面我使用最簡單的方式就像最初我們沒用這些工具一樣來開發一款現代 WebApp。

這個 WebApp 我現在稱他為“MT Music Player”(GitHub 地址),他是一個簡單的單頁應用,做的事情也相當簡單,就是上傳音訊並播放他們。麻雀雖小,但五臟俱全:由於有自定義列表,所以需要具備路由的功能;由於多處 UI 需要響應同一個狀態,所以需要具備全域性資料管理;最主要的是,他使用元件式開發,方便協作維護。可以點選這裡體驗。

桌面端

模組化

主流瀏覽器都已經支援 ES6 的全部特性(尾遞迴優化除外),包括 ES Modules,Classes,我可以直接使用這些特性而不必通過 Webpack 來編譯,然後使用 HTTP2 直接載入他們。

元件化

React, Vue 都提供一套完整的元件化方案。現在,Web Components 在除了 Edge 外其他主流瀏覽器中都已經得到支援,<template> 元素提供了可重複使用的模版,Shadow DOM 提供了元件的界限。

React 使用 JSX 來編寫模版,優點是直觀,可程式設計。現在有了 ES6 的模版字串,我可以使用模版字串來編寫 HTML 程式碼,計算後將其解析成 DOM 插入或者替代文件中的某個元素即可。

// `variable` 更新後重新呼叫
template = `<span>${variable}</span>`
document.body.innerHTML = template
複製程式碼

但實際上並不能這麼做,因為例項化模版插入文件後還有需要更新他,這個方式將更新整個元件,效能太差。React 使用 Virtual DOM 的方式來更新元件,他計算出整個元件的 Virtual DOM 表示並進行 Diff 得到需要更新的部分 DOM 之後再更新他們,需要更新的 DOM 通常只佔整個元件的一小部分,所以這個的更新方式相比上面的方式要快得多。

想象一下,如果元件中的某個資料變化需要更新元件中的某個 DOM ,我們不使用 Virtual DOM ,不使用 Diff ,而是直接得到這個 DOM 直接進行更新,這樣不是沒有了效能問題嗎?只需要把 Node 和資料繫結就可以做到這一點。

回到 ES 的模版字串,他可以使用一個標籤函式來計算最終字串,現在可以利用他來進行 Node 和資料繫結:替換模版字串中的變數後解析到 <template> 元素中,再使用 DOM 相關 API 查詢到對應的 Node。下面是一段拙劣的程式碼:

const tempsMap = new Map

const html = (strings, ...values) => {
    // 注:同一個模版字串 strings 相同
    let result = tempsMap.get(strings)
    if (!result) {
        const temp = document.createElement('template')
        temp.innerHTML = strings.reduce((p, c, i) => {
            return p + '<!---->' + `{{placeholder-${i}}}` + '<!---->' + c
        })
        tempsMap.set(strings, {strings, values, temp})
    }
    return {...tempsMap.get(strings), values}
}

const instances = new Map

const render = (result, container) => {
    let instance = instances.get(container)
    if (instance) {
        // 更新
        instance.setValue(result)
    } else {
        // 首次渲染
        const instance = result.temp.content.cloneNode(true)
        container.append(instance)

        const nodes = result.values.map((v, i) => {
            const xpr = `//node()[contains(text(),'{{placeholder-${i + 1}}}')]/text()`
            return document.evaluate(xpr, document, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null).snapshotItem(0)
        })
        instance.setValue = (result) => {
            result.values.forEach((value, i) => {
                nodes[i].data = value
            })
        }
        instance.setValue(result)
        instances.set(container, instance)
    }
}

// 重複呼叫就可以更新元件
setInterval(() => {
    render(html`<span>${Date.now()}</span>`, document.body)
}, 1000)
複製程式碼

基於這個思想有一個很完善的實現—— lit-html點選這裡檢視一個典型的元件。

全域性資料管理

不管是 React 還是 Vue 都有一個資料管理的庫,他們有個共同點是資料繫結到檢視,當資料更新時,能立刻反應到檢視上。React Redux 通過 React 的 props 來更新檢視,我想訂閱的方式可能更適合上面提到的元件式方案:元件訂閱一個資料物件,當這個資料物件更新時通知元件更新。Proxy 很容易做到這一點:

const handles = new Map()

const handler = {
    get(target, key) {
      return target[key]
    },
    set(target, key, value) {
      target[key] = value
      const listeners = handles.get(key)
      listeners.forEach(/* 更新元件 */)
      return true
    },
};

// 全域性資料物件
const store = new Proxy({
    appState: {}
}, handler)

export const connect = (page, func) => {
  const listeners = handles.get(page)
  if (!func.connectedPage) func.connectedPage = new Set()
  func.connectedPage.add(page)
  listeners.add(func)
}
複製程式碼

當我們例項化元件時,元件通過 connect 訂閱一份資料如 appState,當重新賦值 store.appState 時,就可以呼叫訂閱該資料的元件,執行回撥函式,經過包裝後,最後是這樣使用的:

export default class AppState extends Component {
  constructor() {
    super();
    this.state = store.appState
    this.clickHandle = () => this.setState({date: new Date})
  }

  render() {
    return html`
      <span @click="${this.clickHandle}">${this.state.date}</span>
    `
  }
}

customElements.define('app-state', AppState)
複製程式碼

路由

一個完整的應用離不開路由,他讓應用易於傳播,即 Native App 所說的深度連結,另外支援路由外,也間接的支援了 Android 的返回鍵。History API 能很快的為單頁應用建立路由功能:

    window.addEventListener('popstate', () => {
        // 由使用者代理更新歷史棧時觸發,如點選後退/前進鍵
    })
    
    // 更新歷史棧
    window.history.pushState(state, title, pathname);
複製程式碼

另外,將歷史棧物件與全域性資料管理結合,可以保證歷史棧修改的適合更新全域性資料 store ,以更新訂閱該資料的元件(完整程式碼),如 <app-route>

總結

解決上面三個問題後,就可以開發現代 WebApp 了。但還存在很多問題:

  • 模組很多時,HTTP2 載入並沒有想象中的快
  • 無法進行服務端渲染,可能需要單獨生成一份 SEO 友好的文件
  • 樣式不能穿透 ShadowDOM,可能需要寫很多重複的樣式
  • 依賴管理比較棘手,目前 jspm 是較好的一個方案

當使用這種不需要“編譯”的方式開發 WebApp 時,喪失了一些目前常用的開發工具:

  • 熱更新
  • 型別系統支援

最後,我還是覺得應該用現在的開發方案結合 Web Components,因為 Web Components 特別適合那種獨立封閉的元件,如視訊播放器 —— <video>

相關文章