Lxxyx

Lxxyx 查看完整档案

南昌编辑南昌大学  |  工商管理方向-人力资源管理 编辑淘系前端团队  |  高级前端工程师 编辑 blog.lxxyx.cn 编辑
编辑

前端工程师@淘宝FED & 南昌大学家园工作室,热爱前端与Node.js。
Email:Lxxyxzj@gmail.com
技术博客:https://blog.lxxyx.cn

个人动态

Lxxyx 赞了文章 · 2020-04-04

Algebraic Effects,以及它在React中的应用

Algebraic Effects是一个在编程语言研究领域新兴的机制,虽然目前还没有工业语言实现它,但是在React社区会经常听到关于它的讨论。React最近新特性(Suspense和hooks)的背后实际上是Algebraic Effects的概念。因此,我花了一些时间来了解Algebraic Effects,希望体悟到React团队是如何理解这些新特性的。

Algebraic Effects

每一个Algebraic Effect都是一轮【程序控制权】的移交与归还:

【effect发起者】发起effect,并暂停执行(暂时交出程序控制权)
-> 沿着调用栈向上查找对应的effect handler(类似于try...catch的查找方式)
-> effect handler执行(获得程序控制权)
-> effect handler执行完毕,【effect发起者】继续执行(归还程序控制权)

例子(这并不是合法的JavaScript):

function getName(user) {
  let name = user.name;
  if (name === null) {
      name = perform 'ask_name'; // perform an effect to get a default name!
  }
  return name;
}

function makeFriends(user1, user2) {
  user1.friendNames.push(getName(user2));
  user2.friendNames.push(getName(user1));
}

const arya = { name: null, friendNames: [] };
const gendry = { name: 'Gendry', friendNames: [] };
try {
  makeFriends(arya, gendry);
} handle (effect) { // effect handler!
  if (effect === 'ask_name') {
    const defaultName = await getDefaultNameFromServer();
      resume with defaultName; // jump back to the effect issuer, and pass something back!
  }
}

console.log('done!');

注意几点:

  1. effect发起者不需要知道effect是如何执行的,effect的执行逻辑由调用者来定义。“做什么”与“怎么做”相互解耦了

    Algebraic effects can be a very powerful instrument to separate the what from the how in the code.
    这一点与try...catch相同,抛出错误的人不需要知道错误是如何被处理的。
    getName可以看成纯函数,因为它只发出“要做什么”的指示,而没有自己实际去做。这样的函数非常容易测试,因为它本身没有执行任何的副作用。
  2. effect执行完以后,会回到effect发起处,并提供effect的执行结果。

    这一点与try...catch不同,try...catch无法恢复执行。
  3. 中间调用者对Algebraic Effects是无感的,比如例子中的makeFriends

Algebraic Effects 与 async / await 的区别

用async / await实现上面的例子:

async function getName(user) {
  let name = user.name;
  if (name === null) {
      name = await getDefaultNameFromServer();
  }
  return name;
}

async function makeFriends(user1, user2) {
  user1.friendNames.push(await getName(user2));
  user2.friendNames.push(await getName(user1));
}

const arya = { name: null, friendNames: [] };
const gendry = { name: 'Gendry', friendNames: [] };

makeFriends(arya, gendry)
  .then(() => console.log('done!'));

异步性会感染所有上层调用者

可以发现,makeFriends现在变成异步的了。这是因为异步性会感染所有上层调用者。如果要将某个同步函数改成async函数,是非常困难的,因为它的所有上层调用者都需要修改。
而在前面Algebraic Effects的例子中,中间调用者makeFriends对Algebraic Effects是无感的。只要在某个上层调用者提供了effect handler就好。

可复用性的区别

注意另一点,getName直接耦合了(写死了)副作用方法getDefaultNameFromServer。而在前面Algebraic Effects的例子中,副作用的执行逻辑是【在运行时】【通过调用关系】【动态地】决定的。这大大增强了getName的可复用性。

在async / await的例子中,通过依赖注入能够达到与Algebraic Effects类似的可复用性。如果getName通过依赖注入来得到副作用方法getDefaultNameFromServer(比如getName通过函数参数来拿到副作用方法),那么getName函数在可复用性上确实与使用Algebraic Effects时相同,并且也是易于测试的(测试的时候注入一个假的getDefaultNameFromServer即可)。但是前面所说的【异步性会感染所有上层调用者】的问题依然存在,getNamemakeFriends都要变成异步的。

Algebraic Effects 与 Generator Functions 的区别

与async / await类似,Generator Function的调用者在调用Generator Function时也是有感的。Generator Function将程序控制权交给它的直接调用者,并且只能由直接调用者来恢复执行、提供结果值。

直接调用者也可以选择将程序控制权沿着执行栈继续向上交。这样的话,直接调用者(下面例子的makeFriends)自己也要变成Generator Function(被感染,与async / await类似),直到遇到能提供【结果值】的调用者(下面例子的main)。

function* getName(user) {
  let name = user.name;
  if (name === null) {
      name = yield 'ask_name'; // perform an effect to get a default name!
  }
  return name;
}

function* makeFriends(user1, user2) {
  user1.friendNames.push(yield* getName(user2));
  user2.friendNames.push(yield* getName(user1));
}

async function main() {
  const arya = { name: null, friendNames: [] };
  const gendry = { name: 'Gendry', friendNames: [] };
  
  let gen = makeFriends(arya, gendry);
  let state = gen.next();
  while(!state.done) {
      if (state.value === 'ask_name') {
          state = gen.next(await getDefaultNameFromServer());
      }
  }
}

main().then(()=>console.log('done!'));

可以看出,在可复用性上,getName没有直接耦合副作用方法getDefaultNameFromServer,而是让某个上层调用者来完成副作用。这一点与使用Algebraic Effects时相同。
redux-sagas就使用Generator Functions,将副作用的执行从saga中抽离出来,saga只需要向调用者发出副作用请求,并将执行权交给调用者,而不自己执行副作用。这使得saga成为纯函数,易于测试:

// 这是一个saga
function* fetchUser(action) {
   try {
      const user = yield call(Api.fetchUser, action.payload.userId);
      yield put({type: "USER_FETCH_SUCCEEDED", user: user});
   } catch (e) {
      yield put({type: "USER_FETCH_FAILED", message: e.message});
   }
}

理论上,确实可以利用generator function的控制权转移来实现Algebraic Effects。但是,无法避免感染调用者的问题,无法像真正的Algebraic Effects那样让调用者无感知。你需要将所有的Functional Component、custom hooks都改造成generator function。并且generator function只能从上次yield的地方恢复执行,而不能恢复到更早的yield状态。详见React Fiber架构:可控的“调用栈”

React中的Algebraic Effects

虽然JavaScript语言不支持Algebraic Effects(事实上,支持Algebraic Effects的语言屈指可数),但是得益于React自己实现的Fiber执行模型,React可以提供接近Algebraic Effects的能力。

React Fiber架构:可控的“调用栈”这篇文章中,我们讨论了React Fiber架构是一种可控的执行模型,每个fiber执行完自己的工作以后就会将控制权交还给调度器,由调度器来决定什么时候执行下一个fiber。

Suspend

<Suspend>就是一个例子。当React在渲染的过程中遇到尚未就绪的数据时,能够暂停渲染。等到数据就绪的时候再继续:

// cache相关的API来自React团队正在开发的react-cache:
// https://github.com/facebook/react/tree/master/packages/react-cache
const cache = createCache();
const UserResource = createResource(fetchUser); // fetchUser is async

const User = (props) => {
    const user = UserResource.read( // 用同步的方式来编写异步代码!
        cache,
          props.id
    );
  return <h3>{user.name}</h3>;
}

function App() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <User id={123} />
      </Suspense>
    </div>
  );
}
react-cache是React团队正在开发的特性,将<Suspense>用于数据获取的场景,让需要等待数据的组件“暂停”渲染。
目前已经发布的,通过React.lazy来暂停渲染的能力,其实也是类似的原理。

在心智模型中UserResource.read可以看做发起了一个Algebraic Effect。User发出这个effect以后,控制权暂时交给了React(因为React是User的调用者)。React scheduler提供了对应的effect handler,检查cache中是否有对应id的user:

  • 如果在cache中,则立即将控制权交还给User,并提供对应的user数据。
  • 如果不在cache中,则调用fetchUser从网络请求对应id的user,在此过程中,渲染暂停,<Suspense>渲染fallback视图。得到结果以后,将控制权交还给User,并提供对应的user数据。

在实际实现中它是通过throw来模拟Algebraic Effect的。如果数据尚未准备好,UserResource.read抛出一个特殊的promise。得益于React Fiber架构,调用栈并不是React scheduler -> App -> User,而是:先React scheduler -> App然后React scheduler -> User。因此User组件抛出的错误会被React scheduler接住,React scheduler会将渲染“暂停”在User组件。这意味着,前面的App组件的工作不会丢失。等到promise解析到数据以后,从User fiber开始继续渲染(相当于控制权直接交还给User)。
继续渲染的方式:React scheduler从上次暂停的组件开始(即User组件),调用render进行渲染,这次渲染的时候User组件能够从cache立即拿到数据。

得益于“render是纯函数”的契约,重新执行User->render不会担心有副作用;
得益于“React已经教育用户使用useMemo来缓存昂贵计算”,重新执行User->render不会有明显的性能劣势。

如果直接使用调用栈来管理组件树的渲染(递归渲染),那么App组件的渲染工作会因为User抛出值而丢失,下次渲染需要从头开始。

Hooks

React团队将hooks都看做Algebraic Effect,React调度器提供了各种基本hooks的“effect handler”,在调用render函数的时候为其提供上下文信息。

比如,useState的返回值取决于它的所处“调用上下文”,即它在组件树中的节点,即fiber。比如一个组件树中有2个Counter组件,那么这两个Counter调用useState的时候,所处的上下文是不一样的,因此它们的useState返回值是独立的。useMemo同理。

参考资料

Algebraic effects, Fibers, Coroutines...
Algebraic Effects for the Rest of Us
Fiber Principles: Contributing To Fiber

查看原文

赞 5 收藏 2 评论 0

Lxxyx 提出了问题 · 2019-10-26

iOS 的 Today Extension 如何使用 SwiftUI 开发?

RT。

之前 Extension 都是用的 Storybook,想使用 SwiftUI 开发却不知道怎么做。官方没有文档也没有 Demo 啥的。

求指教,谢谢

关注 1 回答 0

Lxxyx 提出了问题 · 2019-10-26

iOS 的 Today Extension 如何使用 SwiftUI 开发?

RT。

之前 Extension 都是用的 Storybook,想使用 SwiftUI 开发却不知道怎么做。官方没有文档也没有 Demo 啥的。

求指教,谢谢

关注 1 回答 0

Lxxyx 关注了用户 · 2019-07-23

家园工作室 @jiayuangongzuoshi

家园在你身后,世界尽在眼前。

关注 11

Lxxyx 评论了文章 · 2018-10-24

给自己的网站添加网易云音乐歌单吧^ ^

最近应该发现,我的博客https://blog.codelabo.cn左下角多了一个音乐播放器

aplayer

这个是怎么实现的?一起来看看吧

APlayer

首先我们需要一个音频播放器,这里我用到了APlayer,这是由bilibili前端大神DIYgod开源的播放器,有兴趣的可以去TA的主页看看,非常惊艳,这里我就不多说了

我们看一下APlayer的官方文档,方法很简单

<link rel="stylesheet" href="APlayer.min.css">
<div id="aplayer"></div>
<script data-original="APlayer.min.js"></script>
const ap = new APlayer({
    container: document.getElementById('aplayer'),
    audio: [{
        name: 'name',
        artist: 'artist',
        url: 'url.mp3',
        cover: 'cover.jpg'
    }]
});

这里的audio是一个音频列表,可以是一个对象或对象数组

对象具体的参数如下

名称描述
name音频名称
artist音频艺术家
url音频链接
cover音频封面
lrc音频歌词

LRC一共有三种方式来给 APlayer 传递歌词,详情可参考https://aplayer.js.org/#/home?id=lrc

这里我们选择最方便的一种,直接给LRC链接

网易云音乐API

这部分我找到了网上有人分享的API,这种官方不可能给公开API,所以还是要小心使用,说不定哪天就被修改了

http://moonlib.com/606.html

我们现在其实想要两个,一个是歌单的列表,还有一个是歌词。

# 歌单

https://music.163.com/api/playlist/detail?id=37880978
id为歌单ID
# 歌词

https://music.163.com/api/song/lyric?os=pc&id=93920&lv=-1&kv=-1&tv=-1
id为歌曲ID
lv:值为-1,我猜测应该是判断是否搜索lyric格式
kv:值为-1,这个值貌似并不影响结果,意义不明
tv:值为-1,是否搜索tlyric格式

接口实现

虽然我们已经找到了网易云音乐API,但是返回的数据不是我们所需要的呀

比如这个歌单的接口

# Request

https://music.163.com/api/playlist/detail?id=2119983629

# Response

{
  "result":{
      "subscribers":[],
      "subscribed": false,
      "creator":{...},
      "artists": null,
      "tracks":[
        {
          album: {
              name: "メトロノーム", 
              id: 36787278, type: "专辑", 
              size: 12, picId:18419018788768520,
          }
          alias: [],
          artists: [{name: "MACO", id: 901025, picId: 0, img1v1Id: 0, briefDesc: "",…}],
          audition: null,
          bMusic: {...},
          commentThreadId: "R_SO_4_515573221",
          copyFrom: "",
          copyright: 1,
          copyrightId: 7003,
          crbt: null,
          ...
        }
      ]
  }
}

里面字段很多,我上面只列举了一部分,tracks就是歌单列表,但是很显然,和我们需要的格式还差很多

那么怎么来转换一下,变成我们需要的数据格式呢?

[{
    name: 'name',
    artist: 'artist',
    url: 'url.mp3',
    cover: 'cover.jpg',
    lrc: 'a.lrc'
}]

这里我们就需要在服务端来完成了,思路很简单,在服务器上请求https://music.163.com/api/playlist/detail?id=2119983629这个接口,然后拿到结果后手动处理一下,最后再返给客户端,相当于做了一次中转

我这里服务端是用koa实现的,其他框架应该差不多

服务端发起请求

在服务端发起请求也可以用我们熟悉的fetch,不过你需要先安装node-fetch这个库

yarn add node-fetch

然后你就可以像前端一样发起请求了

const fetch = require('node-fetch');

//...

const getPlayList = (id) => {
    return fetch(`http://music.163.com/api/playlist/detail?id=${id}`)
    .then((response) => {
        if (response.ok) {
            return response.json();
        }
    })
    .catch((err) => {
        console.warn(err);
    })
}

接口定义

现在我们需要新增一个接口用来处理歌单,返回出我们需要的格式

//获取音乐列表
router.get('/playlist/:id', async (ctx, next) => {
    const responseData = {
        "success": false,
        "data":[],
        "message": "",
    }
    const { id } = ctx.params;
    try {
        const data = await getPlayList(id);
        if(data.code===200){
            const playList = data.result.tracks.map(item=>({
                id: item.id,
                name: item.name,
                artist: item.artists.map(el=>el.name).join(','),//由于歌手是一个数组,这里我们把它转换成字符串拼接
                url: `https://music.163.com/song/media/outer/url?id=${item.id}.mp3`,//歌曲地址
                cover: item.album.picUrl.replace(/http:/,'https:'),
                lrc:null
            }))
            responseData.success = true;
            responseData.message = '操作成功';
            responseData.data = playList;
            ctx.body = responseData;
        }
    } catch (error) {
        responseData.success = false;
        responseData.message = error.message;
        responseData.data = [];
        ctx.body = responseData;
    }
});

注意这里的歌曲链接url,本来返回信息里面是不包含的,只有歌曲ID,不过我们发现通过https://music.163.com/song/media/outer/url?id=ID可以直接在线播放指定ID的歌曲,所以我们这里直接写在返回结果上。

歌词处理

还有一个问题就是歌词,上面的接口中,歌词返回结果也不是我们需要的格式

# Request

https://music.163.com/api/song/lyric?os=pc&id=93920&lv=-1&kv=-1&tv=-1

# Response

{
  "sgc": true,
  "sfy": false,
  "qfy": false,
  "lrc": {
    "version": 7,
    "lyric": "[00:29.620]细雨带风湿透黄昏的街道\n[00:35.050]抹去雨水双眼无帮地仰望\n[00:40.240]望向孤单的晚灯是那伤感的记忆\n[00:48.630]再次泛起心里无数的思念\n[00:54.000]以往片刻欢笑仍挂在脸上\n[00:58.770]愿你此刻可会知是我衷心的说声\n[01:06.310]喜欢你\n[01:08.940]那双眼动人笑声更迷人\n[01:14.330]愿再可轻抚你那可爱面容\n[01:22.490]挽手说梦话象昨天你共我\n[01:42.970]满带理想的我曾经多冲动\n[01:48.340]埋怨与她相爱难有自由\n[01:53.040]愿你此刻可会知是我衷心的说声\n[02:00.420]喜欢你\n[02:03.230]那双眼动人笑声更迷人\n[02:08.540]愿再可轻抚你那可爱面容\n[02:16.750]挽手说梦话象昨天你共我\n[02:24.740]每晚夜里自我独行\n[02:27.670]随处荡 多冰冷\n[02:35.070]以往为了自我挣扎从不知她的痛苦\n[02:49.380]喜欢你\n[02:52.020]那双眼动人笑声更迷人\n[02:57.420]愿再可轻抚你那可爱面容\n[03:05.590]挽手说梦话象昨天你共我\n[03:13.870]挽手说梦话象昨天你共我\n"
  },
  "klyric": {...},
  "code": 200
}

反正就是很全面,但是我们需要的仅仅是里面的内容部分,比如上面我就只需要这一段

[00:29.620]细雨带风湿透黄昏的街道
[00:35.050]抹去雨水双眼无帮地仰望
[00:40.240]望向孤单的晚灯是那伤感的记忆
[00:48.630]再次泛起心里无数的思念
[00:54.000]以往片刻欢笑仍挂在脸上
[00:58.770]愿你此刻可会知是我衷心的说声
...

所以我们需要再次做一个中介处理

const getLyric = (id) => {
    return fetch(`http://music.163.com/api/song/lyric?os=pc&id=${id}&lv=-1&kv=-1&tv=-1`)
    .then((response) => {
        if (response.ok) {
            return response.json();
        }
    })
    .catch((err) => {
        console.warn(err);
    })
}

//获取音乐歌词
router.get('/lyric/:id', async (ctx, next) => {
    const { id } = ctx.params;
    try {
        const lyric = await getLyric(id);
        ctx.body = lyric.lrc.lyric;//返回指定部分
    } catch (error) {
        ctx.body = '';
    }
});

这样在上面歌词列表中就可以直接用/api/lyric/:ID来获取歌词了

//...
{
  id: item.id,
  name: item.name,
  artist: item.artists.map(el=>el.name).join(','),
  url: `https://music.163.com/song/media/outer/url?id=${item.id}.mp3`,
  cover: item.album.picUrl.replace(/http:/,'https:'),
  lrc:`/api/lyric/${item.id}`//这里歌词写上我们定义的接口地址
}
//...

测试一下吧

通过以上处理,我们接口就返回我们自定义的数据格式了

# Request

https://localhost:3000/api/playlist/2119983629

# Response

{
  "success": true,
  "data": [
     {
      "id": 515573221,
      "name": "Sweet Memory",
      "artist": "MACO",
      "url": "https://music.163.com/song/media/outer/url?id=515573221.mp3",
      "cover": "https://p1.music.126.net/-U7mfaIjENUu8G_O0Dhv8g==/18419018788768520.jpg",
      "lrc": "/api/lyric/515573221"
    },
    {
      "id": 488388942,
      "name": "願い~あの頃のキミへ~",
      "artist": "當山みれい",
      "url": "https://music.163.com/song/media/outer/url?id=488388942.mp3",
      "cover": "https://p1.music.126.net/kbLlBkGfEcA3RJyC5JhkDA==/18346451021830743.jpg",
      "lrc": "/api/lyric/488388942"
    },
    ...
  ],
  "message": "操作成功"
}

添加到博客

其实上面对接口的数据改造才是关键,下面添加到自己的页面就很简单了。

如果你是传统HTML页面,可以直接文章开头的方式引用

<link rel="stylesheet" href="APlayer.min.css">
<div id="aplayer"></div>
<script data-original="APlayer.min.js"></script>
const ap = new APlayer({
    container: document.getElementById('aplayer'),
    audio: [{
        name: 'name',
        artist: 'artist',
        url: 'url.mp3',
        cover: 'cover.jpg'
    }]
});

如果使用了使用模块管理器:

import 'APlayer/dist/APlayer.min.css';
import APlayer from 'APlayer';

const ap = new APlayer(options);

如果是react项目,那么可以用封装好的react-aplayer

import React from 'react';
import ReactAplayer from 'react-aplayer';

export default class App extends React.Component {
  // event binding example
  onPlay = () => {
    console.log('on play');
  };

  onPause = () => {
    console.log('on pause');
  };

  // example of access aplayer instance
  onInit = ap => {
    this.ap = ap;
  };

  render() {
    const props = {
      theme: '#F57F17',
      lrcType: 3,
      audio: [
        {
          name: '光るなら',
          artist: 'Goose house',
          url: 'https://moeplayer.b0.upaiyun.com/aplayer/hikarunara.mp3',
          cover: 'https://moeplayer.b0.upaiyun.com/aplayer/hikarunara.jpg',
          lrc: 'https://moeplayer.b0.upaiyun.com/aplayer/hikarunara.lrc',
          theme: '#ebd0c2'
        }
      ]
    };

    return (
      <div>
        <ReactAplayer
          {...props}
          onInit={this.onInit}
          onPlay={this.onPlay}
          onPause={this.onPause}
        />
        {/* example of access aplayer instance API */}
        <button onClick={() => this.ap.toggle()}>toggle</button>
      <div>
    );
  }
}

如果是vue项目,可以使用vue-aplayer

<aplayer autoplay
  :music="{
    title: 'secret base~君がくれたもの~',
    artist: 'Silent Siren',
    src: 'https://moeplayer.b0.upaiyun.com/aplayer/secretbase.mp3',
    pic: 'https://moeplayer.b0.upaiyun.com/aplayer/secretbase.jpg'
  }"
/>

其他更多可以参考Aplayer生态

小节

这里的歌单,我选择了自己收藏的歌曲。每次用网易云音乐客户端播放听歌的时候,收藏的歌曲,在我的博客上也可以同步进行更新。

差不多就这些了,可能对于专业后端开发来说,这些完全就是小学生操作,但是对于一个前端来说,做这些事就感觉闯入了一片新天地,还是有很多感悟的。很多以前前端做不了的事,现在nodeJS也能帮我们解决,进一步打通了前后端的天然屏障,离全栈也越来越近了 ^ ^

大家如果喜欢我的博客,可以多多关注一下

查看原文

Lxxyx 赞了文章 · 2018-10-18

如何编写 Typescript 声明文件

使用TypeScript已经有了一段时间,这的确是一个好东西,虽说在使用的过程中也发现了一些bug,不过都是些小问题,所以整体体验还是很不错的。

TypeScript之所以叫Type,和它的强类型是分不开的,这也是区别于JavaScript最关键的一点,类型的声明可以直接写在代码中,也可以单独写一个用来表示类型的描述文件*.d.ts

常用方式

首先在d.ts中是不会存在有一些简单的基本类型定义的(因为这些都是写在表达式、变量后边的,在这里定义没有任何意义),声明文件中定义的往往都是一些复杂结构的类型。

大部分语法都与写在普通ts文件中的语法一致,也是export后边跟上要导出的成员。

最简单的就是使用type关键字来定义:

type A = {                 // 定义复杂结构
  b: number
  c: string
}

type Func = () => number   // 定义函数

type Key = number | string // 多个类型

组合类型

以及在TypeScript中有着很轻松的方式针对type进行复用,比如我们有一个Animal类型,以及一个Dog类型,可以使用&来进行复用。

P.S> &符号可以拼接多个

type Animal = {
  weight: number
  height: number
}

type Dog = Animal & {
  leg: number
}

动态的 JSON 类型指定

如果我们有一个JSON结构,而它的key是动态的,那么我们肯定不能将所有的key都写在代码中,我们只需要简单的指定一个通配符即可:

type info = {
  [k: string]: string | number // 可以指定多个类型
}

const infos: info = {
  a: 1,
  b: '2',
  c: true, // error 类型不匹配
}

以及在新的版本中更推荐使用内置函数Record来实现:

const infos: Record<string, string | number> = {
  a: 1,
  b: '2',
  c: true, // error
}

获取变量的类型

假如我们有一个JSON对象,里边包含了nameage两个属性,我们可以通过一些TypeScript内置的工具函数来实现一些有意思的事情。

通过keyoftypeof组合可以得到我们想要的结果:

const obj = {
  name: 'Niko',
  age: 18
}

// 如果是这样的取值,只能写在代码中,不能写在 d.ts 文件中,因为声明文件里边不能存在实际有效的代码
type keys = keyof typeof obj

let a: keys = 'name' // pass
let b: keys = 'age'  // pass

let c: keys = 'test' // error

而如果我们想要将一个类型不统一的JSON修改为统一类型的JSON也可以使用这种方式:

const obj = {
  name: 'Niko',
  age: 18,
  birthday: new Date()
}

const infos: Record<keyof typeof obj, string> = {
  name: '',
  age: '',
  birthday: 123, // 出错,提示类型不匹配
  test: '', // 提示不是`info`的已知类型
}

获取函数的返回值类型

又比如说我们有一个函数,函数会返回一个JSON,而我们需要这个JSON来作为类型。

那么可以通过ReturnType<>来实现:

function func () {
  return {
    name: 'Niko',
    age: 18
  }
}

type results = ReturnType<typeof func>

// 或者也可以拼接 keyof 获取所有的 key
type resultKeys = keyof ReturnType<typeof func>

// 亦或者可以放在`Object`中作为动态的`key`存在
type infoJson = Record<keyof ReturnType<typeof func>, string>

在代码中声明函数和class类型

因为我们知道函数和class在创建的时候是都有实际的代码的(函数体、构造函数)。
但是我们是写在d.ts声明文件中的,这只是一个针对类型的约束,所以肯定是不会存在真实的代码的,但是如果在普通的ts文件中这么写会出错的,所以针对这类情况,我们需要使用declare关键字,表示我们这里就是用来定义一个类型的,而非是一个对象、函数:

class Personal {
  name: string
  // ^ 出错了,提示`name`必须显式的进行初始化
}

function getName (personal: Personal): name
// ^ 出错了,提示函数缺失实现

以下为正确的使用方式:

-declare class Personal {
+declare class Personal {
  name: string
}

-function getName (personal: Personal): name
+declare function getName (personal: Personal): name

当然了,一般情况下是不建议这么定义class的,应该使用interface来代替它,这样的class应该仅存在于针对非TS模块的描述,如果是自己开发的模块,那么本身结构就具有声明类型的特性。

函数重载

这个概念是在一些强类型语言中才有的,依托于TypeScript,这也算是一门强类型语言了,所以就会有需要用到这种声明的地方。

例如我们有一个add函数,它可以接收string类型的参数进行拼接,也可以接收number类型的参数进行相加。

需要注意的是,只有在做第三方插件的函数重载定义时能够放到d.ts文件中,其他环境下建议将函数的定义与实现放在一起(虽说配置paths也能够实现分开处理,但是那样就失去了对函数创建时的约束)

// index.ts

// 上边是声明
function add (arg1: string, arg2: string): string
function add (arg1: number, arg2: number): number
// 因为我们在下边有具体函数的实现,所以这里并不需要添加 declare 关键字

// 下边是实现
function add (arg1: string | number, arg2: string | number) {
  // 在实现上我们要注意严格判断两个参数的类型是否相等,而不能简单的写一个 arg1 + arg2
  if (typeof arg1 === 'string' && typeof arg2 === 'string') {
    return arg1 + arg2
  } else if (typeof arg1 === 'number' && typeof arg2 === 'number') {
    return arg1 + arg2
  }
}

TypeScript 中的函数重载也只是多个函数的声明,具体的逻辑还需要自己去写,他并不会真的将你的多个重名 function 的函数体进行合并

多个函数的顺序问题

想象一下,如果我们有一个函数,传入Date类型的参数,返回其unix时间戳,如果传入Object,则将对象的具体类型进行toString输出,其余情况则直接返回,这样的一个函数应该怎么写?

仅做示例演示,一般正常人不会写出这样的函数...

function build (arg: any) {
  if (arg instanceof Date) {
    return arg.valueOf()
  } else if (typeof arg === 'object') {
    return Object.prototype.toString.call(arg)
  } else {
    return arg
  }
}

但是这样的函数重载在声明的顺序上就很有讲究了,一定要将精确性高的放在前边:

// 这样是一个错误的示例,因为无论怎样调用,返回值都会是`any`类型
function build(arg: any): any
function build(arg: Object): string
function build(arg: Date): number

因为TypeScript在查找到一个函数重载的声明以后就会停止不会继续查找,any是一个最模糊的范围,而Object又是包含Date的,所以我们应该按照顺序从小到大进行排列:

function build(arg: Date): number
function build(arg: Object): string
function build(arg: any): any

// 这样在使用的时候才能得到正确的类型提示
const res1 = build(new Date()) // number
const res2 = build(() => { })  // string
const res3 = build(true)       // any

一些不需要函数重载的场景

函数重载的意义在于能够让你知道传入不同的参数得到不同的结果,如果传入的参数不同,但是得到的结果(__类型__)却相同,那么这里就不要使用函数重载(没有意义)。

如果函数的返回值类型相同,那么就不需要使用函数重载

function func (a: number): number
function func (a: number, b: number): number

// 像这样的是参数个数的区别,我们可以使用可选参数来代替函数重载的定义
function func (a: number, b?: number): number
// 注意第二个参数在类型前边多了一个`?`

// 亦或是一些参数类型的区别导致的
function func (a: number): number
function func (a: string): number

// 这时我们应该使用联合类型来代替函数重载
function func (a: number | string): number

Interface

interface是在TypeScript中独有的,在JavaScript并没有interface一说。
因为interface只是用来规定实现它的class对应的行为,没有任何实质的代码,对于脚本语言来说这是一个无效的操作

在语法上与class并没有什么太大的区别,但是在interface中只能够进行成员属性的声明,例如function只能够写具体接收的参数以及返回值的类型,并不能够在interface中编写具体的函数体,同样的,针对成员属性也不能够直接在interface中进行赋值:

// 这是一个错误的示例
interface PersonalIntl {
  name: string = 'Niko'

  sayHi (): string {
    return this.name
  }
}

// 在 interface 中只能存在类型声明
interface PersonalIntl {
  name: string

  sayHi (): string
}

其实在一些情况下使用interface与普通的type定义也没有什么区别。
比如我们要导出一个存在nameage两个属性的对象:

// types/personal.d.ts
export interface PersonalIntl {
  name: string
  age:  number
}

// index.d.ts
import { PersonalIntl } from './types/personal'

const personal: PersonalIntl = {
  name: 'Niko',
  age:  18,
}

如果将interface换成type定义也是完全没问题的:

// types/personal.d.ts
export type PersonalIntl = {
  name: string
  age:  number
}

这样的定义在基于上边的使用是完全没有问题的,但是这样也仅仅适用于Object字面量的声明,没有办法很好的约束class模式下的使用,所以我们采用interface来约束class的实现:

import { PersonalIntl } from './types/personal'

class Personal implements PersonalIntl {
  constructor(public name: string, public age: number) { }

  // 上边的简写与下述代码效果一致

  public name: string
  public age: number

  constructor (name: string, age: number) {
    this.name = name
    this.age = age
  }
}

const personal = new Personal('niko', 18)

关于函数成员声明的一些疑惑

首先,在接口中有两种方式可以定义一个函数,一个被定义在实例上,一个被定义在原型链上。
两种声明方式如下:

interface PersonalIntl {
  func1 (): any      // 原型链方法

  func2: () => any   // 实例属性
}

但是我们在实现这两个属性时其实是可以互相转换的,并没有强要求必须使用哪种方式:

class Personal implements PersonalIntl {
  func1 () {
    console.log(this)
  }

  func2 = () => {
    console.log(this)
  }
}

其实这两者在编译后的JavaScript代码中是有区别的,并不清楚这是一个bug还是设计就是如此,类似这样的结构:

var Personal = /** @class */ (function () {
    function Personal() {
        var _this = this;
        this.func2 = function () {
            console.log(_this);
        };
    }
    Personal.prototype.func1 = function () {
        console.log(this);
    };
    return Personal;
}());

所以在使用的时候还是建议最好按照interface定义的方式来创建,避免一些可能存在的奇奇怪怪的问题。

接口声明的自动合并

因为interfaceTypeScript特有的,所以也会有一些有意思的特性,比如相同命名的interface会被自动合并:

interface PersonalIntl {
  name: string
}

interface PersonalIntl {
  age: number
}

class Personal implements PersonalIntl {
  name = 'Niko'
  age = 18
}

不要在 interface 中使用函数重载

interface中使用函数重载,你会得到一个错误的结果,还是拿上边的build函数来说,如果在interface中声明,然后在class中实现,那么无论怎样调用,返回值的类型都会认为是any

所以正确的做法是在class中声明重载,在class中实现,interface中最多只定义一个any,而非三个重载。

class Util implements UtilIntl {
  build(arg: Date): number
  build(arg: Object): string
  build(arg: any): any

  build(arg: any) {
    if (arg instanceof Date) {
      return arg.valueOf()
    } else if (typeof arg === 'object') {
      return Object.prototype.toString.call(arg)
    } else {
      return arg
    }
  }
}

小结

有关TypeScript声明类型声明相关的目前就总结了这些比较常用的,欢迎小伙伴们进行补充。

在之前的版本中有存在modulenamespace的定义,但是目前来看,好像更推荐使用 ES-Modules 版本的 import/export来实现类似的功能,而非自定义的语法,所以就略过了这两个关键字相关的描述

官方文档中有针对如何编写声明文件的模版,可以参考:传送阵

参考资料

查看原文

赞 52 收藏 32 评论 13

Lxxyx 收藏了文章 · 2018-06-28

详细介绍 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 有使用心得或者踩坑经历,也欢迎分享。

查看原文

Lxxyx 评论了文章 · 2018-06-27

没朋友(mpvue),点个外卖,一个人也要好好吃饭

图片描述
每次写文章时,总会感慨万千。面对成功,我们总是抱以较高的期望,总想急不可耐就能得到胜利的果实。可每一次成功不可能轻而易举的就来到我们的面前。几乎每一次都需要我们付出极大的耐心和勤奋,甚至于近乎偏执狂般的傻傻坚持。自打小程序推出以来,我便开始转入这个市场。并不是因为他的热度而是一种学习兴趣吧,总想着去探索下新知识。也正是有了这种动力,才能让我一直坚持下来。距我上一篇文章《滴滴一夏,小程序专车来了》发表至今,已有半月之久。之间结识了好多好朋友。他们乐观开朗,诙谐。以自己最朴实的热忱对待他人。他们为我的征联不吝惜笔墨添彩,给我鼓励和修正。同时也感谢黄轶老师给我的面试机会还有慕课上的精彩课程。回归正题,今天给大家带来的是美团外卖微信小程序,基于mpvue开发。因为mpvue推出至今也才短短3个月,所有我会详细的讲解每一个细节,希望帮到在mpvue上步履艰难的你。

前言

学习就好比是座大山,人们沿着不同的路登山,分享着自己看到的风景。你不一定能看到别人看到的风景,体会到别人的心情。只有自己去登山,才能看到不一样的风景,体会更加深刻。因此建议还是一定要去实践一波。

项目使用的技术栈

  • 数据请求: flyio.js- 同时支持浏览器、小程序、Node、Weex的基于Promise的跨平台http请求库。可以让您在多个端上尽可能大限度的实现代码复用
  • css预编译器: stylus-基于Node.js的CSS的预处理框架
  • 数据来源:EasyMock-为测试提供模拟数据
  • 整体框架: mpvue
  • 地图:腾讯地图api

下载启动步骤

# install dependencies
npm install
# serve with hot reload at localhost:8080
npm run dev
# build for production with minification
npm run build
# build for production and view the bundle analyzer report
npm run build --report
  • 3、小程序开发工具指向下面的dist目录

效果图

即使在优美的语言描述,还是也抵不过图片来的实际。一起欣赏下美团外卖吧!
index

mpvue与小程序擦出的火花(采坑之旅)

在写美团外卖小程序时,我跟大家一样步履蹒跚。总会遇到各种各样的问题以及徘徊在vue写法和mpvue写法中间不能自拔。mpvue刚出不久,有效的资源真的甚少,没有一套基本项目流程的介绍。所以我便萌发了这篇文章,通过这个完整的项目去构思整套mpvue开发。很多问题可能不能一一列举,但我会把最常见最常用的地方尽我所能的去阐述。乐于分享,帮助社区。

一、mpvue中数据请求的封装

写项目最重要的便是数据,有了数据整个页面就活起来了,数据的浇灌便需要http的请求。微信的javascript运行情况和浏览器不同,页面的脚本逻辑是在JsCore中运行,JsCore是一个没有窗口对象的情况,JsCore中也没有XmlhttpRequest对象,所以jquery、zepto、axios这些都无法涉足微信开发中来,而此时,fly便担任了这一重任。

  • 1、安装flyio.js
npm install flyio
  • 2、在util创建一个fly.js用于封装
import Vue from 'vue' 
var Fly=require("flyio/dist/npm/wx.js") //wx.js为flyio的微信小程序入口文件
var fly=new Fly(); //创建fly实例
//添加拦截器
fly.interceptors.request.use((config,promise)=>{
    config.headers["X-Tag"]="flyio";  //给所有请求添加自定义header
    return config;
})
//配置请求基地址
fly.config.baseURL="https://www.easy-mock.com/mock/5aded45053796b38dd26e970/"
Vue.prototype.$http=fly  //将fly挂载在vue上供全局使用
export default fly
  • 3、在根目录的main.js下封装一个getList方法.用到请求数据的页面直接调用这个方法即可。提高代码复用率

    Vue.prototype.getList = function () {
         wx.showLoading({
           title: '加载中',
       })
       this.$http.get('sell#!method=get').then((res)=>{
           this.restaurant = res.data.data.restaurant;  //商家数据
           this.goods = res.data.data.goods; //商品数据
           this.seller = res.data.data.seller; //商家详细数据
           this.ratings= res.data.data.ratings //评论数据
           wx.hideLoading();
         }).catch((e)=>{
         console.log(e)
       })
    }

封装好了数据的请求,我们的项目就实现了一大半了。接下来就是如何利用这些数据来填充我们的页面完成交互。

二、mpvue实用功能的详解

接下来我将会一一介绍在mpvue中如何实现定位,位置搜索,上拉加载下拉刷新,物品之间的二级联动。让我们打起精神,一起focus下面的知识点。

mpvue定位及位置搜索

mpvue中定位及位置搜索跟小程序类似,大家可以先看看我上篇文章,那里又详细的地址解析,逆地址解析,关键词搜索等。
map
官方拷贝下来的js放在utils下,这里要注意的是一定要将他的输出更改为

export default QQMapWX;

这样才可以在页面中使用,这里配合微信小程序提供的 wx.getLocation()和 wx.chooseLocation()API使用。

 import QQMapWX from "../../utils/map"; //导入刚引入的js
    var qqmapsdk;
    qqmapsdk = new QQMapWX({
    key:'DHNBZ-2ZLKK-T7IJJ-AXSQW-WX5L6-A6FJZ'
    });

mpvue上拉加载下拉刷新

通过onPullDownRefresh和onReachBottom方法实现mpvue小程序下拉加载和上拉刷新

index

// 局部开启下拉刷新,就在文件下的main.js
export default {
  config: {
    "enablePullDownRefresh": true,
  }
}
 onReachBottom() {
        let nextPage = this.page +1; //定义每一页page,下来刷新新的一页+1
        this.page = nextPage  //更新page
        this.$http.get('sell#!method=get').then((res)=>{
        this.restaurant =[...res.data.data.restaurant,...this.restaurant]//请求的新数据,解构出来渲染页面
        }).catch((e)=>{
        console.log(e)
        })
    },
onPullDownRefreash(){
  this.isShow = !this.isShow 
}

mpvue中实现二级联动

实现该功能的思路:

  • 1 左到右:通过点击左侧滑栏的某一项,获取到该元素携带的 id ,然后动态传给右侧滑栏的 scroll-into-view ,从而实现右侧滑栏对应的该元素运动置顶。
  • 2 右到左:通过计算整个右侧滑栏滚动上去的高度 与右侧滑栏中每一个分类距顶部的距离做比对,获取到该滚动置顶的分类的 index 。然后用获取到的 index 乘以左侧滑栏中某一项的高度,动态赋值给左侧滑栏内的 scrollTop ,控制左侧滑栏的联动。

index

注意这几点:
(1) 小程序 wxss 中使用 rpx,而 js 中 scrollTop 获取到的值是 px,所以存在rpx 转 px 的问题。以 iPhone6 为基准,转换公式:

// percent 为当前设备1rpx对应的px值
var percent = res.windowWidth / 750;

(2) 微信自带scroll-view UI组件,通过 bindscroll="scroll" 绑定滚动事件;通过 scroll-top="{{scrollTop}}" 动态控制 左侧滑栏的被动滚动。代码就不一一贴出来,项目中有详细的注释。点这里查看

三、mpvue组件分析,组件通信

做完一个项目并不难,但做好一个项目却要经过无数次的思考。其中之一就是看文档,所谓书读百变,其义自现。的确,当你一遍又一遍的看文档后你会发现你写起来非常的顺手,用到即来。没事可以点击vue文档多看看。

组件分析

什么是组件分析?对mpvue来讲,组件是构成项目的基本单元。只要划分好了组件,项目写起来那是非常的快的。为了方便理解,这里定义两类组件:页面组件,功能组件。页面组件就是当前你看见的这个网页地址的完整显示,他将包含几个功能组件。
美团外卖小程序功能组件很多,大致的列几个:

  1. 评分组件,需要的props的值为:星星的大小,商家的评分
  2. 购物车组件: 需要的props:selectFoods,deliveryPrice, minPrice等
  3. 公告组件:每个项目难免发布一些公告或者弹窗,把它抽出来当一个组件
  4. 间距split组件: 组件可以很大很多功能,也可能很少,只要在项目中经常用的就可以抽出来当一个组件。
  5. swiper组件: 轮播图作为一个组件,可以减少我们一个页面成堆的代码,把它抽出来当一个组件,日后也更易维护。

组件通信

一、 组件间可以通过props传递数据,这里以选物品 -> 选择组件 -> 购物车 -> 订单详情一条线来详细描述组件间数据怎么传递的。

    • 1 选择组件
    props: {
          food: {  //接受一个food,代表选择的是哪个商品
              type: Object,
          }
      },
      addCart(event) {
          if(!this.food.count){
              this.$set(this.food, 'count', 1)  //点击事件传递给父组件
              this.food.count = 1; 
          }else{
              this.food.count++  // 商品++
          }
      },
    • 2 购物车
    通过props接受一个selectFood,这里把它放入小程序的本地中提供给订单页面
     try {
                wx.setStorageSync('selectFoods', this.selectFoods)
            } catch (e) {   
                console.log(e) 
            }
    • 3 订单页面
     try {
        var value = wx.getStorageSync('selectFoods') //拿到存储的数据,使用同步的概念
        if (value) {
          this.isShow = false; // 判断订单也是否有数据,没有数据则用v-show引用一个组件去渲染页面
          this.orderList=value; //数据渲染页面
        }
      } catch (e) {
        console.log(e)
      };

    二、父子组件间方法的调用可以通过$on, $emit

    var Event = new Vue();//相当于又new了一个vue实例,Event中含有vue的全部方法
    Event.$emit('msg',this.msg);//发送数据,第一个参数是发送数据的名称,接收时还用这个名字接收,第二个参数是这个数据现在的位置
    Event.$on('msg',function(msg){//接收数据,第一个参数是数据的名字,与发送时的名字对应,第二个参数是一个方法,
    要对数据的操})

    ---
    fitting

    寄语

    很多东西想聊出来,比如mpvue小程序中引用图片一定要放在static文件目录下,组件的属性计算computed。以及vuex在全局数据的使用等等。但是一个人的精力真的有限,本人也是一个大三的学生,最近忙于找实习公司,同时也在研究下vue底层源码分析。所有可能到这里差不多就结束了。对其中一些内容比较喜欢的可以去我的github上下载,慢慢研究。这里附上我项目的地址mpvue美团外卖小程序。也希望有志同道和的可以加入一起讨论,如果有什么不懂得可以直接评论我,或者发我qq:972774037我也会第一时间帮你解答。也希望通过这个为我找实习增添色彩吧,下期将推出更加精彩的vue项目,持续关注我吧!!!

    查看原文

    Lxxyx 赞了文章 · 2018-06-21

    React + TS 2.8:终极组件设计模式指南

    原文:Ultimate React Component Patterns with Typescript 2.8, Martin Hochel

    本文的写作灵感来自于《React Component Patterns》,线上演示地址>>点我>>

    熟悉我的朋友都知道,我不喜欢写无类型支持的 JavaScript,所以从 TypeScript 0.9 开始我就深深地爱上它了。
    除了类型化的 JavaScript,我也非常喜欢 React,React 和 TypeScript 的结合让我感觉置身天堂:D。
    在整个应用中,类型安全和 VDOM 的无缝衔接,让开发体验变得妙不可言!

    所以本文想要分享什么信息呢?
    尽管网上有很多关于 React 组件设计模式的文章,但是没有一篇介绍如何使用 TypeScript 来实现。
    与此同时,最新版的 TypeScript 2.8 也带来了令人激动人心的功能,比如支持条件类型(Conditional Types)、标准库中预定义的条件类型以及同态映射类型修饰符等等,这些功能使我们能够更简便地写出类型安全的通用组件模式。

    本文非常长,但是请不要被吓到了,因为我会手把手教你掌握终极 React 组件设计模式!

    文中所有的设计模式和例子都使用 TypeScript 2.8 和严格模式

    准备

    磨刀不误砍柴工。首先我们要安装好 typescripttslib,使用 tslib 可以让我们生成的代码更加紧凑。

    yarn add -D typescript
    # tslib 弥补编译目标不支持的功能,如
    yarn add tslib

    然后,就可以使用 tsc 命令来初始化项目的 TypeScript 配置了。

    # 为项目创建 tsconfig.json ,使用默认编译设置
    yarn tsc --init

    接着,安装 reactreact-dom 和它们的类型文件。

    yarn add react react-dom
    yarn add -D @types/{react,react-dom}

    非常棒!现在我们就可以开始研究组件模式了,你准备好了么?

    无状态组件

    无状态组件(Stateless Component)就是没有状态(state)的组件。大多数时候,它们就是纯函数
    下面让我们来使用 TypeScript 随便编写一个无状态的按钮组件。

    就像使用纯 JavaScript 一样,我们需要引入 react 以支持 JSX 。
    (译注:TypeScript 中,要支持 JSX,文件拓展名必须为 .tsx

    import React from 'react'
    const Button = ({ onClick: handleClick, children }) => (
      <button onClick={handleClick}>{children}</button>
    )

    不过 tsc 编译器报错了:(。我们需要明确地告诉组件它的属性是什么类型。所以,让我们来定义组件属性:

    import React, { MouseEvent, ReactNode } from 'react'
    type Props = { 
     onClick(e: MouseEvent<HTMLElement>): void
     children?: ReactNode 
    }
    const Button = ({ onClick: handleClick, children }: Props) => (
      <button onClick={handleClick}>{children}</button>
    )

    很好!这下终于没有报错了!但是我们还可以做得更好!

    @types/react 类型模块中预定了 type SFC<P>,它是 interface StatelessComponent<P> 的类型别名,并且它预定义了 childrendisplayNamedefaultProps 等属性。所以,我们用不着自己写,可以直接拿来用。

    于是,最终的代码长这样:

    Stateless Component

    状态组件

    让我们来创建一个有状态的计数组件,并在其中使用我们上面创建的 Button 组件。

    首先,定义好初始状态 initialState

    const initialState = { clicksCount: 0 }

    这样我们就可以使用 TypeScript 来对它进行类型推断了。

    这种做法可以让我们不用分别独立维护类型和实现,如果实现变更了类型也会随之自动改变,妙!

    type State = Readonly<typeof initialState>

    同时,这里也明确地把所有属性都标记为只读。在使用的时候,我们还需要显式地把状态定义为只读,并声明为 State 类型。

    readonly state: State = initialState

    为什么声明为只读呢?
    这是因为 React 不允许直接更新 state 及其属性。类似下面的做法是错误的

    this.state.clicksCount = 2
    this.state = { clicksCount: 2 }

    该做法在编译时不会出错,但是会导致运行时错误。通过使用 Readonly 显式地把类型 type State 的属性都标记为只读属性,以及声明 state 为只读对象,TypeScript 可以实时地把错误用法反馈给开发者,从而避免错误。

    比如:

    Compile time State type safety

    由于容器组件 ButtonCounter 还没有任何属性,所以我们把 Component 的第一个泛型参数组件属性类型设置为 object,因为 props 属性在 React 中总是 {}。第二个泛型参数是组件状态类型,所以这里使用我们前面定义的 State 类型。

    Stateful Component

    你可能已经注意到,在上面的代码中,我们把组件更新函数独立成了组件类外部的纯函数。这是一种常用的模式,这样的话我们就可以在不需要了解任何组件内部细节的情况下,单独对这些更新函数进行测试。此外,由于我们使用了 TypeScript ,而且已经把组件状态设置为只读,所以在这种纯函数中对状态的修改也会被及时发现。

    const decrementClicksCount = (prevState: State) 
                          => ({ clicksCount: prevState.clicksCount-- })
    
    // Will throw following complile error:
    //
    // [ts]
    // Cannot assign to 'clicksCount' because it is a constant or a read-only property.

    是不是很酷呢?;)

    默认属性

    现在让我们来拓展一下 Button 组件,给它添加一个 string 类型的 color 属性。

    type Props = { 
        onClick(e: MouseEvent<HTMLElement>): void
        color: string 
    }

    如果想给组件设置默认属性,我们可以使用 Button.defaultProps = {...} 实现。这样的话,就需要把类型 Propscolor 标记为可选属性。像下面这样(多了一个问号):

    type Props = { 
        onClick(e: MouseEvent<HTMLElement>): void
        color?: string 
    }

    此时,Button 组件就变成了下面的模样:

    const Button: SFC<Props> = ({ onClick: handleClick, color, children }) => (
        <button style={{ color }} onClick={handleClick}>
            {children}
        </button>
    )

    这种实现方式工作起来是没毛病的,但是却存在隐患。因为我们是在严格模式下,所以可选属性 color 的类型其实是联合类型 undefined | string

    假如后续我们需要用到 color,那么 TypeScript 就会抛出错误,因为编译器并不知道 color 已经被定义在 Component.defaultProps 了。

    Default Props issue

    为了告诉 TypeScript 编译器 color 已经被定义了,有以下 3 种办法:

    • 使用! 操作符(Bang Operator)显式地告诉编译器它的值不为空,像这样 <button onClick={handleClick!}>{children}</button>
    • 使用三元操作符(Ternary Operator)告诉编译器值它的值不为空:<button onClick={handleClick ? handleClick: undefined}>{children}</button>
    • 创建一个可复用的高阶函数(High Order Function)withDefaultProps,该函数会更新我们的属性类型定义并且设置默认属性。是我见过的最纯粹的解决办法。

    多亏了 TypeScript 2.8 新增的预定义条件类型,withDefaultProps 实现起来非常简单。

    withDefaultProps High order function generic helper

    注意: Omit 并没有成为 TypeScript 2.8 预定义的条件映射类型,因此需要自行实现: declare type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;

    下面我们用它来解决上面的问题:

    Define default props on our Button component

    或者更简单的:

    Define default props on inline with Component implementation

    现在,Button 的组件属性已经定义好,可以被使用了。在类型定义上,默认属性也被标记为可选属性,但是在是现实上仍然是必选的。

    {
      onClick(e: MouseEvent<HTMLElement>): void
      color?: string
    }

    button with default props

    在使用方式上也是一模一样:

    render(){
      return (
        <ButtonWithDefaultProps 
          onClick={this.handleIncrement}
        >
          Increment
        </ButtonWithDefaultProps>
      )
    }

    withDefaultProps 也能用在直接使用 class 定义的组件上,如下图所示:

    inline class

    这里多亏了 TS 的类结构源,我们不需要显式定义 Props 泛型类型

    ButtonViaClass 组件的用法也还是保持一致:

    render(){
      return (
        <ButtonViaClass
          onClick={this.handleIncrement}
        >
          Increment
        </ButtonViaClass>
      )
    }

    接下来我们会编写一个可展开的菜单组件,当点击组件时,它会显示子组件内容。我们会用多种不同的组件模式来实现它。

    渲染回调/渲染属性模式

    要想让一个组件变得可复用,最简单的办法是把组件子元素变成一个函数或者新增一个 render 属性。这也是渲染回调(Render Callback)又被称为子组件函数(Function as Child Component)的原因。

    首先,让我们来实现一个拥有 render 属性的 Toggleable 组件:

    toggleable component

    存在不少疑惑?

    让我们来一步一步看各个重要部分的实现:

    const initialState = { show: false }
    type State = Readonly<typeof initialState>

    这个没什么新内容,就跟我们前文的例子一样,只是声明状态类型。

    接下来我们需要定义组件属性。注意:这里我们使用映射类型 Partial 来把属性标记为可选,而不是使用 ? 操作符。

    type Props = Partial<{
      children: RenderCallback
      render: RenderCallback
    }>
    type RenderCallback = (args: ToggleableComponentProps) => JSX.Element
    type ToggleableComponentProps = { 
      show: State['show']
      toggle: Toggleable['toggle'] 
    }

    我们希望同时支持子组件函数和渲染回调函数,所以这里把它们都标记为可选的。为了避免重复造轮子,这里为渲染函数创建了 RenderCallback 类型:

    type RenderCallback = (args: ToggleableComponentProps) => JSX.Element

    其中,看起来可能令人疑惑的是类型 type ToggleableComponentProps

    type ToggleableComponentProps = { 
      show: State['show']
      toggle: Toggleable['toggle'] 
    }

    这个其实是用到了 TS 的类型查询功能,这样的话我们就不需要重复定义类型了:

    • show: State['show']:使用在状态中已经定义的类型来为 show 声明类型
    • toggle: Toggleable['toggle']:通过类型推断和类结构获取方法类型。优雅而强大!

    其他部分的实现是很直观的,标准的渲染属性/子组件函数模式:

    export class Toggleable extends Component<Props, State> {
      // ...
      render() {
        const { children, render } = this.props
        const renderProps = { show: this.state.show, toggle: this.toggle }
        if (render) {
          return render(renderProps)
        }
        return isFunction(children) ? children(renderProps) : null
      }
      // ...
    }

    至此,我们就可以通过子组件函数来使用 Toggleable 组件了:

    children as a function

    或者给 render 属性传递渲染函数:

    render prop

    得益于强大的 TS ,我们在编码的时候还可以有代码提示和正确的类型检查:

    soundness

    如果我们想复用它,可以简单的创建一个新组件来使用它:

    ToggleableMenu

    这个全新的 ToggleableMenu 组件现在就可以用在菜单组件中了:

    Menu Component

    而且效果也正如我们所预期:

    menu demo

    这种方式非常适合用在需要改变渲染内容本身,而又不想使用状态的场景。因为我们把渲染逻辑移到了 ToggleableMenu 的子组件函数中,同时又把状态逻辑留在 Toggleable 组件中。

    组件注入

    为了让我们的组件更加灵活,我们还可以引入组件注入(Component Injection)模式。

    何为组件注入模式?如果你熟悉 React-Router 的话,那么在定义路由的时候就是在使用这个模式:

    <Route path="/foo" component={MyView} />

    所以,除了传递 render/children 属性,我们还可以通过 component 属性来注入组件。为此,我们需要把行内渲染回调函数重构成可复用的无状态组件:

    import { ToggleableComponentProps } from './toggleable'
    type MenuItemProps = { title: string }
    const MenuItem: SFC<MenuItemProps & ToggleableComponentProps> = ({
      title,
      toggle,
      show,
      children,
    }) => (
      <>
        <div onClick={toggle}>
          <h1>{title}</h1>
        </div>
        {show ? children : null}
      </>
    )

    这样的话,ToggleableMenu 也需要重构下:

    type Props = { title: string }
    const ToggleableMenu: SFC<Props> = ({ title, children }) => (
      <Toggleable
        render={({ show, toggle }) => (
          <MenuItem show={show} toggle={toggle} title={title}>
            {children}
          </MenuItem>
        )}
      />
    )

    接下来,让我们来定义新的 component 属性。
    首先,我们需要更新下属性成员:

    • children 可以是函数或者是 ReactNode
    • component 是新成员,它的值为组件,该组件的属性需要实现 ToggleableComponentProps,同时它又必须支持默认为 any 的泛型类型,这样它不会仅仅用于实现了 ToggleableComponentProps 属性的组件。
    • props 是新成员,用来往下传递任意属性,这也是一种通用模式。它被定义为类型是 any 的索引类型,所以这里我们其实丢失了严格的安全检查。
    // 使用任意属性类型来声明默认属性,props 默认为空对象
    const defaultProps = { props: {} as { [name: string]: any } }
    type Props = Partial<
      {
        children: RenderCallback | ReactNode
        render: RenderCallback
        component: ComponentType<ToggleableComponentProps<any>>
      } & DefaultProps
    >
    type DefaultProps = typeof defaultProps

    接着,需要把新的 props 同步到 ToggleableComponentProps,这样才能使用 props 属性 <Toggleable props={...}/>

    export type ToggleableComponentProps<P extends object = object> = {
      show: State['show']
      toggle: Toggleable['toggle']
    } & P

    最后还需要修改下 render 方法:

    render() {
        const { 
         component: InjectedComponent, 
         children, 
         render, 
         props 
        } = this.props
        const renderProps = { 
         show: this.state.show, toggle: this.toggle 
        }
        // 当使用 component 属性时,children 不是一个函数而是 ReactNode
        if (InjectedComponent) {
          return (
            <InjectedComponent {...props} {...renderProps}>
              {children}
            </InjectedComponent>
          )
        }
        if (render) {
          return render(renderProps)
        }
        // children as a function comes last
        return isFunction(children) ? children(renderProps) : null
      }

    把前面的内容都综合起来,就实现了一个支持 render 属性、函数子组件和组件注入的 Toggleable 组件:

    full toggleable component

    其使用方式如下:

    ToggleableMenu with component injection pattern

    这里要注意:我们自定义的 props 属性并没有安全的类型检查,因为它被定义为索引类型 { [name: string]: any }

    We can pass anything to our props prop :(

    在菜单组件的渲染中,ToggleableMenuViaComponentInjection 组件的使用方式跟原来一致:

    export class Menu extends Component {
      render() {
        return (
          <>
            <ToggleableMenuViaComponentInjection title="First Menu">
              Some content
            </ToggleableMenuViaComponentInjection>
            <ToggleableMenuViaComponentInjection title="Second Menu">
              Another content
            </ToggleableMenuViaComponentInjection>
            <ToggleableMenuViaComponentInjection title="Third Menu">
              More content
            </ToggleableMenuViaComponentInjection>
          </>
        )
      }
    }

    泛型组件

    在前面我们实现组件注入模式时,有一个大问题是 props 属性失去了严格的类型检查。如何解决这个问题?你可能已经猜到了!我们可以把 Toggleable 实现为泛型组件。

    首先,我们需要把属性泛型化。我们可以使用默认泛型参数,这样的话,当我们不需要传 props 时就可以不用显式传递该参数了。

    type Props<P extends object = object> = Partial<
      {
        children: RenderCallback | ReactNode
        render: RenderCallback
        component: ComponentType<ToggleableComponentProps<P>>
      } & DefaultProps<P>
    >

    此外,还需要使 ToggleableComponentProps 泛型化,不过它现在其实已经是了,所以这块不需要重写。

    唯一需要改动的是 type DefaultProps ,因为目前的实现方式中,它是没有办法获取泛型类型的,所以我们需要把它改为另一种方式:

    type DefaultProps<P extends object = object> = { props: P }
    const defaultProps: DefaultProps = { props: {} }

    马上就要完成了!

    最后把 Toggleable 组件变成泛型组件。同样地,我们使用了默认参数,因为只有在使用组件注入时才需要传参,其他情况时则不需要。

    export class Toggleable<T = {}> extends Component<Props<T>, State> {}

    大功告成!不过,真的么?我们如何才能在 JSX 中使用泛型类型?

    很遗憾,并不能。

    所以,我们还需要引入 ofType 泛型组件工厂模式:

    export class Toggleable<T extends object = object> extends Component<Props<T>, State> {
      static ofType<T extends object>() {
        return Toggleable as Constructor<Toggleable<T>>
      }
    }

    完整的实现版本如下:

    generic props

    有了 static ofType 静态方法之后,我们就可以创建正确的类型检查泛型组件了:

    ofType

    一切都跟之前一样,但是这次我们的 props 有了类型检查!

    type safe

    高阶组件

    既然我们的 Toggleable 组件已经实现了 render 属性,那么实现高阶组件(High Order Component, HOC)就很容易了。渲染回调模式的最大好处之一就是,它可以直接用于实现 HOC。

    下面让我们来实现这个 HOC。

    我们需要新增以下内容:

    • displayName(用于调试工具展示,便于阅读)
    • WrappedComponent (用于访问原组件,便于测试)
    • 使用 hoist-non-react-statics 包的 hoistNonReactStatics 方法

    hoc implemention

    这样我们就可以以 HOC 的方式来创建 Toggleable 菜单项了, 而且仍然保持了对属性的类型检查。

    const ToggleableMenuViaHOC = withToggleable(MenuItem)

    Proper type annotation

    受控组件

    压轴大戏来了!
    我们来实现一个可以通过父组件进行高度配置的 Toggleable ,这种是一种非常强大的模式。

    可能有人会问,受控组件(Controlled Component)是什么?在这里意味着,我想要同时控制 Menu 组件中所有 ToggleableMenu 的内容是否显示,看看下面的动态你应该就知道是什么了。

    controlled component

    为了实现该目标,我们需要修改下 ToggleableMenu 组件,修改后的内容如下:

    ToggleableMenu

    然后,我们还需要在 Menu 中新增一个状态,并且把它传递给 ToggleableMenu

    Stateful Menu component

    最后,还需要修改 Toggleable 最后一次,让它变得更加无敌和灵活。
    修改内容如下:

    1. 新增 show 属性到 Props
    2. 更新默认属性(因为 show 是可选的)
    3. 更新默认状态,使用属性 show 的值来初始化状态 show,因为我们希望该值只能来自于其父组件
    4. 使用 componentWillReceiveProps 来利用公开属性更新状态

    1 & 2 对应的修改:

    const initialState = { show: false }
    const defaultProps: DefaultProps = { ...initialState, props: {} }
    
    type State = Readonly<typeof initialState>
    type DefaultProps<P extends object = object> = { props: P } & Pick<State, 'show'>

    3 & 4 对应的修改:

    export class Toggleable<T = {}> extends Component<Props<T>, State> {
      static readonly defaultProps: Props = defaultProps
      // Bang operator used, I know I know ...
      state: State = { show: this.props.show! }
    
      componentWillReceiveProps(nextProps: Props<T>) {
        const currentProps = this.props
        
        if (nextProps.show !== currentProps.show) {
          this.setState({ show: Boolean(nextProps.show) })
        }
      }
    }

    至此,终极 Toggleable 组件诞生了:

    final Toggleable

    同时,使用 ToggleablewithToggleable 也还要做些轻微调整,以便传递 show 属性和类型检查。

    withToggleable Hoc with controllable functionality

    总结

    使用 TS 来实现对 React 组件进行正确的类型检查其实是相当难的。但是随着 TS 2.8 新功能的发布,我们几乎可以随意使用通用的 React 组件模式来实现类型安全的组件。

    在本篇超长文中,多亏了 TS,我们学习了如何实现具有多种模式且类型安全的组件。

    综合来看,其实最强大的模式非属性渲染(Render Prop)莫属,有了它,我们可以不费吹灰之力就可以实现组件注入和高阶组件。

    文中所有的示范代码托管于作者的 GitHub 仓库

    最后,还有一点要强调的是,本文中涉及的类型安全模板可能只适用于使用 VDOM/JSX 的库:

    • 使用语言服务的 Angular 模板也具备类型检查,但是在有些地方也还是会失效,比如 ngFor
    • Vue 模板目前也还没有类似 Angular ,所以它的模板和数据绑定实际上是魔术字符串。不过这可能在未来会改变。虽然也可以对模板字符串使用 VDOM,不过用起来应该会很笨重,因为有太多属性类型定义。(snabdom 表示:怪我咯)。
    查看原文

    赞 75 收藏 71 评论 1

    Lxxyx 赞了文章 · 2018-06-19

    详细介绍 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

    认证与成就

    • 获得 402 次点赞
    • 获得 49 枚徽章 获得 1 枚金徽章, 获得 19 枚银徽章, 获得 29 枚铜徽章

    擅长技能
    编辑

    开源项目 & 著作
    编辑

    注册于 2015-07-07
    个人主页被 4k 人浏览