胡萝卜有点坑

胡萝卜有点坑 查看完整档案

杭州编辑  |  填写毕业院校  |  填写所在公司/组织 qilei.site 编辑
编辑

80后,小面包,不颓废,不萧条,乐观,积极,感性,裸奔

个人动态

胡萝卜有点坑 赞了文章 · 2019-05-05

看完这篇,你也可以实现一个360度全景插件

导读

本文从绘图基础开始讲起,详细介绍了如何使用Three.js开发一个功能齐全的全景插件。

我们先来看一下插件的效果:

如果你对Three.js已经很熟悉了,或者你想跳过基础理论,那么你可以直接从全景预览开始看起。

本项目的github地址:https://github.com/ConardLi/t...

一、理清关系

1.1 OpenGL

OpenGL是用于渲染2D、3D量图形的跨语言、跨平台的应用程序编程接口(API)

这个接口由近350个不同的函数调用组成,用来从简单的图形比特绘制复杂的三维景象。

OpenGL ES OpenGL 三维图形 API 的子集,针对手机、PDA和游戏主机等嵌入式设备而设计。

基于OpenGL,一般使用CCpp开发,对前端开发者来说不是很友好。

1.2 WebGL

WebGLJavaScriptOpenGL ES 2.0结合在一起,从而为前端开发者提供了使用JavaScript编写3D效果的能力。

WebGLHTML5 Canvas提供硬件3D加速渲染,这样Web开发人员就可以借助系统显卡来在浏览器里更流畅地展示3D场景和模型了,还能创建复杂的导航和数据视觉化。

1.3 Canvas

Canvas是一个可以自由制定大小的矩形区域,可以通过JavaScript可以对矩形区域进行操作,可以自由的绘制图形,文字等。

一般使用Canvas都是使用它的2dcontext功能,进行2d绘图,这是其本身的能力。

和这个相对的,WebGL是三维,可以描画3D图形,WebGL,想要在浏览器上进行呈现,它必须需要一个载体,这个载体就是Canvas,区别于之前的2dcontext,还可以从Canvas中获取webglcontext

1.4 Three.js

我们先来从字面意思理解下:Three代表3Djs代表JavaScript,即使用JavaScript来开发3D效果。

Three.js是使用JavaScript WebGL接口进行封装与简化而形成的一个易用的3D库。

直接使用WebGL进行开发对于开发者来说成本相对来说是比较高的,它需要你掌握较多的计算机图形学知识。

Three.js在一定程度上简化了一些规范和难以理解的概念,对很多API进行了简化,这大大降低了学习和开发三维效果成本。

下面我们来具体看一下使用Three.js必须要知道的知识。

二、Three.js基础知识

使用Three.js绘制一个三维效果,至少需要以下几个步骤:

  • 创建一个容纳三维空间的场景 — Sence
  • 将需要绘制的元素加入到场景中,对元素的形状、材料、阴影等进行设置
  • 给定一个观察场景的位置,以及观察角度,我们用相机对象(Camera)来控制
  • 将绘制好的元素使用渲染器(Renderer)进行渲染,最终呈现在浏览器上

拿电影来类比的话,场景对应于整个布景空间,相机是拍摄镜头,渲染器用来把拍摄好的场景转换成胶卷。

2.1 场景

场景允许你设置哪些对象被three.js渲染以及渲染在哪里。

我们在场景中放置对象、灯光和相机。

很简单,直接创建一个Scene的实例即可。

 _scene = new Scene();

2.2 元素

有了场景,我们接下来就需要场景里应该展示哪些东西。

一个复杂的三维场景往往就是由非常多的元素搭建起来的,这些元素可能是一些自定义的几何体(Geometry),或者外部导入的复杂模型。

Three.js 为我们提供了非常多的Geometry,例如SphereGeometry(球体)、 TetrahedronGeometry(四面体)、TorusGeometry(圆环体)等等。

Three.js中,材质(Material)决定了几何图形具体是以什么形式展现的。它包括了一个几何体如何形状以外的其他属性,例如色彩、纹理、透明度等等,MaterialGeometry是相辅相成的,必须结合使用。

下面的代码我们创建了一个长方体体,赋予它基础网孔材料(MeshBasicMaterial

    var geometry = new THREE.BoxGeometry(200, 100, 100);
    var material = new THREE.MeshBasicMaterial({ color: 0x645d50 });
    var mesh = new THREE.Mesh(geometry, material);
            _scene.add(mesh);

能以这个角度看到几何体实际上是相机的功劳,这个我们下面的章节再介绍,这让我们看到一个几何体的轮廓,但是感觉怪怪的,这并不像一个几何体,实际上我们还需要为它添加光照和阴影,这会让几何体看起来更真实。

基础网孔材料(MeshBasicMaterial)不受光照影响的,它不会产生阴影,下面我们为几何体换一种受光照影响的材料:网格标准材质(Standard Material),并为它添加一些光照:

    var geometry = new THREE.BoxGeometry(200, 100, 100);
    var material = new THREE.MeshStandardMaterial({ color: 0x645d50 });
    var mesh = new THREE.Mesh(geometry, material);
    _scene.add(mesh);
    // 创建平行光-照亮几何体
    var directionalLight = new THREE.DirectionalLight(0xffffff, 1);
     directionalLight.position.set(-4, 8, 12);
    _scene.add(directionalLight);
    // 创建环境光
    var ambientLight = new THREE.AmbientLight(0xffffff);
    _scene.add(ambientLight);

有了光线的渲染,让几何体看起来更具有3D效果,Three.js中光源有很多种,我们上面使用了环境光(AmbientLight)和平行光(DirectionalLight)。

环境光会对场景中的所有物品进行颜色渲染。

平行光你可以认为像太阳光一样,从极远处射向场景中的光。它具有方向性,也可以启动物体对光的反射效果。

除了这两种光,Three.js还提供了其他几种光源,它们适用于不同情况下对不同材质的渲染,可以根据实际情况选择。

2.3 坐标系

在说相机之前,我们还是先来了解一下坐标系的概念:

在三维世界中,坐标定义了一个元素所处于三维空间的位置,坐标系的原点即坐标的基准点。

最常用的,我们使用距离原点的三个长度(距离x轴、距离y轴、距离z轴)来定义一个位置,这就是直角坐标系。

在判定坐标系时,我们通常使用大拇指、食指和中指,并互为90度。大拇指代表X轴,食指代表Y轴,中指代表Z轴。

这就产生了两种坐标系:左手坐标系和右手坐标系。

Three.js中使用的坐标系即右手坐标系。

我们可以在我们的场景中添加一个坐标系,这样我们可以清楚的看到元素处于什么位置:

 var axisHelper = new THREE.AxisHelper(600);
 _scene.add(axisHelper);

其中红色代表X轴,绿色代表Y轴,蓝色代表Z轴。

2.4 相机

上面看到的几何体的效果,如果不创建一个相机(Camera),是什么也看不到的,因为默认的观察点在坐标轴原点,它处于几何体的内部。

相机(Camera)指定了我们在什么位置观察这个三维场景,以及以什么样的角度进行观察。

2.4.1 两种相机的区别

目前Three.js提供了几种不同的相机,最常用的,也是下面插件中使用的两种相机是:PerspectiveCamera(透视相机)、 OrthographicCamera(正交投影相机)。

上面的图很清楚的解释了两种相机的区别:

右侧是 OrthographicCamera(正交投影相机)他不具有透视效果,即物体的大小不受远近距离的影响,对应的是投影中的正交投影。我们数学课本上所画的几何体大多数都采用这种投影。

左侧是PerspectiveCamera(透视相机),这符合我们正常人的视野,近大远小,对应的是投影中的透视投影。

如果你想让场景看起来更真实,更具有立体感,那么采用透视相机最合适,如果场景中有一些元素你不想让他随着远近放大缩小,那么采用正交投影相机最合适。

2.4.2 构造参数

我们再分别来看看两个创建两个相机需要什么参数:

_camera = new OrthographicCamera(left, right, top, bottom, near, far);

OrthographicCamera接收六个参数,left, right, top, bottom分别对应上、下、左、右、远、近的一个距离,超过这些距离的元素将不会出现在视野范围内,也不会被浏览器绘制。实际上,这六个距离就构成了一个立方体,所以OrthographicCamera的可视范围永远在这个立方体内。

_camera = new PerspectiveCamera(fov, aspect, near, far);

PerspectiveCamera接收四个参数,nearfar和上面的相同,分别对应相机可观测的最远和最近距离;fov代表水平范围可观测的角度,fov越大,水平范围能观测到的范围越广;aspect代表水平方向和竖直方向可观测距离的比值,所以fovaspect就可以确定垂直范围内能观测到的范围。

2.4.3 position、lookAt

关于相机还有两个必须要知道的点,一个是position属性,一个是lookAt函数:

position属性指定了相机所处的位置。

lookAt函数指定相机观察的方向。

实际上position的值和lookAt接收的参数都是一个类型为Vector3的对象,这个对象用来表示三维空间中的坐标,它有三个属性:x、y、z分别代表距离x轴、距离y轴、距离z轴的距离。

下面,我们让相机观察的方向指向原点,另外分别让x、y、z为0,另外两个参数不为0,看一下视野会发生什么变化:

_camera = new OrthographicCamera(-window.innerWidth / 2, window.innerWidth / 2, window.innerHeight / 2, -window.innerHeight / 2, 0.1, 1000);
 _camera.lookAt(new THREE.Vector3(0, 0, 0))

 _camera.position.set(0, 300, 600); // 1 - x为0

 _camera.position.set(500, 0, 600); // 2 - y为0

 _camera.position.set(500, 300, 0); // 3 - z为0

很清楚的看到position决定了我们视野的出发点,但是镜头指向的方向是不变的。

下面我们将position固定,改变相机观察的方向:

_camera = new OrthographicCamera(-window.innerWidth / 2, window.innerWidth / 2, window.innerHeight / 2, -window.innerHeight / 2, 0.1, 1000);
_camera.position.set(500, 300, 600); 

_camera.lookAt(new THREE.Vector3(0, 0, 0)) // 1 - 视野指向原点

_camera.lookAt(new THREE.Vector3(200, 0, 0)) // 2 - 视野偏向x轴

可见:我们视野的出发点是相同的,但是视野看向的方向发生了改变。

2.4.4 两种相机对比

好,有了上面的基础,我们再来写两个例子看一看两个相机的视角对比,为了方便观看,我们创建两个位置不同的几何体:

var geometry = new THREE.BoxGeometry(200, 100, 100);
var material = new THREE.MeshStandardMaterial({ color: 0x645d50 });
var mesh = new THREE.Mesh(geometry, material);
_scene.add(mesh);

var geometry = new THREE.SphereGeometry(50, 100, 100);
var ball = new THREE.Mesh(geometry, material);
ball.position.set(200, 0, -200);
_scene.add(ball);

正交投影相机视野:

_camera = new OrthographicCamera(-window.innerWidth / 2, window.innerWidth / 2, window.innerHeight / 2, -window.innerHeight / 2, 0.1, 1000);
_camera.position.set(0, 300, 600);
_camera.lookAt(new THREE.Vector3(0, 0, 0))

透视相机视野:

_camera = new PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1100);
_camera.position.set(0, 300, 600);
_camera.lookAt(new THREE.Vector3(0, 0, 0))

可见,这印证了我们上面关于两种相机的理论

2.5 渲染器

上面我们创建了场景、元素和相机,下面我们要告诉浏览器将这些东西渲染到浏览器上。

Three.js也为我们提供了几种不同的渲染器,这里我们主要看WebGL渲染器(WebGLRenderer)。顾名思义:WebGL渲染器使用WebGL来绘制场景,其够利用GPU硬件加速从而提高渲染性能。

_renderer = new THREE.WebGLRenderer();

你需要将你使用Three.js绘制的元素添加到浏览器上,这个过程需要一个载体,上面我们介绍,这个载体就是Canvas,你可以通过_renderer.domElement获取到这个Canvas,并将它给定到真实DOM中。

 _container = document.getElementById('conianer');
 _container.appendChild(_renderer.domElement);

使用setSize函数设定你要渲染的范围,实际上它改变的就是上面Canvas的范围:

_renderer.setSize(window.innerWidth, window.innerHeight);

现在,你已经指定了一个渲染的载体和载体的范围,你可以通过render函数渲染上面指定的场景和相机:

_renderer.render(_scene, _camera);

实际上,你如果依次执行上面的代码,可能屏幕上还是黑漆漆的一片,并没有任何元素渲染出来。

这是因为上面你要渲染的元素可能并未被加载完,你就执行了渲染,并且只执行了一次,这时我们需要一种方法,让场景和相机进行实时渲染,我们需要用到下面的方法:

2.6 requestAnimationFrame

window.requestAnimationFrame()告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。

该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

window.requestAnimationFrame(callback);

若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用window.requestAnimationFrame()

使用者韩函数就意味着,你可以在requestAnimationFrame不停的执行绘制操作,浏览器就实时的知道它需要渲染的内容。

当然,某些时候你已经不需要实时绘制了,你也可以使用cancelAnimationFrame立即停止这个绘制:

window.cancelAnimationFrame(myReq);

来看一个简单的例子:

        var i = 0;
        var animateName;
        animate();
        function animate() {
            animateName = requestAnimationFrame(animate);
            console.log(i++);
            if (i > 100) {
                cancelAnimationFrame(animateName);
            }
        }

来看一下执行效果:

我们使用requestAnimationFrameThree.js的渲染器结合使用,这样就能实时绘制三维动画了:

        function animate() {
            requestAnimationFrame(animate);
            _renderer.render(_scene, _camera);
        }

借助上面的代码,我们可以简单实现一些动画效果:

        var y = 100;
        var option = 'down';
        function animateIn() {
            animateName = requestAnimationFrame(animateIn);
            mesh.rotateX(Math.PI / 40);
            if (option == 'up') {
                ball.position.set(200, y += 8, 0);
            } else {
                ball.position.set(200, y -= 8, 0);
            }
            if (y < 1) { option = 'up'; }
            if (y > 100) { option = 'down' }
        }

2.7 总结

上面的知识是Three.js中最基础的知识,也是最重要的和最主干的。

这些知识能够让你在看到一个复杂的三维效果时有一定的思路,当然,要实现还需要非常多的细节。这些细节你可以去官方文档中查阅。

下面的章节即告诉你如何使用Three.js进行实战 — 实现一个360度全景插件。

这个插件包括两部分,第一部分是对全景图进行预览。

第二部分是对全景图的标记进行配置,并关联预览的坐标。

我们首先来看看全景预览部分:

三、全景预览

3.1 基本逻辑

  • 将一张全景图包裹在球体的内壁
  • 设定一个观察点,在球的圆心
  • 使用鼠标可以拖动球体,从而改变我们看到全景的视野
  • 鼠标滚轮可以缩放,和放大,改变观察全景的远近
  • 根据坐标在全景图上挂载一些标记,如文字、图标等,并且可以增加事件,如点击事件

3.2 初始化

我们先把必要的基础设施搭建起来:

场景、相机(选择远景相机,这样可以让全景看起来更真实)、渲染器:


_scene = new THREE.Scene();
initCamera();
initRenderer();
animate();

// 初始化相机
function initCamera() {
    _camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1100);
    _camera.position.set(0, 0, 2000);
    _camera.lookAt(new THREE.Vector3(0, 0, 0));
}

// 初始化渲染器
function initRenderer() {
    _renderer = new THREE.WebGLRenderer();
    _renderer.setSize(window.innerWidth, window.innerHeight);
    _container = document.getElementById('panoramaConianer');
    _container.appendChild(_renderer.domElement);
}

// 实时渲染
function animate() {
    requestAnimationFrame(animate);
    _renderer.render(_scene, _camera);
}

下面我们在场景内添加一个球体,并把全景图作为材料包裹在球体上面:

var mesh = new THREE.Mesh(new THREE.SphereGeometry(1000, 100, 100),
new THREE.MeshBasicMaterial(
        { map: ImageUtils.loadTexture('img/p3.png') }
    ));
_scene.add(mesh);

然后我们看到的场景应该是这样的:

这不是我们想要的效果,我们想要的是从球的内部观察全景,并且全景图是附着外球的内壁的,而不是铺在外面:

我们只要需将Materialscale的一个属性设置为负值,材料即可附着在几何体的内部:

 mesh.scale.x = -1;

然后我们将相机的中心点移动到球的中心:

 _camera.position.set(0, 0, 0);

现在我们已经在全景球的内部啦:

3.3 事件处理

全景图已经可以浏览了,但是你只能看到你眼前的这一块,并不能拖动它看到其他部分,为了精确的控制拖动的速度和缩放、放大等场景,我们手动为它增加一些事件:

监听鼠标的mousedown事件,在此时将开始拖动标记_isUserInteracting设置为true,并且记录起始的屏幕坐标,以及起始的相机lookAt的坐标。

_container.addEventListener('mousedown', (event)=>{
  event.preventDefault();
  _isUserInteracting = true;
  _onPointerDownPointerX = event.clientX;
  _onPointerDownPointerY = event.clientY;
  _onPointerDownLon = _lon;
  _onPointerDownLat = _lat;
});

监听鼠标的mousemove事件,当_isUserInteractingtrue时,实时计算当前相机lookAt的真实坐标。

_container.addEventListener('mousemove', (event)=>{
  if (_isUserInteracting) {
    _lon = (_onPointerDownPointerX - event.clientX) * 0.1 + _onPointerDownLon;
    _lat = (event.clientY - _onPointerDownPointerY) * 0.1 + _onPointerDownLat;
  }
});

监听鼠标的mouseup事件,将_isUserInteracting设置为false

_container.addEventListener('mouseup', (event)=>{
 _isUserInteracting = false;
});

当然,上面我们只是改变了坐标,并没有告诉相机它改变了,我们在animate函数中来做这件事:

function animate() {
  requestAnimationFrame(animate);
  calPosition();
  _renderer.render(_scene, _camera);
  _renderer.render(_sceneOrtho, _cameraOrtho);
}

function calPosition() {
  _lat = Math.max(-85, Math.min(85, _lat));
  var phi = tMath.degToRad(90 - _lat);
  var theta = tMath.degToRad(_lon);
  _camera.target.x = _pRadius * Math.sin(phi) * Math.cos(theta);
  _camera.target.y = _pRadius * Math.cos(phi);
  _camera.target.z = _pRadius * Math.sin(phi) * Math.sin(theta);
  _camera.lookAt(_camera.target);
}

监听mousewheel事件,对全景图进行放大和缩小,注意这里指定了最大缩放范围maxFocalLength和最小缩放范围minFocalLength

_container.addEventListener('mousewheel', (event)=>{
  var ev = ev || window.event;
  var down = true;
  var m = _camera.getFocalLength();
  down = ev.wheelDelta ? ev.wheelDelta < 0 : ev.detail > 0;
  if (down) {
    if (m > minFocalLength) {
      m -= m * 0.05
      _camera.setFocalLength(m);
    }
  } else {
    if (m < maxFocalLength) {
      m += m * 0.05
      _camera.setFocalLength(m);
    }
  }
});

来看一下效果吧:

3.4 增加标记

在浏览全景图的时候,我们往往需要对某些特殊的位置进行一些标记,并且这些标记可能附带一些事件,比如你需要点击一个标记才能到达下一张全景图。

下面我们来看看如何在全景中增加标记,以及如何为这些标记添加事件。

我们可能不需要让这些标记随着视野的变化而放大和缩小,基于此,我们使用正交投影相机来展现标记,只需给它一个固定的观察高度:

  _cameraOrtho = new THREE.OrthographicCamera(-window.innerWidth / 2, window.innerWidth / 2, window.innerHeight / 2, -window.innerHeight / 2, 1, 10);
  _cameraOrtho.position.z = 10;
  _sceneOrtho = new Scene();

利用精灵材料(SpriteMaterial)来实现文字标记,或者图片标记:

// 创建文字标记
function createLableSprite(name) {
  const canvas = document.createElement('canvas');
  const context = canvas.getContext('2d');
  const metrics = context.measureText(name);
  const width = metrics.width * 1.5;
  context.font = "10px 宋体";
  context.fillStyle = "rgba(0,0,0,0.95)";
  context.fillRect(2, 2, width + 4, 20 + 4);
  context.fillText(name, 4, 20);
  const texture = new Texture(canvas);
  const spriteMaterial = new SpriteMaterial({ map: texture });
  const sprite = new Sprite(spriteMaterial);
  sprite.name = name;
  const lable = {
    name: name,
    canvas: canvas,
    context: context,
    texture: texture,
    sprite: sprite
  };
  _sceneOrtho.add(lable.sprite);
  return lable;
}
// 创建图片标记
function createSprite(position, url, name) {
  const textureLoader = new TextureLoader();
  const ballMaterial = new SpriteMaterial({
    map: textureLoader.load(url)
  });
  const sp = {
    pos: position,
    name: name,
    sprite: new Sprite(ballMaterial)
  };
  sp.sprite.scale.set(32, 32, 1.0);
  sp.sprite.name = name;
  _sceneOrtho.add(sp.sprite);
  return sp;
}

创建好这些标记,我们把它渲染到场景中。

我们必须告诉场景这些标记的位置,为了直观的理解,我们需要给这些标记赋予一种坐标,这种坐标很类似于经纬度,我们叫它lonlat,具体是如何给定的我们在下面的章节:全景标记中会详细介绍。

在这个过程中,一共经历了两次坐标转换:

第一次转换:将“经纬度”转换为三维空间坐标,即我们上面讲的那种x、y、z形式的坐标。

使用geoPosition2World函数进行转换,得到一个Vector3对象,我们可以将当前相机_camera作为参数传入这个对象的project方法,这会得到一个标准化后的坐标,基于这个坐标可以帮我们判断标记是否在视野范围内,如下面的代码,若标准化坐标在-11的范围内,则它会出现在我们的视野中,我们将它进行准确渲染。

第二次转换:将三维空间坐标转换为屏幕坐标。

如果我们直接讲上面的三维空间坐标坐标应用到标记中,我们会发现无论视野如何移动,标记的位置是不会有任何变化的,因为这样算出来的坐标永远是一个常量。

所以我们需要借助上面的标准化坐标,将标记的三维空间坐标转换为真实的屏幕坐标,这个过程是worldPostion2Screen函数来实现的。

关于geoPosition2WorldworldPostion2Screen两个函数的实现,大家有兴趣可以去我的github源码中查看,这里就不多做解释了,因为这又要牵扯到一大堆专业知识啦。😅

var wp = geoPosition2World(_sprites.lon, _sprites.lat);
var sp = worldPostion2Screen(wp, _camera);
var test = wp.clone();
test.project(_camera);
if (test.x > -1 && test.x < 1 && test.y > -1 && test.y < 1 && test.z > -1 && test.z < 1) {
    _sprites[i].sprite.scale.set(32, 32, 32);
    _sprites[i].sprite.position.set(sp.x, sp.y, 1);
}else {
    _sprites[i].sprite.scale.set(1.0, 1.0, 1.0);
    _sprites[i].sprite.position.set(0, 0, 0);
}

现在,标记已经添加到全景上面了,我们来为它添加一个点击事件:

Three.js并没有单独提供为Sprite添加事件的方法,我们可以借助光线投射器(Raycaster)来实现。

Raycaster提供了鼠标拾取的能力:

通过setFromCamera函数来建立当前点击的坐标(经过归一化处理)和相机的绑定关系。

通过intersectObjects来判定一组对象中有哪些被命中(点击),得到被命中的对象数组。

这样,我们就可以获取到点击的对象,并基于它做一些处理:

_container.addEventListener('click', (event)=>{
  _mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  _mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
  _raycaster.setFromCamera(_mouse, _cameraOrtho);
  var intersects = _raycaster.intersectObjects(_clickableObjects);
  intersects.forEach(function (element) {
    alert("点击到了: " + element.object.name);
  });
});

点击到一个标记,进入到下一张全景图:

四、全景标记

为了让全景图知道,我要把标记标注在什么地方,我需要一个工具来把原图和全景图上的位置关联起来:


由于这部分代码和Three.js关系不大,这里我只说一下基本的实现逻辑,有兴趣可以去我的github仓库查看。

4.1 要求

  • 建立坐标和全景的映射关系,为全景赋予一套虚拟坐标
  • 在一张平铺的全景图上,可以在任意位置增加标记,并获取标记的坐标
  • 使用坐标在预览全景增加标记,看到的标记位置和平铺全景中的位置相同

4.2 坐标

2D平面上,我们能监听屏幕的鼠标事件,我们可以获取的也只是当前的鼠标坐标,我们要做的是将鼠标坐标转换成三维空间坐标。

看起来好像是不可能的,二维坐标怎么能转换成三维坐标呢?

但是,我们可以借助一种中间坐标来转换,可以把它称之为“经纬度”。

在这之前,我们先来看看我们常说的经纬度到底是什么。

4.3 经纬度

使用经纬度,可以精确的定位到地球上任意一个点,它的计算规则是这样的:

通常把连接南极到北极的线叫做子午线也叫经线,其所对应的面叫做子午面,规定英国伦敦格林尼治天文台原址的那条经线称为0°经线,也叫本初子午线其对应的面即本初子午面。

经度:球面上某店对应的子午面与本初子午面间的夹角。东正西负。

纬度 :球面上某点的法线(以该店作为切点与球面相切的面的法线)与赤道平面的夹角。北正南负。

由此,地球上每一个点都能被对应到一个经度和纬度,想对应的,也能对应到某条经线和纬线上。

这样,即使把球面展开称平面,我们仍然能用经纬度表示某店点的位置:

4.4 坐标转换

基于上面的分析,我们完全可以给平面的全景图赋予一个虚拟的“经纬度”。我们使用Canvas为它绘制一张"经纬网":

将鼠标坐标转换为"经纬度":

function calLonLat(e) {
  var h = _setContainer.style.height.split("px")[0];
  var w = _setContainer.style.width.split("px")[0];
  var ix = _setContainer.offsetLeft;
  var iy = _setContainer.offsetTop;
  iy = iy + h;
  var x = e.clientX;
  var y = e.clientY;
  var lonS = (x - ix) / w;
  var lon = 0;
  if (lonS > 0.5) {
    lon = -(1 - lonS) * 360;
  } else {
    lon = 1 * 360 * lonS;
  }
  var latS = (iy - y) / h;
  var lat = 0;
  if (latS > 0.5) {
    lat = (latS - 0.5) * 180;
  } else {
    lat = (0.5 - latS) * 180 * -1
  }
  lon = lon.toFixed(2);
  lat = lat.toFixed(2);
  return { lon: lon, lat: lat };
}

这样平面地图上的某点就可以和三维坐标关联起来了,当然,这还需要一定的转换,有兴趣可以去源码研究下geoPosition2WorldworldPostion2Screen两个函数。

五、插件封装

上面的代码中,我们实现了全景预览和全景标记的功能,下面,我们要把这些功能封装成插件。

所谓插件,即可以直接引用你写的代码,并添加少量的配置就可以实现想要的功能。

5.1 全景预览封装

我们来看看,究竟哪些配置是可以抽取出来的:

var options = {
  container: 'panoramaConianer',
  url: 'resources/img/panorama/pano-7.jpg',
  lables: [],
  widthSegments: 60,
  heightSegments: 40,
  pRadius: 1000,
  minFocalLength: 1,
  maxFocalLength: 100,
  sprite: 'label',
  onClick: () => { }
}
  • container:dom容器的id
  • url:图片路径
  • lables:全景中的标记数组,格式为 {position:{lon:114,lat:38},logoUrl:'lableLogo.png',text:'name'}
  • widthSegments:水平切段数
  • heightSegments:垂直切段数(值小粗糙速度快,值大精细速度慢)
  • pRadius:全景球的半径,推荐使用默认值
  • minFocalLength:镜头最小拉近距离
  • maxFocalLength:镜头最大拉近距离
  • sprite:展示的标记类型label,icon
  • onClick:标记的点击事件

上面的配置是可以用户配置的,那么用户该如何传入插件呢?

我们可以在插件中声明一些默认配置options,用户使用构造函数传入参数,然后使用Object.assign将传入配置覆盖到默认配置。

接下来,你就可以使用this.def来访问这些变量了,然后只需要把写死的代码改成这些配置即可。

options = {
    // 默认配置...
}

function tpanorama(opt) {
  this.render(opt);
}

tpanorama.prototype = {
  constructor: this,
  def: {},
  render: function (opt) {
    this.def = Object.assign(options, opt);
    // 初始化操作...
  }
}

5.2 全景标记封装

基本逻辑和上面的类似,下面是提取出来的一些参数。

var setOpt = {
  container: 'myDiv',//setting容器
  imgUrl: 'resources/img/panorama/3.jpg',
  width: '',//指定宽度,高度自适应
  showGrid: true,//是否显示格网
  showPosition: true,//是否显示经纬度提示
  lableColor: '#9400D3',//标记颜色
  gridColor: '#48D1CC',//格网颜色
  lables: [],//标记   {lon:114,lat:38,text:'标记一'}
  addLable: true,//开启后双击添加标记  (必须开启经纬度提示)
  getLable: true,//开启后右键查询标记  (必须开启经纬度提示)
  deleteLbale: true,//开启默认中键删除 (必须开启经纬度提示)
}

六、发布

接下来,我们就好考虑如何将写好的插件让用户使用了。

我们主要考虑两种场景,直接引用和npm install

6.1 直接引用JS

为了不污染全局变量,我们使用一个自执行函数(function(){}())将代码包起来,然后将我们写好的插件暴露给全局变量window

我把它放在originSrc目录下。

(function (global, undefined) {

    function tpanorama(opt) {
        // ...
    }

    tpanorama.prototype = {
        // ...
    }

    function tpanoramaSetting(opt) {
        // ...
    }

    tpanoramaSetting.prototype = {
        // ...
    }

    global.tpanorama = tpanorama;
    global.tpanoramaSetting = panoramaSetting;
}(window))

6.2 使用npm install

直接将写好的插件导出:

module.exports = tpanorama;
module.exports = panoramaSetting;

我把它放在src目录下。

同时,我们要把package.json中的main属性指向我们要导出的文件:"main": "lib/index.js",然后将namedescriptionversion等信息补充完整。

下面,我们就可以开始发布了,首先你要有一个npm账号,并且登陆,如果你没有账号,使用下面的命令创建一个账号。

npm adduser --registry http://registry.npmjs.org

如果你已经有账号了,那么可以直接使用下面的命令进行登陆。

npm login --registry http://registry.npmjs.org

登陆成功之后,就可以发布了:

npm publish --registry http://registry.npmjs.org

注意,上面每个命令我都手动指定了registry,这是因为当前你使用的npm源可能已经被更换了,可能使用的是淘宝源或者公司源,这时不手动指定会导致发布失败。

发布成功后直接在npm官网上看到你的包了。

然后,你可以直接使用npm install tpanorama进行安装,然后进行使用:

var { tpanorama,tpanoramaSetting } = require('tpanorama');

6.3 babel编译

最后不要忘了,无论使用以上哪种方式,我们都要使用babel编译后才能暴露给用户。

scripts中创建一个build命令,将源文件进行编译,最终暴露给用户使用的将是liborigin

"build": "babel src --out-dir lib && babel originSrc --out-dir origin",

你还可以指定一些其他的命令来供用户测试,如我将写好的例子全部放在examples中,然后在scripts定义了expamle命令:

"example": "npm run webpack && node ./server/www"

这样,用户将代码克隆后直接在本地运行npm run example就可以进行调试了。

七、小结

本项目的github地址:https://github.com/ConardLi/t...

文中如有错误,欢迎在评论区指正,如果这篇文章帮助到了你,欢迎点赞和关注。

想阅读更多优质文章、可关注我的github博客,你的star✨、点赞和关注是我持续创作的动力!

推荐关注我的微信公众号【code秘密花园】,每天推送高质量文章,我们一起交流成长。

查看原文

赞 156 收藏 115 评论 5

胡萝卜有点坑 关注了标签 · 2018-09-24

关注 201

胡萝卜有点坑 关注了用户 · 2018-08-29

黑色的影子 @527990618

关注 69

胡萝卜有点坑 回答了问题 · 2018-08-06

html>table>tr>td可以超出省略,多种情况。

可以尝试 使用 max-width, 以及 text-overflow

多行 超出省略, 如果要浏览器兼容的话 不好用 css , 可以使用 js 去截取字符数.

关注 2 回答 1

胡萝卜有点坑 收藏了文章 · 2018-06-21

喂,快给我打一个小程序预览码

需求

开发小程序的朋友们随时都会听到一句话:“喂,快给我打一个xxx环境的预览码”,无论你正在干什么,都得赶紧地回一句:“稍等,这就给你打码……”

然后苦逼的你build了一个xxx环境的包,打开了微信开发者工具,点了一下预览,等了一下,预览码出来了,你复制丢给你的爸爸们。

终于有一天,你正在专心致志做一些不可描述的事情时,“喂,快给我打一个xxx环境的预览码”,这时你内心怒吼了一句:“老子不给你打码!你自己打去!”

于是就有了这个需求,要搞个东西让爸爸们自主打码,嗯,应该就是只有一个按钮,点一下就可以出现预览二维码的东西,意淫了一下应该是这样的:

打码过程

没错!就这样干!

规划一下

干大事就要从胡思乱想开始,现在来想想要搞成这个功能,需要做点什么准备工作吧。

找微信开发者工具的接口
最重要的事情莫过于看看微信开发者工具有没有给我们提供这样的接口让我们去操作,经过一番查阅文档我们会发现,果然有!

https://developers.weixin.qq....
会发现,文档给我们提供了两种方式的接口,命令行调用以及HTTP调用。有了接口,一切都好办了,无非就是调一下接口,拿到二维码,贴到页面上去而已嘛,很简单。

梳理开发流程
我们就把这个简单的事情,用流程图说明一下:
https://www.processon.com/vie...
流程图

所需技术

工欲善其事,必先利其器,我们要搞这个东西,还是先要把用到的技术整理一下。

  1. 微信开发者工具
  2. 一个小程序项目(这里以一个mpvue项目为例子)
  3. 前端vue + vux,这里前端没什么需要做的东西,这样的搭配纯属是因为本来就正在做移动端的东西,直接拿来用而已。
  4. 后端koa2,当然后端用什么都可以,这里选择koa2,纯属是因为我也不会用别的……
  5. 前后端HTTP请求统一用axios
  6. 涉及到node操作命令行需要用到shelljs

好像没别的东西了,用到了再说吧。

撸起袖子从后端开始

为了省事,直接把前后端的东西放在一起。项目目录:

项目目录

可以看到server这个目录下放的都是后端的东西。

server/index.js
先看看入口文件index.js,从这里我们可以知道后端要做两件事情,第一要能访问到前端build出来的静态资源,第二要能与前端通过HTTP接口进行交互。见代码:

const path = require('path')
const Koa = require('koa')
const koaStatic = require('koa-static')
const bodyParser = require('koa-bodyparser')
const router = require('./router')
const app = new Koa()
const port = 9871
app.use(bodyParser())
// 处理静态资源 这里是前端build好之后的目录
app.use(koaStatic(
  path.resolve(__dirname, '../dist')
))
// 路由处理接口
app.use(router.routes()).use(router.allowedMethods())
// 监听端口
app.listen(9871)
console.log(`[demo] start-quick is starting at port ${port}`)

静态资源方面的话使用koa-static即可,重点是怎样给前端提供接口,这就要看路由了。

server/router/index.js

const Router = require('koa-router')
// 业务逻辑
const wx = require('../controller/wx')
const router = new Router({
  // 接口前缀 比如open接口 请求路径就是/api/open
  prefix: '/api'
})
router.get('/open', wx.open)
  .get('/login', wx.login)
  .get('/preview', wx.preview)
  .get('/build', wx.build)
module.exports = router

这里可以清晰看到,后端提供了四个接口,但具体每个接口的业务逻辑则封装在controller里的wx.js,如果以后还有别的业务逻辑,就在controller加相应的模块即可。

server/controller/wx.js

// 微信开发者工具接口调用逻辑
const {open, login, preview, build} = require('../utli/wxToolApi')
// 处理成功失败返回格式的工具
const {successBody, errorBody} = require('../utli')
class WxController {
  /**
   * 根据环境对mpvue项目进行打包
   * @returns {Promise<void>}
   */
  static async build (ctx) {
    // 前端传过来的get参数
    const query = ctx.request.query
    if (!query || !query.env) {
      ctx.body = errorBody(null, '构建项目失败')
      return
    }
    const [err, data] = await build(query.env)
    ctx.body = err ? errorBody(err, '构建项目失败') : successBody(data, '构建项目成功')
  }
  /**
   * 打开微信开发者工具
   * @returns {Promise<void>}
   */
  static async open (ctx) {
    const [err, data] = await open()
    ctx.body = err ? errorBody(err, '打开微信开发者工具失败') : successBody(data, '打开微信开发者工具成功')
  }
  /**
   * 登录微信开发者工具
   * @returns {Promise<void>}
   */
  static async login (ctx) {
    const [err, data] = await login()
    ctx.body = err ? errorBody(err, '登录二维码返回失败') : successBody(data, '登录二维码返回成功')
  }
  /**
   * 查看预览码
   * @returns {Promise<void>}
   */
  static async preview (ctx) {
    const [err, data] = await preview()
    ctx.body = err ? errorBody(err, '预览二维码返回失败') : successBody(data, '预览二维码返回成功')
  }
}
module.exports = WxController

为了代码更加清晰,这里将具体操作微信开发者工具的接口逻辑抽到util/wxToolApi.js里去了,仅仅处理怎样以统一格式返回给前端。
util/wxToolApi.js

const {promiseWrap, successBody, errorBody} = require('../utli')
const {INSTALL_PATH, PROJECT_PATH, PORT_PATH, PORT_FILE_NAME, HOST} = require('../const')
const {readFile} = require('../utli/nodeApi')
const shell = require('shelljs')
const axios = require('axios')
module.exports = {
  /**
   * 根据环境对mpvue项目进行打包
   * @param env [doc, pre, prd]
   * @returns {*}
   */
  build (env) {
    return promiseWrap(new Promise((resolve, reject) => {
      // 进入项目目录
      shell.cd(PROJECT_PATH)
      // 执行打包命令
      shell.exec(`npm run build:${env}`, function (code, stdout, stderr) {
        resolve(stdout)
      })
    }))
  },
  /**
   * 打开微信开发者工具
   * @returns {*}
   */
  open () {
    return promiseWrap(new Promise((resolve, reject) => {
      // 进入项目目录
      shell.cd(INSTALL_PATH)
      // 执行微信开发者工具接口“命令行启动工具”
      shell.exec(`cli -o ${PROJECT_PATH}`, function (code, stdout, stderr) {
        if (stderr) return reject(stderr)
        resolve(stdout)
      })
    }))
  },
  /**
   * 获取微信开发者工具端口号
   * @returns {Promise<*>}
   */
  async getPort () {
    shell.cd(PORT_PATH)
    // http 服务在工具启动后自动开启,HTTP 服务端口号在用户目录下记录,可通过检查用户目录、检查用户目录下是否有端口文件及尝试连接来判断工具是否安装/启动。
    const [err, data] = await readFile(PORT_FILE_NAME)
    return err ? errorBody(err, '读取端口号文件失败') : successBody(data, '读取端口号文件成功')
  },
  /**
   * 微信开发者工具进行登录
   * @returns {*}
   */
  login () {
    return promiseWrap(new Promise(async (resolve, reject) => {
      // 获取端口号
      const portData = await module.exports.getPort()
      if (portData.code !== 0) {
        reject(portData)
        return
      }
      const port = portData.data
      axios.get(`http://${HOST}:${port}/login?format=base64`)
        .then(res => {
          resolve(res.data)
        })
        .catch(e => {
          reject(e)
        })
    }))
  },
  /**
   * 微信开发者工具获取预览码
   * @returns {*}
   */
  preview () {
    return promiseWrap(new Promise(async (resolve, reject) => {
      const portData = await module.exports.getPort()
      if (portData.code !== 0) {
        reject(portData)
        return
      }
      const port = portData.data
      axios.get(`http://${HOST}:${port}/preview?format=base64&projectpath=${encodeURIComponent(PROJECT_PATH)}`)
        .then(res => {
          resolve(res.data)
        })
        .catch(e => {
          reject(e)
        })
    }))
  }
}

这里有一点需要注意,为什么只有open接口需要用命令行调用方式?那是因为HTTP调用方式必须加端口,比如open接口

# 打开工具
http://127.0.0.1:端口号/open
# 打开/刷新项目
http://127.0.0.1:端口号/open?projectpath=项目全路径

如果你根本都没有打开微信开发者工具,在以下地方就会找不到端口:

端口号文件位置:

macOS : ~/Library/Application Support/微信web开发者工具/Default/.ide

Windows : ~/AppData/Local/微信web开发者工具/User Data/Default/.ide

所以作为一个全自动化打码工具,怎么可能还要自己去手动打开微信开发者工具呢!

前端

后端的东西基本就那么多,终于到前端了,前端十分简单,就不多说了:

<template>
  <div>
    <group title="请选择环境">
      <radio :options="envOption" v-model="env"></radio>
    </group>
    <x-button class="btn" type="default" @click.native="handlePreviewProject">点击预览</x-button>
    <div v-if="loginImg" class="code">
      <divider>请先登录</divider>
      <img class="code-img" :data-original="loginImg" alt="">
    </div>
    <div v-if="preImg" class="code" id="preImg">
      <divider>预览二维码</divider>
      <img class="code-img" :data-original="`${base64Prefix}${preImg}`" alt="">
    </div>
  </div>
</template>

<script>
import {openProject, login, previewProject, buildProject} from 'SERVICES/index'
import {showLoading, hideLoading} from 'UTILS'
import { Divider, XButton, Radio, Group } from 'vux'
export default {
  data () {
    return {
      // data表示取得数据的协定名称,image/png 是数据类型名称,base64 是数据的编码方法,逗号后面就是这个image/png文件base64编码后的数据。
      base64Prefix: 'data:image/png;base64,',
      // 登录二维码
      loginImg: '',
      // 预览二维码
      preImg: '',
      // 环境 默认为doc
      env: 'doc',
      // 所有的环境选项
      envOption: ['doc', 'pre', 'prd']
    }
  },
  components: {
    Divider,
    XButton,
    Radio,
    Group
  },
  methods: {
    handleError (msg) {
      alert(msg)
    },
    async login () {
      const {data: {code, data, msg}} = await login()
      if (code !== 0) {
        this.handleError(msg)
        return code
      }
      this.loginImg = data
      return code
    },
    async previewProject () {
      const {data: {code, data, msg}} = await previewProject()
      if (code !== 0) {
        this.handleError(msg)
        return code
      }
      this.preImg = data
      return code
    },
    async handlePreviewProject () {
      showLoading()
      // 重置二维码
      this.resetImg()
      // 打开微信开发者工具
      const {data: {code}} = await openProject()
      if (code !== 0) {
        // 登录微信开发者工具
        await this.login()
        hideLoading()
        return
      }
      // 根据环境打包
      await buildProject(this.env)
      // 预览
      await this.previewProject()
      hideLoading()
    },
    resetImg () {
      this.loginImg = ''
      this.preImg = ''
    }
  }
}
</script>

<style lang='less'>
  .btn {
    width: 90%!important;
    margin: 30px auto 30px auto;
  }
  .code {
    display: flex;
    align-items: center;
    flex-direction: column;
    .code-img {
      width: 300px;
      height: 300px;
    }
  }
</style>

这里有一个坑就是,login返回的base64是带了data:image/jpeg;base64,前缀的,所以可以直接放到img的src里,但是获取预览码的preview返回的却没有这个前缀!所以需要自己加上去,就是那个base64Prefix:'data:image/png;base64,'

最后

其实到这里已经基本实现了整个打码功能,但如果真的要可以用还有很多事情没做。

  1. 部署到测试机器上。虽然可以直接用自己的机子作为部署这个工具的机器,但这实在是有点……如果要部署到测试机器上,有一个问题就是,微信开发者工具依赖图形界面,而服务器一般是命令行,虽然有 https://github.com/cytle/wech... 这样的项目移植微信开发者工具到linux,但这种部署方式似乎还是怪怪的。
  2. 假设完成了上述部署,进行小程序项目打包的环节需要修改一下,变成根据选择的环境,到相应的代码仓库(比如gitlab)拉取该环境的最新代码,然后进行安装依赖才能执行打包命令。
  3. 既然都做到这一步了,也不差把上传小程序也加上去,微信开发者工具接口也有提供,这样一来整个测试打码到上线的步骤都有了。

End~

查看原文

胡萝卜有点坑 赞了文章 · 2018-03-12

[翻译]基于Webpack4使用懒加载分离打包React代码

原文地址:https://engineering.innovid.com/code-splitting-using-lazy-loading-with-react-redux-typescript-and-webpack-4-3ec60140ec5a
作者:Aviv Shafir
摘要:Innovid网站使用Webpack4对一个React项目进行了优化改造。主要使用了新的optimization配置和动态注入功能。

Hey,这里是Innovid,一个领先的视频广告平台。我们每天处理130万小时的视频,而在我们的web项目中,经常会使用到Webpack。我们非常喜欢这个工具。

最近,我们将一个项目迁移到了最新的Webpack4。它给我们带来了一些开箱即用的新特性,比如在构建时间上进行了非常大的优化。

在本次迁移中,我们决定使用懒加载这一Webpack最吸引人的特性来分割app中的主要代码部分。

代码分割能够帮助你延迟加载用户当前需要的内容,同时也能显著地提升用户体验。尽管你没有减少app的总代码量,但你已经避免加载一些用户也许永远也用不到的代码了。而且还能够在初始加载时减少加载的代码数量。
——React 文档

Webpack根据你的应用程序构建了一个依赖关系图。从你的入口文件开始,它递归遍历所有文件和它们的依赖文件,使用loader和plugin对你的文件施了点魔法,最后就输出了提供给用户的生成包。

我们现在将生成包分为app.js(我们的应用代码)和vendors.js(第三方库)。
我们使用webpack-bundle-analyzer插件来可视化两个生成包:
初始包

app.js大小116KB,vendors.js大小399KB

Webpack配置

app.js是我们程序的入口,所以自动打包成app.js。而第三方包vendors.js是使用了新的optimization配置,将从node_modules文件夹中引入的所有文件打包生成的。

mode: "production",
  entry: {
    app: path.join(__dirname, "index.tsx"),
  },
  output: {
    path: path.resolve(__dirname, "public/dist"),
    publicPath: "",
    chunkFilename: "[name].js",
    filename: "[name].js"
  },
  optimization: {
        runtimeChunk: {
            name: "manifest"
        },
        splitChunks: {
            cacheGroups: {
                vendor: {
                    test: /[\\/]node_modules[\\/]/,
                    name: "vendors",
                    priority: -20,
                    chunks: "all"
                }
            }
        }
   }

注意: 在Webpack4中,我们不再使用CommonChunkPlugin了,它被splitChunksruntimeChunk这两个新API所取代。

懒加载React组件

现在的vendors和app包都是用户在第一次打开页面室加载的。我们发现可以将一些“重量级”的组件懒加载来提升首屏体验,并且减少初始包的体积。

比如说:redux-form是一个管理react应用表单的库,它只在一个名为GenerateTags的大型组件中使用。由于它体积较大并且只在特定场景下被使用,所以用它来作为懒加载的实验对象是再好不过了。redux-form和GenerateTags组件可以被抽取到单独一个chunk中,这样我们在渲染首屏时请求的包体积更小。

让我们看看现在流行的动态导入工具库:react-loadable。它基础封装了未来JS的新语法import()

const GenerateTags = Loadable({
  loader: () =>
    import(/* webpackChunkName: "generateTags" */ "./GenerateTags"),
    loading: LoadingSpinner
});

使用之后,我们的包变成了下面这样:
抽取组件

GenerateTags已经被抽取到单独的一个chunk中,但redux-form仍然在vendor.js包里。

结果不尽如人意,因为redux-form仍然在vendors.js包中,但我们希望它跟GenerateTags都被抽取到一个不同的chunk中来实现按需加载。

之所以会出现这样的情况,是因为我们在别的文件中也引用了redux-form。比如说我们在combineReducers 中编写了下面的代码:

import { reducer as formReducer } from "redux-form";
const applicationReducer: Reducer<any> = combineReducers({
    user,
    sidenav,
    navigation,
    //...
    form: formReducer
});

这段代码顶部的静态导入语句导致redux-form库成了我们vendors包的一部分。也就是说,Webpack认为它已经被静态导入成我们的app入口依赖树的一部分,所以不能被懒加载。

为了解决这个问题,我们决定动态注入redux-form reducer。首先,我们移除了导入redux-form reducer的语句,并且加了下面的代码来实现动态注入redux reducer:

export function injectAsyncReducer(store, name, asyncReducer) {
  if (store.asyncReducers[name]) {
    return;
  }
  store.asyncReducers[name] = asyncReducer;
  store.replaceReducer(createReducer(store.asyncReducers));
}

export const configureStore = (initialState: AppState) => {
  const enhancer = compose(applyMiddleware(...getMiddleware()));
  const store: any = createStore(createReducer(), initialState, enhancer);
  store.asyncReducers = {};
  return store;
};


const createReducer = (asyncReducers = {}) => {
    return combineReducers({
        user,
        sidenav,
        navigation,
        //...
        ...asyncReducers
    });
};

最后,我们在GenerateTags组件的componentDidMount中调用injectAsyncReducer方法。

public componentDidMount() {
    const reduxFormReducer = require("redux-form").reducer;
    injectAsyncReducer(store, "form", reduxFormReducer);
  }

注意,不推荐从组件直接获取一个store的引用,因为这样会导致你在做服务端渲染时出现一些问题。
这里你可以阅读更多注入异步代码和使用HOC的知识。

TypeScript配置

我们在项目中使用了typescript。我们必须在tsconfig.json中更新esnext的module配置,以及设置removeCommentsfalse(要支持动态注入,TS的版本必须高于2.4)。这样,之前的动态注入才会起作用。通过“告诉”typescript编译器避开我们的import语句,并且不要对它们进行转码来让Webpack正常工作。

{
  "compilerOptions": {
    "target": "es5",
    "sourceMap": false,
    "inlineSourceMap": true,
    "module": "esnext",
    "moduleResolution": "node",
    "jsx": "react",
    "preserveConstEnums": true,
    "removeComments": false,
    "lib": ["es6", "dom"]
  },
  "types": ["node"]
}

最后的结果就像下面这样:

vendors.js 314 KiB, app.js 96.6 KiB, generateTags.js 23.2 KiB, vendors~generateTags.js 90.2 KiB

最后我们成功了,GenerateTags和它的依赖文件redux-form被提取出vendor.js并且能够被按需加载。

总结

我们推荐你阅读这个文章来优化Webpack。

  • 使用动态注入可以减少最终包的体积。还能疼痛感异步加载提供更快的首屏加载速度。
  • typescript从2.4版本开始支持动态注入,你只需要记住修改一部分配置就能使用这个功能。
  • 迁移到Webpack4并不不复杂,但是目前还没有关于新配置和API的介绍文档。但我相信很快它们都会有的。
  • 动态注入redux reducer是一个很有用的小技巧,它能够帮助我们的app在使用redux reducer时延迟加载一些库。
查看更多我翻译的Medium文章请访问:
项目地址:https://github.com/WhiteYin/translation
SF专栏:https://segmentfault.com/blog/yin-translation
查看原文

赞 24 收藏 47 评论 1

胡萝卜有点坑 赞了回答 · 2018-01-31

解决为什么import React from 'react',React首字母必须大写?

请题主采纳正确的答案,以免误导后来人:

  • 目前采纳的“小火车”的回答是完全错误的;
  • “jokester”的回答才是正确的。
import React from 'react';
const comp = () => (
    <div>something...</div>
);
//...

// 编译 JSX 后:

import React from 'react';
const comp = () => (
    React. createElement('div', null, 'something...' )
);
//...

这个问题跟首字母是不是大写没有关系,是必须使用React

关注 12 回答 10

胡萝卜有点坑 赞了文章 · 2018-01-26

转眼人到中年:前端老程序员无法忘怀的一次百度电话面试

等待,山雨欲来

2010年9月22日,中秋,记得那天下着零星的小雨。大部分同学都已回了家,深秋的校园显得格外空旷寂寥。平时车来人往的校道,也只剩三三两两的行人低着头走着。

匆匆忙忙吃完早餐,一个人背着书包来到了教学楼,找了僻静的角落坐下。看看手机,8:45左右,离电话面试还有大概15分钟。心里有些紧张,毕竟是大厂的面试,要求肯定很严格,不知道待会会问什么问题。内心突然有些懊恼,应该提前多做些功课的。

如期而至的电话

时间一分一秒地流逝,心跳越来越快。9点整,上海的电话如期打来。双方确认了身份后,连自我介绍都不用,直接就进入了技术面试环节。有点出乎意料,在我彼时的设想里,第一个环节应该是自我介绍才对。

没有太多的时间去诧异,电话那头,面试题一个接一个地抛了过来。我把耳机听筒紧紧地贴着耳朵,生怕听不清面试的题目,或者错过关键的信息。其时,脑子飞快地转着,想着如何回答面试官的题目,以及怎么更有条理地组织我的回答。

“JS是如何实现继承的?”

“知不知道什么是跨域?什么情况下会出现跨域?有哪些解决方案?”

“说说你对标签语义化的了解。”

。。。

脑子里一直嗡嗡响,也不知道过了多长时间,面试官突然安静了下来。顿了大约有10秒,那边说:“技术的问题也问得差不多了,就先到这里。你这边有没有什么问题想问的?”

我如释重负,赶紧喘了口气,然后问了几个我之前已经准备好的问题,包括面试部门的工作,员工培训机制,学习建议等,面试官也一一解答。

也许就要结束了

面试终于要结束了,一直紧绷着的神经开始松弛下来,人反而紧张了起来。毕竟,后面还有生死未卜的两周在等待着我,而未知总是让人感到恐惧。

接下来,就是我最想听到的那句收尾的话了。

面试官:“那么,面试就先到这里,今天是周末,一个多小时的面试,辛苦你啦。面试结果会在两周之内反馈到你这边。”

周末,还是中秋,仔细想来,也是为了迁就我的时间,面试官才不得不在这么特殊的时间到公司加班。而且面试过程中,面试官挺nice的,并不是预想中高高在上冷冰冰的态度。

面试官的“辛苦你啦”让我有点小内疚,赶紧回道:“挺不好意思的,因为我这边时间的原因,辛苦您周末过来公司加班。中秋节快乐。”

具体怎么说的记不清了,只记得当时态度很真诚,并不是因为客套。

面试官听到我的回答后,明显楞了一下,似乎有些意外。同样祝我节日快乐后,电话那头安静了一会,只有偶尔轻轻的键盘敲击声。

当幸福来敲门

感觉过了好长时间,其实也可能只有十来秒。听筒里再次传来面试官的声音,不知道是不是心理作用,感觉面试官的语调跟之前有些不同。

“这样,我提前把面试结果告诉你,你一面通过了。在你之前也面了好多人,到目前为止,你是回答得最好的。二面具体时间稍后通知。加油哦,好好准备下一轮面试。”

幸福来得太突然,感觉握着电话的手都在微微颤抖。

“非常感谢,我一定好好准备。”

直到现在,我还不知道为什么面试官突然决定提前告诉我面试结果。也许,陌生面试者的一句“中秋节快乐”,触动了在他乡拼搏的年轻游子的心。

一切无从求证,记忆也终将随时间淡去,在那个下着蒙蒙细雨的清晨。

技术面的问题

一个多小时的面试,问了很多问题,事后稍事整理记录了下来,主要是围绕JavaScript展开。

JS部分:

  1. JS是如何实现继承的?
  2. object的prototype是什么?(接上一个问题)
  3. JS如何实现数据以及功能的封装。(即类是如何实现的)
  4. 如果一个标签里面包含了10000个image,如何有效地对这10000个image实现事件绑定,比如说click事件。(考察事件冒泡机制)
  5. 假设现在有对象A、B,A对象绑定了S事件,如何对B对象也绑定S事件?(其实不清楚)
  6. 如何实现跨域请求?你知道的有多少种方法?各有什么优缺点?
  7. 当使用隐藏框架实现跨域请求时,如果框架页跟当前页不属于同个父域,是否可以实现跨域?
  8. 如何实现私有变量?说出一种方法即可。
  9. 函数闭包使用得多吗?什么情况下需要使用函数闭包?
  10. 当某个事件发生时,如果获得事件发生的对象。(ff和ie不同)
  11. 当绑定事件时,this指针指向的是?
  12. 当为document绑定事件时,this指针指向的是?
  13. 发送ajax请求有多少个步骤?如何判定发送成功?(readyState和onreadystatechange)
  14. 表示请求成功返回的状态码是多少?你还知道哪些状态码?分别表示什么意思?

jQuery部分:

  1. jQuery里如何绑定事件?有多少种方式?
  2. jQuery绑定事件时,this指针指向的是?(dom对象还是jQuery对象)
  3. 对于页面中暂时不存在的对象,如果进行事件绑定?
  4. 为什么选用jQuery框架(言下之意就是还有哪些其他的框架,各有什么优缺点,即你对比之后选择的原因)
  5. 有没有考虑过jQuery UI?如何对jQuery UI的样式进行定制?
  6. 有没有自己写作jQuery插件(即如何写jQuery插件)

html+CSS:

  1. 用html+CSS实现这样的布局效果,左栏固定宽度,右栏宽度自适应并填满剩下空间。
  2. 说说<strong>标签和<b>标签的区别,如果让你选择,你会选择使用哪个?
  3. 说说你对对html标签语义化的理解。

后记:关于二面

大约一周后,接到了二面的电话。面试的结果有点出乎意料,那种惊讶,夹杂着莫名其妙的情绪,至今还无法忘怀。

故事有点长,未完待续。

查看原文

赞 27 收藏 44 评论 17

认证与成就

  • 获得 34 次点赞
  • 获得 14 枚徽章 获得 0 枚金徽章, 获得 4 枚银徽章, 获得 10 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2014-02-20
个人主页被 833 人浏览