SegmentFault 小王子的技术妖刀最新的文章
2020-11-29T18:20:41+08:00
https://segmentfault.com/feeds/blogs
https://creativecommons.org/licenses/by-nc-nd/4.0/
Flutter · Python AI 弹幕播放器来袭
https://segmentfault.com/a/1190000038320928
2020-11-29T18:20:41+08:00
2020-11-29T18:20:41+08:00
心动音符
https://segmentfault.com/u/yukilzw
5
<p>AI智能弹幕(也称蒙版弹幕):弹幕浮在视频的上方却永远不会挡住人物。起源于哔哩哔哩的web端黑科技,而后分别实现在IOS和Android的app端,如今被用于短视频、直播等媒体行业,用户体验提升显著。</p><p>本文除了会使用<code>Flutter</code>新方案进行跨端实现,同时也会讲解如何将一段任意视频流使用<code>opencv-python</code>处理成蒙版数据源,达成从0到1的前后端AI体系。先来看看双端最终运行效果吧:</p><h6>自行clone源码打包:<a href="https://link.segmentfault.com/?enc=EY0BJpX%2F4IgdEcRv6Y2ESQ%3D%3D.clu4bOBugVTWF9PHLsOeI470aavOY0W4L1325aUUBKQ7nZRqlw7KDIdLdF8u27A4" rel="nofollow">Zoe barrage</a></h6><h6>IPhone运行录屏:<a href="https://www.bilibili.com/video/BV1Mp4y1z7ud">点这里</a></h6><h6>APP运行截图:</h6><p><img src="/img/remote/1460000038319519" alt="" title=""></p><h4>实现流程目录</h4><ul><li><p>Python后端:</p><ul><li>依次提取视频流的 <strong>关键帧</strong> 保存为图片</li><li>将所有关键帧传给 <strong>神经网络模型</strong> 让算法将图片中非人物抹去,并保存图片帧</li><li>将只含有人物的图片帧进行 <strong>像素色值转换</strong>,得到 <strong>灰度图</strong>,最后再转为 <strong>黑白反色图</strong></li><li>通过识别黑白反色图的 <strong>轮廓坐标</strong> ,生成一份 <strong>时间:路径</strong> 配置文件提供给前端</li></ul></li><li><p>Flutter前端:</p><ul><li>实现一个弹幕调度动画组</li><li>根据 <strong>配置文件</strong> 将弹幕外层容器 <strong>裁剪</strong> 为一个刚好透出人物的漏洞形状,也称蒙版</li><li>引入播放器,视频流播放时,为 <strong>关键帧</strong> 同步渲染其对应的蒙版形状</li></ul></li><li><p>拓展:</p><ul><li>Web前端实现</li><li>视频点播与直播</li><li>总结与优化</li></ul></li></ul><h4>1. Python后端</h4><h5>1.1 提取关键帧</h5><pre><code># config.py --- 配置文件
import os
import cv2
VIDEO_NAME = 'source.mp4' # 处理的视频文件名
FACE_KEY = '*****' # AI识别key
FACE_SECRET = '*****' # AI密钥
dirPath = os.path.dirname(os.path.abspath(__file__))
cap = cv2.VideoCapture(os.path.join(dirPath, VIDEO_NAME))
FPS = round(cap.get(cv2.CAP_PROP_FPS), 0)
# 进行识别的关键帧,FPS每上升30,关键帧间隔+1(保证flutter在重绘蒙版时的性能的一致性)
FRAME_CD = max(1, round(FPS / 30))
if cv2.CAP_PROP_FRAME_COUNT / FRAME_CD >= 900:
raise Warning('经计算你的视频关键帧已经超过了900,建议减少视频时长或FPS帧率!')</code></pre><p>在这份配置文件中,会先读取视频的帧率,<code>30FPS</code>的视频会吧每一帧都当做关键帧进行处理,<code>60FPS</code>则会隔一帧处理一次,这样是为了保证Flutter在绘制蒙版的性能统一。<br>另外需要注意的是由于演示DEMO为完全离线环境,视频和最终蒙版文件都会被打包到APP,视频文件不宜过大。</p><pre><code># frame.py --- 视频帧提取
import os
import shutil
import cv2
import config
dirPath = os.path.dirname(os.path.abspath(__file__))
images_path = dirPath + '/images'
cap = cv2.VideoCapture(os.path.join(dirPath, config.VIDEO_NAME))
count = 1
if os.path.exists(images_path):
shutil.rmtree(images_path)
os.makedirs(images_path)
# 循环读取视频的每一帧
while True:
ret, frame = cap.read()
if ret:
if(count % config.FRAME_CD == 0):
print('the number of frames:' + str(count))
# 保存截取帧到本地
cv2.imwrite(images_path + '/frame' + str(count) + '.jpg', frame)
count += 1
cv2.waitKey(0)
else:
print('frames were created successfully')
break
cap.release()</code></pre><p>这里使用<code>opencv</code>提取视频的关键帧图片并保存在当前目录<code>images</code>文件夹下。</p><h5>1.2 通过AI模型提取人物</h5><p><img src="/img/remote/1460000038319513" alt="" title=""><br>提取图像中人物的工作需要交给 <strong>卷积神经网络</strong> 来完成,不同程度的训练对图像分类的准确率影响很大,而这也直接决定了最终的效果。大公司有算法团队来专门训练模型,我们的DEMO使用FACE++提供的开放测试接口,准确率与其付费商用的无异,就是会被限流,失败率高达80%,不过后面我们可以在代码编写中解决这个问题。</p><pre><code># discern.py --- 调用算法接口返回人体模型灰度图
import os
import shutil
import base64
import re
import json
import threading
import requests
import config
dirPath = os.path.dirname(os.path.abspath(__file__))
clip_path = dirPath + '/clip'
if not os.path.exists(clip_path):
os.makedirs(clip_path)
# 图像识别类
class multiple_req:
reqTimes = 0
filename = None
data = {
'api_key': config.FACE_KEY,
'api_secret': config.FACE_SECRET,
'return_grayscale': 1
}
def __init__(self, filename):
self.filename = filename
def once_again(self):
# 成功率大约10%,记录一下被限流失败的次数 :)
self.reqTimes += 1
print(self.filename +' fail times:' + str(self.reqTimes))
return self.reqfaceplus()
def reqfaceplus(self):
abs_path_name = os.path.join(dirPath, 'images', self.filename)
# 图片以二进制提交
files = {'image_file': open(abs_path_name, 'rb')}
try:
response = requests.post(
'https://api-cn.faceplusplus.com/humanbodypp/v2/segment', data=self.data, files=files)
res_data = json.loads(response.text)
# 免费的API 很大概率被限流返回失败,这里递归调用,一直到这个图片成功识别后返回
if 'error_message' in res_data:
return self.once_again()
else:
# 识别成功返回结果
return res_data
except requests.exceptions.RequestException as e:
return self.once_again()
# 多线程并行函数
def thread_req(n):
# 创建图像识别类
multiple_req_ins = multiple_req(filename=n)
res = multiple_req_ins.reqfaceplus()
# 返回结果为base64编码彩色图、灰度图
img_data_color = base64.b64decode(res['body_image'])
img_data = base64.b64decode(res['result'])
with open(dirPath + '/clip/clip-color-' + n, 'wb') as f:
# 保存彩色图片
f.write(img_data_color)
with open(dirPath + '/clip/clip-' + n, 'wb') as f:
# 保存灰度图片
f.write(img_data)
print(n + ' clip saved.')
# 读取之前准备好的所有视频帧图片进行识别
image_list = os.listdir(os.path.join(dirPath, 'images'))
image_list_sort = sorted(image_list, key=lambda name: int(re.sub(r'\D', '', name)))
has_cliped_list = os.listdir(clip_path)
for n in image_list_sort:
if 'clip-' + n in has_cliped_list and 'clip-color-' + n in has_cliped_list:
continue
'''
为每帧图片起一个单独的线程来递归调用,达到并行效果。所有图片被识别保存完毕后退出主进程,此过程需要几分钟。
(这里每个线程中都是不断地递归网络请求、挂起等待、IO写入,不占用CPU)
'''
t = threading.Thread(target=thread_req, name=n, args=[n])
t.start()</code></pre><p>先读取上文<code>images</code>目录下所有关键帧列表,并为每一个关键帧图片起一个线程,每个线程里创建一个识别类<code>multiple_req</code>的实例,在每个实例里会对当前传入的文件进行不断递归提交识别请求,一直到识别成功为止(请大家自行申请一个免费KEY,我怕face++把我的号封了:)返回识别后的图片保存在<code>clip</code>目录下。<br>这个过程因为接口命中成功率很低,同一张图片甚至会反复识别几十次,不过大部分时间都是在等待网络传输和IO读写,所以可以放心大胆地起几百个线程CPU单核都跑不满,等个几分钟全部结果返回脚本会自动退出。</p><h5>1.2 像素转换、生成轮廓路径</h5><p><img src="/img/remote/1460000038319517" alt="" title=""><br>我们之前已经得到了算法帮我们提取后的人关键帧,接下来需要利用<code>opencv</code>来转换像素:<br><strong>人物关键帧</strong> to <strong>灰度图</strong> to <strong>黑白反色图</strong> to <strong>轮廓JSON</strong></p><pre><code># translate.py --- openCV转换灰度图 & 轮廓判定转换坐标JSON
import os
import json
import re
import shutil
import cv2
import config
dirPath = os.path.dirname(os.path.abspath(__file__))
clip_path = os.path.join(dirPath, 'mask')
cap = cv2.VideoCapture(os.path.join(dirPath, config.VIDEO_NAME))
frame_width = cap.get(cv2.CAP_PROP_FRAME_WIDTH) # 分辨率(宽)
frame_height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT) # 分辨率(高)
FPS = round(cap.get(cv2.CAP_PROP_FPS), 0) # 视频FPS
mask_cd = int(1000 / FPS * config.FRAME_CD) # 初始帧时间
milli_seconds_plus = mask_cd # 每次递增一帧的增加时间
jsonTemp = { # 最后要存入的json配置
'mask_cd': mask_cd,
'frame_width': frame_width,
'frame_height': frame_height
}
if os.path.exists(clip_path):
shutil.rmtree(clip_path)
os.makedirs(clip_path)
# 输出灰度图与轮廓坐标集合
def output_clip(filename):
global mask_cd
# 读取原图(这里我们原图就已经是灰度图了)
img = cv2.imread(os.path.join(dirPath, 'clip', filename))
# 转换成灰度图(openCV必须要转换一次才能喂给下一层)
gray_in = cv2.cvtColor(img , cv2.COLOR_BGR2GRAY)
# 反色变换,gray_in为一个三维矩阵,代表着灰度图的色值0~255,我们将黑白对调
gray = 255 - gray_in
# 将灰度图转换为纯黑白图,要么是0要么是255,没有中间值
_, binary = cv2.threshold(gray , 220 , 255 , cv2.THRESH_BINARY)
# 保存黑白图做参考
cv2.imwrite(clip_path + '/invert-' + filename, binary)
# 从黑白图中识趣包围图形,形成轮廓数据
contours, _ = cv2.findContours(binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
# 解析轮廓数据存入缓存
clip_list = []
for item in contours:
if item.size > 0:
# 每个轮廓是一个三维矩阵,shape为(n, 1, 2) ,n为构成这个面的坐标数量,1没什么意义,2代表两个坐标x和y
rows, _, __ = item.shape
clip = []
clip_list.append(clip)
for i in range(rows):
# 将np.ndarray转为list,不然后面JSON序列化解析不了
clip.append(item[i, 0].tolist())
millisecondsStr = str(mask_cd)
# 将每一个轮廓信息保存到key为帧所对应时间的list
jsonTemp[millisecondsStr] = clip_list
print(filename + ' time(' + millisecondsStr +') data.')
mask_cd += milli_seconds_plus
# 列举刚才算法返回的灰度图
clipFrame = []
for name in os.listdir(os.path.join(dirPath, 'clip')):
if not re.match(r'^clip-frame', name):
continue
clipFrame.append(name)
# 对文件名进行排序,按照帧顺序输出
clipFrameSort = sorted(clipFrame, key=lambda name: int(re.sub(r'\D', '', name)))
for name in clipFrameSort:
output_clip(name)
# 全部坐标提取完成后写成json提供给flutter
jsObj = json.dumps(jsonTemp)
fileObject = open(os.path.join(dirPath, 'res.json'), 'w')
fileObject.write(jsObj)
fileObject.close()
print('calc done')</code></pre><p>对每一个人物关键帧进行计算,这里就是一层层的像素操作。<code>opencv</code>会把图片像素点生成<code>numpy</code>三维矩阵,计算速度快,操作起来便捷,比如我们要把一个三维矩阵<code>gray_in</code>的灰度图黑白像素对换,只需要<code>gray = 255 - gray_in</code>就可以得到一个新的矩阵而不需要用python语言来循环。<br>最后把计算出的帧的闭包图形路径转换为普通的多维数组类型并存入配置文件<code>Map<key, value></code>,<code>key</code>为视频的进度时间<code>ms</code>,<code>value</code>为闭包路径(就是图中白色区域的包围路径,排除黑色人物区域),是一个二维数组,因为一帧里会有n个闭包路径组成。另外还要将视频信息存入配置文件,其中<code>frame_cd</code>就是告诉flutter每间隔多少<code>ms</code>切换下一帧蒙版,视频的宽高分辨率用于flutter初始化播放器自适应布局。<br>具体JSON数据结构可见上方图片。现在我们已经得到了一个<code>res.json</code>的配置文件,里面包含了该视频关键帧数据的裁剪坐标集,接下来就用flutter去剪纸吧~</p><h4>2. Flutter前端</h4><h5>2.1 弹幕调度动画组</h5><p>弹幕调度系统各端实现都大同小异,只是动画库的API方式区别。flutter里使用<code>SlideTransition</code>可以实现单条弹幕文字的动画效果。</p><pre><code>// core.dart --- 单条弹幕动画
class Barrage extends StatefulWidget {
final BarrageController barrageController;
Barrage(this.barrageController, {Key key}) : super(key: key);
@override
_BarrageState createState() => _BarrageState();
}
class _BarrageState extends State<Barrage> with TickerProviderStateMixin {
AnimationController _animationController;
Animation<Offset> _offsetAnimation;
_PlayPauseState _playPauseState;
void _initAnimation() {
final barrageController = widget.barrageController;
_animationController = AnimationController(
value: barrageController.value.scrollRate,
duration: barrageController.duration,
vsync: this,
);
_animationController.addListener(() {
barrageController.setScrollRate(_animationController.value);
});
_offsetAnimation = Tween<Offset>(
begin: const Offset(1.0, 0.0),
end: const Offset(-1.0, 0.0),
).animate(_animationController);
_playPauseState = _PlayPauseState(barrageController)
..init()
..addListener(() {
_playPauseState.isPlaying ? _animationController.forward() : _animationController.stop(canceled: false);
});
if (_playPauseState.isPlaying) {
_animationController.forward();
}
}
void _disposeAnimation() {
_animationController.dispose();
_playPauseState.dispose();
}
@override
void initState() {
super.initState();
_initAnimation();
}
@override
void didUpdateWidget(Barrage oldWidget) {
super.didUpdateWidget(oldWidget);
_disposeAnimation();
_initAnimation();
}
@override
void deactivate() {
_disposeAnimation();
super.deactivate();
}
@override
Widget build(BuildContext context) {
return SlideTransition(
position: _offsetAnimation,
child: SizedBox(
width: double.infinity,
child: widget.barrageController.content,
),
);
}
}</code></pre><p>当有海量弹幕来袭时,首先需要在播放器上层的<code>Container</code>容器中创造多个弹幕通道,并通过算法调度每一个弹幕该出现在哪个通道,初始化动画,并在移除屏幕后<code>dispose</code>动画并移除该条弹幕的<code>Widget</code><br><img src="/img/remote/1460000038319516" alt="" title=""><br>在此基础上,还需要设置一个时间的随机性,让每一条弹幕动画的飘动时间有一个细微的差异,以此来优化整体弹幕流的视觉效果。关于弹幕调度详细代码可参考此项目<a href="https://link.segmentfault.com/?enc=X%2FuJ6gzOKiQId%2Bwrtt95hA%3D%3D.DYzD6Ls5FeEHmR2hx9NtJCRmDF7XfpwWs1Oe2k9%2FogmIPzIn9Kn4fMNtWPTXgDsSYgIblOUYnS0Jb%2F5YzR57m4MTUg6DqAbGWmdnQqijxgk%3D" rel="nofollow">core.dart</a>文件。这里便不做详述。</p><h5>2.2 裁剪蒙版容器</h5><pre><code>// main.dart (部分代码) --- 初始化时引入配置文件
class Index extends StatefulWidget {
//...
}
class IndexState extends State<Index> with WidgetsBindingObserver {
//...
Map cfg;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
Future<String> loadString = DefaultAssetBundle.of(context).loadString("py/res.json");
loadString.then((String value) {
setState(() {
cfg = json.decode(value);
});
});
}
//...
//...
}</code></pre><p>正式环境肯定是从网络http长连接或者socket获取实时数据,由于我们是离线演示DEMO,方便起见需要在初始化时加载刚才后端产出蒙版路径<code>res.json</code>打包到APP中。</p><pre><code>// barrage.dart (部分代码) --- 裁剪蒙版容器
class BarrageInit extends StatefulWidget {
final Map cfg;
const BarrageInit({Key key, this.cfg}) : super(key: key);
@override
BarrageInitState createState() => BarrageInitState();
}
class BarrageInitState extends State<BarrageInit> {
//...
BarrageWallController _controller;
List curMaskData;
//...
@override
Widget build(BuildContext context) {
num scale = MediaQuery.of(context).size.width / widget.cfg['frame_width'];
return ClipPath(
clipper: curMaskData != null ? MaskPath(curMaskData, scale) : null,
child: Container(
color: Colors.transparent,
child: _controller.buildView(),
),
);
}
}
class MaskPath extends CustomClipper<Path> {
List<dynamic> curMaskData;
num scale;
MaskPath(this.curMaskData, this.scale);
@override
Path getClip(Size size) {
var path = Path();
curMaskData.forEach((maskEach) {
for (var i = 0; i < maskEach.length; i++) {
if (i == 0) {
path.moveTo(maskEach[i][0] * scale, maskEach[i][1] * scale);
} else {
path.lineTo(maskEach[i][0] * scale, maskEach[i][1] * scale);
}
}
});
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) {
return true;
}
}
</code></pre><p>flutter实现蒙版效果的核心就在于<code>CustomClipper</code>类,它允许我们通过<code>Path</code>对象来自定义坐标绘制一个裁剪路径(类似于canvas绘图),我们创建一个<code>MaskPath</code>,并在里面绘制我们刚才加载的配置文件的那一帧,然后通过<code>ClipPath</code>包裹弹幕外层容器,就可以实现一个剪裁蒙版的效果:<br><img src="/img/remote/1460000038319518" alt="" title=""><br>这里加背景色为了看的更清楚,后续我们会把<code>Container</code>背景颜色设置为<code>Colors.transparent</code></p><h5>2.3 视频流蒙版同步</h5><p>首先我们需要引入一个播放器,考虑到IOS和Android插件的稳定性,我们用flutter官方提供的播放器插件<code>video_player</code></p><pre><code>// video.dart (部分代码) --- 监听播放器进度重绘蒙版
class VedioBg extends StatefulWidget {
//...
}
class VedioBgState extends State<VedioBg> {
VideoPlayerController _controller;
Future _initializeVideoPlayerFuture;
bool _playing;
num inMilliseconds = 0;
Timer timer;
//...
@override
void initState() {
super.initState();
int cd = widget.cfg['mask_cd'];
_controller = VideoPlayerController.asset('py/source.mp4')
..setLooping(true)
..addListener(() {
final bool isPlaying = _controller.value.isPlaying;
final int nowMilliseconds = _controller.value.position.inMilliseconds;
if ((inMilliseconds == 0 && nowMilliseconds > 0) || nowMilliseconds < inMilliseconds) {
timer?.cancel();
int stepsTime = (nowMilliseconds / cd).round() * cd;
timer = Timer.periodic(Duration(milliseconds: cd), (timer) {
stepsTime += cd;
eventBus.fire(ChangeMaskEvent(stepsTime.toString()));
});
}
inMilliseconds = nowMilliseconds;
_playing = isPlaying;
});
_initializeVideoPlayerFuture = _controller.initialize().then((_) {});
_controller.play();
}
//...
}</code></pre><p>在video初始化后,通过<code>addListener</code>开始监听播放进度。当播放进度改变时候,获取当前的进度毫秒,<strong>去寻找与当前进度最接近的配置文件中的数据集<code>stepsTime</code></strong>,这个配置的蒙版就是当前播放画面帧的裁剪蒙版,<strong>此时立刻通过<code>eventBus.fire</code>通知蒙版容器用<code>key</code>为<code>stepsTime</code>的数组路径进行重绘。</strong>校准蒙版。<br>这里实际操作中会遇到两个问题:</p><ol><li><p>如何确定当前的进度离哪一帧数据集最近?</p><ul><li>答:在之前数据准备时,通过计算在配置写入了<code>mask_cd</code>,这个时间是最初提取关键帧的间隔,有了间隔时长就可以通过计算得到<code>int stepsTime = (nowMilliseconds / mask_cd).round() * mask_cd;</code></li></ul></li><li><p>播放器的回调是500毫秒改变一次时间进度,但是我们要做到极致体验不能有这么久的延迟,否则不能保证画面和蒙版同步</p><ul><li>答:在每次触发进度改变时,新起一个<code>Timer.periodic</code>循环计时器,循环时间就是之前的<code>mask_cd</code>,同时把此刻的进度时间存起来,那么接下来的500毫秒内,即使播放器没有通知我们进度,我们也可以通过不断地累加自行技术,在计时器的回调里调用<code>eventBus.fire</code>通知蒙版重绘校准。<strong>切记当视频播放完成并开启循环模式时,要将计时器清除</strong></li></ul></li></ol><p>到这里已经基本实现了一个Flutter AI弹幕播放器啦~</p><h4>3. 拓展</h4><h5>3.1 Web前端实现</h5><p>web前端实现要比native实现简单,这里稍微提及一下。服务端处理数据流程是不变的,但是如果只需要对接web前端,<strong>就不用将灰度图转换为json配置</strong>。这得益于webkit浏览器内核帮我们做了很多工作。<br><img src="/img/remote/1460000038319515" alt="" title=""><br><img src="/img/remote/1460000038319514" alt="" title=""><br>从哔哩哔哩网站中审查元素上就可以看到,在播放器<code><video></code>元素上有一层弹幕蒙版<code><div></code>,这个蒙版设置了一个<code>-webkit-mask-image</code>的CSS属性,传入我们之前生成的灰度图片,浏览器内部会帮我们挖出一个蒙版,省去了我们自己去计算轮廓的步骤,<code>canvas</code>和<code>svg</code>也有的API可以实现这个效果,但是无疑CSS是最简单的。</p><h5>3.2 视频点播与直播</h5><p>其实对于蒙版弹幕来讲本质上没有区别,因为视频网站不可能吧一整个视频编码为<code>mp4</code>格式放给用户,都是通过长连接返回<code>m4s</code>或<code>flv</code>的视频切片给用户,所以直播点播都一样。蒙版弹幕的配置信息,不管是web端的base64图片,还是app需要的坐标点json,都需要跟随视频切片一起编码为二进制流,拉到端内再解码,视频的部分喂给播放器,蒙版信息单独抽出来。这两部分得在一个数据包,如果分开传输,就会造成画面蒙版不同步的问题。<br>在直播场景中,视频上传到云端需要实时地提取关键帧,进行图像识别分类,最后再编码推给用户端,这个过程需要时间,所以在开启蒙版弹幕的直播间里会出现延迟,这个是正常的。</p><h5>3.3 总结</h5><p>目前flutter缺少稳定开源的多功能播放器插件,官方的插件只具备基本功能,比如直播流切片就无法支持,一些第三方机构的插件又不一定靠得住,也跟不上flutter版本更新的速度,这是目前整个flutter生态存在的问题,导致了要商用就需要投入大量研发成本去开发native插件。<br>关于这个AI弹幕播放器DEMO,还有些可优化的细节,比如增加蒙版播放器的进度控制,横竖屏切换,特效弹幕等等。文中代码只引入了部分片段,前后端完整代码请参考:</p><h5>Github仓库:<a href="https://link.segmentfault.com/?enc=Ymgw140uC90tTEQrkVHn1g%3D%3D.YoJ2q169DYsxbRsGvDcf9h8rG%2FO2wptDVSo%2BPJ89nUvMHUHc%2FeRLZUWeTGfnpuFz" rel="nofollow">https://github.com/yukilzw/zoe_barrage</a></h5>
带你7天玩转可视化建站平台
https://segmentfault.com/a/1190000022668430
2020-05-17T21:58:23+08:00
2020-05-17T21:58:23+08:00
心动音符
https://segmentfault.com/u/yukilzw
37
<h3>前言</h3><hr><p>对于互联网公司而言,活动运营页面发布频率高,每次开发又要人力成本不划算。因此需要有一套平台系统来满足运营产品自我快速建站。此类软件产品最早追溯到QQ空间,凡客建站等。</p><p>此项目旨在用最少的代码实现可视化搭建、发布、预览、调试等核心功能。并对每一个关键原理进行说明。<br>在此之前,希望你对<strong>Javascript(ES2015,React hooks)、nodejs、webpack</strong>等基础知识有所了解,便可轻松设计开发属于你的建站平台(也许只需要你花费一周的时间ヽ( ̄▽ ̄)ノ)。</p><p>首先我们来看看效果:<br><img src="/img/remote/1460000022668434" alt="可视化编辑器面板" title="可视化编辑器面板"></p><h4>目前支持的功能</h4><h6>编辑器相关:</h6><ul><li>拖拽菜单组件放入画布,能放置的位置标绿框,不能放的标红框;选中锚点、移入高亮。</li><li>鼠标按住拖动画布内组件四周改变宽高,拖动中心改变定位</li><li>属性面板输入样式、自定义属性配置,实时更新画布预览</li><li>页面编辑快捷键操作,包括:保存(Ctrl+S),撤销(Ctrl+Z),恢复(Ctrl+Y),删除(DEL),复制(Ctrl+C),剪切(Ctrl+X),粘贴(Ctr+V),上移节点(↑),下移节点(↓),缩放移动画布(空格按下+左键拖动+滚轮缩放)</li><li><p>直接拖动左下方树结构批量移动节点</p><h6>服务层相关:</h6></li><li>提供平台前端页面(编辑器、预览页)的请求接口与路由模板</li><li>打包构建:对组件仓库的分包,对编辑器SDK的打包</li><li><p>开发组件调试模式的命令行脚本</p><h6>预览相关:</h6></li><li>将页面搭建配置创建React组件树,动态加载所需组件JS文件</li><li>组件懒加载功能</li></ul><h3>实现原理</h3><hr><p>主要划分为4个部分:编辑器、预览页、服务端、组件仓库<br>用户在编辑器内拖入组件仓库已开发的组件,设置样式与自定义属性,为页面生成JSON配置,将配置提交服务端保存。预览页请求返回配置,预览页根据配置动态下载组件文件并渲染。<br><img src="/img/remote/1460000022668433" alt="整体流程" title="整体流程"></p><h5>一、页面JSON配置与渲染的关系</h5><p>不论在编辑器内还是预览页,页面都是根据JSON配置来递归渲染</p><pre><code>{
"name": "View",
"style": {
"position": "relative",
"width": "1089px",
"height": "820px"
},
"props": {
"lazy": true
},
"el": "wc12",
"children": [
//{ ...}
]
}</code></pre><p>这是一个简单的布局组件所映射的JSON结构,其中包括了该组件的样式,传入组件的props属性,以及其唯一的key(el)值,还有他的子组件children数组,数组里的内容就是其包裹组件的JSON结构。通过这样的嵌套关系,可以将其映射成组件树。<br><img src="/img/remote/1460000022668435" alt="compile.js" title="compile.js"></p><p>当页面JSON配置发生变化时,依靠react单向数据流会重新渲染,此时我们需要一个通用方法,来递归的创建组件的占位DIV,<strong>但是需要注意的是,首次创建的只是一个空壳,return的子组件为null。</strong><br><img src="/img/remote/1460000022668436" alt="global.js" title="global.js"><br>与此同时我们调用异步加载组件js的方法,等该组件下载好后自动注入到这个壳里。这个方法的特点在于,我们每次加载新的组件会优先从<code>window.comp</code>下找是否有已缓存的组件对象,如果为<code>undefined</code>,说明这是一个全新的组件,就请求对应的JS下载,<strong>并且将<code>window.comp</code>下的这个组件标识为正在请求的<code>Promise</code>,这样如果相同组件并发调用此方法,会<code>awati</code>同一个<code>Promise</code>不会重复请求</strong>,而且组件缓存后也可以直接用<code>await</code>拿到组件对象。<br><img src="/img/remote/1460000022668437" alt="compile.js -&gt; CompBox" title="compile.js -&gt; CompBox"></p><p>通过上述的几个方法,我们已经能够将JSON配置渲染为页面DOM,并且动态加载组件JS文件了~</p><h5>二、编辑器内的操作</h5><p>既然我们的页面是根据JSON配置来渲染的,<strong>那么对页面任何的增删改查,都可以抽象为对JSON树内某个节点的数据结构修改。我们需要一个通用的搜索方法,来搜索JSON树,并传入一个标识,来指明这次操作的类型</strong>。<br><img src="/img/remote/1460000022668438" alt="" title=""><br><img src="/img/remote/1460000022668440" alt="common.js" title="common.js"><br><code>searchTree</code>是所有操作的通用方法,本质上是对JSON配置树的BFS搜索,只要找到对应的<code>key</code>节点,根据<code>EnumEdit</code>中的枚举类型操作数据后返回修改后的结果树,<code>dispatch</code>新的配置树通知react重新渲染。</p><p>除此之外,具体操作这里涉及到各种键盘、鼠标事件的绑定,这部分暂不做赘述,可自行查询MDN文档。</p><h5>三、样式、自定义属性的注入</h5><p>我们在右侧编辑区填写的内容,都会在渲染时注入到对应的组件里,样式<code>style</code>会注入在包裹组件的壳里,自定义属性会当做<code>prop</code>传入子组件,在组件开发中,我们可以从<code>props</code>中拿到编辑器内填写的属性值。<br><img src="/img/remote/1460000022668439" alt="compile.js -&gt; CompBox" title="compile.js -&gt; CompBox"></p><h5>四、编辑历史记录管理</h5><p>历史记录为一个队列的数据结构,如果我们保存1000条记录,每修改一次JSON配置,就将其入队,每次入队时发现记录大于1000,就将队列头部抛弃。<br>当前页面显示的配置为一个指针,指向队列中某条记录,撤销就指针后移,恢复就指针前移。每次触发compile时,将新的配置树计入队列,不需要手动记录。利用hooks自带的缓存机制非常容易实现。</p><p><img src="/img/remote/1460000022668441" alt="record.js" title="record.js"></p><h5>五、画布的缩放处理</h5><p>在搭建使用程中,我们寄希望于画布设计尺寸永远为1920(移动端则为750),但是视口显然没那么大,所以我们要将画布以左上角为缩放焦点<code>transform-origin: 0 0</code>,拖动导航slide或按下空格利用滚轮缩放。这个过程中不断改变<code>transform: scale</code>来刷新视图。<br><strong>需要注意的是,scale的改变为浏览器重绘,并不会改变原有的DOM占位尺寸,因此缩小画布会有很大的空白区域,为了解决这个问题,我们需要在画布外再包一层div,每次画布改变缩放后,利用<code>getBoundingClientRect()</code>来获取缩放后画布实际的宽高,并将这个数值定义在外层div上,外层div设置为<code>overflow: hidden</code>,这样窗口滚动的距离就会依据外层容器来出滚动条。</strong><br><img src="/img/remote/1460000022668444" alt="" title=""><br>画布的高度计算时,要计算出一个<code>min-height</code>,为当前搭建区域的<code>offsetHeight</code>,保证画布内没有组件撑开时,也能够铺满一个屏幕。<br><img src="/img/remote/1460000022668442" alt="" title=""><br>此外对画布根节点要设置一个<code>padding-bottom: 300px</code>,作用是保证底部永远有一个空白区域,能够让搭建者拖入新的组件到根节点下。</p><h5>六、组件的开发</h5><p>每一个组件都的固有结构,<code>index.js</code>,<code>config.json</code>是必须存在的(服务层会根据此文件构建,稍后会提到):<br><img src="/img/remote/1460000022668443" alt="comp/Image" title="comp/Image"><br>入口文件即为业务代码,配置为一个JSON文件,决定了编辑器内所能编辑的自定义选项:</p><pre><code>{
"name": "图片",
"staticProps": [
{
"name": "点击链接",
"prop": "link",
"size": "long"
},
{
"name": "是否在新窗口打开链接",
"prop": "blank",
"type": "switch", // 配置类型,目前支持`text`默认,`select`,`switch`,`color`
"size": "long" // 配置是否占满编辑器一行
},
{
"name": "图片地址",
"prop": "src",
"size": "long"
}
],
"defaultStyles": { // 拖入组件到画布时默认的样式
"position": "relative",
"width": "180px",
"height": "180px",
"marginTop": "0px"
},
"defaultProps": { // 拖入组件到画布时默认的自定义属性
"src": "http://r.photo.store.qq.com/psb?/V14dALyK4PrHuj/h50SMf97hSy.BJlJw31fagrw.NUaJD83gvydmoGN77w!/r/dLgAAAAAAAAA",
"blank": true
},
"hasChild": false, // 是否允许有子组件,如果不允许拖拽的时候移入会标红,提示当前节点不能被注入
"canResizeByMouse": true // 是否允许通过拖动九宫格蒙版来修改组件的宽高位置
}</code></pre><p>上述这样的一个图片组件,在编辑器内对应的配置项即为:<br><img src="/img/remote/1460000022668445" alt="" title=""></p><h5>七、组件的构建打包</h5><p>这是构建阶段非常重要的一环,我们上面说过,每一个组件对应一个JS文件,那么我们就需要在页面生成前将当前所有组件都构建好。<br><img src="/img/remote/1460000022668446" alt="webpack.config.comp.js" title="webpack.config.comp.js"><br>这里首先找出仓库中的组件,加入打包的<code>entry</code>入口,然后利用webpack的<code>library</code>,<code>libraryTarget</code>配置,将组件打包到<code>window[name]</code>下,name为组件名(比如Image,View),我们来看看打包后的组件代码:<br><img src="/img/remote/1460000022668447" alt="" title=""><br>果不其然 ,组件js下载执行后直接被挂载到window下了,此时此刻你可以回头看开头提到的<code>loadAsync</code>加载组件的方法,是否恍然大悟了呢。<br>这里你可能又发现一个问题,组件都依赖<code>react</code>库,那每个组件单独打包,岂不是都要加载一遍,那包得多大?<br><img src="/img/remote/1460000022668449" alt="" title=""><br>从JS体积可以看出,实际上根本没有打包这些通用库,只包含了业务代码而已。这里同样利用webpack的<code>externals</code>属性,可以指定某些依赖直接从window下取:<br><img src="/img/remote/1460000022668448" alt="webpack.config.comp.js" title="webpack.config.comp.js"><br>那么是什么时候将<code>react</code>注入window下的呢?<br><img src="/img/remote/1460000022668450" alt="global.js" title="global.js"><br>在编辑器或者预览页面,加载全局配置,也就是SDK初始化之前,就将组件所依赖的全局对象注入好了,这样后续组件异步下载后就可以直接执行。<br>关于编辑器和预览页的打包不做特别说明,就是普通的<code>webpack</code>配置打包,记得抽出公共模块就好。</p><h5>七、组件的代理调试</h5><p>平台开发好了,这个时候我们要往里开发业务组件了,那么如何调试呢。<br>通过<code>npm run dev:comp debug=XXX,YYY</code>命令(XXX为组件名)来执行调试脚本<br><img src="/img/remote/1460000022668451" alt="" title=""><br>脚本首先通过<code>process.argv</code>传入的参数获取要调试的组件<br><img src="/img/remote/1460000022668452" alt="debugComp.js" title="debugComp.js"><br>然后使用node API来调用<code>webpack-dev-server</code><br><strong>需要注意的是,这里仅仅是在本地创建了组件的代理,还需要在组件资源加载上区分哪些组件需要请求本地调试地址,详情可见上方<code>loadAsync</code>方法,我们通过在预览页和编辑器后方加入<code>debug_comp=XXX</code>参数来告诉此方法该组件要请求本地调试地址</strong><br><img src="/img/remote/1460000022668453" alt="server/index.js" title="server/index.js"><br>最后记得如果当前用户请求的URL是调试模式,在node express服务的<code>ejs</code>模板接口里加上<code>webpack-dev-server</code>的代码script标签,</p><h5>八、服务端对页面配置的管理</h5><p>因为此项目为演示项目,并没有对页面配置用id进行区分,每次提交都是存取同一个配置文件<code>page.json</code><br><img src="/img/remote/1460000022668454" alt="opPageJSON.js" title="opPageJSON.js"><br>生产环境下需要连接数据库,将每一份配置生产一个ID,在打开编辑时取对应的请求ID返回配置。<br><strong>要额外注意的一点,我们在返回配置接口数据时,要去搜索当前构建文件夹中存在的js与哈希值的映射,这样保证前端页面能正确的加载最新构建的js地址</strong><br><img src="/img/remote/1460000022668455" alt="" title=""></p><h3>项目结构</h3><hr><pre><code>├─config.js // 前后端通用配置
├─comp // 组件仓库
│ ├─Image // 组件名
│ │ config.json // 组件配置
│ │ index.js // 组件入口
│ │ index.less // 组件样式
│ │
│ ├─Text
│ │ ...
│ │
│ └─View
│ ...
│
├─script // 配置脚本
│ debugComp.js // 组件调试脚本
│ webpack.config.comp.js // 组件打包配置
│ webpack.config.edit.js // 编辑器打包配置
│ webpack.config.page.js // 预览页打包配置
│
├─server // 建站平台服务端
│ │ getCompUrlHook.js // 生成组件js文件哈希映射
│ │ getCompJSONconfig.js // 查询组件仓库内当前所有存在的组件配置
│ │ index.js // 服务端总入口
│ │ opPageJSON.js // 存取页面对应的配置JSON树
│ │
│ └─template // 模板
│ index.ejs // html渲染模板
│ page.json // 页面配置JSON树
│
└─src // 建站平台前端SDK
│ context.js // 全局状态对象
│ global.js // 全局配置依赖
│ reducer.js // 全局状态管理
│
├─edit // 编辑器
│ │ compile.js // 编译配置树为组件树
│ │ board.js // 编辑器可视区域面板
│ │ index.js // 编辑器总入口
│ │ menu.js // 编辑器组件菜单
│ │ option.js // 编辑器属性操作面板
│ │ record.js // 操作历史记录管理
│ │ tree.js // 搭建树层级展示
│ │ search.js // 搜索页面配置树方法
│ │
│ └─style // 编辑器样式
│
└─page // 预览页
compile.js // 渲染组件配置
index.js // 预览页总入口</code></pre><h3>结语</h3><hr><p>此项目对可视化建站的整体前后端流程有一个完整实现。基于此基础上,可以根据需要拓展定制化的编辑器功能、页面渲染功能等。</p><p><img src="/img/remote/1460000022668456" alt="" title=""></p>
TS版react-native三端同构设计
https://segmentfault.com/a/1190000022414159
2020-04-18T15:31:14+08:00
2020-04-18T15:31:14+08:00
心动音符
https://segmentfault.com/u/yukilzw
2
<h3>什么是三端同构?</h3>
<p>对于刚接触rn开发的同学可能不是太了解,简单介绍一下。三端指的是安卓、IOS、H5。rn本身就是跨平台框架,为了让RN能在不改动代码的情况下同时兼容H5,只需要引入一些库和配置一些细节即可。</p>
<p>本模板采用目前<code>react-native@0.6x</code>,<code>react-navigation@5.x</code>新版本,搭建ts开发环境。<br>新版本相关库的API与老rn版本有些是不兼容的,请不要随意更改主版本!</p>
<p>不管是对于刚入门RN的小白,还是熟练运用RN老版本的熟手,都能轻松上手<br><a href="https://link.segmentfault.com/?enc=OKPr0XarTLTNcJZMjOZptg%3D%3D.sMbL1gPXaNV8bhbXlLdGoSyeXQCKN1duSKMCbMTxBo%2BMu50xmiT8vxorOkZiZPRJ" rel="nofollow">模板源码</a></p>
<h3>搭建流程(从零起步)</h3>
<h5>1. 安装RN</h5>
<p>照着<a href="https://link.segmentfault.com/?enc=0sKE4dqRAh3Tex74c%2FSQfQ%3D%3D.V7jAlAXmc3bZxHs3HyKvPROl5HpDFNU37NCorBDxHwcRvZ%2FaWajDFa7ik3s06e8u" rel="nofollow">官网</a>配置开发环境,不熟悉客户端开发的前端可以选沙盒环境。但是建议一步步装客户端环境,毕竟现在跨平台时代。</p>
<h5>2. 使用react-native来生成typescript模板</h5>
<p>首先新版本的RN已经不再使用<code>react-native-cli</code>,也就是说我们不需要全局安装任何包。只要你的npm版本在5.2+,可以使用npx命令(<a href="https://link.segmentfault.com/?enc=djOGJy0iEJTwXaaL55TYHw%3D%3D.Ww3SKFhkwlppoQzi5A9lea0oANVuHeZvIL7HE2qNken2dDSHI%2B2P9%2FFstgjb21P2" rel="nofollow">npx介绍</a>)即可。</p>
<pre><code>npx react-native init MyApp --template react-native-template-typescript</code></pre>
<p>npx生成rn for ts模板,安装完成后这就是一个官方可运行的RN项目了,接下来我们就来修改这个模板</p>
<h5>3. 配置package.json</h5>
<p>为了使用新版本的<code>@react-navigation v5</code>搭建完整可用的RN开发项目、以及我们要做的三端同构 ,需要新增一些npm依赖的包。</p>
<pre><code>"dependencies": {
// ...模板自带的包
// ...
"@react-native-community/async-storage": "1.9.0", // 相当于localStorage(可选)
"@react-native-community/masked-view": "0.1.9", // 路由所需要的包 (必须)
"@react-navigation/stack": "5.2.10", // 路由所需要的包 (必须)
"react": "16.11.0",
"react-dom": "16.11.0",
"react-native": "0.62.2",
"react-native-gesture-handler": "1.6.0", // 路由所需要的包,原生手势系统 (必须)
"react-native-reanimated": "^1.8.0", // 路由所需要的包 (必须)
"react-native-safe-area-context": "0.7.3", // 路由所需要的包 (必须)
"react-native-screens": "^2.3.0", // 路由所需要的包 (必须)
"react-native-webview": "8.0.0", // RN打开webView容器,原生调用 (可选)
"react-redux": "5.0.7",
"redux": "4.0.0",
"redux-thunk": "2.3.0"
},
"devDependencies": {
// ...模板自带的包
// ...
"@babel/plugin-transform-runtime": "^7.9.0", // babel的插件包(必须)
"@babel/preset-typescript": "^7.9.0", // babel的编译ts(必须)
"@react-navigation/core": "3.4.2", // rn路由在H5里运行的核心包(必须)
"@react-navigation/web": "1.0.0-alpha.8", // rn路由在H5里运行的核心包(必须)
"babel-loader": "^8.1.0", // webpack loader(必须)
"file-loader": "3.0.1", // webpack loader(必须)
"html-webpack-plugin": "^4.2.0", // webpack plugin(必须)
"react-native-web": "0.12.2", // rn组件映射为WEB的dom(必须)
"webpack": "4.42.1", // 打包rn项目到h5(必须)
"webpack-cli": "3.3.2", // 打包rn项目到h5(必须)
"webpack-dev-server": "3.5.1" // 调试rn项目到h5(必须)
},</code></pre>
<p>该装的都装上别漏了,建议梭哈</p>
<h5>4. 配置weback和文件入口</h5>
<p>首先新建一个<code>index.html</code>文件作为H5打包模板<br>根目录下<code>index.ts</code>为RN为原生打包的入口,新建一个<code>index.web.ts</code>当做H5的打包入口,注意在webpack中添加</p>
<pre><code>resolve: {
extensions: [
'.web.ts',
'.web.tsx',
'.ts',
'.tsx',
'.js',
'.jsx'
],
alias: {
'react-native$': 'react-native-web'
}
},</code></pre>
<p>extensions配置的目的是为了让我们的项目能够在<code>import XX from './xx'</code>引入文件时,能够编写两个不同后缀的文件<code>xx.web.ts</code>,<code>xx.ts</code>。一个给RN打包给原生,一个给webpack打包给H5,用来对不同平台的代码做定制化开发,这一点在路由上是极其有用的。.web.ts一定要配置在.ts前面,让webpack优先找到属于H5的文件打包。</p>
<p>另外alias就是将<code>react-native</code>整个库的组件映射为<code>react-native-web</code>,实际上这个库的原理就是为RN每个组件写对应的dom然后传入props。因为你也可以自行拓展,有的原生组件<code>react-native-web</code>是没有的,比如一开始提到的webView。</p>
<pre><code>{
test: /\.(js|jsx|ts|tsx)$/,
use: [
{
loader: 'babel-loader',
options: {
cacheDirectory: false,
presets: [
'module:metro-react-native-babel-preset',
'@babel/preset-typescript'
],
plugins: [
'@babel/plugin-transform-runtime'
]
}
}
],
exclude: /node_modules/
}</code></pre>
<p>其次配置<code>babel-loader</code>来编译ts,这里尤其要注意:<br>不能使用<code>ts-loader</code>来编译!<br>因为跟文件<code>tsconfig.json</code>中我们配的<code>jsx</code>识别为<code>react-native</code>,是用来编译给客户端的,但是实际上面我们将其代理成了dom元素,打包会产生冲突导致loader编译错误。解决方案为直接使用<code>babel-loader</code>进行配置编译TS,生成sourcemap</p>
<h5>5. 配置React-navigation、Redux</h5>
<p>Redux状态管理和普通react一样,也可以用别的库,毕竟状态管理不涉及到渲染。主要说明React-navigation,这个RN的路由库,每个版本的API差异很大,V5的版本将API拆分为多个组<code>@react-navigation/</code>,有很多种路由配置选择,我这里提供一种:</p>
<pre><code>// router.ts
import 'react-native-gesture-handler';
import React from 'react';
import { connect } from 'react-redux';
import AsyncStorage from '@react-native-community/async-storage';
import {
InitialState,
useLinking,
NavigationContainerRef,
NavigationContainer,
DefaultTheme,
DarkTheme
} from '@react-navigation/native';
import {
createStackNavigator,
HeaderStyleInterpolators
} from '@react-navigation/stack';
import routes from './config';
type RootDrawerParamList = {
[key: string]: any;
};
const Stack = createStackNavigator<RootDrawerParamList>();
const HeaderNull = function(): React.ReactNode {
return null;
};
const MyApp = function() {
const NAVIGATION_PERSISTENCE_KEY = 'NAVIGATION_STATE';
const containerRef = React.useRef<NavigationContainerRef>(null);
const [initialState, setInitialState] = React.useState<
InitialState | undefined
>();
const [theme, setTheme] = React.useState(DefaultTheme);
return <NavigationContainer
ref={containerRef}
initialState={initialState}
onStateChange={async (state) => {
try {
await AsyncStorage.setItem(
NAVIGATION_PERSISTENCE_KEY,
JSON.stringify(state)
);
} catch (e) {
console.log(e);
}
}}
theme={theme}
>
<Stack.Navigator>
{(Object.keys(routes) as (keyof typeof routes)[]).map(
(name) => (
<Stack.Screen
key={name}
name={name}
component={routes[name].screen}
options={{
header: props => HeaderNull()
}}
/>
)
)}
</Stack.Navigator>
</NavigationContainer>;
};
const mapStateToProps = (state: any) => state;
export default connect(mapStateToProps)(MyApp);</code></pre>
<p>简单来说就是把路由配置导入就行了,但是这个<code>config</code>要抽离出来,因为我们在这个目录下还有一个<code>route.web.ts</code>,同样要引用这个配置,但是引用配置的库却是不同的,在H5里我们使用<code>@react-navigation/web</code>:</p>
<pre><code>// router.web.ts
import { connect } from 'react-redux';
import { createSwitchNavigator } from '@react-navigation/core';
import { createBrowserApp } from '@react-navigation/web';
import routes from './config';
const MyNavigator = createSwitchNavigator(routes);
const MyApp = createBrowserApp(MyNavigator);
const mapStateToProps = state => state;
export default connect(mapStateToProps)(MyApp);</code></pre>
<p>这样一来,我们就能保证路由的每个页面都是引用的相同的组件了</p>
<h5>6. link所使用的原生组件库</h5>
<p>该项目最开始配置<code>package.json</code>有两个原生模块模块包,需要使用link进行关联操作,否则构建客户端报错。在<code>npm i</code>后执行:</p>
<ul>
<li>
<code>npx react-native link react-native-gesture-handler</code> 这个是react-navigation使用的原生的手势系统。</li>
<li>
<code>npx react-native link @react-native-community/async-storage</code> 这个类似于浏览器的localStorage原生缓存。</li>
</ul>
<h5>7. 让模板启动起来吧~</h5>
<p>配置好npm启动命令</p>
<pre><code>// package.json
"scripts": {
"android": "react-native run-android",
"ios": "react-native run-ios",
"start": "react-native start",
"web:dev": "webpack-dev-server --config webpack.config.web.ts --inline --hot --colors",
"web": "webpack -p --config webpack.config.web.ts"
},</code></pre>
<ul>
<li>原生客户端使用<code>npm run android</code>或<code>npx react-native run-android</code>(npm v5.2+)启动</li>
<li>
<code>npm run web:dev</code>启动webpack-dev-server调试,和普通移动端开发一样</li>
<li>
<code>npm run web</code>打包为生产环境代码到dist-H5</li>
</ul>
<h5>8. 效果展示</h5>
<ul><li>Native:</li></ul>
<p><img src="/img/remote/1460000022414162" alt="native-0" title="native-0"><br><img src="/img/remote/1460000022414163" alt="native-1" title="native-1"><br><img src="/img/remote/1460000022414166" alt="native-2" title="native-2"></p>
<ul><li>H5</li></ul>
<p><img src="/img/remote/1460000022414164" alt="H5-0" title="H5-0"><br><img src="/img/remote/1460000022414165" alt="H5-1" title="H5-1"></p>
<h5>9. 总结</h5>
<p>从上面的截图可以看到几点:</p>
<ol>
<li>UI样式上基本能一比一还原</li>
<li>路由动画不如原生(这个貌似图片看不到...)</li>
<li>H5上首页有个红圈,这是我故意用了一个<code>react-native-web</code>不支持的组件,在H5就会标红这个区域。</li>
<li>Native-3中有一个客户端半屏webView容器打开了第三方H5,是使用的原生RN组件,这个在H5里显然是没有的,所以遇到这种情况,需要单独对环境进行兼容处理。(我这里没有兼容,所以打包H5的时候你能看到webpack抛出的警告)</li>
</ol>
<p>其实总体来说还是OK的,生产环境下只建议使用UI堆积类应用,H5硬件交互的就别想了,还是要走JSBridge桥接原生。但是用来在浏览器调试RN开发的视图也不错~</p>
<p>如果对项目配置细节(路由,webpack,ts等)有疑惑的,可以直接参考已经搭好的模板:<br><a href="https://link.segmentfault.com/?enc=lqNWIjC0EIVaLn9MKqZiyg%3D%3D.friqygz9qNTqFA0GMx3OS6x1m3fTVv8JG9nCbuDBDMS6PTJPs6eiEohnOhcZF8ww" rel="nofollow">github.com/yukilzw/rn_web</a></p>
H5与IOS、Android、RN通信与回调
https://segmentfault.com/a/1190000022396580
2020-04-16T19:16:17+08:00
2020-04-16T19:16:17+08:00
心动音符
https://segmentfault.com/u/yukilzw
0
<p>在一个hybrid应用的模型中,native客户端通常通过webView容器来打开外部链接,并向H5提供客户端方法,通过Bridge的方式,获取设备硬件控制权,比如相机、音视频、定位、屏幕亮度等。这里将详细介绍H5与安卓、IOS、react-native的通信实现细节。</p>
<h2>一、webView概述</h2>
<h3>IOS容器</h3>
<p>在IOS客户端中,我们首先要提到的是一个叫UIWebView的容器,苹果对他的介绍是:</p>
<p>UIWebView是一个可加载网页的对象,它有浏览记录功能,且对加载的网页内容是可编程的。说白了UIWebView有类似浏览器的功能,我们使用可以它来打开页面,并做一些定制化的功能,如可以让js调某个方法可以取到手机的GPS信息。</p>
<p>但需要注意的是,Safari浏览器使用的浏览器控件和UIwebView组件并不是同一个,两者在性能上有很大的差距。幸运的是,苹果发布iOS8的时候,新增了一个WKWebView组件容器,如果你的APP只考虑支持iOS8及以上版本,那么你就可以使用这个新的浏览器控件了。</p>
<p>WKWebView重构了原有UIWebView的14个类,3个协议,性能提升的同时,赋予了开发者更加细致的配置(这些配置仅针对客户端IOS开发,对于前端H5来说,保持两种容器调用方法的一致性很重要)。</p>
<h3>Android容器</h3>
<p>在安卓客户端中,webView容器与手机自带的浏览器内核一致,多为android-chrome。不存在兼容性和性能问题。</p>
<h3>RN容器</h3>
<p>在react-native开发中,从rn 0.37版本开始官方引入了<WebView>组件,在安卓中调用原生浏览器,在IOS中默认调用的是UIWebView容器。从IOS12开始,苹果正式弃用UIWebView,统一采用WKWebView。</p>
<p>RN从0.57起,可指定使用WKWebView作为WebView的实现</p>
<pre><code>// rn js code
<WebView useWebKit={true} source={{ url: 'https://m.douyu.com' }} /></code></pre>
<p>WebView组件不要嵌套在<TouchableWithoutFeedback>或<TouchableHighlight>原生点击组件中,会造成H5内页面滚动失效</p>
<h2>二、通信原理</h2>
<h3>H5调用IOS客户端方法</h3>
<p>Javascript调用Native,并没有现成的API可以直接拿来用,而是需要间接地通过一些方法来实现。UIWebView与WKWebView有个特性:在H5内发起的所有网络请求,都可以通过拦截在Native层得到通知。这样我们就可以在容器内发起一个自定义协议的网络请求并带上参数,通常是这样的格式:jsbridge://methodName?param1=value1&param2=value2</p>
<p>在客户端代码中,只要拦截层发现协议为jsbridge的请求,就不透传到网络请求层,直接处理相应的业务调用逻辑。</p>
<p>在H5中发起这种特定协议的请求方式分两种:</p>
<ol><li>通过localtion.href;2. 通过iframe方式;</li></ol>
<p>通过location.href有个问题,就是如果我们连续多次修改window.location.href的值,在Native层只能接收到最后一次请求,前面的请求都会被忽略掉。</p>
<p>使用iframe方式,以唤起Native APP的分享组件为例,简单的实现如下:</p>
<pre><code>// h5 js code
var url = 'jsbridge://getShare?title=分享标题&desc=分享描述&link=http%3A%2F%2Fwww.douyu.com&cbName=jsCallClientBack';
var iframe = document.createElement('iframe');
iframe.style.width = '1px';
iframe.style.height = '1px';
iframe.style.display = 'none';
iframe.src = url;
document.body.appendChild(iframe);
setTimeout(function() {
iframe.remove();
}, 100);</code></pre>
<p>然后Webview就可以拦截这个请求,并且解析出相应的方法和参数:</p>
<pre><code>// IOS swift code
func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool {
let url = request.URL
let scheme = url?.scheme
let method = url?.host
let query = url?.query
if url != nil && scheme == "jsbridge" {
switch method! {
case "getShare":
self.getShare()
default:
print("default")
}
return false
} else {
return true
}
}</code></pre>
<p>这里我们在请求参数中加上了cbName=jsCallClientBack,这个jsCallClientBack为JS调用客户端所定义的回调函数,在业务层jsBridge封装中,我们传入一个匿名函数作为回调,底层将这个函数绑定在window的jsbridge对象下并为其定义一个独一无二的key,这个key就是jsCallClientBack,客户端在处理完逻辑后,会通过上面已经介绍过的方法来回调window下的方法。</p>
<p><strong>在将回调绑定在window下时,特别注意要使用bind保持函数内this的原有指向不变</strong></p>
<h3>IOS客户端调用H5方法</h3>
<p>Native调用Javascript语言,是通过UIWebView组件的stringByEvaluatingJavaScriptFromString方法来实现的,该方法返回js脚本的执行结果。</p>
<pre><code>// IOS swift code
webview.stringByEvaluatingJavaScriptFromString("window.methodName()")</code></pre>
<p>从上面代码可以看出它其实就是执行了一个字符串化的js代码,调用了window下的一个对象,如果我们要让native来调用我们js写的方法,那这个方法就要在window下能访问到。但从全局考虑,我们只要暴露一个对象如JSBridge给native调用就好了。</p>
<p>调用客户端原生方法的回调函数也将绑在window下供客户端成功反调用,实际上一次调用客户端方法最后产生的结果是双向互相调用。</p>
<h3>H5调用Android客户端方法</h3>
<p>在安卓webView中有三种调用native的方式:</p>
<ol>
<li>通过schema方式,客户端使用shouldOverrideUrlLoading方法对url请求协议进行解析。这种js的调用方式与ios的一样,使用iframe来调用native方法。</li>
<li>
<p>通过在webview页面里直接注入原生js代码方式,使用addJavascriptInterface方法来实现。</p>
<pre><code>// android JAVA code
class JSInterface {
@JavascriptInterface
public String getShare() {
//...
return "share";
}
}
webView.addJavascriptInterface(new JSInterface(), "AndroidNativeApi");</code></pre>
<p>上面的代码就是在页面的window对象里注入了AndroidNativeApi对象。在js里可以直接调用原生方法。</p>
</li>
<li>
<p>使用prompt,console.log,alert方式,这三个方法对js里是属性原生的,在android webview这一层是可以重写这三个方法的。一般我们使用prompt,因为这个在js里使用的不多,用来和native通讯副作用比较少。</p>
<pre><code> // android JAVA code
class WebChromeClient extends WebChromeClient {
@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
// 重写window下的prompt,通过result返回结果
}
@Override
public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
}
@Override
public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
}
} </code></pre>
</li>
</ol>
<p>一般而言安卓客户端选用1、2方案中的一种进行通信,从前端层面来讲,推荐客户端都使用schema协议的方式,便于前端jsBridge底层代码的维护与迭代。</p>
<h3>Android客户端调用H5方法</h3>
<p>在安卓APP中,客户端通过webview的loadUrl进行调用:</p>
<pre><code>// android JAVA code
webView.loadUrl("javascript:window.jsBridge.getShare()");</code></pre>
<p>H5端将方法绑定在window下的对象即可,无需与IOS作区分</p>
<h3>H5调用RN客户端</h3>
<p>我们知道RN的webView组件实际上就是对原生容器的二次封装,因此我们不需要直接通过schema协议来通信,只需要使用浏览器postMessage、onMessage来传递消息即可,类似于iframe,而真正的通信过程RN已经帮我们做了。</p>
<pre><code>// h5 js code
window.postMessage(data);</code></pre>
<pre><code>// rn js code
<WebView
ref="webView"
source={require('../html/index.html')}
injectedJavaScript={'window.androidConfig = {}'} // 通过这个props可以在webView初始化时注入属性方法
onMessage={e => {
let { data } = e.nativeEvent;
//...
}}
/></code></pre>
<h3>RN客户端调用H5</h3>
<p>postMessage是双向的,所以也可以在RN里发消息,H5里接消息来触发对应的回调</p>
<pre><code>this.refs.webView.postMessage({
cbName: 'xxx',
param: {}
}); </code></pre>
<h2>三、前端jsBridge的封装</h2>
<p>在了解了js与客户端底层的通信原理后,我们可以将IOS、安卓统一封装成jsBridge提供给业务层开发调用。</p>
<pre><code>class JsBridge {
static lastCallTime
constructor() {
if (UA.isReactNative()) {
document.addEventListener('message', function(e) {
window.jsClientCallBack[e.data.cbName](e.data.param);
});
}
}
// 通用callNtive方法
callClient(functionName, data, callback) {
// 避免连续调用
if (JsBridge.lastCallTime && (Date.now() - JsBridge.lastCallTime) < 100) {
setTimeout(() => {
this.callClient(functionName, data, callback);
}, 100);
return;
}
JsBridge.lastCallTime = Date.now();
data = data || {};
if (callback) {
const cbName = randomName();
Object.assign(data, { cbName });
window.jsClientCallBack[cbName] = callBack.bind(this);
}
if (UA.isIOS()) {
data.forEach((key, value) => {
try {
data[key] = JSON.stringify(value);
} catch(e) { }
});
var url = 'jsbridge://' + functionName + '?' parseJSON(data);
var iframe = document.createElement('iframe');
iframe.style.width = '1px';
iframe.style.height = '1px';
iframe.style.display = 'none';
iframe.src = url;
document.body.appendChild(iframe);
setTimeout(() => {
iframe.remove();
}, 100);
} else if (UA.isAndroid()) { // 这里安卓客户端使用的是上面说的第二种通信方法
window.AndroidNativeApi &&
window.AndroidNativeApi[functionName] &&
window.AndroidNativeApi[functionName](JSON.stringify(data));
} else if (UA.isReactNative()) { //rn的<webView>组件可以设置props.userAgent来让H5识别
window.postMessage(
JSON.stringify(data);
);
} else {
console.error('未获取platform信息,调取api失败');
}
}
// 业务层自定义方法
getShare(data, callBack) {
//..
}
}</code></pre>
<p>在核心封装的基础上,我们可以还做更多的优化,比如将每个回调函数调用后自我销毁释放内存</p>
<h2>四、调试</h2>
<ol>
<li>安卓使用chrome://inspect进行调试,需要翻墙</li>
<li>IOS使用mac safari的develop选项进行调试</li>
<li>使用RN的<a href="https://link.segmentfault.com/?enc=tJ710fn4Q6fRQjPGRYBJng%3D%3D.33ivgqzcIQz2k6Mj2eucnnBIt4D1M3BDx7nMzbAgBy0%3D" rel="nofollow">http://localhost</a>:8081/debugger-ui 只能调试RN代码,无法调试webView代码,RN下webView调试和对应native相同,但是在chrome://inspect下会出现样式问题。</li>
<li>除非是纯RN编写,直接打包成APP,否则不建议在RN下调用webView组件</li>
</ol>
使用flutter跨平台开发 - 斗鱼APP
https://segmentfault.com/a/1190000019852614
2019-07-23T21:37:23+08:00
2019-07-23T21:37:23+08:00
心动音符
https://segmentfault.com/u/yukilzw
13
<h2>前言</h2>
<h4>话不多说,直接上APP效果截图:(源码地址:<a href="https://link.segmentfault.com/?enc=cfnxGc1jMw5XEx8E%2BRhP4g%3D%3D.4XidRZBwNsL0XvazmndG39RIAlJoDg7t2LNt2sIV4qFqltKOIDiBUnskJ0G6vB8P" rel="nofollow"></a><a href="https://link.segmentfault.com/?enc=ho%2F3Vpf6HiuoDNRUMwklmQ%3D%3D.r%2FCQRzO2lR6xqZmIRIYj8YeQDBxjQh%2BS31w8%2BrhvXLUJrVlohFv%2BIE7I4gFw%2BfFT" rel="nofollow">https://github.com/yukilzw/dy...</a>)</h4>
<p><img src="/img/remote/1460000020381094?w=375&h=753" alt="图片描述" title="图片描述"></p>
<p><img src="/img/remote/1460000020381147?w=375&h=755" alt="图片描述" title="图片描述"></p>
<p><img src="/img/remote/1460000020251527?w=340&h=685" alt="图片描述" title="图片描述"></p>
<p><img src="http://r.photo.store.qq.com/psb?/V14dALyK4PrHuj/MLs.r66dWgbZWl3nbHTS52HLpFYuc8gZv6RVCNg0JVw!/r/dFEBAAAAAAAA" alt="图片描述" title="图片描述"></p>
<h4>主要涵盖功能:</h4>
<ul>
<li>滑动状态导航、轮播图</li>
<li>移动端px兼容</li>
<li>封装HTTP、IO缓存操作</li>
<li>页面路由传值</li>
<li>bloc全局状态管理</li>
<li>礼物横幅动画队列</li>
<li>弹幕消息滚动</li>
<li>接入静态视频流</li>
<li>九宫格抽奖游戏</li>
<li>照片选择</li>
<li>webView容器</li>
<li>...</li>
</ul>
<h2>初识</h2>
<p>flutter - 轻松、快速地构建漂亮的移动应用。</p>
<ul>
<li>Flutter是Google使用Dart语言开发的移动应用开发框架,使用一套Dart代码就能构建高性能、高保真的iOS和Android应用程序,并且在排版、图标、滚动、点击等方面实现零差异。</li>
<li>作为一个全新的移动端开发选择,谷歌希望日后为新系统fuchasia所使用(谷歌希望fuchasia能替代安卓系统,成为未来物联网多端集成的系统),因此在此之前同样支持在安卓、IOS上运行flutter,引导开发者技术转型。</li>
<li>flutter和前端的关系:flutter作为一种新的APP开发模式,和前端本质上没有任何关系(这一点和RN有本质区别),无论前端工程师还是安卓IOS工程师,面临的学习成本都是相似的。</li>
</ul>
<h2>详解</h2>
<h3>Flutter框架简介</h3>
<p><img src="https://ceph-dev-pub.dz11.com/fed-doc/1563345316987.png" alt="上传成功" title="上传成功"></p>
<ol>
<li>跨平台应用的框架,没有使用WebView或者系统平台自带的控件,使用自身的高性能渲染引擎(Skia)自绘,</li>
<li>界面开发语言使用dart,底层渲染引擎使用C, C++</li>
<li>组合大于继承,控件本身通常由许多小型、单用途的控件(Widget)组成,结合起来产生强大的效果,类的层次结构是扁平的,以最大化可能的组合数量</li>
</ol>
<h3>构建管道</h3>
<p><img src="https://ceph-dev-pub.dz11.com/fed-doc/1563346011910.jpg" alt="上传成功" title="上传成功"></p>
<ol>
<li>整合视图所有组件类的状态</li>
<li>通过继承自flutter基类的build方法,构建widget树,在任何状态改变的,重新触发build</li>
<li>根据传入每个widget的参数来计算样式,合并至渲染树</li>
<li>利用skia引擎在画布上进行重绘</li>
</ol>
<ul><li>关于flutter渲染数据流相关的更详细分析,可查阅文章<br/></li></ul>
<p><a href="https://link.segmentfault.com/?enc=BiF96XrAByDuqsCPtYmWDg%3D%3D.jXCXEYoLHD%2BkK0fuMelR9G9Zp%2B5IJKWTHO0e5z27sHTUaKB4LaMCKFo2op538LK2ZOL%2FFgsUQAYgllNjUauZSG5sHv9oo1lLa%2F5WE%2B2aPhE%3D" rel="nofollow">flutter 原理</a></p>
<h3>Flutter与React-native对比</h3>
<h4>架构设计差异</h4>
<p><strong>RN:</strong> <br><img src="https://ceph-dev-pub.dz11.com/fed-doc/1563349953381.png" alt="上传成功" title="上传成功"></p>
<p><strong>Flutter:</strong> <br><img src="https://ceph-dev-pub.dz11.com/fed-doc/1563350052318.png" alt="上传成功" title="上传成功"></p>
<ul>
<li>RN利用Bridge桥接的方式调用原生进行渲染,JSCore负责解析JS代码逻辑,映射到原生对应控件,由于安卓、IOS原生的差异性较大,RN底层需要对多端进行映射实现,并且难免出现不兼容情况。<br/></li>
<li>Flutter利用skia引擎绘制渲染,直接与GPU交互,脱离系统层面,因此绘制出的UI可以完全一致,甚至可以用在web端(Flutter-web)。</li>
</ul>
<p>用前端比较熟悉的WEB开发作比方,RN好比是构建DOM用浏览器内核解析渲染,而Flutter则类似canvas画布,在H5开发中,canvas绘制的图像由于是依赖GPU的像素点操作,不会有UI兼容性问题。</p>
<p>综上所述,flutter的性能的确做到完完全全等于原生,而RN在一些复杂业务场景下,会出现卡顿白屏。因此尽管RN诞生了快4年了,也没有复杂的APP使用纯RN开发,都是将RN当做热更新组件集成到现有原生APP。而Flutter不同,它就是一种APP开发方式,官方甚至没有给出集成到现有业务的方案(尽管此前美团通过修改Flutter打包逻辑做到了这一点,但是有点南辕北辙的意思)。</p>
<h4>打包体积差异</h4>
<p><img src="https://ceph-dev-pub.dz11.com/fed-doc/1563348991175.png" alt="上传成功" title="上传成功"></p>
<p>Q:从上表数据可以看出,在IOS下RN的体积要小的多,在安卓下Flutter体积要小的多,这是什么原因呢?</p>
<p>上面我们提到过,flutter需要skia引擎,这在安卓系统里是自带的(skia也是谷歌开发的东西),但是IOS里没这个玩意,所以打包的时候,需要吧C++编译后的代码也打到包里,无形中就增加了10M多。同理安卓系统JS引擎是V8,没有JSCore,所以RN打包安卓的时候,也要把JSCore打进去。但是不管是哪种方式,随着包的业务体积变大,引擎所占用的比例也就变得越来越小了。</p>
<ul><li>关于Flutter与RN的详细对比可以查阅:</li></ul>
<p><a href="https://link.segmentfault.com/?enc=V%2B3J7ZF4jNMnYeutfGaABg%3D%3D.d1Gf1qtrBEttL5EOhulzKXIwAb1h4ALhn90Xgy7U7KDR7IjjLsg458Ldlhs%2BhkTk48lRmKkAiW7boyS42xlFCQ%3D%3D" rel="nofollow">全网最全 Flutter 与 React Native 深入对比分析</a></p>
<h4>对比总结</h4>
<ul>
<li>Flutter性能会更好无线接近原生的体验,Dart是AOT编译的,编译成快速、可预测的本地代码</li>
<li>RN采用JS语言开发,基于React,对前端工程师更友好。Dart语言受众小,有学习成本</li>
<li>Flutter自己实现了一套UI框架,丢弃了原生的UI框架。而RN还是可以自己利用原生框架,两个各有好处。Flutter的兼容性高,RN可以利用原生已有的优秀UI</li>
<li>RN的布局更像css,而Flutter的布局更像native布局,但是去掉xml通过代码直接写需要适应下</li>
<li>Flutter的第三方库还很少,RN发展的早,社区环境逐步变得完善,Flutter github还有5000+个issues要解决</li>
</ul>
<h2>实践</h2>
<p>最让大家关心的是如何利用flutter来开发一款APP,不幸的是,这对于前端工程师来说也许是一件不太友好的事,因为Flutter本身就不是设计给前端用的。</p>
<p>接下来会通过归纳学习路线,指引大家一步步的开发出自己的flutter应用,这个过程需要十足的耐心</p>
<h3>Dart</h3>
<p>第一步就是要学习Dart,这是一门2011年才由谷歌推出的新的编程语言,它是强类型、纯面向对象的。如果你对Typescript足够熟练,会降低Dart学习成本(作为前端开发者,建议先熟练掌握Typescript,这是必须的)。</p>
<p>推荐文档:<br/></p>
<p><a href="https://link.segmentfault.com/?enc=cWWTfDGkYVmXPOGS%2F5VIxg%3D%3D.MaN%2Fqp9P5eOmVlTVkRTXQA0aFd1wgkhBuMxodcqUQRYLnBnz9UrnYtx1lFkUid9A9UKSKrdu5iR1OMApR9sL2g%3D%3D" rel="nofollow">Dart 语法描述(中)</a> - 来源于Dart中文网,顺着目录把所有语法看一遍</p>
<p><a href="https://link.segmentfault.com/?enc=Ut8gijc%2BDoL8eYDTZ7Hg9w%3D%3D.FSGOC%2F%2B2rX1QBD91uzGcSTZXlFzw2AakYn2ogCVVMMxRoeiy1xK8GxA9R5ATT3ZHpDpMwyai6w6j7g2JAQ2okQ%3D%3D" rel="nofollow">Dart SDK</a> - Dart SDK 使用 API,也就是Dart虚拟机提供的底层类,比如网络、IO操作、异步等,类似于nodeJS中的net、fs、os等自带模块。</p>
<p><a href="https://link.segmentfault.com/?enc=nKbci7tUBkfV6JKM98zG%2FQ%3D%3D.y9LZJII4sb96UpxFK8ORGSsfHEiGmYaNiGr4exI%2F3V4%3D" rel="nofollow">Dart PUB </a> - 扩展的第三方模块,说白了,就是我们JS中的npm包,这是叫做pub,可以将自己封装的模块发到这个网址上,并且也有版本管理,和npm简直一模一样</p>
<p>中文版的内容有些删减,但是也覆盖了日常开发中所有的场景,如需要浏览全部API请查最新官方英文文档 <a href="https://link.segmentfault.com/?enc=vTtHQBoQU%2FDsqSRoB2fBYQ%3D%3D.aAhtkIZmu5fSeMiMzyGcI1vOONnKyTQrdcz7wG8qdpg%3D" rel="nofollow">Dart</a></p>
<h3>flutter</h3>
<h4>搭建开发环境</h4>
<p>如果搭建过RN环境,想必大家第一次都是各种报红,Dart在这方面更友好,只要要找官方指引走,基本上一次就能跑起来Hello Word</p>
<p><a href="https://link.segmentfault.com/?enc=VaTVTZYBDBkrb%2FHRn%2BnBFQ%3D%3D.ANhl6bOAibmAGTEayClzJ0q1mDpVcQb6J%2FH%2F9%2B8D0diAkCI4xPAze6QLfDXBxYNw" rel="nofollow">Flutter 搭建开发环境 </a></p>
<p>总结一下顺序就是:</p>
<ol>
<li>配置国内镜像到环境变量(有VPN当我没说...)</li>
<li>安装Flutter SDK(下载双击exe傻瓜操作)</li>
<li>将安装路径下 <code>flutter\bin</code> 文件夹配置到环境变量</li>
<li>安装 Android Studio并打开SDK Manage安装android SDK和android sdk build-tools</li>
</ol>
<p><img src="https://ceph-dev-pub.dz11.com/fed-doc/1563353803808.png" alt="上传成功" title="上传成功"></p>
<p><span style="color: red; font-weight:bold">如果之前使用过RN,可能已经安卓过SDK,但是版本并不一定适用,可以在cmd执行<code>flutter doctor</code>来检测当前环境是否搭建成功,如果有错误,根据提示安装对应版本SDK即可。</span></p>
<p><img src="https://ceph-dev-pub.dz11.com/fed-doc/1563354019502.png" alt="上传成功" title="上传成功"></p>
<p>Android Studio的报错我们不管,我们用Vscode开发,我们只要关注第二项没红叉,这里警告是因为我的SDK版本是之前RN使用的,不完全符合,但这个版本不会出错没有红叉</p>
<ol>
<li>安装VScode flutter插件</li>
<li>在一个目录下命令行执行<code>flutter create myapp</code>然后进入目录<code>cd myapp</code>
</li>
<li>将你的手机插电脑,或者启一个虚拟安卓机,执行<code>flutter devices</code>检查是否有已连接设备</li>
<li>执行<code>flutter run</code>
</li>
</ol>
<p>看到一个Flutter APP界面出现在你的应用上,真是激动不已啊~</p>
<p><img src="https://ceph-dev-pub.dz11.com/fed-doc/1563354405160.png" alt="上传成功" title="上传成功"></p>
<h4>应用的热更新 & 热重载</h4>
<p>熟悉前端的同学都知道,在web开发时我们更新一段JS代码,页面不会刷新,会局部重载,而在业务结构改动的时候,热重载不会生效,必须进行刷新页面进行热更新,这得益于webpack-dev-server里webSocket的消息实现,在Flutter也有类似的功能,</p>
<p>当我们应用在cli里启动后,输入小写r触发热重载,输入大写R触发热更新。</p>
<p><img src="https://ceph-dev-pub.dz11.com/fed-doc/1563354769695.png" alt="上传成功" title="上传成功"></p>
<p>用VSCODE安装Flutter插件直接在根目录main.dart下按F5启动。</p>
<p><img src="https://ceph-dev-pub.dz11.com/fed-doc/1563450951193.png" alt="上传成功" title="上传成功"></p>
<p><img src="https://ceph-dev-pub.dz11.com/fed-doc/1563449347577.png" alt="上传成功" title="上传成功"></p>
<p>如果使用vscode内部插件启动flutter,需要点击编辑器上方的刷新按钮,根据个人喜欢选择启动方式</p>
<h4>项目开发</h4>
<p>在一切就绪后,我们开始进行编码,flutter的布局方式和WEB有着很大区别,没有层级样式表CSS,一切由Dart代码布局,通过为Widget注入参数来实现,但是由于Dart是强类型语言,远不如CSS灵活,每一种Widget都只允许注入指定参数,否则会编译报错,这就需要开发者对每一种Widget的用法都熟于心,这也增加了flutter的门槛。</p>
<p>布局由于是嵌套关系,但是flutter并没有HTML、XML、JSX这样的模板语法,纯粹由Dart类来构建视图结构,所以就出现了一个非常恐怖的问题 <strong>地狱嵌套</strong>,试想一下,如果大家用React的时候,不用JSX,而全部用<code>Raact.createElement</code> 嵌套<code>children</code>去实现dom树结构,然后将样式通过<code>props</code>中的<code>style</code>来设置,那这个代码有多可怕,但是flutter的写法就是这样的。</p>
<p>从<a href="https://link.segmentfault.com/?enc=c9LrcNlganRaoNw43rDkRA%3D%3D.ayzaOn7dWqnAmXhIDOoxYHeGaFMLCvyv8YwVrbH54NtQVAomlV3P%2BLkq7szDS2wD" rel="nofollow">flutter 中文网</a>来一步步学习flutter开发吧~</p>
<p>提供一个官方最全的虽有flutter API查阅文档<a href="https://link.segmentfault.com/?enc=8CSZEK3fD02Fi2YfqHAPkw%3D%3D.0ViQwhn9bHvGcyD93PqtXNNnlpYOpnYGGOwP8Zg81R8%3D" rel="nofollow">f;utter SDK</a></p>
<h4>调试</h4>
<p>如果想要像chrome浏览器那样进行UI、断点调试,可以在VSCODE里安装官方工具<a href="https://link.segmentfault.com/?enc=q3fNUfn8e52qE7lzGmNyhg%3D%3D.azvJLMt%2FYZb8goaMv6n%2B7LhZFScq5mS00h4QpW2C6qIcLddkStUqOfIGGAAsNBZVYYos0z%2BbwFX%2FA9ApdvNg5w%3D%3D" rel="nofollow">Dart DevTools</a>,这玩意用起来类似于chrome f12和react devtools,使用方法是在Vscode里F5启动flutter项目后,ctrl + p 输入:</p>
<p><img src="https://ceph-dev-pub.dz11.com/fed-doc/1563451010955.png" alt="上传成功" title="上传成功"></p>
<p>然后chrome会弹出调试界面:包含tree结构检查,元素审查,渲染性能,CPU,GPU,内存情况等等</p>
<p><img src="https://ceph-dev-pub.dz11.com/fed-doc/1563451114381.png" alt="上传成功" title="上传成功"></p>
<h4>路由</h4>
<p>APP的路由不存在url,因此路由都是动态的,不可能说我打开APP指定一个URL去唤起对应的某个页面,<br>在这个flutter<a href="https://link.segmentfault.com/?enc=e6PyFx%2FZKxadomw%2BxBrRdA%3D%3D.e9I6o39jbhvusohK1E%2FBLBNfInzgxumZcwyYyy2hflYVtGNr55d3PVWCWa%2FsqZBBza3w5CveGpSfsh4i%2BPVeJfrOZXZSb6b%2FPRayFSRhaJY%3D" rel="nofollow">github官方demo</a>上有路由的实现方法,可以借鉴,但是没有给出传递参数的办法,我个人想办法用传参继承的方式做到了这一点,可参考<a href="https://link.segmentfault.com/?enc=ZeEyhwz%2BgREi6dbanJ7cbw%3D%3D.L84KMmsB7c%2Fq7DhPUKnshaQ%2BSTF5z%2BdoT522LAjv%2B%2BKtBfVHG9J%2ByFD73wFOJeLs" rel="nofollow">dy_flutter</a></p>
<h4>状态管理</h4>
<p>在大型APP项目的开发中,如果仅仅使用<code>StatefulWidget</code>进行setState管理实例属性,无法满足要求,需要一种全局的状态管理手段,Flutter的状态管理方案的选择非常多样化,可以酌情选择:</p>
<ol>
<li>直接使用flutter的<code>InheritedWidget</code>进行module状态管理 <a href="https://link.segmentfault.com/?enc=jbRMKMqxmo1uscilslPG0Q%3D%3D.mmnxGC5rPluuYs8FurDuyO9fQgNGKkRruuve6Ikx9e1hM%2F%2BjivkD6iMTc5%2Bem3Ha" rel="nofollow">InheritedWidget数据管理</a>,这种方式属于未封装的原生API,操作起来可维护性不强不做推荐</li>
<li>使用Redux,什么玩意?Redux?是的,国外的一个大佬用Dart语言基于<code>InheritedWidget</code>API实现了一套flutter_redux库,不用多说了,就是anction+reducer+dispatch玩法,这是最适合前端的方案~~<a href="https://link.segmentfault.com/?enc=nYHsN3UKvRTEGZ%2FiZ5%2FL2g%3D%3D.NY1xfgrsmsJGMgEnyeiqI54hb5Ic1nGWfLnq%2BtBvXUSMAA5jnkTTQ3PpyZEAUQua" rel="nofollow">Flutter接入Redux</a>
</li>
<li>ScopedModel 也是根据<code>InheritedWidget</code>继承实现的封装,API和redux不一样,不作赘述。</li>
<li>Bloc(<a href="https://link.segmentfault.com/?enc=LFVtEm9o3GzPu3p5Wl1mnA%3D%3D.ejBN1IR9ywR%2FBdpypg0CMuFTMDBp5EqFL9c57ZDOJcGe7cHmBHgdo4KzRJFQUD2rPBEN1H%2F2I5rFijkTZxO2Og%3D%3D" rel="nofollow">Bloc文档【英】</a>),BLoc是一种利用reactive programming方式构建应用的方法,这是一个由流构成的完全异步的世界。更深层次的来讲,Bloc底层是基于RxDart的订阅观察者模式,RxDart是Rx设计的Dart语言实现,同类知名的还有RxJava,Rxjs,前者用在安卓应用状态管理,后者用在Angular前端框架状态管理。</li>
</ol>
<h4>视频流</h4>
<p>目前使用官方插件video_player,但是只能支持播放静态视频,没有太好的直播流方案,还在研究</p>
<h4>插件化</h4>
<p>由于flutter 是自己绘制的UI层,不像RN或者Native那样可以直接调用系统功能(WebView、摄像头、陀螺仪)等,所以在真实业务场景下需要使用原生代码来实现插件给flutter调用。</p>
<h2>总结</h2>
<ul>
<li>本文对flutter的原理、搭建、学习路线等资料进行了一定的归纳,实际开发中会遇到各种各样的坑,有的是因为对flutter API不熟,有的甚至是因为flutter本身存在的一些问题。</li>
<li>目前flutter仍处于发展探索阶段,离正式商业化使用还有距离,毕竟不可能将现有APP整个用flutter重构,现有APP接入flutter又是本末倒置,RN仍是跨双端开发更稳定的方案,使用于大多数场景,且JS + React的模式也更适合前端工程师。</li>
</ul>
使用prince-cli,轻松构建高性能React SPA项目~
https://segmentfault.com/a/1190000015806266
2018-07-29T17:39:40+08:00
2018-07-29T17:39:40+08:00
心动音符
https://segmentfault.com/u/yukilzw
3
<h2>prince-cli 快速指南</h2>
<p>这是一个为快速创建SPA所设计的脚手架,旨在为开发人员提供简单规范的开发方式、服务端环境、与接近native应用的体验。使用它你能够获得下面这些便利</p>
<ul>
<li>快速开始编写react spa页面,在浏览器对react组件打断点调试</li>
<li>浏览器实时刷新,控制台、redux-devtools可随时查看状态</li>
<li>模拟ajax、jsonp请求与webSocket消息,真实服务端环境</li>
<li>拥有类似native的翻页路由,且刷新页面reducer会缓存状态不会丢失</li>
</ul>
<p>目前此脚手架由我个人设计搭建维护,并不断完善中,欢迎issues</p>
<h4>使用方式</h4>
<ol>
<li>安装node.js(v8.0.0+)以及对应版本npm</li>
<li>在需要创建项目的路径打开cmd命令行</li>
<li>执行 <code>npm install prince-cli -g</code> 全局安装prince-cli</li>
<li>执行 <code>prince new myApp</code> 新建react SPA项目(myApp为新项目名,可随意更改)</li>
<li>进入项目路径 <code>cd myApp</code>
</li>
<li>执行 <code>npm install</code> 拉取项目依赖包</li>
<li>执行 <code>npm run dev</code> 启动开发环境</li>
<li>开发完成执行 <code>npm run build</code> 打包发布</li>
</ol>
<p><img src="/img/bVbet6a?w=406&h=299" alt="cli" title="cli"></p>
<h4>开发规范</h4>
<pre><code>########### myApp项目结构 ###########
├── mock
│ ├── mock.api.js // rest请求接口
│ ├── socket.api.js // websocket接口
│ └── data
│ ├── mockData.js // rest请求mock数据
│ └── socketData.js // websocket推送mock数据
├── src
│ ├── action // 事件
│ ├── assests // 静态文件
│ ├── component // 组件
│ ├── less // 样式
│ ├── reducers // 状态管理
│ ├── route // 路由
│ └── service // 方法
├── entry.js // 入口
├── package.json // npm配置
├── postcss.config.js // postcss配置
├── server.js // 本地服务端
├── temp.html // 模板
└── webpack.config.js // webpack配置
</code></pre>
<ul>
<li>在开始之前,你需要先了解react,redux,redux-router4,less的大概使用方法,请查阅官方文档。</li>
<li>service概念并不属于react原始体系,通俗来讲就是将页面组件的业务逻辑抽离到一个独立的类,避免在component里做过多的逻辑处理,component仅调用service的方法,拿到数据并render渲染。</li>
<li>在action、reducers、service中都有公共的common文件夹,抽出每个页面组件都需要使用的方法(在这些公共的类里包含了一些socket消息订阅、fetch请求、redux本地缓存中间件的封装,可以根据业务需求增减)。</li>
<li>server.js 对模块进行了打包,监听文件更改刷新等功能,创建了3个服务,分别为静态资源服务:<code>http://localhost:4396</code>(用于代理本地资源,与自刷新浏览器);rest请求服务:<code>http://localhost:4397</code>(用于接受ajax,jsonp请求,返回mock数据);webSocket服务:<code>ws://localhost:4398</code>(用于收发webSocket消息)。一般来说不需要更改此文件</li>
<li>具体请求接口、订阅发送webSocket消息方法都在公共service中,且有代码演示,使用尤为方便</li>
<li>PS:此脚手架设计目标是化繁为简,保留核心功能,减小学习成本,适用于小规模SPA快速开发,没有引入eslint代码测试、单元测试等,如有需要可自行添加</li>
</ul>
<p><img src="/img/bVbet6J?w=851&h=661" alt="dev" title="dev"></p>
<h4>构建模块</h4>
<ul>
<li>命令行相关:commander、shelljs、git-clone等</li>
<li>打包相关:webpack4、babel等</li>
<li>服务相关:koa2、browser-sync、ws等</li>
<li>应用相关:react、redux、react-router4、less等</li>
</ul>
<h4>推荐文档</h4>
<ul>
<li>React中文文档:<a href="https://link.segmentfault.com/?enc=AkOTBdub6Tw7R3nFJ%2Byx9g%3D%3D.f5DYsD4cUjV4tdBAv6iqtUJNps6f5qupPw2KQn9Aphs%3D" rel="nofollow">https://doc.react-china.org/</a>
</li>
<li>Redux中文文档:<a href="https://link.segmentfault.com/?enc=o937dAeYLO4UnwguxAG0qA%3D%3D.wuILgd7ceAVgGIncJN844Hjo9kx7fKoNkkwEEZmqjuU%3D" rel="nofollow">http://cn.redux.js.org/</a>
</li>
<li>React-Router4英文文档: <a href="https://link.segmentfault.com/?enc=HjTaIcZw4hbaY8VX2VA5dA%3D%3D.Rk5dd%2Fqr8ScYdTz%2F%2BClMVIlc%2BqeFKzyf1XaymEhK6uYZkV33F1gaZ3x%2FL5hGJdKI" rel="nofollow">https://reacttraining.com/rea...</a>
</li>
<li>Less中文文档:<a href="https://link.segmentfault.com/?enc=SLvyfAo6eP9MPpSs0iNLkQ%3D%3D.r0pUorb3lWbQpQIUhkrsHDnLb6l3I%2FNQVY3FLRuDW3M%3D" rel="nofollow">http://lesscss.cn/</a>
</li>
</ul>
<h4>结语</h4>
<ul>
<li>除了SPA之外,服务端路由的项目也可以基于此脚手架进行改造。除了React项目,还可以换成vue、angular项目。目前我正在对此进行封装,在prince-cli中通过配置化加载不同场景,不同框架的项目。</li>
<li>关于从零搭建脚手架的过程,其实并不困难,最重要的是设计,而不是实现。大型脚手架也是基于这些模块设计,只是提供了更多的可配置功能,以后会对此方面专门进行整合分享。</li>
</ul>
<p>prince-cli 项目地址:<a href="https://link.segmentfault.com/?enc=Uuuv3e76fApaet5Z82y27A%3D%3D.g%2BlMWMmse9L1iyyti8JAjhqxNFU3H4LxJUTfgwUMs1EvZc%2F35PyDNif3JuJ6t91C" rel="nofollow">https://github.com/yukilzw/pr...</a><br>如果觉得对你有所帮助,多谢支持 ~</p>
react+react-router4+redux最新版构建分享
https://segmentfault.com/a/1190000010632731
2017-08-12T23:28:06+08:00
2017-08-12T23:28:06+08:00
心动音符
https://segmentfault.com/u/yukilzw
10
<p> 相信很多刚入坑React的小伙伴们有一个同样的疑惑,由于React相关库不断的再进行版本迭代,网上很多以前的技术分享变得不再适用。比如<code>react-touter2</code>与<code>react-router4</code>在写法上存在不少区别,以前的调用方法将无法使得项目正常工作。我最近用React全家桶在构建一个spa,由于官方文档给的比较繁琐,使用类似<code>react-cli</code>的脚手架工具会使你的理解停留在表面,能用单反相机就不用傻瓜相机~~最好还是自己动手丰衣足食。在这里希望能用通俗易懂的方式来说一下如何快速构建spa。(PS:此文旨在让大家少走弯路,因此在项目结构上力求全而简)</p>
<hr>
<p>在此之前你先需要懂得基本的 nodejs 操作与 ES2015 语法。<br>通过<code>npm</code>安装webpack:<code>npm install webpack</code>,然后用node运行配配置文件(这里并非绝对,也可以直接在cmd里运行,但不推荐)</p>
<hr>
<p>首先给出项目结构:</p>
<pre><code>--component //组件文件夹
ㄴ--hello.jsx //组件jsx
--more-component //嵌套组件可以放在次级目录
--js
ㄴ--common.js //自己常用的js方法,
--css
ㄴ--hello.css //每个组件对应一个css文件,便于管理
--img
--route
ㄴ--router.jsx //路由配置组件
--store //redux相关
ㄴ--action.js //状态发起动作方法
ㄴ--reducer.js //接受动作后改变状态
entry.jsx //打包入口
temp.html //打包模板html
webpack.config.js //webpack配置</code></pre>
<p>项目结构根据个人习惯可以修改,但原则上要保持条理清晰,有时候还要根据项目结构修改webpack配置等。</p>
<hr>
<p>接下来配置<code>webpack</code>,同时<code>npm</code>安装所需要的 <code>loader</code>。<code>webpack</code>相关配置请参考<a href="https://link.segmentfault.com/?enc=GAAHVCToKaZWUobgM5%2BwtQ%3D%3D.Ab%2FiaHzyQ4VzbVSqM8ddBZjD4O8FNISAuK3KDuT5g%2FOGACEFv4vnGyt7Alv6m8yP" rel="nofollow">webpack中文文档</a>。本章不多做赘述。<br>给出一个简单配置:<br>webpack.config.js</p>
<pre><code>const webpack = require("webpack");
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const compiler = webpack({
entry: "./entry.jsx",
output:{
path: path.resolve(__dirname, "./dist"),
filename:"code.min.js"
},
module:{
rules:[
{
test:/\.css$/,
include:[path.resolve(__dirname, "./")],
loader:"style-loader!css-loader"
},
{
test:/\.js$/,
include:[path.resolve(__dirname, "./")],
loader:"babel-loader",
options: {
presets: ['es2015',"stage-0"]
}
},
{
test:/\.jsx$/,
include:[path.resolve(__dirname, "./")],
loader:"babel-loader",
options: {
presets: ['es2015',"stage-0","react"]
}
},
{
test: /\.(png|jpeg|jpg)$/,
include:[path.resolve(__dirname, "./img")],
loader:'file-loader?name=img/[name]-[hash].[ext]'
}
]
},
plugins: [
new HtmlWebpackPlugin({
template:'./temp.html',
filename:'./spa.html',
inject:'body'
})
]
});
const watching = compiler.watch({
aggregateTimeout: 300,
poll: undefined
}, (err, stats) => {
if (err || stats.hasErrors())console.log(stats.compilation.errors);
else{console.log('success')}
})</code></pre>
<p>当编写好<code>webpack.config.js</code>文件后,我们只需要用node运行它,那么当我们的react项目改变时会自行编译到一个自动生成的<code>dist</code>文件夹里(建议一定开启监听文件改变编译,而不是每次改变后手动运行<code>webpack.config.js</code>,因为那样会很慢!)</p>
<hr>
<p>做好了这些准备工作,接下来正式进入 <code>React</code> 世界:<br>entry.js</p>
<pre><code>import React from 'react'
import { render } from 'react-dom'
import { createStore } from 'redux'
import todoApp from './store/reducers'
import Main from './route/router.jsx'
let store = createStore(todoApp)
render(
<Main store={store} />,
document.body
)</code></pre>
<p>上面<code>import</code>的模块请<code>npm</code>安装,我们在<code>entry</code>里仅仅创建一个状态管理的<code>store</code>对象,并且将<code>router.jsx</code>的路由模块渲染到<code>body</code>中,<code>reducers</code>是<code>redux</code>中具体需要更改哪些状态的js文件,在<code>creatStore</code>里绑定。(关于redux的更多使用方法及理解需要详细具体讲解,涉及篇幅较大,本文暂不涉及,有兴趣可以看文档<a href="https://link.segmentfault.com/?enc=4HMwl05yzNpyNa1opF9jSA%3D%3D.ZXNA53dU2UVhE%2FycAoJBM6pYRckVqeLUQliDPNpmcjY%3D" rel="nofollow">redux中文文档</a>,我会整理后再单独章节分享)</p>
<hr>
<p>接下来我们将编写路由组件<br>router.jsx</p>
<pre><code>import React from 'react'
import { HashRouter as Router,Route } from 'react-router-dom'
import { Provider } from 'react-redux'
import Hello from '../component/hello.jsx';
class Main extends React.Component {
render(){
const { store } = this.props
return (
<Router hashType="noslash">
<Provider store={store}>
<Route render={({ location }) => {
return(
<div key={location.pathname} name={location.pathname}>
<Route location={location} path="/hello" component={Hello}/>
</div>
)
}}/>
</Provider>
</Router>
)
}
}
export default Main ;</code></pre>
<p>这与<code>react-router2</code>有一些差别,原来的方法已经不再使用,在<code>react-router4</code>中<code>HashRouter</code>或<code>BrowserRouter</code>组件从<code>react-redux-dom</code>中引入。</p>
<hr>
<p>关于业务组件的编写,相信大家都很熟悉,即使以前使用<code>es5</code>开发的小伙伴也应该能很快上手<br>hello.jsx</p>
<pre><code>import '../css/xxx.css';
import React from 'react';
import { connect } from 'react-redux';
import * as action from '../store/actions';
class Hello extends React.Component{
constructor(props){
super(props)
this.state={...}
}
componentDidMount(){
this.props.dispatch(action.hi())
}
render() {
const { name } = this.props
return (
<div>{name}</div>
)
}
}
export default connect(state => state)(Hello)</code></pre>
<p>在这个组件里,我们将<code>redux</code>中管理的<code>state</code>和触发状态更改的<code>dispatch</code>方法通过<code>connect</code>绑定在了<code>props</code>中,可以随时调用,同时该组件将监听<code>redux</code>中<code>state</code>的变化实时更新数据。</p>
<hr>
<p>我们需要改变<code>redux</code>状态时所触发的动作<br>action.js</p>
<pre><code>export const hi = () => {
return {
type:'hi',
...//其他你需要的属性
}
}</code></pre>
<p>根据<code>redux</code>要求,这里的<code>type</code>属性是必须的,不能用别的字段名,否则运行时浏览器会报<code>type</code>不存在。</p>
<hr>
<p>接收<code>action</code>后执行状态改变的文件就是<br>reducers.js</p>
<pre><code>import { combineReducers } from 'redux'
const name = (state='', action) => {
switch (action.type) {
case 'hi':
return "hello world!"
default :
return state
}
}
const todoApp = combineReducers({
name,
//more state
})
export default todoApp;</code></pre>
<p><code>reducer</code>首先用<code>action</code>中传入的<code>type</code>属性来判断我们要做的是哪种操作,然后再根据传入的其他属性当做参数做你想要的改变,最后返回一个<code>{name : ...}</code>的对象,然后所有类似的对象通过<code>combineReducers</code>合并为一个总状态对象暴露给组件访问。</p>
<hr>
<p>当以上文件利用webpack编译打包好以后,一个最简单的react全家桶spa就完成了(虽然只包含一个组件)。<br>在实际的使用过程中,需要更多的库来使我们的应用更强大且美观。比如路由过度动画<code>react-addons-css-transition-group</code>,redux异步更改state数据<code>redux-thunk</code>,Ajax的兼容shim<code>whatwg-fetch</code>,移动端滚动<code>iscroll</code>等等。</p>
<p>关于<code>react-router4</code>与<code>redux</code>的详细用法还是建议要静下心来理解文档,这样才能在变化多端的开发需求中运用自如。(我之前也用过<code>vuex</code>,感觉相比之下<code>redux</code>文档稍显繁琐,<code>vuex</code>文档看了很容易就理解上手了)</p>
<p>如果感兴趣可以访问我的成熟项目源码<a href="https://link.segmentfault.com/?enc=3%2FD%2BPWlRnDUl7wIviakvig%3D%3D.r9pYhBA4%2FPdxe1ixKRBlYtCeG7NBiVevO0peupSxteNRlaNU99geO%2FRDlA1x%2FX9mb2Q4HA3mcr0KF9zrGjVihA%3D%3D" rel="nofollow">React医疗类移动app --Github</a>,欢迎各位多多指教,多多star ^_^</p>