Weex 中別具匠心的 JS Framework | 掘金技術徵文

一縷殤流化隱半邊冰霜發表於2017-04-24

前言

Weex為了提高Native的極致效能,做了很多優化的工作

為了達到所有頁面在使用者端達到秒開,也就是網路(JS Bundle下載)和首屏渲染(展現在使用者第一屏的渲染時間)時間和小於1s。

Weex 中別具匠心的 JS Framework  | 掘金技術徵文

手淘團隊在對Weex進行效能優化時,遇到了很多問題和挑戰:

JS Bundle下載慢,壓縮後60k左右大小的JS Bundle,在全網環境下,平均下載速度大於800ms(在2G/3G下甚至是2s以上)。
JS和Native通訊效率低,拖慢了首屏載入時間。

最終想到的辦法就是把JSFramework內建到SDK中,達到極致優化的作用。

  1. 客戶端訪問Weex頁面時,首先會網路請求JS Bundle,JS Bundle被載入到客戶端本地後,傳入JSFramework中進行解析渲染。JS Framework解析和渲染的過程其實是根據JS Bundle的資料結構建立Virtual DOM 和資料繫結,然後傳遞給客戶端渲染。
    由於JSFramework在本地,所以就減少了JS Bundle的體積,每個JS Bundle都可以減少一部分體積,Bundle裡面只保留業務程式碼。每個頁面下載Bundle的時間都可以節約10-20ms。如果Weex頁面非常多,那麼每個頁面累計起來節約的時間就很多了。 Weex這種預設就拆包載入的設計,比ReactNative強,也就不需要考慮一直困擾ReactNative頭疼的拆包的問題了。

  2. 整個過程中,JSFramework將整個頁面的渲染分拆成一個個渲染指令,然後通過JS Bridge傳送給各個平臺的RenderEngine進行Native渲染。因此,儘管在開發時寫的是 HTML / CSS / JS,但最後在各個移動端(在iOS上對應的是iOS的Native UI、在Android上對應的是Android的Native UI)渲染後產生的結果是純Native頁面。
    由於JSFramework在本地SDK中,只用在初始化的時候初始化一次,之後每個頁面都無須再初始化了。也進一步的提高了與Native的通訊效率。

JSFramework在客戶端的作用在前幾篇文章裡面也提到了。它的在Native端的職責有3個:

Weex 中別具匠心的 JS Framework  | 掘金技術徵文

  1. 管理每個Weex instance例項的生命週期。
  2. 不斷的接收Native傳過來的JS Bundle,轉換成Virtual DOM,再呼叫Native的方法,構建頁面佈局。
  3. 響應Native傳過來的事件,進行響應。

接下來,筆者從原始碼的角度詳細分析一下Weex 中別具匠心的JS Framework是如何實現上述的特性的。

目錄

  • 1.Weex JS Framework 初始化
  • 2.Weex JS Framework 管理例項的生命週期
  • 3.Weex JS Framework 構建Virtual DOM
  • 4.Weex JS Framework 處理Native觸發的事件
  • 5.Weex JS Framework 未來可能做更多的事情

一. Weex JS Framework 初始化

分析Weex JS Framework 之前,先來看看整個Weex JS Framework的程式碼檔案結構樹狀圖。以下的程式碼版本是0.19.8。


weex/html5/frameworks
    ├── index.js
    ├── legacy   
    │     ├── api         // 定義 Vm 上的介面
    │     │   ├── methods.js        // 以$開頭的一些內部方法
    │     │   └── modules.js        // 一些元件的資訊
    │     ├── app        // 頁面例項相關程式碼
    │     │   ├── bundle            // 打包編譯的主程式碼
    │     │   │     ├── bootstrap.js
    │     │   │     ├── define.js
    │     │   │     └── index.js  // 處理jsbundle的入口
    │     │   ├── ctrl              // 處理Native觸發回來方法
    │     │   │     ├── index.js
    │     │   │     ├── init.js
    │     │   │     └── misc.js
    │     │   ├── differ.js        // differ相關的處理方法
    │     │   ├── downgrade.js     //  H5降級相關的處理方法
    │     │   ├── index.js
    │     │   ├── instance.js      // Weex例項的建構函式
    │     │   ├── register.js      // 註冊模組和元件的處理方法
    │     │   ├── viewport.js
    │     ├── core       // 資料監聽相關程式碼,ViewModel的核心程式碼
    │     │   ├── array.js
    │     │   ├── dep.js
    │     │   ├── LICENSE
    │     │   ├── object.js
    │     │   ├── observer.js
    │     │   ├── state.js
    │     │   └── watcher.js
    │     ├── static     // 一些靜態的方法
    │     │   ├── bridge.js
    │     │   ├── create.js
    │     │   ├── life.js
    │     │   ├── map.js
    │     │   ├── misc.js
    │     │   └── register.js
    │     ├── util        // 工具函式如isReserved,toArray,isObject等方法
    │     │   ├── index.js
    │     │   └── LICENSE
    │     │   └── shared.js
    │     ├── vm         // 元件模型相關程式碼
    │     │   ├── compiler.js     // ViewModel模板解析器和資料繫結操作
    │     │   ├── directive.js    // 指令編譯器
    │     │   ├── dom-helper.js   // Dom 元素的helper
    │     │   ├── events.js       // 元件的所有事件以及生命週期
    │     │   └── index.js        // ViewModel的構造器和定義
    │     ├── config.js
    │     └── index.js // 入口檔案
    └── vanilla
          └── index.js複製程式碼

還會用到runtime資料夾裡面的檔案,所以runtime的檔案結構也梳理一遍。


weex/html5/runtime
    ├── callback-manager.js
    ├── config.js  
    ├── handler.js 
    ├── index.js 
    ├── init.js 
    ├── listener.js 
    ├── service.js 
    ├── task-center.js 
    └── vdom  
          ├── comment.js        
          ├── document.js 
          ├── element-types.js 
          ├── element.js 
          ├── index.js 
          ├── node.js 
          └── operation.js複製程式碼

接下來開始分析Weex JS Framework 初始化。

Weex JS Framework 初始化是從對應的入口檔案是 html5/render/native/index.js


import { subversion } from '../../../package.json'
import runtime from '../../runtime'
import frameworks from '../../frameworks/index'
import services from '../../services/index'

const { init, config } = runtime
config.frameworks = frameworks
const { native, transformer } = subversion

// 根據serviceName註冊service
for (const serviceName in services) {
  runtime.service.register(serviceName, services[serviceName])
}

// 呼叫runtime裡面的freezePrototype()方法,防止修改現有屬性的特性和值,並阻止新增新屬性。
runtime.freezePrototype()

// 呼叫runtime裡面的setNativeConsole()方法,根據Native設定的logLevel等級設定相應的Console
runtime.setNativeConsole()

// 註冊 framework 元資訊
global.frameworkVersion = native
global.transformerVersion = transformer

// 初始化 frameworks
const globalMethods = init(config)

// 設定全域性方法
for (const methodName in globalMethods) {
  global[methodName] = (...args) => {
    const ret = globalMethods[methodName](...args)
    if (ret instanceof Error) {
      console.error(ret.toString())
    }
    return ret
  }
}複製程式碼

上述方法中會呼叫init( )方法,這個方法就會進行JS Framework的初始化。

init( )方法在weex/html5/runtime/init.js裡面。



export default function init (config) {
  runtimeConfig = config || {}
  frameworks = runtimeConfig.frameworks || {}
  initTaskHandler()

  // 每個framework都是由init初始化,
  // config裡面都包含3個重要的virtual-DOM類,`Document`,`Element`,`Comment`和一個JS bridge 方法sendTasks(...args)
  for (const name in frameworks) {
    const framework = frameworks[name]
    framework.init(config)
  }

  // @todo: The method `registerMethods` will be re-designed or removed later.
  ; ['registerComponents', 'registerModules', 'registerMethods'].forEach(genInit)

  ; ['destroyInstance', 'refreshInstance', 'receiveTasks', 'getRoot'].forEach(genInstance)

  adaptInstance('receiveTasks', 'callJS')

  return methods
}複製程式碼

在初始化方法裡面傳入了config,這個入參是從weex/html5/runtime/config.js裡面傳入的。


import { Document, Element, Comment } from './vdom'
import Listener from './listener'
import { TaskCenter } from './task-center'

const config = {
  Document, Element, Comment, Listener,
  TaskCenter,
  sendTasks (...args) {
    return global.callNative(...args)
  }
}

Document.handler = config.sendTasks

export default config複製程式碼

config裡面包含Document,Element,Comment,Listener,TaskCenter,以及一個sendTasks方法。

config初始化以後還會新增一個framework屬性,這個屬性是由weex/html5/frameworks/index.js傳進來的。


import * as Vanilla from './vanilla/index'
import * as Vue from 'weex-vue-framework'
import * as Weex from './legacy/index'
import Rax from 'weex-rax-framework'

export default {
  Vanilla,
  Vue,
  Rax,
  Weex
}複製程式碼

init( )獲取到config和config.frameworks以後,開始執行initTaskHandler()方法。


import { init as initTaskHandler } from './task-center'複製程式碼

initTaskHandler( )方法來自於task-center.js裡面的init( )方法。


export function init () {
  const DOM_METHODS = {
    createFinish: global.callCreateFinish,
    updateFinish: global.callUpdateFinish,
    refreshFinish: global.callRefreshFinish,

    createBody: global.callCreateBody,

    addElement: global.callAddElement,
    removeElement: global.callRemoveElement,
    moveElement: global.callMoveElement,
    updateAttrs: global.callUpdateAttrs,
    updateStyle: global.callUpdateStyle,

    addEvent: global.callAddEvent,
    removeEvent: global.callRemoveEvent
  }
  const proto = TaskCenter.prototype

  for (const name in DOM_METHODS) {
    const method = DOM_METHODS[name]
    proto[name] = method ?
      (id, args) => method(id, ...args) :
      (id, args) => fallback(id, [{ module: 'dom', method: name, args }], '-1')
  }

  proto.componentHandler = global.callNativeComponent ||
    ((id, ref, method, args, options) =>
      fallback(id, [{ component: options.component, ref, method, args }]))

  proto.moduleHandler = global.callNativeModule ||
    ((id, module, method, args) =>
      fallback(id, [{ module, method, args }]))
}複製程式碼

這裡的初始化方法就是往prototype上11個方法:createFinish,updateFinish,refreshFinish,createBody,addElement,removeElement,moveElement,updateAttrs,updateStyle,addEvent,removeEvent。

如果method存在,就用method(id, ...args)方法初始化,如果不存在,就用fallback(id, [{ module: 'dom', method: name, args }], '-1')初始化。

最後再加上componentHandler和moduleHandler。

initTaskHandler( )方法初始化了13個方法(其中2個handler),都繫結到了prototype上


    createFinish(id, [{ module: 'dom', method: createFinish, args }], '-1')
    updateFinish(id, [{ module: 'dom', method: updateFinish, args }], '-1')
    refreshFinish(id, [{ module: 'dom', method: refreshFinish, args }], '-1')
    createBody:(id, [{ module: 'dom', method: createBody, args }], '-1')

    addElement:(id, [{ module: 'dom', method: addElement, args }], '-1')
    removeElement:(id, [{ module: 'dom', method: removeElement, args }], '-1')
    moveElement:(id, [{ module: 'dom', method: moveElement, args }], '-1')
    updateAttrs:(id, [{ module: 'dom', method: updateAttrs, args }], '-1')
    updateStyle:(id, [{ module: 'dom', method: updateStyle, args }], '-1')

    addEvent:(id, [{ module: 'dom', method: addEvent, args }], '-1')
    removeEvent:(id, [{ module: 'dom', method: removeEvent, args }], '-1')

    componentHandler(id, [{ component: options.component, ref, method, args }]))
    moduleHandler(id, [{ module, method, args }]))複製程式碼

回到init( )方法,處理完initTaskHandler()之後有一個迴圈:


  for (const name in frameworks) {
    const framework = frameworks[name]
    framework.init(config)
  }複製程式碼

在這個迴圈裡面會對frameworks裡面每個物件呼叫init方法,入參都傳入config。

比如Vanilla的init( )實現如下:


function init (cfg) {
  config.Document = cfg.Document
  config.Element = cfg.Element
  config.Comment = cfg.Comment
  config.sendTasks = cfg.sendTasks
}複製程式碼

Weex的init( )實現如下:


export function init (cfg) {
  config.Document = cfg.Document
  config.Element = cfg.Element
  config.Comment = cfg.Comment
  config.sendTasks = cfg.sendTasks
  config.Listener = cfg.Listener
}複製程式碼

初始化config以後就開始執行genInit


['registerComponents', 'registerModules', 'registerMethods'].forEach(genInit)複製程式碼

function genInit (methodName) {
  methods[methodName] = function (...args) {
    if (methodName === 'registerComponents') {
      checkComponentMethods(args[0])
    }
    for (const name in frameworks) {
      const framework = frameworks[name]
      if (framework && framework[methodName]) {
        framework[methodName](...args)
      }
    }
  }
}複製程式碼

methods預設有3個方法


const methods = {
  createInstance,
  registerService: register,
  unregisterService: unregister
}複製程式碼

除去這3個方法以外都是呼叫framework對應的方法。



export function registerComponents (components) {
  if (Array.isArray(components)) {
    components.forEach(function register (name) {
      /* istanbul ignore if */
      if (!name) {
        return
      }
      if (typeof name === 'string') {
        nativeComponentMap[name] = true
      }
      /* istanbul ignore else */
      else if (typeof name === 'object' && typeof name.type === 'string') {
        nativeComponentMap[name.type] = name
      }
    })
  }
}複製程式碼

上述方法就是註冊Native的元件的核心程式碼實現。最終的註冊資訊都存在nativeComponentMap物件中,nativeComponentMap物件最初裡面有如下的資料:


export default {
  nativeComponentMap: {
    text: true,
    image: true,
    container: true,
    slider: {
      type: 'slider',
      append: 'tree'
    },
    cell: {
      type: 'cell',
      append: 'tree'
    }
  }
}複製程式碼

接著會呼叫registerModules方法:


export function registerModules (modules) {
  /* istanbul ignore else */
  if (typeof modules === 'object') {
    initModules(modules)
  }
}複製程式碼

initModules是來自./frameworks/legacy/app/register.js,在這個檔案裡面會呼叫initModules (modules, ifReplace)進行初始化。這個方法裡面是註冊Native的模組的核心程式碼實現。

最後呼叫registerMethods



export function registerMethods (methods) {
  /* istanbul ignore else */
  if (typeof methods === 'object') {
    initMethods(Vm, methods)
  }
}複製程式碼

initMethods是來自./frameworks/legacy/app/register.js,在這個方法裡面會呼叫initMethods (Vm, apis)進行初始化,initMethods方法裡面是註冊Native的handler的核心實現。

當registerComponents,registerModules,registerMethods初始化完成之後,就開始註冊每個instance例項的方法


['destroyInstance', 'refreshInstance', 'receiveTasks', 'getRoot'].forEach(genInstance)複製程式碼

這裡會給genInstance分別傳入destroyInstance,refreshInstance,receiveTasks,getRoot四個方法名。


function genInstance (methodName) {
  methods[methodName] = function (...args) {
    const id = args[0]
    const info = instanceMap[id]
    if (info && frameworks[info.framework]) {
      const result = frameworks[info.framework][methodName](...args)

      // Lifecycle methods
      if (methodName === 'refreshInstance') {
        services.forEach(service => {
          const refresh = service.options.refresh
          if (refresh) {
            refresh(id, { info, runtime: runtimeConfig })
          }
        })
      }
      else if (methodName === 'destroyInstance') {
        services.forEach(service => {
          const destroy = service.options.destroy
          if (destroy) {
            destroy(id, { info, runtime: runtimeConfig })
          }
        })
        delete instanceMap[id]
      }

      return result
    }
    return new Error(`invalid instance id "${id}"`)
  }
}複製程式碼

上面的程式碼就是給每個instance註冊方法的具體實現,在Weex裡面每個instance預設都會有三個生命週期的方法:createInstance,refreshInstance,destroyInstance。所有Instance的方法都會存在services中。

init( )初始化的最後一步就是給每個例項新增callJS的方法


adaptInstance('receiveTasks', 'callJS')複製程式碼

function adaptInstance (methodName, nativeMethodName) {
  methods[nativeMethodName] = function (...args) {
    const id = args[0]
    const info = instanceMap[id]
    if (info && frameworks[info.framework]) {
      return frameworks[info.framework][methodName](...args)
    }
    return new Error(`invalid instance id "${id}"`)
  }
}複製程式碼

當Native呼叫callJS方法的時候,就會呼叫到對應id的instance的receiveTasks方法。

Weex 中別具匠心的 JS Framework  | 掘金技術徵文

整個init流程總結如上圖。

init結束以後會設定全域性方法。


for (const methodName in globalMethods) {
  global[methodName] = (...args) => {
    const ret = globalMethods[methodName](...args)
    if (ret instanceof Error) {
      console.error(ret.toString())
    }
    return ret
  }
}複製程式碼

Weex 中別具匠心的 JS Framework  | 掘金技術徵文

圖上標的紅色的3個方法表示的是預設就有的方法。

至此,Weex JS Framework就算初始化完成。

二. Weex JS Framework 管理例項的生命週期

當Native初始化完成Component,Module,handler之後,從遠端請求到了JS Bundle,Native通過呼叫createInstance方法,把JS Bundle傳給JS Framework。於是接下來的這一切從createInstance開始說起。

Native通過呼叫createInstance,就會執行到html5/runtime/init.js裡面的function createInstance (id, code, config, data)方法。


function createInstance (id, code, config, data) {
  let info = instanceMap[id]

  if (!info) {
    // 檢查版本資訊
    info = checkVersion(code) || {}
    if (!frameworks[info.framework]) {
      info.framework = 'Weex'
    }

    // 初始化 instance 的 config.
    config = JSON.parse(JSON.stringify(config || {}))
    config.bundleVersion = info.version
    config.env = JSON.parse(JSON.stringify(global.WXEnvironment || {}))
    console.debug(`[JS Framework] create an ${info.framework}@${config.bundleVersion} instance from ${config.bundleVersion}`)

    const env = {
      info,
      config,
      created: Date.now(),
      framework: info.framework
    }
    env.services = createServices(id, env, runtimeConfig)
    instanceMap[id] = env

    return frameworks[info.framework].createInstance(id, code, config, data, env)
  }
  return new Error(`invalid instance id "${id}"`)
}複製程式碼

這個方法裡面就是對版本資訊,config,日期等資訊進行初始化。並在Native記錄一條日誌資訊:


[JS Framework] create an Weex@undefined instance from undefined複製程式碼

上面這個createInstance方法最終還是要呼叫html5/framework/legacy/static/create.js裡面的createInstance (id, code, options, data, info)方法。


export function createInstance (id, code, options, data, info) {
  const { services } = info || {}
  // 初始化target
  resetTarget()
  let instance = instanceMap[id]
  /* istanbul ignore else */
  options = options || {}
  let result
  /* istanbul ignore else */
  if (!instance) {
    instance = new App(id, options)
    instanceMap[id] = instance
    result = initApp(instance, code, data, services)
  }
  else {
    result = new Error(`invalid instance id "${id}"`)
  }
  return result
}複製程式碼

new App()方法會建立新的 App 例項物件,並且把物件放入 instanceMap 中。

App物件的定義如下:


export default function App (id, options) {
  this.id = id
  this.options = options || {}
  this.vm = null
  this.customComponentMap = {}
  this.commonModules = {}

  // document
  this.doc = new renderer.Document(
    id,
    this.options.bundleUrl,
    null,
    renderer.Listener
  )
  this.differ = new Differ(id)
}複製程式碼

其中有三個比較重要的屬性:

  1. id 是 JS Framework 與 Native 端通訊時的唯一標識。
  2. vm 是 View Model,元件模型,包含了資料繫結相關功能。
  3. doc 是 Virtual DOM 中的根節點。

舉個例子,假設Native傳入瞭如下的資訊進行createInstance初始化:


args:( 
      0,
       “(這裡是網路上下載的JS,由於太長了,省略)”, 
      { 
        bundleUrl = "http://192.168.31.117:8081/HelloWeex.js"; 
        debug = 1; 
      }
)複製程式碼

那麼instance = 0,code就是JS程式碼,data對應的是下面那個字典,service = @{ }。通過這個入參傳入initApp(instance, code, data, services)方法。這個方法在html5/framework/legacy/app/ctrl/init.js裡面。


export function init (app, code, data, services) {
  console.debug('[JS Framework] Intialize an instance with:\n', data)
  let result

  /* 此處省略了一些程式碼*/ 

  // 初始化weexGlobalObject
  const weexGlobalObject = {
    config: app.options,
    define: bundleDefine,
    bootstrap: bundleBootstrap,
    requireModule: bundleRequireModule,
    document: bundleDocument,
    Vm: bundleVm
  }

  // 防止weexGlobalObject被修改
  Object.freeze(weexGlobalObject)
  /* 此處省略了一些程式碼*/ 

  // 下面開始轉換JS Boudle的程式碼
  let functionBody
  /* istanbul ignore if */
  if (typeof code === 'function') {
    // `function () {...}` -> `{...}`
    // not very strict
    functionBody = code.toString().substr(12)
  }
  /* istanbul ignore next */
  else if (code) {
    functionBody = code.toString()
  }
  // wrap IFFE and use strict mode
  functionBody = `(function(global){\n\n"use strict";\n\n ${functionBody} \n\n})(Object.create(this))`

  // run code and get result
  const globalObjects = Object.assign({
    define: bundleDefine,
    require: bundleRequire,
    bootstrap: bundleBootstrap,
    register: bundleRegister,
    render: bundleRender,
    __weex_define__: bundleDefine, // alias for define
    __weex_bootstrap__: bundleBootstrap, // alias for bootstrap
    __weex_document__: bundleDocument,
    __weex_require__: bundleRequireModule,
    __weex_viewmodel__: bundleVm,
    weex: weexGlobalObject
  }, timerAPIs, services)

  callFunction(globalObjects, functionBody)

  return result
}複製程式碼

上面這個方法很重要。在上面這個方法中封裝了一個globalObjects物件,裡面裝了define 、require 、bootstrap 、register 、render這5個方法。

也會在Native本地記錄一條日誌:


[JS Framework] Intialize an instance with: undefined複製程式碼

在上述5個方法中:


/**
 * @deprecated
 */
export function register (app, type, options) {
  console.warn('[JS Framework] Register is deprecated, please install lastest transformer.')
  registerCustomComponent(app, type, options)
}複製程式碼

其中register、render、require是已經廢棄的方法。

bundleDefine函式原型:



(...args) => defineFn(app, ...args)複製程式碼

bundleBootstrap函式原型:


(name, config, _data) => {
    result = bootstrap(app, name, config, _data || data)
    updateActions(app)
    app.doc.listener.createFinish()
    console.debug(`[JS Framework] After intialized an instance(${app.id})`)
  }複製程式碼

bundleRequire函式原型:


name => _data => {
    result = bootstrap(app, name, {}, _data)
  }複製程式碼

bundleRegister函式原型:


(...args) => register(app, ...args)複製程式碼

bundleRender函式原型:


(name, _data) => {
    result = bootstrap(app, name, {}, _data)
  }複製程式碼

上述5個方法封裝到globalObjects中,傳到 JS Bundle 中。


function callFunction (globalObjects, body) {
  const globalKeys = []
  const globalValues = []
  for (const key in globalObjects) {
    globalKeys.push(key)
    globalValues.push(globalObjects[key])
  }
  globalKeys.push(body)
  // 最終JS Bundle會通過new Function( )的方式被執行
  const result = new Function(...globalKeys)
  return result(...globalValues)
}複製程式碼

最終JS Bundle是會通過new Function( )的方式被執行。JS Bundle的程式碼將會在全域性環境中執行,並不能獲取到 JS Framework 執行環境中的資料,只能用globalObjects物件裡面的方法。JS Bundle 本身也用了IFFE 和 嚴格模式,也並不會汙染全域性環境。

Weex 中別具匠心的 JS Framework  | 掘金技術徵文

以上就是createInstance做的所有事情,在接收到Native的createInstance呼叫的時候,先會在JSFramework中新建App例項物件並儲存在instanceMap 中。再把5個方法(其中3個方法已經廢棄了)傳入到new Function( )中。new Function( )會進行JSFramework最重要的事情,將 JS Bundle 轉換成 Virtual DOM 傳送到原生模組渲染。

三. Weex JS Framework 構建Virtual DOM

構建Virtual DOM的過程就是編譯執行JS Boudle的過程。

先給一個實際的JS Boudle的例子,比如如下的程式碼:


// { "framework": "Weex" }
/******/ (function(modules) { // webpackBootstrap
/******/     // The module cache
/******/     var installedModules = {};

/******/     // The require function
/******/     function __webpack_require__(moduleId) {

/******/         // Check if module is in cache
/******/         if(installedModules[moduleId])
/******/             return installedModules[moduleId].exports;

/******/         // Create a new module (and put it into the cache)
/******/         var module = installedModules[moduleId] = {
/******/             exports: {},
/******/             id: moduleId,
/******/             loaded: false
/******/         };

/******/         // Execute the module function
/******/         modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

/******/         // Flag the module as loaded
/******/         module.loaded = true;

/******/         // Return the exports of the module
/******/         return module.exports;
/******/     }


/******/     // expose the modules object (__webpack_modules__)
/******/     __webpack_require__.m = modules;

/******/     // expose the module cache
/******/     __webpack_require__.c = installedModules;

/******/     // __webpack_public_path__
/******/     __webpack_require__.p = "";

/******/     // Load entry module and return exports
/******/     return __webpack_require__(0);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ function(module, exports, __webpack_require__) {

    var __weex_template__ = __webpack_require__(1)
    var __weex_style__ = __webpack_require__(2)
    var __weex_script__ = __webpack_require__(3)

    __weex_define__('@weex-component/916f9ecb075bbff1f4ea98389a4bb514', [], function(__weex_require__, __weex_exports__, __weex_module__) {

        __weex_script__(__weex_module__, __weex_exports__, __weex_require__)
        if (__weex_exports__.__esModule && __weex_exports__.default) {
          __weex_module__.exports = __weex_exports__.default
        }

        __weex_module__.exports.template = __weex_template__

        __weex_module__.exports.style = __weex_style__

    })

    __weex_bootstrap__('@weex-component/916f9ecb075bbff1f4ea98389a4bb514',undefined,undefined)

/***/ },
/* 1 */
/***/ function(module, exports) {

    module.exports = {
      "type": "div",
      "classList": [
        "container"
      ],
      "children": [
        {
          "type": "image",
          "attr": {
            "src": "http://9.pic.paopaoche.net/up/2016-7/201671315341.png"
          },
          "classList": [
            "pic"
          ],
          "events": {
            "click": "picClick"
          }
        },
        {
          "type": "text",
          "classList": [
            "text"
          ],
          "attr": {
            "value": function () {return this.title}
          }
        }
      ]
    }

/***/ },
/* 2 */
/***/ function(module, exports) {

    module.exports = {
      "container": {
        "alignItems": "center"
      },
      "pic": {
        "width": 200,
        "height": 200
      },
      "text": {
        "fontSize": 40,
        "color": "#000000"
      }
    }

/***/ },
/* 3 */
/***/ function(module, exports) {

    module.exports = function(module, exports, __weex_require__){'use strict';

    module.exports = {
        data: function () {return {
            title: 'Hello World',
            toggle: false
        }},
        ready: function ready() {
            console.log('this.title == ' + this.title);
            this.title = 'hello Weex';
            console.log('this.title == ' + this.title);
        },
        methods: {
            picClick: function picClick() {
                this.toggle = !this.toggle;
                if (this.toggle) {
                    this.title = '圖片被點選';
                } else {
                    this.title = 'Hello Weex';
                }
            }
        }
    };}
    /* generated by weex-loader */


/***/ }
/******/ ]);複製程式碼

JS Framework拿到JS Boudle以後,會先執行bundleDefine。


export const defineFn = function (app, name, ...args) {
  console.debug(`[JS Framework] define a component ${name}`)

  /*以下程式碼省略*/
  /*在這個方法裡面註冊自定義元件和普通的模組*/

}複製程式碼

使用者自定義的元件放在app.customComponentMap中。執行完bundleDefine以後呼叫bundleBootstrap方法。

bundleDefine會解析程式碼中的__weex_define__("@weex-component/")定義的component,包含依賴的子元件。並將component記錄到customComponentMap[name] = exports陣列中,維護元件與元件程式碼的對應關係。由於會依賴子元件,因此會被多次呼叫,直到所有的元件都被解析完全。



export function bootstrap (app, name, config, data) {
  console.debug(`[JS Framework] bootstrap for ${name}`)

  // 1. 驗證自定義的Component的名字
  let cleanName
  if (isWeexComponent(name)) {
    cleanName = removeWeexPrefix(name)
  }
  else if (isNpmModule(name)) {
    cleanName = removeJSSurfix(name)
    // 檢查是否通過老的 'define' 方法定義的
    if (!requireCustomComponent(app, cleanName)) {
      return new Error(`It's not a component: ${name}`)
    }
  }
  else {
    return new Error(`Wrong component name: ${name}`)
  }

  // 2. 驗證 configuration
  config = isPlainObject(config) ? config : {}
  // 2.1 transformer的版本檢查
  if (typeof config.transformerVersion === 'string' &&
    typeof global.transformerVersion === 'string' &&
    !semver.satisfies(config.transformerVersion,
      global.transformerVersion)) {
    return new Error(`JS Bundle version: ${config.transformerVersion} ` +
      `not compatible with ${global.transformerVersion}`)
  }
  // 2.2 降級版本檢查
  const downgradeResult = downgrade.check(config.downgrade)

  if (downgradeResult.isDowngrade) {
    app.callTasks([{
      module: 'instanceWrap',
      method: 'error',
      args: [
        downgradeResult.errorType,
        downgradeResult.code,
        downgradeResult.errorMessage
      ]
    }])
    return new Error(`Downgrade[${downgradeResult.code}]: ${downgradeResult.errorMessage}`)
  }

  // 設定 viewport
  if (config.viewport) {
    setViewport(app, config.viewport)
  }

  // 3. 新建一個新的自定義的Component元件名字和資料的viewModel
  app.vm = new Vm(cleanName, null, { _app: app }, null, data)
}複製程式碼

bootstrap方法會在Native本地日誌記錄:


[JS Framework] bootstrap for @weex-component/677c57764d82d558f236d5241843a2a2(此處的編號是舉一個例子)複製程式碼

bootstrap方法的作用是校驗引數和環境資訊,如果不符合當前條件,會觸發頁面降級,(也可以手動進行,比如Native出現問題了,降級到H5)。最後會根據Component新建對應的viewModel。



export default function Vm (
  type,
  options,
  parentVm,
  parentEl,
  mergedData,
  externalEvents
) {
  /*省略部分程式碼*/
  // 初始化
  this._options = options
  this._methods = options.methods || {}
  this._computed = options.computed || {}
  this._css = options.style || {}
  this._ids = {}
  this._vmEvents = {}
  this._childrenVms = []
  this._type = type

  // 繫結事件和生命週期
  initEvents(this, externalEvents)

  console.debug(`[JS Framework] "init" lifecycle in 
  Vm(${this._type})`)
  this.$emit('hook:init')
  this._inited = true

  // 繫結資料到viewModel上
  this._data = typeof data === 'function' ? data() : data
  if (mergedData) {
    extend(this._data, mergedData)
  }
  initState(this)

  console.debug(`[JS Framework] "created" lifecycle in Vm(${this._type})`)
  this.$emit('hook:created')
  this._created = true

  // backward old ready entry
  if (options.methods && options.methods.ready) {
    console.warn('"exports.methods.ready" is deprecated, ' +
      'please use "exports.created" instead')
    options.methods.ready.call(this)
  }

  if (!this._app.doc) {
    return
  }

  // 如果沒有parentElement,那麼就指定為documentElement
  this._parentEl = parentEl || this._app.doc.documentElement
  // 構建模板
  build(this)
}複製程式碼

上述程式碼就是關鍵的新建viewModel的程式碼,在這個函式中,如果正常執行完,會在Native記錄下兩條日誌資訊:


[JS Framework] "init" lifecycle in Vm(677c57764d82d558f236d5241843a2a2)  [;
[JS Framework] "created" lifecycle in Vm(677c57764d82d558f236d5241843a2a2)  [;複製程式碼

同時幹了三件事情:

  1. initEvents 初始化事件和生命週期
  2. initState 實現資料繫結功能
  3. build模板並繪製 Native UI

1. initEvents 初始化事件和生命週期


export function initEvents (vm, externalEvents) {
  const options = vm._options || {}
  const events = options.events || {}
  for (const type1 in events) {
    vm.$on(type1, events[type1])
  }
  for (const type2 in externalEvents) {
    vm.$on(type2, externalEvents[type2])
  }
  LIFE_CYCLE_TYPES.forEach((type) => {
    vm.$on(`hook:${type}`, options[type])
  })
}複製程式碼

在initEvents方法裡面會監聽三類事件:

  1. 元件options裡面定義的事情
  2. 一些外部的事件externalEvents
  3. 還要繫結生命週期的hook鉤子

const LIFE_CYCLE_TYPES = ['init', 'created', 'ready', 'destroyed']複製程式碼

生命週期的鉤子包含上述4種,init,created,ready,destroyed。

$on方法是增加事件監聽者listener的。$emit方式是用來執行方法的,但是不進行dispatch和broadcast。$dispatch方法是派發事件,沿著父類往上傳遞。$broadcast方法是廣播事件,沿著子類往下傳遞。$off方法是移除事件監聽者listener。

事件object的定義如下:


function Evt (type, detail) {
  if (detail instanceof Evt) {
    return detail
  }

  this.timestamp = Date.now()
  this.detail = detail
  this.type = type

  let shouldStop = false
  this.stop = function () {
    shouldStop = true
  }
  this.hasStopped = function () {
    return shouldStop
  }
}複製程式碼

每個元件的事件包含事件的object,事件的監聽者,事件的emitter,生命週期的hook鉤子。

initEvents的作用就是對當前的viewModel繫結上上述三種事件的監聽者listener。

2. initState 實現資料繫結功能


export function initState (vm) {
  vm._watchers = []
  initData(vm)
  initComputed(vm)
  initMethods(vm)
}複製程式碼
  1. initData,設定 proxy,監聽 _data 中的屬性;然後新增 reactiveGetter & reactiveSetter 實現資料監聽。 (
  2. initComputed,初始化計算屬性,只有 getter,在 _data 中沒有對應的值。
  3. initMethods 將 _method 中的方法掛在例項上。

export function initData (vm) {
  let data = vm._data

  if (!isPlainObject(data)) {
    data = {}
  }
  // proxy data on instance
  const keys = Object.keys(data)
  let i = keys.length
  while (i--) {
    proxy(vm, keys[i])
  }
  // observe data
  observe(data, vm)
}複製程式碼

在initData方法裡面最後一步會進行data的observe。

資料繫結的核心思想是基於 ES5 的 Object.defineProperty 方法,在 vm 例項上建立了一系列的 getter / setter,支援陣列和深層物件,在設定屬性值的時候,會派發更新事件。

Weex 中別具匠心的 JS Framework  | 掘金技術徵文

這塊資料繫結的思想,一部分是借鑑了Vue的實現,這塊打算以後寫篇文章專門談談。

3. build模板


export function build (vm) {
  const opt = vm._options || {}
  const template = opt.template || {}

  if (opt.replace) {
    if (template.children && template.children.length === 1) {
      compile(vm, template.children[0], vm._parentEl)
    }
    else {
      compile(vm, template.children, vm._parentEl)
    }
  }
  else {
    compile(vm, template, vm._parentEl)
  }

  console.debug(`[JS Framework] "ready" lifecycle in Vm(${vm._type})`)
  vm.$emit('hook:ready')
  vm._ready = true
}複製程式碼

build構建思路如下:

compile(template, parentNode)

  1. 如果 type 是 content ,就建立contentNode。
  2. 否則 如果含有 v-for 標籤, 那麼就迴圈遍歷,建立context,繼續compile(templateWithoutFor, parentNode)
  3. 否則 如果含有 v-if 標籤,繼續compile(templateWithoutIf, parentNode)
  4. 否則如果 type 是 dynamic ,繼續compile(templateWithoutDynamicType, parentNode)
  5. 否則如果 type 是 custom ,那麼呼叫addChildVm(vm, parentVm),build(externalDirs),遍歷子節點,然後再compile(childNode, template)
  6. 最後如果 type 是 Native ,更新(id/attr/style/class),append(template, parentNode),遍歷子節點,compile(childNode, template)

Weex 中別具匠心的 JS Framework  | 掘金技術徵文

在上述一系列的compile方法中,有4個引數,

  1. vm: 待編譯的 Vm 物件。
  2. target: 待編譯的節點,是模板中的標籤經過 transformer 轉換後的結構。
  3. dest: 當前節點父節點的 Virtual DOM。
  4. meta: 後設資料,在內部呼叫時可以用來傳遞資料。

編譯的方法也分為以下7種:

  1. compileFragment 編譯多個節點,建立 Fragment 片段。
  2. compileBlock 建立特殊的Block。
  3. compileRepeat 編譯 repeat 指令,同時會執行資料繫結,在資料變動時會觸發 DOM 節點的更新。
  4. compileShown 編譯 if 指令,也會執行資料繫結。
  5. compileType 編譯動態型別的元件。
  6. compileCustomComponent 編譯展開使用者自定義的元件,這個過程會遞迴建立子 vm,並且繫結父子關係,也會觸發子元件的生命週期函式。
  7. compileNativeComponent 編譯內建原生元件。這個方法會呼叫 createBody 或 createElement 與原生模組通訊並建立 Native UI。

上述7個方法裡面,除了compileBlock和compileNativeComponent以外的5個方法,都會遞迴呼叫。

編譯好模板以後,原來的JS Boudle就都被轉變成了類似Json格式的 Virtual DOM 了。下一步開始繪製Native UI。

4. 繪製 Native UI

繪製Native UI的核心方法就是compileNativeComponent (vm, template, dest, type)。

compileNativeComponent的核心實現如下:


function compileNativeComponent (vm, template, dest, type) {
  applyNaitveComponentOptions(template)

  let element
  if (dest.ref === '_documentElement') {
    // if its parent is documentElement then it's a body
    console.debug(`[JS Framework] compile to create body for ${type}`)
    // 構建DOM根
    element = createBody(vm, type)
  }
  else {
    console.debug(`[JS Framework] compile to create element for ${type}`)
    // 新增元素
    element = createElement(vm, type)
  }

  if (!vm._rootEl) {
    vm._rootEl = element
    // bind event earlier because of lifecycle issues
    const binding = vm._externalBinding || {}
    const target = binding.template
    const parentVm = binding.parent
    if (target && target.events && parentVm && element) {
      for (const type in target.events) {
        const handler = parentVm[target.events[type]]
        if (handler) {
          element.addEvent(type, bind(handler, parentVm))
        }
      }
    }
  }

  bindElement(vm, element, template)

  if (template.attr && template.attr.append) { // backward, append prop in attr
    template.append = template.attr.append
  }

  if (template.append) { // give the append attribute for ios adaptation
    element.attr = element.attr || {}
    element.attr.append = template.append
  }

  const treeMode = template.append === 'tree'
  const app = vm._app || {}
  if (app.lastSignal !== -1 && !treeMode) {
    console.debug('[JS Framework] compile to append single node for', element)
    app.lastSignal = attachTarget(vm, element, dest)
  }
  if (app.lastSignal !== -1) {
    compileChildren(vm, template, element)
  }
  if (app.lastSignal !== -1 && treeMode) {
    console.debug('[JS Framework] compile to append whole tree for', element)
    app.lastSignal = attachTarget(vm, element, dest)
  }
}複製程式碼

繪製Native的UI會先繪製DOM的根,然後繪製上面的子孩子元素。子孩子需要遞迴判斷,如果還有子孩子,還需要繼續進行之前的compile的流程。

Weex 中別具匠心的 JS Framework  | 掘金技術徵文

每個 Document 物件中都會包含一個 listener 屬性,它可以向 Native 端傳送訊息,每當建立元素或者是有更新操作時,listener 就會拼裝出制定格式的 action,並且最終呼叫 callNative 把 action 傳遞給原生模組,原生模組中也定義了相應的方法來執行 action 。

例如當某個元素執行了 element.appendChild() 時,就會呼叫 listener.addElement(),然後就會拼成一個類似Json格式的資料,再呼叫callTasks方法。



export function callTasks (app, tasks) {
  let result

  /* istanbul ignore next */
  if (typof(tasks) !== 'array') {
    tasks = [tasks]
  }

  tasks.forEach(task => {
    result = app.doc.taskCenter.send(
      'module',
      {
        module: task.module,
        method: task.method
      },
      task.args
    )
  })

  return result
}複製程式碼

在上述方法中會繼續呼叫在html5/runtime/task-center.js中的send方法。


send (type, options, args) {
    const { action, component, ref, module, method } = options

    args = args.map(arg => this.normalize(arg))

    switch (type) {
      case 'dom':
        return this[action](this.instanceId, args)
      case 'component':
        return this.componentHandler(this.instanceId, ref, method, args, { component })
      default:
        return this.moduleHandler(this.instanceId, module, method, args, {})
    }
  }複製程式碼

這裡存在有2個handler,它們的實現是之前傳進來的sendTasks方法。


const config = {
  Document, Element, Comment, Listener,
  TaskCenter,
  sendTasks (...args) {
    return global.callNative(...args)
  }
}複製程式碼

sendTasks方法最終會呼叫callNative,呼叫本地原生的UI進行繪製。

Weex 中別具匠心的 JS Framework  | 掘金技術徵文

四. Weex JS Framework 處理Native觸發的事件

最後來看看Weex JS Framework是如何處理Native傳遞過來的事件的。

在html5/framework/legacy/static/bridge.js裡面對應的是Native的傳遞過來的事件處理方法。



const jsHandlers = {
  fireEvent: (id, ...args) => {
    return fireEvent(instanceMap[id], ...args)
  },
  callback: (id, ...args) => {
    return callback(instanceMap[id], ...args)
  }
}

/**
 * 接收來自Native的事件和回撥
 */
export function receiveTasks (id, tasks) {
  const instance = instanceMap[id]
  if (instance && Array.isArray(tasks)) {
    const results = []
    tasks.forEach((task) => {
      const handler = jsHandlers[task.method]
      const args = [...task.args]
      /* istanbul ignore else */
      if (typeof handler === 'function') {
        args.unshift(id)
        results.push(handler(...args))
      }
    })
    return results
  }
  return new Error(`invalid instance id "${id}" or tasks`)
}複製程式碼

在Weex 每個instance例項裡面都包含有一個callJS的全域性方法,當本地呼叫了callJS這個方法以後,會呼叫receiveTasks方法。

關於Native會傳遞過來哪些事件,可以看這篇文章《Weex 事件傳遞的那些事兒》

在jsHandler裡面封裝了fireEvent和callback方法,這兩個方法在html5/frameworks/legacy/app/ctrl/misc.js方法中。


export function fireEvent (app, ref, type, e, domChanges) {
  console.debug(`[JS Framework] Fire a "${type}" event on an element(${ref}) in instance(${app.id})`)
  if (Array.isArray(ref)) {
    ref.some((ref) => {
      return fireEvent(app, ref, type, e) !== false
    })
    return
  }
  const el = app.doc.getRef(ref)
  if (el) {
    const result = app.doc.fireEvent(el, type, e, domChanges)
    app.differ.flush()
    app.doc.taskCenter.send('dom', { action: 'updateFinish' }, [])
    return result
  }
  return new Error(`invalid element reference "${ref}"`)
}複製程式碼

fireEvent傳遞過來的引數包含,事件型別,事件object,是一個元素的ref。如果事件會引起DOM的變化,那麼還會帶一個引數描述DOM的變化。

在htlm5/frameworks/runtime/vdom/document.js裡面


  fireEvent (el, type, e, domChanges) {
    if (!el) {
      return
    }
    e = e || {}
    e.type = type
    e.target = el
    e.timestamp = Date.now()
    if (domChanges) {
      updateElement(el, domChanges)
    }
    return el.fireEvent(type, e)
  }複製程式碼

這裡可以發現,其實對DOM的更新是單獨做的,然後接著把事件繼續往下傳,傳給element。

接著在htlm5/frameworks/runtime/vdom/element.js裡面


  fireEvent (type, e) {
    const handler = this.event[type]
    if (handler) {
      return handler.call(this, e)
    }
  }複製程式碼

最終事件在這裡通過handler的call方法進行呼叫。

當有資料發生變化的時候,會觸發watcher的資料監聽,當前的value和oldValue比較。先會呼叫watcher的update方法。


Watcher.prototype.update = function (shallow) {
  if (this.lazy) {
    this.dirty = true
  } else {
    this.run()
  }複製程式碼

update方法裡面會呼叫run方法。


Watcher.prototype.run = function () {
  if (this.active) {
    const value = this.get()
    if (
      value !== this.value ||
      // Deep watchers and watchers on Object/Arrays should fire even
      // when the value is the same, because the value may
      // have mutated; but only do so if this is a
      // non-shallow update (caused by a vm digest).
      ((isObject(value) || this.deep) && !this.shallow)
    ) {
      // set new value
      const oldValue = this.value
      this.value = value
      this.cb.call(this.vm, value, oldValue)
    }
    this.queued = this.shallow = false
  }
}複製程式碼

run方法之後會觸發differ,dep會通知所有相關的子檢視的改變。



Dep.prototype.notify = function () {
  const subs = this.subs.slice()
  for (let i = 0, l = subs.length; i < l; i++) {
    subs[i].update()
  }
}複製程式碼

相關聯的子檢視也會觸發update的方法。

Weex 中別具匠心的 JS Framework  | 掘金技術徵文

還有一種事件是Native通過模組的callback回撥傳遞事件。



export function callback (app, callbackId, data, ifKeepAlive) {
  console.debug(`[JS Framework] Invoke a callback(${callbackId}) with`, data,
            `in instance(${app.id})`)
  const result = app.doc.taskCenter.callback(callbackId, data, ifKeepAlive)
  updateActions(app)
  app.doc.taskCenter.send('dom', { action: 'updateFinish' }, [])
  return result
}複製程式碼

callback的回撥比較簡單,taskCenter.callback會呼叫callbackManager.consume的方法。執行完callback方法以後,接著就是執行differ.flush,最後一步就是回撥Native,通知updateFinish。

Weex 中別具匠心的 JS Framework  | 掘金技術徵文

至此,Weex JS Framework 的三大基本功能都分析完畢了,用一張大圖做個總結,描繪它幹了哪些事情:

Weex 中別具匠心的 JS Framework  | 掘金技術徵文

圖片有點大,連結點這裡

五.Weex JS Framework 未來可能做更多的事情

除了目前官方預設支援的 Vue 2.0,Rax的Framework,還可以支援其他平臺的 JS Framework 。Weex還可以支援自己自定義的 JS Framework。只要按照如下的步驟來定製,可以寫一套完整的 JS Framework。

  1. 首先你要有一套完整的 JS Framework。
  2. 瞭解 Weex 的 JS 引擎的特性支援情況。
  3. 適配 Weex 的 native DOM APIs。
  4. 適配 Weex 的初始化入口和多例項管理機制。
  5. 在 Weex JS runtime 的 framework 配置中加入自己的 JS Framework 然後打包。
  6. 基於該 JS Framework 撰寫 JS bundle,並加入特定的字首註釋,以便 Weex JS runtime 能夠正確識別。

如果經過上述的步驟進行擴充套件以後,可以出現如下的程式碼:


import * as Vue from '...'
import * as React from '...'
import * as Angular from '...'
export default { Vue, React, Angular };複製程式碼

這樣可以支援Vue,React,Angular。

如果在 JS Bundle 在檔案開頭帶有如下格式的註釋:


// { "framework": "Vue" }
...複製程式碼

這樣 Weex JS 引擎就會識別出這個 JS bundle 需要用 Vue 框架來解析。並分發給 Vue 框架處理。

這樣每個 JS Framework,只要:1. 封裝了這幾個介面,2. 給自己的 JS Bundle 第一行寫好特殊格式的註釋,Weex 就可以正常的執行基於各種 JS Framework 的頁面了。

Weex 支援同時多種框架在一個移動應用中共存並各自解析基於不同框架的 JS bundle。

這一塊筆者暫時還沒有實踐各自解析不同的 JS bundle,相信這部分未來也許可以幹很多有趣的事情。

最後

本篇文章把 Weex 在 Native 端的 JS Framework 的工作原理簡單的梳理了一遍,中間唯一沒有深究的點可能就是 Weex 是 如何 利用
Vue 進行資料繫結的,如何監聽資料變化的,這塊打算另外開一篇文章詳細的分析一下。到此篇為止,Weex 在 Native 端的所有原始碼實現就分析完畢了。

請大家多多指點。

References:

Weex 官方文件
Weex 框架中 JS Framework 的結構
淺析weex之vdom渲染
Native 效能穩定性極致優化

本次徵文活動的連結: juejin.im/post/58d8e9…

相關文章