前后端分离的跨域介绍
(给前端大全加星标,提升前端技能)
http://chonger.org/2019/06/03/20190603231900.html
webpack这个工具,已经是各大主流框架、项目毕不可少的了,也确实大大方便、简化了开发人员的日常工作。在vue-cli
、angular-cli
、create-react-app
等等一些脚手架中也会常常遇到。
使用webpack来构建、打包前端项目,尤其是在SPA(single-page application)的场景,已经成为了主流,其中附带的webpack-dev-server
也是非常强大的功能。
但是在nodejs后端项目的构建、打包方面,我也看到了有的社区中的一些讨论,大多数持有的态度为是不需要的。其实从我个人的角度来看,我觉得是非常必要的,我认同大家所说的只是脚本项目、本来基于nodejs就都是支持的,没必要打包,我认为打包是必要的,主要指的是交付、部署方面。
如果只是站在前端的角度看待问题,webpack为项目提供了语法降级兼容、CSS预编译、JS合并压缩、公共代码抽离、图片转码等等,也确实在浏览器兼容性、网页优化等方面拥有非常强大、完善的插件。
在nodejs的后端项目上,确实是没有这么多事儿的。只有脚本,聚焦的也就是脚本语法转换、打包这些简单的功能,就类似于我们在其他语言开发完模块功能后,都是会打包为动态链接库一样,来进行发布、部署。
具体需不需要在nodejs的后端项目中使用webpack,仁者见仁智者见智,更多的根据实际场景来考虑考虑,也不能一味的追求。
刚好最近为公司产品做了个B/S的小工具,也将自己的一些思考、做法,尽量详细的整理出来,供大家以作参考。
0x00 从应用场景倒推设计
个人的看法,在开始一个小项目的时候,不但要关注功能与需求,而且还要考虑好最终的部署、应用场景,尤其是小工具之类的。技术实现业务功能的方案非常多,我们往往就是要挑出比较契合的方案。比如:在线使用还是本地使用、移动端还是PC端、需不需要跨平台等等。
就比如这次想要做的小工具,只是一个工具应用,但是在部署应用上,希望的是能做到低配置、低资源,不需要很重量级的,最好是一个绿色版程序包,随起即用,随删即卸。
最后暂定的部署、运行目录为:
/bin
/node_modules
/node
/app.js
/www
/package.json
/bin
基础的应用脚本,启停服务/node
nodejs运行环境,如果系统自带则可以移除/node_modules
应用依赖的模块/www
web静态资源目录,放置前端静态资源/app.js
主程序文件/package.json
nodejs项目描述文件
由于是B/S的方式,前端考虑使用Vue
,所以页面相关的事儿都交由浏览器完成,这样前端项目的交付物就是一堆静态的资源文件,这也完全符合当前基于vue-cli
开发的结果物。
后端的话徘徊于Go
和nodejs
,其实我更喜欢Go
,尤其是交叉编译,之前也有基于Go
做过简单的B/S工具,非常的舒服,以后找时间分享出来。这次,考虑到时间紧迫,就暂定了nodejs
。主要是会与mysql
、redis
、dubbo
等服务交互,相对熟悉一些。
这样的话,项目的整体结构为:
项目整体结构图
很普通的一个通用结构,在其他的语言框架中也都是通用的,有经验的开发人员,一眼就能明白。
基于这样的结构,为了方便理解,可以从一个简单的数据流程来看一下,可以帮助大家理解每个组件所处的位置以及负责的事儿。
数据流程以及各组件职责
在项目的结构上,虽然都是基于nodejs,又由于是前后端分离开发,为了项目的整洁且互不干涉,也可以由前后端人员分别独立开发,所以就简单的拆分为两个项目。考虑到需要统一的编译构建,所以再添加一个主项目,负责管理构建子项目,生成可以直接交付的部署包。
所以,从git的项目目录结构看,是这样的:
git项目目录结构图
基于vue
和nodejs
的项目开发,没有什么很大的难度,所以在业务功能开发方面就不去深入介绍。比如一个字典功能的代码片段:
Vuex
Controller
Service
0x01 webpack的多环境需求
本次主要关注点在webpack,所以,接下来主要讨论的一个就是,如何开发模式、生产模式的多环境运行与打包。
为什么要考虑多环境?可以先从这几方面思考下
项目在
开发环境
、测试环境
、生产环境
等,及多端中,相同的变量,需要定义为不同的属性值(非配置文件方式,直接替换编入代码中)。
比如有代码const name = ''
在开发模式
生成结果为:const name = '/api'
在生成模式
生成结果为:const name = ''
后端服务提供的接口格式为
/:module/:action
格式,module
名字随意定义。
从生产环境
考虑,后端是不需要CORS(跨域资源共享)
方式来处理跨域的。
从开发环境
考虑,由于是分离式的,如果直接请求后端接口,则需要解决跨域问题。
所以,开发环境
中需要在前端方来解决跨域,可以使用webpack来完成请求代理。这个时候如何使用统一的代理规则,而不是使用每个module
名称配置规则?后端服务在开发调试阶段,配置项使用开发环境,部署时需要使用生产环境的配置信息,且不想使用独立的配置文件,希望能压缩为一个可执行脚本。
其实,都不难,就跟maven
中resource
的filter
功能一样,在编译时对配置文件中的属性进行替换。
javascript
脚本语言也一样,并没有所谓的编译过程,都是纯文本的,只能看做是源代码
的预处理、合并、混淆、压缩,也正是在这些操作的过程中完成属性的替换。
Webpack
接下来就主要从示例来看下如何使用webpack
来实现多环境的配置与应用
0x02 前端项目中的跨域和多环境问题
在基于vue-cli
的开发模式中,已经很好的配置了webpack
来支持开发环境
和生产环境
。
分别执行npm run serve
和npm run build
,将会使用不同的混淆压缩方式。
我之所以想在前端来实现多环境的属性替换,是想做这么一个事儿。
在未分离之前开发模式是这样的,我们常常会基于一个web服务
来进行开发。比如使用jsp
、aspx
、php
等来开发页面,然后在同一个项目中开发接口服务。
最终的部署方式也都是一个包含了接口代码和页面静态资源的部署包,而我也想在这个小工具中使用这样的方式,我们可以在文章开头设想的运行目录可以看出,就是同时包含了接口服务和页面静态资源。
整体的运行模式可以这样来描述。
生成环境运行方式
一个独立的程序包,在本地3000
端口提供http
服务,包含静态资源文件模块
和业务接口模块
,这样是B/S结构的基础需求。
静态资源模块
也就是前端静态资源,这些资源主要是提供给浏览器来进行页面的渲染、展示,是提供给用户操作的界面。业务接口模块
就是俗称的后端逻辑,主要对浏览器端的一些请求做出响应处理。比如动态页面的渲染、数据接口的请求等等。
从上图中我们可以整理出这样的信息:
服务端
收到请求,获取相对路径/index.html
,优先查找静态资源(static目录中)是否有配合,找到后,使用文件接口读取静态文件内容,响应给浏览器
this.$ajax.get('/dict/all')
服务端
收到请求后,同样的先匹配静态资源,匹配不上后,再匹配是否有相应注册的路由,匹配成功后,则由对应的业务逻辑完成处理,返回响应。
上面的这些东西,对于大家应该都是老生常谈了,为什么还要不厌其烦的大费篇幅呢?一个主要是为了让还未能掌握的朋友能借鉴学习,另一个就是为了和后面的内容形成对比。
接着,咱们来看下,在前后端分离方式中,基于webpack-dev-server
的开发环境中,变成了什么样子了。先看图
开发环境运行方式
前后端分离,形如字义,被拆分为两个独立的服务,一个依旧是在3000
端口坚守的后端服务,另一个就是前端项目的服务了,图中配置的端口为8080
。
分别启动两个服务,后端服务与前面描述的方式一致,前端服务运行后,会先根据webpack
的配置,扫描加载前端的“源码”文件,合并打包为静态资源。同时会监控前端“源码”目录的文件变更,如果发现有文件被修改,则触发合并打包。
同样的,从图中整理信息如下:
前端服务
收到请求,获取相对路径/index.html
,会在启动时合并打包的静态资源中匹配,找到后,读取文件内容,响应给浏览器
this.$ajax.get('/dict/all')
前端服务
收到请求后,匹配不到静态资源,转而匹配路由,同样匹配不到,响应404
先暂停流程,来看一下前后端分离中最常遇到的这个404
问题,当前这种情况下,解决方式不少,先简单的看看
方案一:修改ajax请求,直接访问后端服务
//修改代码逻辑
this.$ajax.get('http://localhost:3000/dict/all')
//全局的后端服务接口地址
const API_URL = 'http://localhost:3000'
//修改代码逻辑
this.$ajax.get(API_URL + '/dict/all')
这样来编码,实现功能是ok的,但我一般不会建议采用这种方式,因为这种方式,还需要让前端开发人员在调用接口时,得不停想着进行拼接。所以,一般会改为这样,示意如下:
//使用代理模式来处理ajax请求,可以进行地址、参数转换和响应数据初步处理
this.$ajax = {
API_URL: 'http://localhost:3000',
//get请求
get: function(url) {
axios.get(this.API_URL + url)
}
}
//修改调用代码逻辑,最终回被转换为 http://localhost:3000/dict/all
//对于业务开发人员,仅需要关心接口文档中的地址的path部分,对于开发,屏蔽了一些不重要的信息干扰
this.$ajax.get('/dict/all')
方案二:使用代理,反向代理后端服务到当前访问域
如果我们如果想让上面的第4步不出现404
,那么就可以在webpack-dev-server
中配置代理规则
//将开发服务接收到的/dict开头请求,转发到http://localhost:3000服务上
devServer: {
proxy: {
'/dict': {
target: 'http://localhost:3000'
}
}
}
这样,我们就可以继续来看下处理流程
使用代理的方式,对于浏览器端是感知不到的,所有的请求都在当前访问域中,只是一些请求最终会被打到后端服务上。
这样的方式,关注点就在
代理规则
的配置上了,前端开发人员可就得盯着这里了,随着后端接口越来越多,比如/rule/search
、/user/search
、/order/:id
等等,就得配置多个代理规则,这样也是行的通的,只是,总感觉不是有点多余。解决N多个代理规则配置问题,第一个是可以让后端统一下接口规则,比如都写为
/api/rule/search
、/api/user/search
、/api/order/:id
等,这样前端就只需要配置一个统一的代理规则
//修改请求前缀
this.$ajax = {
API_URL: '/api',
//get请求
get: function(url) {
axios.get(this.API_URL + url)
}
}
//调用逻辑
this.$ajax.get('/dict/all')
//配置代理
proxy: {
'/api': {
target: 'http://localhost:3000'
}
}
http://localhost:3000/index.html
http://localhost:3000/js/index.js
http://localhost:3000/api/dict/all
http://localhost:3000/api/rule/search
所以在统一代理规则上,还可以这样做,后端同学还是使用默认的方式,定义接口为
/rule/search
、/user/search
、/order/:id
,修改前端的代理配置为
//修改请求前缀
this.$ajax = {
API_URL: '/api',
//get请求
get: function(url) {
axios.get(this.API_URL + url)
}
}
//调用逻辑
this.$ajax.get('/dict/all')
//配置代理
proxy: {
'/api': {
target: 'http://localhost:3000',
//重写路径,抹掉/api,
//e.g. http://localhost:8080/api/dict/all => http://localhost:3000/dict/all
pathRewrite: {
"^/api": ""
}
}
}
这样,前后端都可以按照自己舒服的方式进行开发了。
开发模式下,一切都会相安无事,一旦到了集成打包发布后,运行起来就会出问题了。因为实际运行中,并没有了代理服务,所以前端ajax的请求就会是http://localhost:3000/api/dict/all
。这个时候,就需要在发布打包的时候修改逻辑为
//修改请求前缀
this.$ajax = {
//开发模式
//API_URL: '/api',
//发布模式
API_URL: '',
//get请求
get: function(url) {
axios.get(this.API_URL + url)
}
}
这样,就需要前端同学打包的时候,来回的切换这里的代码,一个不留神,就会出错。这种重复性的替换工作,也实时的会让打包的时候强迫关注这里,更期望的方式是类似于mvn package -Pxx
这样的方式来实现自动切换。
也就是核心的主角DefinePlugin
,
DefinePlugin
允许创建一个在编译时可以配置的全局常量。这可能会对开发模式和生产模式的构建允许不同的行为非常有用。如果在开发构建中,而不在发布构建中执行日志记录,则可以使用全局常量来决定是否记录日志。这就是DefinePlugin
的用处,设置它,就可以忘记开发环境和生产环境构建的规则。
Link: https://webpack.docschina.org/plugins/define-plugin/
在vue-cli
中的vue-cli-service
,已经对DefinePlugin
进行了封装,可以通过mode
和.env[.mode]
配合来实现。具体的资料可以参考官方文档:在客户端侧代码中使用环境变量
前端项目中,添加环境变量配置
VUE_APP_customPrefix=/api
VUE_APP_customPrefix=
同时修改项目中使用的地方,示意如下
//修改请求前缀
this.$ajax = {
API_URL: process.env.VUE_APP_customPrefix,
//get请求
get: function(url) {
axios.get(this.API_URL + url)
}
}
这样,就可以彻底放手不用再去关注不同环境需要变更的地方了,只需要关注业务,完成开发,然后执行npm run serve
或者npm run build
就ok了。
0x03 后端项目中的多环境问题
后端项目考虑多环境问题,不像前端那样必须得替换,因为前端毕竟只是纯纯的静态资源。而后端常用的做法就是添加启动参数
或者使用配置文件
添加启动参数
设置些简单的数据类型还ok,复杂或量大的情况下,一般都是使用配置文件
,然后在启动参数中指定使用的配置文件。类似于java -jar spring-boot.jar --spring.profiles.active=prod
,然后就会加载application-prod.yml
配置文件。这种方式在内部使用是没有问题的,如果是作为小工具类的,提供的运行文件中,往往就会暴露其他环境的配置信息。使用配置文件
的另一种方式就是资源文件替换,在项目中会按照不同的环境名称设立文件夹,然后分别放不同的环境配置文件。打包的时候通过resource
指定配置目录。这种方式也是ok的,只是在小工具的范畴内,更希望的就类似一个.exe
文件,直接双击使用,也不用附带什么配置文件之类的。
在nodejs后端项目中,添加webpack
的相关依赖
"devDependencies": {
"clean-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "^5.0.3",
"webpack": "^4.32.2",
"webpack-cli": "^3.3.2",
"webpack-merge": "^4.2.1"
}
创建一个build
目录,来放置跟构建相关的配置,由于是要考虑多环境,所以按照webpack.config.js
、webpack.dev.js
、webpack.test.js
等,来拆分配置。
webpack.config.js
存放公共的配置信息webpack.dev.js
、webpack.test.js
分别存放不同环境的配置信息
公共的webpack
配置信息如下
const path = require("path")
const CopyPlugin = require('copy-webpack-plugin')
module.exports = {
entry: {
app: './src/main.js'
},
target: 'node',
output: {
filename: "[name].js",
//输出到主项目目录
path: path.resolve(process.cwd(), "../dist")
},
//排除项目依赖,不进行打包,由运行时安装提供
externals: externals(),
plugins: [
//附带项目描述文件,主要进行依赖安装
new CopyPlugin(["./package.json"])
]
}
function externals() {
let manifest = require('../package.json');
let dependencies = manifest.dependencies;
let externals = {};
for (let p in dependencies) {
externals[p] = 'commonjs ' + p;
}
return externals;
}
由于在不同环境中需要使用不同的数据库配置,可以在build
目录中创建profile
文件夹,存放不同环境的配置信息
开发环境配置:
module.exports = {
database: {
user: {
host: "127.0.0.1",
port: "8066",
}
}
}
测试环境配置:
module.exports = {
database: {
user: {
host: "192.168.1.100",
port: "3306",
}
}
}
接着将在不同的环境中使用DefinePlugin
来替换掉值,编写不同环境的打包配置
开发环境配置:
const webpack = require('webpack')
const merge = require("webpack-merge")
const config = require("./webpack.config.js")
const profile = require("./profile/profile.dev.js")
//参数值转换为字符串
Object.keys(profile).map(key => {
profile[key] = JSON.stringify(profile[key])
})
//合并基础配置
module.exports = merge(config, {
mode: 'development',
devtool: 'eval',
plugins: [
//添加替换插件
new webpack.DefinePlugin(profile),
]
})
测试环境配置:
const webpack = require('webpack')
const merge = require("webpack-merge")
const TerserPlugin = require('terser-webpack-plugin')
const config = require("./webpack.config.js")
const profile = require("./profile/profile.test.js")
//参数值转换为字符串
Object.keys(profile).map(key => {
profile[key] = JSON.stringify(profile[key])
})
//合并基础配置
module.exports = merge(config, {
mode: 'production',
optimization: {
//配置压缩参数
minimizer: [
new TerserPlugin({
terserOptions: {
ecma: 5
}
})
]
},
plugins: [
//添加替换插件
new webpack.DefinePlugin(profile),
]
})
完成多环境的配置后,就可以直接在代码中使用应用变量标记
const databaseConfig = database
这样,代码在合并压缩后,就会将不同profile
中定义的值,替换进去,打包后的结果如图
后端项目合并压缩结果
以为到这里就功成身退了么?其实,有个深坑在那里等着。
前面的一波操作,可以看到主要关注的是发布时候的打包模式。回过头,来看看开发模式。按照后端项目的方式,启动项目
> node ./src/main.js
会发现如下提示:
//文件名省略
~XXXXXXX.js:29
const databaseConfig = database
^
ReferenceError: database is not defined
开发模式已经不能直接运行的了,因为这个database
变量只有在webpack
打包后才会有具体的值,直接启动会提示未定义。
总不能每次代码修改都要执行webpack --config ./build/webpack.dev.js
打出测试环境的包,然后node ./app.js
来启动吧,虽然目的是一致的,但是这种操作,总是有点让人感觉到抓狂,就不能像前端项目那样,直接源文件变更自动编译、还能替换变量适应多环境么?答案是: 还真的可以,只是需要实现一个简易版的类webpack-dev-server
经过多环境的修改,由于源代码中放入了替换标记,导致源代码不能直接执行,必须通过webpack
的打包才能获得完整的执行文件。就是上面说的手动执行的过程,而要实现的简易版开发服务,也正是帮助自动来完成这两步。
先来讨论下思路,回过头来看前端项目的开发模式,启动开发服务后,就可以直接访问打包后的文件,那么,这些文件在哪里?webpack把打包后的文件放哪里了?答案是:内存文件系统
Memory-fs
一个简易的内存文件系统,提供了标准的文件操作接口,访问项目主页。
而webpack
也提供了inputFileSystem
和outputFileSystem
接口来可以扩展不同的文件系统。
先完成第一步,启动webpack打包,并将结果写入内存文件系统(不落地)
在项目中添加memory-fs
依赖
"devDependencies": {
……
"memory-fs": "^0.4.1",
……
}
接着,参看webpack-dev-server
的方式来简单实现一个开发服务。
前端简易版开发服务
在build
目录中创建一个start.js
来作为开发服务的启动文件
const webpack = require("webpack")
const MemoryFS = require('memory-fs')
//使用开发模式的webpack配置
const config = require('./webpack.dev')
//创建文件服务
const fs = new MemoryFS()
//创建webpack打包器
const compiler = webpack(config);
//更改webpack打包器的输出文件系统为内存模式
compiler.outputFileSystem = fs
//开始执行打包
compiler.run((err, stats) => {
//输出日志
console.log(stats.toString({
chunks: false,
colors: true
}))
//第二步
});
打包结果
接下来就是第二步,执行打包后的脚本文件
执行脚本就简单了,可以从memory-fs
中读出文件内容后来进行执行。
一种执行方式,使用熟悉的
eval()
函数来执行代码字符串。另一种就是使用nodejs的
vm
模块来执行。
2.1 读取编译后的脚本文件
由于后端项目打包成了一个app.js
,所以直接从webpack
的stats
中获取打包后的文件路径,然后读取出来
//开始执行打包
compiler.run((err, stats) => {
//输出日志
console.log(stats.toString({
chunks: false,
colors: true
}))
//获取打包文件输出路径
const file = stats.compilation.outputOptions.path + '/' + stats.compilation.chunks[0].files[0]
//读出文件内容
const content = fs.readFileSync(file)
});
2.2 执行脚本字符串
使用
eval
执行
eval(content.toString())
使用
vm
执行
const vm = require('vm')
const NativeModule = require('module')
//将代码字符串包装为nodejs模块,function (exports, require, module, __filename, __dirname) {}
const wrapper = NativeModule.wrap(content)
//执行函数
const result = new vm.Script(wrapper).runInThisContext()
const m = { exports: {} }
result.call(m, m.exports, require, m)
相关参考:http://nodejs.cn/api/vm.html
好了,这次就先聊这些,非常感谢大家耐心看完。
刚开始从期望的一个小工具使用形态
来完成一个简易版的系统设计
,并简单的阐述了系统各模块功能以及交互流程
,然后到项目的组织搭建
。
在开发中又简单的聊了下前后端分离的跨域问题
,以及前端在多环境中的打包
。
最后,又介绍了在nodejs后端项目使用webpack打包
、nodejs后端项目的多环境问题
以及使用webpack完成nodejs后端项目的开发模式服务
。
觉得本文对你有帮助?请分享给更多人
关注「前端大全」加星标,提升前端技能
好文章,我在看❤️