返回笔记首页

Hybrid 混合开发-Web与Native的完美融合

主题配置

简历描述模板

版本一:注重技术实现

plain
负责Hybrid App的前端架构设计,基于JSBridge实现Web与Native的双向通信机制,支持同
步/异步调用,响应时间<50ms。封装统一的Native能力调用SDK,覆盖相机、定位、支付、
分享等20+个原生功能,接口调用成功率99.5%。优化WebView性能,通过资源预加载、离
线包方案、DNS预解析等手段将页面加载时间从2.5s缩短至0.6s。设计热更新机制,支持
H5资源的增量更新和灰度发布,版本迭代周期从2周缩短至2小时,线上bug修复响应时间
<30分钟。建立完善的容错机制,处理WebView崩溃、网络异常等10+种异常场景。

版本二:强调架构能力

plain
主导Hybrid混合开发技术栈建设,制定前端与客户端的协作规范。设计JSBridge通信协议,
采用URL Scheme + JavascriptInterface混合方案,兼容iOS和Android,支持权限管理、
回调队列、超时处理等特性。搭建离线包管理平台,实现H5资源的本地化存储和增量更新,
秒开率从40%提升至90%。开发WebView容器增强方案,注入全局对象、拦截网络请求、自
定义UA,打造类似小程序的运行环境。建立版本管理体系,支持多版本并存、动态降级、
AB测试,保障业务稳定性。

版本三:突出业务价值

plain
作为Hybrid架构负责人,推动公司移动端技术升级,实现一套H5代码同时运行在iOS/
Android/微信小程序。通过JSBridge调用原生能力,H5获得接近原生的用户体验,开发效
率提升60%。实施离线包方案后,页面首屏时间从2.5s降至0.6s,用户留存率提升15%。建
立热更新机制,关键业务bug可在1小时内完成线上修复,不依赖App发版,业务迭代速度
提升3倍。支撑公司10+条业务线的H5开发,累计服务1000万+用户,崩溃率<0.1%,好评
率92%。项目获得年度最佳技术创新奖。

SOP 标准回答

Q1: JSBridge的通信原理是什么?

标准回答

plain
JSBridge是Web和Native之间的桥梁,实现双向通信。我们项目用的是URL Scheme +
JavascriptInterface混合方案。

Web调用Native主要有三种方式:

第一种是URL Scheme。Web通过iframe或location.href发起一个自定义协议的URL,比如
jsbridge://callNative?method=getLocation&params={...}。Native通过WebView拦截这
个URL,解析method和params,执行对应的Native方法,然后把结果通过evaluateJavascript
回传给Web。这种方式兼容性好,iOS和Android都支持,但有个问题是URL长度有限制,传
大数据会被截断。

第二种是JavascriptInterface(Android)或WKScriptMessageHandler(iOS)。这是系统
提供的JS和Native互相调用的API。Native在WebView里注入一个全局对象,比如window.
JSBridge,Web直接调用这个对象的方法。Android用addJavascriptInterface注入,iOS用
WKUserContentController.add注入。这种方式性能好,支持大数据传输,但Android 4.2
以下有安全漏洞,需要额外处理。

我们的方案是两种结合,优先用JavascriptInterface,降级到URL Scheme。具体流程是:

Web端调用时,先检查window.JSBridge对象是否存在,存在就直接调用,不存在就走URL
Scheme。每次调用生成一个唯一的callbackId,放到一个回调队列里。Native执行完成后,
通过evaluateJavascript调用Web的callback函数,传入callbackId和结果数据。Web根据
callbackId找到对应的Promise的resolve或reject,完成这次调用。

还有几个细节问题:

一是超时处理。如果Native 10秒没回调,Web就认为超时,reject这个Promise。避免页面
一直等待。

二是权限管理。不是所有方法都能随便调,比如支付、获取用户信息这些敏感操作,Native
要检查权限。我们定义了权限等级,Web调用时Native会检查当前页面的权限等级,不够就
拒绝。

三是版本兼容。新版本Native可能新增了方法,老版本没有。Web调用前要先检查版本号,
如果版本不支持就走降级方案或提示用户升级。

整体架构就是这样,Web和Native通过JSBridge互相调用,配合回调队列和Promise,实现
了优雅的异步通信。

Q2: 离线包方案是怎么实现的?

标准回答

plain
离线包就是把H5的静态资源(HTML、CSS、JS、图片)提前下载到本地,WebView加载时直
接读本地文件,不走网络请求,实现秒开。

我们的离线包方案分三层:

第一层是资源打包。构建时把H5项目打包成一个zip文件,包含所有静态资源。每个包有个
唯一的版本号,比如1.0.0,和一个MD5校验值。打包完成后上传到CDN,同时更新版本配置
文件。

第二层是资源下载。App启动时或在Wifi环境下,通过接口拉取最新的版本配置。对比本地
版本和线上版本,如果有更新就下载新的离线包。下载是静默的,不影响用户使用。下载
完成后验证MD5,解压到本地目录,比如/data/data/com.xxx.app/h5/。

第三层是资源加载。WebView加载H5页面时,Native拦截所有资源请求。先查本地离线包是
否有这个资源,有就直接返回本地文件,没有就走正常的网络请求。这个拦截是通过
WebViewClient的shouldInterceptRequest实现的。

有几个技术点:

一是增量更新。如果只改了几个文件,没必要下载整个包。我们用diff算法生成差分包,只
包含变化的文件。Native下载差分包,和本地文件合并,生成新版本。这样能节省90%的流
量。

二是预加载。用户还没点进H5页面,我们就提前创建WebView实例,加载离线包资源。等用
户真正打开时,页面已经渲染好了,体验就是秒开。这个预加载是在后台线程做的,不影响
主线程。

三是降级策略。如果离线包加载失败,比如文件损坏、版本不匹配,就降级到线上资源。还
有个兜底方案,如果线上资源也加载失败,显示一个本地的错误页面,不让用户看到白屏。

四是灰度发布。新版本离线包先给5%的用户,观察崩溃率、加载时间等指标,没问题再逐步
放量到100%。如果有问题,可以随时回滚到老版本。

这套方案实施后,页面秒开率从40%提升到90%,用户体验大幅提升。线上更新也不用发
App版本了,直接推离线包,几分钟就能覆盖所有用户。

Q3: 热更新是怎么做的?

标准回答

plain
热更新就是不发App版本,直接更新H5资源。我们主要是通过离线包的版本管理实现的。

具体流程是:

1. 开发完新功能或修复bug后,打包生成新版本的离线包,上传到CDN。

2. 更新版本配置接口,把新版本的信息(版本号、下载地址、MD5、更新类型)写入配置。

3. App端定时或在特定时机(App启动、从后台切回前台、打开H5页面)拉取版本配置。

4. 对比本地版本和线上版本,判断是否需要更新。更新策略有三种:
   - 强制更新:立即下载新版本,下载完成前阻塞页面显示loading
   - 静默更新:后台下载新版本,下次打开生效
   - 提示更新:弹窗提示用户有新版本,用户确认后下载

5. 下载完成后,验证MD5确保完整性,解压到本地目录,覆盖旧版本。

6. WebView加载时,Native检测本地版本,使用最新版本的资源。

有几个关键点:

一是更新粒度。我们不是整个App只有一个离线包,而是按业务模块拆分,比如首页一个包,
商品详情一个包,个人中心一个包。这样更新某个模块不影响其他模块,也能减小包体积。

二是灰度和回滚。新版本先灰度给一部分用户,通过用户ID取模分流。观察指标正常再全量。
如果发现问题,可以立即回滚,把版本配置改回老版本,用户下次打开就会重新下载老版本。

三是缓存策略。离线包有个缓存时间,比如24小时。在缓存时间内,不请求版本接口,直接
用本地资源。过期后才检查更新。这样能减少接口请求,也能保证资源是最新的。

四是差异化更新。不同渠道、不同版本的App可能需要不同的H5资源。我们在版本配置里加
了条件字段,比如appVersion >= 2.0.0才下载某个包。这样能做到精准推送。

五是紧急回滚。如果发现严重bug,可以启动紧急回滚机制。后台推送一条消息给客户端,
客户端收到消息立即删除本地离线包,强制走线上资源。这样能在几分钟内止损。

整个热更新机制让我们的迭代速度大幅提升。之前一个小改动要等App发版,少说也要一周。
现在改完代码,推个离线包,几分钟就能覆盖所有用户。线上bug也能快速修复,不用等发版。

💡 难点与亮点分析

难点一:JSBridge完整实现

技术实现

javascript
// bridge/JSBridge.js

class JSBridge {
  constructor() {
    this.callbacks = new Map()
    this.callbackId = 0
    this.readyQueue = []
    this.isReady = false

    this.init()
  }

  init() {
    // 检测环境
    this.isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent)
    this.isAndroid = /Android/i.test(navigator.userAgent)

    // 注册全局回调处理函数
    window._handleBridgeCallback = this.handleCallback.bind(this)

    // 等待Native注入完成
    this.waitForReady()
  }

  waitForReady() {
    // iOS通过WKWebView注入
    if (this.isIOS && window.webkit && window.webkit.messageHandlers) {
      this.isReady = true
      this.flushQueue()
      return
    }

    // Android通过JavascriptInterface注入
    if (this.isAndroid && window.AndroidJSBridge) {
      this.isReady = true
      this.flushQueue()
      return
    }

    // 重试机制
    if (!this.isReady) {
      setTimeout(() => this.waitForReady(), 100)
    }
  }

  flushQueue() {
    // 执行队列中的调用
    while (this.readyQueue.length > 0) {
      const { method, params, resolve, reject } = this.readyQueue.shift()
      this.callNative(method, params).then(resolve).catch(reject)
    }
  }

  /**
   * 调用Native方法
   * @param {string} method - 方法名
   * @param {object} params - 参数
   * @returns {Promise}
   */
  call(method, params = {}) {
    return new Promise((resolve, reject) => {
      if (!this.isReady) {
        // 未就绪时加入队列
        this.readyQueue.push({ method, params, resolve, reject })
        return
      }

      this.callNative(method, params).then(resolve).catch(reject)
    })
  }

  callNative(method, params) {
    return new Promise((resolve, reject) => {
      const callbackId = this.generateCallbackId()

      // 存储回调
      const timer = setTimeout(() => {
        // 超时处理
        this.callbacks.delete(callbackId)
        reject(new Error('JSBridge调用超时'))
      }, 10000)

      this.callbacks.set(callbackId, { resolve, reject, timer })

      // 构造调用数据
      const data = {
        method,
        params,
        callbackId
      }

      try {
        if (this.isIOS) {
          // iOS调用
          window.webkit.messageHandlers.JSBridge.postMessage(data)
        } else if (this.isAndroid) {
          // Android调用
          const result = window.AndroidJSBridge.call(JSON.stringify(data))

          // Android可以同步返回
          if (result) {
            this.handleCallback(callbackId, JSON.parse(result))
          }
        } else {
          // 降级方案:URL Scheme
          this.callByURLScheme(data)
        }
      } catch (error) {
        clearTimeout(timer)
        this.callbacks.delete(callbackId)
        reject(error)
      }
    })
  }

  callByURLScheme(data) {
    // 通过iframe发起URL Scheme调用
    const iframe = document.createElement('iframe')
    iframe.style.display = 'none'

    const url = `jsbridge://call?data=${encodeURIComponent(JSON.stringify(data))}`
    iframe.src = url

    document.body.appendChild(iframe)

    setTimeout(() => {
      document.body.removeChild(iframe)
    }, 100)
  }

  handleCallback(callbackId, response) {
    const callback = this.callbacks.get(callbackId)
    if (!callback) return

    clearTimeout(callback.timer)
    this.callbacks.delete(callbackId)

    if (response.success) {
      callback.resolve(response.data)
    } else {
      callback.reject(new Error(response.error || '调用失败'))
    }
  }

  generateCallbackId() {
    return `cb_${++this.callbackId}_${Date.now()}`
  }

  // 常用Native能力封装

  /**
   * 获取设备信息
   */
  getDeviceInfo() {
    return this.call('getDeviceInfo')
  }

  /**
   * 获取位置信息
   */
  getLocation() {
    return this.call('getLocation')
  }

  /**
   * 选择图片
   * @param {number} count - 选择数量
   * @param {string} sourceType - 来源类型:camera/album/both
   */
  chooseImage(count = 1, sourceType = 'both') {
    return this.call('chooseImage', { count, sourceType })
  }

  /**
   * 扫码
   */
  scanCode() {
    return this.call('scanCode')
  }

  /**
   * 分享
   * @param {object} config - 分享配置
   */
  share(config) {
    return this.call('share', config)
  }

  /**
   * 支付
   * @param {object} params - 支付参数
   */
  pay(params) {
    return this.call('pay', params)
  }

  /**
   * 设置导航栏
   * @param {object} config - 导航栏配置
   */
  setNavigationBar(config) {
    return this.call('setNavigationBar', config)
  }

  /**
   * 关闭WebView
   */
  closeWebView() {
    return this.call('closeWebView')
  }

  /**
   * 打开新页面
   * @param {string} url - 页面URL
   */
  navigateTo(url) {
    return this.call('navigateTo', { url })
  }

  /**
   * 保存图片到相册
   * @param {string} url - 图片URL
   */
  saveImageToPhotosAlbum(url) {
    return this.call('saveImageToPhotosAlbum', { url })
  }

  /**
   * 设置剪贴板
   * @param {string} data - 要复制的内容
   */
  setClipboardData(data) {
    return this.call('setClipboardData', { data })
  }

  /**
   * 获取剪贴板内容
   */
  getClipboardData() {
    return this.call('getClipboardData')
  }

  /**
   * 显示Toast
   * @param {string} title - 提示内容
   * @param {number} duration - 持续时间
   */
  showToast(title, duration = 2000) {
    return this.call('showToast', { title, duration })
  }

  /**
   * 显示Loading
   * @param {string} title - 提示内容
   */
  showLoading(title = '加载中...') {
    return this.call('showLoading', { title })
  }

  /**
   * 隐藏Loading
   */
  hideLoading() {
    return this.call('hideLoading')
  }
}

// 导出单例
export default new JSBridge()

Vue插件封装

javascript
// bridge/index.js
import JSBridge from './JSBridge'

export default {
  install(app) {
    // 挂载到全局属性
    app.config.globalProperties.$bridge = JSBridge

    // 提供注入
    app.provide('bridge', JSBridge)
  }
}

使用示例

vue
<script setup>
import { inject } from 'vue'

const bridge = inject('bridge')

// 获取设备信息
async function getDeviceInfo() {
  try {
    const info = await bridge.getDeviceInfo()
    console.log('设备信息:', info)
  } catch (error) {
    console.error('获取失败:', error)
  }
}

// 选择图片
async function chooseImage() {
  try {
    const images = await bridge.chooseImage(9, 'both')
    console.log('选择的图片:', images)
  } catch (error) {
    console.error('选择失败:', error)
  }
}

// 分享
async function share() {
  try {
    await bridge.share({
      title: '分享标题',
      desc: '分享描述',
      link: 'https://example.com',
      imgUrl: 'https://example.com/image.jpg'
    })
    console.log('分享成功')
  } catch (error) {
    console.error('分享失败:', error)
  }
}
</script>

<template>
  <div class="page">
    <button @click="getDeviceInfo">获取设备信息</button>
    <button @click="chooseImage">选择图片</button>
    <button @click="share">分享</button>
  </div>
</template>

难点二:WebView性能优化

优化策略

javascript
// webview/WebViewOptimizer.js

class WebViewOptimizer {
  constructor() {
    this.preloadedViews = new Map()
    this.resourceCache = new Map()
  }

  /**
   * WebView预创建
   * 提前创建WebView实例,减少首次加载时间
   */
  preCreateWebView() {
    if (this.preloadedViews.has('default')) {
      return this.preloadedViews.get('default')
    }

    const webview = this.createWebView({
      enableCache: true,
      enableJavaScript: true,
      enableDomStorage: true
    })

    this.preloadedViews.set('default', webview)
    return webview
  }

  /**
   * 资源预加载
   * 预加载常用资源到内存
   */
  async preloadResources(urls) {
    const promises = urls.map(url => {
      return fetch(url)
        .then(res => res.text())
        .then(content => {
          this.resourceCache.set(url, content)
        })
        .catch(err => {
          console.error('预加载失败:', url, err)
        })
    })

    await Promise.all(promises)
  }

  /**
   * 拦截资源请求
   * WebView加载时拦截网络请求,优先返回缓存
   */
  shouldInterceptRequest(url) {
    // 检查离线包
    const offlineResource = this.getOfflineResource(url)
    if (offlineResource) {
      return offlineResource
    }

    // 检查内存缓存
    if (this.resourceCache.has(url)) {
      return this.resourceCache.get(url)
    }

    // 走正常网络请求
    return null
  }

  /**
   * 获取离线包资源
   */
  getOfflineResource(url) {
    // 解析URL,获取路径
    const path = this.parseURLPath(url)

    // 从离线包目录读取
    const offlinePath = `/data/h5/${path}`

    try {
      return this.readLocalFile(offlinePath)
    } catch {
      return null
    }
  }

  /**
   * DNS预解析
   * 提前解析域名,减少DNS查询时间
   */
  prefetchDNS(domains) {
    domains.forEach(domain => {
      const link = document.createElement('link')
      link.rel = 'dns-prefetch'
      link.href = `//${domain}`
      document.head.appendChild(link)
    })
  }

  /**
   * 并行加载资源
   * 同时加载多个资源,充分利用带宽
   */
  parallelLoad(resources) {
    return Promise.all(
      resources.map(resource => fetch(resource))
    )
  }

  /**
   * 启用HTTP/2推送
   * 服务端主动推送资源给客户端
   */
  enableHTTP2Push() {
    // 需要服务端配合
    // 通过Link header实现
    // Link: </style.css>; rel=preload; as=style
  }

  /**
   * 图片懒加载
   * 只加载可视区域的图片
   */
  lazyLoadImages() {
    const images = document.querySelectorAll('img[data-src]')
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const img = entry.target
          img.src = img.dataset.src
          observer.unobserve(img)
        }
      })
    })

    images.forEach(img => observer.observe(img))
  }

  /**
   * 代码分割
   * 按需加载代码,减小首屏体积
   */
  async loadChunk(chunkName) {
    try {
      const module = await import(
        /* webpackChunkName: "[request]" */
        `@/chunks/${chunkName}.js`
      )
      return module.default
    } catch (error) {
      console.error('加载模块失败:', chunkName, error)
      throw error
    }
  }

  /**
   * 开启硬件加速
   * 使用GPU加速渲染
   */
  enableHardwareAcceleration() {
    // 给需要加速的元素添加transform
    const elements = document.querySelectorAll('.accelerate')
    elements.forEach(el => {
      el.style.transform = 'translateZ(0)'
      el.style.willChange = 'transform'
    })
  }

  /**
   * 减少重排重绘
   * 批量修改DOM,使用RAF
   */
  batchDOMUpdate(updates) {
    requestAnimationFrame(() => {
      updates.forEach(update => update())
    })
  }

  // 工具方法
  createWebView(config) {
    // Native实现
    // 返回WebView实例
  }

  parseURLPath(url) {
    const urlObj = new URL(url)
    return urlObj.pathname
  }

  readLocalFile(path) {
    // Native实现
    // 读取本地文件内容
  }
}

export default new WebViewOptimizer()

亮点一:离线包管理系统

架构设计

javascript
// offline/OfflinePackageManager.js

class OfflinePackageManager {
  constructor() {
    this.packages = new Map()
    this.config = null
    this.downloading = new Set()
  }

  /**
   * 初始化
   */
  async init() {
    // 加载本地配置
    await this.loadLocalConfig()

    // 检查更新
    await this.checkUpdate()
  }

  /**
   * 加载本地配置
   */
  async loadLocalConfig() {
    try {
      const configStr = await this.readFile('/data/h5/config.json')
      this.config = JSON.parse(configStr)

      // 加载包列表
      Object.entries(this.config.packages || {}).forEach(([key, pkg]) => {
        this.packages.set(key, pkg)
      })
    } catch (error) {
      console.error('加载本地配置失败:', error)
      this.config = { packages: {} }
    }
  }

  /**
   * 检查更新
   */
  async checkUpdate() {
    try {
      // 请求服务端配置
      const response = await fetch('/api/offline/config')
      const serverConfig = await response.json()

      // 对比版本
      for (const [key, serverPkg] of Object.entries(serverConfig.packages)) {
        const localPkg = this.packages.get(key)

        if (!localPkg || localPkg.version !== serverPkg.version) {
          // 需要更新
          await this.downloadPackage(key, serverPkg)
        }
      }
    } catch (error) {
      console.error('检查更新失败:', error)
    }
  }

  /**
   * 下载离线包
   */
  async downloadPackage(key, pkg) {
    // 避免重复下载
    if (this.downloading.has(key)) {
      return
    }

    this.downloading.add(key)

    try {
      console.log(`开始下载离线包: ${key} v${pkg.version}`)

      // 下载zip文件
      const zipData = await this.downloadFile(pkg.url, (progress) => {
        console.log(`下载进度: ${(progress * 100).toFixed(2)}%`)
      })

      // 验证MD5
      const md5 = await this.calculateMD5(zipData)
      if (md5 !== pkg.md5) {
        throw new Error('MD5校验失败')
      }

      // 解压到临时目录
      const tempDir = `/data/h5/temp/${key}`
      await this.unzip(zipData, tempDir)

      // 移动到正式目录
      const targetDir = `/data/h5/${key}`
      await this.moveDir(tempDir, targetDir)

      // 更新配置
      this.packages.set(key, pkg)
      await this.saveConfig()

      console.log(`离线包下载成功: ${key} v${pkg.version}`)
    } catch (error) {
      console.error(`下载离线包失败: ${key}`, error)
    } finally {
      this.downloading.delete(key)
    }
  }

  /**
   * 获取资源
   */
  getResource(packageKey, resourcePath) {
    const pkg = this.packages.get(packageKey)
    if (!pkg) {
      return null
    }

    const fullPath = `/data/h5/${packageKey}/${resourcePath}`

    try {
      return this.readFile(fullPath)
    } catch {
      return null
    }
  }

  /**
   * 清除离线包
   */
  async clearPackage(key) {
    try {
      const dir = `/data/h5/${key}`
      await this.deleteDir(dir)

      this.packages.delete(key)
      await this.saveConfig()

      console.log(`清除离线包成功: ${key}`)
    } catch (error) {
      console.error(`清除离线包失败: ${key}`, error)
    }
  }

  /**
   * 清除所有离线包
   */
  async clearAll() {
    for (const key of this.packages.keys()) {
      await this.clearPackage(key)
    }
  }

  /**
   * 保存配置
   */
  async saveConfig() {
    const config = {
      packages: Object.fromEntries(this.packages)
    }

    await this.writeFile(
      '/data/h5/config.json',
      JSON.stringify(config, null, 2)
    )
  }

  // Native桥接方法
  async downloadFile(url, onProgress) {
    // 调用Native下载文件
    return window.JSBridge.call('downloadFile', { url, onProgress })
  }

  async calculateMD5(data) {
    // 调用Native计算MD5
    return window.JSBridge.call('calculateMD5', { data })
  }

  async unzip(data, targetDir) {
    // 调用Native解压文件
    return window.JSBridge.call('unzip', { data, targetDir })
  }

  async readFile(path) {
    // 调用Native读取文件
    return window.JSBridge.call('readFile', { path })
  }

  async writeFile(path, content) {
    // 调用Native写入文件
    return window.JSBridge.call('writeFile', { path, content })
  }

  async deleteDir(path) {
    // 调用Native删除目录
    return window.JSBridge.call('deleteDir', { path })
  }

  async moveDir(source, target) {
    // 调用Native移动目录
    return window.JSBridge.call('moveDir', { source, target })
  }
}

export default new OfflinePackageManager()

真实项目经验

经验一:权限管理

不同页面有不同的权限等级,限制可调用的Native能力:

javascript
// bridge/PermissionManager.js

class PermissionManager {
  constructor() {
    this.permissions = {
      // 权限等级定义
      public: ['getDeviceInfo', 'showToast', 'showLoading'],
      protected: ['getLocation', 'chooseImage', 'scanCode'],
      private: ['pay', 'getUserInfo', 'getPhoneNumber']
    }

    this.pageLevel = 'public' // 默认权限等级
  }

  /**
   * 设置页面权限等级
   */
  setPageLevel(level) {
    this.pageLevel = level
  }

  /**
   * 检查方法权限
   */
  checkPermission(method) {
    for (const [level, methods] of Object.entries(this.permissions)) {
      if (methods.includes(method)) {
        return this.hasLevel(level)
      }
    }
    return false
  }

  /**
   * 判断是否有权限等级
   */
  hasLevel(level) {
    const levels = ['public', 'protected', 'private']
    const currentIndex = levels.indexOf(this.pageLevel)
    const requiredIndex = levels.indexOf(level)

    return currentIndex >= requiredIndex
  }
}

export default new PermissionManager()

经验二:错误监控

监控JSBridge调用失败、WebView崩溃等异常:

javascript
// monitor/ErrorMonitor.js

class ErrorMonitor {
  constructor() {
    this.errors = []
    this.init()
  }

  init() {
    // 监听全局错误
    window.addEventListener('error', (e) => {
      this.reportError({
        type: 'js_error',
        message: e.message,
        filename: e.filename,
        lineno: e.lineno,
        colno: e.colno,
        stack: e.error?.stack
      })
    })

    // 监听Promise拒绝
    window.addEventListener('unhandledrejection', (e) => {
      this.reportError({
        type: 'promise_reject',
        message: e.reason?.message || e.reason,
        stack: e.reason?.stack
      })
    })

    // 监听资源加载失败
    window.addEventListener('error', (e) => {
      if (e.target !== window) {
        this.reportError({
          type: 'resource_error',
          tagName: e.target.tagName,
          src: e.target.src || e.target.href
        })
      }
    }, true)
  }

  reportError(error) {
    this.errors.push({
      ...error,
      timestamp: Date.now(),
      url: location.href,
      ua: navigator.userAgent
    })

    // 上报到服务器
    this.sendToServer(error)
  }

  sendToServer(error) {
    // 使用sendBeacon确保数据发送
    if (navigator.sendBeacon) {
      navigator.sendBeacon('/api/error/report', JSON.stringify(error))
    } else {
      fetch('/api/error/report', {
        method: 'POST',
        body: JSON.stringify(error),
        keepalive: true
      }).catch(() => {})
    }
  }
}

export default new ErrorMonitor()

面试常见追问

Q: JSBridge为什么要设计回调队列? A: 因为JSBridge是异步通信,Web调用Native后不会立即得到结果,需要等Native执行完回调。如果同时发起多个调用,回调可能乱序。用回调队列配合callbackId,每次调用生成唯一ID,Native回调时带上这个ID,Web根据ID找到对应的Promise,这样就能正确处理多个并发调用。

Q: 离线包的增量更新是怎么实现的? A: 我们用bsdiff算法生成差分包。构建新版本时,对比新旧文件,生成一个patch文件,里面只包含变化的部分。客户端下载patch文件后,用bspatch算法把patch应用到旧文件上,生成新文件。这样一个5MB的包,如果只改了100KB,差分包可能只有几十KB,节省大量流量。

Q: 如何保证离线包的完整性和安全性? A: 完整性用MD5校验。每个离线包上传时计算MD5,客户端下载后也计算MD5,两个值不一致就认为文件损坏,重新下载。安全性用HTTPS传输加签名验证。服务端用私钥对包签名,客户端用公钥验证签名,确保包没有被篡改。还有就是权限控制,不是所有页面都能加载离线包,要检查域名白名单。

Q: 热更新的灰度策略是怎么实现的? A: 我们在版本配置里加了分流规则。比如指定userId % 100 < 5的用户下载新版本,其他用户还是用老版本。这样就实现了5%的灰度。观察新版本的崩溃率、加载时间等指标,如果正常就逐步扩大灰度比例,10%、50%、100%。如果有问题立即回滚,改回老版本配置。整个过程不需要发App版本,配置改完几分钟就能生效。