H5App、微信小程序开发

原生h5api

对视频截图

<video controls src="./assets/demo.mp4" width="400" height="300" id="video">
      Sorry, your browser doesn't support embedded videos.
    </video>
    <button onclick="screenShot()">Screenshot</button>
    <script>
      function screenShot() {
        const video = document.getElementById('video');
        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d');
        canvas.width = video.videoWidth;
        canvas.height = video.videoHeight;
        ctx.drawImage(video, 0, 0);

        // download picture
        const a = document.createElement('a');
        a.href = canvas.toDataURL('image/png');
        a.download = `${Date.now()}`;
        a.click();
      }
    </script>

http://www.html5plus.org/doc

判断是否是微信浏览器

//判断是否微信登陆
    function isWeiXin() {
        var ua = window.navigator.userAgent.toLowerCase();
        console.log(ua);//mozilla/5.0 (iphone; cpu iphone os 9_1 like mac os x) applewebkit/601.1.46 (khtml, like gecko)version/9.0 mobile/13b143 safari/601.1
        if (ua.match(/MicroMessenger/i) == 'micromessenger') {
            return true;
        } else {
            return false;
        }
    }
    if (isWeiXin()) {
        alert(" 是来自微信内置浏览器")
    } else {
        alert("不是来自微信内置浏览器")
    }

h5打开电话号码、邮件应用

通过设置html标签就可以打开

<a href="tel:150****2223" />手机号码</a>
<a href="mailto:***@***.com" />send email</a>

或者通过form表单提交,还可以默认显示邮件标题和内容

<form action="mailto:**@**.com" entype="text/plain"> 
   <div>
     <input name = "subject" value="C++研发">
     <input type="submit" value="投递简历">
  </div>
</form>

微信内置浏览器由于安全问题,在微信内部打开邮件app之后地址栏是空白,可以选择外部浏览器打开页面

h5多行文本截断

Https://zoo.team/article/text-overflow

普通的单行文本截断(只支持单行文本截断)

.demo {
   overflow:hidden;
   white-space:nowrap;
   text-overflow:ellipsis;
}

对于移动端,可以使用webkit特有的显示方式,因为移动端设备大多是基于webkit的内核,只有opera mini和ie不是

.demo {
  -webkit-line-clamp:2;/*用来限制一个块元素显示的文本行数,2表示最多显示2行,还需要组合其他webkit属性*/
  display:-webkit-box;/*和第一行结合使用,将对象作为弹性伸缩盒子模型显示*/
  -webkit-box-orient:vertical; /*和第一行结合使用,设置或检索伸缩盒对象的子元素的排列方式*/
  overflow:hidden;
  text-overflow:ellipsis;
}

也可以设置伪元素,适用于文本一定会超出范围的情况

.demo {
  position:relative;
  line-height:20px;
  height:40px;
  overflow:hidden;
}
/*设置伪元素的内容和样式*/
.demo::after {
  content:"...";
  position:absolute;
  bottom:0;
  right:0
}

h5抽屉原生实现

顶部抽屉导航栏

<html>
  <div id="root">
    <div class="box">
        <div class="btn-box">
            <button id="btn" class="btn">
                <span></span>
                <span></span>
                <span></span>
            </button>
        </div>
        <div id="content-box" class="content-box">
            <ul class="content-ul">
                <li class="content-li active">首页</li>
                <li class="content-li">文章</li>
                <li class="content-li">归档</li>
            </ul>
        </div>
    </div>
	</div>
</html>
<script>
let btn = document.getElementById("btn");
let nav = document.getElementById("content-box");
let contentLi = document.getElementsByClassName("content-li");

let hide = true;
let length = contentLi.length;

/**
 *  实现导航条抽屉式
 */
btn.addEventListener('click', function () {
    if (hide){
        nav.style.height = "200px";
        hide = false;
    } else {
        nav.style.height = "0";
        hide = true;
    }
});

/**
 *  导航选中效果
 */
for (let i = 0; i < length; i++ ){
    contentLi[i].addEventListener('click', function () {
        for (let j = 0; j < length; j++){
            contentLi[j].classList.remove('active');
        }
        contentLi[i].classList.add('active');
    })
}
</script>

样式

#root{
    width: 100%;
    height: 400px;
    margin-top: 100px;
    background: #cccccc;
}
*{
    margin: 0;
    padding: 0;
}
li{
    list-style: none;
}
.box{
    width: 400px;
    border: 1px solid #eeeeee;
    height: 100%;
    margin: 0 auto;
    background: #ffffff;
}
.btn-box{
    width: 100%;
    height: 60px;
    display: flex;
    justify-content: flex-end;
    align-items: center;
    border-bottom: 1px solid #cccccc;
}
.btn{
    height: 40px;
    width: 40px;
    display: flex;
    flex-direction: column;
    flex-wrap: wrap;
    justify-content: space-around;
    border: none;
    background: #ffffff;
    outline: none;
}
.btn span{
    display: inline-block;
    height: 4px;
    width: 30px;
    background: #2b2b2b;
}
.content-box{
    height: 0;
    border-bottom: 1px solid #dddddd;
    background: #cccccc;
    overflow: hidden;  /* 让子元素不能撑开父元素 */
    transition:height ease-out .3s;
    -webkit-transition:height ease-out .3s; /* Safari */
}
.content-li{
    padding: 5px 10px;
    margin-bottom: 20px;
    font-size: 20px;
}
.active{
    background: #ffffff;
}

侧边抽屉--主体不移动

<html>
  <div class="main">
    <div id="open">打开</div>
    <div class="left"></div>  
    <div class="mask"></div>
	</div>
</html>
<script>
  var open = document.getElementById("open");
  var left = document.querySelector(".left");
  var mask = document.querySelector(".mask");
  open.onclick = function(){
    left.className += " left-open";
    mask.style.display = "block";
  }
  mask.onclick = function(){
      left.className += "left";
      mask.style.display = "none";
  }
</script>

样式

body,html{
  height:100%;
}
.main{
  position:relative;
  width:100%;
  height:100%;
}
.left{
  position:absolute;
  width:260px;
  height:100%;
  left:-300px;
  background:skyblue;
  z-index:1000;  
  box-shadow:5px 0px 10px rgba(0,0,0,.2);
  transition:all 0.3s;
}
.left.left-open{
  left:0;
}
.mask{
  display:none;
  position:absolute;
  height:100%;
  width:100%;
  background:rgba(0,0,0,.5);
  z-index:500;  
}
#open{
  position:absolute;
  top:10px;
  left:10px;
  padding:10px;
  background:skyblue;
  cursor:pointer;
}

侧边抽屉--主体移动

<div id="all">
  <div id="drawer"></div>
  <div id="main">
    <div id="nav">
      <div id="open"></div>
    </div>
    <div id="content"></div>
    <div id="mask"></div>
  </div>
</div>
<script>
  var drawer = document.getElementById("drawer");
  var main = document.getElementById("main");
  var mask = document.getElementById("mask");
  document.getElementById("open").onclick = function(){
    drawer.style.left = 0; 
    main.style.left = 200 + "px"; 
    mask.style.display = "block";
  }
  mask.onclick = function(){
    drawer.style.left = -200 + "px"; 
    main.style.left = 0; 
    mask.style.display = "none";
  }
</script>

样式

#all{
  position:absolute;
  top:50%;
  left:50%;
  transform:translate(-50%,-50%);
  background:#ddd;
  width:300px;
  height:500px;
  overflow:hidden;
}
#drawer{
  position:absolute;
  width:200px;
  height:500px;
  background:skyblue;
  top:0;
  left:-200px;
  transition:all 0.5s;
}
#main{
  position:absolute;
  width:300px;
  height:500px;
  background:tomato;
  top:0;
  left:0;
  transition:all 0.5s;
}
#nav{
  height:50px;
  background:yellow;
  position:relative;
}
#open{
  background:tomato;
  width:35px;
  height:35px;
  position:absolute;
  top:50%;
  left:10px;
  transform:translate(0,-50%);
  cursor:pointer;
}
#mask{
  display:none;
  position:absolute;
  width:300px;
  height:500px;
  top:0;
  background:rgba(0,0,0,.5);
}

300ms延迟问题

假定这么一个场景。用户在 iOS Safari 里边点击了一个链接。由于用户可以进行双击缩放或者单击跳转的操作,当用户一次点击屏幕之后,浏览器并不能立刻判断用户是确实要打开这个链接,还是想要进行双击操作。因此,iOS Safari 就等待 300 毫秒,以判断用户是否再次点击了屏幕。

​ 鉴于iPhone的成功,其他移动浏览器都复制了 iPhone Safari 浏览器的多数约定,包括双击缩放,几乎现在所有的移动端浏览器都有这个功能。

方案:

1.禁用缩放

当HTML文档头部包含如下meta标签时:

<meta name="viewport" content="user-scalable=no">
<meta name="viewport" content="initial-scale=1,maximum-scale=1">

表明这个页面是不可缩放的,那双击缩放的功能就没有意义了,此时浏览器可以禁用默认的双击缩放行为并且去掉300ms的点击延迟。

2.更改默认的视口宽度

为了让桌面站点能在移动端浏览器正常显示,移动端浏览器默认的视口宽度并不等于设备浏览器视窗宽度,而是要比设备浏览器视窗宽度大,通常是980px。我们可以通过以下标签来设置视口宽度为设备宽度。

<meta name="viewport" content="width=device-width">

因为双击缩放主要是用来改善桌面站点在移动端浏览体验的,而随着响应式设计的普及,很多站点都已经对移动端坐过适配和优化了,这个时候就不需要双击缩放了,如果能够识别出一个网站是响应式的网站,那么移动端浏览器就可以自动禁掉默认的双击缩放行为并且去掉300ms的点击延迟。如果设置了上述meta标签,那浏览器就可以认为该网站已经对移动端做过了适配和优化,就无需双击缩放操作了。 这个方案相比方案一的好处在于,它没有完全禁用缩放,而只是禁用了浏览器默认的双击缩放行为,但用户仍然可以通过双指缩放操作来缩放页面。

3.使用touch-action这个CSS属性。这个属性指定了相应元素上能够触发的用户代理(也就是浏览器)的默认行为。如果将该属性值设置为touch-action: none,那么表示在该元素上的操作不会触发用户代理的任何默认行为,就无需进行300ms的延迟判断。

4.使用touch事件模拟触发click事件

touch事件模拟click事件

移动端click事件有延迟,像zepto都封装了这类函数,称为tap事件

function tap(element,callback) {
  var startTime = 0;
  var delayTime = 200;
  var isMove = false;
  element.addEventListener('touchstart', function(e){
		startTime = Date.now();
  })
  
  element.addEventListener('touchmove', function(e){
    isMove = true;
  })
  
  element.addEventListener('touchend', function(e){
    if(isMove || (Date.now() - startTime > delayTime)){
			return;
    } else {
      callback(e)
    }
  })
}

var btn = document.getElementById('btn');
tap(btn, function(){
  alert('taped')
})

也可以写为计算滑动距离,未超过该距离则不认为是滑动事件

function tap(element,callback) {
  var startTime = 0;
  var delayTime = 200;
  var [startX, startY, endX, endY] = [0,0,0,0];
  element.addEventListener('touchstart', function(e){
		startTime = Date.now();
    startX = e.touches[0].clientX;
    startY = e.touches[0].clientY;
  })
  
  element.addEventListener('touchmove', function(e){
    endX = e.touches[0].clientX;
    endY = e.touches[0].clientY;
  })
  
  element.addEventListener('touchend', function(e){
    if(Math.abs(endX - startX) > 1000 || Math.abs(endY - startY) || (Date.now() - startTime > delayTime)){
			return;
    } else {
      callback(e)
    }
  })
}

高清屏适配方案

唤醒APP

<a onclick="openApp()">点击唤醒app</a>	

js

var download_schema = 'taobao://'; //app的协议有安卓同事提供,这里是用的淘宝
var universal_link = 'ios下载地址';//ios下载地址
var getVersionUrl = 'Android下载地址';//Android移动端下载地址
var u = navigator.userAgent.toLocaleLowerCase();
//console.log(u);
var isWeixin = u.match(/MicroMessenger/i) == 'micromessenger'; //判断是不是微信浏览器
var isAndroid = u.indexOf('android') > -1 || u.indexOf('linux') > -1; //android终端或者uc浏览器
var isiOS = !! u.match(/(iphone|ipod|ipad|mac)/i);
 
function openApp() {
    //alert('1');
    //alert(isAndroid);
    //alert(isiOS);
    if (isAndroid) {
        android1();
    }
    if (isiOS) {
        ios();
    }
    //alert("调用下载失败"); //此处弹窗时,是没有version参数,如果在app中打开,是会有这个参数的
}
 
function android1() {
    //如果是微信,直接下载
    if (isWeixin) {
        window.location.href = "Android下载地址 "; /***Android移动端下载地址***/
    } else {
        window.location.href = download_schema; /***打开app的协议,有安卓同事提供***/
        window.setTimeout(function () {
            //window.location.href = "Android下载地址";/***Android移动端下载地址***/
            window.location.href = getVersionUrl; /***Android移动端下载地址***/
        }, 100);
    }
}
 
function ios() {
    window.location.href = universal_link + "?schema=" + encodeURIComponent(download_schema);
} 

jsbridge

https://www.zoo.team/article/jsbridge

jsbridge就是JavaScript(H5)与Native通信的桥梁,在H5开发中经常有操作客户端的需求,比如获取App信息,打开/关闭一个WebView,吊起支付面板等等,但这些功能只能在Native中实现,因此诞生JSBridge,通过JSBridge与Native通信,赋予了JavaScript操作Native的能力,同时也给了Native调用JavaScript的能力。

在大多数APP开发过程中,都会通过H5来实现部分功能,而Hybird APP基本90%以上都是H5。现在很少有纯原生的APP。但是,由于H5页面是内嵌到原生应用的WebView组件(一个浏览器内核)中,而手机浏览器Javascript引擎是在一个沙箱环境中运行,因此JavaScript的权限受到严格限制,比如没有本地文件读写权限、不能使用GPS、不能修改系统配置等。所以,如果JavaScript要用到这些受限的能力时,就需要委托原生去实现,原生完成后,再将结果通知JavaScript,因此,JavaScript和原生之间就需要一个通信的桥梁,而这个桥梁本质上就是原生的浏览器组件(我们统一称之为WebView)与Javascript 通信的通道,一般称为 WebView JavaScript Bridge, 为了简单,一般简称为 JS bridge。需要说明的是,原生不仅仅指移动端(Android、IOS)上原生代码开发的部分,它也可以是Windows、MAC上的,所以原生一词主要是为了区分H5,而本文只讨论移动端的Js Bridge 。

JSBridge 是一种 JS 实现的 Bridge,连接着桥两端的 Native 和 H5。它在 APP 内方便地让 Native 调用 JS,JS 调用 Native ,是双向通信的通道。JSBridge 主要提供了 JS 调用 Native 代码的能力,实现原生功能如查看本地相册、打开摄像头、指纹支付等。

简单地说,JSBridge就是定义Native和JS的通信,Native只通过一个固定的桥对象调用JS,JS也只通过固定的桥对象调用Native。JSBridge另一个叫法及大家熟知的Hybrid app技术。

客户端发版限制

尤其是ios,要审核。比如要急着上线一个功能或者活动,那么这时就需要h5。还有就是一些可能会频繁改动或者新增的功能,比如商品详情这种,新增品类啊,某种品类新增类目啊等等,更新频繁的,如果用native开发的话,要频繁的发版,这就不大现实。这也是为什么现在大家比较推崇的Hybird混合开发。

调用流程:

首先设计出一个Native与JS交互的全局桥对象

JS如何调用Native——Native如何得知api被调用——分析url-参数和回调的格式

Native如何调用JS——H5中api方法的注册以及格式

具体方式

Native调用JS

Native 调用 JS 比较简单,只要 H5 将 JS 方法暴露在 Window 上给 Native 调用即可。

Android 中主要有两种方式实现。

在 4.4 以前,通过 loadUrl 方法,执行一段 JS 代码来实现。loadUrl 方法使用起来方便简洁,但是效率低无法获得返回结果且调用的时候会刷新 WebView 。 在 4.4 以后,可以使用 evaluateJavascript 方法实现。evaluateJavascript 方法效率高获取返回值方便,调用时候不刷新 WebView,但是只支持 Android 4.4+。

两种方法都要遵循:”javascript: 方法名(‘参数,需要转为字符串’)”的规则

iOS 在 WKWebview 中可以通过 stringByevaluateJavaScript:javaScriptString 来实现,支持 iOS 8.0 及以上系统。

JS调用Native

注入API:

基于 Webview 提供的能力,我们可以向 Window 上注入对象或方法。JS 通过这个对象或方法进行调用时,执行对应的逻辑操作,可以直接调用 Native 的方法。使用该方式时,JS 需要等到 Native 执行完对应的逻辑后才能进行回调里面的操作。

Android 的 Webview 提供了 addJavascriptInterface 方法

iOS 的 UIWebview 官方库提供了 JavaScriptScore 方法,然后可以将api绑定到JSContext上(然后Html中JS默认通过window.top.***可调用),支持 iOS 7.0 及以上系统。WKWebview 提供了 window.webkit.messageHandlers 方法,支持 iOS 8.0 及以上系统。

UIWebview 在几年前常用,目前已不常见。

拦截 URL Scheme:

Android 和 iOS 都可以通过拦截 URL Scheme 并解析 scheme 来决定是否进行对应的 Native 代码逻辑处理。

iOS 的 WKWebview 可以根据拦截到的 URL Scheme 和对应的参数执行相关的操作。

Android的话,Webview 提供了 shouldOverrideUrlLoading 方法来提供给 Native 拦截 H5 发送的 URL Scheme 请求

这种方法的优点是不存在漏洞问题、使用灵活,可以实现 H5 和 Native 页面的无缝切换。例如在某一页面需要快速上线的情况下,先开发出 H5 页面。某一链接填写的是 H5 链接,在对应的 Native 页面开发完成前先跳转至 H5 页面,待 Native 页面开发完后再进行拦截,跳转至 Native 页面,此时 H5 的链接无需进行修改。但是使用 iframe.src 来发送 URL Scheme 需要对 URL 的长度作控制,使用复杂,速度较慢。

重写prompt等原生js方法

Android 4.2 之前注入对象的接口是 addJavascriptInterface ,但是由于安全原因慢慢不被使用。一般会通过修改浏览器的部分 Window 对象的方法来完成操作。主要是拦截 alert、confirm、prompt、console.log 四个方法,分别被 Webview 的 onJsAlert、onJsConfirm、onConsoleMessage、onJsPrompt 监听。

iOS 由于安全机制, WKWebView 对 alert、confirm、prompt 等方法做了拦截,如果通过此方式进行 Native 与 JS 交互,需要实现 WKWebView 的三个 WKUIDelegate 代理方法。

Kbone

kbone 是一个致力于微信小程序和 Web 端同构的解决方案。

微信小程序的底层模型和 Web 端不同,我们想直接把 Web 端的代码挪到小程序环境内执行是不可能的。kbone 的诞生就是为了解决这个问题,它实现了一个适配器,在适配层里模拟出了浏览器环境,让 Web 端的代码可以不做什么改动便可运行在小程序里。

kbone的优势:

  • 大部分流行的前端框架都能够在 kbone 上运行,比如 Vue、React、Preact 等。
  • 支持更为完整的前端框架特性,因为 kbone 不会对框架底层进行删改(比如 Vue 中的 v-html 指令、Vue-router 插件)。
  • 提供了常用的 dom/bom 接口,让用户代码无需做太大改动便可从 Web 端迁移到小程序端。
  • 在小程序端运行时,仍然可以使用小程序本身的特性(比如像 live-player 内置组件、分包功能)。
  • 提供了一些 Dom 扩展接口,让一些无法完美兼容到小程序端的接口也有替代使用方案(比如 getComputedStyle 接口)。

kbone提供三种方式

对于新项目,可以使用 kbone-cli 来创建项目,首先安装 kbone-cli:

npm install -g kbone-cli

创建项目:

kbone init my-app	

启动项目:

// 开发小程序端
npm run mp

// 开发 Web 端
npm run web

// 构建 Web 端
npm run build

https://github.com/Tencent/kbone

kbone与remax

相同点

都是在 worker 线程维护一棵 vdom tree,然后同步到 render 线程通过 wxml|axml 来进行渲染。

不同点:

  1. kbone 是适配了 js dom api ,上层可以用任何框架,如 react、vue、原生 js 来写小程序。remax 是自已写了一套 react 的 renderer,上层只支持 react。
  2. remax 在 dom tree 发生变化时,不是把整棵 vdom tree 传到 render 线程,而是计算差异,把差异传到 render 线程,这点可以加快了两个线程之间的数据传输速度。

kbone

kbone 在 worker 线程适配了一套 js dom api,上层不管是哪种前端框架(react、vue)或原生 js 最终都需要调用 js dom api 操作 dom,适配的 js dom api 则接管了所有的 dom 操作,并在内存中维护了一棵 dom tree,所有上层最终调用的 dom 操作都会更新到这棵 dom tree 中,每次操作(有节流)后会把 dom tree 同步到 render 线程中,通过 wxml 自定义组件进行 render。

因此所有小程序的代码都是放在 worker 上跑,开发者可以通过不同的前端框架(react、vue、angular) 或原生 js 来构建小程序了。

worker线程

worker 线程会运行所有的小程序代码,并适配了 js dom api 和定义一套数据结构来描述一棵 dom tree。

模拟 js dom api 就是把 api 函数重新实现一次,这些函数用来操作自己在内存中维护的 dom tree,

在 worker 线程中本身是没有 document 对象的,只需要把自己模拟的 document 对象存放到全局变量中,那上层的前端框架或原生 js 代码就能调用到了。通过 document 创建的每个节点有四个重要的属性:

  1. type: 当前节点类型
  2. parentNode:父节点对象
  3. childNodes: 孩子节点对象数组

dom tree是一棵多叉树,每个节点定义了当前节点的属性和孩子节点。接下来就是把这棵树传到 render 线程,并由 render 线程把他显示出来。这里传到 render 线程采用的是小程序提供的方法 setData,把这棵 dom tree 当成数据传到 render 界面。

render线程

上面代码是 wxml 语法写的一个小程序界面,worker 线程中的内存 dom tree 可以和 wxml 里的节点一一对应,只需要把 dom tree 通过递归迭代映射到 wxml 的节点。

kbone 定义了一个 [Element 自定义组件],用于渲染 dom tree 上的每个节点和他的孩子节点。 Element 节点做的事情比较简单,首先是把自己渲染出来,然后再把子节点渲染出来,同时子节点的子节点又通过 Element 来渲染,这样就通过自定义组件实现了递归功能,这是 wxml 自定义组件提供的自引用特性,每个节点通过 dom 节点的 type 来区分,从而把一棵内存 dom tree 通过 wxml 渲染出来了。

Kbone提供运行时的兼容方案. 通过伪造的 window document 闭包运行将 jsx vue 文件的运行结果转成虚拟 dom 树的方式, 再通过自定义组件的方式(合并节点组层), 最后渲染到小程序中. 本质上是一种 view = f(state) 的方式.

这种方式保持了 web 的开发习惯, 能兼容多种框架. 只需要有对应的 render 就可以做到兼容其他小程序. 但是也存在一定的上限, 也就是页面节点数越多, 则带来的计算虚拟节点的消耗也越来越大.

理论上通过 BOM/DOM 的模拟伪造, 可以达到运行时完全兼容. 之后主要精力则放在了设备 APIS 的差异化上.

同时由于闭包实现的机制 页面间的数据共享等方案需要特殊对待 https://github.com/Tencent/kbone/issues/71 中讨论的问题.

微信小程序团队出品, 平台也有一定的支持, miniprogram-element miniprogram-render 不会计算到小程序的包大小中.

同时也带来了额外问题, 如果有个性化修改, 同样也得不到内置的支持. 可能不会继续支持其他平台.

Remax

Remax 是通过 react 来写小程序,整个小程序是运行在 worker 线程,remax 实现了一套自定义的 renderer,原理是在 worker 线程维护了一套 vdom tree,这个 vdom tree 会通过小程序提供的 setData 方法传到 render 线程,render 线程则把 vdom tree 递归的遍历出来。

所以整体实现和 kbone 类似,都是在 worker 线程维护一棵 dom tree,再把这棵 dom tree 传到 render 线程进行渲染,唯一的区别是 remax dom tree 发生变化时,会计算差异,而不需要把整棵树都传到 render 线程,此功能是 react 提供的,就是在 diff 完后找出差异,则把差异传到 render 线程,

差异里面记录好了是哪个节点要进行删除或添加,其中 path 变量标识是树上的哪个节点,如 root.children.0.children.1,他代表的意思就是顶节点下第 0 个孩子节点下的第 1 个孩子节点。

render 线程会记录一棵 vdom tree 在内存中,每次 worker 线程传过来的 patch 会标识要操作树上的哪些节点,把这些节点 patch 到 render 线程的 vdom tree 上后,再更新到界面上。

Taro

使用 npm 安装 CLI

npm install -g @tarojs/cli

项目初始化

taro init myApp

在创建完项目之后,Taro 会默认开始安装项目所需要的依赖,安装使用的工具按照 yarn>cnpm>npm 顺序进行检测,一般来说,依赖安装会比较顺利,但某些情况下可能会安装失败,这时候你可以在项目目录下自己使用安装命令进行安装

不同小程序打包

选择微信小程序模式,需要自行下载并打开微信开发者工具,然后选择项目根目录进行预览

# npm script
$ npm run dev:weapp
$ npm run build:weapp

选择百度小程序模式,需要自行下载并打开百度开发者工具,然后在项目编译完后选择项目根目录下 dist 目录进行预览

# npm script
$ npm run dev:swan
$ npm run build:swan

taro多端兼容的原理

taro 有自己实现的类 react 实现, 写法上与 react 无异. 编译时转义, 最大的好处就是能够保证各端上的运行效率不会太差, 只是牺牲部分框架兼容的性能, 静态模板的能力得以保留. 同样的由于 react jsx 语法的灵活性, 无法做到百分百的兼容转义, 在开发时就要注意, 按照官方给的最佳实践来书写.

taro 有众多的社区实践, 表明次方法是可以走得通的. 只要避开一些高级的写法.

转义同时带来另一个开发体验的不友好. 由于经过 CODE > AST > AST > CODE 的过程, 大量穷举 jsx 语法的转换, 丢失了 sourceMap.

由于这种转换的无穷尽, taro 团队长期陷入对模板的转义维护. 包括对 DSL 的跟进程度. 生态自建等. 无法复用社区组件等.

架构特点:

  • 重编译时,轻运行时:这从两边代码行数的对比就可见一斑。
  • 编译后代码与 React 无关:Taro 只是在开发时遵循了 React 的语法。
  • 直接使用 Babel 进行编译:这也导致当前 Taro 在工程化和插件方面的羸弱。

mpvue也是将 Vue SFC 写法的代码编译成 小程序代码文件(JS、WXML、WXSS、JSON)。

最大的区别是 Taro 将 JSX 编译成 小程序模版,而 mpvue 是将 Vue 模版编译成 小程序模版。但是由于 Vue 模版和 小程序模版的相似性,mpvue 在这一块的工作量比 Taro 少得多。

而 mpvue 的运行时和 Vue 的运行时是强关联的,首先我们来看看 Vue 的运行时。

一个 .vue 的单文件由三部分构成: template, script, style

橙色路径部分, template 会在编译的过程中,在 vue-loader 中通过 ast 进行分析,最终生成一段 render 函数,执行 render 函数会生成虚拟dom树,虚拟 DOM 树是对真实 DOM 树的抽象,树中的节点被称作 vnode 。

Vue 拿到 虚拟 DOM 树之后,就可以去和上次老的 虚拟 DOM 树 做 patch diff 对比。patch 阶段之后,vue 就会使用真实的操作DOM 的方法(比如说 insertBefore , appendChild 之类的),去操作DOM结点,更新视图。

同时,绿色路径的部分,在实例化 Vue 的时候,会对数据 data 做响应式的处理,在监测到 data 发生改变时,会调用 render 函数,生成最新的虚拟 DOM 树, 接着对比老的虚拟 DOM 树进行 patch, 找出最小修改代价的 vnode 节点进行修改。

而 mpvue 的运行时,会首先将 patch 阶段的 DOM 操作相关方法置空,也就是什么都不做。其次,在创建 Vue 实例的同时,还会偷偷的调用 Page() 用于生成了小程序的 page 实例。然后 运行时的 patch 阶段会直接调用 $updateDataToMp() 方法,这个方法会获取挂在在 page 实例上维护的数据 ,然后通过 setData 方法更新到视图层。

和 Taro 重编译时轻运行时不同,mpvue 算是:半编译时,半运行时。这点从代码量的对比也能大致反映出来。

mpvue 的 WXML 模版和 Taro 一样,也是通过代码编译得到的;不同于 Taro 运行时和 React 无关,mpvue 本质上还是将 Vue 运行在了小程序,且实现了 Vue@2.4.1 绝大部分特性(只有极少数特性由于小程序模版的限制未能实现,如 :filterslotv-html);且整个框架基于 Webpack 实现了较为完善的工程化。

taro团队掘金总结:https://juejin.cn/post/6844904036743774216#heading-4

taro@Next

taro@Next和之前的架构不同,Taro Next 是 近乎全运行

新架构的特点:

  • 无 DSL 限制:无论是你们团队是 React 还是 Vue 技术栈,都能够使用 Taro 开发(domain-specific language (DSL) ,即:领域特定语言,可以理解为:React/Vue 不同的语法就是不同的 DSL。 )
  • 模版动态构建:和之前模版通过编译生成的不同,Taro Next 的模版是固定的,然后基于组件的 template,动态 “递归” 渲染整棵 Taro DOM 树。
  • 新特性无缝支持:由于 Taro Next 本质上是将 React/Vue 运行在小程序上,因此,各种新特性也就无缝支持了。
  • 社区贡献更简单:错误栈将和 React/Vue 一致,团队只需要维护核心的 taro-runtime。
  • 基于 Webpack:Taro Next 基于 Webpack 实现了多端的工程化,提供了插件功能。

taro@Next创建了 taro-runtime 的包,然后在这个包中实现了 一套 高效、精简版的 DOM/BOM API,然后通过 Webpack 的 ProvidePlugin 插件,注入到小程序的逻辑层。

DOM/BOM 注入之后,理论上来说,Nerv/Preact 就可以直接运行了。但是 React 有点特殊,因为 React-DOM 包含大量浏览器兼容类的代码,导致包太大,而这部分代码我们是不需要的,因此我们需要做一些定制和优化。

React 16之后的架构,最上层是 React 的核心部分 react-core ,中间是 react-reconciler,其的职责是维护 VirtualDOM 树,内部实现了 Diff/Fiber 算法,决定什么时候更新、以及要更新什么。

Renderer 负责具体平台的渲染工作,它会提供宿主组件、处理事件等等。例如 React-DOM 就是一个渲染器,负责 DOM 节点的渲染和 DOM 事件处理。

因此,taro@Next实现了 taro-react 包,用来连接 react-reconcilertaro-runtime 的 BOM/DOM API:

具体的实现主要分为两步:

  1. 实现 react-reconcilerhostConfig 配置,即在 hostConfig 的方法中调用对应的 Taro BOM/DOM 的 API。
  2. 实现 render 函数(类似于 ReactDOM.render)方法,可以看成是创建 Taro DOM Tree 的容器。

Vue 和 React 最大的区别就在于运行时的 CreateVuePage 方法,这个方法里进行了一些运行时的处理,比如:生命周期的对齐。

其他的部分,如通过 BOM/DOM 方法构建、修改 DOM Tree 及渲染原理,都是和 React 一致的。

性能优化

同等条件下,编译时做的工作越多,也就意味着运行时做的工作越少,性能会更好。Taro Next 的新架构变成 近乎全运行 之后,花了很多精力在性能优化上面。

相比原生小程序,Taro Next 多了一些其他工作,如:引入React/Vue 带来的 包的 Size 增加,运行时的损耗、Taro DOM Tree 的构建和更新、DOM data 初始化和更新。

Taro DOM Tree 的构建和更新DOM data 初始化和更新两个过程是主要的性能优化方向

包大小

和之前模版通过编译生成的不同,Taro Next 的模版是固定的,然后基于组件的 template,动态 “递归” 渲染整棵 Taro DOM 树。也就是说,Taro Next 的 WXML 大小是有上限的

随着项目的增加,页面越来越多,原生的项目 WXML 体积会不断增加,而 Taro Next 不会。也就是说,当页面的数量超过一个临界点时,Taro Next 的包体积可能会更小。因此,包 Size 的问题不足为虑。

DOM Tree

在 Taro DOM Tree 的构建和更新阶段,我们实现了一套仅实现了高效的、精简版 DOM/BOM API,而且仅仅实现了必要的。

Github上有一个仓库 jsdom,基本上是在 Node.js 上实现了一套 Web 标准的 DOM/BOM ,这个仓库的代码在压缩前大概有 2.1M,而 Taro Next 的核心的 DOM/BOM API 代码才 1000 行不到。

因此,我们最大限度的保证了 Taro DOM Tree 构建和更新阶段的性能。

Update data

在数据更新阶段,首先前面有提到过,Taro Next 的更新是 DOM 级别的,比 Data 级别的更新更加高效,因为 Data 粒度更新实际上是有冗余的,并不是所有的 Data 的改变最后都会引起 DOM 的更新

其次,Taro 在更新的时候将 Taro DOM Tree 的 path 进行压缩,这点也极大的提升了性能。

最终的结果是:在某些业务场景写,addselect 数据,Taro Next 的性能比原生的还要好。

Remax

remax的特点

Taro:静态编译,没有真实的用到React,只是使用了react的写法,写法上有诸多限制。

Remax: Remax 的运行时本质是一个通过 react-reconciler 实现的一个小程序端的渲染器

remax的缺点:

  • 需要加载react相关依赖 依赖体积问题(压缩后大概208kb,压缩前750kb),页面.axml(压缩前后差不多24kb)
  • 用户较少,稳定性有待验证,有issue提出渲染卡顿

remax的优点:

  • 完整的使用了react
  • 完整的 TypeScript 支持
  • 支持引用自定义组件,引用便捷

目前的多端开发主要就两种模式 编译转换 运行兼容 两种方式的优缺点都非常明显;

静态编辑相对而言能提供更好的体验支持, 对开发者有一定的语法约束, 调试能力的要求.

运行时兼容优点无需额外的学习成本(目前都在往这个方向上不断靠拢)也就是 BOM/DOM 的支持. 缺点也很明显, 由于小程序对 setData & 节点长度的限制. 运行时兼容首次渲染/长列表上的性能会受较大的影响. 页面更新基本上框架做到 diff update

目前社区中基本都在 运行时 上做文章, 因为这对开发者来说能最小的迁移成本, 以及扩展成本. 只要开发对应端的 react-reconciler 即可. 对性能要求苛刻的, 基本都写原生了.

另一个观点就是设备的性能逐步提高, 优化业务逻辑的更新效率, 用户体验能逐步的提高, 也就更倾向于该方案了.

Flipper

flipper是facebook开源的h5真机调试工具

可以像xcode一样启动

https://fbflipper.com/

在js中使用flipper

安装

$ npm install js-flipper

启动

import flipperClient from 'js-flipper';

// Start the client and pass your app name
flipperClient.start('My cool browser app');
如果你觉得我的文章对你有帮助的话,希望可以推荐和交流一下。欢迎關注和 Star 本博客或者关注我的 Github