7

微信小程序架构分析 (下)

 3 years ago
source link: https://zhuanlan.zhihu.com/p/22932309
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

微信小程序架构分析 (下)

开源开发者,vimer

这一篇拖了一段时间,原因是实现一个可以运行微信小程序的 web 环境比我想象中要困难一些, 这一方面是因为微信对于代码进行了压缩混淆,另一方面主要原因是开发者工具内部逻辑调用比较复杂(难怪 bug 不少),完全无法拿出来重用。

小程序实时运行工具 wept 的开发已经基本完成了, 你可以通过我的代码对小程序的 web 环境实现有更全面的认识。下面我将介绍它的实现过程以及实时更新的原理。

小程序 web 服务实现

我在 wept 的开发中使用 koa 提供 web 服务,以及 et-improve 提供模板渲染。

第一步: 准备页面模板

我们需要三个页面,一个做为控制层 index.html,一个做为 service 层service.html,还有一个做为 view 层的 view.html

index.html:

<div class="head">
</div>
<div class="scrollable">
</div>
<div class="tabbar-root">
</div>
<script>
  var __wxConfig__ = {{= _.config}}
  var __root__ = '{{= _.root}}'
</script>
<script src="/script/build.js"></script>

service.html:

<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <link href="https://res.wx.qq.com/mpres/htmledition/images/favicon218877.ico" rel="Shortcut Icon">
  <script>
  var __wxAppData = {}
  var __wxRoute
  var __wxRouteBegin
  global = {}
  var __wxConfig = {{= _.config}}
  </script>
  <script src="/script/bridge.js" type="text/javascript"></script>
  <script src="/script/service.js" type="text/javascript"></script>
  {{each _.utils as util}}
  <script src="/app/{{= util}}" type="text/javascript"></script>
  {{/}}
  <script src="/app/app.js" type="text/javascript"></script>
  {{each _.routes as route}}
  <script> var __wxRoute = '{{= route | noext}}', __wxRouteBegin = true;</script>
  <script src="/app/{{= route}}" type="text/javascript"></script>
  {{/}}
</head>
<body>
  <script>
    window._____sendMsgToNW({
      sdkName: 'APP_SERVICE_COMPLETE'
    })
  </script>
</body>

view.html:

<head>
  <link href="https://res.wx.qq.com/mpres/htmledition/images/favicon218877.ico" rel="Shortcut Icon">
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
  <link rel="stylesheet" type="text/css" href="/css/default.css">
  <link rel="stylesheet" type="text/css" href="/app/app.wxss">
  <link rel="stylesheet" type="text/css" href="/app/{{= _.path}}.wxss">
  <script> var __path__ = '{{= _.path}}'</script>
  <script src="/script/ViewBridge.js" async type="text/javascript"></script>
  <script src="/script/view.js" type="text/javascript"></script>
  <script>
  {{= _.inject_js}}
  </script>
  <script>
    document.dispatchEvent(new CustomEvent("generateFuncReady", {
      detail: {
        generateFunc: $gwx('./{{= _.path}}.wxml')
      }
    }))
  </script>
</head>
<body>
  <div></div>
</body>

第二步: 实现 http 服务

koa 实现的代码逻辑非常简单:

server.js

// 日志中间件
app.use(logger())
// gzip
app.use(compress({
  threshold: 2048,
  flush: require('zlib').Z_SYNC_FLUSH
}))
// 错误提醒中间件
app.use(notifyError)
// 使用当前目录下文件处理 404 请求
app.use(staticFallback)
// 各种 route 实现
app.use(router.routes())
app.use(router.allowedMethods())
// 对于 public 目录启用静态文件服务
app.use(require('koa-static')(path.resolve(__dirname, '../public')))
// 创建启动服务
let server = http.createServer(app.callback())
server.listen(3000)

router.js

router.get('/', function *() {
  // 加载 index.html 模板和数据,输出 index 页面
})

router.get('/appservice', function *() {
  // 加载 service.html 模板和数据,输出 service 页面
})

// 让 `/app/**` 加载小程序所在目录文件
router.get('/app/(.*)', function* () {
  if (/\.(wxss|js)$/.test(file)) {
    // 动态编译为 css 和相应 js
  } else if (/\.wxml/.test(file)) {
    // 动态编译为 html
  } else {
    // 查找其它类型文件, 存在则返回
    let exists = util.exists(file)
    if (exists) {
      yield send(this, file)
    } else {
      this.status = 404
      throw new Error(`File: ${file} not found`)
    }
  }
})

第三步:实现控制层功能

实现完上面两步,就可以访问 view 页面了,但是你会发现它只能渲染,并不会有任何功能,因为 view 层功能依赖于控制层进行的通讯, 如果控制层收不到消息,它不会响应任何事件。

控制层是整个实现过程中最复杂的一块,因为官方工具的代码与 nwjs 以及 react 等第三方组件耦合过高,所以无法拿来直接使用。 你可以在 wept 项目的 src 目录下找到控制层逻辑的所有代码,总体上控制层要负责以下几个功能:

  • 实现 service 层,view 层以及控制层之间的通讯逻辑
  • 依据路由指令动态创建 view (wept 使用 iframe 实现)
  • 根据当前页面动态渲染 header 和 tabbar
  • 实现原生 API 调用,返回结果给 service 层

wept 里面 iframe 之间的通讯是通过 message.js 模块实现的,控制页面(index.html)代码如下:

window.addEventListener('message', function (e) {
  let data = e.data
  let cmd = data.command
  let msg = data.msg
  // 没有跟 contentscript 握手阶段,不需要处理
  if (data.to == 'contentscript') return
  // 这是个遗留方法,基本废弃掉了
  if (data.command == 'EXEC_JSSDK') {
    sdk(data)
  // 直接转发 view 层消息到 service,主要是各种事件通知
  } else if (cmd == 'TO_APP_SERVICE') {
    toAppService(data)
  // 除了 publish 发送消息给 view 层以及控制层可以处理的逻辑(例如设置标题),
  // 其它全部转发 service 处理,所有控制层的处理结果统一先返回 service
  } else if (cmd == 'COMMAND_FROM_ASJS') {
    let sdkName = data.sdkName
    if (command.hasOwnProperty(sdkName)) {
      command[sdkName](data)
    } else {
      console.warn(`Method ${sdkName} not implemented for command!`)
    }
  } else {
    console.warn(`Command ${cmd} not recognized!`)
  }
})

具体实现逻辑可以查看 src/command.js src/service.jssrc/sdk/*.js。对于 view/service 页面只需把原来 bridge.js 的window.postMessage 改为 window.top.postMessage 即可。

view 层的控制逻辑由 src/view.js 以及 src/viewManage.js 实现,viewManage 实现了 navigateTo, redirectTo 以及 navigateBack 来响应 service 层通过名为 publish 的 command 传来的对应页面路由事件。

header.js 和 tabbar.js 包含了基于 react 实现的 header 和 tabbar 模块(原计划是使用 vue,但是没找到与原生 js 模块通讯的 API)

sdk 目录下包含了 storage,录音,罗盘模块,其它比较简单一些的原生底层调用我直接写在 command.js 里面了。

以上就是实现运行小程序所需 webserver 的全部逻辑了,其实现并不复杂,主要困难在与理解微信这一整套通讯方式。

实现小程序实时更新

第一步: 监视文件变化并通知前端

wept 使用了 chokidar 模块监视文件变化,变化后使用 WebSocket 告知所有客户端进行更新操作。 具体实现位于 lib/watcher.js 和 lib/socket.js, 发送内容是 json 格式的字符串。

前端控制层收到 WebSocket 消息后再通过 postMessage 接口转发消息给 view/service 层:

view.postMessage({
  msg: {
    data: {
      data: { path }
    },
    eventName: 'reload'
  },
  command: 'CUSTOM'
})

view/service 层监听 reload 事件:

WeixinJSBridge.subscribe('reload', function(data) {
  // data 即为上面的 msg.data
})

第二步: 前端响应不同文件变化

前端需要对 4 种(wxml wxss json javascript)不同类型文件进行 4 种不同的热更新处理,其中 wxss 和 json 相对简单。

  • wxss 文件变化后前端控制层通知(postMessage 接口)对应页面(如果是 app.wxss 则是所有 view 页面)进行刷新,view 层收到消息后只需要更改对应 css 文件的时间戳就可以了,代码如下:

    o.subscribe('reload', function(data) {
        if (/\.wxss$/.test(data.path)) {
        var p = '/app/' + data.path
        var els = document.getElementsByTagName('link')
        ;[].slice.call(els).forEach(function(el) {
          var href = el.getAttribute('href').replace(/\?(.*)$/, '')
          if (p == href) {
            console.info('Reload: ' + data.path)
            el.setAttribute('href', href + '?id=' + Date.now())
          }
        })
      }
    })
    
  • json 文件变化首先需要判断,如果是 app.json 我们无法热更新,所以目前做法是刷新页面,对于页面的 json, 我们只需要在控制层上对 header 设置相应状态就可以了 (渲染工作由 react 帮我们处理):

    socket.onmessage = function (e) {
      let data = JSON.parse(e.data)
      let p = data.path
      if (data.type == 'reload'){
        if (p == 'app.json') {
          redirectToHome()
        } else if (/\.json$/.test(p)) {
          let win = window.__wxConfig__['window']
          win.pages[p.replace(/\.json$/, '')] = data.content
          // header 通过全局 __wxConfig__ 获取 state 进行渲染
          header.reset()
          console.info(`Reset header for ${p.replace(/\.json$/, '')}`)
        }
      }
    }
    
  • wxml 使用 VirtualDom API 提供的 diff apply 进行处理。首先需要一个接口获取新的 generateFunc 函数(用于生成 VirtualDom), 添加 koa 的 router:

    router.get('/generateFunc', function* () {
      this.body = yield loadFile(this.query.path + '.wxml')
      this.type = 'text'
    })
    
    function loadFile(p, throwErr = true) {
      return new Promise((resolve, reject) => {
        fs.stat(`./${p}`, (err, stats) => {
          if (err) {
            if (throwErr) return reject(new Error(`file ${p} not found`))
            // 文件不存在有可能是文件被删除,所以不能使用 reject
            return resolve('')
          }
          if (stats && stats.isFile()) {
            // parer 函数调用 exec 命令执行 wcsc 文件生成 wxml 对应的 javascript 代码
            return parser(`${p}`).then(resolve, reject)
          } else {
            return resolve('')
          }
        })
      })
    }
    

    有了接口就可以请求接口,然后执行返回函数进行 diff apply:

    // curr 为当前的 VirtualDom 树
    if (!curr) return
    var xhr = new XMLHttpRequest()
    xhr.onreadystatechange = function() {
      if (xhr.readyState === 4) {
        if (xhr.status === 200) {
          var text = xhr.responseText
          var func = new Function(text + '\n return $gwx("./' +__path__+ '.wxml")')
          window.__generateFunc__ = func()
          var oldTree = curr
          // 获取当前 data 生成新的树
          var o = m(p.default.getData(), false),
          // 进行 diff apply
          a = oldTree.diff(o);
          a.apply(x);
          document.dispatchEvent(new CustomEvent("pageReRender", {}));
          console.info('Hot apply: ' + __path__ + '.wxml')
        }
      }
    }
    xhr.open('GET', '/generateFunc?path=' + encodeURIComponent(__path__))
    xhr.send()
    
  • javascript 更新逻辑相对复杂一些, 首先依然是一个接口来获取新的 javascript 代码:

    router.get('/generateJavascript', function* () {
      this.body = yield loadFile(this.query.path)
      this.type = 'text'
    })
    

    然后我们在 window 对象上加入 Reload 函数执行具体的更换逻辑:

    window.Reload = function (e) {
    var pages = __wxConfig.pages;
    if (pages.indexOf(window.__wxRoute) == -1) return
    // 替换原来的构造函数
    f[window.__wxRoute] = e
    var keys = Object.keys(p)
    // 判定是否当前使用中页面
    var isCurr = s.route == window.__wxRoute
    keys.forEach(function (key) {
      var o = p[key];
      key = Number(key)
      var query = o.__query__
      var page = o.page
      var route = o.route
      // 页面已经被创建
      if (route == window.__wxRoute) {
        // 执行封装后的 onHide 和 onUnload
        isCurr && page.onHide()
        page.onUnload()
        // 创建新 page 对象
        var newPage = new a.default(e, key, route)
        newPage.__query__ = query
        // 重新绑定当前页面
        if (isCurr) s.page = newPage
        o.page = newPage
        // 执行 onLoad 和 onShow
        newPage.onLoad()
        if (isCurr) newPage.onShow()
        // 更新 data 数据
        window.__wxAppData[route] = newPage.data
        window.__wxAppData[route].__webviewId__ = key
        // 发送更新事件, 通知 view 层
        u.publish(c.UPDATE_APP_DATA)
        u.info("Update view with init data")
        u.info(newPage.data)
        // 发送 appDataChange 事件
        u.publish("appDataChange", {
          data: {
            data: newPage.data
          },
          option: {
            timestamp: Date.now()
          }
        })
        newPage.__webviewReady__ = true
      }
    })
    u.info("Reload page: " + window.__wxRoute)
    }
    

    以上代码需要添加到 t.pageHolder 函数后才可运行

    最后在 view 层初始化后把 Page 函数切换到 Reload 函数(当然你也可以在请求返回 javascript 前把 Page 重命名为 Reload) 。

    <body>
    <script>
      window._____sendMsgToNW({
        sdkName: 'APP_SERVICE_COMPLETE'
      })
    </script>
    </body>
    

总算是把这个坑填上了。希望通过这一系列的分析带给前端开发者更多思路。

最后欢迎关注我的知乎,或者我的微信、微博帐号 @chemzqm


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK