回到最初:开发不需要“编译” 的 WebApp

4,317 阅读4分钟

目前开发 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>